diff --git a/build.gradle.kts b/build.gradle.kts index 51e88ed..7a3b289 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,5 +36,7 @@ dependencies { implementation("org.nanohttpd:nanohttpd:2.3.1") implementation("redis.clients:jedis:3.3.0") implementation("io.vavr:vavr:1.0.0-alpha-3") + implementation("io.vavr:vavr-jackson:1.0.0-alpha-3") + implementation("com.fasterxml.jackson:jackson-base:2.11.1") testImplementation("junit", "junit", "4.12") } diff --git a/src/main/java/nouritsu/Dao.java b/src/main/java/nouritsu/Dao.java index f7f254a..6de8f79 100644 --- a/src/main/java/nouritsu/Dao.java +++ b/src/main/java/nouritsu/Dao.java @@ -13,6 +13,9 @@ import redis.clients.jedis.JedisPool; import java.util.function.Function; +import static nouritsu.Serde.deserialize; +import static nouritsu.Serde.serialize; + public class Dao { private final JedisPool pool; // Prefixes for the redis keys because other applications might also be using the redis @@ -33,15 +36,14 @@ 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)); + .map(item -> deserialize(item, ShoppingItem.class)); } public String clearList(String 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); + return created("Cleared list of user “%s“").formatted(id); } // The .exists() check is necessary because smembers throws an exception instead of returning null or an empty set. @@ -60,41 +62,37 @@ public class Dao { } public Either getStore(String name) { - return withRedis(redis -> Option.of(redis.lrange(STORE_PREFIX + name, 0, 1000L)) + return withRedis(redis -> Option.of(redis.get(STORE_PREFIX + name))) .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)) - ); + .map(s -> deserialize(s, Store.class)); } // Check if an item is known and add it to the list if it is. public Either addToList(String id, String item) { return getItem(item).map(it -> { withRedis(redis -> redis.sadd(USER_PREFIX + id, item)); - return "Added “%s“ to list of user “%s”".formatted(it, id); + return created("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”" + withRedis(redis -> redis.set(ITEM_PREFIX + item.name(), serialize(item))); + return created("Registered new item “%s” under the section “%s”") .formatted(item.name(), item.section().name().toLowerCase()); } + private String created(String msg) { + return "{\"msg\": \"%s\"}".formatted(msg); + } + public String addStore(Store store) { // just overwrite it if we already know that store withRedis(redis -> - redis.del(STORE_PREFIX + store.name()) + // <- this plus doesn’t actually add anything. + redis.del(STORE_PREFIX + store.name()) + // This plus doesn’t actually add anything. // It just allows us to do both steps in one statement in the lambda body. - redis.rpush( - STORE_PREFIX + store.name(), - store.sections().map(Section::name).toJavaArray(String[]::new) - ) + + redis.set(STORE_PREFIX + store.name(), serialize(store)) ); - return "Registered new store “%s” with the sections [%s]".formatted(store.name(), store.sections()); + return created("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 5688df5..aaf9e24 100644 --- a/src/main/java/nouritsu/Nouritsu.java +++ b/src/main/java/nouritsu/Nouritsu.java @@ -9,6 +9,7 @@ import io.vavr.collection.HashSet; import io.vavr.collection.Map; import io.vavr.control.Either; import io.vavr.control.Try; +import nouritsu.types.ListWithRest; import nouritsu.types.Resp; import nouritsu.types.ShoppingItem; import nouritsu.types.Store; @@ -35,7 +36,7 @@ public class Nouritsu extends NanoHTTPD { err.printStackTrace(); return newFixedLengthResponse(Status.INTERNAL_ERROR, MIME_PLAINTEXT, err.getMessage()); }, - response -> newFixedLengthResponse(response.status(), MIME_PLAINTEXT, response.text()) + response -> newFixedLengthResponse(response.status(), "application/json", response.text()) ); } @@ -85,7 +86,7 @@ public class Nouritsu extends NanoHTTPD { private static Resp getList(Map params, Dao dao) { return getFromParams(params, "user") .flatMap(dao::getList) - .map(shoppingItems -> shoppingItems.mkString(",")) + .map(Serde::serialize) .fold(Function.identity(), Resp.OK); } @@ -95,6 +96,7 @@ public class Nouritsu extends NanoHTTPD { zip(dao.getList(userId), dao.getStore(storeName)) )) .map(t -> t.apply(Nouritsu::sortBySection)) + .map(Serde::serialize) .fold(Function.identity(), Resp.OK); } @@ -106,15 +108,10 @@ public class Nouritsu extends NanoHTTPD { // This kind of breaks the style, // but I can’t come up with a more elegant solution, // so lots of locals it is. - private static String sortBySection(HashSet items, Store store) { + private static ListWithRest sortBySection(HashSet items, Store store) { final var itemsBySection = items.groupBy(ShoppingItem::section); final var sorted = store.sections().flatMap(s -> itemsBySection.get(s).getOrElse(HashSet.empty())); - final var rest = items.removeAll(sorted); - final var formattedItems = sorted.map(ShoppingItem::name).mkString(", "); - if (rest.isEmpty()) { - return formattedItems; - } - return formattedItems + rest.mkString("\nItems not available or not classified in this store: ", ", ", ""); + return new ListWithRest(sorted, items.removeAll(sorted)); } private static Resp addToList(Map params, Dao dao) { @@ -148,11 +145,7 @@ public class Nouritsu extends NanoHTTPD { return zip(getFromParams(params, k1), getFromParams(params, k2)); } - public static void main(String[] args) { - try { - new Nouritsu(14523, new Dao()); - } catch (IOException e) { - e.printStackTrace(); - } + public static void main(String[] args) throws IOException { + new Nouritsu(14523, new Dao()); } } diff --git a/src/main/java/nouritsu/Serde.java b/src/main/java/nouritsu/Serde.java new file mode 100644 index 0000000..a24413a --- /dev/null +++ b/src/main/java/nouritsu/Serde.java @@ -0,0 +1,24 @@ +package nouritsu; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.vavr.control.Try; +import io.vavr.jackson.datatype.VavrModule; + +/** + * *Ser*ialize and *de*serialize. Name stolen from the Rust framework. + */ +public class Serde { + private static final ObjectMapper MAPPER = new ObjectMapper().registerModule(new VavrModule()); + + public static T deserialize(String s, Class clazz) { + return Try.of(() -> MAPPER.readValue(s, clazz)) + // I don’t see a case where this can actually happen + .getOrElseThrow((e) -> new IllegalArgumentException("Could not deserialize %s as %s".formatted(s, clazz), e)); + } + + public static String serialize(T obj) { + return Try.of(() -> MAPPER.writeValueAsString(obj)) + // unreachable, but checked exceptions force me to do this + .getOrElseThrow((e) -> new IllegalArgumentException("Could not serialize " + obj.toString(), e)); + } +} diff --git a/src/main/java/nouritsu/types/ListWithRest.java b/src/main/java/nouritsu/types/ListWithRest.java new file mode 100644 index 0000000..dce48af --- /dev/null +++ b/src/main/java/nouritsu/types/ListWithRest.java @@ -0,0 +1,14 @@ +package nouritsu.types; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.vavr.collection.HashSet; +import io.vavr.collection.Seq; + +/** + * A sorted shopping list for a store. + * Rest contains the items that can’t be purchased at the selected store. + */ +public record ListWithRest( + @JsonProperty("items") Seq items, + @JsonProperty("rest") HashSet rest +) {} diff --git a/src/main/java/nouritsu/types/ShoppingItem.java b/src/main/java/nouritsu/types/ShoppingItem.java index e5a82c9..832cb81 100644 --- a/src/main/java/nouritsu/types/ShoppingItem.java +++ b/src/main/java/nouritsu/types/ShoppingItem.java @@ -1,8 +1,12 @@ package nouritsu.types; +import com.fasterxml.jackson.annotation.JsonProperty; import io.vavr.control.Either; -public record ShoppingItem(String name, Section section) { +public record ShoppingItem( + @JsonProperty("name") String name, + @JsonProperty("section") 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 aad4af0..ef3c24c 100644 --- a/src/main/java/nouritsu/types/Store.java +++ b/src/main/java/nouritsu/types/Store.java @@ -1,5 +1,6 @@ package nouritsu.types; +import com.fasterxml.jackson.annotation.JsonProperty; import io.vavr.collection.List; import io.vavr.collection.Seq; import io.vavr.control.Either; @@ -7,7 +8,10 @@ import io.vavr.control.Either; /** * A store with an ordered list of sections from entrance to exit. */ -public record Store(String name, Seq
sections) { +public record Store( + @JsonProperty("name") String name, + @JsonProperty("sections") 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") + Section.valuesAsString()))