From 50f1ca6757af75fd88cb0960293daf2cefd66b29 Mon Sep 17 00:00:00 2001 From: kageru Date: Sat, 4 Jul 2020 00:38:18 +0200 Subject: [PATCH] finish http handling, start persistence work --- build.gradle.kts | 1 + src/main/java/nouritsu/Dao.java | 52 +++++++++-- src/main/java/nouritsu/Nouritsu.java | 90 ++++++++++++------- src/main/java/nouritsu/types/Category.java | 12 --- src/main/java/nouritsu/types/Message.java | 11 --- src/main/java/nouritsu/types/Resp.java | 11 +++ src/main/java/nouritsu/types/Section.java | 21 +++++ .../java/nouritsu/types/ShoppingItem.java | 7 +- src/main/java/nouritsu/types/Store.java | 11 ++- 9 files changed, 149 insertions(+), 67 deletions(-) delete mode 100644 src/main/java/nouritsu/types/Category.java delete mode 100644 src/main/java/nouritsu/types/Message.java create mode 100644 src/main/java/nouritsu/types/Resp.java create mode 100644 src/main/java/nouritsu/types/Section.java diff --git a/build.gradle.kts b/build.gradle.kts index 4a9f046..51e88ed 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,6 +34,7 @@ application { dependencies { implementation("org.nanohttpd:nanohttpd:2.3.1") + implementation("redis.clients:jedis:3.3.0") implementation("io.vavr:vavr:1.0.0-alpha-3") testImplementation("junit", "junit", "4.12") } diff --git a/src/main/java/nouritsu/Dao.java b/src/main/java/nouritsu/Dao.java index c6b4821..01a3ae6 100644 --- a/src/main/java/nouritsu/Dao.java +++ b/src/main/java/nouritsu/Dao.java @@ -1,18 +1,54 @@ package nouritsu; -import io.vavr.collection.List; +import io.vavr.collection.HashSet; import io.vavr.control.Either; -import nouritsu.types.Message; +import nouritsu.types.Resp; +import nouritsu.types.Section; +import nouritsu.types.ShoppingItem; +import nouritsu.types.Store; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; -import java.util.stream.Collectors; +import java.util.function.Function; public class Dao { - public Either clearList(String id) { - return Either.right("Cleared list of user “%s“".formatted(id)); + private final JedisPool pool; + + Dao() { + pool = new JedisPool(); } - public Either getList(String id) { - return Either.right(List.of("first", "second", "third") - .collect(Collectors.joining(","))); + private T withRedis(Function f) { + try (var redis = pool.getResource()) { + return f.apply(redis); + } + } + + public String clearList(String id) { + withRedis(redis -> redis.del(id)); + // Just always return okay, even if we didn’t have data to begin with. + // Idempotency yay \o/ + return "Cleared list of user “%s“".formatted(id); + } + + public Either> getList(String id) { + return withRedis(redis -> + redis.exists(id) + ? Either.right(HashSet.ofAll(redis.smembers(id)).map(s -> new ShoppingItem(s, Section.CANS))) + : Either.left(Resp.NOT_FOUND.apply("No list found for user “%s”".formatted(id))) + ); + } + + public Either addToList(String id, String item) { + return Either.right("Added “%s“ to list of user “%s”".formatted(item, id)); + } + + public String addItem(ShoppingItem item) { + return "Registered new item “%s” under the section “%s”" + .formatted(item.name(), item.section().name().toLowerCase()); + } + + public Either addStore(Store store) { + return Either.right("Registered new store “%s” with the sections [%s]".formatted(store.name(), store.sections())); } } diff --git a/src/main/java/nouritsu/Nouritsu.java b/src/main/java/nouritsu/Nouritsu.java index b5653fd..df65810 100644 --- a/src/main/java/nouritsu/Nouritsu.java +++ b/src/main/java/nouritsu/Nouritsu.java @@ -2,15 +2,18 @@ package nouritsu; import fi.iki.elonen.NanoHTTPD; import fi.iki.elonen.NanoHTTPD.Response.Status; +import io.vavr.Tuple; import io.vavr.Tuple2; import io.vavr.collection.HashMap; -import io.vavr.collection.List; import io.vavr.collection.Map; import io.vavr.control.Either; import io.vavr.control.Try; -import nouritsu.types.Message; +import nouritsu.types.Resp; +import nouritsu.types.ShoppingItem; +import nouritsu.types.Store; import java.io.IOException; +import java.util.function.BiFunction; import java.util.function.Function; public class Nouritsu extends NanoHTTPD { @@ -26,6 +29,7 @@ public class Nouritsu extends NanoHTTPD { public Response serve(IHTTPSession session) { return Try.of(() -> processRequest(session, dao)) .fold( + // This won’t be exposed to the outside world, so returning the error message is fine err -> newFixedLengthResponse(Status.INTERNAL_ERROR, MIME_PLAINTEXT, err.getMessage()), response -> newFixedLengthResponse(response.status(), MIME_PLAINTEXT, response.text()) ); @@ -35,61 +39,81 @@ public class Nouritsu extends NanoHTTPD { Valid endpoints are (not case sensitive): /addStore/?name=storeName§ions=first,second,third /addItem/?name=itemName&category=itemCategory - /addToList/?user=userId&itemName + /addToList/?user=userId&item=itemName /getList/?user=userId&store=optionalStoreName /clearList/?user=userId """; - private static Message processRequest(IHTTPSession session, Dao dao) { - var params = extractParams(session); - return switch (session.getUri().replaceAll("/", "").toLowerCase()) { - case "", "index.html" -> new Message(HELP, Status.OK); - case "addstore" -> addStore(params, dao); - case "additem" -> addItem(params, dao); - case "addtolist" -> addToList(params, dao); - case "getlist" -> getList(params, dao); - case "clearlist" -> clearList(params, dao); - default -> new Message(HELP, Status.NOT_FOUND); - }; + private static Resp processRequest(IHTTPSession session, Dao dao) { + // Type inference can’t handle this :feelsBadMan: + BiFunction, Dao, Resp> handler = + switch (session.getUri().replaceAll("/", "").toLowerCase()) { + case "addstore" -> Nouritsu::addStore; + case "additem" -> Nouritsu::addItem; + case "addtolist" -> Nouritsu::addToList; + case "getlist" -> Nouritsu::getList; + case "clearlist" -> Nouritsu::clearList; + default -> (_1, _2) -> Resp.NOT_FOUND.apply(HELP); + }; + return handler.apply(extractParams(session), dao); } - // Repack the parameters into vavr collections. - private static Map> extractParams(IHTTPSession session) { + // Repack the parameters into a vavr Map. + // Each parameter should only occur once per request. + private static Map extractParams(IHTTPSession session) { return session.getParameters() .entrySet() .stream() - .map(e -> new Tuple2<>(e.getKey(), List.ofAll(e.getValue()))) - .collect(HashMap.collector()); + .map(Tuple::fromEntry) + .collect(HashMap.collector()) + // guaranteed to be non-empty + .mapValues(list -> list.get(0)); } - private static Message clearList(Map> params, Dao dao) { + private static Resp clearList(Map params, Dao dao) { return getFromParams(params, "user") - .map(List::head) // save, the list can’t be empty - .flatMap(dao::clearList) - .fold(Function.identity(), Message.OK); + .map(dao::clearList) + .fold(Function.identity(), Resp.CREATED); } - private static Message getList(Map> params, Dao dao) { + private static Resp getList(Map params, Dao dao) { return getFromParams(params, "user") - .map(List::head) // save, the list can’t be empty .flatMap(dao::getList) - .fold(Function.identity(), Message.OK); + .map(shoppingItems -> shoppingItems.mkString(",")) + .fold(Function.identity(), Resp.OK); } - private static Message addToList(Map> params, Dao dao) { - return new Message("", Status.NOT_IMPLEMENTED); + private static Resp addToList(Map params, Dao dao) { + return getAsTuple(params, "user", "item") + // This is where Kotlin-like destructuring would be really nice to have: + // flatMap(id, item -> ...) + .flatMap(t -> dao.addToList(t._1, t._2)) + .fold(Function.identity(), Resp.CREATED); } - private static Message addItem(Map> params, Dao dao) { - return new Message("", Status.NOT_IMPLEMENTED); + private static Resp addItem(Map params, Dao dao) { + return getAsTuple(params, "name", "category") + .flatMap(t -> ShoppingItem.tryParse(t._1, t._2)) + .map(dao::addItem) + .fold(Function.identity(), Resp.CREATED); } - private static Message addStore(Map> params, Dao dao) { - return new Message("", Status.NOT_IMPLEMENTED); + private static Resp addStore(Map params, Dao dao) { + return getAsTuple(params, "name", "categories") + .flatMap(t -> Store.tryParse(t._1, t._2)) + .flatMap(dao::addStore) + .fold(Function.identity(), Resp.CREATED); } - private static Either> getFromParams(Map> params, String key) { - return params.get(key).toEither(new Message("Parameter “%s” not found".formatted(key), Status.NOT_FOUND)); + private static Either getFromParams(Map params, String key) { + return params.get(key).toEither(Resp.NOT_FOUND.apply("Parameter “%s” not found".formatted(key))); + } + + // Get k1 and k2 as a tuple in Either.Right or Either.Left if k1 or k2 is/are not in the map. + private static Either> getAsTuple(Map params, String k1, String k2) { + return getFromParams(params, k1) + .map(Tuple::of) + .flatMap(name -> getFromParams(params, k2).map(name::append)); } public static void main(String[] args) { diff --git a/src/main/java/nouritsu/types/Category.java b/src/main/java/nouritsu/types/Category.java deleted file mode 100644 index a285247..0000000 --- a/src/main/java/nouritsu/types/Category.java +++ /dev/null @@ -1,12 +0,0 @@ -package nouritsu.types; - -public enum Category { - CANS, - FRUITS, - VEGETABLES, - PASTA, - RICE, - MEAT, - DAIRY, - HYGIENE, -} diff --git a/src/main/java/nouritsu/types/Message.java b/src/main/java/nouritsu/types/Message.java deleted file mode 100644 index 4398c9a..0000000 --- a/src/main/java/nouritsu/types/Message.java +++ /dev/null @@ -1,11 +0,0 @@ -package nouritsu.types; - -import fi.iki.elonen.NanoHTTPD.Response.Status; -import io.vavr.Function1; - -public record Message(String text, Status status) { - public static Function1 OK = Function1.of(s -> new Message(s, Status.OK)); - public static Function1 BAD_REQUEST = Function1.of(s -> new Message(s, Status.BAD_REQUEST)); - public static Function1 CREATED = Function1.of(s -> new Message(s, Status.CREATED)); - public static Function1 NOT_FOUND = Function1.of(s -> new Message(s, Status.NOT_FOUND)); -} diff --git a/src/main/java/nouritsu/types/Resp.java b/src/main/java/nouritsu/types/Resp.java new file mode 100644 index 0000000..b105cb6 --- /dev/null +++ b/src/main/java/nouritsu/types/Resp.java @@ -0,0 +1,11 @@ +package nouritsu.types; + +import fi.iki.elonen.NanoHTTPD.Response.Status; +import io.vavr.Function1; + +public record Resp(String text, Status status) { + public static Function1 OK = Function1.of(s -> new Resp(s, Status.OK)); + public static Function1 BAD_REQUEST = Function1.of(s -> new Resp(s, Status.BAD_REQUEST)); + public static Function1 CREATED = Function1.of(s -> new Resp(s, Status.CREATED)); + public static Function1 NOT_FOUND = Function1.of(s -> new Resp(s, Status.NOT_FOUND)); +} diff --git a/src/main/java/nouritsu/types/Section.java b/src/main/java/nouritsu/types/Section.java new file mode 100644 index 0000000..d05217a --- /dev/null +++ b/src/main/java/nouritsu/types/Section.java @@ -0,0 +1,21 @@ +package nouritsu.types; + +import io.vavr.collection.Stream; +import io.vavr.control.Either; + +public enum Section { + CANS, + FRUITS, + VEGETABLES, + PASTA, + RICE, + MEAT, + DAIRY, + HYGIENE; + + public static Either tryParse(String catName) { + return Stream.of(Section.values()) + .find(c -> c.name().equalsIgnoreCase(catName)) + .toEither(Resp.BAD_REQUEST.apply("Category “%s” not found".formatted(catName))); + } +} diff --git a/src/main/java/nouritsu/types/ShoppingItem.java b/src/main/java/nouritsu/types/ShoppingItem.java index 9809916..23678d0 100644 --- a/src/main/java/nouritsu/types/ShoppingItem.java +++ b/src/main/java/nouritsu/types/ShoppingItem.java @@ -1,4 +1,9 @@ package nouritsu.types; -public record ShoppingItem(String name, Category category) { +import io.vavr.control.Either; + +public record ShoppingItem(String name, Section section) { + public static Either tryParse(String name, String category) { + return Section.tryParse(category).map(c -> new ShoppingItem(name, c)); + } } diff --git a/src/main/java/nouritsu/types/Store.java b/src/main/java/nouritsu/types/Store.java index 04b08ab..c0f23a2 100644 --- a/src/main/java/nouritsu/types/Store.java +++ b/src/main/java/nouritsu/types/Store.java @@ -1,9 +1,16 @@ package nouritsu.types; -import java.util.List; +import io.vavr.collection.List; +import io.vavr.collection.Seq; +import io.vavr.control.Either; /** * A store with an ordered list of sections from entrance to exit. */ -public record Store(String name, List sections) { +public record Store(String name, Seq
sections) { + public static Either tryParse(String name, String sections) { + return Either.traverse(List.of(sections.split(",")), Section::tryParse) + .mapLeft(errors -> Resp.BAD_REQUEST.apply(errors.mkString("\n"))) + .map(secs -> new Store(name, secs)); + } }