diff --git a/api-gateway/caching-with-redis/README.md b/api-gateway/caching-with-redis/README.md new file mode 100644 index 0000000..36475c2 --- /dev/null +++ b/api-gateway/caching-with-redis/README.md @@ -0,0 +1,46 @@ +# Redis Cache + +This project provides an example Redis cache implementation in Knot.x. + +It was created with [Knot.x Starter Kit](https://github.com/Knotx/knotx-starter-kit). + +## Run +Build and run docker swarm: + +```bash +./gradlew build +docker swarm init +docker stack deploy -c redis-handler.yml redis-handler +``` + +## Endpoints + +There is one endpoint `GET /book` that calls Google Books API. It's value is cached for 20 seconds. + +You can check the logs with + +```bash +docker service logs redis-handler_knotx +``` + +Open [localhost:8092/book](http://localhost:8092/book). In the logs there should be: + +``` +No valid cache for key: the-book, calling the action +New value cached under key: the-book for 20 seconds +``` + +After refresh the value will be retrieved from Redis and Google Books API won't be called: + +``` +Retrieved value from cache under key the-book +``` + +And after waiting 20 seconds and refreshing the Google API will be called again, because the cache will be invalidated: + +``` +No valid cache for key: the-book, calling the action +New value cached under key: the-book for 20 seconds +``` + +[license]:https://github.com/Cognifide/knotx/blob/master/LICENSE diff --git a/api-gateway/caching-with-redis/build.gradle.kts b/api-gateway/caching-with-redis/build.gradle.kts new file mode 100644 index 0000000..06f0553 --- /dev/null +++ b/api-gateway/caching-with-redis/build.gradle.kts @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2019 Knot.x Project + * + * Licensed 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. + */ +plugins { + id("io.knotx.distribution") + id("com.bmuschko.docker-remote-api") + id("java") +} + +dependencies { + subprojects.forEach { "dist"(project(":${it.name}")) } +} + +sourceSets.named("test") { + java.srcDir("functional/src/test/java") +} + +allprojects { + group = "io.knotx" + + repositories { + jcenter() + gradlePluginPortal() + } +} + +tasks.named("build") { + dependsOn("runTest") +} + +apply(from = "https://raw.githubusercontent.com/Knotx/knotx-starter-kit/${project.property("knotxVersion")}/gradle/docker.gradle.kts") +apply(from = "https://raw.githubusercontent.com/Knotx/knotx-starter-kit/${project.property("knotxVersion")}/gradle/javaAndUnitTests.gradle.kts") diff --git a/api-gateway/caching-with-redis/buildSrc/build.gradle.kts b/api-gateway/caching-with-redis/buildSrc/build.gradle.kts new file mode 100644 index 0000000..9a27953 --- /dev/null +++ b/api-gateway/caching-with-redis/buildSrc/build.gradle.kts @@ -0,0 +1,9 @@ +repositories { + mavenLocal() + mavenCentral() + gradlePluginPortal() +} +dependencies { + implementation("com.bmuschko:gradle-docker-plugin:6.4.0") + implementation("io.knotx:knotx-gradle-plugins:0.1.2") +} \ No newline at end of file diff --git a/api-gateway/caching-with-redis/docker/Dockerfile b/api-gateway/caching-with-redis/docker/Dockerfile new file mode 100644 index 0000000..7cbe38d --- /dev/null +++ b/api-gateway/caching-with-redis/docker/Dockerfile @@ -0,0 +1,11 @@ +FROM knotx/knotx:$knotx_version +LABEL maintainer="Knot.x Project" + +ENV JAVA_OPTS "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=18092" + +COPY ./out/knotx /usr/local/knotx + +HEALTHCHECK --interval=5s --timeout=2s --retries=12 \ + CMD curl --silent --fail localhost:8092/healthcheck || exit 1 + +CMD [ "knotx", "run-knotx" ] diff --git a/api-gateway/caching-with-redis/gradle.properties b/api-gateway/caching-with-redis/gradle.properties new file mode 100644 index 0000000..f50d72a --- /dev/null +++ b/api-gateway/caching-with-redis/gradle.properties @@ -0,0 +1,6 @@ +version=0.0.1-SNAPSHOT +knotxVersion=2.2.1 +knotx.version=2.2.1 +knotxConf=knotx +knotx.conf=knotx +docker.image.name=knotx-example/redis \ No newline at end of file diff --git a/api-gateway/caching-with-redis/gradle/wrapper/gradle-wrapper.jar b/api-gateway/caching-with-redis/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..5c2d1cf Binary files /dev/null and b/api-gateway/caching-with-redis/gradle/wrapper/gradle-wrapper.jar differ diff --git a/api-gateway/caching-with-redis/gradle/wrapper/gradle-wrapper.properties b/api-gateway/caching-with-redis/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..bb8b2fc --- /dev/null +++ b/api-gateway/caching-with-redis/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/api-gateway/caching-with-redis/gradlew b/api-gateway/caching-with-redis/gradlew new file mode 100644 index 0000000..b0d6d0a --- /dev/null +++ b/api-gateway/caching-with-redis/gradlew @@ -0,0 +1,188 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed 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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/api-gateway/caching-with-redis/gradlew.bat b/api-gateway/caching-with-redis/gradlew.bat new file mode 100644 index 0000000..9991c50 --- /dev/null +++ b/api-gateway/caching-with-redis/gradlew.bat @@ -0,0 +1,100 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem http://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/api-gateway/caching-with-redis/knotx/conf/logback.xml b/api-gateway/caching-with-redis/knotx/conf/logback.xml new file mode 100644 index 0000000..d0d52a4 --- /dev/null +++ b/api-gateway/caching-with-redis/knotx/conf/logback.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + %d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/api-gateway/caching-with-redis/knotx/conf/openapi.yaml b/api-gateway/caching-with-redis/knotx/conf/openapi.yaml new file mode 100644 index 0000000..7dad72c --- /dev/null +++ b/api-gateway/caching-with-redis/knotx/conf/openapi.yaml @@ -0,0 +1,31 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Redis Cache Example + +servers: + - url: http://{domain}:{port} + description: The local API server + variables: + domain: + default: localhost + description: api domain + port: + enum: + - '8092' + default: '8092' + +paths: + /healthcheck: + get: + operationId: healthcheck-operation + responses: + default: + description: example vert.x healthcheck + + /book: + get: + operationId: get-book-operation + responses: + default: + description: example cachable endpoint \ No newline at end of file diff --git a/api-gateway/caching-with-redis/knotx/conf/routes/handlers/actions.conf b/api-gateway/caching-with-redis/knotx/conf/routes/handlers/actions.conf new file mode 100644 index 0000000..502e6e2 --- /dev/null +++ b/api-gateway/caching-with-redis/knotx/conf/routes/handlers/actions.conf @@ -0,0 +1,30 @@ +fetch-book-with-redis { + factory = redis-cache + config { + redis.host = redis #it's available under this host in docker compose/swarm + cache.ttl = 20 + cacheKey = the-book + payloadKey = fetch-book + } + doAction = fetch-book +} + +fetch-book { + factory = http + config { + endpointOptions { + path = "/books/v1/volumes/ZqP8E3CmWggC" + domain = www.googleapis.com + port = 443 + allowedRequestHeaders = ["Content-Type"] + } + webClientOptions { + ssl = true + } + } +} + +book-to-body { + factory = payload-to-body + config.key = "fetch-book._result" +} diff --git a/api-gateway/caching-with-redis/knotx/conf/routes/handlers/tasks.conf b/api-gateway/caching-with-redis/knotx/conf/routes/handlers/tasks.conf new file mode 100644 index 0000000..32b8bff --- /dev/null +++ b/api-gateway/caching-with-redis/knotx/conf/routes/handlers/tasks.conf @@ -0,0 +1,8 @@ +get-book-task { + action = fetch-book-with-redis + onTransitions { + _success { + action = book-to-body + } + } +} diff --git a/api-gateway/caching-with-redis/knotx/conf/routes/operations.conf b/api-gateway/caching-with-redis/knotx/conf/routes/operations.conf new file mode 100644 index 0000000..c79924f --- /dev/null +++ b/api-gateway/caching-with-redis/knotx/conf/routes/operations.conf @@ -0,0 +1,53 @@ +routingOperations = ${routingOperations} [ + { + operationId = healthcheck-operation + handlers = [ + { + name = healthcheck + } + ] + } + { + operationId = get-book-operation + handlers = ${config.server.handlers.common.request} [ + { + name = singleFragmentSupplier + config = { + type = json + # the task that the fragment supplier will use to get data + configuration.data-knotx-task = get-book-task + } + } + { + name = fragmentsHandler + config = { + taskFactories = [ + { + factory = default + config { + tasks = { + include required(classpath("routes/handlers/tasks.conf")) + } + nodeFactories = [ + { + factory = action + config.logLevel = info + config.actions = { + include required(classpath("routes/handlers/actions.conf")) + } + } + { + factory = subtasks + } + ] + } + } + ] + } + } + { + name = fragmentsAssembler + } + ] ${config.server.handlers.common.response} + } +] diff --git a/api-gateway/caching-with-redis/modules/healthcheck/build.gradle.kts b/api-gateway/caching-with-redis/modules/healthcheck/build.gradle.kts new file mode 100644 index 0000000..fea6eed --- /dev/null +++ b/api-gateway/caching-with-redis/modules/healthcheck/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + `java-library` +} + +dependencies { + "io.knotx:knotx".let { v -> + implementation(platform("$v-dependencies:${project.property("knotxVersion")}")) + implementation("$v-server-http-api:${project.property("knotxVersion")}") + } + "io.vertx:vertx".let { v -> + implementation("$v-web") + implementation("$v-web-client") + implementation("$v-rx-java2") + implementation("$v-health-check") + } +} diff --git a/api-gateway/caching-with-redis/modules/healthcheck/src/main/java/io/knotx/example/healthcheck/HealthCheckHandlerFactory.java b/api-gateway/caching-with-redis/modules/healthcheck/src/main/java/io/knotx/example/healthcheck/HealthCheckHandlerFactory.java new file mode 100644 index 0000000..d5d9830 --- /dev/null +++ b/api-gateway/caching-with-redis/modules/healthcheck/src/main/java/io/knotx/example/healthcheck/HealthCheckHandlerFactory.java @@ -0,0 +1,29 @@ +package io.knotx.example.healthcheck; + +import io.knotx.server.api.handler.RoutingHandlerFactory; +import io.vertx.core.Handler; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.healthchecks.Status; +import io.vertx.reactivex.core.Vertx; +import io.vertx.reactivex.ext.healthchecks.HealthCheckHandler; +import io.vertx.reactivex.ext.healthchecks.HealthChecks; +import io.vertx.reactivex.ext.web.RoutingContext; + +public class HealthCheckHandlerFactory implements RoutingHandlerFactory { + + @Override + public String getName() { + return "healthcheck"; + } + + @Override + public Handler create(Vertx vertx, JsonObject config) { + HealthChecks checks = HealthChecks.create(vertx); + checks.register("dummy check", 200, future -> { + // do check here + + future.complete(Status.OK()); + }); + return HealthCheckHandler.createWithHealthChecks(checks); + } +} \ No newline at end of file diff --git a/api-gateway/caching-with-redis/modules/healthcheck/src/main/resources/META-INF/services/io.knotx.server.api.handler.RoutingHandlerFactory b/api-gateway/caching-with-redis/modules/healthcheck/src/main/resources/META-INF/services/io.knotx.server.api.handler.RoutingHandlerFactory new file mode 100644 index 0000000..b75544b --- /dev/null +++ b/api-gateway/caching-with-redis/modules/healthcheck/src/main/resources/META-INF/services/io.knotx.server.api.handler.RoutingHandlerFactory @@ -0,0 +1 @@ +io.knotx.example.healthcheck.HealthCheckHandlerFactory \ No newline at end of file diff --git a/api-gateway/caching-with-redis/modules/redis/build.gradle.kts b/api-gateway/caching-with-redis/modules/redis/build.gradle.kts new file mode 100644 index 0000000..545c631 --- /dev/null +++ b/api-gateway/caching-with-redis/modules/redis/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + `java-library` +} + +dependencies { + implementation(group = "org.apache.commons", name = "commons-lang3") + + "io.knotx:knotx".let { v -> + implementation(platform("$v-dependencies:${project.property("knotxVersion")}")) + implementation("$v-server-http-api:${project.property("knotxVersion")}") + implementation("$v-fragments-action-core:${project.property("knotxVersion")}") +// implementation("$v-fragments-handler-core:${project.property("knotxVersion")}") + implementation("$v-server-http-common-placeholders:${project.property("knotxVersion")}") + implementation("$v-commons:${project.property("knotxVersion")}") + } + "io.vertx:vertx".let { v -> + implementation("$v-web") + implementation("$v-web-client") + implementation("$v-rx-java2") + implementation("$v-circuit-breaker") + implementation("$v-redis-client") + } +} diff --git a/api-gateway/caching-with-redis/modules/redis/src/main/java/io/knotx/example/redis/action/RedisCacheAction.java b/api-gateway/caching-with-redis/modules/redis/src/main/java/io/knotx/example/redis/action/RedisCacheAction.java new file mode 100644 index 0000000..7598d0a --- /dev/null +++ b/api-gateway/caching-with-redis/modules/redis/src/main/java/io/knotx/example/redis/action/RedisCacheAction.java @@ -0,0 +1,128 @@ +package io.knotx.example.redis.action; + +import io.knotx.example.redis.client.RedisConnectionManager; +import io.knotx.example.redis.util.JsonSerializer; +import io.knotx.fragments.action.api.Action; +import io.knotx.fragments.api.Fragment; +import io.knotx.fragments.api.FragmentContext; +import io.knotx.fragments.api.FragmentResult; +import io.knotx.server.api.context.ClientRequest; +import io.knotx.server.common.placeholders.PlaceholdersResolver; +import io.knotx.server.common.placeholders.SourceDefinitions; +import io.vertx.core.AsyncResult; +import io.vertx.core.Future; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import io.vertx.core.logging.Logger; +import io.vertx.core.logging.LoggerFactory; +import io.vertx.core.net.SocketAddress; +import io.vertx.redis.client.RedisOptions; +import io.vertx.redis.client.Response; +import java.util.Optional; + +/** + * @see RedisCacheActionFactory + */ +public class RedisCacheAction implements Action { + private static final Logger LOGGER = LoggerFactory.getLogger(RedisCacheAction.class); + + private final RedisConnectionManager redisConnection; + private final RedisCacheActionOptions options; + private final Action doAction; + + RedisCacheAction(Vertx vertx, JsonObject config, Action doAction) { + this.options = new RedisCacheActionOptions(config); + this.doAction = doAction; + + redisConnection = new RedisConnectionManager(vertx, buildRedisOptions(), 2); + } + + @Override + public void apply(FragmentContext fragmentContext, Handler> resultHandler) { + String cacheKey = resolveCacheKey(fragmentContext.getClientRequest()); + + LOGGER.info("Retrieving data from redis (key: {})", cacheKey); + redisConnection.call(redisAPI -> redisAPI.get(cacheKey, response -> { + Optional cachedValue = getObjectFromResponse(response.result()); + + if (!cachedValue.isPresent()) { + LOGGER.info("No valid cache for key '{}'. Calling the action", cacheKey); + callDoActionAndCache(fragmentContext, resultHandler, cacheKey); + } else { + LOGGER.info("Retrieved value from cache under key '{}'", cacheKey); + proceedWithCachedValue(fragmentContext, resultHandler, cachedValue.get()); + } + }), () -> { + LOGGER.warn("Connection with Redis isn't live, calling the action"); + callDoActionAndCache(fragmentContext, resultHandler, cacheKey); + }); + } + + private String resolveCacheKey(ClientRequest clientRequest) { + return PlaceholdersResolver.resolve(options.getCacheKey(), buildSourceDefinitions(clientRequest)); + } + + private SourceDefinitions buildSourceDefinitions(ClientRequest clientRequest) { + return SourceDefinitions.builder() + .addClientRequestSource(clientRequest) + .build(); + } + + private RedisOptions buildRedisOptions() { + SocketAddress address = SocketAddress.inetSocketAddress(options.getRedisPort(), options.getRedisHost()); + + return new RedisOptions() + .setEndpoint(address) + .setPassword(options.getRedisPassword()); + } + + private void proceedWithCachedValue(FragmentContext fragmentContext, Handler> resultHandler, Object cachedValue) { + Fragment fragment = fragmentContext.getFragment(); + fragment.appendPayload(options.getPayloadKey(), cachedValue); + + FragmentResult result = new FragmentResult(fragment, FragmentResult.SUCCESS_TRANSITION); + Future.succeededFuture(result).setHandler(resultHandler); + } + + private void callDoActionAndCache(FragmentContext fragmentContext, Handler> resultHandler, String cacheKey) { + doAction.apply(fragmentContext, asyncResult -> { + if (asyncResult.succeeded()) { + FragmentResult fragmentResult = asyncResult.result(); + + if (FragmentResult.SUCCESS_TRANSITION.equals(fragmentResult.getTransition())) { + JsonObject resultPayload = fragmentResult.getFragment().getPayload(); + Object objectToSerialize = resultPayload.getMap().get(options.getPayloadKey()); + + JsonSerializer + .serializeObject(objectToSerialize) + .ifPresent(serializedObject -> cacheObject(serializedObject, cacheKey)); + } + + Future.succeededFuture(fragmentResult) + .setHandler(resultHandler); + } else { + Future.failedFuture(asyncResult.cause()) + .setHandler(resultHandler); + } + }); + } + + private Optional getObjectFromResponse(Response response) { + return Optional.ofNullable(response) + .map(Response::toString) + .flatMap(JsonSerializer::deserializeObject); + } + + private void cacheObject(String serializedObject, String cacheKey) { + redisConnection.call(redisAPI -> { + redisAPI.setex(cacheKey, options.getTtl(), serializedObject, response -> { + if (response.succeeded()) { + LOGGER.info("New value cached under key: {} for {} seconds", cacheKey, options.getTtl()); + } else { + LOGGER.error("Error while caching new value under key: {}", response.cause(), cacheKey); + } + }); + }, () -> LOGGER.warn("Cannot cache a new value (key: '{}'), because the Redis connection isn't live", cacheKey)); + } +} diff --git a/api-gateway/caching-with-redis/modules/redis/src/main/java/io/knotx/example/redis/action/RedisCacheActionFactory.java b/api-gateway/caching-with-redis/modules/redis/src/main/java/io/knotx/example/redis/action/RedisCacheActionFactory.java new file mode 100644 index 0000000..60667d2 --- /dev/null +++ b/api-gateway/caching-with-redis/modules/redis/src/main/java/io/knotx/example/redis/action/RedisCacheActionFactory.java @@ -0,0 +1,49 @@ +package io.knotx.example.redis.action; + +import io.knotx.fragments.action.api.Action; +import io.knotx.fragments.action.api.ActionFactory; +import io.knotx.fragments.action.api.Cacheable; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; + +/** + * Action factory for caching fragment payload values on Redis server. Can be initialized with a configuration: + *
+ *   productDetails {
+ *     factory = redis-cache
+ *     config {
+ *       redis {
+ *         host = localhost
+ *         port = 6379
+ *         password = my-password
+ *       }
+ *       cache.ttl = 60
+ *       cacheKey = product-{param.id}
+ *       payloadKey = product
+ *     }
+ *     doAction = fetch-product
+ *   }
+ * 
+ * + * Parameters: + *
+ *   redis.host - default value: "localhost"
+ *   redis.port - default value: 6379
+ *   redis.password - empty by default
+ *   cache.ttl - in seconds, default value: 60
+ * 
+ */ +@Cacheable +public class RedisCacheActionFactory implements ActionFactory { + + @Override + public String getName() { + return "redis-cache"; + } + + @Override + public Action create(String alias, JsonObject config, Vertx vertx, Action doAction) { + return new RedisCacheAction(vertx, config, doAction); + } + +} diff --git a/api-gateway/caching-with-redis/modules/redis/src/main/java/io/knotx/example/redis/action/RedisCacheActionOptions.java b/api-gateway/caching-with-redis/modules/redis/src/main/java/io/knotx/example/redis/action/RedisCacheActionOptions.java new file mode 100644 index 0000000..48e34ac --- /dev/null +++ b/api-gateway/caching-with-redis/modules/redis/src/main/java/io/knotx/example/redis/action/RedisCacheActionOptions.java @@ -0,0 +1,73 @@ +package io.knotx.example.redis.action; + +import io.vertx.core.json.JsonObject; + +import java.util.Optional; + +public class RedisCacheActionOptions { + + private static final String DEFAULT_HOST = "localhost"; + private static final int DEFAULT_PORT = 6379; + private static final long DEFAULT_TTL = 60; + + private final long ttl; + private final String payloadKey; + private final String cacheKey; + private final String redisHost; + private final int redisPort; + private final String redisPassword; + + RedisCacheActionOptions(JsonObject config) { + ttl = Optional.of(config) + .map(conf -> conf.getJsonObject("cache")) + .map(cacheConf -> cacheConf.getLong("ttl", DEFAULT_TTL)) + .orElse(DEFAULT_TTL); + + redisHost = Optional.of(config) + .map(conf -> conf.getJsonObject("redis")) + .map(redisConf -> redisConf.getString("host")) + .orElse(DEFAULT_HOST); + + redisPort = Optional.of(config) + .map(conf -> conf.getJsonObject("redis")) + .map(redisConf -> redisConf.getInteger("port")) + .orElse(DEFAULT_PORT); + + redisPassword = Optional.of(config) + .map(conf -> conf.getJsonObject("redis")) + .map(redisConf -> redisConf.getString("password")) + .orElse(null); + + payloadKey = Optional.of(config) + .map(conf-> conf.getString("payloadKey")) + .orElseThrow(() -> new IllegalArgumentException("Action requires payloadKey value in configuration.")); + + cacheKey = Optional.of(config) + .map(conf-> conf.getString("cacheKey")) + .orElseThrow(() -> new IllegalArgumentException("Action requires cacheKey value in configuration.")); + } + + public String getTtl() { + return Long.toString(ttl); + } + + public String getPayloadKey() { + return payloadKey; + } + + public String getCacheKey() { + return cacheKey; + } + + public String getRedisHost() { + return redisHost; + } + + public int getRedisPort() { + return redisPort; + } + + public String getRedisPassword() { + return redisPassword; + } +} diff --git a/api-gateway/caching-with-redis/modules/redis/src/main/java/io/knotx/example/redis/client/RedisConnectionManager.java b/api-gateway/caching-with-redis/modules/redis/src/main/java/io/knotx/example/redis/client/RedisConnectionManager.java new file mode 100644 index 0000000..eb06c9d --- /dev/null +++ b/api-gateway/caching-with-redis/modules/redis/src/main/java/io/knotx/example/redis/client/RedisConnectionManager.java @@ -0,0 +1,81 @@ +package io.knotx.example.redis.client; + +import io.vertx.core.AsyncResult; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import io.vertx.core.logging.Logger; +import io.vertx.core.logging.LoggerFactory; +import io.vertx.redis.client.RedisAPI; +import io.vertx.redis.client.RedisOptions; +import io.vertx.redis.client.Redis; + +import java.util.function.Consumer; + +public class RedisConnectionManager { + + private static final Logger LOGGER = LoggerFactory.getLogger(RedisConnectionManager.class); + + private final Vertx vertx; + private final RedisOptions options; + private final int maxReconnectRetries; + + private Redis client; + private RedisAPI redisApi; + private boolean connected; + + public RedisConnectionManager(Vertx vertx, RedisOptions options, int maxReconnectRetries) { + this.vertx = vertx; + this.options = options; + this.maxReconnectRetries = maxReconnectRetries; + + connect(onConnect -> {}); + } + + public void call(Consumer ifConnected, Runnable ifNotConnected) { + if (connected) { + ifConnected.accept(redisApi); + } else { + ifNotConnected.run(); + } + } + + private void connect(Handler> handler) { + Redis.createClient(vertx, options).connect(onConnect -> { + if (onConnect.succeeded()) { + client = onConnect.result(); + redisApi = RedisAPI.api(client); + connected = true; + + client.exceptionHandler(this::reconnect); + } else { + connected = false; + } + + handler.handle(onConnect); + }); + } + + private long getReconnectDelay(int retry) { + long retryFactor = Math.min(retry, 10); + return (long) Math.pow(2, retryFactor * 10); + } + + private void reconnect(Throwable e) { + LOGGER.error("Connection to Redis lost. Cause: {}", e); + retryReconnect(0); + } + + private void retryReconnect(int retry) { + long delay = getReconnectDelay(retry); + + vertx.setTimer(delay, timer -> connect(onReconnect -> { + if (onReconnect.failed()) { + if (retry + 1 > maxReconnectRetries) { + LOGGER.error("Couldn't connect to Redis (tried {} reconnections). Cause: {}", retry, onReconnect.cause()); + } else { + retryReconnect(retry + 1); + } + } + })); + } +} diff --git a/api-gateway/caching-with-redis/modules/redis/src/main/java/io/knotx/example/redis/util/JsonSerializer.java b/api-gateway/caching-with-redis/modules/redis/src/main/java/io/knotx/example/redis/util/JsonSerializer.java new file mode 100644 index 0000000..c861fb9 --- /dev/null +++ b/api-gateway/caching-with-redis/modules/redis/src/main/java/io/knotx/example/redis/util/JsonSerializer.java @@ -0,0 +1,37 @@ +package io.knotx.example.redis.util; + +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.core.logging.Logger; +import io.vertx.core.logging.LoggerFactory; + +import java.util.Optional; + +public class JsonSerializer { + + private static final Logger LOGGER = LoggerFactory.getLogger(JsonSerializer.class); + + private JsonSerializer() { + // util + } + + public static Optional serializeObject(Object object) { + if (object instanceof JsonObject || object instanceof JsonArray) { + return Optional.of(object.toString()); + } else { + LOGGER.error("Couldn't serialize object {}, it's neither JsonObject nor JsonArray", object); + return Optional.empty(); + } + } + + public static Optional deserializeObject(String json) { + if (json.startsWith("{")) { + return Optional.of(new JsonObject(json)); + } else if (json.startsWith("[")) { + return Optional.of(new JsonArray(json)); + } else { + LOGGER.error("Couldn't deserialize string, it doesn't look like a correct JSON: {}", json); + return Optional.empty(); + } + } +} diff --git a/api-gateway/caching-with-redis/modules/redis/src/main/resources/META-INF/services/io.knotx.fragments.action.api.ActionFactory b/api-gateway/caching-with-redis/modules/redis/src/main/resources/META-INF/services/io.knotx.fragments.action.api.ActionFactory new file mode 100644 index 0000000..85d6cd4 --- /dev/null +++ b/api-gateway/caching-with-redis/modules/redis/src/main/resources/META-INF/services/io.knotx.fragments.action.api.ActionFactory @@ -0,0 +1 @@ +io.knotx.example.redis.action.RedisCacheActionFactory diff --git a/api-gateway/caching-with-redis/out/test/classes/META-INF/graphql-api.main.kotlin_module b/api-gateway/caching-with-redis/out/test/classes/META-INF/graphql-api.main.kotlin_module new file mode 100644 index 0000000..2983af7 Binary files /dev/null and b/api-gateway/caching-with-redis/out/test/classes/META-INF/graphql-api.main.kotlin_module differ diff --git a/api-gateway/caching-with-redis/redis-handler.yml b/api-gateway/caching-with-redis/redis-handler.yml new file mode 100644 index 0000000..71e0964 --- /dev/null +++ b/api-gateway/caching-with-redis/redis-handler.yml @@ -0,0 +1,18 @@ +version: '3.7' + +networks: + knotnet: + +services: + redis: + image: redis + networks: + - knotnet + knotx: + image: knotx-example/redis:latest + command: ["knotx", "run-knotx"] + ports: + - "8092:8092" + - "18092:18092" + networks: + - knotnet diff --git a/api-gateway/caching-with-redis/settings.gradle.kts b/api-gateway/caching-with-redis/settings.gradle.kts new file mode 100644 index 0000000..fdfe9b4 --- /dev/null +++ b/api-gateway/caching-with-redis/settings.gradle.kts @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2019 Knot.x Project + * + * Licensed 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. + */ +rootProject.name = "redis-example" + +pluginManagement { + val knotxVersion: String by settings + plugins { + id("io.knotx.distribution") version knotxVersion + id("io.knotx.release-base") version knotxVersion + } +} + +include("healthcheck") +include("redis") + +project(":healthcheck").projectDir = file("modules/healthcheck") +project(":redis").projectDir = file("modules/redis") \ No newline at end of file