diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65a8333 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Gradle +/.gradle +build/ + +# Yarn +/kotlin-js-store diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..d2e3f31 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + kotlin("multiplatform") version "1.7.0" apply false +} + +group = "opensavvy" +version = "1.0-SNAPSHOT" + +subprojects { + group = rootProject.group + version = rootProject.version +} + +allprojects { + repositories { + mavenCentral() + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..a32ea3a --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +kotlin.code.style=official +kotlin.mpp.stability.nowarn=true +kotlin.js.generate.executable.default=false diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..41d9927a4d4fb3f96a785543079b8df6723c946b GIT binary patch literal 59821 zcmWIWW@h1HVBp|jU|?`$00AZt!N9=4$-uzi>l)&y>*?pF&&+_TFn6P!tpfuCgFOQS zg9x%hUq?SrH`m}0JzuxazGqJRccpwh-0K5rCf zo|1Hb%=t$|+Du{N1LhwZM>Yy`a>SMx7Rwi(ySnrHx%2V&>lrG#_A-3lvAJV6oOj?DLgEJ0!Cg-|K0&O+BBYpS(=)dFPpPORuKd_*UgD z;BNdd)$o*D`X&vL!^aEreh1z^WfFW*UE5tbb+LHmJFepoLb5KGUb0HvKEFi6L9J8D zvT@r^(OirH6GsV{CR=xkSuN!5P z*mpC%)i&1)_`mo|?t3TIIR|dt_npdnQAl?~`O#fD51Y+0ZA5=W8Ev*SzV_|Si&vYK zg+H`k+Q?A8^VY5$-i$+!>mPmkvGZAlg}mGK7p_~H%If}0ADRBj^1SU6nbzijc%4LTi$TAfWBoje(Y z^|yv?2y9(gCffOV|}v4_}&f{c`QC??C8tJd7;jH^*`sPvO`=*(hY@Tt>YFnPzaoeuErTQ<}+FEAL&A0b9Ff#y{I89bdmlwRG*tfF9jKuFT(G(^y2|qLKI1%Eb$sthwHGB{QrRx+YT7vd) zFAI^)KE69A7;N=Qgz9=E%e!z?_G5ZU(?u-tb4|H;(eD*g&E6_&3mOszvw2=B_wm+x1SLn{v^*0U$ z{I$LG7SFETs)aPN3z83R4jq_9JEt%BslgoM+2J4&aY~yRLaEh$iKI`nv zC{G=mk8{1Yy>XXWz+b`e=sdgHG`YF!FGtUs*E6s2A@7Fxmz#KBKmS*+rE>J5v^FZOr+c89jzbpF4fTzFs1jraeV8|UPk{N^~w>rtPlG(|bc{apD9 zuANYa08{TsMQav*7QCrJ|mJXLPznwEA?=73}^5yNytB=wb{d`ebBfRlWLB)Hv zZ!48UzpPDeko)`kh4jrM|L(9o3YD25`Byr2asS`_4pLubYfs4SS}K~^At(`GF*jtT z#?`C|4$C>e88T@(^-aE`u&a(t^d(#{EWbVwTl=S7%nj|Feo!XYJ6yI4y&SF^2_sb@)J{Fb!=^qFV`Uhf!6yO zrdzej^qaV^v+ZrNf8Z2T(R8EjhR~!*A=T#MatE9{H6LA*e8$Va?|a<)pq>Mu>xw=lQUm)FYep4@75ee#WQPyV=O0TpOjW#(kVICa z$v-qNR&I$tV8X({aDyGv`T{j-aCy-sH7&6;rv&EZfTH~LqSWGIpUmQt)V$OpNCmSr z^nS2(sK~!Ov)Qew!a*JyB2f;vrI=JhwYf60qZOp2yi`S8R-aSd$$MLG_l}zm|J&?- zT$#S_#pKK4@m9)CrORqB&-|zSuj-=Y+>3>7w%>*7O1La!C!gGD{{2qzzW2MIzqzYk zU;mfmfcB3QE=>6*i$o7!>hYLtdU8(g0ZZZMAJc>YiT_>k4CyQMh7Tjk=u9qoF1b=B?amUyAO0|!2})L$!m z`@rV#(<_WD^SC$VPgAscHMKLl_+Z)5skbgQeMtY&QWZ@$Qw^mNyHi4P0HWMiM?xlWOc3T~ZU z)0!S5n6l3I`DS6^pvebMr_O9p{I+GbV$`P?FDa3hXil-0De4ku);=*xo+9A2UF~O4 zY++DQ>2|M!+KXO1wcxxGJUb$H>8$sYpWmEy@_}!~?bA<|AIqP9q$r_UZ)3*t$+}l% zUQJ3r6cVRZ9{hmy&do)V_ZRNinj*GW>gbc4lSaFyrUx)Zy*L!gookV^P*zr~Z^7k5 znmj_Q9hu)p%y7GSh;z=C7dtDj9_VS!o?&XBZu|by#&?dhA9h5QzIb7L+0=Z)^dsAX zmCi|5oImt?N8Ur`81oN`&fn@o=DNIp;H!H_MAd5UzvmhGY`Uu2S2n)C?8JXHtyI$Q zy0quQ!0Ml_9}9hVs7`!)`{|AqWv7pwuyb86@nc)`!eh}7{d|4yE;zOP({5&S^MHV) z^(jXTO?T=0{m#oW?%ub3l}u1n&T+$&slgfEf403@SQGbc!nTy)n^yc5#$PVv&S3c1 z+9r0>u;lO76U!DFs{C#{X7F=&!IU?KzqUKPmg?>NVimEgo_FDw15#VIY-{_#EPB;< z8?V*P_nP0>O&+-)`5>(L*Qa8^{4RI>zSXbI78cB$<@rS5>`L?374wyy;uDrE|M+gX z#gu_FrRx=(RSti!=6cEey`>~T=8Vi74rn4w?8`DGq@7<=3aRz@_uop z%YU|uKly59Hy-B~`5_><<&{yK1M|�cCgb#m`a|c{XyiMM^|2OP(X?bxKl6T=|ov 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 Dsm?U7 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..aa991fc --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original 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 +# +# https://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 POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${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 "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# 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 ;; #( + MSYS* | 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" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@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 https://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 Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@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 execute + +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 execute + +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 + +: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 %* + +: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/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..cc01ac5 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,2 @@ + +rootProject.name = "Pedestal" -- 2.51.2 From 4af7f0fa02465cfa88fef8c7331a2e1a13d535e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 26 Jun 2022 13:18:48 +0200 Subject: [PATCH 02/11] build: Ignore useless IDEA files --- .idea/.gitignore | 21 ++++++++++++++++++++ .idea/.name | 1 + .idea/inspectionProfiles/Project_Default.xml | 5 +++++ 3 files changed, 27 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/.name create mode 100644 .idea/inspectionProfiles/Project_Default.xml diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..d873c80 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,21 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Compiled files +/artifacts + +# Will be regerenated when the project loads if they are missing +/compiler.xml +/git_toolbox_prj.xml +/gradle.xml +/jarRepositories.xml +/modules +/vcs.xml + +# Different for everyone +/misc.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..2258eec --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Pedestal \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..e13319d --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,5 @@ + + + + -- 2.51.2 From 93fec84dc81e619d5ff698ddfd8ded4848bb47ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 24 Jun 2022 22:55:18 +0200 Subject: [PATCH 03/11] build(gradle): Added the RefreshVersions plugin --- build.gradle.kts | 2 +- settings.gradle.kts | 4 ++++ versions.properties | 10 ++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 versions.properties diff --git a/build.gradle.kts b/build.gradle.kts index d2e3f31..5ccc4fa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - kotlin("multiplatform") version "1.7.0" apply false + kotlin("multiplatform") apply false } group = "opensavvy" diff --git a/settings.gradle.kts b/settings.gradle.kts index cc01ac5..90c2ff2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,6 @@ rootProject.name = "Pedestal" + +plugins { + id("de.fayard.refreshVersions") version "0.40.1" +} diff --git a/versions.properties b/versions.properties new file mode 100644 index 0000000..696b5a3 --- /dev/null +++ b/versions.properties @@ -0,0 +1,10 @@ +#### Dependencies and Plugin versions with their available updates. +#### Generated by `./gradlew refreshVersions` version 0.40.1 +#### +#### Don't manually edit or split the comments that start with four hashtags (####), +#### they will be overwritten by refreshVersions. +#### +#### suppress inspection "SpellCheckingInspection" for whole file +#### suppress inspection "UnusedProperty" for whole file + +version.kotlin=1.7.0 -- 2.51.2 From a9a7a1424f0861b2926e544126263aec8a237925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 24 Jun 2022 23:02:45 +0200 Subject: [PATCH 04/11] build(backbone): Created the Backbone project --- README.md | 90 +------------------ backbone/README.md | 9 ++ backbone/build.gradle.kts | 22 +++++ .../kotlin/opensavvy.backbone/Test.kt | 13 +++ settings.gradle.kts | 4 + 5 files changed, 50 insertions(+), 88 deletions(-) create mode 100644 backbone/README.md create mode 100644 backbone/build.gradle.kts create mode 100644 backbone/src/commonTest/kotlin/opensavvy.backbone/Test.kt diff --git a/README.md b/README.md index ee74b56..d193f83 100644 --- a/README.md +++ b/README.md @@ -2,91 +2,5 @@ OpenSavvy Pedestal is a collection of open source utilities for unified software architecture, aiming to solve common problems in a simple and elegant way. -## Getting started - -To make it easy for you to get started with GitLab, here's a list of recommended next steps. - -Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! - -## Add your files - -- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files -- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command: - -``` -cd existing_repo -git remote add origin https://gitlab.com/opensavvy/pedestal.git -git branch -M main -git push -uf origin main -``` - -## Integrate with your tools - -- [ ] [Set up project integrations](https://gitlab.com/opensavvy/pedestal/-/settings/integrations) - -## Collaborate with your team - -- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) -- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) -- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) -- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) -- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html) - -## Test and Deploy - -Use the built-in continuous integration in GitLab. - -- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) -- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) -- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) -- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) -- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) - -*** - -# Editing this README - -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template. - -## Suggestions for a good README -Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. - -## Name -Choose a self-explaining name for your project. - -## Description -Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. - -## Badges -On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. - -## Visuals -Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. - -## Installation -Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. - -## Usage -Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. - -## Support -Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. - -## Roadmap -If you have ideas for releases in the future, it is a good idea to list them in the README. - -## Contributing -State if you are open to contributions and what your requirements are for accepting them. - -For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. - -You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. - -## Authors and acknowledgment -Show your appreciation to those who have contributed to the project. - -## License -For open source projects, say how it is licensed. - -## Project status -If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. +- [Pedestal Backbone](backbone/README.md) unifies API architecture by including caching and efficient transfers directly into the API itself. +- Pedestal Spine (TODO) allows Backbone implementations to interact with each other transparently through a network boundary. diff --git a/backbone/README.md b/backbone/README.md new file mode 100644 index 0000000..912d008 --- /dev/null +++ b/backbone/README.md @@ -0,0 +1,9 @@ +# Pedestal Backbone • A common API structure + +A regular REST API suffers from the following issues: +- most endpoints send recursive data, which often include information that the client already has, +- no caching capabilities, +- difficult integration with reactive UI frameworks. + +Pedestal Backbone solves these issues by integrating cache management directly into the API to reduce transfer volume. +The client code can easily subscribe to API events using their favorite reactive UI framework for easy state management. diff --git a/backbone/build.gradle.kts b/backbone/build.gradle.kts new file mode 100644 index 0000000..4dcb861 --- /dev/null +++ b/backbone/build.gradle.kts @@ -0,0 +1,22 @@ +@file:Suppress("UNUSED_VARIABLE") + +plugins { + kotlin("multiplatform") +} + +kotlin { + jvm() + js(IR) { + browser() + nodejs() + } + + sourceSets { + val commonTest by getting { + dependencies { + implementation(kotlin("test-common")) + implementation(kotlin("test-annotations-common")) + } + } + } +} diff --git a/backbone/src/commonTest/kotlin/opensavvy.backbone/Test.kt b/backbone/src/commonTest/kotlin/opensavvy.backbone/Test.kt new file mode 100644 index 0000000..cdb1747 --- /dev/null +++ b/backbone/src/commonTest/kotlin/opensavvy.backbone/Test.kt @@ -0,0 +1,13 @@ +package opensavvy.backbone + +import kotlin.test.Test +import kotlin.test.assertTrue + +class BackboneTest { + + @Test + fun test() { + assertTrue(true) + } + +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 90c2ff2..11306ed 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,3 +4,7 @@ rootProject.name = "Pedestal" plugins { id("de.fayard.refreshVersions") version "0.40.1" } + +include( + "backbone", +) -- 2.51.2 From 1626d7fa597b47c297991ce6fac41b8ec8e063ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 24 Jun 2022 23:12:37 +0200 Subject: [PATCH 05/11] docs: Basic introduction to Gradle --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index d193f83..bd62176 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,13 @@ OpenSavvy Pedestal is a collection of open source utilities for unified software - [Pedestal Backbone](backbone/README.md) unifies API architecture by including caching and efficient transfers directly into the API itself. - Pedestal Spine (TODO) allows Backbone implementations to interact with each other transparently through a network boundary. + +## Development + +Pedestal is managed by Gradle, which requires a valid Java installation. +On Windows, substitute `./gradlew` by `gradlew.bat` in the given commands. + +Gradle is invoked by running `./gradlew ` in the project root. +- `./gradlew projects` to list the available projects, +- `./gradlew tasks` to list the available tasks in the root project, +- `./gradlew :` to execute a task from another project (for example, `./gradlew backbone:tasks` to get the list of tasks in the Backbone project). -- 2.51.2 From 74d9c62d603a4533d3243490cceb0357c943b6d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 24 Jun 2022 23:43:30 +0200 Subject: [PATCH 06/11] build(tester): Created the Tester project --- backbone/build.gradle.kts | 3 +-- settings.gradle.kts | 1 + tester/README.md | 3 +++ tester/build.gradle.kts | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 tester/README.md create mode 100644 tester/build.gradle.kts diff --git a/backbone/build.gradle.kts b/backbone/build.gradle.kts index 4dcb861..b8b4edc 100644 --- a/backbone/build.gradle.kts +++ b/backbone/build.gradle.kts @@ -14,8 +14,7 @@ kotlin { sourceSets { val commonTest by getting { dependencies { - implementation(kotlin("test-common")) - implementation(kotlin("test-annotations-common")) + implementation(project(":tester")) } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 11306ed..c3dd07e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,4 +7,5 @@ plugins { include( "backbone", + "tester", ) diff --git a/tester/README.md b/tester/README.md new file mode 100644 index 0000000..d9e22ad --- /dev/null +++ b/tester/README.md @@ -0,0 +1,3 @@ +# Pedestal Tester + +Small convenience module to store common utilities for testing. diff --git a/tester/build.gradle.kts b/tester/build.gradle.kts new file mode 100644 index 0000000..8449a6b --- /dev/null +++ b/tester/build.gradle.kts @@ -0,0 +1,34 @@ +@file:Suppress("UNUSED_VARIABLE") + +plugins { + kotlin("multiplatform") +} + +kotlin { + jvm() + js(IR) { + browser() + nodejs() + } + + sourceSets { + val commonMain by getting { + dependencies { + api(kotlin("test-common")) + api(kotlin("test-annotations-common")) + } + } + + val jvmMain by getting { + dependencies { + api(kotlin("test-junit")) + } + } + + val jsMain by getting { + dependencies { + api(kotlin("test-js")) + } + } + } +} -- 2.51.2 From 51229675b88c53927dec013262cea8f224bbb1c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 24 Jun 2022 23:47:07 +0200 Subject: [PATCH 07/11] build(idea): Run configurations for unit tests --- .idea/runConfigurations/All_tests.xml | 23 +++++++++++++++++++ .../runConfigurations/All_tests__Browser_.xml | 23 +++++++++++++++++++ .idea/runConfigurations/All_tests__JVM_.xml | 23 +++++++++++++++++++ .../runConfigurations/All_tests__NodeJS_.xml | 23 +++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 .idea/runConfigurations/All_tests.xml create mode 100644 .idea/runConfigurations/All_tests__Browser_.xml create mode 100644 .idea/runConfigurations/All_tests__JVM_.xml create mode 100644 .idea/runConfigurations/All_tests__NodeJS_.xml diff --git a/.idea/runConfigurations/All_tests.xml b/.idea/runConfigurations/All_tests.xml new file mode 100644 index 0000000..40d3617 --- /dev/null +++ b/.idea/runConfigurations/All_tests.xml @@ -0,0 +1,23 @@ + + + + + + + true + true + false + + + \ No newline at end of file diff --git a/.idea/runConfigurations/All_tests__Browser_.xml b/.idea/runConfigurations/All_tests__Browser_.xml new file mode 100644 index 0000000..da6e624 --- /dev/null +++ b/.idea/runConfigurations/All_tests__Browser_.xml @@ -0,0 +1,23 @@ + + + + + + + true + true + false + + + \ No newline at end of file diff --git a/.idea/runConfigurations/All_tests__JVM_.xml b/.idea/runConfigurations/All_tests__JVM_.xml new file mode 100644 index 0000000..2d293ab --- /dev/null +++ b/.idea/runConfigurations/All_tests__JVM_.xml @@ -0,0 +1,23 @@ + + + + + + + true + true + false + + + \ No newline at end of file diff --git a/.idea/runConfigurations/All_tests__NodeJS_.xml b/.idea/runConfigurations/All_tests__NodeJS_.xml new file mode 100644 index 0000000..34e445f --- /dev/null +++ b/.idea/runConfigurations/All_tests__NodeJS_.xml @@ -0,0 +1,23 @@ + + + + + + + true + true + false + + + \ No newline at end of file -- 2.51.2 From 7c7ef7716ec08b3a33ad6d379bd235dc99f98d08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 25 Jun 2022 20:38:05 +0200 Subject: [PATCH 08/11] feat(backbone): Basic objects See https://gitlab.com/opensavvy/pedestal/-/issues/1 --- backbone/build.gradle.kts | 6 + .../kotlin/opensavvy/backbone/Backbone.kt | 40 +++ .../kotlin/opensavvy/backbone/Cache.kt | 239 ++++++++++++++++++ .../kotlin/opensavvy/backbone/Data.kt | 185 ++++++++++++++ .../kotlin/opensavvy/backbone/Ref.kt | 64 +++++ .../kotlin/opensavvy/backbone/Result.kt | 36 +++ versions.properties | 2 + 7 files changed, 572 insertions(+) create mode 100644 backbone/src/commonMain/kotlin/opensavvy/backbone/Backbone.kt create mode 100644 backbone/src/commonMain/kotlin/opensavvy/backbone/Cache.kt create mode 100644 backbone/src/commonMain/kotlin/opensavvy/backbone/Data.kt create mode 100644 backbone/src/commonMain/kotlin/opensavvy/backbone/Ref.kt create mode 100644 backbone/src/commonMain/kotlin/opensavvy/backbone/Result.kt diff --git a/backbone/build.gradle.kts b/backbone/build.gradle.kts index b8b4edc..f821584 100644 --- a/backbone/build.gradle.kts +++ b/backbone/build.gradle.kts @@ -12,6 +12,12 @@ kotlin { } sourceSets { + val commonMain by getting { + dependencies { + api(KotlinX.coroutines.core) + } + } + val commonTest by getting { dependencies { implementation(project(":tester")) diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/Backbone.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/Backbone.kt new file mode 100644 index 0000000..7a85e36 --- /dev/null +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/Backbone.kt @@ -0,0 +1,40 @@ +package opensavvy.backbone + +import kotlinx.coroutines.flow.Flow + +/** + * A common interface for API endpoints. + * + * A [Backbone] represents all endpoints for a specific data type. + * Each method represents a different endpoint to implement actions on the relevant objects (list all objects, + * create a new one, etc). + */ +interface Backbone { + + /** + * A cache used to store previous results of [directRequest]. + * + * As a convenience, you can use [request] to launch a request through the cache. + */ + val cache: Cache + + /** + * Fetches the value associated with a [ref] in an external media (e.g. a remote server, a database). + * + * This function completely bypasses the [cache]: a request will be sent everytime it is called. + * To avoid sending unnecessary requests, use [request] instead. + * + * The returned flow is **short-lived**: it is closed after the request finishes. + */ + fun directRequest(ref: Ref): Flow> + + companion object { + /** + * Fetches the value associated with a [ref] in an external media (e.g. a remote server, a database). + * + * This function takes the [cache] into account, it will return a previous value if there is one stored. + * To force sending a request, use [directRequest] instead. + */ + fun Backbone.request(ref: Ref) = cache[ref] + } +} diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/Cache.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/Cache.kt new file mode 100644 index 0000000..96503ce --- /dev/null +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/Cache.kt @@ -0,0 +1,239 @@ +package opensavvy.backbone + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import opensavvy.backbone.Cache.Default +import opensavvy.backbone.Ref.Companion.directRequest + +/** + * Stores information temporarily to avoid unneeded network requests. + * + * The value stored behind a reference can be accessed through the [get] operator. + * + * ### Cache state + * + * Each piece of data in a cache can be in three different states: + * - Up-to-date: the last query was not long ago, we consider its result still valid, + * - Stale: the last query was long enough ago that it deserves to be checked again, but it probably is still valid. + * In this case, the cache returns the old value and starts a refresh request in the background. + * - Expired: the last query was too long ago for the data to still be valid. + * In this case, the cache starts a refresh request and will not return a value until the request finishes. + * All data that was never previously queried is in this state. + * + * Different cache implementations differ on how they transition data from one state to another. + * Some cache implementations may not have a 'stale' state. + * As a user of the cache, you may want to force the state of a specific object if you have more knowledge than the cache, + * in this case you can use [update] and [expire]. + * + * ### Cache chaining + * + * Cache implementations can be chained. + * A possible scenario for some data that rarely changes can be: + * - Cache the data in memory for 5 minutes, + * - Cache the data in hard storage for 1 hour, + * - Query the data for real afterwards. + * + * Cache chaining is instantiated in the opposite order, like iterators (the last in the chain is the first checked, + * and delegates to the previous one if they do not have the value). + * The first element of the chain, and therefore the one responsible for actually starting the request, is [Default]. + * Note that [Default] has a few implementation differences, it is not recommended to use it directly without chaining it under another implementation. + */ +interface Cache { + + /** + * Gets the value associated with a [ref] in this cache. + * + * This function returns a [Flow] synchronously: it is safe to call in synchronous-only areas of the program, such + * as inside the body of a UI component. + * You can then subscribe to the [Flow] to access the actual values. + * + * For example, here is it how using a value in a React application could look like: + * ```kotlin + * fun Component(props) { + * // Query the cache + * val flow = props.cache[props.ref] + * // Create a React-styled state so React re-renders + * // the component when the cache returns a new value + * var data by useState(props.ref.initialData) + * + * // Asynchronously wait for new values in the cache + * useEffect { + * val job = Job() + * + * // Subscribe to new events from the cache, and assign them to + * // the React state to tell React when a re-render is necessary + * CoroutineScope(job).launch { + * flow.collect { data = it } + * } + * + * // When the component is unmounted, cancel the asynchronous + * // job (e.g. unsubscribe from the flow) + * cleanup { + * job.cancel() + * } + * } + * } + * ``` + * Of course, in the real world, this machinery could be hidden behind a custom hook + * (e.g. in [Compose](https://developer.android.com/reference/kotlin/androidx/compose/runtime/package-summary#(kotlinx.coroutines.flow.Flow).collectAsState(kotlin.Any,kotlin.coroutines.CoroutineContext))))). + * However, this is a good demonstration of how to adapt flows to any reactive framework. + * + */ + operator fun get(ref: Ref): Flow> + + /** + * Force the cache to accept that [value] is a more recent value for the given [ref] than whatever it was previously + * storing. + * + * This is a convenience method around [updateAll]. + */ + suspend fun update(ref: Ref, value: O) { + update(Data(Result.Success(value), Data.Status.Completed, ref)) + } + + /** + * Force the cache to accept that [value] is a more recent value for the given [Data.ref] than whatever it was + * previously storing. + * + * This is a convenience method around [updateAll]. + */ + suspend fun update(value: Data) { + updateAll(listOf(value)) + } + + /** + * Force the cache to accept that the given [values] are more recent values for the given [Data.ref] than + * whatever it was previously storing. + * + * Values are updated in all cache layers. + * + * If multiple values are provided for the same reference, a cache implementation may either: + * - take only the first occurrence into account, + * - take only the last occurrence into account, + * - take all occurrences into account, as if `updateAll` was called once consecutively with each occurrence, in the same order as their appearance in the iterable. + * + * If [values] is empty, this function does nothing. + * + * For convenience, you may use [update] instead. + */ + suspend fun updateAll(values: Iterable>) + + /** + * Force the cache to accept that whatever value is currently associated with the given [ref] is invalid and + * should be forgotten. + * + * The next call of [get] for the given reference will query the value from scratch instead of using the cache. + * + * To expire a value and immediately start a query, see [refresh]. + * + * This is a convenience method around [expireAll]. + */ + suspend fun expire(ref: Ref) { + expireAll(listOf(ref)) + } + + /** + * Force the cache to accept that whatever values are currently associated with the given [refs] are invalid and + * should be forgotten. + * + * The next call of [get] for any of the given references will query the value from scratch instead of using the cache. + * Therefore, calling this function with an empty iterable does nothing. + * + * To expire a single reference, you may use [expire] instead. + * To expire all values, use [expireAll] instead. + */ + suspend fun expireAll(refs: Iterable>) + + /** + * Expires all the given values for this layer and the previous ones. + * + * @see expireAll + */ + suspend fun expireAllRecursively(refs: Iterable>) + + /** + * Force the cache to forget its entire internal state. + * + * The next call of [get] on any reference will query the value from scratch instead of using the cache. + * + * To expire selected references, use [expire] and [expireAll] instead. + */ + suspend fun expireAll() + + /** + * Expires all values for this layer and the previous ones. + * + * @see expireAll + */ + suspend fun expireAllRecursively() + + /** + * Default cache implementation aimed to be used as the first link in a cache chain. + * + * This is not actually a valid implementation of a cache (it doesn't do any caching) and only serves as a link + * between caches and the underlying network APIs (via [Backbone]), it takes a few liberties with the API: + * - [get] doesn't actually return a long-lived flow, + * - [updateAll] and [expireAll] don't actually do anything. + */ + class Default : Cache { + override fun get(ref: Ref): Flow> = flow { + emit(Data(Result.NoData, Data.Status.Loading.Basic(), ref)) + + // Request the backbone and emit all the results + val result = ref.directRequest() + emitAll(result) + } + + override suspend fun updateAll(values: Iterable>) { + // This has no state, there is nothing to update + } + + override suspend fun expireAll(refs: Iterable>) { + // This has no state, there is nothing to expire + } + + override suspend fun expireAll() { + // This has no state, there is nothing to expire + } + + override suspend fun expireAllRecursively() { + // This has no state, there is nothing to expire + } + + override suspend fun expireAllRecursively(refs: Iterable>) { + // This has no state, there is nothing to expire + } + } + + /** + * Simple implementation that handles delegating to an [upstream] cache layer the functions that should be delegated. + */ + abstract class CacheLayer(protected val upstream: Cache) : Cache { + override suspend fun updateAll(values: Iterable>) { + upstream.updateAll(values) + } + + override suspend fun expireAllRecursively() { + expireAll() + upstream.expireAllRecursively() + } + + override suspend fun expireAllRecursively(refs: Iterable>) { + expireAll(refs) + upstream.expireAllRecursively(refs) + } + } + + companion object { + /** + * Force the cache to request the value again, and start a new query. + * + * This is the same as calling [expire] followed by [get]. + */ + suspend fun Cache.refresh(ref: Ref): Flow> { + expire(ref) + return get(ref) + } + } +} diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/Data.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/Data.kt new file mode 100644 index 0000000..127adb5 --- /dev/null +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/Data.kt @@ -0,0 +1,185 @@ +package opensavvy.backbone + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import opensavvy.backbone.Data.Status +import opensavvy.backbone.Data.Status.Completed +import opensavvy.backbone.Data.Status.Loading +import opensavvy.backbone.Data.Status.Loading.Basic + +/** + * Wrapper around a data object of type [O]. + * + * This class represents the state at a specific time of the result of requesting a [reference][Ref]. + * At that specific time, the following combinaisons are possible: + * - the request is finished and successful, + * - the request is finished and failed, + * - the request is not finished, + * - the request is not finished, but we remember the previous value for the same request. + * + * These states are encoded by the values of [data] and [status], for example a finished request that was successful will + * have [data] be an instance of [Result.Success], with the result being available using [Result.Success.value], and the + * [status] attribute will be [Status.Completed]. + * Read the documentation of the various values to understand all the possible combinaisons. + * + * Use destructuration to easily access the various values: + * ```kotlin + * val (data, status) = someData() + * ``` + * + * This class represents the state of some data at a specific instant in time, which it does not store. + * This class will often appear inside a [Flow], allowing to subscribe to updates to the state as requests terminate. + */ +data class Data( + /** + * The data itself. + * + * For more information, read the [Result] documentation. + */ + val data: Result, + /** + * Whether there is an ongoing request to update the [data]. + * + * For more information, read the [Status] documentation. + */ + val status: Status, + /** + * The reference to the data this object represents, which was used to initiate the request. + */ + val ref: Ref, +) { + + override fun toString() = "Data($data is $status for $ref)" + + /** + * Whether a piece of [Data] is [Completed] or still [Loading]. + */ + sealed class Status { + + /** + * No requests are ongoing for the given [reference][ref]. + * + * @see Loading + */ + object Completed : Status() { + override fun toString() = "Completed" + } + + /** + * A request is ongoing for the given [reference][ref]. + * + * This object stores the amount of [progress][progression] of the ongoing request, which can be used to display + * an indicator to a user. + * + * See [Basic] for a simple implementation of this class. + * + * @see Completed + */ + abstract class Loading( + /** + * How much progress the ongoing request is at. + * + * If it is not possible to know the amount of progress, `null` is used. + * Otherwise, the progression is a number between 0 (inclusive, no progress has been made) + * and 1 (inclusive, the request is completely finished). + * + * The value of 1 is allowed for ease of programming, but it doesn't mean anything: a value which has fully + * finished loading should have a status of [Completed], not "Loading 100%". + */ + val progression: Float?, + ) : Status() { + + /** + * How much progress the ongoing request is at, in percents. + * + * This is a convenience property around [progression]. + * Much like it, `null` is returned when the progress is unknown. + * When progress is known, this returns an integer value between 0 (inclusive, no progress has been made) + * and 100 (inclusive, the request has finished). + */ + val percent: Int? + get() = progression + ?.times(100) + ?.toInt() + + init { + if (progression != null) + require(progression in 0f..1f) { "The progression must be either null or between 0 and 1 (inclusive), found $progression" } + } + + /** + * Simple implementation of the [Loading] abstract class. + * + * This class is a strict implementation without any additional features. + * If you need to add another field (e.g. for transfer units like Mb, speed indicators like Mb/s), create + * your own subclass of [Loading] instead of using this one. + * + * @param progression See [Loading.progression] + */ + class Basic(progression: Float? = null) : Loading(progression) { + override fun toString() = + if (progression != null) "Loading.Basic(progression = $progression)" + else "Loading.Basic" + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Loading) return false + return progression == other.progression + } + + override fun hashCode(): Int { + return progression.hashCode() + } + } + } + } + + companion object { + /** + * The default empty data that can be used as a stand-in for a future value. + * + * This can be used as the argument of React's `useState` or Compose's `collectAsState`. + */ + val Ref.initialData + get() = Data(Result.NoData, Basic(), this) + + /** + * Skips the loading events in the [Flow]. + * + * After calling this method, the flow will only emit values that are completely finished. + * + * @see firstResult + */ + fun Flow>.skipLoading() = filter { it.status !is Loading } + + /** + * Returns the first non-loading element of this [Flow]. + * + * Because this transforms a flow into a single value, the reactivity is lost. + * Only use this method in contexts where being notified on new values is not important. + * + * @see skipLoading + * @see firstSuccessOrThrow + */ + suspend fun Flow>.firstResult() = skipLoading().first() + + /** + * Waits for the first concrete value ([Completed] and not [Result.NoData]). + * + * If it is [successful][Result.Success], the value is returned. + * If it is a [failure][Result.Failure], [Result.Failure.toThrowable] is thrown. + */ + suspend fun Flow>.firstSuccessOrThrow(): O { + val result = skipLoading() + .filter { it.data !is Result.NoData } + .first() + + return when (result.data) { + is Result.NoData -> error("Impossible, we just filtered them out") + is Result.Success -> result.data.value + is Result.Failure -> throw result.data.toThrowable() + } + } + } +} diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/Ref.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/Ref.kt new file mode 100644 index 0000000..0687a2f --- /dev/null +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/Ref.kt @@ -0,0 +1,64 @@ +package opensavvy.backbone + +import kotlinx.coroutines.flow.Flow +import opensavvy.backbone.Backbone.Companion.request +import opensavvy.backbone.Data.Companion.firstSuccessOrThrow +import opensavvy.backbone.Ref.Companion.directRequest +import opensavvy.backbone.Ref.Companion.request + +/** + * A reference to a specific [object][O]. + * + * A reference is a small object that allows to pass around an object from an API without querying it. + * + * [Ref] implementation should ensure that their [equals] and [hashCode] functions are correct. + * The [backbone] field for a particular reference should always return the same [Backbone] object. + * + * To access the value behind a reference, use [directRequest] or [request]. + * + * @param O The object this reference refers to. + */ +interface Ref { + + /** + * The [Backbone] responsible for this reference. + * + * This property should always return the same [Backbone] instance for a given [Ref]. + */ + val backbone: Backbone + + companion object { + /** + * Requests the referenced data (without taking into account the cache). + * + * This is a convenience method around [Backbone.directRequest]. + */ + fun Ref.directRequest() = backbone.directRequest(this) + + /** + * Requests the referenced data, returning a value from the cache if one is stored. + * + * This is a convenience method around [Backbone.request]. + */ + fun Ref.request() = backbone.request(this) + + /** + * Forces the cache to forget anything it might remember about this reference. + * + * The next time [request] is called, a new request will be started. + */ + suspend fun Ref.expire() { + backbone.cache.expire(this) + } + + /** + * Queries the value behind the reference, throws an exception if anything goes wrong. + * + * This method can be used to easily access the value behind a reference. + * However, because it returns a single value and not a [Flow], it is not possible to be notified when the value + * is modified. + * This method should therefore only be used in non-reactive contexts (tests, to write the value to the terminal, etc). + */ + suspend fun Ref.requestValue() = request().firstSuccessOrThrow() + } +} diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/Result.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/Result.kt new file mode 100644 index 0000000..e9fdc96 --- /dev/null +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/Result.kt @@ -0,0 +1,36 @@ +package opensavvy.backbone + +/** + * The various possible results of querying a [reference][Ref]. + * + * There are three major possible cases: + * - [NoData]: the request has not finished yet, the request is neither successful nor failed, + * - [Success]: the request is successful, [Success.value] contains the result, + * - [Failure]: the request has failed, the object contains the reason why. + */ +sealed interface Result { + + /** + * The request has not finished yet, and no previous result is available. + */ + object NoData : Result { + override fun toString() = "NoData" + } + + /** + * The request is successful, [value] contains its result. + */ + data class Success(val value: O) : Result { + override fun toString() = "Success($value)" + } + + /** + * The request has failed. + */ + interface Failure : Result { + /** + * Converts this failure into a [Throwable] object. + */ + fun toThrowable(): Throwable + } +} diff --git a/versions.properties b/versions.properties index 696b5a3..8e358e2 100644 --- a/versions.properties +++ b/versions.properties @@ -8,3 +8,5 @@ #### suppress inspection "UnusedProperty" for whole file version.kotlin=1.7.0 + +version.kotlinx.coroutines=1.6.3 -- 2.51.2 From fb74f0b4d47ec50e3f897e5deb8d90aec9488212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 25 Jun 2022 22:16:42 +0200 Subject: [PATCH 09/11] feat(backbone): ExpirationCache: expire objects from a previous layer after some time --- backbone/build.gradle.kts | 1 + .../backbone/cache/ExpirationCache.kt | 115 ++++++++++++++++++ versions.properties | 2 + 3 files changed, 118 insertions(+) create mode 100644 backbone/src/commonMain/kotlin/opensavvy/backbone/cache/ExpirationCache.kt diff --git a/backbone/build.gradle.kts b/backbone/build.gradle.kts index f821584..239ffb7 100644 --- a/backbone/build.gradle.kts +++ b/backbone/build.gradle.kts @@ -15,6 +15,7 @@ kotlin { val commonMain by getting { dependencies { api(KotlinX.coroutines.core) + api(KotlinX.datetime) } } diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/ExpirationCache.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/ExpirationCache.kt new file mode 100644 index 0000000..55a546e --- /dev/null +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/ExpirationCache.kt @@ -0,0 +1,115 @@ +package opensavvy.backbone.cache + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import opensavvy.backbone.Cache +import opensavvy.backbone.Data +import opensavvy.backbone.Ref +import opensavvy.backbone.cache.ExpirationCache.Companion.expireAfter +import opensavvy.logger.Logger.Companion.debug +import opensavvy.logger.loggerFor +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +/** + * Cache layer that expires values from the previous layer after a specified [duration][expireAfter]. + * + * To add an [ExpirationCache] to a previous layer, use [Cache.expireAfter][ExpirationCache.Companion.expireAfter]: + * ```kotlin + * val cache = Cache.Default + * .expireAfter(5.minutes, Job()) + * ``` + */ +class ExpirationCache( + /** + * The previous cache layer, from which values will be expired. + */ + upstream: Cache, + /** + * After how much time should the values from the previous cache layer be expired. + * + * The values will not be expired exactly this time after the last update. + * Instead, the implementation guarantees that the data will be expired between [expireAfter] and [expireAfter] * 2. + */ + private val expireAfter: Duration = 5.minutes, + /** + * The asynchronous context in which the cleaner runs. + * + * Cancelling this job will cancel the expiration job, after which this cache will stop expiring data. + */ + context: CoroutineContext = EmptyCoroutineContext, +) : Cache.CacheLayer(upstream) { + private val lastUpdate = HashMap, Instant>() + private val lock = Semaphore(1) + + private val job = Job(context[Job]) + private val scope = CoroutineScope(job + CoroutineName("ExpirationCache task")) + + init { + scope.launch { + while (isActive) { + delay(expireAfter) + log.debug { "Starting a cleanup job for expired cache values" } + + lock.withPermit { + val now = Clock.System.now() + val iterator= lastUpdate.iterator() + while (iterator.hasNext()) { + val (_, instant) = iterator.next() + + if (instant < now - expireAfter) + iterator.remove() + } + } + } + } + } + + private suspend fun markAsUpdatedNow(ref: Ref) { + lock.withPermit { + lastUpdate[ref] = Clock.System.now() + } + } + + override fun get(ref: Ref): Flow> = upstream[ref] + .onEach { markAsUpdatedNow(ref) } + + override suspend fun updateAll(values: Iterable>) { + for (value in values) + markAsUpdatedNow(value.ref) + + // Ensure the previous layers are updated as well + super.updateAll(values) + } + + override suspend fun expireAll(refs: Iterable>) { + for (ref in refs) + lock.withPermit { + lastUpdate.remove(ref) + } + } + + override suspend fun expireAll() { + lock.withPermit { + lastUpdate.clear() + } + } + + companion object { + private val log = loggerFor(this) + + /** + * Factory function to easily add a [ExpirationCache] layer to an existing cache chain. + * + * @see ExpirationCache + */ + fun Cache.expireAfter(duration: Duration, context: CoroutineContext = EmptyCoroutineContext) = ExpirationCache(this, duration, context) + } +} diff --git a/versions.properties b/versions.properties index 8e358e2..2854c28 100644 --- a/versions.properties +++ b/versions.properties @@ -10,3 +10,5 @@ version.kotlin=1.7.0 version.kotlinx.coroutines=1.6.3 + +version.kotlinx.datetime=0.4.0 -- 2.51.2 From 6317df8e44c35ca1ef9ae1e77e9b6ebb428ab325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 26 Jun 2022 12:36:53 +0200 Subject: [PATCH 10/11] feat(logger): Implemented a simple multiplatform logging library --- README.md | 1 + backbone/build.gradle.kts | 2 + logger/README.md | 16 +++ logger/build.gradle.kts | 27 +++++ .../kotlin/opensavvy.logger/LogLevel.kt | 52 +++++++++ .../kotlin/opensavvy.logger/Logger.kt | 103 ++++++++++++++++++ .../kotlin/opensavvy.logger/LoggerTest.kt | 39 +++++++ .../jsMain/kotlin/opensavvy.logger/Logger.kt | 27 +++++ .../jvmMain/kotlin/opensavvy/logger/Logger.kt | 42 +++++++ settings.gradle.kts | 1 + tester/build.gradle.kts | 2 + tester/src/jvmMain/resources/logback.xml | 12 ++ versions.properties | 4 + 13 files changed, 328 insertions(+) create mode 100644 logger/README.md create mode 100644 logger/build.gradle.kts create mode 100644 logger/src/commonMain/kotlin/opensavvy.logger/LogLevel.kt create mode 100644 logger/src/commonMain/kotlin/opensavvy.logger/Logger.kt create mode 100644 logger/src/commonTest/kotlin/opensavvy.logger/LoggerTest.kt create mode 100644 logger/src/jsMain/kotlin/opensavvy.logger/Logger.kt create mode 100644 logger/src/jvmMain/kotlin/opensavvy/logger/Logger.kt create mode 100644 tester/src/jvmMain/resources/logback.xml diff --git a/README.md b/README.md index bd62176..bf65fc8 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ OpenSavvy Pedestal is a collection of open source utilities for unified software - [Pedestal Backbone](backbone/README.md) unifies API architecture by including caching and efficient transfers directly into the API itself. - Pedestal Spine (TODO) allows Backbone implementations to interact with each other transparently through a network boundary. +- [Pedestal Logger](logger/README.md) is a simple logger implementation for multiplatform projects. ## Development diff --git a/backbone/build.gradle.kts b/backbone/build.gradle.kts index 239ffb7..506e740 100644 --- a/backbone/build.gradle.kts +++ b/backbone/build.gradle.kts @@ -16,6 +16,8 @@ kotlin { dependencies { api(KotlinX.coroutines.core) api(KotlinX.datetime) + + implementation(project(":logger")) } } diff --git a/logger/README.md b/logger/README.md new file mode 100644 index 0000000..2695089 --- /dev/null +++ b/logger/README.md @@ -0,0 +1,16 @@ +# Pedestal Logger: a simple multiplatform logger + +This module introduces a `Logger` interface which can easily be used in Kotlin Multiplatform code: +```kotlin +import opensavvy.logger.loggerFor + +class Foo { + init { + log.trace { "This is a simple tracing message!" } + } + + companion object { + private val log = loggerFor(this) + } +} +``` diff --git a/logger/build.gradle.kts b/logger/build.gradle.kts new file mode 100644 index 0000000..d033531 --- /dev/null +++ b/logger/build.gradle.kts @@ -0,0 +1,27 @@ +@file:Suppress("UNUSED_VARIABLE") + +plugins { + kotlin("multiplatform") +} + +kotlin { + jvm() + js(IR) { + browser() + nodejs() + } + + sourceSets { + val commonTest by getting { + dependencies { + implementation(project(":tester")) + } + } + + val jvmMain by getting { + dependencies { + implementation("org.slf4j:slf4j-api:_") + } + } + } +} diff --git a/logger/src/commonMain/kotlin/opensavvy.logger/LogLevel.kt b/logger/src/commonMain/kotlin/opensavvy.logger/LogLevel.kt new file mode 100644 index 0000000..8777276 --- /dev/null +++ b/logger/src/commonMain/kotlin/opensavvy.logger/LogLevel.kt @@ -0,0 +1,52 @@ +package opensavvy.logger + +enum class LogLevel { + // The order of the elements is important + TRACE, + DEBUG, + INFO, + WARN, + ERROR, + NONE, + ; + + /** + * `true` if [TRACE] messages should be printed. + */ + val trace = ordinal <= 0 + /** + * `true` if [DEBUG] messages should be printed. + */ + val debug = ordinal <= 1 + /** + * `true` if [INFO] messages should be printed. + */ + val info = ordinal <= 2 + /** + * `true` if [WARN] messages should be printed. + */ + val warn = ordinal <= 3 + /** + * `true` if [ERROR] messages should be printed. + */ + val error = ordinal <= 4 + + companion object { + /** + * The default log level. + * + * Whenever a new [Logger] is created, it uses this value to initialize its own [level][Logger.level]. + * This value is mutable: for example to edit the log level for the whole program, you can use: + * ```kotlin + * import opensavvy.logger.LogLevel + * + * fun main() { + * LogLevel.default = LogLevel.TRACE + * + * // The rest of your code + * } + * ``` + */ + var default = INFO + } +} diff --git a/logger/src/commonMain/kotlin/opensavvy.logger/Logger.kt b/logger/src/commonMain/kotlin/opensavvy.logger/Logger.kt new file mode 100644 index 0000000..883a729 --- /dev/null +++ b/logger/src/commonMain/kotlin/opensavvy.logger/Logger.kt @@ -0,0 +1,103 @@ +package opensavvy.logger + +/** + * Multiplatform interface for logger implementations. + * + * Logging for a class can be implemented as follows: + * ```kotlin + * class Foo { + * init { + * log.trace { "Foo's constructor was called!" } + * } + * + * companion object { + * private val log = loggerFor(this) + * } + * } + * ``` + * + * The messages included or excluded can be selected using [level]. + * For example, to only display error messages for a specific class, use: + * ```kotlin + * class Foo { + * companion object { + * private val log = loggerFor(this) + * + * init { + * log.level = LogLevel.ERROR + * } + * } + * } + * ``` + * To set the log level for the entire program, see [LogLevel.default]. + * + * The available logging methods are: + * - [trace], + * - [debug], + * - [info], + * - [warn], + * - [error]. + * + * They all accept the same arguments: + * - zero or more objects, which will be appended to the message + * - a lambda producing a message, which will only be evaluated if the given level is enabled. + * + * Examples: + * ```kotlin + * log.level { "Simple form" } + * log.level(obj1, obj2) { "With two objects" } + * ``` + * + * Because the various logging methods are inline and check that their level is enabled early, logging using this + * interface is performant no matter the implementation. + * + * The [loggerFor] method is used to instantiate a platform-specific implementation. + * On Kotlin/JS, it uses the `console` object. + * On Kotlin/JVM, it uses the [Slf4j](https://www.slf4j.org/) library, you will need to include a Slf4j binding in your project. + */ +interface Logger { + + /** + * The log level of this logger. + * + * Implementations of the [Logger] interface may add their own system to decide whether a message is printed, + * additionally to this attribute. + * For example, LogBack has its own log level configuration, Slf4j-simple never prints trace and debug messages. + */ + var level: LogLevel + + fun forceTrace(message: String, vararg objects: Any) + fun forceDebug(message: String, vararg objects: Any) + fun forceInfo(message: String, vararg objects: Any) + fun forceWarn(message: String, vararg objects: Any) + fun forceError(message: String, vararg objects: Any) + + companion object { + inline fun Logger.trace(vararg objects: Any, message: () -> String) { + if (level.trace) + forceTrace(message(), *objects) + } + + inline fun Logger.debug(vararg objects: Any, message: () -> String) { + if (level.debug) + forceDebug(message(), *objects) + } + + inline fun Logger.info(vararg objects: Any, message: () -> String) { + if (level.info) + forceInfo(message(), *objects) + } + + inline fun Logger.warn(vararg objects: Any, message: () -> String) { + if (level.warn) + forceWarn(message(), *objects) + } + + inline fun Logger.error(vararg objects: Any, message: () -> String) { + if (level.error) + forceError(message(), *objects) + } + } +} + +expect fun loggerFor(obj: Any): Logger diff --git a/logger/src/commonTest/kotlin/opensavvy.logger/LoggerTest.kt b/logger/src/commonTest/kotlin/opensavvy.logger/LoggerTest.kt new file mode 100644 index 0000000..d98fc62 --- /dev/null +++ b/logger/src/commonTest/kotlin/opensavvy.logger/LoggerTest.kt @@ -0,0 +1,39 @@ +package opensavvy.logger + +import opensavvy.logger.Logger.Companion.debug +import opensavvy.logger.Logger.Companion.error +import opensavvy.logger.Logger.Companion.info +import opensavvy.logger.Logger.Companion.trace +import opensavvy.logger.Logger.Companion.warn +import kotlin.test.Test + +class LoggerTest { + + @Test + fun output() { + val log = loggerFor(this) + log.level = LogLevel.TRACE + + log.trace { "This is a trace message!" } + log.debug { "This is a debug message!" } + log.info { "This is an info message!" } + log.warn { "This is a warning!" } + log.error { "This is an error!" } + } + + private data class Message(val int: Int, val text: String) + + @Test + fun outputWithObject() { + val message = Message(5, "hello") + + val log = loggerFor(this) + log.level = LogLevel.TRACE + + log.trace(message) { "This is a trace message!" } + log.debug(message) { "This is a debug message!" } + log.info(message) { "This is an info message!" } + log.warn(message) { "This is a warning!" } + log.error(message) { "This is an error!" } + } +} diff --git a/logger/src/jsMain/kotlin/opensavvy.logger/Logger.kt b/logger/src/jsMain/kotlin/opensavvy.logger/Logger.kt new file mode 100644 index 0000000..48a15db --- /dev/null +++ b/logger/src/jsMain/kotlin/opensavvy.logger/Logger.kt @@ -0,0 +1,27 @@ +package opensavvy.logger + +private class ConsoleLogger(private val self: Any) : Logger { + override var level = LogLevel.default + + override fun forceTrace(message: String, vararg objects: Any) { + console.log(self::class.simpleName, message, *objects) + } + + override fun forceDebug(message: String, vararg objects: Any) { + console.log(self::class.simpleName, message, *objects) + } + + override fun forceInfo(message: String, vararg objects: Any) { + console.info(self::class.simpleName, message, *objects) + } + + override fun forceWarn(message: String, vararg objects: Any) { + console.warn(self::class.simpleName, message, *objects) + } + + override fun forceError(message: String, vararg objects: Any) { + console.error(self::class.simpleName, message, *objects) + } +} + +actual fun loggerFor(obj: Any): Logger = ConsoleLogger(obj) diff --git a/logger/src/jvmMain/kotlin/opensavvy/logger/Logger.kt b/logger/src/jvmMain/kotlin/opensavvy/logger/Logger.kt new file mode 100644 index 0000000..6aaed4d --- /dev/null +++ b/logger/src/jvmMain/kotlin/opensavvy/logger/Logger.kt @@ -0,0 +1,42 @@ +package opensavvy.logger + +import org.slf4j.LoggerFactory + +private class SlfLogger(self: Any): Logger { + override var level = LogLevel.default + private val logger = LoggerFactory.getLogger(self::class.java) + ?: error("Could not find a logger for ${self::class.java}") + + private fun generateFormat(message: String, vararg objects: Any): String { + var result = message + + repeat(objects.size) { + result += " {}" + } + + return result + } + + override fun forceTrace(message: String, vararg objects: Any) { + logger.trace(generateFormat(message, *objects), *objects) + } + + override fun forceDebug(message: String, vararg objects: Any) { + logger.debug(generateFormat(message, *objects), *objects) + } + + override fun forceInfo(message: String, vararg objects: Any) { + logger.info(generateFormat(message, *objects), *objects) + } + + override fun forceWarn(message: String, vararg objects: Any) { + logger.warn(generateFormat(message, *objects), *objects) + } + + override fun forceError(message: String, vararg objects: Any) { + logger.error(generateFormat(message, *objects), *objects) + } + +} + +actual fun loggerFor(obj: Any): Logger = SlfLogger(obj) diff --git a/settings.gradle.kts b/settings.gradle.kts index c3dd07e..0a51f0d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,5 +7,6 @@ plugins { include( "backbone", + "logger", "tester", ) diff --git a/tester/build.gradle.kts b/tester/build.gradle.kts index 8449a6b..b18b5c2 100644 --- a/tester/build.gradle.kts +++ b/tester/build.gradle.kts @@ -16,6 +16,8 @@ kotlin { dependencies { api(kotlin("test-common")) api(kotlin("test-annotations-common")) + + implementation("ch.qos.logback:logback-classic:_") } } diff --git a/tester/src/jvmMain/resources/logback.xml b/tester/src/jvmMain/resources/logback.xml new file mode 100644 index 0000000..7821ac7 --- /dev/null +++ b/tester/src/jvmMain/resources/logback.xml @@ -0,0 +1,12 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + diff --git a/versions.properties b/versions.properties index 2854c28..b530c1f 100644 --- a/versions.properties +++ b/versions.properties @@ -7,8 +7,12 @@ #### suppress inspection "SpellCheckingInspection" for whole file #### suppress inspection "UnusedProperty" for whole file +version.ch.qos.logback..logback-classic=1.2.11 + version.kotlin=1.7.0 version.kotlinx.coroutines=1.6.3 version.kotlinx.datetime=0.4.0 + +version.org.slf4j..slf4j-api=1.7.36 -- 2.51.2 From 512d8ea2d8628885867d2c1f16fa181e4a76c26e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 26 Jun 2022 13:12:43 +0200 Subject: [PATCH 11/11] feat(backbone): MemoryCache: store objects in a HashMap --- backbone/build.gradle.kts | 2 + .../opensavvy/backbone/cache/MemoryCache.kt | 107 ++++++++++++++++ .../cache/MemoryCacheTest.kt | 117 ++++++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 backbone/src/commonMain/kotlin/opensavvy/backbone/cache/MemoryCache.kt create mode 100644 backbone/src/commonTest/kotlin/opensavvy.backbone/cache/MemoryCacheTest.kt diff --git a/backbone/build.gradle.kts b/backbone/build.gradle.kts index 506e740..316a046 100644 --- a/backbone/build.gradle.kts +++ b/backbone/build.gradle.kts @@ -24,6 +24,8 @@ kotlin { val commonTest by getting { dependencies { implementation(project(":tester")) + + api(KotlinX.coroutines.test) } } } diff --git a/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/MemoryCache.kt b/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/MemoryCache.kt new file mode 100644 index 0000000..2c910c6 --- /dev/null +++ b/backbone/src/commonMain/kotlin/opensavvy/backbone/cache/MemoryCache.kt @@ -0,0 +1,107 @@ +package opensavvy.backbone.cache + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import opensavvy.backbone.Cache +import opensavvy.backbone.Data +import opensavvy.backbone.Data.Companion.initialData +import opensavvy.backbone.Ref +import opensavvy.backbone.cache.MemoryCache.Companion.cachedInMemory +import opensavvy.logger.Logger.Companion.trace +import opensavvy.logger.loggerFor +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * In-memory [Cache] implementation. + * + * Updates from the previous cache layer are stored in a dictionary. + * When [get] is called, results are returned from the dictionary if available. + * Otherwise, the query is transmitted to the previous layer. + * + * This implementation never frees the cache or invalidates elements inside it. + * To free memory, add a subsequent layer responsible for it (e.g. [ExpirationCache]). + * + * Use the [cachedInMemory] factory for easy cache chaining: + * ```kotlin + * val cache = Cache.Default() + * .cachedInMemory() + * .expireAfter(2.minutes) + * ``` + */ +class MemoryCache( + upstream: Cache, + context: CoroutineContext = EmptyCoroutineContext, +) : Cache.CacheLayer(upstream) { + + private val cache = HashMap, MutableStateFlow>>() + private val cacheLock = Semaphore(1) + + private val jobs = HashMap, Job>() + private val jobsLock = Semaphore(1) + + private val subscribeJob = SupervisorJob(context[Job]) + private val scope = CoroutineScope(subscribeJob) + + private fun getUnsafe(ref: Ref) = cache.getOrPut(ref) { MutableStateFlow(ref.initialData) } + + override fun get(ref: Ref): Flow> = flow { + emit(ref.initialData) + + val cached = cacheLock.withPermit { getUnsafe(ref) } + + jobsLock.withPermit { + jobs.getOrPut(ref) { + scope.launch(CoroutineName("MemoryCache for $ref")) { + log.trace { "Subscribing to previous layer for $ref" } + + upstream[ref] + .collect { cached.value = it } + } + } + } + + emitAll(cached) + }.distinctUntilChanged() + .onEach { log.trace(it) { "Updated value" } } + + override suspend fun updateAll(values: Iterable>) { + cacheLock.withPermit { + for (value in values) + getUnsafe(value.ref).value = value + } + + super.updateAll(values) + } + + override suspend fun expireAll(refs: Iterable>) { + cacheLock.withPermit { + for (ref in refs) + cache.remove(ref) + } + + jobsLock.withPermit { + for (ref in refs) { + jobs.remove(ref)?.cancel("MemoryCache.expireAll(refs) was called") + } + } + } + + override suspend fun expireAll() { + cacheLock.withPermit { + cache.clear() + } + + jobsLock.withPermit { + jobs.values.forEach { it.cancel("MemoryCache.expireAll() was called") } + } + } + + companion object { + private val log = loggerFor(this) + + fun Cache.cachedInMemory(context: CoroutineContext = EmptyCoroutineContext) = MemoryCache(this, context) + } +} diff --git a/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/MemoryCacheTest.kt b/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/MemoryCacheTest.kt new file mode 100644 index 0000000..fe91559 --- /dev/null +++ b/backbone/src/commonTest/kotlin/opensavvy.backbone/cache/MemoryCacheTest.kt @@ -0,0 +1,117 @@ +package opensavvy.backbone.cache + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import opensavvy.backbone.* +import opensavvy.backbone.Ref.Companion.requestValue +import opensavvy.backbone.cache.ExpirationCache.Companion.expireAfter +import opensavvy.backbone.cache.MemoryCache.Companion.cachedInMemory +import opensavvy.logger.Logger.Companion.info +import opensavvy.logger.loggerFor +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.minutes + +@OptIn(ExperimentalCoroutinesApi::class) +class MemoryCacheTest { + + private data class AbsoluteIntRef(val value: Int, override val backbone: Backbone) : Ref { + override fun toString() = "AbsoluteIntRef($value)" + } + + private class AbsoluteIntBackbone(override val cache: Cache) : Backbone { + override fun directRequest(ref: Ref): Flow> = flow { + require(ref is AbsoluteIntRef) + + log.info { "directRequest called for $ref" } + + if (ref.value > 0) + emit(Data(Result.Success(ref.value.toUInt()), Data.Status.Completed, ref)) + else + emit(Data(Result.Success((-ref.value).toUInt()), Data.Status.Completed, ref)) + } + + fun convert(value: Int) = AbsoluteIntRef(value, this) + + companion object { + private val log = loggerFor(this) + } + } + + @Test + fun withoutCache() = runTest { + val backbone = AbsoluteIntBackbone(Cache.Default()) + + val zero = backbone.convert(0) + val one = backbone.convert(1) + val minus = backbone.convert(-1) + + assertEquals(0u, zero.requestValue()) + assertEquals(1u, one.requestValue()) + assertEquals(1u, minus.requestValue()) + } + + @Test + fun infiniteMemoryCache() = runTest { + val job = Job() + + val cache = Cache.Default() + .cachedInMemory(coroutineContext + job) + + val backbone = AbsoluteIntBackbone(cache) + + val zero = backbone.convert(0) + val one = backbone.convert(1) + val minus = backbone.convert(-1) + + assertEquals(0u, zero.requestValue()) + assertEquals(1u, one.requestValue()) + assertEquals(1u, minus.requestValue()) + + job.cancel() + } + + @Test + fun expiringDefaultCache() = runTest { + val job = Job() + + val cache = Cache.Default() + .expireAfter(1.minutes, coroutineContext + job) // This is useless, since the previous layer is a Default + + val backbone = AbsoluteIntBackbone(cache) + + val zero = backbone.convert(0) + val one = backbone.convert(1) + val minus = backbone.convert(-1) + + assertEquals(0u, zero.requestValue()) + assertEquals(1u, one.requestValue()) + assertEquals(1u, minus.requestValue()) + + job.cancel() + } + + @Test + fun expiringMemoryCache() = runTest { + val job = Job() + + val cache = Cache.Default() + .cachedInMemory(coroutineContext + job) + .expireAfter(1.minutes, coroutineContext + job) + + val backbone = AbsoluteIntBackbone(cache) + + val zero = backbone.convert(0) + val one = backbone.convert(1) + val minus = backbone.convert(-1) + + assertEquals(0u, zero.requestValue()) + assertEquals(1u, one.requestValue()) + assertEquals(1u, minus.requestValue()) + + job.cancel() + } +}