diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml new file mode 100644 index 0000000..808be56 --- /dev/null +++ b/.mvn/extensions.xml @@ -0,0 +1,9 @@ + + + + io.takari.polyglot + polyglot-yaml + 0.3.2 + + + diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..0ff3e10 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,3 @@ +language: java +jdk: openjdk11 +script: mvn clean verify \ No newline at end of file diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..c30ee04 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: java $JAVA_OPTS -p ./target/parallel-java-1.0.jar:./target/dependency -m nl.jqno.paralleljava/nl.jqno.paralleljava.Main diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a6b9de --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Parallel Java + +[![Build Status](https://img.shields.io/travis/jqno/paralleljava.svg?style=plastic)](https://travis-ci.org/jqno/paralleljava) + +This app is written in Java from a Parallel Universe where annotations were never invented. You can check: there isn't a single annotation in this codebase! + +It requires a JDK 11 to build and run. + +It's a showcase for my talk, Java from a Parallel Universe. It's also a fully functioning [Todo Backend](https://www.todobackend.com/) (you can [run the Todo Backend test suite](https://www.todobackend.com/specs/index.html?https://parallel-java.herokuapp.com/todo +)!), using the [Spark web framework](http://sparkjava.com/), the [Jdbi database framework](http://jdbi.org/), and **no framework** for dependency injection because you really really don't need one. It also uses Java 11 `var` declarations, [Vavr](http://www.vavr.io/) and [Polyglot for Maven](https://github.com/takari/polyglot-maven) because I think they're pretty nifty and because they make the code look a little different, as if, I dunno, as if it came from a Parallel Universe or something? Also, the application is fully modularized and has 100% test coverage because why not. + +Note, however, that this is still a demo app that is not production-ready. Some corners have definitely been cut. For example, the [InMemoryRepository](https://github.com/jqno/paralleljava/blob/master/src/main/java/nl/jqno/paralleljava/app/persistence/inmemory/InMemoryRepository.java) is not thread-safe. + diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 0000000..d9c3a49 --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pom.yml b/pom.yml new file mode 100644 index 0000000..e5c7103 --- /dev/null +++ b/pom.yml @@ -0,0 +1,112 @@ +modelVersion: 4.0.0 +groupId: nl.jqno.paralleljava +artifactId: parallel-java +version: 1.0 +packaging: jar + +name: parallel-java +description: "Todo-Backend built with Java from a Parallel Universe: a demo project to illustrate the point of my talk 'Java from a Parallel Universe'" + +properties: { + encoding: utf-8, + maven.compiler.source: 11, + maven.compiler.target: 11, + coverage.threshold: 1.0 +} + +repositories: + - { id: bintray-jqno-picotest-repo, url: "https://dl.bintray.com/jqno/picotest-repo" } + +dependencyManagement: + dependencies: + - { groupId: org.jdbi, artifactId: jdbi3-bom, version: 3.6.0, type: pom, scope: import } + +dependencies: + - { groupId: io.vavr, artifactId: vavr, version: 0.10.0 } + - { groupId: io.vavr, artifactId: vavr-gson, version: 0.10.0 } + - { groupId: com.sparkjava, artifactId: spark-core, version: 2.7.2 } + - { groupId: org.slf4j, artifactId: slf4j-api, version: 1.7.26 } + - { groupId: org.slf4j, artifactId: slf4j-simple, version: 1.7.26 } + - { groupId: com.google.code.gson, artifactId: gson, version: 2.8.5 } + - { groupId: org.jdbi, artifactId: jdbi3-core } + - { groupId: org.jdbi, artifactId: jdbi3-vavr } + - { groupId: org.postgresql, artifactId: postgresql, version: 42.2.5 } + - { groupId: com.h2database, artifactId: h2, version: 1.4.199 } + + - { groupId: nl.jqno.picotest, artifactId: picotest, version: 0.3, scope: test } + - { groupId: nl.jqno.equalsverifier, artifactId: equalsverifier, version: 3.1.7, scope: test } + - { groupId: org.assertj, artifactId: assertj-core, version: 3.11.1, scope: test } + - { groupId: org.assertj, artifactId: assertj-vavr, version: 0.1.0, scope: test } + - { groupId: com.tngtech.archunit, artifactId: archunit, version: 0.9.3, scope: test } + + # REST-Assured is useful but problematic on Java 11. We need to overrule Groovy and exclude JAXB-OSGI. + - { groupId: org.codehaus.groovy, artifactId: groovy, version: 2.5.6, scope: test } + - { groupId: org.codehaus.groovy, artifactId: groovy-xml, version: 2.5.6, scope: test } + - groupId: io.rest-assured + artifactId: rest-assured + version: 3.3.0 + scope: test + exclusions: + - groupId: com.sun.xml.bind + artifactId: jaxb-osgi + +build: + plugins: + - groupId: org.apache.maven.plugins + artifactId: maven-compiler-plugin + version: 3.8.0 + + - groupId: org.apache.maven.plugins + artifactId: maven-surefire-plugin + version: 2.22.1 + configuration: + argLine: "@{argLine} --add-opens nl.jqno.paralleljava/nl.jqno.paralleljava.app.domain=ALL-UNNAMED" + + - groupId: org.apache.maven.plugins + artifactId: maven-dependency-plugin + version: 3.1.1 + configuration: + includeScope: runtime + executions: + - id: default + phase: package + goals: [copy-dependencies] + + - groupId: org.apache.maven.plugins + artifactId: maven-checkstyle-plugin + version: 3.0.0 + dependencies: + - { groupId: com.puppycrawl.tools, artifactId: checkstyle, version: 8.18 } + configuration: + configLocation: checkstyle.xml + includeTestSourceDirectory: true + encoding: UTF-8 + consoleOutput: true + excludes: "**/module-info.java" + executions: + - id: default + phase: verify + goals: [check] + configuration: + failsOnError: true + + - groupId: org.jacoco + artifactId: jacoco-maven-plugin + version: 0.8.3 + configuration: + excludes: + - "nl/jqno/paralleljava/Main.class" + executions: + - id: default-prepare-angent + goals: [prepare-agent] + - id: default-report + goals: [report] + - id: default-check + goals: [check] + configuration: + rules: + - element: BUNDLE + limits: + - counter: INSTRUCTION + value: COVEREDRATIO + minimum: ${coverage.threshold} diff --git a/scripts/deploy b/scripts/deploy new file mode 100755 index 0000000..a6cb42b --- /dev/null +++ b/scripts/deploy @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +git push -f heroku master diff --git a/scripts/jacoco b/scripts/jacoco new file mode 100755 index 0000000..ab43460 --- /dev/null +++ b/scripts/jacoco @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +mvn clean verify +mvn jacoco:report +open target/site/jacoco/index.html + diff --git a/scripts/logs b/scripts/logs new file mode 100755 index 0000000..b00a67a --- /dev/null +++ b/scripts/logs @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +heroku logs --tail --source app + diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java new file mode 100644 index 0000000..af30d35 --- /dev/null +++ b/src/main/java/module-info.java @@ -0,0 +1,14 @@ +module nl.jqno.paralleljava { + exports nl.jqno.paralleljava; + + opens nl.jqno.paralleljava.app.domain to gson; + + requires io.vavr; + requires io.vavr.gson; + requires jdbi3.core; + requires jdbi3.vavr; + requires java.sql; // required for gson + requires gson; + requires slf4j.api; + requires spark.core; +} \ No newline at end of file diff --git a/src/main/java/nl/jqno/paralleljava/Main.java b/src/main/java/nl/jqno/paralleljava/Main.java new file mode 100644 index 0000000..e973bb5 --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/Main.java @@ -0,0 +1,41 @@ +package nl.jqno.paralleljava; + +import io.vavr.collection.HashMap; +import nl.jqno.paralleljava.app.Runner; +import nl.jqno.paralleljava.app.controller.DefaultController; +import nl.jqno.paralleljava.app.environment.Environment; +import nl.jqno.paralleljava.app.environment.HerokuEnvironment; +import nl.jqno.paralleljava.app.logging.LoggerFactory; +import nl.jqno.paralleljava.app.logging.Slf4jLogger; +import nl.jqno.paralleljava.app.persistence.RandomIdGenerator; +import nl.jqno.paralleljava.app.persistence.database.DatabaseRepository; +import nl.jqno.paralleljava.app.persistence.database.JdbiEngine; +import nl.jqno.paralleljava.app.persistence.database.TodoMapper; +import nl.jqno.paralleljava.app.serialization.GsonSerializer; +import nl.jqno.paralleljava.app.server.SparkServer; + +public class Main { + public static void main(String... args) { + LoggerFactory loggerFactory = c -> new Slf4jLogger(org.slf4j.LoggerFactory.getLogger(c)); + + var processBuilder = new ProcessBuilder(); + var environmentMap = HashMap.ofAll(processBuilder.environment()); + var environment = new HerokuEnvironment(environmentMap); + + var fullUrl = environment.hostUrl().getOrElse(Environment.DEFAULT_URL) + Environment.ENDPOINT; + var port = environment.port().getOrElse(Environment.DEFAULT_PORT); + var jdbcUrl = environment.jdbcUrl().getOrElse(Environment.DEFAULT_JDBC_URL); + + var todoMapper = new TodoMapper(fullUrl); + var dbEngine = new JdbiEngine(jdbcUrl, todoMapper, loggerFactory); + var repository = new DatabaseRepository(dbEngine); + + var idGenerator = new RandomIdGenerator(); + var serializer = GsonSerializer.create(loggerFactory); + var controller = new DefaultController(fullUrl, repository, idGenerator, serializer, loggerFactory); + var server = new SparkServer(Environment.ENDPOINT, port, controller, loggerFactory); + + var runner = new Runner(repository, server, loggerFactory); + runner.startup(); + } +} diff --git a/src/main/java/nl/jqno/paralleljava/app/Runner.java b/src/main/java/nl/jqno/paralleljava/app/Runner.java new file mode 100644 index 0000000..46acae7 --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/Runner.java @@ -0,0 +1,36 @@ +package nl.jqno.paralleljava.app; + +import nl.jqno.paralleljava.app.logging.Logger; +import nl.jqno.paralleljava.app.logging.LoggerFactory; +import nl.jqno.paralleljava.app.persistence.Repository; +import nl.jqno.paralleljava.app.server.Server; + +public class Runner { + private final Repository repository; + private final Server server; + private final Logger logger; + + public Runner(Repository repository, Server server, LoggerFactory loggerFactory) { + this.repository = repository; + this.server = server; + this.logger = loggerFactory.create(getClass()); + } + + public void startup() { + repository.initialize() + .onSuccess(ignored -> { + printBanner(); + server.run(); + }); + } + + private void printBanner() { + logger.forProduction(" _ _ _____ _ _ __ _ _"); + logger.forProduction("| \\ | | ___ | ___| __ __ _ _ __ ___ _____ _____ _ __| | _| |\\ \\ \\ \\"); + logger.forProduction("| \\| |/ _ \\ | |_ | '__/ _` | '_ ` _ \\ / _ \\ \\ /\\ / / _ \\| '__| |/ / | \\ \\ \\ \\"); + logger.forProduction("| |\\ | (_) | | _|| | | (_| | | | | | | __/\\ V V / (_) | | | <|_| ) ) ) )"); + logger.forProduction("|_| \\_|\\___/ |_| |_| \\__,_|_| |_| |_|\\___| \\_/\\_/ \\___/|_| |_|\\_(_) / / / /"); + logger.forProduction("=======================================================================/_/_/_/"); + logger.forProduction(" :: Built with Plain Java! :: \uD83C\uDF89"); + } +} diff --git a/src/main/java/nl/jqno/paralleljava/app/controller/Controller.java b/src/main/java/nl/jqno/paralleljava/app/controller/Controller.java new file mode 100644 index 0000000..18cb989 --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/controller/Controller.java @@ -0,0 +1,12 @@ +package nl.jqno.paralleljava.app.controller; + +import io.vavr.control.Try; + +public interface Controller { + Try get(); + Try get(String id); + Try post(String json); + Try patch(String id, String json); + Try delete(); + Try delete(String id); +} diff --git a/src/main/java/nl/jqno/paralleljava/app/controller/DefaultController.java b/src/main/java/nl/jqno/paralleljava/app/controller/DefaultController.java new file mode 100644 index 0000000..4a2199b --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/controller/DefaultController.java @@ -0,0 +1,97 @@ +package nl.jqno.paralleljava.app.controller; + +import io.vavr.Function1; +import io.vavr.control.Try; +import nl.jqno.paralleljava.app.domain.Todo; +import nl.jqno.paralleljava.app.logging.Logger; +import nl.jqno.paralleljava.app.logging.LoggerFactory; +import nl.jqno.paralleljava.app.persistence.IdGenerator; +import nl.jqno.paralleljava.app.persistence.Repository; +import nl.jqno.paralleljava.app.serialization.Serializer; + +import java.util.UUID; + +public class DefaultController implements Controller { + private final String url; + private final Repository repository; + private final IdGenerator idGenerator; + private final Serializer serializer; + private final Logger logger; + + public DefaultController(String url, Repository repository, IdGenerator idGenerator, Serializer serializer, LoggerFactory loggerFactory) { + this.url = url; + this.repository = repository; + this.idGenerator = idGenerator; + this.serializer = serializer; + this.logger = loggerFactory.create(getClass()); + } + + public Try get() { + return repository.getAll() + .map(serializer::serializeTodos); + } + + public Try get(String id) { + var uuid = serializer.deserializeUuid(id); + if (uuid.isEmpty()) { + return Try.failure(new IllegalArgumentException("Invalid GET request: " + id)); + } + + return repository + .get(uuid.get()) + .flatMap(o -> o.map(serializer::serializeTodo).toTry(() -> new IllegalArgumentException("Cannot find " + id))); + } + + public Try post(String json) { + logger.forProduction("POSTed: " + json); + var partialTodo = serializer.deserializePartialTodo(json); + if (partialTodo.isEmpty() || partialTodo.get().title().isEmpty()) { + return Try.failure(new IllegalArgumentException("Invalid POST request: " + json)); + } + + var pt = partialTodo.get(); + var id = idGenerator.generateId(); + var todo = new Todo(id, pt.title().get(), buildUrlFor(id), false, pt.order().getOrElse(0)); + return repository.create(todo) + .map(ignored -> serializer.serializeTodo(todo)); + } + + public Try patch(String id, String json) { + logger.forProduction("PATCHed: " + json); + var uuid = serializer.deserializeUuid(id); + var partialTodo = serializer.deserializePartialTodo(json); + if (uuid.isEmpty() || partialTodo.isEmpty()) { + return Try.failure(new IllegalArgumentException("Invalid PATCH request: " + id + ", " + json)); + } + + var pt = partialTodo.get(); + Function1 updater = todo -> new Todo( + todo.id(), + pt.title().getOrElse(todo.title()), + todo.url(), + pt.completed().getOrElse(todo.completed()), + pt.order().getOrElse(todo.order()) + ); + return repository.update(uuid.get(), updater) + .map(serializer::serializeTodo); + } + + public Try delete() { + return repository.deleteAll() + .map(ignored -> ""); + } + + public Try delete(String id) { + var uuid = serializer.deserializeUuid(id); + if (uuid.isEmpty()) { + return Try.failure(new IllegalArgumentException("Invalid DELETE request: " + id)); + } + + return repository.delete(uuid.get()) + .map(ignored -> ""); + } + + private String buildUrlFor(UUID id) { + return url + "/" + id.toString(); + } +} diff --git a/src/main/java/nl/jqno/paralleljava/app/domain/PartialTodo.java b/src/main/java/nl/jqno/paralleljava/app/domain/PartialTodo.java new file mode 100644 index 0000000..44079b4 --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/domain/PartialTodo.java @@ -0,0 +1,70 @@ +package nl.jqno.paralleljava.app.domain; + +import io.vavr.control.Option; + +import java.util.Objects; +import java.util.UUID; + +public final class PartialTodo { + private final UUID id; + private final String title; + private final String url; + private final Boolean completed; + private final Integer order; + + public PartialTodo() { + this.id = null; + this.title = null; + this.url = null; + this.completed = null; + this.order = null; + } + + public PartialTodo(Option id, Option title, Option url, Option completed, Option order) { + this.id = id.getOrNull(); + this.title = title.getOrNull(); + this.url = url.getOrNull(); + this.completed = completed.getOrNull(); + this.order = order.getOrNull(); + } + + public Option id() { + return Option.of(id); + } + + public Option title() { + return Option.of(title); + } + + public Option url() { + return Option.of(url); + } + + public Option completed() { + return Option.of(completed); + } + + public Option order() { + return Option.of(order); + } + + public boolean equals(Object obj) { + if (!(obj instanceof PartialTodo)) { + return false; + } + PartialTodo other = (PartialTodo)obj; + return Objects.equals(id, other.id) && + Objects.equals(title, other.title) && + Objects.equals(url, other.url) && + Objects.equals(completed, other.completed) && + Objects.equals(order, other.order); + } + + public int hashCode() { + return Objects.hash(id, title, url, completed, order); + } + + public String toString() { + return "PartialTodo: [id=" + id() + ", title=" + title() + ", url=" + url() + ", completed=" + completed() + ", order=" + order() + "]"; + } +} diff --git a/src/main/java/nl/jqno/paralleljava/app/domain/Todo.java b/src/main/java/nl/jqno/paralleljava/app/domain/Todo.java new file mode 100644 index 0000000..9701da0 --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/domain/Todo.java @@ -0,0 +1,76 @@ +package nl.jqno.paralleljava.app.domain; + +import java.util.Objects; +import java.util.UUID; + +public final class Todo { + private final UUID id; + private final String title; + private final String url; + private final boolean completed; + private final int order; + + public Todo(UUID id, String title, String url, boolean completed, int order) { + this.id = id; + this.title = title; + this.url = url; + this.completed = completed; + this.order = order; + } + + public UUID id() { + return id; + } + + public String title() { + return title; + } + + public String url() { + return url; + } + + public boolean completed() { + return completed; + } + + public int order() { + return order; + } + + public Todo withId(UUID id) { + return new Todo(id, title(), url(), completed(), order()); + } + + public Todo withTitle(String title) { + return new Todo(id(), title, url(), completed(), order()); + } + + public Todo withCompleted(boolean completed) { + return new Todo(id(), title(), url(), completed, order()); + } + + public Todo withOrder(int order) { + return new Todo(id(), title(), url(), completed(), order); + } + + public boolean equals(Object obj) { + if (!(obj instanceof Todo)) { + return false; + } + Todo other = (Todo)obj; + return Objects.equals(id, other.id) && + Objects.equals(title, other.title) && + Objects.equals(url, other.url) && + completed == other.completed && + order == other.order; + } + + public int hashCode() { + return Objects.hash(id, title, url, completed, order); + } + + public String toString() { + return "Todo: [id=" + id + ", title=" + title + ", url=" + url + ", completed=" + completed + ", order=" + order + "]"; + } +} diff --git a/src/main/java/nl/jqno/paralleljava/app/environment/Environment.java b/src/main/java/nl/jqno/paralleljava/app/environment/Environment.java new file mode 100644 index 0000000..b9105ae --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/environment/Environment.java @@ -0,0 +1,16 @@ +package nl.jqno.paralleljava.app.environment; + +import io.vavr.control.Option; + +public interface Environment { + + int DEFAULT_PORT = 4567; + String DEFAULT_URL = "http://localhost"; + String DEFAULT_JDBC_URL = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"; + + String ENDPOINT = "/todo"; + + Option port(); + Option hostUrl(); + Option jdbcUrl(); +} diff --git a/src/main/java/nl/jqno/paralleljava/app/environment/HerokuEnvironment.java b/src/main/java/nl/jqno/paralleljava/app/environment/HerokuEnvironment.java new file mode 100644 index 0000000..12dadbc --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/environment/HerokuEnvironment.java @@ -0,0 +1,31 @@ +package nl.jqno.paralleljava.app.environment; + +import io.vavr.collection.Map; +import io.vavr.control.Option; +import io.vavr.control.Try; + +public class HerokuEnvironment implements Environment { + + private final Map env; + + public HerokuEnvironment(Map env) { + this.env = env; + } + + public Option port() { + return env.get("PORT").flatMap(this::parse); + } + + public Option hostUrl() { + // hard-coded for now + return Option.some("https://parallel-java.herokuapp.com"); + } + + public Option jdbcUrl() { + return env.get("JDBC_DATABASE_URL"); + } + + private Option parse(String port) { + return Try.of(() -> Integer.parseInt(port)).toOption(); + } +} diff --git a/src/main/java/nl/jqno/paralleljava/app/logging/Logger.java b/src/main/java/nl/jqno/paralleljava/app/logging/Logger.java new file mode 100644 index 0000000..5fb859c --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/logging/Logger.java @@ -0,0 +1,10 @@ +package nl.jqno.paralleljava.app.logging; + +public interface Logger { + void forDevelopment(String message); + void forProduction(String message); + void firstThingNextMorning(String message); + void firstThingNextMorning(String message, Throwable t); + void wakeMeUp(String message); + void wakeMeUp(String message, Throwable t); +} diff --git a/src/main/java/nl/jqno/paralleljava/app/logging/LoggerFactory.java b/src/main/java/nl/jqno/paralleljava/app/logging/LoggerFactory.java new file mode 100644 index 0000000..4a6a624 --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/logging/LoggerFactory.java @@ -0,0 +1,9 @@ +package nl.jqno.paralleljava.app.logging; + +import io.vavr.Function1; + +public interface LoggerFactory extends Function1, Logger> { + default Logger create(Class c) { + return apply(c); + } +} diff --git a/src/main/java/nl/jqno/paralleljava/app/logging/Slf4jLogger.java b/src/main/java/nl/jqno/paralleljava/app/logging/Slf4jLogger.java new file mode 100644 index 0000000..8c176bd --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/logging/Slf4jLogger.java @@ -0,0 +1,33 @@ +package nl.jqno.paralleljava.app.logging; + +public class Slf4jLogger implements Logger { + private final org.slf4j.Logger logger; + + public Slf4jLogger(org.slf4j.Logger logger) { + this.logger = logger; + } + + public void forDevelopment(String message) { + logger.debug(message); + } + + public void forProduction(String message) { + logger.info(message); + } + + public void firstThingNextMorning(String message) { + logger.warn(message); + } + + public void firstThingNextMorning(String message, Throwable t) { + logger.warn(message, t); + } + + public void wakeMeUp(String message) { + logger.error(message); + } + + public void wakeMeUp(String message, Throwable t) { + logger.error(message, t); + } +} diff --git a/src/main/java/nl/jqno/paralleljava/app/persistence/IdGenerator.java b/src/main/java/nl/jqno/paralleljava/app/persistence/IdGenerator.java new file mode 100644 index 0000000..db32641 --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/persistence/IdGenerator.java @@ -0,0 +1,7 @@ +package nl.jqno.paralleljava.app.persistence; + +import java.util.UUID; + +public interface IdGenerator { + UUID generateId(); +} diff --git a/src/main/java/nl/jqno/paralleljava/app/persistence/RandomIdGenerator.java b/src/main/java/nl/jqno/paralleljava/app/persistence/RandomIdGenerator.java new file mode 100644 index 0000000..36be1c2 --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/persistence/RandomIdGenerator.java @@ -0,0 +1,9 @@ +package nl.jqno.paralleljava.app.persistence; + +import java.util.UUID; + +public class RandomIdGenerator implements IdGenerator { + public UUID generateId() { + return UUID.randomUUID(); + } +} diff --git a/src/main/java/nl/jqno/paralleljava/app/persistence/Repository.java b/src/main/java/nl/jqno/paralleljava/app/persistence/Repository.java new file mode 100644 index 0000000..3ba16ce --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/persistence/Repository.java @@ -0,0 +1,20 @@ +package nl.jqno.paralleljava.app.persistence; + +import io.vavr.Function1; +import io.vavr.collection.List; +import io.vavr.control.Option; +import io.vavr.control.Try; +import nl.jqno.paralleljava.app.domain.Todo; + +import java.util.UUID; + +public interface Repository { + Try initialize(); + Try create(Todo todo); + Try> get(UUID id); + Try> getAll(); + Try update(Todo todo); + Try update(UUID id, Function1 f); + Try delete(UUID id); + Try deleteAll(); +} diff --git a/src/main/java/nl/jqno/paralleljava/app/persistence/database/DatabaseRepository.java b/src/main/java/nl/jqno/paralleljava/app/persistence/database/DatabaseRepository.java new file mode 100644 index 0000000..239a0c6 --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/persistence/database/DatabaseRepository.java @@ -0,0 +1,98 @@ +package nl.jqno.paralleljava.app.persistence.database; + +import io.vavr.Function1; +import io.vavr.collection.List; +import io.vavr.control.Option; +import io.vavr.control.Try; +import nl.jqno.paralleljava.app.domain.Todo; +import nl.jqno.paralleljava.app.persistence.Repository; +import org.jdbi.v3.core.Handle; + +import java.util.UUID; + +public class DatabaseRepository implements Repository { + + private Engine engine; + + public DatabaseRepository(Engine engine) { + this.engine = engine; + } + + public Try initialize() { + var sql = "CREATE TABLE todo (id VARCHAR(36) PRIMARY KEY, title VARCHAR, completed BOOLEAN, index INTEGER)"; + return engine.execute(handle -> handle.execute(sql)) + .recoverWith(f -> { + if (f.getMessage() != null && f.getMessage().toLowerCase().contains("\"todo\" already exists")) { + return Try.success(null); + } else { + return Try.failure(f); + } + }); + } + + public Try create(Todo todo) { + return engine.execute(handle -> + handle.createUpdate("INSERT INTO todo (id, title, completed, index) VALUES (:id, :title, :completed, :order)") + .bind("id", todo.id().toString()) + .bind("title", todo.title()) + .bind("completed", todo.completed()) + .bind("order", todo.order()) + .execute()); + } + + public Try> get(UUID id) { + return engine.query(handle -> handleGet(handle, id)); + } + + public Try> getAll() { + return engine.query(handle -> + handle.createQuery("SELECT id, title, completed, index FROM todo") + .mapTo(Todo.class) + .collect(List.collector())); + } + + public Try update(Todo todo) { + return engine.execute(handle -> handleUpdate(handle, todo)); + } + + public Try update(UUID id, Function1 f) { + return engine.query(handle -> { + var option = handleGet(handle, id); + if (option.isEmpty()) { + throw new IllegalArgumentException("Can't find Todo with id " + id); + } + var oldTodo = option.get(); + var newTodo = f.apply(oldTodo).withId(oldTodo.id()); + handleUpdate(handle, newTodo); + return newTodo; + }); + } + + public Try delete(UUID id) { + return engine.execute(handle -> + handle.createUpdate("DELETE FROM todo WHERE id = :id") + .bind("id", id.toString()) + .execute()); + } + + public Try deleteAll() { + return engine.execute(handle -> handle.execute("DELETE FROM todo")); + } + + private Option handleGet(Handle handle, UUID id) { + var o = handle.createQuery("SELECT id, title, completed, index FROM todo WHERE id = :id") + .bind("id", id.toString()) + .mapTo(Todo.class) + .findFirst(); + return Option.ofOptional(o); + } + + private int handleUpdate(Handle handle, Todo todo) { + return handle.createUpdate("UPDATE todo SET title = :title, completed = :completed, index = :order WHERE id = :id") + .bind("title", todo.title()) + .bind("completed", todo.completed()) + .bind("order", todo.order()) + .bind("id", todo.id().toString()) + .execute(); + } +} diff --git a/src/main/java/nl/jqno/paralleljava/app/persistence/database/Engine.java b/src/main/java/nl/jqno/paralleljava/app/persistence/database/Engine.java new file mode 100644 index 0000000..e10950e --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/persistence/database/Engine.java @@ -0,0 +1,10 @@ +package nl.jqno.paralleljava.app.persistence.database; + +import io.vavr.control.Try; +import org.jdbi.v3.core.HandleCallback; +import org.jdbi.v3.core.HandleConsumer; + +public interface Engine { + Try execute(HandleConsumer consumer); + Try query(HandleCallback callback); +} diff --git a/src/main/java/nl/jqno/paralleljava/app/persistence/database/JdbiEngine.java b/src/main/java/nl/jqno/paralleljava/app/persistence/database/JdbiEngine.java new file mode 100644 index 0000000..f0039b5 --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/persistence/database/JdbiEngine.java @@ -0,0 +1,32 @@ +package nl.jqno.paralleljava.app.persistence.database; + +import io.vavr.control.Try; +import nl.jqno.paralleljava.app.logging.Logger; +import nl.jqno.paralleljava.app.logging.LoggerFactory; +import org.jdbi.v3.core.HandleCallback; +import org.jdbi.v3.core.HandleConsumer; +import org.jdbi.v3.core.Jdbi; + +public class JdbiEngine implements Engine { + private final Jdbi jdbi; + private final Logger logger; + + public JdbiEngine(String jdbcUrl, TodoMapper todoMapper, LoggerFactory loggerFactory) { + this.jdbi = Jdbi + .create(jdbcUrl) + .registerRowMapper(todoMapper); + this.logger = loggerFactory.create(getClass()); + } + + public Try execute(HandleConsumer consumer) { + return Try.of(() -> { + jdbi.useHandle(h -> h.useTransaction(consumer)); + return null; + }).onFailure(f -> logger.wakeMeUp("Failed to execute statement", f)); + } + + public Try query(HandleCallback callback) { + return Try.of(() -> jdbi.withHandle(h -> h.inTransaction(callback))) + .onFailure(f -> logger.wakeMeUp("Failed to execute query", f)); + } +} diff --git a/src/main/java/nl/jqno/paralleljava/app/persistence/database/TodoMapper.java b/src/main/java/nl/jqno/paralleljava/app/persistence/database/TodoMapper.java new file mode 100644 index 0000000..b49461b --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/persistence/database/TodoMapper.java @@ -0,0 +1,26 @@ +package nl.jqno.paralleljava.app.persistence.database; + +import nl.jqno.paralleljava.app.domain.Todo; +import org.jdbi.v3.core.mapper.RowMapper; +import org.jdbi.v3.core.statement.StatementContext; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.UUID; + +public class TodoMapper implements RowMapper { + private String urlPrefix; + + public TodoMapper(String urlPrefix) { + this.urlPrefix = urlPrefix; + } + + public Todo map(ResultSet rs, StatementContext ctx) throws SQLException { + return new Todo( + UUID.fromString(rs.getString("id")), + rs.getString("title"), + urlPrefix + "/" + rs.getString("id"), + rs.getBoolean("completed"), + rs.getInt("index")); + } +} diff --git a/src/main/java/nl/jqno/paralleljava/app/persistence/inmemory/InMemoryRepository.java b/src/main/java/nl/jqno/paralleljava/app/persistence/inmemory/InMemoryRepository.java new file mode 100644 index 0000000..9fe8fbb --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/persistence/inmemory/InMemoryRepository.java @@ -0,0 +1,82 @@ +package nl.jqno.paralleljava.app.persistence.inmemory; + +import io.vavr.Function1; +import io.vavr.collection.List; +import io.vavr.control.Option; +import io.vavr.control.Try; +import nl.jqno.paralleljava.app.domain.Todo; +import nl.jqno.paralleljava.app.logging.Logger; +import nl.jqno.paralleljava.app.logging.LoggerFactory; +import nl.jqno.paralleljava.app.persistence.Repository; + +import java.util.ArrayList; +import java.util.UUID; + +/** + * NOTE: this class is totally not thread-safe! + */ +public class InMemoryRepository implements Repository { + private static final java.util.List todos = new ArrayList<>(); + private static final Try SUCCESS = Try.success(null); + + private final Logger logger; + + public InMemoryRepository(LoggerFactory loggerFactory) { + this.logger = loggerFactory.create(getClass()); + } + + public Try initialize() { + return SUCCESS; + } + + public Try create(Todo todo) { + logger.forProduction("Creating Todo " + todo); + todos.add(todo); + return SUCCESS; + } + + public Try> get(UUID id) { + var result = List.ofAll(todos) + .find(t -> t.id().equals(id)); + return Try.success(result); + } + + public Try> getAll() { + return Try.success(List.ofAll(todos)); + } + + public Try update(Todo todo) { + var index = List.ofAll(todos) + .map(Todo::id) + .indexOf(todo.id()); + todos.remove(index); + todos.add(index, todo); + return SUCCESS; + } + + public Try update(UUID id, Function1 f) { + return get(id).flatMap(option -> { + if (option.isEmpty()) { + return Try.failure(new IllegalArgumentException("Can't find Todo with id " + id)); + } else { + var oldTodo = option.get(); + var newTodo = f.apply(oldTodo).withId(oldTodo.id()); + return update(newTodo).map(ignored -> newTodo); + } + }); + } + + public Try delete(UUID id) { + var index = List.ofAll(todos) + .map(Todo::id) + .indexOf(id); + todos.remove(index); + return SUCCESS; + } + + public Try deleteAll() { + logger.forProduction("Clearing all Todos"); + todos.clear(); + return SUCCESS; + } +} diff --git a/src/main/java/nl/jqno/paralleljava/app/serialization/GsonSerializer.java b/src/main/java/nl/jqno/paralleljava/app/serialization/GsonSerializer.java new file mode 100644 index 0000000..6908b1e --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/serialization/GsonSerializer.java @@ -0,0 +1,73 @@ +package nl.jqno.paralleljava.app.serialization; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import io.vavr.CheckedFunction0; +import io.vavr.collection.List; +import io.vavr.control.Option; +import io.vavr.control.Try; +import io.vavr.gson.VavrGson; +import nl.jqno.paralleljava.app.domain.PartialTodo; +import nl.jqno.paralleljava.app.domain.Todo; +import nl.jqno.paralleljava.app.logging.Logger; +import nl.jqno.paralleljava.app.logging.LoggerFactory; + +import java.lang.reflect.Type; +import java.util.UUID; + +public class GsonSerializer implements Serializer { + private static final Type LIST_OF_TODO_TYPE = new TypeToken>(){}.getType(); + + private final Gson gson; + private final Logger logger; + + public static Serializer create(LoggerFactory loggerFactory) { + var gsonBuilder = new GsonBuilder(); + VavrGson.registerAll(gsonBuilder); + return new GsonSerializer(gsonBuilder.create(), loggerFactory); + } + + public GsonSerializer(Gson gson, LoggerFactory loggerFactory) { + this.gson = gson; + this.logger = loggerFactory.create(getClass()); + } + + public String serializeTodo(Todo todo) { + return gson.toJson(todo); + } + + public Option deserializeTodo(String json) { + return attemptDeserialization(json, () -> gson.fromJson(json, Todo.class)); + } + + public String serializePartialTodo(PartialTodo todo) { + return gson.toJson(todo); + } + + public Option deserializePartialTodo(String json) { + return attemptDeserialization(json, () -> gson.fromJson(json, PartialTodo.class)); + } + + public String serializeTodos(List todos) { + return gson.toJson(todos); + } + + public List deserializeTodos(String json) { + return attemptDeserialization(json, () -> (List)gson.fromJson(json, LIST_OF_TODO_TYPE)).getOrElse(List.empty()); + } + + public String serializeUuid(UUID id) { + return gson.toJson(id); + } + + public Option deserializeUuid(String json) { + return attemptDeserialization(json, () -> gson.fromJson(json, UUID.class)); + } + + private Option attemptDeserialization(String json, CheckedFunction0 f) { + return Try.of(f) + .onFailure(t -> logger.firstThingNextMorning("Failed to deserialize " + json, t)) + .toOption(); + } +} diff --git a/src/main/java/nl/jqno/paralleljava/app/serialization/Serializer.java b/src/main/java/nl/jqno/paralleljava/app/serialization/Serializer.java new file mode 100644 index 0000000..f5b9e05 --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/serialization/Serializer.java @@ -0,0 +1,22 @@ +package nl.jqno.paralleljava.app.serialization; + +import io.vavr.collection.List; +import io.vavr.control.Option; +import nl.jqno.paralleljava.app.domain.PartialTodo; +import nl.jqno.paralleljava.app.domain.Todo; + +import java.util.UUID; + +public interface Serializer { + String serializeTodo(Todo todo); + Option deserializeTodo(String json); + + String serializePartialTodo(PartialTodo todo); + Option deserializePartialTodo(String json); + + String serializeTodos(List todos); + List deserializeTodos(String json); + + String serializeUuid(UUID uuid); + Option deserializeUuid(String json); +} diff --git a/src/main/java/nl/jqno/paralleljava/app/server/Server.java b/src/main/java/nl/jqno/paralleljava/app/server/Server.java new file mode 100644 index 0000000..681577f --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/server/Server.java @@ -0,0 +1,5 @@ +package nl.jqno.paralleljava.app.server; + +public interface Server { + void run(); +} diff --git a/src/main/java/nl/jqno/paralleljava/app/server/SparkServer.java b/src/main/java/nl/jqno/paralleljava/app/server/SparkServer.java new file mode 100644 index 0000000..b28e066 --- /dev/null +++ b/src/main/java/nl/jqno/paralleljava/app/server/SparkServer.java @@ -0,0 +1,68 @@ +package nl.jqno.paralleljava.app.server; + +import io.vavr.control.Option; +import io.vavr.control.Try; +import nl.jqno.paralleljava.app.controller.Controller; +import nl.jqno.paralleljava.app.logging.Logger; +import nl.jqno.paralleljava.app.logging.LoggerFactory; +import spark.Response; + +import static spark.Spark.*; + +public class SparkServer implements Server { + + private final String endpoint; + private final Controller controller; + private final int port; + private final Logger logger; + + public SparkServer(String endpoint, int port, Controller controller, LoggerFactory loggerFactory) { + this.endpoint = endpoint; + this.controller = controller; + this.port = port; + this.logger = loggerFactory.create(getClass()); + } + + public void run() { + logger.forProduction("Starting on port " + port); + + port(port); + enableCors(); + + get(endpoint, + (request, response) -> buildResponse(response, controller.get())); + get(endpoint + "/:id", + (request, response) -> buildResponse(response, controller.get(request.params("id")))); + post(endpoint, + (request, response) -> buildResponse(response, controller.post(request.body()))); + patch(endpoint + "/:id", + (request, response) -> buildResponse(response, controller.patch(request.params("id"), request.body()))); + delete(endpoint, + (request, response) -> buildResponse(response, controller.delete())); + delete(endpoint + "/:id", + (request, response) -> buildResponse(response, controller.delete(request.params("id")))); + } + + private void enableCors() { + options("/*", (request, response) -> { + Option.of(request.headers("Access-Control-Request-Headers")) + .forEach(h -> response.header("Access-Control-Allow-Headers", h)); + Option.of(request.headers("Access-Control-Request-Method")) + .forEach(h -> response.header("Access-Control-Allow-Methods", h)); + return "OK"; + }); + + before((request, response) -> { + response.header("Access-Control-Allow-Origin", "*"); + }); + } + + private String buildResponse(Response response, Try method) { + return method + .onFailure(e -> { + var isInvalidRequest = IllegalArgumentException.class.equals(e.getClass()); + response.status(isInvalidRequest ? 400 : 500); + }) + .getOrElse(""); + } +} diff --git a/src/test/java/nl/jqno/paralleljava/ArchitectureTest.java b/src/test/java/nl/jqno/paralleljava/ArchitectureTest.java new file mode 100644 index 0000000..a90c17d --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/ArchitectureTest.java @@ -0,0 +1,43 @@ +package nl.jqno.paralleljava; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import nl.jqno.paralleljava.app.logging.Slf4jLogger; +import nl.jqno.paralleljava.app.persistence.database.DatabaseRepository; +import nl.jqno.paralleljava.app.serialization.GsonSerializer; +import nl.jqno.paralleljava.app.server.SparkServer; +import nl.jqno.picotest.Test; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +public class ArchitectureTest extends Test { + + private static final JavaClasses IMPORTED_CLASSES = + new ClassFileImporter().importPackages("nl.jqno.paralleljava"); + + public void architecture() { + test("only SparkServer and SparkServerTest access Spark classes", () -> { + assertBoundary("spark..", SparkServer.class.getPackage()); + }); + + test("only Slf4jLogger accesses Slf4j classes", () -> { + assertBoundary("org.slf4j..", Slf4jLogger.class.getPackage()); + }); + + test("only GsonSerializer accesses Gson classes", () -> { + assertBoundary("com.google.gson..", GsonSerializer.class.getPackage()); + }); + + test("only DatabaseRepository accesses Engine classes", () -> { + assertBoundary("org.jdbi..", DatabaseRepository.class.getPackage()); + }); + } + + private void assertBoundary(String restrictedPackageIdentifier, Package whiteListedPackage) { + var rule = noClasses() + .that().resideOutsideOfPackage(whiteListedPackage.getName()) + .and().dontHaveFullyQualifiedName(Main.class.getCanonicalName()) + .should().accessClassesThat().resideInAPackage(restrictedPackageIdentifier); + rule.check(IMPORTED_CLASSES); + } +} diff --git a/src/test/java/nl/jqno/paralleljava/TestData.java b/src/test/java/nl/jqno/paralleljava/TestData.java new file mode 100644 index 0000000..a2a734a --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/TestData.java @@ -0,0 +1,44 @@ +package nl.jqno.paralleljava; + +import io.vavr.collection.List; +import io.vavr.control.Option; +import nl.jqno.paralleljava.app.domain.PartialTodo; +import nl.jqno.paralleljava.app.domain.Todo; + +import java.util.UUID; + +public interface TestData { + + String URL_PREFIX = "http://localhost/blabla"; + + interface SomeTodo { + UUID ID = UUID.randomUUID(); + String URL = URL_PREFIX + "/" + ID.toString(); + Todo TODO = new Todo(ID, "title", URL, true, 1337); + + PartialTodo PARTIAL_COMPLETE = new PartialTodo(Option.of(ID), Option.of("title"), Option.of(URL), Option.of(true), Option.of(1337)); + PartialTodo PARTIAL_POST = new PartialTodo(Option.none(), Option.of("title"), Option.none(), Option.none(), Option.none()); + + String SERIALIZED = "{\"id\":\"" + ID + "\",\"title\":\"title\",\"url\":\"" + URL + "\",\"completed\":true,\"order\":1337}"; + String SERIALIZED_PARTIAL_POST = "{\"title\":\"title\"}"; + String SERIALIZED_PARTIAL_POST_WITH_ORDER = "{\"title\":\"title\",\"order\":1337}"; + } + + interface AnotherTodo { + UUID ID = UUID.randomUUID(); + String URL = URL_PREFIX + "/" + ID.toString(); + Todo TODO = new Todo(ID, "something", URL, false, 1); + String SERIALIZED = "{\"id\":\"" + ID + "\",\"title\":\"something\",\"url\":\"" + URL + "\",\"completed\":false,\"order\":1}"; + } + + interface ListOfTodos { + List LIST = List.of(SomeTodo.TODO, AnotherTodo.TODO); + String SERIALIZED = "[" + SomeTodo.SERIALIZED + "," + AnotherTodo.SERIALIZED + "]"; + } + + interface Invalid { + String ID = "this is an invalid uuid"; + String JSON = "this is an invalid json document"; + String SERIALIZED_TODO_WITH_NO_TITLE = "{\"order\":1337}"; + } +} diff --git a/src/test/java/nl/jqno/paralleljava/app/RunnerTest.java b/src/test/java/nl/jqno/paralleljava/app/RunnerTest.java new file mode 100644 index 0000000..225dd80 --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/RunnerTest.java @@ -0,0 +1,41 @@ +package nl.jqno.paralleljava.app; + +import nl.jqno.paralleljava.app.logging.Slf4jLogger; +import nl.jqno.paralleljava.app.logging.StubLogger; +import nl.jqno.paralleljava.app.persistence.StubRepository; +import nl.jqno.paralleljava.app.server.StubServer; +import nl.jqno.picotest.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class RunnerTest extends Test { + + public void runner() { + var repo = new StubRepository(); + var server = new StubServer(); + var logger = new StubLogger(); + var runner = new Runner(repo, server, c -> new Slf4jLogger(logger)); + + beforeEach(() -> { + repo.clear(); + server.clear(); + }); + + test("happy path", () -> { + runner.startup(); + + assertThat(repo.calledInitialize).isEqualTo(1); + assertThat(server.calledRun).isEqualTo(1); + assertThat(logger.calledInfo).isEqualTo(7); + }); + + test("repo initialization fails", () -> { + repo.failNextCall = true; + + runner.startup(); + + assertThat(repo.calledInitialize).isEqualTo(1); + assertThat(server.calledRun).isEqualTo(0); + }); + } +} diff --git a/src/test/java/nl/jqno/paralleljava/app/controller/DefaultControllerTest.java b/src/test/java/nl/jqno/paralleljava/app/controller/DefaultControllerTest.java new file mode 100644 index 0000000..476efb5 --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/controller/DefaultControllerTest.java @@ -0,0 +1,200 @@ +package nl.jqno.paralleljava.app.controller; + +import nl.jqno.paralleljava.app.domain.Todo; +import nl.jqno.paralleljava.app.logging.LoggerFactory; +import nl.jqno.paralleljava.app.persistence.IdGenerator; +import nl.jqno.paralleljava.app.persistence.Repository; +import nl.jqno.paralleljava.app.persistence.inmemory.InMemoryRepository; +import nl.jqno.paralleljava.app.serialization.GsonSerializer; +import nl.jqno.paralleljava.app.serialization.Serializer; +import nl.jqno.paralleljava.app.persistence.ConstantIdGenerator; +import nl.jqno.paralleljava.app.logging.NopLogger; +import nl.jqno.picotest.Test; + +import java.util.UUID; + +import static nl.jqno.paralleljava.TestData.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.vavr.api.VavrAssertions.assertThat; + +public class DefaultControllerTest extends Test { + + private final UUID constantId = UUID.randomUUID(); + private final String fullUrl = "/blabla/todo"; + + private final LoggerFactory loggerFactory = c -> new NopLogger(); + private final Repository repository = new InMemoryRepository(loggerFactory); + private final IdGenerator idGenerator = new ConstantIdGenerator(constantId); + private final Serializer serializer = GsonSerializer.create(loggerFactory); + + private final DefaultController controller = new DefaultController(fullUrl, repository, idGenerator, serializer, loggerFactory); + + public void get() { + beforeEach(() -> { + repository.deleteAll(); + }); + + test("get returns an empty list when no todos are present", () -> { + var actual = controller.get(); + assertThat(actual).contains("[]"); + }); + + test("get returns all todos", () -> { + repository.create(SomeTodo.TODO); + repository.create(AnotherTodo.TODO); + + var actual = controller.get(); + assertThat(actual).contains(ListOfTodos.SERIALIZED); + }); + } + + public void getWithId() { + beforeEach(() -> { + repository.deleteAll(); + }); + + test("get with id returns a specific serialized todo if it exists", () -> { + repository.create(SomeTodo.TODO); + + var actual = controller.get(SomeTodo.ID.toString()); + assertThat(actual).contains(SomeTodo.SERIALIZED); + }); + + test("get with id fails if id is invalid", () -> { + var actual = controller.get(Invalid.ID); + assertThat(actual).failBecauseOf(IllegalArgumentException.class); + }); + + test("get with id fails if it doesn't exist", () -> { + var actual = controller.get(SomeTodo.ID.toString()); + assertThat(actual).failBecauseOf(IllegalArgumentException.class); + }); + } + + public void post() { + beforeEach(() -> { + repository.deleteAll(); + }); + + test("post adds a todo without order", () -> { + var expected = new Todo(constantId, "title", fullUrl + "/" + constantId, false, 0); + var expectedSerialized = serializer.serializeTodo(expected); + + var actual = controller.post(SomeTodo.SERIALIZED_PARTIAL_POST); + assertThat(actual).contains(expectedSerialized); + assertThat(repository.getAll()).hasValueSatisfying(l -> assertThat(l).contains(expected)); + }); + + test("post adds a todo with order", () -> { + var expected = new Todo(constantId, "title", fullUrl + "/" + constantId, false, 1337); + var expectedSerialized = serializer.serializeTodo(expected); + + var actual = controller.post(SomeTodo.SERIALIZED_PARTIAL_POST_WITH_ORDER); + assertThat(actual).contains(expectedSerialized); + assertThat(repository.getAll()).hasValueSatisfying(l -> assertThat(l).contains(expected)); + }); + + test("post fails when todo is invalid", () -> { + var actual = controller.post(Invalid.JSON); + assertThat(actual).failBecauseOf(IllegalArgumentException.class); + }); + + test("post fails when todo has no title", () -> { + var actual = controller.post(Invalid.SERIALIZED_TODO_WITH_NO_TITLE); + assertThat(actual).failBecauseOf(IllegalArgumentException.class); + }); + } + + public void patch() { + beforeEach(() -> { + repository.deleteAll(); + }); + + test("patch changes title", () -> { + repository.create(SomeTodo.TODO); + var expected = SomeTodo.TODO.withTitle("another title"); + + var result = controller.patch(SomeTodo.ID.toString(), "{\"title\":\"another title\"}"); + var actual = repository.get(SomeTodo.ID); + + assertThat(result).contains(serializer.serializeTodo(expected)); + assertThat(actual).hasValueSatisfying(o -> assertThat(o).contains(expected)); + }); + + test("patch changes completed", () -> { + repository.create(SomeTodo.TODO); + var expected = SomeTodo.TODO.withCompleted(false); + + var result = controller.patch(SomeTodo.ID.toString(), "{\"completed\":false}"); + var actual = repository.get(SomeTodo.ID); + + assertThat(result).contains(serializer.serializeTodo(expected)); + assertThat(actual).hasValueSatisfying(o -> assertThat(o).contains(expected)); + }); + + test("patch changes order", () -> { + repository.create(SomeTodo.TODO); + var expected = SomeTodo.TODO.withOrder(47); + + var result = controller.patch(SomeTodo.ID.toString(), "{\"order\":47}"); + var actual = repository.get(SomeTodo.ID); + + assertThat(result).contains(serializer.serializeTodo(expected)); + assertThat(actual).hasValueSatisfying(o -> assertThat(o).contains(expected)); + }); + + test("delete with id fails if id is invalid", () -> { + var actual = controller.patch(Invalid.ID, "{\"order\":47}"); + assertThat(actual).failBecauseOf(IllegalArgumentException.class); + }); + + test("post fails when todo is invalid", () -> { + var actual = controller.patch(SomeTodo.ID.toString(), Invalid.JSON); + assertThat(actual).failBecauseOf(IllegalArgumentException.class); + }); + + test("patch fails if id doesn't exist", () -> { + var actual = controller.patch(SomeTodo.ID.toString(), "{\"order\":47}"); + assertThat(actual).failBecauseOf(IllegalArgumentException.class); + }); + } + + public void delete() { + beforeEach(() -> { + repository.deleteAll(); + }); + + test("delete clears all todos", () -> { + repository.create(SomeTodo.TODO); + + var actual = controller.delete(); + + assertThat(actual).hasValueSatisfying(s -> assertThat(s).isEmpty()); + assertThat(repository.getAll()).hasValueSatisfying(l -> assertThat(l).isEmpty()); + }); + } + + public void deleteWithId() { + beforeEach(() -> { + repository.deleteAll(); + }); + + test("delete with id removes the corresponding todo", () -> { + repository.create(AnotherTodo.TODO); + repository.create(SomeTodo.TODO); + + var actual = controller.delete(SomeTodo.ID.toString()); + + assertThat(actual).hasValueSatisfying(s -> assertThat(s).isEmpty()); + assertThat(repository.getAll()).hasValueSatisfying(l -> { + assertThat(l).doesNotContain(SomeTodo.TODO); + assertThat(l).contains(AnotherTodo.TODO); + }); + }); + + test("delete with id fails if id is invalid", () -> { + var actual = controller.delete(Invalid.ID); + assertThat(actual).failBecauseOf(IllegalArgumentException.class); + }); + } +} diff --git a/src/test/java/nl/jqno/paralleljava/app/controller/StubController.java b/src/test/java/nl/jqno/paralleljava/app/controller/StubController.java new file mode 100644 index 0000000..2267e1d --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/controller/StubController.java @@ -0,0 +1,75 @@ +package nl.jqno.paralleljava.app.controller; + +import io.vavr.control.Try; +import nl.jqno.paralleljava.app.controller.Controller; + +public class StubController implements Controller { + + private static final Try SUCCESS = Try.success(""); + private static final Try FAILURE_4xx = Try.failure(new IllegalArgumentException()); + private static final Try FAILURE_5xx = Try.failure(new IllegalStateException()); + + public boolean nextRequestFails4xx = false; + public boolean nextRequestFails5xx = false; + public int calledGet = 0; + public int calledGetWithId = 0; + public int calledPost = 0; + public int calledPatchWithId = 0; + public int calledDelete = 0; + public int calledDeleteWithId = 0; + + public void clear() { + nextRequestFails4xx = false; + nextRequestFails5xx = false; + calledGet = 0; + calledGetWithId = 0; + calledPost = 0; + calledPatchWithId = 0; + calledDelete = 0; + calledDeleteWithId = 0; + } + + public int calledTotal() { + return calledGet + calledGetWithId + calledPost + calledPatchWithId + calledDelete + calledDeleteWithId; + } + + public Try get() { + calledGet += 1; + return response(); + } + + public Try get(String id) { + calledGetWithId += 1; + return response(); + } + + public Try post(String json) { + calledPost += 1; + return response(); + } + + public Try patch(String id, String json) { + calledPatchWithId += 1; + return response(); + } + + public Try delete() { + calledDelete += 1; + return response(); + } + + public Try delete(String id) { + calledDeleteWithId += 1; + return response(); + } + + private Try response() { + if (nextRequestFails4xx) { + return FAILURE_4xx; + } + if (nextRequestFails5xx) { + return FAILURE_5xx; + } + return SUCCESS; + } +} diff --git a/src/test/java/nl/jqno/paralleljava/app/domain/PartialTodoTest.java b/src/test/java/nl/jqno/paralleljava/app/domain/PartialTodoTest.java new file mode 100644 index 0000000..678c1e0 --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/domain/PartialTodoTest.java @@ -0,0 +1,42 @@ +package nl.jqno.paralleljava.app.domain; + +import nl.jqno.equalsverifier.EqualsVerifier; +import nl.jqno.paralleljava.TestData.SomeTodo; +import nl.jqno.picotest.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.vavr.api.VavrAssertions.assertThat; + +public class PartialTodoTest extends Test { + + public void partialTodo() { + + test("equals and hashCode", () -> { + EqualsVerifier.forClass(PartialTodo.class) + .verify(); + }); + + test("getters (Some)", () -> { + assertThat(SomeTodo.PARTIAL_COMPLETE.id()).contains(SomeTodo.ID); + assertThat(SomeTodo.PARTIAL_COMPLETE.title()).contains("title"); + assertThat(SomeTodo.PARTIAL_COMPLETE.url()).contains(SomeTodo.URL); + assertThat(SomeTodo.PARTIAL_COMPLETE.completed()).contains(true); + assertThat(SomeTodo.PARTIAL_COMPLETE.order()).contains(1337); + }); + + test("getters (None)", () -> { + assertThat(SomeTodo.PARTIAL_POST.id()).isEmpty(); + assertThat(SomeTodo.PARTIAL_POST.title()).contains("title"); + assertThat(SomeTodo.PARTIAL_POST.url()).isEmpty(); + assertThat(SomeTodo.PARTIAL_POST.completed()).isEmpty(); + assertThat(SomeTodo.PARTIAL_POST.order()).isEmpty(); + }); + + test("toString", () -> { + assertThat(SomeTodo.PARTIAL_COMPLETE.toString()) + .isEqualTo("PartialTodo: [id=Some(" + SomeTodo.ID + "), title=Some(title), url=Some(" + SomeTodo.URL + "), completed=Some(true), order=Some(1337)]"); + assertThat(SomeTodo.PARTIAL_POST.toString()) + .isEqualTo("PartialTodo: [id=None, title=Some(title), url=None, completed=None, order=None]"); + }); + } +} diff --git a/src/test/java/nl/jqno/paralleljava/app/domain/TodoTest.java b/src/test/java/nl/jqno/paralleljava/app/domain/TodoTest.java new file mode 100644 index 0000000..932c885 --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/domain/TodoTest.java @@ -0,0 +1,70 @@ +package nl.jqno.paralleljava.app.domain; + +import nl.jqno.equalsverifier.EqualsVerifier; +import nl.jqno.paralleljava.TestData.SomeTodo; +import nl.jqno.picotest.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TodoTest extends Test { + + public void todo() { + + test("equals and hashCode", () -> { + EqualsVerifier.forClass(Todo.class) + .verify(); + }); + + test("getters", () -> { + assertThat(SomeTodo.TODO.id()).isEqualTo(SomeTodo.ID); + assertThat(SomeTodo.TODO.title()).isEqualTo("title"); + assertThat(SomeTodo.TODO.url()).isEqualTo("http://localhost/blabla/" + SomeTodo.ID.toString()); + assertThat(SomeTodo.TODO.completed()).isEqualTo(true); + assertThat(SomeTodo.TODO.order()).isEqualTo(1337); + }); + + test("toString", () -> { + assertThat(SomeTodo.TODO.toString()) + .isEqualTo("Todo: [id=" + SomeTodo.ID + ", title=title, url=" + SomeTodo.URL + ", completed=true, order=1337]"); + }); + + test("withId", () -> { + var anotherId = UUID.randomUUID(); + var actual = SomeTodo.TODO.withId(anotherId); + assertThat(actual.id()).isEqualTo(anotherId); + assertThat(actual.title()).isEqualTo(SomeTodo.TODO.title()); + assertThat(actual.url()).isEqualTo(SomeTodo.TODO.url()); + assertThat(actual.completed()).isEqualTo(SomeTodo.TODO.completed()); + assertThat(actual.order()).isEqualTo(SomeTodo.TODO.order()); + }); + + test("withTitle", () -> { + var actual = SomeTodo.TODO.withTitle("another title"); + assertThat(actual.title()).isEqualTo("another title"); + assertThat(actual.id()).isEqualTo(SomeTodo.ID); + assertThat(actual.url()).isEqualTo(SomeTodo.TODO.url()); + assertThat(actual.completed()).isEqualTo(SomeTodo.TODO.completed()); + assertThat(actual.order()).isEqualTo(SomeTodo.TODO.order()); + }); + + test("withCompleted", () -> { + var actual = SomeTodo.TODO.withCompleted(!SomeTodo.TODO.completed()); + assertThat(actual.completed()).isEqualTo(!SomeTodo.TODO.completed()); + assertThat(actual.id()).isEqualTo(SomeTodo.ID); + assertThat(actual.title()).isEqualTo(SomeTodo.TODO.title()); + assertThat(actual.url()).isEqualTo(SomeTodo.TODO.url()); + assertThat(actual.order()).isEqualTo(SomeTodo.TODO.order()); + }); + + test("withOrder", () -> { + var actual = SomeTodo.TODO.withOrder(86); + assertThat(actual.order()).isEqualTo(86); + assertThat(actual.id()).isEqualTo(SomeTodo.ID); + assertThat(actual.title()).isEqualTo(SomeTodo.TODO.title()); + assertThat(actual.url()).isEqualTo(SomeTodo.TODO.url()); + assertThat(actual.completed()).isEqualTo(SomeTodo.TODO.completed()); + }); + } +} diff --git a/src/test/java/nl/jqno/paralleljava/app/environment/HerokuEnvironmentTest.java b/src/test/java/nl/jqno/paralleljava/app/environment/HerokuEnvironmentTest.java new file mode 100644 index 0000000..bb74193 --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/environment/HerokuEnvironmentTest.java @@ -0,0 +1,46 @@ +package nl.jqno.paralleljava.app.environment; + +import io.vavr.collection.HashMap; +import nl.jqno.picotest.Test; + +import static org.assertj.vavr.api.VavrAssertions.assertThat; + +public class HerokuEnvironmentTest extends Test { + + private Environment environment = new HerokuEnvironment(HashMap.empty()); + + public void port() { + test("a valid port is provided", () -> { + setEnvironmentVariable("PORT", "42"); + var actual = environment.port(); + assertThat(actual).contains(42); + }); + + test("an invalid port is provided", () -> { + setEnvironmentVariable("PORT", "this is not the port you're looking for"); + var actual = environment.port(); + assertThat(actual).isEmpty(); + }); + + test("no port is provided", () -> { + var actual = environment.port(); + assertThat(actual).isEmpty(); + }); + + test("host url", () -> { + var actual = environment.hostUrl(); + assertThat(actual).contains("https://parallel-java.herokuapp.com"); + }); + + test("jdbc url", () -> { + setEnvironmentVariable("JDBC_DATABASE_URL", "some-jdbc"); + var actual = environment.jdbcUrl(); + assertThat(actual).contains("some-jdbc"); + }); + } + + private void setEnvironmentVariable(String key, String value) { + var env = HashMap.of(key, value); + environment = new HerokuEnvironment(env); + } +} diff --git a/src/test/java/nl/jqno/paralleljava/app/logging/NopLogger.java b/src/test/java/nl/jqno/paralleljava/app/logging/NopLogger.java new file mode 100644 index 0000000..8736a63 --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/logging/NopLogger.java @@ -0,0 +1,13 @@ +package nl.jqno.paralleljava.app.logging; + +import nl.jqno.paralleljava.app.logging.Logger; + +public class NopLogger implements Logger { + + public void forDevelopment(String message) {} + public void forProduction(String message) {} + public void firstThingNextMorning(String message) {} + public void firstThingNextMorning(String message, Throwable t) {} + public void wakeMeUp(String message) {} + public void wakeMeUp(String message, Throwable t) {} +} diff --git a/src/test/java/nl/jqno/paralleljava/app/logging/Slf4jLoggerTest.java b/src/test/java/nl/jqno/paralleljava/app/logging/Slf4jLoggerTest.java new file mode 100644 index 0000000..d0d644f --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/logging/Slf4jLoggerTest.java @@ -0,0 +1,64 @@ +package nl.jqno.paralleljava.app.logging; + +import nl.jqno.picotest.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class Slf4jLoggerTest extends Test { + + private static final String SOME_MESSAGE = ""; + private static final Throwable SOME_EXCEPTION = new Throwable(); + + private StubLogger underlying; + private Slf4jLogger logger; + + public void logger() { + beforeEach(() -> { + underlying = new StubLogger(); + logger = new Slf4jLogger(underlying); + }); + + test("forDevelopment calls DEBUG", () -> { + logger.forDevelopment(SOME_MESSAGE); + assertThat(underlying.calledDebug).isEqualTo(1); + assertThat(underlying.calledTotal()).isEqualTo(1); + assertThat(underlying.lastThrowable).isNull(); + }); + + test("forProduction calls INFO", () -> { + logger.forProduction(SOME_MESSAGE); + assertThat(underlying.calledInfo).isEqualTo(1); + assertThat(underlying.calledTotal()).isEqualTo(1); + assertThat(underlying.lastThrowable).isNull(); + }); + + test("firstThingNextMorning calls WARN", () -> { + logger.firstThingNextMorning(SOME_MESSAGE); + assertThat(underlying.calledWarn).isEqualTo(1); + assertThat(underlying.calledTotal()).isEqualTo(1); + assertThat(underlying.lastThrowable).isNull(); + }); + + test("firstThingNextMorning with exception calls WARN with exception", () -> { + logger.firstThingNextMorning(SOME_MESSAGE, SOME_EXCEPTION); + assertThat(underlying.calledWarn).isEqualTo(1); + assertThat(underlying.calledTotal()).isEqualTo(1); + assertThat(underlying.lastThrowable).isEqualTo(SOME_EXCEPTION); + }); + + test("wakeMeUp calls ERROR", () -> { + logger.wakeMeUp(SOME_MESSAGE); + assertThat(underlying.calledError).isEqualTo(1); + assertThat(underlying.calledTotal()).isEqualTo(1); + assertThat(underlying.lastThrowable).isNull(); + }); + + test("wakeMeUp with exception calls ERROR with exception", () -> { + logger.wakeMeUp(SOME_MESSAGE, SOME_EXCEPTION); + assertThat(underlying.calledError).isEqualTo(1); + assertThat(underlying.calledTotal()).isEqualTo(1); + assertThat(underlying.lastThrowable).isEqualTo(SOME_EXCEPTION); + }); + } +} + diff --git a/src/test/java/nl/jqno/paralleljava/app/logging/StubLogger.java b/src/test/java/nl/jqno/paralleljava/app/logging/StubLogger.java new file mode 100644 index 0000000..9445a2d --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/logging/StubLogger.java @@ -0,0 +1,47 @@ +package nl.jqno.paralleljava.app.logging; + +import org.slf4j.helpers.SubstituteLogger; + +import java.util.concurrent.LinkedBlockingQueue; + +public class StubLogger extends SubstituteLogger { + public int calledDebug = 0; + public int calledInfo = 0; + public int calledWarn = 0; + public int calledError = 0; + public Throwable lastThrowable = null; + + public StubLogger() { + super(StubLogger.class.getName(), new LinkedBlockingQueue<>(), false); + } + + public int calledTotal() { + return calledDebug + calledInfo + calledWarn + calledError; + } + + public void debug(String msg) { + calledDebug += 1; + } + + public void info(String msg) { + calledInfo += 1; + } + + public void warn(String msg) { + calledWarn += 1; + } + + public void warn(String msg, Throwable e) { + calledWarn += 1; + lastThrowable = e; + } + + public void error(String msg) { + calledError += 1; + } + + public void error(String msg, Throwable e) { + calledError += 1; + lastThrowable = e; + } +} diff --git a/src/test/java/nl/jqno/paralleljava/app/persistence/ConstantIdGenerator.java b/src/test/java/nl/jqno/paralleljava/app/persistence/ConstantIdGenerator.java new file mode 100644 index 0000000..491c4db --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/persistence/ConstantIdGenerator.java @@ -0,0 +1,17 @@ +package nl.jqno.paralleljava.app.persistence; + +import nl.jqno.paralleljava.app.persistence.IdGenerator; + +import java.util.UUID; + +public class ConstantIdGenerator implements IdGenerator { + private final UUID id; + + public ConstantIdGenerator(UUID id) { + this.id = id; + } + + public UUID generateId() { + return id; + } +} diff --git a/src/test/java/nl/jqno/paralleljava/app/persistence/RandomIdGeneratorTest.java b/src/test/java/nl/jqno/paralleljava/app/persistence/RandomIdGeneratorTest.java new file mode 100644 index 0000000..bf63ef0 --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/persistence/RandomIdGeneratorTest.java @@ -0,0 +1,19 @@ +package nl.jqno.paralleljava.app.persistence; + +import nl.jqno.picotest.Test; +import org.assertj.core.api.Assertions; + +import java.util.UUID; + +public class RandomIdGeneratorTest extends Test { + + public void uuidGenerator() { + var generator = new RandomIdGenerator(); + + test("generates a valid uuid", () -> { + var actual = generator.generateId(); + var roundTrip = UUID.fromString(actual.toString()); + Assertions.assertThat(actual).isEqualTo(roundTrip); + }); + } +} diff --git a/src/test/java/nl/jqno/paralleljava/app/persistence/StubRepository.java b/src/test/java/nl/jqno/paralleljava/app/persistence/StubRepository.java new file mode 100644 index 0000000..c98de42 --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/persistence/StubRepository.java @@ -0,0 +1,82 @@ +package nl.jqno.paralleljava.app.persistence; + +import io.vavr.Function1; +import io.vavr.collection.List; +import io.vavr.control.Option; +import io.vavr.control.Try; +import nl.jqno.paralleljava.app.domain.Todo; + +import java.util.UUID; + +public class StubRepository implements Repository { + + public boolean failNextCall = false; + public int calledInitialize = 0; + public int calledCreate = 0; + public int calledGet = 0; + public int calledGetAll = 0; + public int calledUpdateDirectly = 0; + public int calledUpdateModify = 0; + public int calledDelete = 0; + public int calledDeleteAll = 0; + + public void clear() { + failNextCall = false; + calledInitialize = 0; + calledCreate = 0; + calledGet = 0; + calledGetAll = 0; + calledUpdateDirectly = 0; + calledUpdateModify = 0; + calledDelete = 0; + calledDeleteAll = 0; + } + + public Try initialize() { + calledInitialize += 1; + return returnValue(null); + } + + public Try create(Todo todo) { + calledCreate += 1; + return returnValue(null); + } + + public Try> get(UUID id) { + calledGet += 1; + return returnValue(Option.none()); + } + + public Try> getAll() { + calledGetAll += 1; + return returnValue(List.empty()); + } + + public Try update(Todo todo) { + calledUpdateDirectly += 1; + return returnValue(null); + } + + public Try update(UUID id, Function1 f) { + calledUpdateModify += 1; + return returnValue(null); + } + + public Try delete(UUID id) { + calledDelete += 1; + return returnValue(null); + } + + public Try deleteAll() { + calledDeleteAll += 1; + return returnValue(null); + } + + private Try returnValue(T value) { + if (failNextCall) { + failNextCall = false; + return Try.failure(new IllegalStateException()); + } + return Try.success(value); + } +} diff --git a/src/test/java/nl/jqno/paralleljava/app/persistence/database/DatabaseRepositoryTest.java b/src/test/java/nl/jqno/paralleljava/app/persistence/database/DatabaseRepositoryTest.java new file mode 100644 index 0000000..9c81d34 --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/persistence/database/DatabaseRepositoryTest.java @@ -0,0 +1,179 @@ +package nl.jqno.paralleljava.app.persistence.database; + +import nl.jqno.paralleljava.TestData; +import nl.jqno.paralleljava.TestData.AnotherTodo; +import nl.jqno.paralleljava.TestData.SomeTodo; +import nl.jqno.paralleljava.app.domain.Todo; +import nl.jqno.paralleljava.app.environment.Environment; +import nl.jqno.paralleljava.app.logging.LoggerFactory; +import nl.jqno.paralleljava.app.logging.NopLogger; +import nl.jqno.picotest.Test; +import org.jdbi.v3.core.statement.StatementContext; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.vavr.api.VavrAssertions.assertThat; + +public class DatabaseRepositoryTest extends Test { + + private static final String IN_MEMORY_DATABASE = Environment.DEFAULT_JDBC_URL; + private static final LoggerFactory NOP_LOGGER = c -> new NopLogger(); + private static final TodoMapper todoMapper = new TodoMapper(TestData.URL_PREFIX); + + public void initialization() { + + test("a table is created", () -> { + var engine = new JdbiEngine(IN_MEMORY_DATABASE, todoMapper, NOP_LOGGER); + var repo = new DatabaseRepository(engine); + var result = repo.initialize(); + assertThat(result).isSuccess(); + }); + + test("initializing twice is a no-op the second time", () -> { + var engine = new JdbiEngine(IN_MEMORY_DATABASE, todoMapper, NOP_LOGGER); + var repo = new DatabaseRepository(engine); + assertThat(repo.initialize()).isSuccess(); + assertThat(repo.initialize()).isSuccess(); + }); + + test("a failure with no message while creating is propagated", () -> { + var engine = new FailingEngine(); + var repo = new DatabaseRepository(engine); + assertThat(repo.initialize()).isFailure(); + }); + + test("a failure with a message while creating is propagated", () -> { + var engine = new FailingEngine(new IllegalStateException("Something went wrong")); + var repo = new DatabaseRepository(engine); + assertThat(repo.initialize()).isFailure(); + }); + + test("a failure to create the table is not propagated if the table already exists", () -> { + var engine = new FailingEngine(new IllegalStateException("Table \"TODO\" already exists")); + var repo = new DatabaseRepository(engine); + assertThat(repo.initialize()).isSuccess(); + }); + } + + public void repository() { + var engine = new JdbiEngine(IN_MEMORY_DATABASE, todoMapper, NOP_LOGGER); + var repo = new DatabaseRepository(engine); + + beforeAll(() -> { + assertThat(repo.initialize()).isSuccess(); + }); + beforeEach(() -> { + assertThat(repo.deleteAll()).isSuccess(); + }); + + test("create a todo", () -> { + var result = repo.create(SomeTodo.TODO); + + assertThat(result).isSuccess(); + assertThat(repo.getAll()).hasValueSatisfying(l -> l.contains(SomeTodo.TODO)); + }); + + test("get a specific todo", () -> { + repo.create(SomeTodo.TODO); + + assertThat(repo.get(SomeTodo.ID)).hasValueSatisfying(o -> assertThat(o).contains(SomeTodo.TODO)); + assertThat(repo.get(UUID.randomUUID())).hasValueSatisfying(o -> assertThat(o).isEmpty()); + }); + + test("update a specific todo", () -> { + var expected = SomeTodo.TODO.withTitle("another title"); + repo.create(SomeTodo.TODO); + repo.create(AnotherTodo.TODO); + + var result = repo.update(expected); + var actual = repo.get(SomeTodo.ID).get(); + + assertThat(result).isSuccess(); + assertThat(actual).contains(expected); + }); + + test("update a todo with a specific id", () -> { + repo.create(SomeTodo.TODO); + + var result = repo.update(SomeTodo.ID, t -> t.withTitle("updated")); + var actual = repo.get(SomeTodo.ID).get(); + + assertThat(result).isSuccess(); + assertThat(actual).hasValueSatisfying(t -> assertThat(t.title()).isEqualTo("updated")); + }); + + test("update a todo with a specific id that doesn't exist fails", () -> { + var result = repo.update(SomeTodo.ID, t -> t); + assertThat(result).isFailure(); + }); + + test("update a todo with a specific id doesn't change the id", () -> { + repo.create(SomeTodo.TODO); + + var result = repo.update(SomeTodo.ID, t -> AnotherTodo.TODO); + var actualOriginal = repo.get(SomeTodo.ID); + var actualNew = repo.get(AnotherTodo.ID); + + assertThat(result).isSuccess(); + assertThat(actualOriginal).hasValueSatisfying( + o -> assertThat(o).hasValueSatisfying( + t -> assertThat(t.title()).isEqualTo(AnotherTodo.TODO.title()))); + assertThat(actualNew).hasValueSatisfying(o -> assertThat(o).isEmpty()); + }); + + test("delete a specific todo", () -> { + repo.create(SomeTodo.TODO); + repo.create(AnotherTodo.TODO); + + var result = repo.delete(SomeTodo.ID); + var actual = repo.get(SomeTodo.ID).get(); + + assertThat(result).isSuccess(); + assertThat(actual).isEmpty(); + }); + + test("delete all todos", () -> { + repo.create(SomeTodo.TODO); + + var result = repo.deleteAll(); + + assertThat(result).isSuccess(); + assertThat(repo.getAll()).hasValueSatisfying(l -> assertThat(l).isEmpty()); + }); + } + + public void failures() { + test("Execute failures cause failed results", () -> { + var engine = new FailingEngine(); + var repo = new DatabaseRepository(engine); + + var result = repo.create(SomeTodo.TODO); + assertThat(result).isFailure(); + }); + + test("Query failures cause failed results", () -> { + var engine = new FailingEngine(); + var repo = new DatabaseRepository(engine); + + var result = repo.getAll(); + assertThat(result).isFailure(); + }); + + test("Mapping failures cause failed results", () -> { + var failingMapper = new TodoMapper("") { + public Todo map(ResultSet rs, StatementContext ctx) throws SQLException { + throw new SQLException("Intentional failure"); + } + }; + var engine = new JdbiEngine(IN_MEMORY_DATABASE, failingMapper, NOP_LOGGER); + var repo = new DatabaseRepository(engine); + repo.create(SomeTodo.TODO); + + var result = repo.getAll(); + assertThat(result).isFailure(); + }); + } +} diff --git a/src/test/java/nl/jqno/paralleljava/app/persistence/database/FailingEngine.java b/src/test/java/nl/jqno/paralleljava/app/persistence/database/FailingEngine.java new file mode 100644 index 0000000..00cd972 --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/persistence/database/FailingEngine.java @@ -0,0 +1,25 @@ +package nl.jqno.paralleljava.app.persistence.database; + +import io.vavr.control.Try; +import org.jdbi.v3.core.HandleCallback; +import org.jdbi.v3.core.HandleConsumer; + +public class FailingEngine implements Engine { + private final Throwable exception; + + public FailingEngine() { + this(new IllegalStateException()); + } + + public FailingEngine(Throwable exception) { + this.exception = exception; + } + + public Try execute(HandleConsumer consumer) { + return Try.failure(exception); + } + + public Try query(HandleCallback callback) { + return Try.failure(exception); + } +} diff --git a/src/test/java/nl/jqno/paralleljava/app/persistence/inmemory/InMemoryRepositoryTest.java b/src/test/java/nl/jqno/paralleljava/app/persistence/inmemory/InMemoryRepositoryTest.java new file mode 100644 index 0000000..dbc03c2 --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/persistence/inmemory/InMemoryRepositoryTest.java @@ -0,0 +1,124 @@ +package nl.jqno.paralleljava.app.persistence.inmemory; + +import nl.jqno.paralleljava.TestData.AnotherTodo; +import nl.jqno.paralleljava.TestData.SomeTodo; +import nl.jqno.paralleljava.app.logging.NopLogger; +import nl.jqno.picotest.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.vavr.api.VavrAssertions.assertThat; + +public class InMemoryRepositoryTest extends Test { + + public void repository() { + var repo = new InMemoryRepository(c -> new NopLogger()); + + beforeEach(() -> { + repo.deleteAll(); + }); + + test("initialize does nothing", () -> { + repo.initialize(); + }); + + test("create a todo", () -> { + var result = repo.create(SomeTodo.TODO); + + assertThat(result).isSuccess(); + assertThat(repo.getAll()).hasValueSatisfying(l -> assertThat(l).contains(SomeTodo.TODO)); + }); + + test("get a specific todo", () -> { + repo.create(SomeTodo.TODO); + + assertThat(repo.get(SomeTodo.ID)).hasValueSatisfying(o -> assertThat(o).contains(SomeTodo.TODO)); + assertThat(repo.get(UUID.randomUUID())).hasValueSatisfying(o -> assertThat(o).isEmpty()); + }); + + test("update a specific todo at index 0", () -> { + var expected = SomeTodo.TODO.withTitle("another title"); + repo.create(SomeTodo.TODO); + repo.create(AnotherTodo.TODO); + + var result = repo.update(expected); + var actual = repo.get(SomeTodo.ID).get(); + + assertThat(result).isSuccess(); + assertThat(actual).contains(expected); + }); + + test("update a specific todo at index 1", () -> { + var expected = SomeTodo.TODO.withTitle("another title"); + repo.create(AnotherTodo.TODO); + repo.create(SomeTodo.TODO); + + var result = repo.update(expected); + var actual = repo.get(SomeTodo.ID).get(); + + assertThat(result).isSuccess(); + assertThat(actual).contains(expected); + }); + + test("update a todo with a specific id", () -> { + repo.create(SomeTodo.TODO); + + var result = repo.update(SomeTodo.ID, t -> t.withTitle("updated")); + var actual = repo.get(SomeTodo.ID).get(); + + assertThat(result).isSuccess(); + assertThat(actual).hasValueSatisfying(t -> assertThat(t.title()).isEqualTo("updated")); + }); + + test("update a todo with a specific id that doesn't exist fails", () -> { + var result = repo.update(SomeTodo.ID, t -> t); + assertThat(result).isFailure(); + }); + + test("update a todo with a specific id doesn't change the id", () -> { + repo.create(SomeTodo.TODO); + + var result = repo.update(SomeTodo.ID, t -> AnotherTodo.TODO); + var actualOriginal = repo.get(SomeTodo.ID); + var actualNew = repo.get(AnotherTodo.ID); + + assertThat(result).isSuccess(); + assertThat(actualOriginal).hasValueSatisfying( + o -> assertThat(o).hasValueSatisfying( + t -> assertThat(t.title()).isEqualTo(AnotherTodo.TODO.title()))); + assertThat(actualNew).hasValueSatisfying(o -> assertThat(o).isEmpty()); + }); + + test("delete a specific todo at index 0", () -> { + repo.create(SomeTodo.TODO); + repo.create(AnotherTodo.TODO); + + var result = repo.delete(SomeTodo.ID); + var actual = repo.get(SomeTodo.ID).get(); + + assertThat(result).isSuccess(); + assertThat(actual).isEmpty(); + }); + + test("delete a specific todo at index 1", () -> { + repo.create(AnotherTodo.TODO); + repo.create(SomeTodo.TODO); + + var result = repo.delete(SomeTodo.ID); + var actual = repo.get(SomeTodo.ID).get(); + + assertThat(result).isSuccess(); + assertThat(actual).isEmpty(); + }); + + test("delete all todos", () -> { + repo.create(SomeTodo.TODO); + + var result = repo.deleteAll(); + + assertThat(result).isSuccess(); + assertThat(repo.getAll()).hasValueSatisfying(l -> assertThat(l).isEmpty()); + }); + } +} diff --git a/src/test/java/nl/jqno/paralleljava/app/serialization/GsonSerializerTest.java b/src/test/java/nl/jqno/paralleljava/app/serialization/GsonSerializerTest.java new file mode 100644 index 0000000..0a65abc --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/serialization/GsonSerializerTest.java @@ -0,0 +1,144 @@ +package nl.jqno.paralleljava.app.serialization; + +import nl.jqno.paralleljava.app.logging.NopLogger; +import nl.jqno.picotest.Test; + +import static nl.jqno.paralleljava.TestData.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.vavr.api.VavrAssertions.assertThat; + +public class GsonSerializerTest extends Test { + + private Serializer serializer = GsonSerializer.create(c -> new NopLogger()); + + public void serializationOfASingleTodo() { + + test("Serializes a Todo to json", () -> { + var actual = serializer.serializeTodo(SomeTodo.TODO); + assertThat(actual) + .contains("\"id\":\"" + SomeTodo.ID + "\"") + .contains("\"title\":\"title\"") + .contains("\"url\":\"" + SomeTodo.URL + "\"") + .contains("\"completed\":true") + .contains("\"order\":1337"); + }); + + test("Deserializes a Todo from json", () -> { + var actual = serializer.deserializeTodo(SomeTodo.SERIALIZED); + assertThat(actual).contains(SomeTodo.TODO); + }); + + test("Deserialization of a Todo returns none when json is invalid", () -> { + var actual = serializer.deserializeTodo(Invalid.JSON); + assertThat(actual).isEmpty(); + }); + + test("Does a complete round-trip on Todo", () -> { + var json = serializer.serializeTodo(SomeTodo.TODO); + var actual = serializer.deserializeTodo(json); + assertThat(actual).contains(SomeTodo.TODO); + }); + } + + public void serializationOfACompletePartialTodo() { + test("Serializes a complete PartialTodo to json", () -> { + var actual = serializer.serializePartialTodo(SomeTodo.PARTIAL_COMPLETE); + assertThat(actual) + .contains("\"id\":\"" + SomeTodo.ID + "\"") + .contains("\"title\":\"title\"") + .contains("\"url\":\"" + SomeTodo.URL + "\"") + .contains("\"completed\":true") + .contains("\"order\":1337"); + }); + + test("Deserializes a complete PartialTodo from json", () -> { + var actual = serializer.deserializePartialTodo(SomeTodo.SERIALIZED); + assertThat(actual).contains(SomeTodo.PARTIAL_COMPLETE); + }); + + test("Deserialization of a complete PartialTodo returns none when json is invalid", () -> { + var actual = serializer.deserializePartialTodo(Invalid.JSON); + assertThat(actual).isEmpty(); + }); + + test("Does a complete round-trip on PartialTodo", () -> { + var json = serializer.serializePartialTodo(SomeTodo.PARTIAL_COMPLETE); + var actual = serializer.deserializePartialTodo(json); + assertThat(actual).contains(SomeTodo.PARTIAL_COMPLETE); + }); + } + + public void serializationOfAPOSTedPartialTodo() { + test("Serializes a POSTed PartialTodo to json", () -> { + var actual = serializer.serializePartialTodo(SomeTodo.PARTIAL_POST); + assertThat(actual) + .contains("\"title\":\"title\"") + .doesNotContain("\"id\":") + .doesNotContain("\"url\":") + .doesNotContain("\"completed\":") + .doesNotContain("\"order\":"); + }); + + test("Deserializes a POSTed PartialTodo from json", () -> { + var actual = serializer.deserializePartialTodo(SomeTodo.SERIALIZED_PARTIAL_POST); + assertThat(actual).contains(SomeTodo.PARTIAL_POST); + }); + + test("Does a complete round-trip on a POSTed PartialTodo", () -> { + var json = serializer.serializePartialTodo(SomeTodo.PARTIAL_POST); + var actual = serializer.deserializePartialTodo(json); + assertThat(actual).contains(SomeTodo.PARTIAL_POST); + }); + } + + public void serializationOfAListOfTodos() { + + test("Serializes a list of Todos to json", () -> { + var actual = serializer.serializeTodos(ListOfTodos.LIST); + assertThat(actual) + .contains(SomeTodo.SERIALIZED) + .contains(AnotherTodo.SERIALIZED); + }); + + test("Deserializes a list of Todos from json", () -> { + var actual = serializer.deserializeTodos(ListOfTodos.SERIALIZED); + assertThat(actual).containsExactlyElementsOf(ListOfTodos.LIST); + }); + + test("Deserialization of a list of Todos returns an empty list when json is invalid", () -> { + var invalidJson = SomeTodo.SERIALIZED; // but not a list + var actual = serializer.deserializeTodos(invalidJson); + assertThat(actual).isEmpty(); + }); + + test("Does a complete round-trip on lists of Todos", () -> { + var json = serializer.serializeTodos(ListOfTodos.LIST); + var actual = serializer.deserializeTodos(json); + assertThat(actual).isEqualTo(ListOfTodos.LIST); + }); + } + + public void serializationOfAUuid() { + + test("Serializes a UUID to json", () -> { + var actual = serializer.serializeUuid(SomeTodo.ID); + assertThat(actual).isEqualTo("\"" + SomeTodo.ID.toString() + "\""); + }); + + test("Deserializes a UUID from json", () -> { + var actual = serializer.deserializeUuid(SomeTodo.ID.toString()); + assertThat(actual).contains(SomeTodo.ID); + }); + + test("Deserialization of a UUID returns none when json is invalid", () -> { + var actual = serializer.deserializeUuid(Invalid.ID); + assertThat(actual).isEmpty(); + }); + + test("Does a complete round-trip on UUID", () -> { + var json = serializer.serializeUuid(SomeTodo.ID); + var actual = serializer.deserializeUuid(json); + assertThat(actual).contains(SomeTodo.ID); + }); + } +} diff --git a/src/test/java/nl/jqno/paralleljava/app/server/SparkServerTest.java b/src/test/java/nl/jqno/paralleljava/app/server/SparkServerTest.java new file mode 100644 index 0000000..cf836be --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/server/SparkServerTest.java @@ -0,0 +1,102 @@ +package nl.jqno.paralleljava.app.server; + +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; +import io.vavr.collection.List; +import nl.jqno.paralleljava.app.logging.NopLogger; +import nl.jqno.paralleljava.app.controller.StubController; +import nl.jqno.picotest.Test; +import spark.Spark; + +import java.util.function.IntSupplier; +import java.util.function.Supplier; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; + +public class SparkServerTest extends Test { + + private static final int PORT = 1337; + private static final String ENDPOINT = "/todo"; + private static final String ENDPOINT_WITH_ID = ENDPOINT + "/some-id"; + private final RequestSpecification when = given().port(PORT).when(); + private StubController underlying; + private Server server; + + public void server() { + underlying = new StubController(); + server = new SparkServer(ENDPOINT, PORT, underlying, c -> new NopLogger()); + + beforeAll(() -> { + server.run(); + Spark.awaitInitialization(); + }); + + beforeEach(() -> { + underlying.clear(); + }); + + afterAll(Spark::stop); + + test("CORS Access-Control-AllowOrigin header is included", + this::corsRequestsHeader); + test("OPTION request", + this::corsOptionsRequest); + + test("GET works", + () -> checkEndpoint(() -> underlying.calledGet, () -> when.get(ENDPOINT))); + test("GET with id works", + () -> checkEndpoint(() -> underlying.calledGetWithId, () -> when.get(ENDPOINT_WITH_ID))); + test("POST works", + () -> checkEndpoint(() -> underlying.calledPost, () -> when.post(ENDPOINT))); + test("PATCH with id works", + () -> checkEndpoint(() -> underlying.calledPatchWithId, () -> when.patch(ENDPOINT_WITH_ID))); + test("DELETE works", + () -> checkEndpoint(() -> underlying.calledDelete, () -> when.delete(ENDPOINT))); + test("DELETE with id works", + () -> checkEndpoint(() -> underlying.calledDeleteWithId, () -> when.delete(ENDPOINT_WITH_ID))); + } + + private void corsRequestsHeader() { + var requests = List.of( + when.get(ENDPOINT), + when.get(ENDPOINT_WITH_ID), + when.post(ENDPOINT), + when.patch(ENDPOINT_WITH_ID), + when.delete(ENDPOINT), + when.delete(ENDPOINT_WITH_ID)); + requests.forEach(r -> r.then().header("Access-Control-Allow-Origin", "*")); + } + + private void corsOptionsRequest() { + var headers = "XXX"; + var methods = "YYY"; + when + .header("Access-Control-Request-Headers", headers) + .header("Access-Control-Request-Method", methods) + .options(ENDPOINT) + .then() + .header("Access-Control-Allow-Headers", headers) + .header("Access-Control-Allow-Methods", methods); + } + + private void checkEndpoint(IntSupplier calledEndpoint, Supplier r) { + r.get().then().statusCode(200); + assertSingleCall(calledEndpoint); + + underlying.clear(); + underlying.nextRequestFails4xx = true; + r.get().then().statusCode(400); + assertSingleCall(calledEndpoint); + + underlying.clear(); + underlying.nextRequestFails5xx = true; + r.get().then().statusCode(500); + assertSingleCall(calledEndpoint); + } + + private void assertSingleCall(IntSupplier calledEndpoint) { + assertThat(calledEndpoint.getAsInt()).isEqualTo(1); + assertThat(underlying.calledTotal()).isEqualTo(1); + } +} diff --git a/src/test/java/nl/jqno/paralleljava/app/server/StubServer.java b/src/test/java/nl/jqno/paralleljava/app/server/StubServer.java new file mode 100644 index 0000000..3681aab --- /dev/null +++ b/src/test/java/nl/jqno/paralleljava/app/server/StubServer.java @@ -0,0 +1,13 @@ +package nl.jqno.paralleljava.app.server; + +public class StubServer implements Server { + public int calledRun = 0; + + public void clear() { + calledRun = 0; + } + + public void run() { + calledRun += 1; + } +} diff --git a/src/test/resources/simplelogger.properties b/src/test/resources/simplelogger.properties new file mode 100644 index 0000000..6640220 --- /dev/null +++ b/src/test/resources/simplelogger.properties @@ -0,0 +1 @@ +org.slf4j.simpleLogger.defaultLogLevel=WARN \ No newline at end of file diff --git a/system.properties b/system.properties new file mode 100644 index 0000000..9146af5 --- /dev/null +++ b/system.properties @@ -0,0 +1 @@ +java.runtime.version=11