├── .gitignore ├── LICENSE ├── README.md ├── build.gradle.kts ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── libs.versions.toml ├── settings.gradle.kts ├── store-app ├── build.gradle.kts └── src │ ├── main │ ├── kotlin │ │ └── io │ │ │ └── spring │ │ │ └── shoestore │ │ │ ├── SpringShoeStoreApplication.kt │ │ │ └── app │ │ │ ├── config │ │ │ ├── AwsConfig.kt │ │ │ ├── CoreConfig.kt │ │ │ └── RedisConfig.kt │ │ │ ├── entities │ │ │ └── CoreUser.kt │ │ │ ├── http │ │ │ ├── OrderController.kt │ │ │ ├── ShoeController.kt │ │ │ └── api │ │ │ │ ├── orders.kt │ │ │ │ └── shoes.kt │ │ │ └── lifecycle │ │ │ └── OnStartupMessaging.kt │ └── resources │ │ └── application.yaml │ └── test │ └── kotlin │ └── io │ └── spring │ └── shoestore │ ├── InventoryTests.kt │ ├── OrderTests.kt │ ├── ShoeProductTests.kt │ └── support │ └── BaseIntegrationTest.kt ├── store-core ├── build.gradle.kts └── src │ ├── main │ └── kotlin │ │ └── io │ │ └── spring │ │ └── shoestore │ │ └── core │ │ ├── orders │ │ ├── Order.kt │ │ ├── OrderAdminService.kt │ │ ├── OrderProcessingService.kt │ │ ├── OrderQueryService.kt │ │ ├── OrderRepository.kt │ │ ├── OrderResult.kt │ │ └── PlaceOrderCommand.kt │ │ ├── products │ │ ├── Currency.kt │ │ ├── Shoe.kt │ │ ├── ShoeLookupQuery.kt │ │ ├── ShoeRepository.kt │ │ └── ShoeService.kt │ │ ├── security │ │ ├── PrincipalUser.kt │ │ └── StoreAuthProvider.kt │ │ ├── shipments │ │ ├── ReceiveShipmentCommand.kt │ │ ├── ShipmentDetailsRepository.kt │ │ ├── ShipmentLineItem.kt │ │ └── ShipmentReceiverService.kt │ │ └── variants │ │ ├── InventoryItem.kt │ │ ├── InventoryManagementService.kt │ │ ├── InventoryWarehousingRepository.kt │ │ ├── ProductVariant.kt │ │ ├── ProductVariantRepository.kt │ │ ├── ProductVariantService.kt │ │ ├── VariantColor.kt │ │ └── VariantSize.kt │ └── test │ ├── kotlin │ └── io │ │ └── spring │ │ └── shoestore │ │ └── core │ │ ├── CoreTest.kt │ │ └── CurrencyTest.kt │ └── resources │ └── logback.xml └── store-details ├── build.gradle.kts └── src └── main ├── kotlin └── io │ └── spring │ └── shoestore │ ├── aws │ └── dynamodb │ │ ├── DynamoClientHelper.kt │ │ ├── DynamoDbOrderRepository.kt │ │ ├── DynamoTableManager.kt │ │ └── OrderTableDetails.kt │ ├── postgres │ ├── PostgresOrderRepository.kt │ ├── PostgresProductVariantRepository.kt │ ├── PostgresShoeRepository.kt │ └── mappers │ │ ├── OrderMappers.kt │ │ ├── ProductVariantMapper.kt │ │ └── ShoeMapper.kt │ ├── redis │ └── RedisInventoryWarehousingRepository.kt │ └── security │ └── FakeStoreAuthProvider.kt └── resources └── db └── migration ├── V001__Create_Shoe_Table.sql ├── V002__Create_Variants_Table.sql ├── V003__Insert_Sample_Data.sql └── V004__Create_Orders_tables.sql /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | build/ 3 | !gradle/wrapper/gradle-wrapper.jar 4 | !**/src/main/**/build/ 5 | !**/src/test/**/build/ 6 | 7 | ### STS ### 8 | .apt_generated 9 | .classpath 10 | .factorypath 11 | .project 12 | .settings 13 | .springBeans 14 | .sts4-cache 15 | bin/ 16 | !**/src/main/**/bin/ 17 | !**/src/test/**/bin/ 18 | 19 | ### IntelliJ IDEA ### 20 | .idea 21 | *.iws 22 | *.iml 23 | *.ipr 24 | out/ 25 | !**/src/main/**/out/ 26 | !**/src/test/**/out/ 27 | 28 | ### NetBeans ### 29 | /nbproject/private/ 30 | /nbbuild/ 31 | /dist/ 32 | /nbdist/ 33 | /.nb-gradle/ 34 | 35 | ### VS Code ### 36 | .vscode/ 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Steve Pember 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to the Spring Shoe Store! 2 | 3 | > A demonstration app built for Spring I/O 2023 to present Clean Architecture principles in Spring 4 | 5 | ## Overview 6 | 7 | As mentioned, this repository is meant to show off a key techniques to attempt to achieve Clean Architecture: 8 | 9 | ### Individual Components 10 | 11 | The application is broken into three individually tested and built Gradle modules; something the [Book](https://www.oreilly.com/library/view/clean-architecture-a/9780134494272/) would call 'Components'. 12 | 13 | * __store-core__: contains all the Entities, Use Cases, DTOs, Interfaces, and other classes necessary to fulfill the business logic. Has no direct dependencies on the other 14 | components, nor any external systems (e.g. a database). It is ignorant and agnostic of anything that is not Core Business Rules. 15 | * __store-details__: contains the implementation "Details"; the interface implementations responsible for actually communicating with external systems, performing I/O, etc... as well as supporting classes necessary to make that work. 16 | * __store-app__: contains the Spring components, configuration, application lifecycle concerns, and is currently the location of HTTP (e.g. controllers.). 17 | This component is the place of actual 'integration'; where the app comes together. It is also the location of Integration tests. 18 | 19 | The three are meant to be illustrative or a starting point; in a real application you may have more than three. For example, 20 | the current `details` component could be broken up into multiple ones, each for redis, postgres, etc. The http concerns could be extracted. 21 | 22 | 23 | ### Dependency directionality 24 | 25 | All dependencies should point 'inward', that is, everything depends on `Core`, but `Core` has no knowledge of the other components. 26 | Practically, this is largely achieved by liberal use of Interfaces and small data classes contained within the Core package. Other components are thus 27 | dependent on core by adhering to the interface. 28 | 29 | The [Repository pattern](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/infrastructure-persistence-layer-design) is also heavily used. 30 | 31 | > Be advised that this code is truly, truly horrendous. It should be used for architecturalreference - or entertainment - purposes only 32 | 33 | ### Encapsulation and Isolation 34 | 35 | Concerns of the Component should be contained within the Component. 36 | 37 | * the details component marks any non-interface-implementation classes with the `internal` keyword. In Kotlin, this 38 | ensures that the class can not be used by jars / artifacts outside the current module. This pairs _nicely_ with the notion of using multiple components like we do here. 39 | * The http layer in `store-app` contains its own API objects, and translates the internal DTOs to an API-consumer-dedicated object. This also 40 | reduces the cross-boundary dependencies; without these API objects the api consumers would be directly dependent on classes contained in core. 41 | 42 | Yes this results in more mapping code, but this is a small price to pay for the loose coupling we achieve with this 43 | architectural style. 44 | 45 | ### Ignorance of Environment 46 | 47 | The system has no notion of traditional environments (e.g. DEV, TEST, PROD, etc). Instead, it utilizes environment variables 48 | to adjust configuration. This means that the system can use Integration Tests with real external dependencies just as 49 | easily as if it were running locally, or in a CI pipeline, or in a k8s cluster. 50 | 51 | Speaking of testing, this application makes heavy use of [TestContainers](https://www.testcontainers.org/), creating a real 52 | Postgres, Redis, and simulates AWS using the wonderful [Localstack](https://localstack.cloud/). 53 | 54 | 55 | To run the tests, try `./gradlew clean test`. To demonstrate the advantages of the repository pattern, this repo is set up to 56 | swap out the storage of Orders from using Postgres to using DynamoDb. 57 | To do so: 58 | 59 | 1. Verify the current tests work (`./gradlew test`) 60 | 2. Find the `getOrderRepository()` Bean function located in `CoreConfig` within the `store-app` component. 61 | 3. Replace the `return PostgresOrderRepository(jdbcTemplate)` line with `return DynamoDbOrderRepository(dynamoDbClient)`. 62 | 4. Run the tests once again (`./gradlew clean test`) 63 | 5. The tests should still pass! -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | id("org.springframework.boot") version "3.0.5" 5 | id("io.spring.dependency-management") version "1.1.0" 6 | kotlin("jvm") version "1.7.22" 7 | kotlin("plugin.spring") version "1.7.22" 8 | } 9 | 10 | allprojects { 11 | apply(plugin = "org.jetbrains.kotlin.jvm") 12 | apply(plugin = "org.jetbrains.kotlin.plugin.spring") 13 | 14 | 15 | group = "io.spring" 16 | version = "0.1.0-SNAPSHOT" 17 | 18 | java { 19 | sourceCompatibility = JavaVersion.VERSION_17 20 | targetCompatibility = JavaVersion.VERSION_17 21 | } 22 | 23 | repositories { 24 | mavenLocal() 25 | mavenCentral() 26 | } 27 | 28 | tasks.withType { 29 | kotlinOptions { 30 | freeCompilerArgs = listOf("-Xjsr305=strict") 31 | jvmTarget = "17" 32 | } 33 | } 34 | 35 | tasks.withType { 36 | useJUnitPlatform() 37 | } 38 | } 39 | 40 | subprojects { 41 | extra["testcontainersVersion"] = "1.17.6" 42 | extra["junitVersion"] = "5.9.2" 43 | 44 | 45 | 46 | dependencies { 47 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2") 48 | implementation("org.flywaydb:flyway-core:9.16.3") 49 | implementation("org.jetbrains.kotlin:kotlin-reflect:1.8.20") 50 | 51 | testImplementation("org.junit.jupiter:junit-jupiter-api:${project.extra["junitVersion"]}") 52 | testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${project.extra["junitVersion"]}") 53 | 54 | } 55 | } 56 | 57 | 58 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spember/spring-shoestore/4d409c505336400a1f307df54a00183d1ae3b210/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip 4 | networkTimeout=10000 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | # 21 | # Gradle start up script for POSIX generated by Gradle. 22 | # 23 | # Important for running: 24 | # 25 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 26 | # noncompliant, but you have some other compliant shell such as ksh or 27 | # bash, then to run this script, type that shell name before the whole 28 | # command line, like: 29 | # 30 | # ksh Gradle 31 | # 32 | # Busybox and similar reduced shells will NOT work, because this script 33 | # requires all of these POSIX shell features: 34 | # * functions; 35 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 36 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 37 | # * compound commands having a testable exit status, especially «case»; 38 | # * various built-in commands including «command», «set», and «ulimit». 39 | # 40 | # Important for patching: 41 | # 42 | # (2) This script targets any POSIX shell, so it avoids extensions provided 43 | # by Bash, Ksh, etc; in particular arrays are avoided. 44 | # 45 | # The "traditional" practice of packing multiple parameters into a 46 | # space-separated string is a well documented source of bugs and security 47 | # problems, so this is (mostly) avoided, by progressively accumulating 48 | # options in "$@", and eventually passing that to Java. 49 | # 50 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 51 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 52 | # see the in-line comments for details. 53 | # 54 | # There are tweaks for specific operating systems such as AIX, CygWin, 55 | # Darwin, MinGW, and NonStop. 56 | # 57 | # (3) This script is generated from the Groovy template 58 | # https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 59 | # within the Gradle project. 60 | # 61 | # You can find Gradle at https://github.com/gradle/gradle/. 62 | # 63 | ############################################################################## 64 | 65 | # Attempt to set APP_HOME 66 | 67 | # Resolve links: $0 may be a link 68 | app_path=$0 69 | 70 | # Need this for daisy-chained symlinks. 71 | while 72 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 73 | [ -h "$app_path" ] 74 | do 75 | ls=$( ls -ld "$app_path" ) 76 | link=${ls#*' -> '} 77 | case $link in #( 78 | /*) app_path=$link ;; #( 79 | *) app_path=$APP_HOME$link ;; 80 | esac 81 | done 82 | 83 | # This is normally unused 84 | # shellcheck disable=SC2034 85 | APP_BASE_NAME=${0##*/} 86 | APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit 87 | 88 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 89 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 137 | 138 | Please set the JAVA_HOME variable in your environment to match the 139 | location of your Java installation." 140 | fi 141 | 142 | # Increase the maximum file descriptors if we can. 143 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 144 | case $MAX_FD in #( 145 | max*) 146 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 147 | # shellcheck disable=SC3045 148 | MAX_FD=$( ulimit -H -n ) || 149 | warn "Could not query maximum file descriptor limit" 150 | esac 151 | case $MAX_FD in #( 152 | '' | soft) :;; #( 153 | *) 154 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 155 | # shellcheck disable=SC3045 156 | ulimit -n "$MAX_FD" || 157 | warn "Could not set maximum file descriptor limit to $MAX_FD" 158 | esac 159 | fi 160 | 161 | # Collect all arguments for the java command, stacking in reverse order: 162 | # * args from the command line 163 | # * the main class name 164 | # * -classpath 165 | # * -D...appname settings 166 | # * --module-path (only if needed) 167 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 168 | 169 | # For Cygwin or MSYS, switch paths to Windows format before running java 170 | if "$cygwin" || "$msys" ; then 171 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 172 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 173 | 174 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 175 | 176 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 177 | for arg do 178 | if 179 | case $arg in #( 180 | -*) false ;; # don't mess with options #( 181 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 182 | [ -e "$t" ] ;; #( 183 | *) false ;; 184 | esac 185 | then 186 | arg=$( cygpath --path --ignore --mixed "$arg" ) 187 | fi 188 | # Roll the args list around exactly as many times as the number of 189 | # args, so each arg winds up back in the position where it started, but 190 | # possibly modified. 191 | # 192 | # NB: a `for` loop captures its iteration list before it begins, so 193 | # changing the positional parameters here affects neither the number of 194 | # iterations, nor the values presented in `arg`. 195 | shift # remove old arg 196 | set -- "$@" "$arg" # push replacement arg 197 | done 198 | fi 199 | 200 | # Collect all arguments for the java command; 201 | # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of 202 | # shell script including quotes and variable substitutions, so put them in 203 | # double quotes to make sure that they get re-expanded; and 204 | # * put everything else in single quotes, so that it's not re-expanded. 205 | 206 | set -- \ 207 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 208 | -classpath "$CLASSPATH" \ 209 | org.gradle.wrapper.GradleWrapperMain \ 210 | "$@" 211 | 212 | # Stop when "xargs" is not available. 213 | if ! command -v xargs >/dev/null 2>&1 214 | then 215 | die "xargs is not available" 216 | fi 217 | 218 | # Use "xargs" to parse quoted args. 219 | # 220 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 221 | # 222 | # In Bash we could simply go: 223 | # 224 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 225 | # set -- "${ARGS[@]}" "$@" 226 | # 227 | # but POSIX shell has neither arrays nor command substitution, so instead we 228 | # post-process each arg (as a line of input to sed) to backslash-escape any 229 | # character that might be a shell metacharacter, then use eval to reverse 230 | # that process (while maintaining the separation between arguments), and wrap 231 | # the whole thing up as a single "set" statement. 232 | # 233 | # This will of course break if any of these variables contains a newline or 234 | # an unmatched quote. 235 | # 236 | 237 | eval "set -- $( 238 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 239 | xargs -n1 | 240 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 241 | tr '\n' ' ' 242 | )" '"$@"' 243 | 244 | exec "$JAVACMD" "$@" 245 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%"=="" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%"=="" set DIRNAME=. 29 | @rem This is normally unused 30 | set APP_BASE_NAME=%~n0 31 | set APP_HOME=%DIRNAME% 32 | 33 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 34 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 35 | 36 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 37 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 38 | 39 | @rem Find java.exe 40 | if defined JAVA_HOME goto findJavaFromJavaHome 41 | 42 | set JAVA_EXE=java.exe 43 | %JAVA_EXE% -version >NUL 2>&1 44 | if %ERRORLEVEL% equ 0 goto execute 45 | 46 | echo. 47 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 48 | echo. 49 | echo Please set the JAVA_HOME variable in your environment to match the 50 | echo location of your Java installation. 51 | 52 | goto fail 53 | 54 | :findJavaFromJavaHome 55 | set JAVA_HOME=%JAVA_HOME:"=% 56 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 57 | 58 | if exist "%JAVA_EXE%" goto execute 59 | 60 | echo. 61 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 62 | echo. 63 | echo Please set the JAVA_HOME variable in your environment to match the 64 | echo location of your Java installation. 65 | 66 | goto fail 67 | 68 | :execute 69 | @rem Setup the command line 70 | 71 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 72 | 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if %ERRORLEVEL% equ 0 goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | set EXIT_CODE=%ERRORLEVEL% 85 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 86 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 87 | exit /b %EXIT_CODE% 88 | 89 | :mainEnd 90 | if "%OS%"=="Windows_NT" endlocal 91 | 92 | :omega 93 | -------------------------------------------------------------------------------- /libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | jupiter = "5.9.2" 3 | 4 | [libraries] 5 | jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "jupiter" } 6 | jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "jupiter" } 7 | jedis = {module="redis.clients:jedis", version="4.3.2"} 8 | 9 | [plugins] -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "shoestore" 2 | include("store-app") 3 | include("store-core") 4 | include("store-details") 5 | 6 | // shared libs 7 | 8 | dependencyResolutionManagement { 9 | versionCatalogs { 10 | create("libs") { 11 | from(files("./libs.versions.toml")) 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /store-app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("org.springframework.boot") version "3.0.5" 3 | id("io.spring.dependency-management") version "1.1.0" 4 | } 5 | 6 | dependencyManagement { 7 | imports { 8 | mavenBom("org.testcontainers:testcontainers-bom:${property("testcontainersVersion")}") 9 | } 10 | } 11 | 12 | dependencies { 13 | implementation(project(":store-core")) 14 | implementation(project(":store-details")) 15 | implementation("org.springframework.boot:spring-boot-starter-web") 16 | 17 | // It does seem strange to see these 'details' located here in the 'app' module 18 | // However, 1) the key thing is to make 'core' agnostic, 2) the app module is the 'dirtiest' of all and 3) the 19 | // spring starters do give us a great deal of convenience (e.g. the jdbc starter automatically creates a Datasource) 20 | implementation("org.springframework.boot:spring-boot-starter-data-redis") 21 | implementation("org.springframework.boot:spring-boot-starter-jdbc") 22 | 23 | implementation(libs.jedis) 24 | implementation(platform("software.amazon.awssdk:bom:2.20.26")) 25 | implementation("software.amazon.awssdk:dynamodb") 26 | 27 | runtimeOnly("org.postgresql:postgresql") 28 | 29 | testImplementation("org.springframework.boot:spring-boot-starter-test") 30 | testImplementation("org.testcontainers:junit-jupiter") 31 | testImplementation("org.testcontainers:postgresql") 32 | testImplementation("org.testcontainers:localstack") 33 | 34 | testImplementation ("com.amazonaws:aws-java-sdk-s3:1.12.470") 35 | 36 | } -------------------------------------------------------------------------------- /store-app/src/main/kotlin/io/spring/shoestore/SpringShoeStoreApplication.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | 6 | @SpringBootApplication 7 | class SpringShoeStoreApplication 8 | 9 | fun main(args: Array) { 10 | runApplication(*args) 11 | } 12 | -------------------------------------------------------------------------------- /store-app/src/main/kotlin/io/spring/shoestore/app/config/AwsConfig.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.app.config 2 | 3 | import org.springframework.beans.factory.annotation.Value 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import software.amazon.awssdk.auth.credentials.AwsBasicCredentials 7 | import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider 8 | import software.amazon.awssdk.regions.Region 9 | import software.amazon.awssdk.services.dynamodb.DynamoDbClient 10 | import java.net.URI 11 | 12 | @Configuration 13 | class AwsConfig { 14 | 15 | @Bean 16 | fun getDynamoClient( 17 | @Value("\${cloud.aws.region.main}") region: String, 18 | @Value("\${cloud.aws.credentials.access-key}") accessKey: String, 19 | @Value("\${cloud.aws.credentials.secret-key}") secretKey: String, 20 | @Value("\${cloud.aws.end-point.uri}") awsUri: String 21 | ): DynamoDbClient { 22 | return DynamoDbClient.builder() 23 | .endpointOverride(URI(awsUri)) 24 | .region(Region.of(region)) 25 | .credentialsProvider( 26 | StaticCredentialsProvider.create( 27 | AwsBasicCredentials.create( 28 | accessKey, 29 | secretKey 30 | ) 31 | ) 32 | ) 33 | .build() 34 | } 35 | } -------------------------------------------------------------------------------- /store-app/src/main/kotlin/io/spring/shoestore/app/config/CoreConfig.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.app.config 2 | 3 | 4 | import io.spring.shoestore.aws.dynamodb.DynamoDbOrderRepository 5 | import io.spring.shoestore.core.orders.OrderAdminService 6 | import io.spring.shoestore.core.orders.OrderProcessingService 7 | import io.spring.shoestore.core.orders.OrderQueryService 8 | import io.spring.shoestore.core.orders.OrderRepository 9 | import io.spring.shoestore.core.products.ShoeService 10 | import io.spring.shoestore.core.security.PrincipalUser 11 | import io.spring.shoestore.core.security.StoreAuthProvider 12 | import io.spring.shoestore.core.variants.InventoryManagementService 13 | import io.spring.shoestore.core.variants.ProductVariantService 14 | import io.spring.shoestore.postgres.PostgresOrderRepository 15 | import io.spring.shoestore.postgres.PostgresProductVariantRepository 16 | import io.spring.shoestore.postgres.PostgresShoeRepository 17 | import io.spring.shoestore.redis.RedisInventoryWarehousingRepository 18 | import io.spring.shoestore.security.FakeStoreAuthProvider 19 | import org.springframework.context.annotation.Bean 20 | import org.springframework.context.annotation.Configuration 21 | import org.springframework.jdbc.core.JdbcTemplate 22 | import redis.clients.jedis.Jedis 23 | import software.amazon.awssdk.services.dynamodb.DynamoDbClient 24 | 25 | @Configuration 26 | class CoreConfig { 27 | 28 | @Bean 29 | fun getShoeService(jdbcTemplate: JdbcTemplate): ShoeService { 30 | // an example of 'hiding' the details implementation, only the shoeservice can be grabbed via DI 31 | return ShoeService(PostgresShoeRepository(jdbcTemplate)) 32 | } 33 | 34 | @Bean 35 | fun getProductVariantService(jdbcTemplate: JdbcTemplate, jedis: Jedis): ProductVariantService { 36 | return ProductVariantService(PostgresProductVariantRepository(jdbcTemplate)) 37 | } 38 | 39 | 40 | @Bean 41 | // Kotlin-ified! 42 | fun getInventoryManagementService(jdbcTemplate: JdbcTemplate, jedis: Jedis) = InventoryManagementService( 43 | PostgresProductVariantRepository(jdbcTemplate), 44 | RedisInventoryWarehousingRepository(jedis) 45 | ) 46 | 47 | @Bean 48 | fun getOrderRepository(jdbcTemplate: JdbcTemplate, dynamoDbClient: DynamoDbClient): OrderRepository { 49 | return PostgresOrderRepository(jdbcTemplate) 50 | // return DynamoDbOrderRepository(dynamoDbClient) 51 | } 52 | 53 | @Bean 54 | fun getOrderProcessingService( 55 | inventoryManagementService: InventoryManagementService, 56 | shoeService: ShoeService, 57 | orderRepository: OrderRepository 58 | ) = OrderProcessingService(inventoryManagementService, shoeService, orderRepository) 59 | 60 | @Bean 61 | fun getOrderAdminService(orderRepository: OrderRepository) = OrderAdminService(orderRepository) 62 | 63 | @Bean 64 | fun getOrderQueryService(orderRepository: OrderRepository): OrderQueryService = OrderQueryService(orderRepository) 65 | 66 | @Bean 67 | fun getStoreAuthProvider(): StoreAuthProvider { 68 | val provider = FakeStoreAuthProvider() 69 | provider.login(PrincipalUser("Sam Testington", "stestington@test.com")) 70 | return provider 71 | } 72 | } -------------------------------------------------------------------------------- /store-app/src/main/kotlin/io/spring/shoestore/app/config/RedisConfig.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.app.config 2 | 3 | import org.springframework.beans.factory.annotation.Value 4 | import org.springframework.context.annotation.Bean 5 | import org.springframework.context.annotation.Configuration 6 | import org.springframework.data.redis.connection.RedisStandaloneConfiguration 7 | import org.springframework.data.redis.connection.jedis.JedisConnectionFactory 8 | import redis.clients.jedis.Jedis 9 | 10 | 11 | @Configuration 12 | class RedisConfig { 13 | 14 | @Bean 15 | fun jedisConnectionFactory( 16 | @Value("\${spring.redis.host}") redisHost: String, 17 | @Value("\${spring.redis.port}") redisPort: String 18 | ): JedisConnectionFactory { 19 | val config = RedisStandaloneConfiguration() 20 | config.hostName = redisHost 21 | config.port = Integer.parseInt(redisPort) 22 | return JedisConnectionFactory(config) 23 | } 24 | 25 | @Bean(destroyMethod = "close") 26 | fun getJedisClient(connectionFactory: JedisConnectionFactory): Jedis { 27 | return connectionFactory.connection.nativeConnection as Jedis 28 | } 29 | 30 | } -------------------------------------------------------------------------------- /store-app/src/main/kotlin/io/spring/shoestore/app/entities/CoreUser.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.app.entities 2 | 3 | 4 | class CoreUser { 5 | } -------------------------------------------------------------------------------- /store-app/src/main/kotlin/io/spring/shoestore/app/http/OrderController.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.app.http 2 | 3 | import io.spring.shoestore.app.http.api.OrderAPIResponse 4 | import io.spring.shoestore.app.http.api.OrderRequest 5 | import io.spring.shoestore.app.http.api.PreviousCustomerOrdersResponse 6 | import io.spring.shoestore.app.http.api.PreviousOrder 7 | import io.spring.shoestore.core.orders.OrderFailure 8 | import io.spring.shoestore.core.orders.OrderProcessingService 9 | import io.spring.shoestore.core.orders.OrderQueryService 10 | import io.spring.shoestore.core.orders.OrderSuccess 11 | import io.spring.shoestore.core.orders.PlaceOrderCommand 12 | import io.spring.shoestore.core.security.StoreAuthProvider 13 | import io.spring.shoestore.core.variants.Sku 14 | import org.slf4j.LoggerFactory 15 | import org.springframework.http.HttpStatus 16 | import org.springframework.http.ResponseEntity 17 | import org.springframework.web.bind.annotation.GetMapping 18 | import org.springframework.web.bind.annotation.PostMapping 19 | import org.springframework.web.bind.annotation.RequestBody 20 | import org.springframework.web.bind.annotation.RestController 21 | import java.time.Instant 22 | 23 | 24 | @RestController 25 | class OrderController( 26 | private val storeAuthProvider: StoreAuthProvider, 27 | private val orderProcessingService: OrderProcessingService, 28 | private val orderQueryService: OrderQueryService 29 | ) { 30 | 31 | 32 | @PostMapping("/orders") 33 | fun processOrder(@RequestBody orderRequest: OrderRequest): ResponseEntity { 34 | val response = orderProcessingService.placeOrder( 35 | PlaceOrderCommand(storeAuthProvider.getCurrentUser(), orderRequest.items.map { Sku(it.key) to it.value }) 36 | ) 37 | return when (response) { 38 | is OrderFailure -> { 39 | log.info("order resulted in a failure") 40 | ResponseEntity( 41 | OrderAPIResponse(false, "", Instant.now()), 42 | HttpStatus.BAD_REQUEST 43 | ) 44 | } 45 | is OrderSuccess -> { 46 | log.info ("Order was successful") 47 | ResponseEntity( 48 | OrderAPIResponse(true, response.orderId.toString(), response.time), 49 | HttpStatus.OK 50 | ) 51 | } 52 | } 53 | } 54 | 55 | @GetMapping("/orders") 56 | fun listOrdersForUser(): ResponseEntity { 57 | log.info("Fetching orders for user ${storeAuthProvider.getCurrentUser()}") 58 | val foundOrders = orderQueryService.retrieveOrdersForUser(storeAuthProvider.getCurrentUser()) 59 | 60 | return ResponseEntity( 61 | PreviousCustomerOrdersResponse(foundOrders.map {order -> 62 | PreviousOrder(order.id.toString(), order.time.toString(), order.price, 63 | order.getItems().associate { it.sku.toString() to it.inventoryItems.size } 64 | ) 65 | }), 66 | HttpStatus.OK) 67 | } 68 | 69 | companion object { 70 | private val log = LoggerFactory.getLogger(OrderController::class.java) 71 | } 72 | } -------------------------------------------------------------------------------- /store-app/src/main/kotlin/io/spring/shoestore/app/http/ShoeController.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.app.http 2 | 3 | import io.spring.shoestore.app.http.api.ShoeData 4 | import io.spring.shoestore.app.http.api.ShoeResults 5 | import io.spring.shoestore.core.products.Shoe 6 | import io.spring.shoestore.core.products.ShoeLookupQuery 7 | import io.spring.shoestore.core.products.ShoeService 8 | import org.springframework.web.bind.annotation.GetMapping 9 | import org.springframework.web.bind.annotation.RequestParam 10 | import org.springframework.web.bind.annotation.RestController 11 | 12 | @RestController 13 | class ShoeController(private val shoeService: ShoeService) { 14 | 15 | @GetMapping("/shoes") 16 | fun listShoes(@RequestParam name: String?): ShoeResults { 17 | val query = ShoeLookupQuery(name, null) 18 | return ShoeResults(shoeService.search(query).map { convert(it) }) 19 | } 20 | 21 | private fun convert(domain: Shoe): ShoeData = ShoeData( 22 | domain.id.value.toString(), 23 | domain.name, 24 | domain.description?:"", 25 | "${domain.price} ${domain.currency.code}" 26 | ) 27 | } -------------------------------------------------------------------------------- /store-app/src/main/kotlin/io/spring/shoestore/app/http/api/orders.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.app.http.api 2 | 3 | import java.time.Instant 4 | 5 | data class OrderRequest(val items: Map) 6 | 7 | data class OrderAPIResponse(val result: Boolean, val orderNumber: String, val date: Instant) 8 | 9 | data class PreviousCustomerOrdersResponse(val orders: List) 10 | 11 | data class PreviousOrder( 12 | val id: String, 13 | val time: String, 14 | val price: Int, 15 | val items: Map 16 | ) 17 | 18 | -------------------------------------------------------------------------------- /store-app/src/main/kotlin/io/spring/shoestore/app/http/api/shoes.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.app.http.api 2 | 3 | data class ShoeResults(val shoes: List) 4 | 5 | // naming is hard 6 | data class ShoeData( 7 | val id: String, 8 | val name: String, 9 | val description: String = "", // note this is not nullable 10 | val displayPrice: String 11 | ) -------------------------------------------------------------------------------- /store-app/src/main/kotlin/io/spring/shoestore/app/lifecycle/OnStartupMessaging.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.app.lifecycle 2 | 3 | import io.spring.shoestore.aws.dynamodb.DynamoTableManager 4 | import org.springframework.context.ApplicationListener 5 | import org.springframework.context.event.ContextRefreshedEvent 6 | import org.springframework.context.event.ContextStartedEvent 7 | import org.springframework.stereotype.Component 8 | import software.amazon.awssdk.services.dynamodb.DynamoDbClient 9 | 10 | 11 | @Component 12 | class OnStartupMessaging(private val dynamoDbClient: DynamoDbClient): ApplicationListener { 13 | override fun onApplicationEvent(event: ContextRefreshedEvent) { 14 | println("App started!") 15 | DynamoTableManager(dynamoDbClient).establishOrderTable() 16 | } 17 | } -------------------------------------------------------------------------------- /store-app/src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | spring: 2 | 3 | flyway: 4 | enabled: true 5 | jooq: 6 | sql-dialect: Postgres 7 | 8 | datasource: 9 | url: jdbc:postgresql://localhost:5432/shoestore?loggerLevel=OFF 10 | username: postgres 11 | password: postgres 12 | 13 | cloud: 14 | aws: 15 | region: 16 | main: us-east-2 17 | credentials: 18 | access-key: USERORGEXAMPLE 19 | secret-key: 2QvM4/Tdmf38SkcD/qalvXO4EXAMPLEKEY 20 | end-point: 21 | uri: "http://www.foo.com" -------------------------------------------------------------------------------- /store-app/src/test/kotlin/io/spring/shoestore/InventoryTests.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore 2 | 3 | import io.spring.shoestore.app.http.api.ShoeData 4 | import io.spring.shoestore.app.http.api.ShoeResults 5 | import io.spring.shoestore.core.products.Shoe 6 | import io.spring.shoestore.core.products.ShoeId 7 | import io.spring.shoestore.core.variants.InventoryItem 8 | import io.spring.shoestore.core.variants.InventoryManagementService 9 | import io.spring.shoestore.core.variants.ProductVariant 10 | import io.spring.shoestore.core.variants.ProductVariantService 11 | import io.spring.shoestore.core.variants.Sku 12 | import io.spring.shoestore.core.variants.VariantColor 13 | import io.spring.shoestore.core.variants.VariantSize 14 | import io.spring.shoestore.support.BaseIntegrationTest 15 | import org.junit.jupiter.api.Assertions.assertEquals 16 | import org.junit.jupiter.api.Assertions.assertNotNull 17 | import org.junit.jupiter.api.Assertions.assertNull 18 | import org.junit.jupiter.api.Test 19 | import org.springframework.beans.factory.annotation.Autowired 20 | 21 | class InventoryTests: BaseIntegrationTest() { 22 | 23 | @Autowired 24 | lateinit var productVariantService: ProductVariantService 25 | 26 | @Autowired 27 | lateinit var inventoryManagementService: InventoryManagementService 28 | 29 | @Test 30 | fun `Test Basic insertion`() { 31 | val shoe = getShoeByName("neak") 32 | 33 | val previousProductSize = productVariantService.listForId(ShoeId.from(shoe.id)).size 34 | 35 | inventoryManagementService.receiveNewItems( 36 | ProductVariant(Sku("SN-001"), ShoeId.from(shoe.id), "Green Small Sneaker", VariantSize.US_10, VariantColor.GREEN), 37 | listOf(InventoryItem("0001111")) 38 | ) 39 | 40 | val products = productVariantService.listForId(ShoeId.from(shoe.id)) 41 | assertEquals(previousProductSize+1, products.size) 42 | val foundVariant = products.first { it.sku == Sku("SN-001") } 43 | assertEquals(Sku("SN-001"), foundVariant.sku) 44 | assertEquals(ShoeId.from(shoe.id), foundVariant.shoeId) 45 | assertEquals("Green Small Sneaker", foundVariant.label) 46 | } 47 | 48 | @Test 49 | fun `Registering a variant more than once does not cause a problem`() { 50 | val shoe = getShoeByName("neak") 51 | 52 | 53 | val variant = ProductVariant(Sku("SN-011"), ShoeId.from(shoe.id), "Green Small Sneaker 2", VariantSize.US_10, VariantColor.GREEN) 54 | 55 | val previousProductSize = productVariantService.listForId(ShoeId.from(shoe.id)).size 56 | inventoryManagementService.receiveNewItems(variant, listOf(InventoryItem("00111"))) 57 | inventoryManagementService.receiveNewItems(variant, listOf(InventoryItem("00112"), InventoryItem("00113"))) 58 | 59 | val products = productVariantService.listForId(ShoeId.from(shoe.id)) 60 | assertEquals(previousProductSize+1, products.size) 61 | } 62 | 63 | @Test 64 | fun `Receiving new shipments add inventory`() { 65 | val shoe = getShoeByName("neak") 66 | val variant = ProductVariant(Sku("SN-002"), 67 | ShoeId.from(shoe.id), 68 | "Green Medium Sneaker", 69 | VariantSize.US_10_5, 70 | VariantColor.GREEN 71 | ) 72 | 73 | val variant2 = ProductVariant(Sku("SN-003"), 74 | ShoeId.from(shoe.id), 75 | "Black Medium Sneaker", 76 | VariantSize.US_10_5, 77 | VariantColor.BLACK 78 | ) 79 | inventoryManagementService.receiveNewItems(variant2, listOf( 80 | InventoryItem("0001") 81 | )) 82 | 83 | inventoryManagementService.receiveNewItems(variant, listOf( 84 | InventoryItem("0011"), 85 | InventoryItem("0012"), 86 | )) 87 | 88 | assertEquals(2, inventoryManagementService.getInventoryCount(variant)) 89 | assertEquals(1, inventoryManagementService.getInventoryCount(variant2)) 90 | 91 | inventoryManagementService.receiveNewItems(variant, listOf( 92 | InventoryItem("0013"), 93 | InventoryItem("0014"), 94 | )) 95 | 96 | assertEquals(4, inventoryManagementService.getInventoryCount(variant)) 97 | } 98 | 99 | @Test 100 | fun `holding and restoring an Inventory Item`() { 101 | val shoe = getShoeByName("neak") 102 | val variant = ProductVariant(Sku("AK-001"), 103 | ShoeId.from(shoe.id), 104 | "Blue Sneaker", 105 | VariantSize.US_10_5, 106 | VariantColor.BLUE 107 | ) 108 | 109 | inventoryManagementService.receiveNewItems(variant, listOf( 110 | InventoryItem("0001") 111 | )) 112 | 113 | val item = inventoryManagementService.holdForOrder(variant) 114 | assertNotNull(item) 115 | 116 | assertNull(inventoryManagementService.holdForOrder(variant)) 117 | 118 | inventoryManagementService.restockItem(variant, item!!) 119 | assertEquals(1, inventoryManagementService.getInventoryCount(variant)) 120 | } 121 | } -------------------------------------------------------------------------------- /store-app/src/test/kotlin/io/spring/shoestore/OrderTests.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore 2 | 3 | import io.spring.shoestore.app.http.api.OrderRequest 4 | import io.spring.shoestore.app.http.api.OrderAPIResponse 5 | import io.spring.shoestore.app.http.api.PreviousCustomerOrdersResponse 6 | import io.spring.shoestore.core.orders.OrderAdminService 7 | import io.spring.shoestore.core.products.Shoe 8 | import io.spring.shoestore.core.products.ShoeId 9 | import io.spring.shoestore.core.variants.InventoryItem 10 | import io.spring.shoestore.core.variants.InventoryManagementService 11 | import io.spring.shoestore.core.variants.ProductVariant 12 | import io.spring.shoestore.core.variants.Sku 13 | import io.spring.shoestore.core.variants.VariantColor 14 | import io.spring.shoestore.core.variants.VariantSize 15 | import io.spring.shoestore.support.BaseIntegrationTest 16 | import org.junit.jupiter.api.Assertions.assertEquals 17 | import org.junit.jupiter.api.Assertions.assertNotNull 18 | import org.junit.jupiter.api.Assertions.assertTrue 19 | import org.junit.jupiter.api.BeforeEach 20 | import org.junit.jupiter.api.Test 21 | import org.springframework.beans.factory.annotation.Autowired 22 | import org.springframework.context.annotation.Bean 23 | import org.springframework.http.HttpStatus 24 | 25 | class OrderTests: BaseIntegrationTest() { 26 | 27 | @Autowired 28 | private lateinit var inventoryManagementService: InventoryManagementService 29 | 30 | @Autowired 31 | private lateinit var adminService: OrderAdminService 32 | 33 | @BeforeEach 34 | fun beforeEach() { 35 | adminService.purgeOrders() 36 | } 37 | 38 | private fun blackVariant(shoeId: String) = ProductVariant( 39 | Sku("OT-001"), 40 | ShoeId.from(shoeId), 41 | "Black Sneaker: Medium", // I have no idea, just random text really 42 | VariantSize.US_10, 43 | VariantColor.BLACK 44 | ) 45 | 46 | private fun greenVariant(shoeId: String) = ProductVariant( 47 | Sku("OT-456"), 48 | ShoeId.from(shoeId), 49 | "Green Sneaker: Medium", 50 | VariantSize.US_10, 51 | VariantColor.GREEN 52 | ) 53 | 54 | @Test 55 | fun `basic order processing` () { 56 | 57 | // insert some inventory 58 | val shoe = getShoeByName("Sneak") 59 | 60 | inventoryManagementService.receiveNewItems(blackVariant(shoe.id), listOf( 61 | InventoryItem("0001"), 62 | InventoryItem("0002"), 63 | InventoryItem("0003") 64 | )) 65 | 66 | 67 | val results = restTemplate.postForEntity( 68 | "http://localhost:${serverPort}/orders", 69 | OrderRequest(mapOf("OT-001" to 1)), 70 | OrderAPIResponse::class.java 71 | ) 72 | 73 | assertNotNull(results) 74 | assertEquals(HttpStatus.OK, results.statusCode) 75 | assertTrue(results.body!!.result) 76 | assertNotNull(results.body!!.orderNumber) 77 | val firstOrder = results.body!!.orderNumber 78 | 79 | 80 | val orders = restTemplate.getForEntity("http://localhost:${serverPort}/orders", PreviousCustomerOrdersResponse::class.java) 81 | assertEquals(HttpStatus.OK, orders.statusCode) 82 | assertEquals(1, orders.body!!.orders.size) 83 | assertEquals(firstOrder, orders.body!!.orders.first().id) 84 | } 85 | 86 | @Test 87 | fun `more complex orders`() { 88 | val shoe = getShoeByName("Sneak") 89 | 90 | inventoryManagementService.receiveNewItems(blackVariant(shoe.id), listOf( 91 | InventoryItem("B-1001"), 92 | InventoryItem("B-1002"), 93 | )) 94 | 95 | inventoryManagementService.receiveNewItems(greenVariant(shoe.id), listOf( 96 | InventoryItem("G-001"), 97 | InventoryItem("G-025"), 98 | InventoryItem("G-1003") 99 | )) 100 | 101 | val order1Results = restTemplate.postForEntity( 102 | "http://localhost:${serverPort}/orders", 103 | OrderRequest(mapOf("OT-001" to 2, "OT-456" to 1)), 104 | OrderAPIResponse::class.java 105 | ) 106 | 107 | println(order1Results.body) 108 | 109 | val order2Results = restTemplate.postForEntity( 110 | "http://localhost:${serverPort}/orders", 111 | OrderRequest(mapOf("OT-456" to 2)), 112 | OrderAPIResponse::class.java 113 | ) 114 | 115 | val orders = restTemplate.getForEntity("http://localhost:${serverPort}/orders", PreviousCustomerOrdersResponse::class.java) 116 | assertEquals(HttpStatus.OK, orders.statusCode) 117 | assertEquals(2, orders.body!!.orders.size) 118 | 119 | orders.body!!.orders.forEach { 120 | println("${it.id} -> ${it.price}") 121 | } 122 | 123 | val o1 = orders.body!!.orders.find { it.id == order1Results.body!!.orderNumber } 124 | assertEquals(19800, o1!!.price) 125 | val o2 = orders.body!!.orders.find { it.id == order2Results.body!!.orderNumber } 126 | assertEquals(9900, o2!!.price) 127 | } 128 | } -------------------------------------------------------------------------------- /store-app/src/test/kotlin/io/spring/shoestore/ShoeProductTests.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore 2 | 3 | import io.spring.shoestore.app.http.api.ShoeResults 4 | import io.spring.shoestore.support.BaseIntegrationTest 5 | import org.junit.jupiter.api.Assertions.assertEquals 6 | import org.junit.jupiter.api.Assertions.assertTrue 7 | import org.junit.jupiter.api.Test 8 | 9 | 10 | class ShoeProductTests: BaseIntegrationTest() { 11 | 12 | @Test 13 | fun contextLoads() { 14 | } 15 | 16 | @Test 17 | fun `no query params should return all`() { 18 | val results = restTemplate.getForObject("http://localhost:${serverPort}/shoes", ShoeResults::class.java) 19 | assertEquals(2, results.shoes.size) 20 | assertTrue(results.shoes.map { it.name }.contains("Spring Sneaker")) 21 | } 22 | 23 | @Test 24 | fun `filter by name`() { 25 | val results = restTemplate.getForObject("http://localhost:${serverPort}/shoes?name=neak", ShoeResults::class.java) 26 | assertEquals(1, results.shoes.size) 27 | assertEquals("Spring Sneaker", results.shoes.first().name) 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /store-app/src/test/kotlin/io/spring/shoestore/support/BaseIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.support 2 | 3 | import io.spring.shoestore.app.http.api.ShoeData 4 | import io.spring.shoestore.app.http.api.ShoeResults 5 | import org.junit.jupiter.api.AfterAll 6 | import org.junit.jupiter.api.BeforeAll 7 | import org.springframework.beans.factory.annotation.Autowired 8 | import org.springframework.boot.test.context.SpringBootTest 9 | import org.springframework.boot.test.web.client.TestRestTemplate 10 | import org.springframework.boot.test.web.server.LocalServerPort 11 | import org.springframework.test.context.DynamicPropertyRegistry 12 | import org.springframework.test.context.DynamicPropertySource 13 | import org.testcontainers.containers.GenericContainer 14 | import org.testcontainers.containers.PostgreSQLContainer 15 | import org.testcontainers.containers.localstack.LocalStackContainer 16 | import org.testcontainers.containers.localstack.LocalStackContainer.Service.DYNAMODB 17 | import org.testcontainers.containers.wait.strategy.WaitStrategy 18 | import org.testcontainers.utility.DockerImageName 19 | 20 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 21 | class BaseIntegrationTest { 22 | 23 | @LocalServerPort 24 | protected var serverPort: Int = 0 25 | 26 | @Autowired 27 | lateinit var restTemplate: TestRestTemplate 28 | 29 | protected fun getShoeByName(namePart: String): ShoeData { 30 | val results = restTemplate.getForObject("http://localhost:${serverPort}/shoes?name=${namePart}", ShoeResults::class.java) 31 | return results.shoes.first() 32 | } 33 | 34 | companion object { 35 | 36 | @JvmStatic 37 | val postgresContainer: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:15.2-alpine") 38 | .withDatabaseName("shoestore") 39 | .withUsername("stavvy") 40 | .withPassword("stavvy") 41 | 42 | @JvmStatic 43 | val redisContainer: GenericContainer<*> = GenericContainer(DockerImageName.parse("redis:7.0.11-alpine")) 44 | .withExposedPorts(6379) 45 | 46 | @JvmStatic 47 | val localStackContainer: LocalStackContainer = LocalStackContainer(DockerImageName.parse("localstack/localstack:1.3.1")) 48 | .withServices(DYNAMODB) 49 | 50 | 51 | 52 | @JvmStatic 53 | @BeforeAll 54 | internal fun setUp() { 55 | if (!postgresContainer.isRunning) { 56 | postgresContainer.start() 57 | } 58 | if (!redisContainer.isRunning) { 59 | redisContainer.start() 60 | } 61 | if (!localStackContainer.isRunning) { 62 | localStackContainer.start() 63 | } 64 | } 65 | 66 | @JvmStatic 67 | @AfterAll 68 | internal fun teardown() { 69 | 70 | } 71 | 72 | @DynamicPropertySource 73 | @JvmStatic 74 | fun registerDynamicProperties(registry: DynamicPropertyRegistry) { 75 | registry.add("spring.datasource.url", postgresContainer::getJdbcUrl) 76 | registry.add("spring.datasource.username", postgresContainer::getUsername) 77 | registry.add("spring.datasource.password", postgresContainer::getPassword) 78 | registry.add("cloud.aws.end-point.uri", 79 | { localStackContainer.getEndpointOverride(LocalStackContainer.Service.DYNAMODB)} 80 | ) 81 | println("Configuring Redis: ${redisContainer.getHost()}, ${redisContainer.getMappedPort(6379).toString()}") 82 | registry.add("spring.redis.host", redisContainer::getHost) 83 | registry.add("spring.redis.port", { redisContainer.getMappedPort(6379).toString()} ) 84 | } 85 | 86 | } 87 | } -------------------------------------------------------------------------------- /store-core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation("org.slf4j:slf4j-api:2.0.6") 3 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/orders/Order.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.orders 2 | 3 | import io.spring.shoestore.core.security.PrincipalUser 4 | import io.spring.shoestore.core.variants.InventoryItem 5 | import io.spring.shoestore.core.variants.ProductVariant 6 | import io.spring.shoestore.core.variants.Sku 7 | import java.time.Instant 8 | import java.util.UUID 9 | 10 | class Order( 11 | val id: UUID, 12 | val user: PrincipalUser, 13 | val time: Instant, 14 | ) { 15 | // user id, sku, quantity, price at the time 16 | // methods for calculating total cost 17 | // Order line item? 18 | 19 | private val items: MutableList = mutableListOf() 20 | 21 | var price = 0 22 | private set 23 | 24 | fun getItems() = items 25 | 26 | fun addItem(item: ProductVariant, pricePer: Int, inventoryItems: List) { 27 | addItem(item.sku, pricePer, inventoryItems) 28 | } 29 | 30 | fun addItem(sku: Sku, pricePer: Int, inventoryItems: List) { 31 | items.add(OrderLineItem(sku, pricePer, inventoryItems)) 32 | price += pricePer*inventoryItems.size 33 | } 34 | 35 | override fun toString(): String = "Order: ${id} ${time} : $price : $user, ${items.count()}" 36 | 37 | // add 38 | data class OrderLineItem( 39 | val sku: Sku, 40 | val pricePer: Int, 41 | val inventoryItems: List 42 | ) 43 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/orders/OrderAdminService.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.orders 2 | 3 | import org.slf4j.LoggerFactory 4 | 5 | class OrderAdminService(private val orderRepository: OrderRepository) { 6 | 7 | fun purgeOrders() { 8 | log.warn("Performing a very dangerous operation") 9 | // how dangerous and silly of me 10 | orderRepository.removeAllOrders() 11 | log.info("Orders purged") 12 | } 13 | 14 | companion object { 15 | private val log = LoggerFactory.getLogger(OrderAdminService::class.java) 16 | } 17 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/orders/OrderProcessingService.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.orders 2 | 3 | import io.spring.shoestore.core.products.ShoeService 4 | import io.spring.shoestore.core.security.PrincipalUser 5 | import io.spring.shoestore.core.variants.InventoryManagementService 6 | import org.slf4j.LoggerFactory 7 | import java.time.Instant 8 | import java.util.UUID 9 | 10 | 11 | class OrderProcessingService( 12 | private val inventoryManagementService: InventoryManagementService, 13 | private val shoeService: ShoeService, 14 | private val orderRepository: OrderRepository 15 | ) { 16 | 17 | 18 | fun placeOrder(command: PlaceOrderCommand): OrderResult { 19 | val order = Order(UUID.randomUUID(), command.user, Instant.now()) 20 | // 'hold' inventory from the warehouse for the skus 21 | // on an error, restore the inventory 22 | 23 | command.items.forEach {(sku, quantity) -> 24 | // ugh more loops while fetching. Who even wrote this? 25 | val variantData = inventoryManagementService.retrieveVariantsAndCount(sku) ?: return OrderFailure("Unknown sku $sku") 26 | val actualShoe = shoeService.get(variantData.first.shoeId) ?: return OrderFailure("Unknown shoe ${variantData.first.shoeId}") 27 | val physicalInventoryItem = inventoryManagementService.holdForOrder(variantData.first) ?: return OrderFailure("No Items remaining!") 28 | // no... we want the sku, the price, but the actual inventory item 29 | order.addItem(variantData.first, actualShoe.price.toInt(), listOf(physicalInventoryItem)) 30 | } 31 | 32 | log.info("Total cost of ${command.items.size} is ${order.price}") 33 | // assume that other operations - likely actually handing payments - are ... taken care of. Hand wave away! 34 | orderRepository.submitOrder(order) 35 | return OrderSuccess(order.id, order.price.toString(), order.time) 36 | } 37 | 38 | companion object { 39 | private val log = LoggerFactory.getLogger(OrderProcessingService::class.java) 40 | } 41 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/orders/OrderQueryService.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.orders 2 | 3 | import io.spring.shoestore.core.security.PrincipalUser 4 | 5 | 6 | class OrderQueryService(private val orderRepository: OrderRepository) { 7 | 8 | fun retrieveOrdersForUser(user: PrincipalUser): List { 9 | return orderRepository.listOrdersForUser(user) 10 | } 11 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/orders/OrderRepository.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.orders 2 | 3 | import io.spring.shoestore.core.security.PrincipalUser 4 | 5 | interface OrderRepository { 6 | 7 | fun submitOrder(order: Order) 8 | 9 | fun listOrdersForUser(user: PrincipalUser): List 10 | 11 | fun removeAllOrders() 12 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/orders/OrderResult.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.orders 2 | 3 | import java.time.Instant 4 | import java.util.UUID 5 | 6 | sealed interface OrderResult 7 | 8 | data class OrderFailure(val reason: String): OrderResult 9 | data class OrderSuccess(val orderId: UUID, val totalCost: String, val time: Instant): OrderResult -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/orders/PlaceOrderCommand.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.orders 2 | 3 | import io.spring.shoestore.core.security.PrincipalUser 4 | import io.spring.shoestore.core.variants.Sku 5 | 6 | data class PlaceOrderCommand(val user: PrincipalUser, val items: List>) -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/products/Currency.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.products 2 | 3 | /** 4 | * Yes we absolutely could have used java.util.Currency. 5 | * 6 | */ 7 | enum class Currency(val code: String) { 8 | US_DOLLAR("USD"), 9 | EURO("EUR"), 10 | BRITISH_POUND("GBP"); 11 | 12 | companion object { 13 | fun lookup(code: String): Currency? { 14 | return values().find { it.code == code } 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/products/Shoe.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.products 2 | 3 | import java.math.BigDecimal 4 | import java.util.UUID 5 | 6 | class Shoe( 7 | val id: ShoeId, 8 | val name: String, 9 | val description: String? = "", 10 | val price: BigDecimal, 11 | val currency: Currency 12 | ) { 13 | } 14 | 15 | data class ShoeId(val value: UUID) { 16 | 17 | companion object { 18 | @JvmStatic 19 | fun from(rawValue: String): ShoeId { 20 | return ShoeId(UUID.fromString(rawValue)) 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/products/ShoeLookupQuery.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.products 2 | 3 | import java.math.BigDecimal 4 | 5 | data class ShoeLookupQuery(val byName: String?, val byPrice: BigDecimal? ) { 6 | fun isEmpty() = byName == null && byPrice == null 7 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/products/ShoeRepository.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.products 2 | 3 | import java.math.BigDecimal 4 | 5 | interface ShoeRepository { 6 | 7 | fun findById(id: ShoeId): Shoe? 8 | 9 | fun list(): List 10 | 11 | fun findByName(namePartial: String): List 12 | 13 | fun findByPriceUnder(upperPrice: BigDecimal): List 14 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/products/ShoeService.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.products 2 | 3 | class ShoeService(private val repository: ShoeRepository) { 4 | 5 | // parse query 6 | 7 | fun get(id: ShoeId): Shoe? = repository.findById(id) 8 | 9 | fun search(query: ShoeLookupQuery): List { 10 | if (query.isEmpty()) { 11 | return repository.list() 12 | } else { 13 | val nameResults = repository.findByName(query.byName?: "") 14 | // todo: price 15 | return nameResults 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/security/PrincipalUser.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.security 2 | 3 | /** 4 | * Represents the security principal, or logged-in user 5 | */ 6 | class PrincipalUser(val name: String, val email: String) { 7 | override fun toString(): String = "$name ($email)" 8 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/security/StoreAuthProvider.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.security 2 | 3 | interface StoreAuthProvider { 4 | 5 | fun getCurrentUser(): PrincipalUser 6 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/shipments/ReceiveShipmentCommand.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.shipments 2 | 3 | import io.spring.shoestore.core.variants.Sku 4 | 5 | data class ReceiveShipmentCommand( 6 | val shipmentId: String, 7 | val items: List>> 8 | ) -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/shipments/ShipmentDetailsRepository.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.shipments 2 | 3 | import io.spring.shoestore.core.variants.InventoryItem 4 | import io.spring.shoestore.core.variants.ProductVariant 5 | import java.time.Instant 6 | 7 | interface ShipmentDetailsRepository { 8 | 9 | fun store(shipmentId: String, time: Instant, items: List>>): List 10 | 11 | fun countByShipment(shipmentId: String): Int 12 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/shipments/ShipmentLineItem.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.shipments 2 | 3 | import io.spring.shoestore.core.variants.InventoryItem 4 | import io.spring.shoestore.core.variants.Sku 5 | import java.time.Instant 6 | 7 | 8 | data class ShipmentLineItem( 9 | val shipmentId: String, 10 | val timeReceived: Instant, 11 | val orderPart: Int, 12 | val sku: Sku, 13 | val list: InventoryItem 14 | ) 15 | { 16 | } 17 | 18 | -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/shipments/ShipmentReceiverService.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.shipments 2 | 3 | import io.spring.shoestore.core.variants.InventoryItem 4 | import io.spring.shoestore.core.variants.InventoryManagementService 5 | import io.spring.shoestore.core.variants.ProductVariant 6 | import io.spring.shoestore.core.variants.Sku 7 | import org.slf4j.LoggerFactory 8 | import java.time.Instant 9 | 10 | 11 | class ShipmentReceiverService( 12 | private val shipmentDetailsRepository: ShipmentDetailsRepository, 13 | private val inventoryManagementService: InventoryManagementService, 14 | ) { 15 | // only handles the receiving and processing of shipments. If an error occurred and we needed to rebuild 16 | // the current state of inventory, we would use the set of items from shipments minus the items in orders 17 | 18 | fun handle(command: ReceiveShipmentCommand): Boolean { 19 | log.info("Receiving and opening a shipment for id ${command.shipmentId}") 20 | // convert to instructions to persist 21 | 22 | // yes this is horribly inefficient. Look at that n+1 loop + sql. oof. who would write such a thing? 23 | // first make sure we know about all of these skus 24 | val skuLookup = mutableMapOf() 25 | command.items.forEach {(sku, _) -> 26 | val variantAndCount = inventoryManagementService.retrieveVariantsAndCount(sku) 27 | if (variantAndCount == null) { 28 | log.error("Unknown sku: ${sku}") 29 | return false 30 | } 31 | skuLookup[sku] = variantAndCount.first 32 | } 33 | 34 | val populatedItems: List>> = command.items.map { pair -> 35 | skuLookup[pair.first]!! to pair.second.map { InventoryItem(it) } 36 | } 37 | 38 | shipmentDetailsRepository.store(command.shipmentId, Instant.now(), populatedItems) 39 | // now that the line items have been persisted, receive new shipments 40 | populatedItems.forEach {(variant, items) -> 41 | // THE HORROR. LOOK AT THIS LOOP ^ 42 | inventoryManagementService.receiveNewItems(variant, items) 43 | } 44 | log.info("Recorded details for shipment ${command.shipmentId}. ") 45 | return true 46 | } 47 | 48 | 49 | 50 | companion object { 51 | private val log = LoggerFactory.getLogger(ShipmentReceiverService::class.java) 52 | } 53 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/variants/InventoryItem.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.variants 2 | 3 | /** 4 | * Represents an 'instance' of a given Variant 5 | */ 6 | data class InventoryItem(val serialNumber: String) { 7 | init { 8 | assert(serialNumber.isNotEmpty() && serialNumber.length > 3) 9 | } 10 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/variants/InventoryManagementService.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.variants 2 | 3 | import org.slf4j.LoggerFactory 4 | 5 | 6 | class InventoryManagementService( 7 | private val productVariantRepository: ProductVariantRepository, 8 | private val inventoryWarehousingRepository: InventoryWarehousingRepository 9 | ) { 10 | 11 | fun receiveNewItems(variant: ProductVariant, items: List) { 12 | productVariantRepository.registerNewVariants(listOf(variant)) 13 | inventoryWarehousingRepository.stock(variant.sku, items) 14 | log.info("Stocked ${items.count()} items for variant $variant") 15 | } 16 | 17 | fun getInventoryCount(variant: ProductVariant):Long { 18 | return inventoryWarehousingRepository.checkInventoryCount(variant.sku) 19 | } 20 | 21 | fun holdForOrder(variant: ProductVariant): InventoryItem? = holdForOrder(variant.sku) 22 | 23 | fun holdForOrder(sku: Sku): InventoryItem? { 24 | return inventoryWarehousingRepository.reserveItem(sku) 25 | } 26 | 27 | fun restockItem(variant: ProductVariant, item: InventoryItem) { 28 | inventoryWarehousingRepository.replaceItem(variant.sku, item) 29 | } 30 | 31 | fun retrieveVariantsAndCount(sku: Sku): Pair? { 32 | val productVariant = productVariantRepository.findById(sku) ?: return null 33 | return productVariant to inventoryWarehousingRepository.checkInventoryCount(sku) 34 | } 35 | 36 | companion object { 37 | private val log = LoggerFactory.getLogger(InventoryManagementService::class.java) 38 | } 39 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/variants/InventoryWarehousingRepository.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.variants 2 | 3 | /** 4 | * A digital 'warehouse'. Should provide some method for locking, preventing inventory from going negative. 5 | */ 6 | interface InventoryWarehousingRepository { 7 | 8 | fun stock(sku: Sku, items: List) 9 | 10 | fun checkInventoryCount(sku: Sku): Long 11 | 12 | fun reserveItem(sku: Sku): InventoryItem? 13 | 14 | fun replaceItem(sku: Sku, item:InventoryItem) 15 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/variants/ProductVariant.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.variants 2 | 3 | import io.spring.shoestore.core.products.ShoeId 4 | 5 | /** 6 | * Represents a combination of properties that a Product is available in. Sometimes called a 'SKU' or a 'Colorway'. 7 | * For example a 'Small, Blue' Shirt. "Green, size 11" Sneaker. 8 | */ 9 | class ProductVariant(val sku: Sku, val shoeId: ShoeId, val label: String, val size: VariantSize, val color: VariantColor) { 10 | override fun toString(): String { 11 | return "Variant: '$label' ($sku) $size, $color" 12 | } 13 | } 14 | 15 | data class Sku(val value: String) { 16 | init { 17 | assert(value.isNotEmpty()) 18 | assert(value.length in 6..127) 19 | } 20 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/variants/ProductVariantRepository.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.variants 2 | 3 | import io.spring.shoestore.core.products.ShoeId 4 | 5 | /** 6 | * Provides basic lookup of Variants that we know about. Should provide fast lookup on which 'SKU's belong to a Shoe 7 | */ 8 | interface ProductVariantRepository { 9 | 10 | fun findAllVariantsForShoe(shoeId: ShoeId): List 11 | 12 | fun findById(sku: Sku): ProductVariant? 13 | 14 | fun registerNewVariants(variants: List) 15 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/variants/ProductVariantService.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.variants 2 | 3 | import io.spring.shoestore.core.products.ShoeId 4 | 5 | class ProductVariantService( 6 | private val productVariantRepository: ProductVariantRepository 7 | ) { 8 | fun listForId(shoeId: ShoeId): List { 9 | return productVariantRepository.findAllVariantsForShoe(shoeId) 10 | } 11 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/variants/VariantColor.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.variants 2 | 3 | enum class VariantColor(val code: String) { 4 | 5 | WHITE("white"), 6 | GREEN("green"), 7 | BLACK("black"), 8 | BLUE("blue"), 9 | RED("red"); 10 | 11 | 12 | companion object { 13 | fun lookup(code: String): VariantColor? { 14 | return values().find { it.code == code } 15 | } 16 | } 17 | 18 | } -------------------------------------------------------------------------------- /store-core/src/main/kotlin/io/spring/shoestore/core/variants/VariantSize.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core.variants 2 | 3 | enum class VariantSize(val code: String) { 4 | // an enum is probably not the best for this ;) 5 | US_10("US 10"), 6 | US_10_5("US 10.5"), 7 | US_11("US 11"), 8 | US_11_5("US 11.5"); 9 | 10 | companion object { 11 | fun lookup(code: String): VariantSize? { 12 | return values().find { it.code == code } 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /store-core/src/test/kotlin/io/spring/shoestore/core/CoreTest.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core 2 | 3 | import io.spring.shoestore.core.products.ShoeId 4 | import org.junit.jupiter.api.Assertions.assertEquals 5 | import org.junit.jupiter.api.Assertions.assertNotEquals 6 | import org.junit.jupiter.api.Test 7 | import java.util.UUID 8 | 9 | class CoreTest { 10 | 11 | @Test 12 | fun shoeIdComparison() { 13 | val common = UUID.randomUUID() 14 | assertEquals(ShoeId(common), ShoeId(common)) 15 | assertNotEquals(ShoeId(common), ShoeId(UUID.randomUUID())) 16 | } 17 | 18 | @Test 19 | fun `creating shoeIds from raw Values`() { 20 | val base = UUID.randomUUID() 21 | assertEquals(ShoeId(base), ShoeId.from(base.toString())) 22 | } 23 | } -------------------------------------------------------------------------------- /store-core/src/test/kotlin/io/spring/shoestore/core/CurrencyTest.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.core 2 | 3 | import io.spring.shoestore.core.products.Currency 4 | import org.junit.jupiter.api.Assertions.assertEquals 5 | import org.junit.jupiter.api.Assertions.assertNull 6 | import org.junit.jupiter.api.Test 7 | 8 | class CurrencyTest { 9 | 10 | @Test 11 | fun `Currency codes are all 3 characters`() { 12 | assertEquals(3, Currency.BRITISH_POUND.code.length) 13 | Currency.values().forEach { c -> 14 | assertEquals(3, c.code.length) 15 | } 16 | } 17 | 18 | @Test 19 | fun `Currencies can be looked up by code`() { 20 | assertNull(Currency.lookup("GCP")) 21 | assertEquals(Currency.BRITISH_POUND, Currency.lookup("GBP")) 22 | } 23 | } -------------------------------------------------------------------------------- /store-core/src/test/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /store-details/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | implementation(project(":store-core")) 3 | implementation("org.springframework:spring-jdbc:6.0.7") 4 | // implementation("redis.clients:jedis:${project.extra["jedisVersion"]}") 5 | implementation(libs.jedis) 6 | implementation(platform("software.amazon.awssdk:bom:2.20.26")) 7 | implementation("software.amazon.awssdk:dynamodb") 8 | } -------------------------------------------------------------------------------- /store-details/src/main/kotlin/io/spring/shoestore/aws/dynamodb/DynamoClientHelper.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.aws.dynamodb 2 | 3 | import software.amazon.awssdk.services.dynamodb.model.AttributeValue 4 | 5 | /** 6 | * Some of the repetition when working with all of the Dynamo client builders was making things hard to read. 7 | * 8 | * Moving some here to clean up 9 | */ 10 | internal object DynamoClientHelper { 11 | 12 | fun createStringAttribute(s: String): AttributeValue = AttributeValue.builder().s(s).build() 13 | 14 | fun createNumAttribute(number: Int): AttributeValue = AttributeValue.builder().n(number.toString()).build() 15 | } -------------------------------------------------------------------------------- /store-details/src/main/kotlin/io/spring/shoestore/aws/dynamodb/DynamoDbOrderRepository.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.aws.dynamodb 2 | 3 | import io.spring.shoestore.aws.dynamodb.DynamoClientHelper.createNumAttribute 4 | import io.spring.shoestore.aws.dynamodb.DynamoClientHelper.createStringAttribute 5 | import io.spring.shoestore.core.orders.Order 6 | import io.spring.shoestore.core.orders.OrderRepository 7 | import io.spring.shoestore.core.security.PrincipalUser 8 | import io.spring.shoestore.core.variants.InventoryItem 9 | import io.spring.shoestore.core.variants.Sku 10 | import org.slf4j.LoggerFactory 11 | import software.amazon.awssdk.services.dynamodb.DynamoDbClient 12 | import software.amazon.awssdk.services.dynamodb.model.AttributeValue 13 | import software.amazon.awssdk.services.dynamodb.model.BatchWriteItemRequest 14 | import software.amazon.awssdk.services.dynamodb.model.DeleteTableRequest 15 | import software.amazon.awssdk.services.dynamodb.model.PutRequest 16 | import software.amazon.awssdk.services.dynamodb.model.QueryRequest 17 | import software.amazon.awssdk.services.dynamodb.model.QueryResponse 18 | import software.amazon.awssdk.services.dynamodb.model.WriteRequest 19 | import java.time.Instant 20 | import java.util.UUID 21 | 22 | 23 | /** 24 | * Intended as a drop-in replacement for the Postgres Repo 25 | */ 26 | class DynamoDbOrderRepository(private val dynamoDbClient: DynamoDbClient): OrderRepository { 27 | override fun submitOrder(order: Order) { 28 | // persisting the structure such that each users' range is just ... all of their order line items 29 | val writes = mutableListOf() 30 | val headerData = mutableMapOf() 31 | headerData[OrderTableDetails.PRIMARY_KEY] = createStringAttribute("user:${order.user.email}") 32 | headerData[OrderTableDetails.RANGE_KEY] = createStringAttribute("order:${order.id}:p:0") 33 | headerData[OrderTableDetails.DUPE_ORDER_ID] = createStringAttribute(order.id.toString()) 34 | headerData[OrderTableDetails.TIME] = createStringAttribute(order.time.toString()) 35 | headerData[OrderTableDetails.PRICE] = createNumAttribute(order.price) 36 | 37 | writes.add(WriteRequest.builder().putRequest(PutRequest.builder().item(headerData).build()).build()) 38 | order.getItems().forEachIndexed{position, lineItem -> 39 | val data = mutableMapOf() 40 | data[OrderTableDetails.PRIMARY_KEY] = createStringAttribute("user:${order.user.email}") 41 | data[OrderTableDetails.RANGE_KEY] = createStringAttribute("order:${order.id}:p:${position+1}") 42 | // too lazy to regex the order out of the range key. oops. 43 | data[OrderTableDetails.DUPE_ORDER_ID] = createStringAttribute(order.id.toString()) 44 | data[OrderTableDetails.SKU] = createStringAttribute(lineItem.sku.value) 45 | data[OrderTableDetails.PRICE_PER] = createNumAttribute(lineItem.pricePer) 46 | data[OrderTableDetails.SERIALS] = createStringAttribute(lineItem.inventoryItems.map { it.serialNumber }.joinToString("")) 47 | writes.add(WriteRequest.builder().putRequest(PutRequest.builder().item(data).build()).build()) 48 | } 49 | 50 | 51 | dynamoDbClient.batchWriteItem(BatchWriteItemRequest.builder().requestItems( 52 | mapOf(OrderTableDetails.NAME to writes) 53 | ).build()) 54 | 55 | log.info("wrote ${writes.size} items to table ${OrderTableDetails.NAME}") 56 | } 57 | 58 | override fun listOrdersForUser(user: PrincipalUser): List { 59 | val queryResult: QueryResponse = dynamoDbClient.query(QueryRequest.builder() 60 | .tableName(OrderTableDetails.NAME) 61 | .keyConditionExpression("${OrderTableDetails.PRIMARY_KEY} = :userEmail") 62 | .expressionAttributeValues(mapOf(":userEmail" to createStringAttribute("user:" + user.email))) 63 | .build() 64 | ) 65 | 66 | val items: List> = queryResult.items() 67 | log.info("How many items did we get? ${items.size}") 68 | // first, build the orders. 69 | val orderStore = mutableMapOf() 70 | items.forEach {item -> 71 | if (item[OrderTableDetails.RANGE_KEY]!!.s().endsWith("p:0")) { 72 | val orderIdRaw = item[OrderTableDetails.DUPE_ORDER_ID]!!.s() 73 | orderStore[orderIdRaw] = Order(UUID.fromString(orderIdRaw), user, Instant.parse(item["Time"]!!.s())) 74 | } 75 | } 76 | 77 | // loop again and ignore the header 78 | 79 | items.forEach {item -> 80 | if (!item[OrderTableDetails.RANGE_KEY]!!.s().endsWith("p:0")) { 81 | val orderIdRaw = item[OrderTableDetails.DUPE_ORDER_ID]!!.s() 82 | orderStore[orderIdRaw]!!.addItem( 83 | Sku(item[OrderTableDetails.SKU]!!.s()), 84 | Integer.parseInt(item[OrderTableDetails.PRICE_PER]!!.n()), 85 | item[OrderTableDetails.SERIALS]!!.s().split(",").map { InventoryItem(it) } 86 | ) 87 | } 88 | } 89 | // kotlin was giving me a hard time with `.values`, so... take that. 90 | return orderStore.toList().map { it.second } 91 | } 92 | 93 | override fun removeAllOrders() { 94 | // it's better to just drop the table and re-create it 95 | log.warn("About to do something very silly") 96 | 97 | dynamoDbClient.deleteTable( 98 | DeleteTableRequest 99 | .builder() 100 | .tableName(OrderTableDetails.NAME) 101 | .build() 102 | ) 103 | 104 | DynamoTableManager(dynamoDbClient).establishOrderTable() 105 | 106 | log.info("reset orders table") 107 | 108 | } 109 | 110 | companion object { 111 | private val log = LoggerFactory.getLogger(DynamoDbOrderRepository::class.java) 112 | 113 | } 114 | } -------------------------------------------------------------------------------- /store-details/src/main/kotlin/io/spring/shoestore/aws/dynamodb/DynamoTableManager.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.aws.dynamodb 2 | 3 | import org.slf4j.LoggerFactory 4 | import software.amazon.awssdk.core.waiters.WaiterResponse 5 | import software.amazon.awssdk.services.dynamodb.DynamoDbClient 6 | import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest 7 | import software.amazon.awssdk.services.dynamodb.model.CreateTableResponse 8 | import software.amazon.awssdk.services.dynamodb.model.DescribeTableRequest 9 | import software.amazon.awssdk.services.dynamodb.model.DescribeTableResponse 10 | import software.amazon.awssdk.services.dynamodb.model.DynamoDbException 11 | import software.amazon.awssdk.services.dynamodb.model.ProvisionedThroughput 12 | import software.amazon.awssdk.services.dynamodb.waiters.DynamoDbWaiter 13 | 14 | /** 15 | * A little layer to help us configure our Dynamo Table(s) 16 | */ 17 | class DynamoTableManager(private val dynamoDbClient: DynamoDbClient) { 18 | 19 | fun establishOrderTable() { 20 | /* 21 | The Order table will be constructed with the userId as the hash and a combination of OrderId and position 22 | as the range. 23 | 24 | This is not sufficient for production, but for our silly sample code, well... 25 | */ 26 | println("ahhhh") 27 | log.info("Establishing the ${OrderTableDetails.NAME} table") 28 | val dbWaiter: DynamoDbWaiter = dynamoDbClient.waiter() 29 | val request: CreateTableRequest = CreateTableRequest.builder() 30 | .attributeDefinitions( 31 | *OrderTableDetails.attributes.toTypedArray() 32 | ) 33 | .keySchema( 34 | *OrderTableDetails.keys.toTypedArray() 35 | ) 36 | .provisionedThroughput( 37 | ProvisionedThroughput.builder() 38 | .readCapacityUnits(1) 39 | .writeCapacityUnits(1) 40 | .build() 41 | ) 42 | .tableName(OrderTableDetails.NAME) 43 | .build() 44 | println("hmmm") 45 | try { 46 | val response: CreateTableResponse = dynamoDbClient.createTable(request) 47 | val tableRequest = DescribeTableRequest.builder() 48 | .tableName(OrderTableDetails.NAME) 49 | .build() 50 | 51 | // Wait until the Amazon DynamoDB table is created. 52 | val waiterResponse: WaiterResponse = dbWaiter.waitUntilTableExists(tableRequest) 53 | waiterResponse.matched().response().ifPresent { x: DescribeTableResponse? -> 54 | log.info("response from system is ${x}") 55 | } 56 | log.info("DDB Table is ${response.tableDescription().tableName()}") 57 | 58 | 59 | } catch (e: DynamoDbException) { 60 | log.error("Could not establish table: ${e}") 61 | } 62 | } 63 | 64 | companion object { 65 | private val log = LoggerFactory.getLogger(DynamoTableManager::class.java) 66 | } 67 | 68 | } -------------------------------------------------------------------------------- /store-details/src/main/kotlin/io/spring/shoestore/aws/dynamodb/OrderTableDetails.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.aws.dynamodb 2 | 3 | import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition 4 | import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement 5 | import software.amazon.awssdk.services.dynamodb.model.KeyType 6 | import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType 7 | 8 | internal object OrderTableDetails { 9 | 10 | const val NAME = "Orders" 11 | 12 | const val PRIMARY_KEY = "UserId" 13 | const val RANGE_KEY = "OrderAndPosition" 14 | 15 | const val DUPE_ORDER_ID = "BackupOrderId" 16 | const val TIME = "Time" 17 | const val PRICE = "Price" 18 | 19 | const val SKU = "Sku" 20 | const val PRICE_PER = "PricePer" 21 | const val SERIALS = "Serials" 22 | 23 | 24 | val attributes: List = listOf( 25 | AttributeDefinition.builder() 26 | .attributeName(PRIMARY_KEY) 27 | .attributeType(ScalarAttributeType.S) 28 | .build(), 29 | 30 | AttributeDefinition.builder() 31 | .attributeName(RANGE_KEY) 32 | .attributeType(ScalarAttributeType.S) 33 | .build() 34 | ) 35 | 36 | val keys: List = listOf( 37 | KeySchemaElement.builder() 38 | .attributeName(PRIMARY_KEY) 39 | .keyType(KeyType.HASH) 40 | .build(), 41 | 42 | KeySchemaElement.builder() 43 | .attributeName(RANGE_KEY) 44 | .keyType(KeyType.RANGE) 45 | .build(), 46 | ) 47 | } -------------------------------------------------------------------------------- /store-details/src/main/kotlin/io/spring/shoestore/postgres/PostgresOrderRepository.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.postgres 2 | 3 | import io.spring.shoestore.core.orders.Order 4 | import io.spring.shoestore.core.orders.OrderRepository 5 | import io.spring.shoestore.core.security.PrincipalUser 6 | import io.spring.shoestore.core.variants.InventoryItem 7 | import io.spring.shoestore.postgres.mappers.LineItemMapper 8 | import io.spring.shoestore.postgres.mappers.OrderMapper 9 | import org.slf4j.LoggerFactory 10 | import org.springframework.jdbc.core.BatchPreparedStatementSetter 11 | import org.springframework.jdbc.core.JdbcTemplate 12 | import java.sql.PreparedStatement 13 | import java.sql.Timestamp 14 | 15 | class PostgresOrderRepository(private val jdbcTemplate: JdbcTemplate): OrderRepository { 16 | 17 | private val orderMapper = OrderMapper() 18 | private val lineItemMapper = LineItemMapper() 19 | 20 | override fun submitOrder(order: Order) { 21 | log.info("Persisting order ${order} -> ${order.price}") 22 | order.getItems().forEach { 23 | log.info("${it.sku} at ${it.pricePer} each. Serials are: ${it.inventoryItems.map{s -> s.serialNumber}}") 24 | } 25 | // insert into orders, then insert each item into order_line_items 26 | jdbcTemplate.update("insert into orders (id, user_email, time_placed, total_price) values (?, ?, ?, ?)", 27 | order.id, 28 | order.user.email, 29 | Timestamp.from(order.time), 30 | order.price 31 | ) 32 | jdbcTemplate.batchUpdate("insert into order_line_items (order_id, position, sku, price_per, serial_numbers) " + 33 | "values (?, ?, ?, ?, ?);", 34 | object: BatchPreparedStatementSetter { 35 | 36 | override fun setValues(ps: PreparedStatement, i: Int) { 37 | val lineItem = order.getItems()[i] 38 | val serials = lineItem.inventoryItems.map {it.serialNumber}.toTypedArray() 39 | ps.setObject(1, order.id) 40 | ps.setInt(2, i) 41 | ps.setString(3, lineItem.sku.value) 42 | ps.setInt(4, lineItem.pricePer) 43 | ps.setArray(5, jdbcTemplate.dataSource!!.connection.createArrayOf("text", serials)) 44 | } 45 | 46 | override fun getBatchSize(): Int = order.getItems().size 47 | }) 48 | } 49 | 50 | override fun listOrdersForUser(user: PrincipalUser): List { 51 | // first grab the orders 52 | val orders = jdbcTemplate.query("select o.*, ?, ? from orders o where o.user_email = ?", orderMapper, user.name, user.email, user.email) 53 | val lookup = orders.associateBy { it.id } 54 | 55 | val items = jdbcTemplate.query("select li.* from order_line_items li, orders o where li.order_id = o.id and o.user_email = ? order by o.id, li.position asc", 56 | lineItemMapper, 57 | user.email 58 | ) 59 | items.forEach {li -> 60 | lookup[li.orderId]?.let { order -> 61 | order.addItem(li.sku, li.pricePer, li.serialNumbers.map { InventoryItem(it) }) 62 | } 63 | } 64 | return orders 65 | } 66 | 67 | override fun removeAllOrders() { 68 | jdbcTemplate.update("delete from order_line_items;") 69 | jdbcTemplate.update("delete from orders;") 70 | } 71 | 72 | companion object { 73 | private val log = LoggerFactory.getLogger(PostgresOrderRepository::class.java) 74 | } 75 | } -------------------------------------------------------------------------------- /store-details/src/main/kotlin/io/spring/shoestore/postgres/PostgresProductVariantRepository.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.postgres 2 | 3 | import io.spring.shoestore.core.products.ShoeId 4 | import io.spring.shoestore.core.variants.ProductVariant 5 | import io.spring.shoestore.core.variants.ProductVariantRepository 6 | import io.spring.shoestore.core.variants.Sku 7 | import io.spring.shoestore.postgres.mappers.ProductVariantMapper 8 | import org.springframework.jdbc.core.BatchPreparedStatementSetter 9 | import org.springframework.jdbc.core.JdbcTemplate 10 | import java.sql.PreparedStatement 11 | 12 | class PostgresProductVariantRepository(private val jdbcTemplate: JdbcTemplate): ProductVariantRepository { 13 | 14 | override fun findAllVariantsForShoe(shoeId: ShoeId): List { 15 | return jdbcTemplate.query("select * from variants where shoe_id = ?;", 16 | PV_MAPPER, 17 | shoeId.value 18 | ) 19 | } 20 | 21 | override fun findById(sku: Sku): ProductVariant? { 22 | return jdbcTemplate.queryForObject("select * from variants where sku = ? limit 1;", PV_MAPPER, sku.value) 23 | } 24 | 25 | override fun registerNewVariants(variants: List) { 26 | jdbcTemplate.batchUpdate("insert into variants (sku, shoe_id, label, size, color) values (?, ?, ?, ?, ?) on conflict do nothing; ", object: BatchPreparedStatementSetter { 27 | override fun setValues(ps: PreparedStatement, i: Int) { 28 | val variant = variants[i] 29 | ps.setString(1, variant.sku.value) 30 | ps.setObject(2, variant.shoeId.value) 31 | ps.setString(3, variant.label) 32 | ps.setString(4, variant.size.code) 33 | ps.setString(5, variant.color.code) 34 | } 35 | 36 | override fun getBatchSize(): Int = variants.size 37 | }) 38 | } 39 | 40 | companion object { 41 | private val PV_MAPPER = ProductVariantMapper() 42 | } 43 | 44 | } -------------------------------------------------------------------------------- /store-details/src/main/kotlin/io/spring/shoestore/postgres/PostgresShoeRepository.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.postgres 2 | 3 | import io.spring.shoestore.core.products.Shoe 4 | import io.spring.shoestore.core.products.ShoeId 5 | import io.spring.shoestore.core.products.ShoeRepository 6 | import io.spring.shoestore.postgres.mappers.ShoeMapper 7 | import org.springframework.jdbc.core.JdbcTemplate 8 | import java.math.BigDecimal 9 | 10 | 11 | class PostgresShoeRepository(private val jdbcTemplate: JdbcTemplate): ShoeRepository { 12 | 13 | private val shoeMapper = ShoeMapper() 14 | 15 | override fun findById(id: ShoeId): Shoe? { 16 | return jdbcTemplate.queryForObject("select * from shoes where id = ? limit 1;", 17 | shoeMapper, 18 | id.value 19 | ) 20 | } 21 | 22 | override fun list(): List { 23 | return jdbcTemplate.query("select * from shoes;", shoeMapper) 24 | } 25 | 26 | override fun findByName(namePartial: String): List { 27 | return jdbcTemplate.query("select * from shoes where name ilike ?", shoeMapper, "%$namePartial%") 28 | } 29 | 30 | override fun findByPriceUnder(upperPrice: BigDecimal): List { 31 | return jdbcTemplate.query("select * from shoes where price_in_cents <= ?", shoeMapper, upperPrice.toInt()) 32 | } 33 | } -------------------------------------------------------------------------------- /store-details/src/main/kotlin/io/spring/shoestore/postgres/mappers/OrderMappers.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.postgres.mappers 2 | 3 | import io.spring.shoestore.core.orders.Order 4 | import io.spring.shoestore.core.security.PrincipalUser 5 | import io.spring.shoestore.core.variants.Sku 6 | import org.springframework.jdbc.core.RowMapper 7 | import java.sql.ResultSet 8 | import java.util.UUID 9 | 10 | internal class OrderMapper: RowMapper { 11 | override fun mapRow(rs: ResultSet, rowNum: Int): Order? { 12 | return Order( 13 | UUID.fromString(rs.getString("id")), 14 | PrincipalUser(rs.getString(5), rs.getString(6)), 15 | rs.getTimestamp("time_placed").toInstant() 16 | ) 17 | } 18 | } 19 | 20 | /** 21 | * Represents the row in the db which we'll eventually collapse into a line item on the Order 22 | */ 23 | internal data class LineItemRow( 24 | val orderId: UUID, 25 | val position: Int, 26 | val sku: Sku, 27 | val pricePer: Int, 28 | val serialNumbers: List 29 | ) 30 | 31 | internal class LineItemMapper: RowMapper { 32 | override fun mapRow(rs: ResultSet, rowNum: Int): LineItemRow { 33 | 34 | val serialArray: Array = rs.getArray("serial_numbers").array as Array 35 | return LineItemRow( 36 | UUID.fromString(rs.getString("order_id")), 37 | rs.getInt("position"), 38 | Sku(rs.getString("sku")), 39 | rs.getInt("price_per"), 40 | serialArray.toList() 41 | ) 42 | } 43 | 44 | } -------------------------------------------------------------------------------- /store-details/src/main/kotlin/io/spring/shoestore/postgres/mappers/ProductVariantMapper.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.postgres.mappers 2 | 3 | import io.spring.shoestore.core.products.ShoeId 4 | import io.spring.shoestore.core.variants.ProductVariant 5 | import io.spring.shoestore.core.variants.Sku 6 | import io.spring.shoestore.core.variants.VariantColor 7 | import io.spring.shoestore.core.variants.VariantSize 8 | import org.springframework.jdbc.core.RowMapper 9 | import java.sql.ResultSet 10 | import java.util.UUID 11 | 12 | internal class ProductVariantMapper: RowMapper { 13 | 14 | override fun mapRow(rs: ResultSet, rowNum: Int): ProductVariant { 15 | return ProductVariant( 16 | sku=Sku(rs.getString("sku")), 17 | shoeId = ShoeId(UUID.fromString(rs.getString("shoe_id"))), 18 | label = rs.getString("label"), 19 | size= VariantSize.lookup(rs.getString("size"))!!, 20 | color= VariantColor.lookup(rs.getString("color"))!! 21 | ) 22 | } 23 | } -------------------------------------------------------------------------------- /store-details/src/main/kotlin/io/spring/shoestore/postgres/mappers/ShoeMapper.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.postgres.mappers 2 | 3 | import io.spring.shoestore.core.products.Currency 4 | import io.spring.shoestore.core.products.Shoe 5 | import io.spring.shoestore.core.products.ShoeId 6 | import org.springframework.jdbc.core.RowMapper 7 | import java.sql.ResultSet 8 | import java.util.UUID 9 | 10 | internal class ShoeMapper: RowMapper { 11 | override fun mapRow(rs: ResultSet, rowNum: Int): Shoe { 12 | return Shoe( 13 | ShoeId(UUID.fromString(rs.getString("id"))), 14 | rs.getString("name"), 15 | rs.getString("description"), 16 | rs.getBigDecimal("price_in_cents"), 17 | Currency.lookup(rs.getString("price_currency"))!! 18 | ) 19 | } 20 | } -------------------------------------------------------------------------------- /store-details/src/main/kotlin/io/spring/shoestore/redis/RedisInventoryWarehousingRepository.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.redis 2 | 3 | import io.spring.shoestore.core.variants.InventoryItem 4 | import io.spring.shoestore.core.variants.InventoryWarehousingRepository 5 | import io.spring.shoestore.core.variants.Sku 6 | import redis.clients.jedis.Jedis 7 | 8 | class RedisInventoryWarehousingRepository(private val redisClient: Jedis): InventoryWarehousingRepository { 9 | 10 | override fun stock(sku: Sku, items: List) { 11 | redisClient.sadd(sku.value, *items.map { it.serialNumber }.toTypedArray()) 12 | } 13 | 14 | override fun checkInventoryCount(sku: Sku) = redisClient.scard(sku.value) 15 | 16 | override fun reserveItem(sku: Sku): InventoryItem? { 17 | val serial = redisClient.spop(sku.value) 18 | return if (serial.isNullOrEmpty()) { 19 | null 20 | } else { 21 | InventoryItem(serial) 22 | } 23 | } 24 | 25 | override fun replaceItem(sku: Sku, item: InventoryItem) { 26 | redisClient.sadd(sku.value, item.serialNumber) 27 | } 28 | } -------------------------------------------------------------------------------- /store-details/src/main/kotlin/io/spring/shoestore/security/FakeStoreAuthProvider.kt: -------------------------------------------------------------------------------- 1 | package io.spring.shoestore.security 2 | 3 | import io.spring.shoestore.core.security.PrincipalUser 4 | import io.spring.shoestore.core.security.StoreAuthProvider 5 | 6 | /** 7 | * Simulate real security and just spit out some user. In reality this class would be something like 8 | * "AuthZeroStoreAuthProvider" or something similar. 9 | * 10 | */ 11 | class FakeStoreAuthProvider: StoreAuthProvider { 12 | 13 | private var currentUser: PrincipalUser? = null 14 | 15 | 16 | override fun getCurrentUser(): PrincipalUser { 17 | if (currentUser == null) { 18 | throw RuntimeException("No logged in user") 19 | 20 | } 21 | return currentUser!! 22 | } 23 | 24 | /** 25 | * For testing purposes, login some user for testing 26 | */ 27 | fun login(user: PrincipalUser) { 28 | this.currentUser = user 29 | } 30 | } -------------------------------------------------------------------------------- /store-details/src/main/resources/db/migration/V001__Create_Shoe_Table.sql: -------------------------------------------------------------------------------- 1 | create table shoes ( 2 | id uuid primary key, 3 | name varchar(256) not null, 4 | description text, 5 | price_in_cents integer not null, 6 | price_currency varchar(3) not null, -- e.g. EUR, USD 7 | constraint price_non_neg check (price_in_cents >= 0) 8 | 9 | ); -------------------------------------------------------------------------------- /store-details/src/main/resources/db/migration/V002__Create_Variants_Table.sql: -------------------------------------------------------------------------------- 1 | create table variants( 2 | sku varchar(256) primary key, 3 | shoe_id uuid not null, 4 | label text not null, 5 | size varchar(128) not null, 6 | color varchar(128) not null, 7 | -- for now just size and color, but we could add additional things like materials 8 | CONSTRAINT fk_shoe 9 | FOREIGN KEY(shoe_id) 10 | REFERENCES shoes(id) 11 | ); 12 | 13 | create index if not exists v_size on variants(size); 14 | create index if not exists v_color on variants(color); -------------------------------------------------------------------------------- /store-details/src/main/resources/db/migration/V003__Insert_Sample_Data.sql: -------------------------------------------------------------------------------- 1 | insert into shoes(id, name, description, price_in_cents, price_currency) values 2 | (gen_random_uuid(), 'Classic sandal', '', 2500, 'USD'), 3 | (gen_random_uuid(), 'Spring Sneaker', '', 9900, 'EUR') 4 | ; 5 | 6 | 7 | 8 | -- id uuid primary key, 9 | -- name varchar(256) not null, 10 | -- description text, 11 | -- price_in_cents integer not null, 12 | -- price_currency varchar(3) not null -- e.g. EUR, USD -------------------------------------------------------------------------------- /store-details/src/main/resources/db/migration/V004__Create_Orders_tables.sql: -------------------------------------------------------------------------------- 1 | create table orders ( 2 | id uuid primary key , 3 | user_email text not null, 4 | time_placed timestamp with time zone not null, 5 | total_price int 6 | ); 7 | create index if not exists orders_user on orders(user_email); 8 | 9 | 10 | 11 | create table order_line_items( 12 | order_id uuid not null, 13 | position int not null, 14 | sku varchar(256) not null, 15 | price_per int not null, 16 | serial_numbers text[], 17 | 18 | constraint fk_line_order 19 | foreign key (order_id) 20 | references orders(id), 21 | 22 | constraint fk_line_sku 23 | foreign key (sku) 24 | references variants(sku) 25 | 26 | ); --------------------------------------------------------------------------------