Return json for successful API calls

This commit is contained in:
kageru 2020-07-09 21:57:51 +02:00
parent 9f8e7dcc1a
commit e58754b03e
Signed by: kageru
GPG Key ID: 8282A2BEA4ADA3D2
7 changed files with 76 additions and 37 deletions

View File

@ -36,5 +36,7 @@ dependencies {
implementation("org.nanohttpd:nanohttpd:2.3.1") implementation("org.nanohttpd:nanohttpd:2.3.1")
implementation("redis.clients:jedis:3.3.0") implementation("redis.clients:jedis:3.3.0")
implementation("io.vavr:vavr:1.0.0-alpha-3") 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") testImplementation("junit", "junit", "4.12")
} }

View File

@ -13,6 +13,9 @@ import redis.clients.jedis.JedisPool;
import java.util.function.Function; import java.util.function.Function;
import static nouritsu.Serde.deserialize;
import static nouritsu.Serde.serialize;
public class Dao { public class Dao {
private final JedisPool pool; private final JedisPool pool;
// Prefixes for the redis keys because other applications might also be using the redis // Prefixes for the redis keys because other applications might also be using the redis
@ -33,15 +36,14 @@ public class Dao {
private Option<ShoppingItem> getItem(String name) { private Option<ShoppingItem> getItem(String name) {
return Option.of(withRedis(redis -> redis.get(ITEM_PREFIX + name))) return Option.of(withRedis(redis -> redis.get(ITEM_PREFIX + name)))
.flatMap(section -> Section.tryParse(section).toOption()) .map(item -> deserialize(item, ShoppingItem.class));
.map(section -> new ShoppingItem(name, section));
} }
public String clearList(String id) { public String clearList(String id) {
withRedis(redis -> redis.del(USER_PREFIX + id)); withRedis(redis -> redis.del(USER_PREFIX + id));
// Just always return okay, even if we didnt have data to begin with. // Just always return okay, even if we didnt have data to begin with.
// Idempotency yay \o/ // 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. // 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<Resp, Store> getStore(String name) { public Either<Resp, Store> 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))) .toEither(Resp.BAD_REQUEST.apply("Store %s not found".formatted(name)))
.map(sections -> sections.stream() .map(s -> deserialize(s, Store.class));
.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. // Check if an item is known and add it to the list if it is.
public Either<Resp, String> addToList(String id, String item) { public Either<Resp, String> addToList(String id, String item) {
return getItem(item).map(it -> { return getItem(item).map(it -> {
withRedis(redis -> redis.sadd(USER_PREFIX + id, item)); 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))); }).toEither(Resp.BAD_REQUEST.apply("Item %s not found".formatted(item)));
} }
public String addItem(ShoppingItem item) { public String addItem(ShoppingItem item) {
withRedis(redis -> redis.set(ITEM_PREFIX + item.name(), item.section().name())); withRedis(redis -> redis.set(ITEM_PREFIX + item.name(), serialize(item)));
return "Registered new item “%s” under the section “%s”" return created("Registered new item “%s” under the section “%s”")
.formatted(item.name(), item.section().name().toLowerCase()); .formatted(item.name(), item.section().name().toLowerCase());
} }
private String created(String msg) {
return "{\"msg\": \"%s\"}".formatted(msg);
}
public String addStore(Store store) { public String addStore(Store store) {
// just overwrite it if we already know that store // just overwrite it if we already know that store
withRedis(redis -> withRedis(redis ->
redis.del(STORE_PREFIX + store.name()) + // <- this plus doesnt actually add anything. redis.del(STORE_PREFIX + store.name())
// This plus doesnt actually add anything.
// It just allows us to do both steps in one statement in the lambda body. // It just allows us to do both steps in one statement in the lambda body.
redis.rpush( + redis.set(STORE_PREFIX + store.name(), serialize(store))
STORE_PREFIX + store.name(),
store.sections().map(Section::name).toJavaArray(String[]::new)
)
); );
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());
} }
} }

View File

@ -9,6 +9,7 @@ import io.vavr.collection.HashSet;
import io.vavr.collection.Map; import io.vavr.collection.Map;
import io.vavr.control.Either; import io.vavr.control.Either;
import io.vavr.control.Try; import io.vavr.control.Try;
import nouritsu.types.ListWithRest;
import nouritsu.types.Resp; import nouritsu.types.Resp;
import nouritsu.types.ShoppingItem; import nouritsu.types.ShoppingItem;
import nouritsu.types.Store; import nouritsu.types.Store;
@ -35,7 +36,7 @@ public class Nouritsu extends NanoHTTPD {
err.printStackTrace(); err.printStackTrace();
return newFixedLengthResponse(Status.INTERNAL_ERROR, MIME_PLAINTEXT, err.getMessage()); 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<String, String> params, Dao dao) { private static Resp getList(Map<String, String> params, Dao dao) {
return getFromParams(params, "user") return getFromParams(params, "user")
.flatMap(dao::getList) .flatMap(dao::getList)
.map(shoppingItems -> shoppingItems.mkString(",")) .map(Serde::serialize)
.fold(Function.identity(), Resp.OK); .fold(Function.identity(), Resp.OK);
} }
@ -95,6 +96,7 @@ public class Nouritsu extends NanoHTTPD {
zip(dao.getList(userId), dao.getStore(storeName)) zip(dao.getList(userId), dao.getStore(storeName))
)) ))
.map(t -> t.apply(Nouritsu::sortBySection)) .map(t -> t.apply(Nouritsu::sortBySection))
.map(Serde::serialize)
.fold(Function.identity(), Resp.OK); .fold(Function.identity(), Resp.OK);
} }
@ -106,15 +108,10 @@ public class Nouritsu extends NanoHTTPD {
// This kind of breaks the style, // This kind of breaks the style,
// but I cant come up with a more elegant solution, // but I cant come up with a more elegant solution,
// so lots of locals it is. // so lots of locals it is.
private static String sortBySection(HashSet<ShoppingItem> items, Store store) { private static ListWithRest sortBySection(HashSet<ShoppingItem> items, Store store) {
final var itemsBySection = items.groupBy(ShoppingItem::section); final var itemsBySection = items.groupBy(ShoppingItem::section);
final var sorted = store.sections().flatMap(s -> itemsBySection.get(s).getOrElse(HashSet.empty())); final var sorted = store.sections().flatMap(s -> itemsBySection.get(s).getOrElse(HashSet.empty()));
final var rest = items.removeAll(sorted); return new ListWithRest(sorted, 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: ", ", ", "");
} }
private static Resp addToList(Map<String, String> params, Dao dao) { private static Resp addToList(Map<String, String> params, Dao dao) {
@ -148,11 +145,7 @@ public class Nouritsu extends NanoHTTPD {
return zip(getFromParams(params, k1), getFromParams(params, k2)); return zip(getFromParams(params, k1), getFromParams(params, k2));
} }
public static void main(String[] args) { public static void main(String[] args) throws IOException {
try { new Nouritsu(14523, new Dao());
new Nouritsu(14523, new Dao());
} catch (IOException e) {
e.printStackTrace();
}
} }
} }

View File

@ -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> T deserialize(String s, Class<T> clazz) {
return Try.of(() -> MAPPER.readValue(s, clazz))
// I dont see a case where this can actually happen
.getOrElseThrow((e) -> new IllegalArgumentException("Could not deserialize %s as %s".formatted(s, clazz), e));
}
public static <T> 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));
}
}

