diff --git a/core/src/main/java/io/github/oasis/core/Game.java b/core/src/main/java/io/github/oasis/core/Game.java index a3abbb2d..c076a9f0 100644 --- a/core/src/main/java/io/github/oasis/core/Game.java +++ b/core/src/main/java/io/github/oasis/core/Game.java @@ -45,6 +45,9 @@ public class Game implements Serializable { @EqualsAndHashCode.Include private String logoRef; @EqualsAndHashCode.Include private int version; + private Long startTime; + private Long endTime; + private long createdAt; private long updatedAt; @EqualsAndHashCode.Include private boolean active; diff --git a/core/src/main/java/io/github/oasis/core/ID.java b/core/src/main/java/io/github/oasis/core/ID.java index f9393a06..84a827cb 100644 --- a/core/src/main/java/io/github/oasis/core/ID.java +++ b/core/src/main/java/io/github/oasis/core/ID.java @@ -26,6 +26,7 @@ public final class ID { public static final String EVENT_API_CACHE_USERS_KEY = "oasis.eventapi.users"; public static final String EVENT_API_CACHE_SOURCES_KEY = "oasis.eventapi.sources"; + public static final String EVENT_API_CACHE_GAMES_KEY = "oasis.eventapi.games"; public static final String ENGINE_STATUS_CHANNEL = "game.status.channel"; public static final String GAME_ENGINES = "oasis.engines.games"; diff --git a/services/events-api/src/main/java/io/github/oasis/services/events/client/AdminApiClient.java b/services/events-api/src/main/java/io/github/oasis/services/events/client/AdminApiClient.java index d8d3b8cc..ec01522a 100644 --- a/services/events-api/src/main/java/io/github/oasis/services/events/client/AdminApiClient.java +++ b/services/events-api/src/main/java/io/github/oasis/services/events/client/AdminApiClient.java @@ -22,6 +22,7 @@ package io.github.oasis.services.events.client; +import io.github.oasis.core.Game; import io.github.oasis.core.exception.OasisRuntimeException; import io.github.oasis.core.model.PlayerWithTeams; import io.github.oasis.core.model.TeamObject; @@ -29,13 +30,14 @@ import io.github.oasis.core.utils.Utils; import io.github.oasis.services.events.db.DataService; import io.github.oasis.services.events.model.EventSource; +import io.github.oasis.services.events.model.GameInfo; import io.github.oasis.services.events.model.UserInfo; import io.vertx.core.AsyncResult; -import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.client.WebClient; +import org.apache.commons.lang3.ObjectUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,6 +54,8 @@ import static io.github.oasis.services.events.client.AdminConstants.STATUS_NOT_FOUND; import static io.github.oasis.services.events.client.AdminConstants.STATUS_SUCCESS; import static io.github.oasis.services.events.client.AdminConstants.TRUE; +import static io.vertx.core.Future.failedFuture; +import static io.vertx.core.Future.succeededFuture; /** * @author Isuru Weerarathna @@ -66,6 +70,7 @@ public class AdminApiClient implements DataService { private final String getPlayerInfoUrl; private final String getEventSourceInfoUrl; + private final String getGameInfoUrl; private final String apiKey; private final String secretKey; @@ -79,6 +84,8 @@ public AdminApiClient(WebClient webClient, JsonObject configs) { getPlayerInfoUrl = baseUrl + adminApiConf.getString("playerGet"); getEventSourceInfoUrl = baseUrl + adminApiConf.getString("eventSourceGet"); + getGameInfoUrl = baseUrl + adminApiConf.getString("gameGet", "/games"); + apiKey = adminApiConf.getString("apiKey"); secretKey = adminApiConf.getString("secretKey"); } @@ -95,11 +102,11 @@ public DataService readUserInfo(String email, Handler> res .onSuccess(res -> { if (res.statusCode() == STATUS_NOT_FOUND) { // no user exists - resultHandler.handle(Future.failedFuture("No user exists by given email " + email)); + resultHandler.handle(failedFuture("No user exists by given email " + email)); return; } else if (res.statusCode() != STATUS_SUCCESS) { // service down - resultHandler.handle(Future.failedFuture("Unable to connect to admin api!")); + resultHandler.handle(failedFuture("Unable to connect to admin api!")); return; } @@ -119,9 +126,9 @@ public DataService readUserInfo(String email, Handler> res } UserInfo userInfo = UserInfo.create(email, jsonObject); - resultHandler.handle(Future.succeededFuture(userInfo)); + resultHandler.handle(succeededFuture(userInfo)); - }).onFailure(err -> resultHandler.handle(Future.failedFuture(err))); + }).onFailure(err -> resultHandler.handle(failedFuture(err))); return this; } @@ -137,11 +144,11 @@ public DataService readSourceInfo(String token, Handler .onSuccess(res -> { if (res.statusCode() == STATUS_NOT_FOUND) { // no user exists - resultHandler.handle(Future.failedFuture("No event source exists by given token " + token)); + resultHandler.handle(failedFuture("No event source exists by given token " + token)); return; } else if (res.statusCode() != STATUS_SUCCESS) { // service down - resultHandler.handle(Future.failedFuture("Unable to connect to admin api!")); + resultHandler.handle(failedFuture("Unable to connect to admin api!")); return; } @@ -153,11 +160,40 @@ public DataService readSourceInfo(String token, Handler if (eventSource.getSecrets() != null && Texts.isNotEmpty(eventSource.getSecrets().getPublicKey())) { jsonObject.put(EventSource.KEY, eventSource.getSecrets().getPublicKey()); EventSource info = EventSource.create(token, jsonObject); - resultHandler.handle(Future.succeededFuture(info)); + resultHandler.handle(succeededFuture(info)); } else { - resultHandler.handle(Future.failedFuture(new OasisRuntimeException("The public key not received for source " + token))); + resultHandler.handle(failedFuture(new OasisRuntimeException("The public key not received for source " + token))); } - }).onFailure(err -> resultHandler.handle(Future.failedFuture(err))); + }).onFailure(err -> resultHandler.handle(failedFuture(err))); + return this; + } + + @Override + public DataService readGameInfo(int gameId, Handler> resultHandler) { + webClient.getAbs(getGameInfoUrl + "/" + gameId) + .putHeader(HEADER_APP_ID, apiKey) + .putHeader(HEADER_APP_KEY, secretKey) + .putHeader(HEADER_ACCEPT, MEDIA_TYPE_JSON) + .send() + .onSuccess(res -> { + if (res.statusCode() == STATUS_NOT_FOUND) { + // no user exists + resultHandler.handle(failedFuture("No game exists by given game id " + gameId)); + return; + } else if (res.statusCode() != STATUS_SUCCESS) { + // service down + resultHandler.handle(failedFuture("Unable to connect to admin api to get game info!")); + return; + } + + var game = res.bodyAsJson(Game.class); + JsonObject jsonObject = new JsonObject() + .put(GameInfo.ID, game.getId()) + .put(GameInfo.START_TIME, ObjectUtils.firstNonNull(game.getStartTime(), game.getCreatedAt())) + .put(GameInfo.END_TIME, ObjectUtils.firstNonNull(game.getEndTime(), Long.MAX_VALUE - 1)); + GameInfo gameInfo = GameInfo.create(game.getId(), jsonObject); + resultHandler.handle(succeededFuture(gameInfo)); + }).onFailure(err -> resultHandler.handle(failedFuture(err))); return this; } } diff --git a/services/events-api/src/main/java/io/github/oasis/services/events/client/CachedApiClient.java b/services/events-api/src/main/java/io/github/oasis/services/events/client/CachedApiClient.java index 8912f509..cfc227ec 100644 --- a/services/events-api/src/main/java/io/github/oasis/services/events/client/CachedApiClient.java +++ b/services/events-api/src/main/java/io/github/oasis/services/events/client/CachedApiClient.java @@ -26,14 +26,17 @@ import io.github.oasis.services.events.db.DataService; import io.github.oasis.services.events.db.RedisService; import io.github.oasis.services.events.model.EventSource; +import io.github.oasis.services.events.model.GameInfo; import io.github.oasis.services.events.model.UserInfo; import io.vertx.core.AsyncResult; -import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.Promise; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import static io.vertx.core.Future.failedFuture; +import static io.vertx.core.Future.succeededFuture; + /** * @author Isuru Weerarathna */ @@ -56,7 +59,7 @@ public DataService readUserInfo(String email, Handler> res UserInfo user = res.result(); if (user != null) { // cache hit - resultHandler.handle(Future.succeededFuture(user)); + resultHandler.handle(succeededFuture(user)); return; } @@ -66,7 +69,7 @@ public DataService readUserInfo(String email, Handler> res persistBackToCache(email, promise, resultHandler); } else { - resultHandler.handle(Future.failedFuture(res.cause())); + resultHandler.handle(failedFuture(res.cause())); } }); return this; @@ -83,10 +86,31 @@ public DataService readSourceInfo(String sourceToken, Handler> resultHandler) { + cacheService.readGameInfo(gameId, res -> { + if (res.succeeded()) { + GameInfo resultInCache = res.result(); + if (resultInCache == null) { + LOG.debug("Game by id '{}' not found in cache!", gameId); + Promise apiPromise = Promise.promise(); + loadGameInfoFromApi(gameId, apiPromise); + persistGameInfoBackToCache(gameId, apiPromise, resultHandler); + } else { + resultHandler.handle(succeededFuture(resultInCache)); } } else { - resultHandler.handle(Future.failedFuture(res.cause())); + LOG.error("Failed to load from cache!", res.cause()); + resultHandler.handle(failedFuture(res.cause())); } }); return this; @@ -95,13 +119,26 @@ public DataService readSourceInfo(String sourceToken, Handler apiPromise, Handler> handler) { apiPromise.future().onSuccess(eventSource -> cacheService.persistSourceInfo(sourceToken, eventSource, res -> { if (res.succeeded()) { - handler.handle(Future.succeededFuture(eventSource)); + handler.handle(succeededFuture(eventSource)); } else { - handler.handle(Future.failedFuture(res.cause())); + handler.handle(failedFuture(res.cause())); } })).onFailure(err -> { LOG.error("Unable to persist source details to the cache!", err); - handler.handle(Future.failedFuture(err)); + handler.handle(failedFuture(err)); + }); + } + + private void persistGameInfoBackToCache(int gameId, Promise apiPromise, Handler> handler) { + apiPromise.future().onSuccess(gameInfo -> cacheService.persistGameInfo(gameId, gameInfo, res -> { + if (res.succeeded()) { + handler.handle(succeededFuture(gameInfo)); + } else { + handler.handle(failedFuture(res.cause())); + } + })).onFailure(err -> { + LOG.error("Unable to fetch game details from api server!", err); + handler.handle(failedFuture(err)); }); } @@ -115,16 +152,26 @@ private void loadEventSourceFromApi(String sourceId, Promise promis }); } + private void loadGameInfoFromApi(int gameId, Promise promise) { + apiClient.readGameInfo(gameId, res -> { + if (res.succeeded()) { + promise.complete(res.result()); + } else { + promise.fail(res.cause()); + } + }); + } + private void persistBackToCache(String email, Promise promise, Handler> finalHandler) { promise.future().onSuccess(userInfo -> cacheService.persistUserInfo(email, userInfo, resultHandler -> { if (resultHandler.succeeded()) { - finalHandler.handle(Future.succeededFuture(userInfo)); + finalHandler.handle(succeededFuture(userInfo)); } else { - finalHandler.handle(Future.failedFuture(resultHandler.cause())); + finalHandler.handle(failedFuture(resultHandler.cause())); } })).onFailure(err -> { LOG.error("Unable to persist user details into the cache!", err); - finalHandler.handle(Future.failedFuture(err)); + finalHandler.handle(failedFuture(err)); }); } diff --git a/services/events-api/src/main/java/io/github/oasis/services/events/db/DataService.java b/services/events-api/src/main/java/io/github/oasis/services/events/db/DataService.java index 6367cacf..eb50cb76 100644 --- a/services/events-api/src/main/java/io/github/oasis/services/events/db/DataService.java +++ b/services/events-api/src/main/java/io/github/oasis/services/events/db/DataService.java @@ -20,6 +20,7 @@ package io.github.oasis.services.events.db; import io.github.oasis.services.events.model.EventSource; +import io.github.oasis.services.events.model.GameInfo; import io.github.oasis.services.events.model.UserInfo; import io.vertx.codegen.annotations.Fluent; import io.vertx.codegen.annotations.ProxyGen; @@ -44,4 +45,7 @@ static DataService createProxy(Vertx vertx, String address) { @Fluent DataService readSourceInfo(String sourceId, Handler> resultHandler); + + @Fluent + DataService readGameInfo(int gameId, Handler> resultHandler); } diff --git a/services/events-api/src/main/java/io/github/oasis/services/events/db/RedisService.java b/services/events-api/src/main/java/io/github/oasis/services/events/db/RedisService.java index 429f15cb..743367a8 100644 --- a/services/events-api/src/main/java/io/github/oasis/services/events/db/RedisService.java +++ b/services/events-api/src/main/java/io/github/oasis/services/events/db/RedisService.java @@ -20,6 +20,7 @@ package io.github.oasis.services.events.db; import io.github.oasis.services.events.model.EventSource; +import io.github.oasis.services.events.model.GameInfo; import io.github.oasis.services.events.model.UserInfo; import io.vertx.codegen.annotations.Fluent; import io.vertx.codegen.annotations.ProxyGen; @@ -54,4 +55,10 @@ static RedisService createProxy(Vertx vertx, String address) { @Fluent RedisService deleteKey(String key, Handler> resultHandler); + + @Fluent + RedisService readGameInfo(int gameId, Handler> resultHandler); + + @Fluent + RedisService persistGameInfo(int gameId, GameInfo gameInfo, Handler> resultHandler); } diff --git a/services/events-api/src/main/java/io/github/oasis/services/events/db/RedisServiceImpl.java b/services/events-api/src/main/java/io/github/oasis/services/events/db/RedisServiceImpl.java index ce747ea5..ff5dabfb 100644 --- a/services/events-api/src/main/java/io/github/oasis/services/events/db/RedisServiceImpl.java +++ b/services/events-api/src/main/java/io/github/oasis/services/events/db/RedisServiceImpl.java @@ -4,9 +4,9 @@ import io.github.oasis.core.utils.CacheUtils; import io.github.oasis.core.utils.Texts; import io.github.oasis.services.events.model.EventSource; +import io.github.oasis.services.events.model.GameInfo; import io.github.oasis.services.events.model.UserInfo; import io.vertx.core.AsyncResult; -import io.vertx.core.Future; import io.vertx.core.Handler; import io.vertx.core.json.Json; import io.vertx.core.json.JsonObject; @@ -20,6 +20,9 @@ import java.util.List; import java.util.Objects; +import static io.vertx.core.Future.failedFuture; +import static io.vertx.core.Future.succeededFuture; + /** * @author Isuru Weerarathna */ @@ -41,7 +44,7 @@ public RedisServiceImpl(Redis redisClient, RedisSettings settings, Handler> re Response result = res.result(); if (Objects.nonNull(result)) { JsonObject userObj = new JsonObject(result.toBuffer()); - resultHandler.handle(Future.succeededFuture(UserInfo.create(email, userObj))); + resultHandler.handle(succeededFuture(UserInfo.create(email, userObj))); } else { - resultHandler.handle(Future.succeededFuture(null)); + resultHandler.handle(succeededFuture(null)); } } else { - resultHandler.handle(Future.failedFuture(res.cause())); + resultHandler.handle(failedFuture(res.cause())); } } ); @@ -72,20 +75,20 @@ public RedisService readSourceInfo(String sourceToken, Handler> resultHandler) { api.hset(Arrays.asList(ID.EVENT_API_CACHE_USERS_KEY, email, userInfo.toJson().encode()), res -> { if (res.succeeded()) { - resultHandler.handle(Future.succeededFuture(userInfo)); + resultHandler.handle(succeededFuture(userInfo)); } else { - resultHandler.handle(Future.failedFuture(res.cause())); + resultHandler.handle(failedFuture(res.cause())); } }); return this; @@ -109,12 +112,12 @@ public RedisService persistSourceInfo(String sourceToken, EventSource eventSourc api.set(Arrays.asList(key, eventSource.toJson().encode()), res -> { if (res.succeeded()) { if (Texts.isNotEmpty(ttlSeconds)) { - api.expire(List.of(key, ttlSeconds), expireRes -> resultHandler.handle(Future.succeededFuture(eventSource))); + api.expire(List.of(key, ttlSeconds), expireRes -> resultHandler.handle(succeededFuture(eventSource))); } else { - resultHandler.handle(Future.succeededFuture(eventSource)); + resultHandler.handle(succeededFuture(eventSource)); } } else { - resultHandler.handle(Future.failedFuture(res.cause())); + resultHandler.handle(failedFuture(res.cause())); } }); return this; @@ -125,11 +128,44 @@ public RedisService deleteKey(String key, Handler> resultHa api.del(List.of(key), res -> { if (res.succeeded()) { LOG.info("Redis cache key deleted! [Key: {}]", key); - resultHandler.handle(Future.succeededFuture(true)); + resultHandler.handle(succeededFuture(true)); } else { LOG.error("Unable to clear Redis cache key! [Key: {}]", key); LOG.error(" Cause:", res.cause()); - resultHandler.handle(Future.failedFuture(res.cause())); + resultHandler.handle(failedFuture(res.cause())); + } + }); + return this; + } + + @Override + public RedisService readGameInfo(int gameId, Handler> resultHandler) { + api.hget(ID.EVENT_API_CACHE_GAMES_KEY, + String.valueOf(gameId), + res -> { + if (res.succeeded()) { + Response result = res.result(); + if (Objects.nonNull(result)) { + JsonObject userObj = new JsonObject(result.toBuffer()); + resultHandler.handle(succeededFuture(GameInfo.create(gameId, userObj))); + } else { + resultHandler.handle(succeededFuture(null)); + } + } else { + resultHandler.handle(failedFuture(res.cause())); + } + } + ); + return this; + } + + @Override + public RedisService persistGameInfo(int gameId, GameInfo gameInfo, Handler> resultHandler) { + api.hset(Arrays.asList(ID.EVENT_API_CACHE_GAMES_KEY, String.valueOf(gameId), gameInfo.toJson().encode()), res -> { + if (res.succeeded()) { + resultHandler.handle(succeededFuture(gameInfo)); + } else { + resultHandler.handle(failedFuture(res.cause())); } }); return this; diff --git a/services/events-api/src/main/java/io/github/oasis/services/events/http/routers/AbstractEventHandler.java b/services/events-api/src/main/java/io/github/oasis/services/events/http/routers/AbstractEventHandler.java index 62c3bb46..b5249b09 100644 --- a/services/events-api/src/main/java/io/github/oasis/services/events/http/routers/AbstractEventHandler.java +++ b/services/events-api/src/main/java/io/github/oasis/services/events/http/routers/AbstractEventHandler.java @@ -23,6 +23,7 @@ import io.github.oasis.services.events.dispatcher.EventDispatcherService; import io.github.oasis.services.events.model.EventProxy; import io.github.oasis.services.events.model.EventSource; +import io.github.oasis.services.events.model.GameInfo; import io.github.oasis.services.events.model.UserInfo; import io.netty.handler.codec.http.HttpResponseStatus; import io.vertx.core.AsyncResult; @@ -33,7 +34,6 @@ import org.slf4j.LoggerFactory; import java.util.List; -import java.util.stream.Collectors; /** * @author Isuru Weerarathna @@ -67,18 +67,12 @@ protected void putEvent(EventProxy event, EventSource source, Handler gameIds = source.getGameIds().stream() .filter(gId -> user.getTeamId(gId).isPresent()) - .collect(Collectors.toList()); + .toList(); for (int gameId : gameIds) { user.getTeamId(gameId).ifPresent(teamId -> { EventProxy gameEvent = event.copyForGame(gameId, source.getSourceId(), user.getId(), teamId); - dispatcherService.pushEvent(gameEvent, dispatcherRes -> { - if (dispatcherRes.succeeded()) { - LOG.info("[{}] Event published.", event.getExternalId()); - } else { - LOG.error("[{}] Unable to publish event!", event.getExternalId(), dispatcherRes.cause()); - } - }); + dispatchEventToGameIfActive(gameEvent); }); } @@ -96,6 +90,38 @@ protected void putEvent(EventProxy event, EventSource source, Handler { + if (gameInfoAsyncResult.succeeded()) { + GameInfo gameInfo = gameInfoAsyncResult.result(); + + if (isInGamePeriod(gameInfo, gameEvent)) { + dispatcherService.pushEvent(gameEvent, dispatcherRes -> { + if (dispatcherRes.succeeded()) { + LOG.info("[{}] Event published. {}", eventId, gameInfo); + } else { + LOG.error("[{}] Unable to publish event!", eventId, dispatcherRes.cause()); + } + }); + } else { + LOG.error("[{}] Game has not yet started or already ended! Hence skipping event submission for game {}.", + eventId, gameInfo); + } + } else { + LOG.error("[" + eventId + "] Unable to read game id=" + gameEvent.getGameId() + " status!", gameInfoAsyncResult.cause()); + } + }); + } + protected void failWithInvalidPayloadFormat(RoutingContext context) { context.fail(HttpResponseStatus.BAD_REQUEST.code(), new IllegalArgumentException(EVENTS_PAYLOAD_FORMAT_IS_INCORRECT)); diff --git a/services/events-api/src/main/java/io/github/oasis/services/events/model/GameInfo.java b/services/events-api/src/main/java/io/github/oasis/services/events/model/GameInfo.java new file mode 100644 index 00000000..9a139f55 --- /dev/null +++ b/services/events-api/src/main/java/io/github/oasis/services/events/model/GameInfo.java @@ -0,0 +1,48 @@ +package io.github.oasis.services.events.model; + +import io.vertx.codegen.annotations.DataObject; +import io.vertx.core.json.JsonObject; + +@DataObject +public class GameInfo { + + public static final String ID = "id"; + public static final String START_TIME = "startTime"; + public static final String END_TIME = "endTime"; + public static final String STATUS = "status"; + + private JsonObject ref; + + public static GameInfo create(int id, JsonObject other) { + return new GameInfo(new JsonObject() + .mergeIn(other) + .put(ID, id)); + } + + public GameInfo(JsonObject ref) { + this.ref = ref; + } + + public JsonObject toJson() { + return ref; + } + + public long getId() { + return ref.getLong(ID); + } + + public long getStartAt() { + return ref.getLong(START_TIME); + } + + public long getEndAt() { + return ref.getLong(END_TIME); + } + + @Override + public String toString() { + return "GameInfo{" + + "ref=" + ref + + '}'; + } +} diff --git a/services/events-api/src/main/resources/application.conf b/services/events-api/src/main/resources/application.conf index 6b2023f7..4fc00a51 100644 --- a/services/events-api/src/main/resources/application.conf +++ b/services/events-api/src/main/resources/application.conf @@ -10,6 +10,7 @@ oasis = { eventSourceGet: "/admin/event-source" playerGet: "/players" + gameGet: "/games" # authentication details of admin api apiKey: "eventapi" diff --git a/services/events-api/src/test/java/io/github/oasis/services/events/AbstractTest.java b/services/events-api/src/test/java/io/github/oasis/services/events/AbstractTest.java index 3fa3ff23..aaeb3452 100644 --- a/services/events-api/src/test/java/io/github/oasis/services/events/AbstractTest.java +++ b/services/events-api/src/test/java/io/github/oasis/services/events/AbstractTest.java @@ -9,6 +9,7 @@ import io.github.oasis.core.ID; import io.github.oasis.core.collect.Pair; import io.github.oasis.services.events.db.RedisVerticle; +import io.github.oasis.services.events.model.GameInfo; import io.github.oasis.services.events.utils.TestDispatcherFactory; import io.github.oasis.services.events.utils.TestDispatcherService; import io.github.oasis.services.events.utils.TestDispatcherVerticle; @@ -99,6 +100,7 @@ private void cleanRedis() { RedissonClient redissonClient = Redisson.create(redisConfigs); redissonClient.getMap(ID.EVENT_API_CACHE_USERS_KEY, StringCodec.INSTANCE).delete(); redissonClient.getMap(ID.EVENT_API_CACHE_SOURCES_KEY, StringCodec.INSTANCE).delete(); + redissonClient.getMap(ID.EVENT_API_CACHE_GAMES_KEY, StringCodec.INSTANCE).delete(); redissonClient.shutdown(); } @@ -107,6 +109,7 @@ private JsonObject getAdminApiConfigs(WireMockRuntimeInfo wireMockRuntime) { .put("baseUrl", "http://localhost:" + wireMockRuntime.getHttpPort() + "/api") .put("eventSourceGet", "/admin/event-source") .put("playerGet", "/players") + .put("gameGet", "/games") .put("apiKey", "eventapi") .put("secretKey", "eventapi"); } @@ -140,6 +143,15 @@ protected void setSourceExists(String token, JsonObject eventSource) { )); } + protected void setGameExists(int gameId, JsonObject gameObj) { + stubFor(get(urlPathEqualTo("/api/games/" + gameId)) + .willReturn( + ok() + .withResponseBody(new Body(gameObj.encode())) + .withHeader("content-type", MediaType.APPLICATION_JSON.toString()) + )); + } + protected JsonObject createEventSource(String token, Integer id, Set games, PublicKey publicKey) { return new JsonObject() .put("token", token) @@ -148,6 +160,29 @@ protected JsonObject createEventSource(String token, Integer id, Set ga .put("secrets", new JsonObject().put("publicKey", Base64.getEncoder().encodeToString(publicKey.getEncoded()))); } + protected JsonObject createGameInfo(int gameId, long startAt) { + return createGameInfo(gameId, startAt, Long.MAX_VALUE - 1); + } + + protected JsonObject createGameInfo(int gameId, long startAt, long endAt) { + return new JsonObject() + .put(GameInfo.ID, gameId) + .put(GameInfo.START_TIME, startAt) + .put(GameInfo.END_TIME, endAt); + } + + protected JsonObject createPlayerWithTeams(String email, long id, Pair... teamToGameMapping) { + JsonArray array = new JsonArray(); + for (Pair pair : teamToGameMapping) { + array.add(new JsonObject().put("id", pair.getLeft()).put("gameId", pair.getRight())); + } + + return new JsonObject() + .put("email", email) + .put("id", id) + .put("teams", array); + } + protected JsonObject createPlayerWith2Teams(String email, long id, Pair pair1, Pair pair2) { JsonArray array = new JsonArray(); array.add(new JsonObject().put("id", pair1.getLeft()).put("gameId", pair1.getRight())); diff --git a/services/events-api/src/test/java/io/github/oasis/services/events/IntegrityDisableCheckTest.java b/services/events-api/src/test/java/io/github/oasis/services/events/IntegrityDisableCheckTest.java index 9657bdbf..0dac742c 100644 --- a/services/events-api/src/test/java/io/github/oasis/services/events/IntegrityDisableCheckTest.java +++ b/services/events-api/src/test/java/io/github/oasis/services/events/IntegrityDisableCheckTest.java @@ -77,6 +77,7 @@ void hashIncorrect(Vertx vertx, VertxTestContext testContext) throws NoSuchAlgor String sourceToken = UUID.randomUUID().toString(); String userEmail = "mom.attack@oasis.io"; setSourceExists(sourceToken, createEventSource(sourceToken, 1, Set.of(1), keyPair.getPublic())); + setGameExists(1, createGameInfo(1, 0)); setPlayerExists(userEmail, createPlayerWithTeam(userEmail, 500, Pair.of(200,1))); JsonObject validPayload = new JsonObject() @@ -100,6 +101,7 @@ void correctHash(Vertx vertx, VertxTestContext testContext) throws NoSuchAlgorit KeyPair keyPair = TestUtils.createKeys(); String sourceToken = UUID.randomUUID().toString(); String userEmail = "mom.attack@oasis.io"; + setGameExists(1, createGameInfo(1, 0)); setSourceExists(sourceToken, createEventSource(sourceToken, 1, Set.of(1), keyPair.getPublic())); setPlayerExists(userEmail, createPlayerWithTeam(userEmail, 500, Pair.of(200,1))); diff --git a/services/events-api/src/test/java/io/github/oasis/services/events/PublishTest.java b/services/events-api/src/test/java/io/github/oasis/services/events/PublishTest.java index d85726b9..ee7aa18f 100644 --- a/services/events-api/src/test/java/io/github/oasis/services/events/PublishTest.java +++ b/services/events-api/src/test/java/io/github/oasis/services/events/PublishTest.java @@ -26,6 +26,7 @@ import io.vertx.ext.web.codec.BodyCodec; import io.vertx.junit5.VertxTestContext; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.redisson.Redisson; @@ -49,6 +50,7 @@ void successPublish(Vertx vertx, VertxTestContext testContext) throws NoSuchAlgo KeyPair keyPair = TestUtils.createKeys(); String token = UUID.randomUUID().toString(); setSourceExists(token, createEventSource(token, 1, Set.of(1), keyPair.getPublic())); + setGameExists(1, createGameInfo(1, 0)); setPlayerExists(KNOWN_USER, createPlayerWithTeam(KNOWN_USER, 500, Pair.of(200, 1))); String hash = TestUtils.signPayload(VALID_PAYLOAD, keyPair.getPrivate()); @@ -66,6 +68,9 @@ void successPublishForOnlyGames(Vertx vertx, VertxTestContext testContext) throw KeyPair keyPair = TestUtils.createKeys(); String token = UUID.randomUUID().toString(); setSourceExists(token, createEventSource(token, 1, Set.of(1,2,3), keyPair.getPublic())); + setGameExists(1, createGameInfo(1, 0)); + setGameExists(2, createGameInfo(2, 0)); + setGameExists(3, createGameInfo(3, 0)); setPlayerExists("isuru@oasis.com", createPlayerWith2Teams("isuru@oasis.com", 500, Pair.of(200,1), Pair.of(201, 2))); JsonObject event = TestUtils.aEvent("isuru@oasis.com", System.currentTimeMillis(), "test.a", 100); @@ -88,6 +93,8 @@ void successPublishForOnlyExistingGames(Vertx vertx, VertxTestContext testContex String sourceToken = UUID.randomUUID().toString(); String userEmail = "success.publish@oasis.io"; setSourceExists(sourceToken, createEventSource(sourceToken, 1, Set.of(1,2), keyPair.getPublic())); + setGameExists(1, createGameInfo(1, 0)); + setGameExists(2, createGameInfo(2, 0)); setPlayerExists(userEmail, createPlayerWith2Teams(userEmail, 500, Pair.of(200,1), Pair.of(201, 3))); JsonObject event = TestUtils.aEvent(userEmail, System.currentTimeMillis(), "test.a", 100); @@ -105,6 +112,8 @@ void dispatcherFailed(Vertx vertx, VertxTestContext testContext) throws NoSuchAl dispatcherService.setReturnSuccess(false); String token = UUID.randomUUID().toString(); setSourceExists(token, createEventSource(token, 1, Set.of(1,2), keyPair.getPublic())); + setGameExists(1, createGameInfo(1, 0)); + setGameExists(2, createGameInfo(2, 0)); setPlayerExists(KNOWN_USER, createPlayerWithTeam(KNOWN_USER, 500, Pair.of(200,1))); @@ -117,6 +126,79 @@ void dispatcherFailed(Vertx vertx, VertxTestContext testContext) throws NoSuchAl ); } + @Test + @DisplayName("When game is not started, any events should not be published") + void whenGameIsNotYetStartedEventsShouldNotPublished(Vertx vertx, VertxTestContext testContext) throws NoSuchAlgorithmException { + KeyPair keyPair = TestUtils.createKeys(); + String sourceToken = UUID.randomUUID().toString(); + String userEmail = "success.publish@oasis.io"; + setSourceExists(sourceToken, createEventSource(sourceToken, 1, Set.of(1), keyPair.getPublic())); + setGameExists(1, createGameInfo(1, Long.MAX_VALUE - 1)); + setPlayerExists(userEmail, createPlayerWithTeam(userEmail, 500, Pair.of(200,1))); + + JsonObject event = TestUtils.aEvent(userEmail, System.currentTimeMillis(), "test.a", 100); + JsonObject payload = new JsonObject().put("data", event); + String hash = TestUtils.signPayload(payload, keyPair.getPrivate()); + + callForEvent(vertx, sourceToken + ":" + hash) + .sendJson(payload, testContext.succeeding(res -> assertSuccessWithInvocations(res, testContext, 0))); + } + + @Test + @DisplayName("When game is expired, any events should not be published") + void whenGameIsExpiredEventsShouldNotPublished(Vertx vertx, VertxTestContext testContext) throws NoSuchAlgorithmException { + KeyPair keyPair = TestUtils.createKeys(); + String sourceToken = UUID.randomUUID().toString(); + long ts = System.currentTimeMillis(); + String userEmail = "success.publish@oasis.io"; + setSourceExists(sourceToken, createEventSource(sourceToken, 1, Set.of(1), keyPair.getPublic())); + setGameExists(1, createGameInfo(1, 0, ts - 1)); + setPlayerExists(userEmail, createPlayerWithTeam(userEmail, 500, Pair.of(200,1))); + + JsonObject event = TestUtils.aEvent(userEmail, ts, "test.a", 100); + JsonObject payload = new JsonObject().put("data", event); + String hash = TestUtils.signPayload(payload, keyPair.getPrivate()); + + callForEvent(vertx, sourceToken + ":" + hash) + .sendJson(payload, testContext.succeeding(res -> assertSuccessWithInvocations(res, testContext, 0))); + } + + @Test + @DisplayName("When some bounded games expired for a token, any events should not be published to those games") + void whenSomeGamesAreExpiredThoseShouldNotPublish(Vertx vertx, VertxTestContext testContext) throws NoSuchAlgorithmException { + KeyPair keyPair = TestUtils.createKeys(); + String sourceToken = UUID.randomUUID().toString(); + String userEmail = "success.publish@oasis.io"; + setSourceExists(sourceToken, createEventSource(sourceToken, 1, Set.of(1, 2), keyPair.getPublic())); + setGameExists(1, createGameInfo(1, Long.MAX_VALUE - 1)); + setGameExists(2, createGameInfo(2, 0)); + setPlayerExists(userEmail, createPlayerWith2Teams(userEmail, 500, Pair.of(200,1), Pair.of(201,2))); + + JsonObject event = TestUtils.aEvent(userEmail, System.currentTimeMillis(), "test.a", 100); + JsonObject payload = new JsonObject().put("data", event); + String hash = TestUtils.signPayload(payload, keyPair.getPrivate()); + + callForEvent(vertx, sourceToken + ":" + hash) + .sendJson(payload, testContext.succeeding(res -> assertSuccessWithInvocations(res, testContext, 1))); + } + + @Test + @DisplayName("When some games does not exist for a token, any events should not be published to those games") + void whenNonExistentGamesThoseShouldNotPublish(Vertx vertx, VertxTestContext testContext) throws NoSuchAlgorithmException { + KeyPair keyPair = TestUtils.createKeys(); + String sourceToken = UUID.randomUUID().toString(); + String userEmail = "success.publish@oasis.io"; + setSourceExists(sourceToken, createEventSource(sourceToken, 1, Set.of(1, 2), keyPair.getPublic())); + setGameExists(1, createGameInfo(1, 0)); + setPlayerExists(userEmail, createPlayerWith2Teams(userEmail, 500, Pair.of(200,1), Pair.of(201,2))); + + JsonObject event = TestUtils.aEvent(userEmail, System.currentTimeMillis(), "test.a", 100); + JsonObject payload = new JsonObject().put("data", event); + String hash = TestUtils.signPayload(payload, keyPair.getPrivate()); + + callForEvent(vertx, sourceToken + ":" + hash) + .sendJson(payload, testContext.succeeding(res -> assertSuccessWithInvocations(res, testContext, 1))); + } @Test @DisplayName("Cache clear for event source reference") @@ -125,6 +207,7 @@ void sourceCacheClearSuccess(Vertx vertx, VertxTestContext testContext) throws N String token = UUID.randomUUID().toString(); String userId = "temp.source.cache@oasis.io"; setSourceExists(token, createEventSource(token, 1, Set.of(1), keyPair.getPublic())); + setGameExists(1, createGameInfo(1, 0)); setPlayerExists(userId, createPlayerWithTeam(userId, 500, Pair.of(200, 1))); JsonObject validPayload = new JsonObject() diff --git a/services/events-api/src/test/java/io/github/oasis/services/events/dispatcher/ExternalDispatcherTest.java b/services/events-api/src/test/java/io/github/oasis/services/events/dispatcher/ExternalDispatcherTest.java index 283eecbb..00bb17ae 100644 --- a/services/events-api/src/test/java/io/github/oasis/services/events/dispatcher/ExternalDispatcherTest.java +++ b/services/events-api/src/test/java/io/github/oasis/services/events/dispatcher/ExternalDispatcherTest.java @@ -19,6 +19,7 @@ package io.github.oasis.services.events.dispatcher; +import com.redis.testcontainers.RedisContainer; import io.github.oasis.core.external.EventAsyncDispatcher; import io.github.oasis.core.external.EventDispatcher; import io.github.oasis.core.external.messages.EngineMessage; @@ -39,6 +40,9 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mockito; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; import java.io.IOException; @@ -49,8 +53,12 @@ */ @DisplayName("External Dispatcher Test") @ExtendWith(VertxExtension.class) +@Testcontainers public class ExternalDispatcherTest { + @Container + protected static final RedisContainer redis = new RedisContainer(DockerImageName.parse("redis:5")); + public static final int SLEEP_MS = 2000; @Test @@ -282,7 +290,8 @@ void afterEach(Vertx vertx, VertxTestContext testContext) { } private JsonObject getDefaultRedisCacheConfigs() { - return new JsonObject().put("impl", RedisVerticle.class.getName()); + return new JsonObject().put("impl", RedisVerticle.class.getName()) + .put("configs", new JsonObject().put("connectionString", redis.getRedisURI())); } private void sleepFor(long ms) { diff --git a/services/events-api/src/test/resources/application.conf b/services/events-api/src/test/resources/application.conf index 0c6d8e94..4d77f0b6 100644 --- a/services/events-api/src/test/resources/application.conf +++ b/services/events-api/src/test/resources/application.conf @@ -10,6 +10,7 @@ oasis = { eventSourceGet: "/admin/event-source" playerGet: "/players" + gameGet: "/games" # authentication details of admin api apiKey: "eventapi" diff --git a/services/stats-api/src/main/java/io/github/oasis/core/services/api/dao/IGameDao.java b/services/stats-api/src/main/java/io/github/oasis/core/services/api/dao/IGameDao.java index 3eeba7dd..9b251fd1 100644 --- a/services/stats-api/src/main/java/io/github/oasis/core/services/api/dao/IGameDao.java +++ b/services/stats-api/src/main/java/io/github/oasis/core/services/api/dao/IGameDao.java @@ -46,7 +46,7 @@ int insertGame(@BindBean Game game, @Bind("ts") long timestamp); default int insertGame(Game game) { - return insertGame(game, System.currentTimeMillis()); + return insertGame(game, game.getCreatedAt() > 0 ? game.getCreatedAt() : System.currentTimeMillis()); } @SqlQuery diff --git a/services/stats-api/src/main/java/io/github/oasis/core/services/api/dao/dto/GameUpdatePart.java b/services/stats-api/src/main/java/io/github/oasis/core/services/api/dao/dto/GameUpdatePart.java index d511cd4c..77528282 100644 --- a/services/stats-api/src/main/java/io/github/oasis/core/services/api/dao/dto/GameUpdatePart.java +++ b/services/stats-api/src/main/java/io/github/oasis/core/services/api/dao/dto/GameUpdatePart.java @@ -40,12 +40,17 @@ public class GameUpdatePart implements Serializable { private String logoRef; private int version; + private long startTime; + private long endTime; + public static GameUpdatePart from(Game game) { return GameUpdatePart.builder() .name(game.getName()) .description(game.getDescription()) .motto(game.getMotto()) .logoRef(game.getLogoRef()) + .startTime(game.getStartTime()) + .endTime(game.getEndTime()) .version(game.getVersion()) .build(); } diff --git a/services/stats-api/src/main/java/io/github/oasis/core/services/api/handlers/CacheClearanceListener.java b/services/stats-api/src/main/java/io/github/oasis/core/services/api/handlers/CacheClearanceListener.java index 820a4f46..872da5e9 100644 --- a/services/stats-api/src/main/java/io/github/oasis/core/services/api/handlers/CacheClearanceListener.java +++ b/services/stats-api/src/main/java/io/github/oasis/core/services/api/handlers/CacheClearanceListener.java @@ -29,6 +29,7 @@ import io.github.oasis.core.services.api.handlers.events.BaseEventSourceChangedEvent; import io.github.oasis.core.services.api.handlers.events.BasePlayerRelatedEvent; import io.github.oasis.core.services.api.handlers.events.EntityChangeType; +import io.github.oasis.core.services.api.handlers.events.GameSpecChangedEvent; import io.github.oasis.core.utils.CacheUtils; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; @@ -73,4 +74,16 @@ public void handlePlayerUpdateEvent(BasePlayerRelatedEvent playerRelatedEvent) { } } } + + @EventListener + public void handleGameUpdateEvent(GameSpecChangedEvent gameChangedEvent) { + if (gameChangedEvent.getChangeType() != EntityChangeType.ADDED) { + try (DbContext db = cache.createContext()) { + db.MAP(ID.EVENT_API_CACHE_GAMES_KEY).remove(String.valueOf(gameChangedEvent.getGameId())); + + } catch (IOException e) { + // silently ignore cache clear failures + } + } + } } diff --git a/services/stats-api/src/main/java/io/github/oasis/core/services/api/handlers/GameStatusListener.java b/services/stats-api/src/main/java/io/github/oasis/core/services/api/handlers/GameStatusListener.java index d72024f8..798c87c6 100644 --- a/services/stats-api/src/main/java/io/github/oasis/core/services/api/handlers/GameStatusListener.java +++ b/services/stats-api/src/main/java/io/github/oasis/core/services/api/handlers/GameStatusListener.java @@ -21,6 +21,9 @@ package io.github.oasis.core.services.api.handlers; +import io.github.oasis.core.external.messages.GameState; +import io.github.oasis.core.services.api.handlers.events.EntityChangeType; +import io.github.oasis.core.services.api.handlers.events.GameSpecChangedEvent; import io.github.oasis.core.services.api.services.IEngineManager; import io.github.oasis.core.services.api.services.impl.GameService; import io.github.oasis.core.services.events.GameStatusChangeEvent; @@ -48,4 +51,11 @@ public void handleGameStatusChangedEvent(GameStatusChangeEvent event) { engineManager.notifyGameStatusChange(event.getNewGameState(), event.getGameRef()); } + @EventListener + public void handleGameStatusChangedEvent(GameSpecChangedEvent event) { + if (event.getChangeType() == EntityChangeType.REMOVED) { + engineManager.notifyGameStatusChange(GameState.STOPPED, event.getGame()); + } + } + } diff --git a/services/stats-api/src/main/java/io/github/oasis/core/services/api/handlers/events/GameSpecChangedEvent.java b/services/stats-api/src/main/java/io/github/oasis/core/services/api/handlers/events/GameSpecChangedEvent.java new file mode 100644 index 00000000..c055b947 --- /dev/null +++ b/services/stats-api/src/main/java/io/github/oasis/core/services/api/handlers/events/GameSpecChangedEvent.java @@ -0,0 +1,43 @@ +/* + * + * * Licensed to the Apache Software Foundation (ASF) under one + * * or more contributor license agreements. See the NOTICE file + * * distributed with this work for additional information + * * regarding copyright ownership. The ASF licenses this file + * * to you under the Apache License, Version 2.0 (the + * * "License"); you may not use this file except in compliance + * * with the License. You may obtain a copy of the License at + * * + * * http://www.apache.org/licenses/LICENSE-2.0 + * * + * * Unless required by applicable law or agreed to in writing, + * * software distributed under the License is distributed on an + * * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * * KIND, either express or implied. See the License for the + * * specific language governing permissions and limitations + * * under the License. + * + */ + +package io.github.oasis.core.services.api.handlers.events; + +import io.github.oasis.core.Game; +import lombok.*; + +import java.io.Serializable; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Setter(AccessLevel.PRIVATE) +public class GameSpecChangedEvent implements Serializable { + + private int gameId; + + @EqualsAndHashCode.Exclude + private Game game; + + private EntityChangeType changeType; + +} diff --git a/services/stats-api/src/main/java/io/github/oasis/core/services/api/services/impl/GameService.java b/services/stats-api/src/main/java/io/github/oasis/core/services/api/services/impl/GameService.java index f8fefe03..1bafde5f 100644 --- a/services/stats-api/src/main/java/io/github/oasis/core/services/api/services/impl/GameService.java +++ b/services/stats-api/src/main/java/io/github/oasis/core/services/api/services/impl/GameService.java @@ -30,6 +30,8 @@ import io.github.oasis.core.services.annotations.AdminDbRepository; import io.github.oasis.core.services.api.exceptions.DataValidationException; import io.github.oasis.core.services.api.exceptions.ErrorCodes; +import io.github.oasis.core.services.api.handlers.events.EntityChangeType; +import io.github.oasis.core.services.api.handlers.events.GameSpecChangedEvent; import io.github.oasis.core.services.api.services.IGameService; import io.github.oasis.core.services.api.to.GameCreateRequest; import io.github.oasis.core.services.api.to.GameUpdateRequest; @@ -83,6 +85,8 @@ public Game addGame(GameCreateRequest gameCreateRequest) throws OasisApiExceptio validateGameObjectForCreation(game); + populateDefaultGameFieldsIfNeeded(game); + Game dbGame = backendRepository.addNewGame(game); backendRepository.updateGameStatus(dbGame.getId(), GameState.CREATED.name(), System.currentTimeMillis()); return dbGame; @@ -95,15 +99,25 @@ public Game updateGame(int gameId, GameUpdateRequest updateRequest) { .motto(StringUtils.defaultIfEmpty(updateRequest.getMotto(), dbGame.getMotto())) .logoRef(ObjectUtils.defaultIfNull(updateRequest.getLogoRef(), dbGame.getLogoRef())) .description(StringUtils.defaultIfEmpty(updateRequest.getDescription(), dbGame.getDescription())) + .startTime(ObjectUtils.defaultIfNull(updateRequest.getStartTime(), dbGame.getStartTime())) + .endTime(ObjectUtils.defaultIfNull(updateRequest.getEndTime(), dbGame.getEndTime())) .version(updateRequest.getVersion()) .build(); - return backendRepository.updateGame(gameId, updatingGame); + var updatedGame = backendRepository.updateGame(gameId, updatingGame); + + publisher.publishEvent(GameSpecChangedEvent.builder() + .changeType(EntityChangeType.MODIFIED) + .game(updatedGame) + .gameId(gameId) + .build()); + + return updatedGame; } @Override + @Transactional public Game deleteGame(int gameId) throws OasisApiException { - changeStatusOfGame(gameId, GameState.STOPPED.name(), System.currentTimeMillis()); // deleting game elements backendRepository.readElementsByGameId(gameId) @@ -113,7 +127,17 @@ public Game deleteGame(int gameId) throws OasisApiException { backendRepository.listAllEventSourcesOfGame(gameId) .forEach(eventSource -> backendRepository.removeEventSourceFromGame(eventSource.getId(), gameId)); - return backendRepository.deleteGame(gameId); + var deletedGame = backendRepository.deleteGame(gameId); + + changeStatusOfGameWithoutPublishing(gameId, GameState.STOPPED.name(), System.currentTimeMillis()); + + publisher.publishEvent(GameSpecChangedEvent.builder() + .gameId(gameId) + .game(deletedGame) + .changeType(EntityChangeType.REMOVED) + .build()); + + return deletedGame; } @Override @@ -167,6 +191,17 @@ public List listGameStatusHistory(int gameId, Long startFrom, Long e return backendRepository.readGameStatusHistory(gameId, startTime, endTime); } + private void populateDefaultGameFieldsIfNeeded(Game game) { + game.setCreatedAt(System.currentTimeMillis()); + game.setUpdatedAt(game.getCreatedAt()); + if (game.getStartTime() == null) { + game.setStartTime(game.getCreatedAt()); + } + if (game.getEndTime() == null) { + game.setEndTime(FAR_FUTURE_EPOCH); + } + } + private GameState validateGameState(String status) throws OasisApiException { if (Objects.isNull(status)) { throw new OasisApiException(ErrorCodes.GAME_UNKNOWN_STATE, diff --git a/services/stats-api/src/main/java/io/github/oasis/core/services/api/to/GameCreateRequest.java b/services/stats-api/src/main/java/io/github/oasis/core/services/api/to/GameCreateRequest.java index ffdd2e00..539f900c 100644 --- a/services/stats-api/src/main/java/io/github/oasis/core/services/api/to/GameCreateRequest.java +++ b/services/stats-api/src/main/java/io/github/oasis/core/services/api/to/GameCreateRequest.java @@ -43,12 +43,17 @@ public class GameCreateRequest implements Serializable { private String description; private String logoRef; + private Long startTime; + private Long endTime; + public Game createGame() { return Game.builder() .name(name) .motto(motto) .description(description) .logoRef(logoRef) + .startTime(startTime) + .endTime(endTime) .build(); } } diff --git a/services/stats-api/src/main/java/io/github/oasis/core/services/api/to/GameUpdateRequest.java b/services/stats-api/src/main/java/io/github/oasis/core/services/api/to/GameUpdateRequest.java index bfd9a961..7f1584a3 100644 --- a/services/stats-api/src/main/java/io/github/oasis/core/services/api/to/GameUpdateRequest.java +++ b/services/stats-api/src/main/java/io/github/oasis/core/services/api/to/GameUpdateRequest.java @@ -44,12 +44,7 @@ public class GameUpdateRequest implements Serializable { @Positive(message = "The 'version' field must be specified to represent updating entity!") private int version; - public Game createGame() { - return Game.builder() - .id(id) - .motto(motto) - .description(description) - .logoRef(logoRef) - .build(); - } + private Long startTime; + private Long endTime; + } diff --git a/services/stats-api/src/main/resources/io/github/oasis/db/schema/changelogs/v0001-07-gamestartend.yml b/services/stats-api/src/main/resources/io/github/oasis/db/schema/changelogs/v0001-07-gamestartend.yml new file mode 100644 index 00000000..9cff680a --- /dev/null +++ b/services/stats-api/src/main/resources/io/github/oasis/db/schema/changelogs/v0001-07-gamestartend.yml @@ -0,0 +1,24 @@ +databaseChangeLog: + - changeSet: + id: add_col_game_start_end_time + author: isuruw + changes: + - addColumn: + tableName: OA_GAME + columns: + - column: + name: start_at + type: bigint + defaultValue: 0 + - column: + name: end_at + type: bigint + defaultValue: 9223372036854775 + + - changeSet: + id: run_mig_populate_startend_time + author: isuruw + comment: "Updates game's start and end time to its created time and far future date respectively" + changes: + - sql: + sql: "UPDATE OA_GAME SET start_at = created_at, end_at = 9223372036854775;" diff --git a/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/insertGame.sql b/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/insertGame.sql index 0a87dbdd..b9fc7907 100644 --- a/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/insertGame.sql +++ b/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/insertGame.sql @@ -1,4 +1,22 @@ -INSERT INTO OA_GAME -(name, motto, description, logo_ref, version, created_at, updated_at) -VALUES -(:name, :motto, :description, :logoRef, 1, :ts, :ts) \ No newline at end of file +INSERT INTO OA_GAME ( + name, + motto, + description, + logo_ref, + version, + start_at, + end_at, + created_at, + updated_at +) +VALUES ( + :name, + :motto, + :description, + :logoRef, + 1, + :startTime, + :endTime, + :ts, + :ts +) \ No newline at end of file diff --git a/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/listGames.sql b/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/listGames.sql index d944dcee..31503d9f 100644 --- a/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/listGames.sql +++ b/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/listGames.sql @@ -5,6 +5,8 @@ SELECT motto, logo_ref AS logoRef, version AS version, + start_at AS startTime, + end_at AS endTime, created_at AS createdAt, updated_at AS updatedAt, is_active As active diff --git a/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/readGame.sql b/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/readGame.sql index 634d43b8..0dc9cc7a 100644 --- a/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/readGame.sql +++ b/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/readGame.sql @@ -5,6 +5,8 @@ SELECT motto, logo_ref AS logoRef, version AS version, + start_at AS startTime, + end_at AS endTime, created_at AS createdAt, updated_at AS updatedAt, is_active As active diff --git a/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/readGameByName.sql b/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/readGameByName.sql index a95037af..2d907c93 100644 --- a/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/readGameByName.sql +++ b/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/readGameByName.sql @@ -5,6 +5,8 @@ SELECT motto, logo_ref AS logoRef, version AS version, + start_at AS startTime, + end_at AS endTime, created_at AS createdAt, updated_at AS updatedAt, is_active As active diff --git a/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/updateGame.sql b/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/updateGame.sql index 780e0c98..c8d7f8f1 100644 --- a/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/updateGame.sql +++ b/services/stats-api/src/main/resources/io/github/oasis/db/scripts/game/updateGame.sql @@ -4,6 +4,8 @@ SET description = :description, motto = :motto, logo_ref = :logoRef, + start_at = :startTime, + end_at = :endTime, version = version + 1, updated_at = :ts WHERE diff --git a/services/stats-api/src/test/java/io/github/oasis/core/services/api/services/GameServiceTest.java b/services/stats-api/src/test/java/io/github/oasis/core/services/api/services/GameServiceTest.java index bacab69f..07437d28 100644 --- a/services/stats-api/src/test/java/io/github/oasis/core/services/api/services/GameServiceTest.java +++ b/services/stats-api/src/test/java/io/github/oasis/core/services/api/services/GameServiceTest.java @@ -21,26 +21,35 @@ import io.github.oasis.core.Game; import io.github.oasis.core.elements.ElementDef; +import io.github.oasis.core.exception.OasisRuntimeException; import io.github.oasis.core.external.messages.GameState; import io.github.oasis.core.model.EventSource; import io.github.oasis.core.model.GameStatus; import io.github.oasis.core.services.api.TestUtils; import io.github.oasis.core.services.api.exceptions.ErrorCodes; +import io.github.oasis.core.services.api.exceptions.OasisApiRuntimeException; +import io.github.oasis.core.services.api.handlers.CacheClearanceListener; +import io.github.oasis.core.services.api.handlers.events.EntityChangeType; +import io.github.oasis.core.services.api.handlers.events.GameSpecChangedEvent; import io.github.oasis.core.services.api.services.impl.GameService; import io.github.oasis.core.services.api.to.ElementCreateRequest; import io.github.oasis.core.services.api.to.EventSourceCreateRequest; import io.github.oasis.core.services.api.to.GameCreateRequest; import io.github.oasis.core.services.api.to.GameUpdateRequest; import io.github.oasis.core.services.events.GameStatusChangeEvent; +import io.github.oasis.core.services.exceptions.OasisApiException; import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.HttpStatus; +import java.time.LocalDate; +import java.time.ZoneOffset; import java.util.Comparator; import java.util.List; import java.util.stream.Collectors; @@ -55,10 +64,16 @@ public class GameServiceTest extends AbstractServiceTest { @Autowired private IGameService gameService; + @SpyBean + private CacheClearanceListener cacheClearanceListener; + + @SpyBean + private IEngineManager engineManager; + public static final String TESTPOINT = "testpoint"; public static final String TESTBADGE = "testbadge"; - private final IEngineManager engineManager = Mockito.mock(IEngineManager.class); +// private final IEngineManager engineManager = Mockito.mock(IEngineManager.class); @Autowired private ApplicationEventPublisher eventPublisher; @@ -93,6 +108,20 @@ void addGame() { doPostError("/games", stackOverflow, HttpStatus.BAD_REQUEST, ErrorCodes.GAME_ALREADY_EXISTS); } + @Test + void addGameWithStartAndEnd() { + var request = promotions.toBuilder() + .startTime(System.currentTimeMillis()) + .endTime(LocalDate.of(2050, 1, 1).atStartOfDay() + .toInstant(ZoneOffset.UTC).toEpochMilli()) + .build(); + Game game = doPostSuccess("/games", request, Game.class); + System.out.println(game); + Assertions.assertNotNull(game); + assertGame(game, request); + assertCurrentGameStatus(game.getId(), "CREATED"); + } + @Test void addGameValidations() { doPostError("/games", stackOverflow.toBuilder().name(null).build(), HttpStatus.BAD_REQUEST, ErrorCodes.INVALID_PARAMETER); @@ -105,7 +134,9 @@ void listGames() { assertEquals(0, doGetPaginatedSuccess("/games", Game.class).getRecords().size()); doPostSuccess("/games", stackOverflow, Game.class); - assertEquals(1, doGetPaginatedSuccess("/games", Game.class).getRecords().size()); + var records = doGetPaginatedSuccess("/games", Game.class).getRecords(); + assertEquals(1, records.size()); + assertGame(records.get(0), stackOverflow); doPostSuccess("/games", promotions, Game.class); assertEquals(2, doGetPaginatedSuccess("/games", Game.class).getRecords().size()); @@ -132,6 +163,27 @@ void updateGame() { assertGame(updatedGame, updateRequest); assertEquals(stackGame.getVersion() + 1, updatedGame.getVersion()); assertCurrentGameStatus(stackId, "CREATED"); + + assertThirdPartyCacheClearanceForGame(stackId, EntityChangeType.MODIFIED); + } + + @Test + void updateStartTimeOfGame() { + Game stackGame = doPostSuccess("/games", stackOverflow, Game.class); + int stackId = stackGame.getId(); + + GameUpdateRequest updateRequest = GameUpdateRequest.builder() + .id(stackId) + .version(stackGame.getVersion()) + .startTime(LocalDate.of(2022, 1, 1).atStartOfDay() + .toInstant(ZoneOffset.UTC).toEpochMilli()) + .build(); + Game updatedGame = doPatchSuccess("/games/" + stackId, updateRequest, Game.class); + assertGame(updatedGame, updateRequest); + assertEquals(stackGame.getVersion() + 1, updatedGame.getVersion()); + assertCurrentGameStatus(stackId, "CREATED"); + + assertThirdPartyCacheClearanceForGame(stackId, EntityChangeType.MODIFIED); } @Test @@ -148,6 +200,7 @@ void updateShouldFailWithoutGameVersion() { .logoRef("new logo ref") .build(); doPatchError("/games/" + stackId, updateRequest, HttpStatus.BAD_REQUEST, ErrorCodes.INVALID_PARAMETER); + Mockito.verify(cacheClearanceListener, Mockito.never()).handleGameUpdateEvent(Mockito.any()); } { @@ -160,6 +213,7 @@ void updateShouldFailWithoutGameVersion() { .version(stackGame.getVersion() + 1000) .build(); doPatchError("/games/" + stackId, updateRequest, HttpStatus.CONFLICT, ErrorCodes.GAME_UPDATE_CONFLICT); + Mockito.verify(cacheClearanceListener, Mockito.never()).handleGameUpdateEvent(Mockito.any()); } } @@ -186,6 +240,8 @@ void updateGameStatusOnly() { Game stackGame = doPostSuccess("/games", stackOverflow, Game.class); int stackId = stackGame.getId(); + sleepSafe(100); + Game updatedGame = doPutSuccess("/games/" + stackId + "/start", null, Game.class); assertCurrentGameStatus(stackId, "STARTED"); } @@ -236,6 +292,7 @@ void deleteGame() { // read back the game var gameDeleted = doGetSuccess("/games/" + stackId, Game.class); assertFalse(gameDeleted.isActive()); + assertCurrentGameStatus(stackId, GameState.STOPPED.name()); assertTrue(doGetListSuccess("/admin/games/" + stackId + "/event-sources", EventSource.class).isEmpty()); // but still event source must exist @@ -246,11 +303,75 @@ void deleteGame() { doGetError("/games/" + stackId + "/elements/" + TESTBADGE + "?withData=false", HttpStatus.NOT_FOUND, ErrorCodes.ELEMENT_NOT_EXISTS); doGetError("/games/" + stackId + "/elements/" + TESTPOINT + "?withData=false", HttpStatus.NOT_FOUND, ErrorCodes.ELEMENT_NOT_EXISTS); + // game removed message should dispatch + ArgumentCaptor eventArgumentCaptor = ArgumentCaptor.forClass(Object.class); + Mockito.verify(spy, Mockito.times(1)).publishEvent(eventArgumentCaptor.capture()); + { + GameSpecChangedEvent specChangedEvent = (GameSpecChangedEvent) eventArgumentCaptor.getValue(); + assertEquals(EntityChangeType.REMOVED, specChangedEvent.getChangeType()); + assertEquals(stackId, specChangedEvent.getGameId()); + } + + Mockito.verify(engineManager, Mockito.times(1)) + .notifyGameStatusChange(Mockito.eq(GameState.STOPPED), Mockito.any()); + } + + + @Test + void deleteGameTransactional() { + Mockito.reset(engineManager); + ApplicationEventPublisher spy = Mockito.mock(ApplicationEventPublisher.class); + if (gameService instanceof GameService) { + // we know this is the service + ((GameService) gameService).setPublisher(spy); + } + + int stackId = doPostSuccess("/games", stackOverflow, Game.class).getId(); + Game dbGame = doGetSuccess("/games/" + stackId, Game.class); + EventSource eventSource = doPostSuccess("/admin/event-sources", EventSourceCreateRequest.builder().name("test-1").build(), EventSource.class); + doPostSuccess("/admin/games/" + stackId + "/event-sources/" + eventSource.getId(), null, null); + + List elementCreateRequests = TestUtils.parseElementRules("rules.yml", stackId); + ElementDef elementPoint = doPostSuccess("/games/" + stackId + "/elements", TestUtils.findById(TESTPOINT, elementCreateRequests), ElementDef.class); + ElementDef elementBadge = doPostSuccess("/games/" + stackId + "/elements", TestUtils.findById(TESTBADGE, elementCreateRequests), ElementDef.class); + + assertEquals(1, doGetListSuccess("/admin/games/" + stackId + "/event-sources", EventSource.class).size()); + + assertEquals(elementBadge.getElementId(), doGetSuccess("/games/" + stackId + "/elements/" + TESTBADGE + "?withData=false", ElementDef.class).getElementId()); + assertEquals(elementPoint.getElementId(), doGetSuccess("/games/" + stackId + "/elements/" + TESTPOINT + "?withData=false", ElementDef.class).getElementId()); + + Mockito.reset(spy); + Mockito.doThrow(new OasisApiRuntimeException(ErrorCodes.UNABLE_TO_CHANGE_GAME_STATE)) + .when(spy).publishEvent(Mockito.eq(GameSpecChangedEvent.builder() + .gameId(stackId) + .changeType(EntityChangeType.REMOVED) + .build())); + doDeletetError("/games/" + stackId, HttpStatus.INTERNAL_SERVER_ERROR, ErrorCodes.UNABLE_TO_CHANGE_GAME_STATE); + + // read back the game + var gameDeleted = doGetSuccess("/games/" + stackId, Game.class); + assertTrue(gameDeleted.isActive()); + assertCurrentGameStatus(stackId, GameState.CREATED.name()); + + assertEquals(doGetListSuccess("/admin/games/" + stackId + "/event-sources", EventSource.class).size(), 1); + + EventSource dbSource = doGetSuccess("/admin/event-sources/" + eventSource.getId(), EventSource.class); + assertNotNull(dbSource); + assertTrue(dbSource.isActive()); + assertNotNull(doGetSuccess("/games/" + stackId + "/elements/" + TESTBADGE + "?withData=false", ElementDef.class)); + assertNotNull(doGetSuccess("/games/" + stackId + "/elements/" + TESTPOINT + "?withData=false", ElementDef.class)); + // game stopped message should dispatch - ArgumentCaptor eventArgumentCaptor = ArgumentCaptor.forClass(GameStatusChangeEvent.class); - assertEngineManagerOnceCalledWithState(spy, GameState.STOPPED, dbGame, eventArgumentCaptor); + ArgumentCaptor eventArgumentCaptor = ArgumentCaptor.forClass(Object.class); + Mockito.verify(spy, Mockito.times(1)).publishEvent(eventArgumentCaptor.capture()); + { + var value = (GameSpecChangedEvent) eventArgumentCaptor.getAllValues().get(0); + Assertions.assertEquals(EntityChangeType.REMOVED, value.getChangeType()); + Assertions.assertEquals(stackId, value.getGameId()); + } } + @Test void deleteNonExistingGame() { int stackId = doPostSuccess("/games", stackOverflow, Game.class).getId(); @@ -348,12 +469,43 @@ private void assertGame(Game db, GameCreateRequest other) { assertEquals(other.getDescription(), db.getDescription()); assertEquals(other.getLogoRef(), db.getLogoRef()); assertEquals(other.getMotto(), db.getMotto()); + if (other.getStartTime() == null) { + assertEquals(db.getStartTime(), db.getCreatedAt()); + } + if (other.getEndTime() == null) { + assertEquals(db.getEndTime(), LocalDate.of(3001, 1, 1).atStartOfDay() + .toInstant(ZoneOffset.UTC).toEpochMilli()); + } } private void assertGame(Game db, GameUpdateRequest other) { assertTrue(db.getId() > 0); - assertEquals(other.getDescription(), db.getDescription()); - assertEquals(other.getLogoRef(), db.getLogoRef()); - assertEquals(other.getMotto(), db.getMotto()); + if (other.getDescription() != null) { + assertEquals(other.getDescription(), db.getDescription()); + } + if (other.getLogoRef() != null) { + assertEquals(other.getLogoRef(), db.getLogoRef()); + } + if (other.getMotto() != null) { + assertEquals(other.getMotto(), db.getMotto()); + } + if (other.getStartTime() != null) { + assertEquals(other.getStartTime(), db.getStartTime()); + } else { + assertTrue(db.getStartTime() >= 0); + } + if (other.getEndTime() != null) { + assertEquals(other.getEndTime(), db.getEndTime()); + } else { + assertTrue(db.getEndTime() >= 0); + } + } + + private void assertThirdPartyCacheClearanceForGame(int gameId, EntityChangeType changeType) { + ArgumentCaptor captor = ArgumentCaptor.forClass(GameSpecChangedEvent.class); + Mockito.verify(cacheClearanceListener, Mockito.times(1)) + .handleGameUpdateEvent(captor.capture()); + assertEquals(gameId, captor.getValue().getGameId()); + assertEquals(EntityChangeType.MODIFIED, captor.getValue().getChangeType()); } }