diff --git a/src/main/java/nouritsu/Dao.java b/src/main/java/nouritsu/Dao.java index 01a3ae6..3b08470 100644 --- a/src/main/java/nouritsu/Dao.java +++ b/src/main/java/nouritsu/Dao.java @@ -1,7 +1,9 @@ package nouritsu; import io.vavr.collection.HashSet; +import io.vavr.collection.List; import io.vavr.control.Either; +import io.vavr.control.Option; import nouritsu.types.Resp; import nouritsu.types.Section; import nouritsu.types.ShoppingItem; @@ -13,6 +15,11 @@ import java.util.function.Function; public class Dao { private final JedisPool pool; + // Prefixes for the redis keys because other applications might also be using the redis + private static final String PREFIX = "nouritsu_"; + private static final String USER_PREFIX = PREFIX + "user_"; + private static final String STORE_PREFIX = PREFIX + "store_"; + private static final String ITEM_PREFIX = PREFIX + "item_"; Dao() { pool = new JedisPool(); @@ -24,31 +31,72 @@ public class Dao { } } + private Option getItem(String name) { + return Option.of(withRedis(redis -> redis.get(ITEM_PREFIX + name))) + .flatMap(section -> Section.tryParse(section).toOption()) + .map(section -> new ShoppingItem(name, section)); + } + public String clearList(String id) { - withRedis(redis -> redis.del(id)); + withRedis(redis -> redis.del(USER_PREFIX + 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) { + private Option> getSet(String key) { 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))) + redis.exists(key) + ? Option.some(HashSet.ofAll(redis.smembers(key))) + : Option.none() ); } + public Either> getList(String id) { + return getSet(USER_PREFIX + id) + .map(items -> items.map(this::getItem).map(Option::get)) + .toEither(Resp.NOT_FOUND.apply("No list found for user “%s”".formatted(id))); + } + + public Either getStore(String name) { + return withRedis(redis -> Option.of(redis.lrange(STORE_PREFIX + name, 0, 1000L)) + .toEither(Resp.BAD_REQUEST.apply("Store %s not found".formatted(name))) + .map(sections -> sections.stream() + .map(Section::tryParse) + // Safe; elements in persistence must have a valid section + .map(Either::get) + .collect(List.collector())) + .map(sections -> new Store(name, sections)) + ); + } + + // Check if an item is known and add it to the list if it is. public Either addToList(String id, String item) { - return Either.right("Added “%s“ to list of user “%s”".formatted(item, id)); + return getItem(item).map(it -> { + withRedis(redis -> redis.sadd(USER_PREFIX + id, item)); + return "Added “%s“ to list of user “%s”".formatted(it, id); + }).toEither(Resp.BAD_REQUEST.apply("Item %s not found".formatted(item))); } public String addItem(ShoppingItem item) { + withRedis(redis -> redis.set(ITEM_PREFIX + item.name(), item.section().name())); 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())); + public String addStore(Store store) { + // just overwrite it if we already know that store + withRedis(redis -> { + redis.del(STORE_PREFIX + store.name()); + redis.rpush( + STORE_PREFIX + store.name(), + store.sections().map(Section::name).toJavaArray(String[]::new) + ); + // There needs to be some kind of return type unless I make a second util function with Consumer. + // Why doesn’t Java just have a `Unit` type? + return true; + } + ); + return "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 4ec7d21..798083e 100644 --- a/src/main/java/nouritsu/Nouritsu.java +++ b/src/main/java/nouritsu/Nouritsu.java @@ -5,6 +5,7 @@ import fi.iki.elonen.NanoHTTPD.Response.Status; import io.vavr.Tuple; import io.vavr.Tuple2; import io.vavr.collection.HashMap; +import io.vavr.collection.HashSet; import io.vavr.collection.Map; import io.vavr.control.Either; import io.vavr.control.Try; @@ -30,7 +31,10 @@ public class Nouritsu extends NanoHTTPD { 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()), + err -> { + err.printStackTrace(); + return newFixedLengthResponse(Status.INTERNAL_ERROR, MIME_PLAINTEXT, err.getMessage()); + }, response -> newFixedLengthResponse(response.status(), MIME_PLAINTEXT, response.text()) ); } @@ -38,9 +42,10 @@ public class Nouritsu extends NanoHTTPD { private static final String HELP = """ Valid endpoints are (not case sensitive): /addStore/?name=storeName§ions=first,second,third - /addItem/?name=itemName&category=itemCategory + /addItem/?name=itemName§ion=itemSection /addToList/?user=userId&item=itemName - /getList/?user=userId&store=optionalStoreName + /getList/?user=userId + /getListForStore/?user=userId&store=StoreName /clearList/?user=userId """; @@ -52,6 +57,7 @@ public class Nouritsu extends NanoHTTPD { case "additem" -> Nouritsu::addItem; case "addtolist" -> Nouritsu::addToList; case "getlist" -> Nouritsu::getList; + case "getlistforstore" -> Nouritsu::getListOrdered; case "clearlist" -> Nouritsu::clearList; default -> (_1, _2) -> Resp.NOT_FOUND.apply(HELP); }; @@ -83,6 +89,31 @@ public class Nouritsu extends NanoHTTPD { .fold(Function.identity(), Resp.OK); } + private static Resp getListOrdered(Map params, Dao dao) { + return getAsTuple(params, "user", "store") + .flatMap(t -> t.apply((userId, storeName) -> + zip(dao.getList(userId), dao.getStore(storeName)) + )) + .map(t -> t.apply(Nouritsu::sortBySection)) + .fold(Function.identity(), Resp.OK); + } + + private static String sortBySection(HashSet items, Store store) { + var itemsBySection = items.groupBy(ShoppingItem::section); + var sorted = store.sections().flatMap(s -> itemsBySection.get(s).getOrElse(HashSet.empty())); + var rest = items.removeAll(sorted); + var sortedString = sorted.map(ShoppingItem::name).mkString(", "); + if (rest.isEmpty()) { + return sortedString; + } + return sortedString + rest.mkString("\nItems not available or not classified in this store: ", ", ", ""); + } + + // Return a Tuple2 containing the values of two eithers if both were Either.Right + private static Either> zip(Either first, Either second) { + return first.flatMap(f -> second.map(s -> Tuple.of(f, s))); + } + 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: @@ -92,16 +123,16 @@ public class Nouritsu extends NanoHTTPD { } private static Resp addItem(Map params, Dao dao) { - return getAsTuple(params, "name", "category") + return getAsTuple(params, "name", "section") .flatMap(t -> t.apply(ShoppingItem::tryParse)) .map(dao::addItem) .fold(Function.identity(), Resp.CREATED); } private static Resp addStore(Map params, Dao dao) { - return getAsTuple(params, "name", "categories") + return getAsTuple(params, "name", "sections") .flatMap(t -> t.apply(Store::tryParse)) - .flatMap(dao::addStore) + .map(dao::addStore) .fold(Function.identity(), Resp.CREATED); } diff --git a/src/main/java/nouritsu/types/Section.java b/src/main/java/nouritsu/types/Section.java index 65002e7..42174c8 100644 --- a/src/main/java/nouritsu/types/Section.java +++ b/src/main/java/nouritsu/types/Section.java @@ -11,6 +11,7 @@ public enum Section { RICE, MEAT, FISH, + FROZEN, DAIRY, HYGIENE, BEVERAGES;