View File

@ -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 cant be purchased at the selected store.
*/
public record ListWithRest(
@JsonProperty("items") Seq<ShoppingItem> items,
@JsonProperty("rest") HashSet<ShoppingItem> rest
) {}

View File

@ -1,8 +1,12 @@
package nouritsu.types; package nouritsu.types;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.vavr.control.Either; 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<Resp, ShoppingItem> tryParse(String name, String category) { public static Either<Resp, ShoppingItem> tryParse(String name, String category) {
return Section.tryParse(category) return Section.tryParse(category)
.map(c -> new ShoppingItem(name, c)) .map(c -> new ShoppingItem(name, c))

View File

@ -1,5 +1,6 @@
package nouritsu.types; package nouritsu.types;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.vavr.collection.List; import io.vavr.collection.List;
import io.vavr.collection.Seq; import io.vavr.collection.Seq;
import io.vavr.control.Either; 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. * A store with an ordered list of sections from entrance to exit.
*/ */
public record Store(String name, Seq<Section>sections) { public record Store(
@JsonProperty("name") String name,
@JsonProperty("sections") Seq<Section> sections
) {
public static Either<Resp, Store> tryParse(String name, String sections) { public static Either<Resp, Store> tryParse(String name, String sections) {
return Either.traverse(List.of(sections.split(",")), Section::tryParse) return Either.traverse(List.of(sections.split(",")), Section::tryParse)
.mapLeft(errors -> Resp.BAD_REQUEST.apply(errors.mkString("\n") + Section.valuesAsString())) .mapLeft(errors -> Resp.BAD_REQUEST.apply(errors.mkString("\n") + Section.valuesAsString()))