diff --git a/.gitignore b/.gitignore index a1c2a23..f645265 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,8 @@ -# Compiled class file -*.class - -# Log file -*.log - -# BlueJ files -*.ctxt - -# Mobile Tools for Java (J2ME) -.mtj.tmp/ - -# Package Files # -*.jar -*.war -*.nar -*.ear -*.zip -*.tar.gz -*.rar - -# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml -hs_err_pid* +# Gradle +.gradle/ +build/ + +# IntelliJ IDEA +.idea/ +*.iml +out/ diff --git a/LICENSE b/LICENSE index 3b81e2a..35f2631 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 The Unbroken Dome +Copyright (c) 2019 Till Krullmann Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..959c786 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,70 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") apply false + id("org.unbroken-dome.test-sets") version "2.1.1" apply false + id("io.spring.dependency-management") version "1.0.7.RELEASE" apply false + id("com.jfrog.bintray") version "1.8.4" apply false +} + + +allprojects { + repositories { + jcenter() + } +} + + +val release: Task by tasks.creating { + doLast { + println("Releasing $version") + } +} + + +if ("release" !in gradle.startParameter.taskNames) { + println("Not a release build, setting version to ${project.version}-SNAPSHOT") + project.version = "${project.version}-SNAPSHOT" +} + + +subprojects { + + plugins.withId("base") { + apply(plugin = "io.spring.dependency-management") + } + + plugins.withId("java") { + dependencies { + "compileOnly"("com.google.code.findbugs:jsr305") + "testImplementation"("org.junit.jupiter:junit-jupiter-api") + "testRuntimeOnly"("org.junit.jupiter:junit-jupiter-engine") + } + + tasks.withType { + useJUnitPlatform() + } + } + + plugins.withId("org.jetbrains.kotlin.jvm") { + dependencies { + "implementation"(kotlin("stdlib-jdk8")) + + "testImplementation"("com.willowtreeapps.assertk:assertk-jvm") + "testImplementation"("io.mockk:mockk") + } + + tasks.withType { + kotlinOptions.jvmTarget = "1.8" + kotlinOptions.freeCompilerArgs = listOf("-Xjvm-default=enable") + } + } + + plugins.withId("io.spring.dependency-management") { + apply(from = "$rootDir/gradle/dependency-management.gradle") + } + + plugins.withId("com.jfrog.bintray") { + apply(from = "$rootDir/gradle/publishing.gradle") + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..050fb47 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,22 @@ +kotlin.code.style=official +kotlinVersion=1.3.21 + +group=org.unbroken-dome.spring-blobstore +version=0.1.0 + + +friendly_name=Spring Blobstore +description=A blobstore abstraction for use in Spring applications +home_url=https://github.com/unbroken-dome/spring-blobstore + +scm_url=https://github.com/unbroken-dome/spring-blobstore.git +issues_url=https://github.com/unbroken-dome/spring-blobstore/issues + +license_name=The MIT License (MIT) +license_url=http://opensource.org/licenses/MIT + +bintray_user= +bintray_key= +bintray_labels=blobstore,spring +bintray_repo=tools +bintray_dryrun=false diff --git a/gradle/dependency-management.gradle b/gradle/dependency-management.gradle new file mode 100644 index 0000000..5b4fc64 --- /dev/null +++ b/gradle/dependency-management.gradle @@ -0,0 +1,34 @@ +dependencyManagement { + + imports { + mavenBom("org.springframework.boot:spring-boot-dependencies:2.1.3.RELEASE") + } + + dependencies { + + dependencySet("org.bouncycastle:1.61") { + entry("bcpkix-jdk15on") + } + + dependency("org.unbroken-dome.jsonwebtoken:jwt:1.5.0") + } +} + + +def dependencySetVersions = [ + "com.google.code.findbugs" : "3.0.2", + "com.willowtreeapps.assertk": "0.13", + "io.mockk" : "1.9.1", + "org.jetbrains.kotlin" : kotlinVersion, + "org.junit.jupiter" : "5.4.0" +] + + +configurations.all { Configuration conf -> + conf.resolutionStrategy.eachDependency { details -> + def v = dependencySetVersions[details.requested.group] + if (v) { + details.useVersion(v) + } + } +} diff --git a/gradle/publishing.gradle b/gradle/publishing.gradle new file mode 100644 index 0000000..cf02f76 --- /dev/null +++ b/gradle/publishing.gradle @@ -0,0 +1,53 @@ +apply plugin: "maven-publish" + + +task sourcesJar(type: Jar) { + group = "build" + description = "Assembles a jar archive containing the sources." + from sourceSets.main.allSource + archiveClassifier = "sources" +} + + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + artifact sourcesJar + } + } +} + + +bintray { + publications = ["mavenJava"] + + user = project.bintray_user + key = project.bintray_key + + dryRun = Boolean.valueOf(project.bintray_dryrun as String) + + pkg { + repo = project.bintray_repo + name = project.name + desc = project.description + websiteUrl = project.home_url + licenses = ['MIT'] + labels = project.bintray_labels.split(',') + + vcsUrl = project.scm_url + issueTrackerUrl = project.issues_url + publicDownloadNumbers = true + } + + pkg.version { + name = project.version + released = new Date() + vcsTag = project.version + } +} + + +task release { + dependsOn tasks.bintrayUpload +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..28861d2 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..44e7c4d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.2.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f955316 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..ac9d90a --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,24 @@ +val kotlinVersion: String by settings + +rootProject.name = "spring-blobstore-parent" + +pluginManagement { + repositories { + gradlePluginPortal() + jcenter() + } + + resolutionStrategy.eachPlugin { + if (requested.id.namespace!!.startsWith("org.jetbrains.kotlin")) { + useVersion(kotlinVersion) + } + } +} + + +include( + + "spring-blobstore", + "spring-blobstore-boot-test", + "spring-blobstore-gcs" +) diff --git a/spring-blobstore-boot-test/build.gradle.kts b/spring-blobstore-boot-test/build.gradle.kts new file mode 100644 index 0000000..f059ad7 --- /dev/null +++ b/spring-blobstore-boot-test/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") +} + + +dependencies { + implementation(project(":spring-blobstore")) + implementation(project(":spring-blobstore-gcs")) + implementation("org.springframework.boot:spring-boot-starter") +} diff --git a/spring-blobstore-boot-test/src/main/resources/application.properties b/spring-blobstore-boot-test/src/main/resources/application.properties new file mode 100644 index 0000000..d1e7687 --- /dev/null +++ b/spring-blobstore-boot-test/src/main/resources/application.properties @@ -0,0 +1 @@ +blobstore.foo.gcs.bucket-name=asdf diff --git a/spring-blobstore-gcs/build.gradle.kts b/spring-blobstore-gcs/build.gradle.kts new file mode 100644 index 0000000..1270194 --- /dev/null +++ b/spring-blobstore-gcs/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + `java-library` + kotlin("jvm") + kotlin("plugin.spring") + id("org.unbroken-dome.test-sets") + id("com.jfrog.bintray") +} + + +testSets { + "autoConfTest" { dirName = "autoconf-test" } +} + + +dependencies { + api(project(":spring-blobstore")) + + implementation("org.springframework:spring-webflux") + implementation("com.fasterxml.jackson.core:jackson-databind") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.unbroken-dome.jsonwebtoken:jwt") + + implementation("org.bouncycastle:bcpkix-jdk15on") + + compileOnly("org.springframework.boot:spring-boot-autoconfigure") + compileOnly("javax.validation:validation-api") + + + testImplementation("io.projectreactor:reactor-test") + + "autoConfTestImplementation"("org.springframework.boot:spring-boot-starter-test") + "autoConfTestRuntimeOnly"("io.projectreactor.netty:reactor-netty") +} diff --git a/spring-blobstore-gcs/src/autoconf-test/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlobStoreAutoConfigurationTest.kt b/spring-blobstore-gcs/src/autoconf-test/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlobStoreAutoConfigurationTest.kt new file mode 100644 index 0000000..9a04f13 --- /dev/null +++ b/spring-blobstore-gcs/src/autoconf-test/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlobStoreAutoConfigurationTest.kt @@ -0,0 +1,37 @@ +package org.unbrokendome.spring.blobstore.gcs + +import assertk.all +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.prop +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.BeanFactoryAnnotationUtils +import org.springframework.boot.SpringBootConfiguration +import org.springframework.boot.autoconfigure.EnableAutoConfiguration +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.ApplicationContext + + +@SpringBootTest +class GcsBlobStoreAutoConfigurationTest { + + @SpringBootConfiguration + @EnableAutoConfiguration + class TestConfig + + + @Test + fun test(applicationContext: ApplicationContext) { + + val blobStore = BeanFactoryAnnotationUtils.qualifiedBeanOfType( + applicationContext.autowireCapableBeanFactory, GcsBlobStore::class.java, + "example" + ) + + assertThat(blobStore).all { + isNotNull() + prop(GcsBlobStore::bucketName).isEqualTo("exampleBucket") + } + } +} diff --git a/spring-blobstore-gcs/src/autoconf-test/resources/application.yaml b/spring-blobstore-gcs/src/autoconf-test/resources/application.yaml new file mode 100644 index 0000000..b97bb2d --- /dev/null +++ b/spring-blobstore-gcs/src/autoconf-test/resources/application.yaml @@ -0,0 +1,4 @@ +blobstore: + example: + gcs: + bucket-name: exampleBucket diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlob.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlob.kt new file mode 100644 index 0000000..0b87745 --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlob.kt @@ -0,0 +1,35 @@ +package org.unbrokendome.spring.blobstore.gcs + +import org.reactivestreams.Publisher +import org.springframework.core.io.buffer.DataBuffer +import org.springframework.http.MediaType +import org.springframework.web.reactive.function.BodyExtractors +import org.springframework.web.reactive.function.client.ClientResponse +import org.unbrokendome.spring.blobstore.Blob +import java.nio.file.Path +import java.time.Instant + + +internal class GcsBlob( + val blobStore: GcsBlobStore, + override val path: Path, + clientResponse: ClientResponse +) : Blob { + + override val size: Long = + clientResponse.headers().contentLength().asLong + + override val contentType: MediaType = + clientResponse.headers().contentType().orElse(MediaType.APPLICATION_OCTET_STREAM) + + override val data: Publisher = + clientResponse.body(BodyExtractors.toDataBuffers()) + + override val etag: String? = + clientResponse.headers().asHttpHeaders().eTag + + override val lastModified: Instant? = + clientResponse.headers().asHttpHeaders().lastModified + .takeIf { it > 0L } + ?.let { Instant.ofEpochMilli(it) } +} diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlobMetadata.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlobMetadata.kt new file mode 100644 index 0000000..a8775ad --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlobMetadata.kt @@ -0,0 +1,30 @@ +package org.unbrokendome.spring.blobstore.gcs + +import com.fasterxml.jackson.annotation.JacksonInject +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.OptBoolean +import org.springframework.http.MediaType +import org.unbrokendome.spring.blobstore.BlobMetadata +import java.net.URI +import java.nio.file.Path +import java.time.Instant + + +@JsonIgnoreProperties(ignoreUnknown = true) +internal class GcsBlobMetadata( + @JacksonInject(useInput = OptBoolean.FALSE) + val blobStore: GcsBlobStore, + @JsonProperty("name", required = true) + override val path: Path, + @JsonProperty("size", required = true) + override val size: Long, + @JsonProperty("contentType", required = true) + override val contentType: MediaType, + @JsonProperty("etag", required = true) + override val etag: String, + @JsonProperty("updated", required = true) + override val lastModified: Instant, + @JsonProperty("mediaLink", required = true) + val mediaLink: URI +) : BlobMetadata diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlobStore.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlobStore.kt new file mode 100644 index 0000000..dd4fe29 --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlobStore.kt @@ -0,0 +1,170 @@ +package org.unbrokendome.spring.blobstore.gcs + +import com.fasterxml.jackson.databind.InjectableValues +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.KotlinModule +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.codec.json.Jackson2JsonDecoder +import org.springframework.web.reactive.function.client.ClientResponse +import org.springframework.web.reactive.function.client.ExchangeStrategies +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.WebClientResponseException +import org.springframework.web.reactive.function.client.bodyToMono +import org.unbrokendome.spring.blobstore.Blob +import org.unbrokendome.spring.blobstore.BlobAlreadyExistsException +import org.unbrokendome.spring.blobstore.BlobInput +import org.unbrokendome.spring.blobstore.BlobMetadata +import org.unbrokendome.spring.blobstore.BlobNotFoundException +import org.unbrokendome.spring.blobstore.BlobStore +import org.unbrokendome.spring.blobstore.gcs.util.checkStatusCode +import reactor.core.publisher.* +import java.nio.file.Path +import java.time.Instant + + +interface GcsBlobStore : BlobStore { + + val bucketName: String + + + companion object { + + @JvmStatic + fun builder(): GcsBlobStoreBuilder = + GcsBlobStoreBuilder() + } +} + + +internal class DefaultGcsBlobStore( + webClientBuilder: WebClient.Builder, + override val bucketName: String +) : GcsBlobStore { + + private val webClient = webClientBuilder + .exchangeStrategies(ExchangeStrategies.builder() + .codecs { codecs -> + val objectMapper = ObjectMapper() + .registerModules(JavaTimeModule(), KotlinModule()) + .setInjectableValues(InjectableValues.Std(mapOf("blobStore" to this))) + + codecs.defaultCodecs().jackson2JsonDecoder( + Jackson2JsonDecoder(objectMapper, MediaType.APPLICATION_JSON) + ) + } + .build() + ) + .build() + + + override fun getMetadata(path: Path): Mono { + return webClient.get() + .uri { b -> + b.path("/storage/v1/b/{bucket}/o/{object}") + .queryParam("fields", "name,size,contentType,etag,updated,mediaLink") + .build(mapOf("bucket" to bucketName, "object" to path)) + } + .retrieve() + .bodyToMono() + .cast() + } + + + override fun retrieve(metadata: BlobMetadata): Mono { + if (metadata !is GcsBlobMetadata || metadata.blobStore != this) { + throw IllegalArgumentException("This BlobMetadata was created by a different BlobStore") + } + return webClient.get() + .uri(metadata.mediaLink) + .exchange() + .checkStatusCode() + .map { clientResponse -> + GcsBlob(this, metadata.path, clientResponse) + } + .onErrorMap(WebClientResponseException.NotFound::class) { error -> + BlobNotFoundException(metadata.path, error) + } + } + + + override fun retrieveDirect( + path: Path, + ifNoneMatch: Iterable?, + ifModifiedSince: Instant? + ): Mono = + webClient.get() + .uri { b -> + b.path("storage/v1/b/{bucket}/o/{object}") + .queryParam("alt", "media") + .build(bucketName, path) + } + .headers { h -> + if (ifNoneMatch != null) { + h.ifNoneMatch = ifNoneMatch.toList() + } + if (ifModifiedSince != null) { + h.ifModifiedSince = ifModifiedSince.toEpochMilli() + } + } + .exchange() + .checkStatusCode() + .map { clientResponse -> + GcsBlob(this, path, clientResponse) + } + + + override fun store(path: Path, input: BlobInput, failIfExists: Boolean): Mono { + + require(input.contentType.isConcrete) { "Blob content type must not contain wildcards" } + + return webClient.post() + .uri { b -> + b.path("upload/storage/v1/b/{bucket}/o") + .queryParam("uploadType", "media") + .queryParam("name", "{object}") + .apply { + if (failIfExists) { + queryParam("ifGenerationMatch", 0) + } + } + .build(mapOf("bucket" to bucketName, "object" to path)) + } + .headers { h -> + h.contentType = MediaType.asMediaType(input.contentType) + input.size.let { optSize -> + if (optSize.isPresent) { + h.contentLength = optSize.asLong + } else { + h.add(HttpHeaders.TRANSFER_ENCODING, "chunked") + } + } + } + .retrieve() + .bodyToMono() + .run { + if (failIfExists) { + onErrorMap({ error -> + error is WebClientResponseException && error.statusCode == HttpStatus.PRECONDITION_FAILED + }) { error -> + BlobAlreadyExistsException(path, error) + } + + } else this + } + .then() + } + + + override fun delete(path: Path): Mono = + webClient.delete() + .uri { b -> + b.path("storage/v1/b/{bucket}/o/{object}") + .build(bucketName, path) + } + .retrieve() + .bodyToMono() + .then() +} diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlobStoreBuilder.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlobStoreBuilder.kt new file mode 100644 index 0000000..8072437 --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlobStoreBuilder.kt @@ -0,0 +1,95 @@ +package org.unbrokendome.spring.blobstore.gcs + +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.WebClientResponseException +import org.unbrokendome.spring.blobstore.gcs.auth.AuthorizationExchangeFilterFunction +import org.unbrokendome.spring.blobstore.gcs.auth.Credentials +import org.unbrokendome.spring.blobstore.gcs.auth.DefaultAccessTokenManagerFactory +import org.unbrokendome.spring.blobstore.gcs.util.ReactiveBackOff +import org.unbrokendome.spring.blobstore.gcs.util.RetryExchangeFilterFunction +import java.time.Clock + + +class GcsBlobStoreBuilder { + + private var webClientBuilder: WebClient.Builder? = null + private lateinit var bucketName: String + + private var clock: Clock? = null + private var credentials: Credentials? = null + + + fun withWebClientBuilder(webClientBuilder: WebClient.Builder) = apply { + this.webClientBuilder = webClientBuilder + } + + + fun withBucketName(bucketName: String) = apply { + this.bucketName = bucketName + } + + + fun withCredentials(credentials: Credentials) = apply { + this.credentials = credentials + } + + + fun withClock(clock: Clock) = apply { + this.clock = clock + } + + + fun build(): GcsBlobStore { + + val webClientBuilder = webClientBuilder ?: WebClient.builder() + + val webClient = webClientBuilder.clone() + + credentials?.let { credentials -> + + val authRetry = RetryExchangeFilterFunction( + retryable = ::authRetryableFilter, + backOffSelector = ::backOffSelector + ) + + val authWebClient = webClientBuilder.clone() + .filter(authRetry) + + val accessTokenManagerFactory = DefaultAccessTokenManagerFactory( + authWebClient, + clock = clock ?: Clock.systemUTC() + ) + + val accessTokenManager = accessTokenManagerFactory.createAccessTokenManager(credentials) + + webClient.filter(AuthorizationExchangeFilterFunction(accessTokenManager)) + } + + val mainRetry = RetryExchangeFilterFunction( + retryable = ::mainRetryableFilter, + backOffSelector = ::backOffSelector + ) + webClient.filter(mainRetry) + + return DefaultGcsBlobStore( + webClientBuilder = webClient, + bucketName = bucketName + ) + } + + + private companion object { + + private fun mainRetryableFilter(error: Throwable): Boolean = + error is WebClientResponseException && error.statusCode.is5xxServerError + + + private fun authRetryableFilter(error: Throwable): Boolean = + mainRetryableFilter(error) || error is WebClientResponseException.Forbidden + + + @Suppress("UNUSED_PARAMETER") + private fun backOffSelector(error: Throwable): ReactiveBackOff = + ReactiveBackOff.exponential().jitter() + } +} diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AccessToken.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AccessToken.kt new file mode 100644 index 0000000..b4eaf42 --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AccessToken.kt @@ -0,0 +1,11 @@ +package org.unbrokendome.spring.blobstore.gcs.auth + +import java.time.Duration +import java.time.Instant + + +internal data class AccessToken( + val encodedToken: String, + val expiresIn: Duration, + val expiration: Instant +) diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AccessTokenManager.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AccessTokenManager.kt new file mode 100644 index 0000000..c3e60a0 --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AccessTokenManager.kt @@ -0,0 +1,62 @@ +package org.unbrokendome.spring.blobstore.gcs.auth + +import org.springframework.http.HttpHeaders +import org.springframework.web.reactive.function.client.ClientResponse +import org.springframework.web.reactive.function.client.bodyToMono +import reactor.core.publisher.* +import java.time.Clock +import java.time.Duration +import java.time.Instant + + +internal interface AccessTokenManager { + + val accessToken: Mono + + + fun provideRequestHeaders(): Mono = + accessToken + .map { token -> + HttpHeaders() + .apply { add(HttpHeaders.AUTHORIZATION, "Bearer ${token.encodedToken}") } + } + .switchIfEmpty { HttpHeaders().toMono() } +} + + +internal abstract class AbstractAccessTokenManager( + protected val clock: Clock +) : AccessTokenManager { + + protected abstract fun retrieveAccessToken(): Mono + + + override val accessToken: Mono + get() = Mono.defer { retrieveAccessToken() } + .cache( + // ttlForValue + { accessToken -> accessToken.expiresIn }, + // ttlForError + { Duration.ofMillis(Long.MAX_VALUE) }, + // ttlForEmpty - irrelevant; retrieveAccessToken will never return an empty Mono + { Duration.ZERO } + ) + + + protected fun parseAccessTokenResponse(response: ClientResponse): Mono { + val responseDate = response.headers().asHttpHeaders() + .date.takeUnless { it < 0 }?.let { Instant.ofEpochMilli(it) } ?: clock.instant() + + return response.bodyToMono() + .map { body -> + + val expiresIn = Duration.ofSeconds(body.expiresIn.toLong()) + + AccessToken( + encodedToken = body.accessToken, + expiresIn = expiresIn, + expiration = responseDate.plus(expiresIn) + ) + } + } +} diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AccessTokenManagerFactory.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AccessTokenManagerFactory.kt new file mode 100644 index 0000000..b31b0fe --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AccessTokenManagerFactory.kt @@ -0,0 +1,36 @@ +package org.unbrokendome.spring.blobstore.gcs.auth + +import org.springframework.web.reactive.function.client.WebClient +import java.time.Clock + + +internal interface AccessTokenManagerFactory { + + fun createAccessTokenManager(credentials: Credentials): AccessTokenManager +} + + +internal class DefaultAccessTokenManagerFactory( + private val webClientBuilder: WebClient.Builder, + private val clock: Clock +) : AccessTokenManagerFactory { + + override fun createAccessTokenManager(credentials: Credentials): AccessTokenManager = + + when (credentials) { + is ServiceAccountCredentials -> + ServiceAccountAccessTokenManager( + webClientBuilder.build(), + credentials, + clock + ) + is AuthorizedUserCredentials -> + AuthorizedUserAccessTokenManager( + webClientBuilder.build(), + credentials, + clock + ) + else -> + throw IllegalArgumentException("Unsupported credentials type") + } +} diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AccessTokenResponse.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AccessTokenResponse.kt new file mode 100644 index 0000000..e1e0a3b --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AccessTokenResponse.kt @@ -0,0 +1,15 @@ +package org.unbrokendome.spring.blobstore.gcs.auth + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty + + +@JsonIgnoreProperties(ignoreUnknown = true) +internal class AccessTokenResponse +@JsonCreator constructor( + @param:JsonProperty("access_token", required = true) + val accessToken: String, + @param:JsonProperty("expires_in", required = true) + val expiresIn: Int +) diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AuthorizationExchangeFilterFunction.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AuthorizationExchangeFilterFunction.kt new file mode 100644 index 0000000..f244a42 --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AuthorizationExchangeFilterFunction.kt @@ -0,0 +1,24 @@ +package org.unbrokendome.spring.blobstore.gcs.auth + +import org.springframework.web.reactive.function.client.ClientRequest +import org.springframework.web.reactive.function.client.ClientResponse +import org.springframework.web.reactive.function.client.ExchangeFilterFunction +import org.springframework.web.reactive.function.client.ExchangeFunction +import reactor.core.publisher.* + + +internal class AuthorizationExchangeFilterFunction( + private val accessTokenManager: AccessTokenManager +) : ExchangeFilterFunction { + + override fun filter(request: ClientRequest, next: ExchangeFunction): Mono = + accessTokenManager.provideRequestHeaders() + .flatMap { authHeaders -> + val authorizedRequest = ClientRequest.from(request) + .headers { h -> + h.addAll(authHeaders) + } + .build() + next.exchange(authorizedRequest) + } +} diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AuthorizedUserAccessTokenManager.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AuthorizedUserAccessTokenManager.kt new file mode 100644 index 0000000..3e59e07 --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/AuthorizedUserAccessTokenManager.kt @@ -0,0 +1,33 @@ +package org.unbrokendome.spring.blobstore.gcs.auth + +import org.springframework.http.MediaType +import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.reactive.function.client.WebClient +import org.unbrokendome.spring.blobstore.gcs.util.retryWithBackoff +import reactor.core.publisher.* +import java.time.Clock + + +internal class AuthorizedUserAccessTokenManager( + private val webClient: WebClient, + private val credentials: AuthorizedUserCredentials, + clock: Clock +) : AbstractAccessTokenManager(clock) { + + override fun retrieveAccessToken(): Mono = + Mono.defer { + webClient.post() + .uri(credentials.tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body( + BodyInserters.fromFormData("client_id", credentials.clientId) + .with("client_secret", credentials.clientSecret) + .with("refresh_token", credentials.refreshToken) + .with("grant_type", "refresh_token") + ) + .exchange() + .flatMap { response -> + parseAccessTokenResponse(response) + } + }.retryWithBackoff() +} diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/Credentials.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/Credentials.kt new file mode 100644 index 0000000..10e0a88 --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/Credentials.kt @@ -0,0 +1,251 @@ +package org.unbrokendome.spring.blobstore.gcs.auth + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSubTypes +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.PropertyNamingStrategy +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.fasterxml.jackson.databind.annotation.JsonNaming +import com.fasterxml.jackson.databind.annotation.JsonPOJOBuilder +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo +import org.bouncycastle.openssl.PEMKeyPair +import org.bouncycastle.openssl.PEMParser +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter +import org.springframework.core.io.Resource +import java.io.InputStream +import java.io.InputStreamReader +import java.io.Reader +import java.io.StringReader +import java.net.URI +import java.security.PrivateKey +import javax.annotation.WillNotClose + + +private const val DEFAULT_TOKEN_URI = "https://oauth2.googleapis.com/token" + + +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes( + JsonSubTypes.Type(ServiceAccountCredentials::class, name = "service_account"), + JsonSubTypes.Type(AuthorizedUserCredentials::class, name = "authorized_user") +) +interface Credentials { + + val tokenUri: URI + + companion object { + + @JvmStatic + fun serviceAccount(): ServiceAccountCredentialsBuilder = + ServiceAccountCredentials.Builder() + + @JvmStatic + fun authorizedUser(): AuthorizedUserCredentialsBuilder = + AuthorizedUserCredentials.Builder() + + @JvmStatic + fun fromJson(resource: Resource, objectMapper: ObjectMapper? = null): Credentials = + resource.inputStream.use { input -> + (objectMapper ?: ObjectMapper()).readerFor(Credentials::class.java) + .readValue(input) + } + + @JvmStatic + fun fromJson(json: String, objectMapper: ObjectMapper? = null): Credentials = + (objectMapper ?: ObjectMapper()).readerFor(Credentials::class.java) + .readValue(json) + } +} + + +interface CredentialsBuilder> { + + fun withTokenUri(tokenUri: URI): B + + fun withClientId(clientId: String): B + + fun build(): Credentials +} + + +@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) +@Suppress("UNCHECKED_CAST") +internal abstract class AbstractCredentialsBuilder> + : CredentialsBuilder { + + protected var tokenUri: URI = URI.create(DEFAULT_TOKEN_URI) + protected lateinit var clientId: String + + @JsonProperty(required = false) + override fun withTokenUri(tokenUri: URI): B = apply { + this.tokenUri = tokenUri + } as B + + @JsonProperty(required = true) + override fun withClientId(clientId: String) = apply { + this.clientId = clientId + } as B +} + + +interface ServiceAccountCredentialsBuilder : CredentialsBuilder { + + fun withClientEmail(clientEmail: String): ServiceAccountCredentialsBuilder + + + fun withPrivateKey(privateKey: PrivateKey): ServiceAccountCredentialsBuilder + + + @JvmDefault + fun withPrivateKey(privateKeyPem: String): ServiceAccountCredentialsBuilder = + withPrivateKey(StringReader(privateKeyPem)) + + + @JvmDefault + @JsonIgnore + fun withPrivateKey(@WillNotClose privateKeyPemReader: Reader): ServiceAccountCredentialsBuilder { + val pemObject = PEMParser(privateKeyPemReader).readObject() + + val converter = JcaPEMKeyConverter() + + val privateKey = when (pemObject) { + is PrivateKeyInfo -> + converter.getPrivateKey(pemObject) + is PEMKeyPair -> + converter.getKeyPair(pemObject).private + else -> + throw IllegalArgumentException("Private key must be a PEM-encoded private key or key pair") + } + + return withPrivateKey(privateKey) + } + + @JvmDefault + @JsonIgnore + fun withPrivateKey(@WillNotClose privateKeyPemInput: InputStream): ServiceAccountCredentialsBuilder = + withPrivateKey(InputStreamReader(privateKeyPemInput)) + + + @JvmDefault + @JsonIgnore + fun withPrivateKey(privateKeyPemResource: Resource): ServiceAccountCredentialsBuilder = + privateKeyPemResource.inputStream.use { input -> withPrivateKey(input) } + + + fun withPrivateKeyId(privateKeyId: String): ServiceAccountCredentialsBuilder + + fun withScopes(scopes: Iterable): ServiceAccountCredentialsBuilder + + @JvmDefault + @JsonIgnore + fun withScopes(vararg scopes: String) = + withScopes(scopes.toSet()) +} + + +interface AuthorizedUserCredentialsBuilder : CredentialsBuilder { + + fun withClientSecret(clientSecret: String): AuthorizedUserCredentialsBuilder + + fun withRefreshToken(refreshToken: String): AuthorizedUserCredentialsBuilder +} + + +@JsonDeserialize(builder = ServiceAccountCredentials.Builder::class) +internal data class ServiceAccountCredentials( + override val tokenUri: URI, + val clientId: String, + val clientEmail: String, + val privateKeyId: String, + val privateKey: PrivateKey, + val scopes: Set = emptySet() +) : Credentials { + + + fun withScopes(scopes: Set) = + copy(scopes = this.scopes + scopes) + + + @JsonPOJOBuilder + @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) + @JsonIgnoreProperties(ignoreUnknown = true) + class Builder : AbstractCredentialsBuilder(), ServiceAccountCredentialsBuilder { + + private lateinit var clientEmail: String + private lateinit var privateKey: PrivateKey + private lateinit var privateKeyId: String + private var scopes: Set = emptySet() + + @JsonProperty(required = true) + override fun withClientEmail(clientEmail: String) = apply { + this.clientEmail = clientEmail + } + + @JsonIgnore + override fun withPrivateKey(privateKey: PrivateKey) = apply { + this.privateKey = privateKey + } + + @JsonProperty(required = true) + override fun withPrivateKey(privateKeyPem: String) = + super.withPrivateKey(privateKeyPem) + + @JsonProperty(required = true) + override fun withPrivateKeyId(privateKeyId: String) = apply { + this.privateKeyId = privateKeyId + } + + @JsonProperty(required = false) + override fun withScopes(scopes: Iterable) = apply { + this.scopes += scopes + } + + override fun build(): ServiceAccountCredentials = + ServiceAccountCredentials( + tokenUri = tokenUri, + clientId = clientId, + clientEmail = clientEmail, + privateKeyId = privateKeyId, + privateKey = privateKey + ) + } +} + + +internal data class AuthorizedUserCredentials( + override val tokenUri: URI, + val clientId: String, + val clientSecret: String, + val refreshToken: String +) : Credentials { + + @JsonPOJOBuilder + @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) + @JsonIgnoreProperties(ignoreUnknown = true) + class Builder : AbstractCredentialsBuilder(), AuthorizedUserCredentialsBuilder { + + private lateinit var clientSecret: String + private lateinit var refreshToken: String + + @JsonProperty(required = true) + override fun withClientSecret(clientSecret: String) = apply { + this.clientSecret = clientSecret + } + + @JsonProperty(required = true) + override fun withRefreshToken(refreshToken: String) = apply { + this.refreshToken = refreshToken + } + + override fun build(): AuthorizedUserCredentials = + AuthorizedUserCredentials( + tokenUri = tokenUri, + clientId = clientId, + clientSecret = clientSecret, + refreshToken = refreshToken + ) + } +} diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/ServiceAccountAccessTokenManager.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/ServiceAccountAccessTokenManager.kt new file mode 100644 index 0000000..4db6268 --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/auth/ServiceAccountAccessTokenManager.kt @@ -0,0 +1,82 @@ +package org.unbrokendome.spring.blobstore.gcs.auth + +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.reactive.function.client.WebClient +import org.unbrokendome.jsonwebtoken.Claims +import org.unbrokendome.jsonwebtoken.Jwt +import org.unbrokendome.jsonwebtoken.signature.SignatureAlgorithms +import org.unbrokendome.spring.blobstore.gcs.util.RandomExponentialBackoff +import org.unbrokendome.spring.blobstore.gcs.util.WebClientErrorFilters +import org.unbrokendome.spring.blobstore.gcs.util.retryWithBackoff +import reactor.core.publisher.* +import reactor.core.scheduler.Scheduler +import reactor.core.scheduler.Schedulers +import java.time.Clock +import java.time.Duration +import java.util.function.Predicate + + +internal class ServiceAccountAccessTokenManager( + private val webClient: WebClient, + private val credentials: ServiceAccountCredentials, + clock: Clock = Clock.systemUTC(), + private val tokenLifetime: Duration = Duration.ofHours(1L), + private val encoderScheduler: Scheduler = Schedulers.parallel() +) : AbstractAccessTokenManager(clock) { + + private val retryErrorFilter = WebClientErrorFilters.forStatusCode(Predicate { status -> + // Server error --- includes timeout errors, which use 500 instead of 408 + status.is5xxServerError || + // Forbidden error --- for historical reasons, used for rate_limit_exceeded + // errors instead of 429 + status == HttpStatus.FORBIDDEN + }) + + + private val jwtProcessor = Jwt.processor() + .encodeOnly() + .signWith(SignatureAlgorithms.RS256, credentials.privateKey) + .header { + it.type = "JWT" + it.keyId = credentials.privateKeyId + } + .build() + + + private fun claims(): Claims { + val now = clock.instant() + return Jwt.claims() + .setIssuer(credentials.clientEmail) + .set("scope", credentials.scopes.joinToString(separator = " ")) + .setAudience(credentials.tokenUri.toString()) + .setIssuedAt(now) + .setExpiration(now.plus(tokenLifetime)) + } + + + private fun encodeAssertion(): Mono = + Mono.fromSupplier { jwtProcessor.encode(claims()) } + .subscribeOn(encoderScheduler) + + + override fun retrieveAccessToken(): Mono = + encodeAssertion() + .flatMap { assertion -> + Mono.defer { + webClient.post() + .uri(credentials.tokenUri) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body( + BodyInserters + .fromFormData("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") + .with("assertion", assertion) + ) + .exchange() + .flatMap { response -> + parseAccessTokenResponse(response) + } + }.retryWithBackoff(retryErrorFilter) + } +} diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/autoconfigure/GcsBlobStoreConfigurationFactory.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/autoconfigure/GcsBlobStoreConfigurationFactory.kt new file mode 100644 index 0000000..05dbee0 --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/autoconfigure/GcsBlobStoreConfigurationFactory.kt @@ -0,0 +1,40 @@ +package org.unbrokendome.spring.blobstore.gcs.autoconfigure + +import org.springframework.beans.factory.config.BeanDefinition +import org.springframework.beans.factory.getBeanProvider +import org.springframework.beans.factory.support.BeanDefinitionBuilder +import org.springframework.boot.context.properties.bind.Bindable +import org.springframework.context.ApplicationContext +import org.springframework.web.reactive.function.client.WebClient +import org.unbrokendome.spring.blobstore.autoconfigure.BlobStoreConfigurationFactory +import org.unbrokendome.spring.blobstore.gcs.GcsBlobStore +import java.time.Clock + + +class GcsBlobStoreConfigurationFactory : BlobStoreConfigurationFactory { + + override val type: String + get() = "gcs" + + + override fun configurationBindable(): Bindable = + Bindable.of(GcsBlobStoreProperties::class.java) + + + override fun beanDefinition( + configuration: GcsBlobStoreProperties, + applicationContext: ApplicationContext + ): BeanDefinition = + BeanDefinitionBuilder.genericBeanDefinition(GcsBlobStore::class.java) { + + val webClientBuilderProvider = applicationContext.getBeanProvider() + val clockProvider = applicationContext.getBeanProvider() + + GcsBlobStore.builder().run { + configuration.configureBuilder(this) + webClientBuilderProvider.ifAvailable { withWebClientBuilder(it) } + clockProvider.ifAvailable { withClock(it) } + build() + } + }.beanDefinition +} diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/autoconfigure/GcsBlobStoreDefinition.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/autoconfigure/GcsBlobStoreDefinition.kt new file mode 100644 index 0000000..eaec512 --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/autoconfigure/GcsBlobStoreDefinition.kt @@ -0,0 +1,14 @@ +package org.unbrokendome.spring.blobstore.gcs.autoconfigure + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.NestedConfigurationProperty +import org.unbrokendome.spring.blobstore.autoconfigure.BlobStoreDefinition + + +@Suppress("ConfigurationProperties") +@ConfigurationProperties +class GcsBlobStoreDefinition : BlobStoreDefinition() { + + @NestedConfigurationProperty + var gcs: GcsBlobStoreProperties? = null +} diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/autoconfigure/GcsBlobStoreProperties.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/autoconfigure/GcsBlobStoreProperties.kt new file mode 100644 index 0000000..02ede5b --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/autoconfigure/GcsBlobStoreProperties.kt @@ -0,0 +1,44 @@ +package org.unbrokendome.spring.blobstore.gcs.autoconfigure + +import org.springframework.core.io.Resource +import org.unbrokendome.spring.blobstore.gcs.GcsBlobStoreBuilder +import kotlin.reflect.KProperty0 +import org.unbrokendome.spring.blobstore.gcs.auth.Credentials as GcsCredentials + + +class GcsBlobStoreProperties { + + var bucketName: String? = null + + var credentials: Credentials? = null + + + class Credentials { + var json: String? = null + var jsonResource: Resource? = null + + internal fun buildCredentials(): GcsCredentials { + json?.let { + return GcsCredentials.fromJson(it) + } + jsonResource?.let { + return GcsCredentials.fromJson(it) + } + throw IllegalStateException("Either \"json\" or \"jsonResource\" is required") + } + } + + + internal fun configureBuilder(builder: GcsBlobStoreBuilder) { + + builder.withBucketName(checkRequiredProperty(this::bucketName)) + + credentials?.let { credentials -> + builder.withCredentials(credentials.buildCredentials()) + } + } +} + + +private fun checkRequiredProperty(prop: KProperty0): R = + checkNotNull(prop.get()) { "Property \"${prop.name}\" is required" } diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/ClientResponseExtensions.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/ClientResponseExtensions.kt new file mode 100644 index 0000000..4e42171 --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/ClientResponseExtensions.kt @@ -0,0 +1,32 @@ +package org.unbrokendome.spring.blobstore.gcs.util + +import org.springframework.web.reactive.function.client.ClientResponse +import org.springframework.web.reactive.function.client.WebClientResponseException +import org.springframework.web.reactive.function.client.bodyToMono +import reactor.core.publisher.* + + +internal fun ClientResponse.checkStatusCode(): Mono = + if (statusCode().isError) { + bodyToMono() + .defaultIfEmpty(byteArrayOf()) + .flatMap { bodyBytes -> + WebClientResponseException.create( + rawStatusCode(), + statusCode().reasonPhrase, + headers().asHttpHeaders(), + bodyBytes, + headers().contentType().map { it.charset }.orElse(null) + ) + .toMono() + } + + } else { + toMono() + } + + +internal fun Mono.checkStatusCode(): Mono = + flatMap { clientResponse -> + clientResponse.checkStatusCode() + } diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/RandomExponentialBackoff.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/RandomExponentialBackoff.kt new file mode 100644 index 0000000..467e001 --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/RandomExponentialBackoff.kt @@ -0,0 +1,82 @@ +package org.unbrokendome.spring.blobstore.gcs.util + +import org.reactivestreams.Publisher +import reactor.core.publisher.* +import reactor.core.scheduler.Scheduler +import reactor.core.scheduler.Schedulers +import java.time.Duration +import java.util.concurrent.ThreadLocalRandom +import java.util.function.Function +import java.util.function.Predicate + + +class RandomExponentialBackoff( + private val errorFilter: Predicate, + private val numRetries: Long = 10, + private val firstBackoff: Duration = Duration.ofMillis(500), + private val backoffFactor: Double = 1.5, + private val maxBackoff: Duration = Duration.ofMillis(Long.MAX_VALUE), + private val jitterFactor: Double = 0.5, + private val timer: Scheduler = Schedulers.parallel() +) : Function, Publisher> { + + override fun apply(throwables: Flux): Publisher = + throwables.index() + .flatMap { indexAndError -> + val iteration = indexAndError.t1 + val error = indexAndError.t2 + + if (!errorFilter.test(error)) { + return@flatMap error.toMono() + } + + if (iteration >= numRetries) { + return@flatMap IllegalStateException( + "Retries exhausted: $iteration/$numRetries", + error + ).toMono() + } + + val nextBackoff = try { + firstBackoff.multipliedBy(Math.pow(backoffFactor, iteration.toDouble()).toLong()) + .coerceAtMost(maxBackoff) + } catch (ex: ArithmeticException) { + maxBackoff + } + + if (nextBackoff.isZero) { + iteration.toMono() + } else { + Mono.delay(applyJitter(nextBackoff), timer) + } + } + + + private fun applyJitter(nextBackoff: Duration): Duration = + nextBackoff.plusMillis(jitter(nextBackoff)) + + + private fun jitter(nextBackoff: Duration): Long { + val jitterOffset = jitterOffset(nextBackoff) + + val lowBound = Math.max(firstBackoff.minus(nextBackoff).toMillis(), -jitterOffset) + val highBound = Math.min(maxBackoff.minus(nextBackoff).toMillis(), jitterOffset) + + val random = ThreadLocalRandom.current() + return if (highBound == lowBound) { + if (highBound == 0L) 0L else random.nextLong(highBound) + } else { + random.nextLong(lowBound, highBound) + } + } + + + private fun jitterOffset(nextBackoff: Duration): Long = + try { + nextBackoff.multipliedBy((jitterFactor * 100.0).toLong()) + .dividedBy(100) + .toMillis() + } catch (ex: ArithmeticException) { + Math.round(Long.MAX_VALUE * jitterFactor) + } +} diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/ReactiveBackOff.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/ReactiveBackOff.kt new file mode 100644 index 0000000..54af5ec --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/ReactiveBackOff.kt @@ -0,0 +1,82 @@ +package org.unbrokendome.spring.blobstore.gcs.util + +import java.time.Duration +import java.util.concurrent.ThreadLocalRandom + + +interface ReactiveBackOff { + + fun nextBackOffDuration(attempt: Long): Duration + + + fun jitter(jitterFactor: Double = 0.2): ReactiveBackOff = + JitterReactiveBackOffDecorator(this, jitterFactor) + + + companion object { + + fun fixed(interval: Duration = Duration.ofMillis(500L)): ReactiveBackOff = + FixedReactiveBackOff(interval) + + fun exponential( + firstBackOff: Duration = Duration.ofMillis(500L), + backOffFactor: Double = 1.5, + maxBackOff: Duration = Duration.ofMillis(Long.MAX_VALUE) + ): ReactiveBackOff = + ExponentialReactiveBackOff(firstBackOff, backOffFactor, maxBackOff) + } +} + + +class FixedReactiveBackOff( + private val interval: Duration +) : ReactiveBackOff { + + override fun nextBackOffDuration(attempt: Long): Duration = + interval +} + + +class ExponentialReactiveBackOff( + private val firstBackOff: Duration, + private val backOffFactor: Double, + private val maxBackOff: Duration +) : ReactiveBackOff { + + override fun nextBackOffDuration(attempt: Long): Duration = + try { + firstBackOff.multipliedBy(Math.pow(backOffFactor, attempt.toDouble()).toLong()) + .coerceAtMost(maxBackOff) + } catch (ex: ArithmeticException) { + maxBackOff + } +} + + +class JitterReactiveBackOffDecorator( + private val delegate: ReactiveBackOff, + jitterFactor: Double = 0.2 +) : ReactiveBackOff { + + init { + require(jitterFactor in (0.0)..(1.0)) { "jitterFactor must be between 0.0 and 1.0" } + } + + private val minJitter = 1.0 - jitterFactor + private val maxJitter = 1.0 + jitterFactor + + override fun nextBackOffDuration(attempt: Long): Duration { + + val nextBackOff = delegate.nextBackOffDuration(attempt) + + val actualJitter = ThreadLocalRandom.current().nextDouble(minJitter, maxJitter) + .coerceIn(0.0, 2.0) + + return try { + nextBackOff.multipliedBy((100 * actualJitter).toLong()) + .dividedBy(100) + } catch (ex: ArithmeticException) { + nextBackOff + } + } +} diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/ReactiveRetryFunction.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/ReactiveRetryFunction.kt new file mode 100644 index 0000000..2256de1 --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/ReactiveRetryFunction.kt @@ -0,0 +1,56 @@ +package org.unbrokendome.spring.blobstore.gcs.util + +import org.reactivestreams.Publisher +import reactor.core.publisher.* +import reactor.core.scheduler.Scheduler +import reactor.core.scheduler.Schedulers +import java.util.function.Function + + +internal typealias RetryablePredicate = (Throwable) -> Boolean +internal typealias BackOffSelector = (Throwable) -> ReactiveBackOff + + +internal class ReactiveRetryFunction( + private val numAttempts: Long = DefaultNumAttempts, + private val retryable: RetryablePredicate = DefaultRetryablePredicate, + private val backOffSelector: BackOffSelector = DefaultBackOffSelector, + private val timer: Scheduler = Schedulers.parallel() +) : Function, Publisher> { + + companion object { + + const val DefaultNumAttempts: Long = 10L + val DefaultRetryablePredicate: RetryablePredicate = { true } + val DefaultBackOffSelector: BackOffSelector = { ReactiveBackOff.fixed().jitter() } + } + + + override fun apply(errors: Flux): Publisher = + errors.index() + .flatMap { indexAndError -> + + val attempt = indexAndError.t1 + val error = indexAndError.t2 + + if (!retryable(error)) { + return@flatMap error.toMono() + } + + if (attempt >= numAttempts) { + return@flatMap IllegalStateException( + "Retries exhausted: $attempt/$numAttempts", + error + ).toMono() + } + + val backOff = backOffSelector(error) + val backOffDuration = backOff.nextBackOffDuration(attempt) + + if (backOffDuration.isZero) { + attempt.toMono() + } else { + Mono.delay(backOffDuration, timer) + } + } +} diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/RetryExchangeFilterFunction.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/RetryExchangeFilterFunction.kt new file mode 100644 index 0000000..34022cf --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/RetryExchangeFilterFunction.kt @@ -0,0 +1,25 @@ +package org.unbrokendome.spring.blobstore.gcs.util + +import org.springframework.web.reactive.function.client.ClientRequest +import org.springframework.web.reactive.function.client.ClientResponse +import org.springframework.web.reactive.function.client.ExchangeFilterFunction +import org.springframework.web.reactive.function.client.ExchangeFunction +import reactor.core.publisher.* + + +internal class RetryExchangeFilterFunction( + private val reactiveRetryFunction: ReactiveRetryFunction +) : ExchangeFilterFunction { + + constructor( + numAttempts: Long = ReactiveRetryFunction.DefaultNumAttempts, + retryable: RetryablePredicate = ReactiveRetryFunction.DefaultRetryablePredicate, + backOffSelector: BackOffSelector = ReactiveRetryFunction.DefaultBackOffSelector + ) : this(ReactiveRetryFunction(numAttempts, retryable, backOffSelector)) + + + override fun filter(request: ClientRequest, next: ExchangeFunction): Mono = + Mono.defer { + next.exchange(request) + }.retryWhen(reactiveRetryFunction) +} diff --git a/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/WebClientErrorFilters.kt b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/WebClientErrorFilters.kt new file mode 100644 index 0000000..c7e4af7 --- /dev/null +++ b/spring-blobstore-gcs/src/main/kotlin/org/unbrokendome/spring/blobstore/gcs/util/WebClientErrorFilters.kt @@ -0,0 +1,28 @@ +package org.unbrokendome.spring.blobstore.gcs.util + +import org.springframework.http.HttpStatus +import org.springframework.web.reactive.function.client.WebClientResponseException +import reactor.core.publisher.* +import java.util.function.Predicate + + +internal object WebClientErrorFilters { + + fun forStatusCode(statusCodePredicate: Predicate): Predicate = + Predicate { error -> + error is WebClientResponseException && statusCodePredicate.test(error.statusCode) + } + + + val DEFAULT = forStatusCode(Predicate { status -> + status == HttpStatus.INTERNAL_SERVER_ERROR || status == HttpStatus.SERVICE_UNAVAILABLE + }) +} + + +fun Mono.retryWithBackoff( + errorFilter: Predicate = WebClientErrorFilters.DEFAULT, + numRetries: Long = 10L +): Mono { + return retryWhen(RandomExponentialBackoff(errorFilter, numRetries)) +} diff --git a/spring-blobstore-gcs/src/main/resources/META-INF/services/org.unbrokendome.spring.blobstore.autoconfigure.BlobStoreConfigurationFactory b/spring-blobstore-gcs/src/main/resources/META-INF/services/org.unbrokendome.spring.blobstore.autoconfigure.BlobStoreConfigurationFactory new file mode 100644 index 0000000..dd983c4 --- /dev/null +++ b/spring-blobstore-gcs/src/main/resources/META-INF/services/org.unbrokendome.spring.blobstore.autoconfigure.BlobStoreConfigurationFactory @@ -0,0 +1 @@ +org.unbrokendome.spring.blobstore.gcs.autoconfigure.GcsBlobStoreConfigurationFactory diff --git a/spring-blobstore-gcs/src/main/resources/META-INF/spring-configuration-metadata.json b/spring-blobstore-gcs/src/main/resources/META-INF/spring-configuration-metadata.json new file mode 100644 index 0000000..f74db29 --- /dev/null +++ b/spring-blobstore-gcs/src/main/resources/META-INF/spring-configuration-metadata.json @@ -0,0 +1,12 @@ +{ + "groups": [ + ], + "properties": [ + { + "name": "blobstore", + "type": "java.util.Map" + } + ], + "hints": [ + ] +} diff --git a/spring-blobstore-gcs/src/test/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlobMetadataJsonTest.kt b/spring-blobstore-gcs/src/test/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlobMetadataJsonTest.kt new file mode 100644 index 0000000..c29f78e --- /dev/null +++ b/spring-blobstore-gcs/src/test/kotlin/org/unbrokendome/spring/blobstore/gcs/GcsBlobMetadataJsonTest.kt @@ -0,0 +1,11 @@ +package org.unbrokendome.spring.blobstore.gcs + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule + +class GcsBlobMetadataJsonTest { + + val objectMapper = ObjectMapper() + .registerModule(JavaTimeModule()) + +} diff --git a/spring-blobstore/build.gradle.kts b/spring-blobstore/build.gradle.kts new file mode 100644 index 0000000..4a2fd56 --- /dev/null +++ b/spring-blobstore/build.gradle.kts @@ -0,0 +1,19 @@ +import groovy.lang.Closure + +plugins { + `java-library` + kotlin("jvm") + kotlin("plugin.spring") + id("com.jfrog.bintray") +} + +val optional: Closure<*> by extra + +dependencies { + api("org.springframework:spring-core") + api("io.projectreactor:reactor-core") + + compileOnly("org.springframework.boot:spring-boot-autoconfigure") + + testImplementation("io.projectreactor:reactor-test") +} diff --git a/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/Blob.kt b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/Blob.kt new file mode 100644 index 0000000..34b2452 --- /dev/null +++ b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/Blob.kt @@ -0,0 +1,10 @@ +package org.unbrokendome.spring.blobstore + +import org.reactivestreams.Publisher +import org.springframework.core.io.buffer.DataBuffer + + +interface Blob : BlobMetadataBase { + + val data: Publisher +} diff --git a/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/BlobInput.kt b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/BlobInput.kt new file mode 100644 index 0000000..a481274 --- /dev/null +++ b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/BlobInput.kt @@ -0,0 +1,32 @@ +package org.unbrokendome.spring.blobstore + +import org.reactivestreams.Publisher +import org.springframework.core.io.buffer.DataBuffer +import org.springframework.util.MimeType +import java.util.OptionalLong + + +interface BlobInput { + + val size: OptionalLong + + val contentType: MimeType + + val data: Publisher + + + companion object { + operator fun invoke( + size: OptionalLong = OptionalLong.empty(), + contentType: MimeType = MimeType.valueOf("application/octet-stream"), + data: Publisher + ): BlobInput = + DefaultBlobInput(size, contentType, data) + + data class DefaultBlobInput( + override val size: OptionalLong, + override val contentType: MimeType, + override val data: Publisher + ) : BlobInput + } +} diff --git a/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/BlobMetadata.kt b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/BlobMetadata.kt new file mode 100644 index 0000000..3211310 --- /dev/null +++ b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/BlobMetadata.kt @@ -0,0 +1,4 @@ +package org.unbrokendome.spring.blobstore + + +interface BlobMetadata : BlobMetadataBase diff --git a/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/BlobMetadataBase.kt b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/BlobMetadataBase.kt new file mode 100644 index 0000000..409f6e1 --- /dev/null +++ b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/BlobMetadataBase.kt @@ -0,0 +1,21 @@ +package org.unbrokendome.spring.blobstore + +import org.springframework.util.MimeType +import java.nio.file.Path +import java.time.Instant + + +interface BlobMetadataBase { + + val path: Path + + val size: Long + + val contentType: MimeType + + val etag: String? + get() = null + + val lastModified: Instant? + get() = null +} diff --git a/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/BlobStore.kt b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/BlobStore.kt new file mode 100644 index 0000000..57d069b --- /dev/null +++ b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/BlobStore.kt @@ -0,0 +1,47 @@ +package org.unbrokendome.spring.blobstore + +import reactor.core.publisher.* +import java.nio.file.Path +import java.time.Instant + + +interface BlobStore { + + fun getMetadata(path: Path): Mono + + + fun retrieve(metadata: BlobMetadata): Mono + + + @JvmDefault + fun retrieveDirect( + path: Path, + ifNoneMatch: Iterable? = null, + ifModifiedSince: Instant? = null + ): Mono = + getMetadata(path) + .flatMap { metadata -> + ifNoneMatch?.toSet()?.takeIf { it.isNotEmpty() } + ?.let { ifNoneMatch -> + val etag = metadata.etag + if (etag != null && etag in ifNoneMatch) { + return@flatMap Mono.empty() + } + } + + if (ifModifiedSince != null) { + val lastModified = metadata.lastModified + if (lastModified != null && lastModified < ifModifiedSince) { + return@flatMap Mono.empty() + } + } + + retrieve(metadata) + } + + + fun store(path: Path, input: BlobInput, failIfExists: Boolean = false): Mono + + + fun delete(path: Path): Mono +} diff --git a/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/BlobStoreExceptions.kt b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/BlobStoreExceptions.kt new file mode 100644 index 0000000..9606735 --- /dev/null +++ b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/BlobStoreExceptions.kt @@ -0,0 +1,15 @@ +package org.unbrokendome.spring.blobstore + +import org.springframework.core.NestedRuntimeException +import java.nio.file.Path + + +abstract class BlobStoreException(msg: String, cause: Throwable? = null) : NestedRuntimeException(msg, cause) + + +class BlobNotFoundException(path: Path, cause: Throwable? = null) + : BlobStoreException("The blob $path does not exist in this BlobStore", cause) + + +class BlobAlreadyExistsException(path: Path, cause: Throwable? = null) + : BlobStoreException("The blob $path already exists in this BlobStore", cause) diff --git a/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/autoconfigure/BlobStoreAutoConfiguration.java b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/autoconfigure/BlobStoreAutoConfiguration.java new file mode 100644 index 0000000..2384dd6 --- /dev/null +++ b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/autoconfigure/BlobStoreAutoConfiguration.java @@ -0,0 +1,14 @@ +package org.unbrokendome.spring.blobstore.autoconfigure; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +@Configuration +public class BlobStoreAutoConfiguration { + + @Bean + public static BlobStoreConfigurationPostProcessor blobStoreConfigurationPostProcessor() { + return new BlobStoreConfigurationPostProcessor(); + } +} diff --git a/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/autoconfigure/BlobStoreConfigurationFactory.kt b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/autoconfigure/BlobStoreConfigurationFactory.kt new file mode 100644 index 0000000..b68aea7 --- /dev/null +++ b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/autoconfigure/BlobStoreConfigurationFactory.kt @@ -0,0 +1,15 @@ +package org.unbrokendome.spring.blobstore.autoconfigure + +import org.springframework.beans.factory.config.BeanDefinition +import org.springframework.boot.context.properties.bind.Bindable +import org.springframework.context.ApplicationContext + + +interface BlobStoreConfigurationFactory { + + val type: String + + fun configurationBindable(): Bindable + + fun beanDefinition(configuration: T, applicationContext: ApplicationContext): BeanDefinition +} diff --git a/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/autoconfigure/BlobStoreConfigurationPostProcessor.kt b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/autoconfigure/BlobStoreConfigurationPostProcessor.kt new file mode 100644 index 0000000..1c25e6e --- /dev/null +++ b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/autoconfigure/BlobStoreConfigurationPostProcessor.kt @@ -0,0 +1,81 @@ +package org.unbrokendome.spring.blobstore.autoconfigure + +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory +import org.springframework.beans.factory.support.AbstractBeanDefinition +import org.springframework.beans.factory.support.AutowireCandidateQualifier +import org.springframework.beans.factory.support.BeanDefinitionRegistry +import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor +import org.springframework.boot.context.properties.bind.Bindable +import org.springframework.boot.context.properties.bind.Binder +import org.springframework.context.ApplicationContext +import org.springframework.context.ApplicationContextAware +import org.springframework.core.ResolvableType +import org.unbrokendome.spring.blobstore.BlobStore +import java.util.ServiceLoader + + +internal class BlobStoreConfigurationPostProcessor : BeanDefinitionRegistryPostProcessor, ApplicationContextAware { + + private lateinit var applicationContext: ApplicationContext + + private val factories = ServiceLoader.load(BlobStoreConfigurationFactory::class.java) + .associateBy { it.type } + + + override fun setApplicationContext(applicationContext: ApplicationContext) { + this.applicationContext = applicationContext + } + + override fun postProcessBeanDefinitionRegistry(registry: BeanDefinitionRegistry) { + + val binder = Binder.get(applicationContext.environment) + + binder.bind("blobstore", blobStoresMapBindable()) + .orElse(emptyMap()) + .forEach { blobStoreName, blobStoreSpec -> + + require(blobStoreSpec.size == 1) { + "Blob store configuration must have a single key that indicates the type of the blob store, " + + "but found ${blobStoreSpec.size}: ${blobStoreSpec.keys}" + } + val blobStoreType = blobStoreSpec.keys.single() + + @Suppress("UNCHECKED_CAST") val factory = + requireNotNull(factories[blobStoreType]) { + "Unknown blob store type: \"$blobStoreType\" for blob store \"$blobStoreName\"" + } as BlobStoreConfigurationFactory + + val blobStoreConfig: Any = binder.bind( + "blobstore.$blobStoreName.$blobStoreType", + factory.configurationBindable() + ).orElseThrow { + IllegalArgumentException("Could not bind blob store configuration of type $blobStoreType") + } + + val beanDefinition = factory.beanDefinition(blobStoreConfig, applicationContext) + if (beanDefinition is AbstractBeanDefinition) { + beanDefinition.addQualifier(AutowireCandidateQualifier(Qualifier::class.java, blobStoreName)) + } + + registry.registerBeanDefinition( + BlobStore::class.java.name + ".$blobStoreName", + beanDefinition + ) + } + } + + + private fun blobStoresMapBindable(): Bindable>> = + Bindable.of>>( + ResolvableType.forClassWithGenerics( + Map::class.java, + ResolvableType.forClass(String::class.java), + ResolvableType.forClassWithGenerics(Map::class.java, String::class.java, Any::class.java) + ) + ) + + + override fun postProcessBeanFactory(beanFactory: ConfigurableListableBeanFactory) { + } +} diff --git a/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/autoconfigure/BlobStoreDefinition.kt b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/autoconfigure/BlobStoreDefinition.kt new file mode 100644 index 0000000..c83b071 --- /dev/null +++ b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/autoconfigure/BlobStoreDefinition.kt @@ -0,0 +1,3 @@ +package org.unbrokendome.spring.blobstore.autoconfigure + +open class BlobStoreDefinition diff --git a/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/filesystem/FileSystemBlob.kt b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/filesystem/FileSystemBlob.kt new file mode 100644 index 0000000..68b56dc --- /dev/null +++ b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/filesystem/FileSystemBlob.kt @@ -0,0 +1,47 @@ +package org.unbrokendome.spring.blobstore.filesystem + +import org.reactivestreams.Publisher +import org.springframework.core.io.buffer.DataBuffer +import org.springframework.core.io.buffer.DataBufferFactory +import org.springframework.core.io.buffer.DataBufferUtils +import org.springframework.util.MimeType +import org.unbrokendome.spring.blobstore.Blob +import org.unbrokendome.spring.blobstore.BlobMetadata +import java.nio.channels.AsynchronousFileChannel +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.time.Instant +import java.util.Properties + + +internal class FileSystemBlob( + val blobStore: FileSystemBlobStore, + override val path: Path, + private val metadata: Properties, + private val dataBufferFactory: DataBufferFactory, + private val bufferSize: Int +) : Blob, BlobMetadata { + + override val size: Long + get() = Files.size(path) + + + override val contentType: MimeType + get() = MimeType.valueOf(metadata.getProperty("content-type")) + + + override val etag: String? + get() = metadata.getProperty("etag") + + + override val lastModified: Instant? + get() = Instant.parse(metadata.getProperty("last-modified")) + + + override val data: Publisher + get() = DataBufferUtils.readAsynchronousFileChannel( + { AsynchronousFileChannel.open(path, StandardOpenOption.READ) }, + dataBufferFactory, bufferSize + ) +} diff --git a/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/filesystem/FileSystemBlobStore.kt b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/filesystem/FileSystemBlobStore.kt new file mode 100644 index 0000000..5d97699 --- /dev/null +++ b/spring-blobstore/src/main/kotlin/org/unbrokendome/spring/blobstore/filesystem/FileSystemBlobStore.kt @@ -0,0 +1,186 @@ +package org.unbrokendome.spring.blobstore.filesystem + +import org.reactivestreams.Publisher +import org.springframework.core.io.FileSystemResource +import org.springframework.core.io.buffer.DataBuffer +import org.springframework.core.io.buffer.DataBufferFactory +import org.springframework.core.io.buffer.DataBufferUtils +import org.unbrokendome.spring.blobstore.Blob +import org.unbrokendome.spring.blobstore.BlobAlreadyExistsException +import org.unbrokendome.spring.blobstore.BlobInput +import org.unbrokendome.spring.blobstore.BlobMetadata +import org.unbrokendome.spring.blobstore.BlobNotFoundException +import org.unbrokendome.spring.blobstore.BlobStore +import reactor.core.publisher.* +import reactor.core.scheduler.Schedulers +import java.io.ByteArrayOutputStream +import java.nio.channels.AsynchronousFileChannel +import java.nio.file.Files +import java.nio.file.NoSuchFileException +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.nio.file.StandardOpenOption.* +import java.security.MessageDigest +import java.time.Clock +import java.util.Base64 +import java.util.Properties + + +interface FileSystemBlobStore : BlobStore { + + val basePath: Path + +} + + +class DefaultFileSystemBlobStore( + override val basePath: Path, + private val dataBufferFactory: DataBufferFactory, + private val bufferSize: Int, + private val clock: Clock, + private val digestAlgorithm: String = "SHA-1" +) : FileSystemBlobStore { + + private val tempDir = basePath.resolve(".tmp") + .also { Files.createDirectories(it) } + + + override fun getMetadata(path: Path): Mono { + val resolvedPath = basePath.resolve(path) + return Mono.defer { + if (!Files.exists(resolvedPath)) { + BlobNotFoundException(path).toMono() + + } else { + DataBufferUtils.read(FileSystemResource(metadataFile(resolvedPath)), dataBufferFactory, bufferSize) + .let(DataBufferUtils::join) + .map { dataBuffer -> + val metadataProps = Properties().also { props -> + dataBuffer.asInputStream(true).use { input -> + props.load(input) + } + } + + FileSystemBlob(this, resolvedPath, metadataProps, dataBufferFactory, bufferSize) + } + .onErrorMap(NoSuchFileException::class) { error -> + BlobNotFoundException(path, error) + } + } + } + } + + + override fun retrieve(metadata: BlobMetadata): Mono { + if (metadata is FileSystemBlob && metadata.blobStore == this) { + return Mono.just(metadata) + } else { + throw IllegalArgumentException("This BlobMetadata was created by a different BlobStore") + } + } + + + override fun store(path: Path, input: BlobInput, failIfExists: Boolean): Mono { + require(input.contentType.isConcrete) { "Blob content type must not contain wildcards" } + + val resolvedPath = basePath.resolve(path) + + return Mono.fromCallable { + + if (failIfExists && Files.exists(resolvedPath)) { + throw BlobAlreadyExistsException(path) + } + + Files.createDirectories(resolvedPath.parent) + + Files.createTempFile(tempDir, "blob", null) + + }.subscribeOn(Schedulers.parallel()) + .flatMap { tempFile -> + + val metadataTempFile = metadataFile(tempFile) + + writeBlobData(input, tempFile) + .flatMap { digest -> writeMetadata(input, digest, metadataTempFile) } + .then(moveFile(tempFile, resolvedPath, !failIfExists)) + .then(moveFile(metadataTempFile, metadataFile(resolvedPath), !failIfExists)) + .doFinally { + Files.deleteIfExists(tempFile) + Files.deleteIfExists(metadataTempFile) + } + } + } + + + override fun delete(path: Path): Mono { + val resolvedPath = basePath.resolve(path) + return Mono.fromRunnable { + Files.delete(resolvedPath) + Files.delete(metadataFile(resolvedPath)) + + }.onErrorMap(NoSuchFileException::class) { error -> + BlobNotFoundException(path, error) + } + } + + + private fun writeBlobData(blobInput: BlobInput, targetFile: Path): Mono = + Mono.fromCallable { MessageDigest.getInstance(digestAlgorithm) } + .flatMap { digester -> + blobInput.data.writeTo(targetFile) + .doOnNext { buffer -> + digester.update(buffer.asByteBuffer()) + } + .doOnNext(DataBufferUtils.releaseConsumer()) + .then(Mono.fromCallable { + val digest = digester.digest() + Base64.getEncoder().encodeToString(digest) + }) + } + + + private fun writeMetadata(blobInput: BlobInput, digest: String, targetFile: Path): Mono { + val metadataProps = Properties().apply { + setProperty("content-type", blobInput.contentType.toString()) + setProperty("etag", digest) + setProperty("last-modified", clock.instant().toString()) + } + + val metadataBytes = ByteArrayOutputStream().use { metadataOut -> + metadataProps.store(metadataOut, null) + metadataOut.toByteArray() + } + + return dataBufferFactory.wrap(metadataBytes).toMono() + .writeTo(targetFile) + .doOnNext(DataBufferUtils.releaseConsumer()) + .then() + } + + + private fun Publisher.writeTo(file: Path) = + Flux.using( + { AsynchronousFileChannel.open(file, WRITE, CREATE, TRUNCATE_EXISTING) }, + { channel -> + DataBufferUtils.write(this, channel) + }, + AsynchronousFileChannel::close + ) + + + private fun moveFile(sourcePath: Path, targetPath: Path, replaceExisting: Boolean): Mono { + return if (replaceExisting) { + Mono.fromRunnable { + Files.copy(sourcePath, targetPath, StandardCopyOption.REPLACE_EXISTING) + } + } else { + Mono.fromRunnable { + Files.copy(sourcePath, targetPath) + } + } + } + + + private fun metadataFile(path: Path) = + path.resolveSibling(path.fileName.toString() + ".metadata") +} diff --git a/spring-blobstore/src/main/resources/META-INF/services/org.unbrokendome.spring.blobstore.autoconfigure.BlobStoreConfigurationFactory b/spring-blobstore/src/main/resources/META-INF/services/org.unbrokendome.spring.blobstore.autoconfigure.BlobStoreConfigurationFactory new file mode 100644 index 0000000..e69de29 diff --git a/spring-blobstore/src/main/resources/META-INF/spring-configuration-metadata.json b/spring-blobstore/src/main/resources/META-INF/spring-configuration-metadata.json new file mode 100644 index 0000000..e7b43e8 --- /dev/null +++ b/spring-blobstore/src/main/resources/META-INF/spring-configuration-metadata.json @@ -0,0 +1,12 @@ +{ + "groups": [ + ], + "properties": [ + { + "name": "blobstore", + "type": "java.util.Map" + } + ], + "hints": [ + ] +} diff --git a/spring-blobstore/src/main/resources/META-INF/spring.factories b/spring-blobstore/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..266268b --- /dev/null +++ b/spring-blobstore/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ + org.unbrokendome.spring.blobstore.autoconfigure.BlobStoreAutoConfiguration diff --git a/spring-blobstore/src/test/kotlin/org/unbrokendome/spring/blobstore/filesystem/FileSystemBlobStoreTest.kt b/spring-blobstore/src/test/kotlin/org/unbrokendome/spring/blobstore/filesystem/FileSystemBlobStoreTest.kt new file mode 100644 index 0000000..ff1a85c --- /dev/null +++ b/spring-blobstore/src/test/kotlin/org/unbrokendome/spring/blobstore/filesystem/FileSystemBlobStoreTest.kt @@ -0,0 +1,157 @@ +package org.unbrokendome.spring.blobstore.filesystem + +import assertk.all +import assertk.assertThat +import assertk.assertions.hasSameContentAs +import assertk.assertions.hasText +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isRegularFile +import assertk.assertions.prop +import assertk.assertions.support.expected +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.core.io.buffer.DataBufferFactory +import org.springframework.core.io.buffer.DataBufferUtils +import org.springframework.core.io.buffer.DefaultDataBufferFactory +import org.springframework.util.MimeType +import org.unbrokendome.spring.blobstore.Blob +import org.unbrokendome.spring.blobstore.BlobInput +import org.unbrokendome.spring.blobstore.BlobMetadata +import org.unbrokendome.spring.blobstore.BlobStore +import reactor.core.publisher.* +import reactor.test.StepVerifier +import java.io.ByteArrayInputStream +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.OptionalLong + + +class FileSystemBlobStoreTest { + + private val dataBufferFactory: DataBufferFactory = DefaultDataBufferFactory() + private val clock: Clock = Clock.fixed(Instant.parse("2019-03-19T15:36:04.295Z"), ZoneOffset.UTC) + private lateinit var tempDir: Path + private lateinit var blobStore: BlobStore + + private val testDataString = "Lorem ipsum dolor sit amet" + private val mediaTypeTextPlain = MimeType.valueOf("text/plain;charset=UTF-8") + + @BeforeEach + fun setup() { + tempDir = Files.createTempDirectory("blobstore") + blobStore = DefaultFileSystemBlobStore( + basePath = tempDir, + dataBufferFactory = DefaultDataBufferFactory(), + bufferSize = 512, + clock = clock + ) + } + + + @AfterEach + fun tearDown() { + tempDir.toFile().deleteRecursively() + } + + + private fun testDataBuffer() = dataBufferFactory.wrap(testDataString.toByteArray()) + + + private fun blobInput(): BlobInput = + BlobInput( + data = Mono.just(testDataBuffer()), + size = OptionalLong.of(testDataString.length.toLong()), + contentType = mediaTypeTextPlain + ) + + + @Test + fun `store operation should create a blob file`() { + + val path = Paths.get("foo/bar") + + StepVerifier.create(blobStore.store(path, blobInput())) + .verifyComplete() + + assertThat(tempDir.resolve(path)).all { + isRegularFile() + transform { it.toFile() }.hasText(testDataString) + } + } + + + @Test + fun `should retrieve metadata of stored blob`() { + + val path = Paths.get("foo/bar") + + val metadataResult = blobStore.store(path, blobInput()) + .then(blobStore.getMetadata(path)) + + StepVerifier.create(metadataResult) + .assertNext { metadata -> + assertThat(metadata).all { + prop(BlobMetadata::contentType).isEqualTo(mediaTypeTextPlain) + prop(BlobMetadata::size).isEqualTo(testDataString.length.toLong()) + prop(BlobMetadata::etag).isNotNull() + prop(BlobMetadata::lastModified).isEqualTo(clock.instant()) + } + } + .verifyComplete() + } + + + @Test + fun `should retrieve contents of stored blob`() { + + val path = Paths.get("foo/bar") + + val blobResult = blobStore.store(path, blobInput()) + .then(blobStore.getMetadata(path) + .flatMap { metadata -> blobStore.retrieve(metadata) }) + + StepVerifier.create(blobResult) + .assertNext { blob -> + assertThat(blob).all { + prop(Blob::contentType).isEqualTo(mediaTypeTextPlain) + prop(Blob::size).isEqualTo(testDataString.length.toLong()) + prop(Blob::etag).isNotNull() + prop(Blob::lastModified).isEqualTo(clock.instant()) + } + + StepVerifier.create(DataBufferUtils.join(blob.data)) + .assertNext { buffer -> + assertThat(buffer.asInputStream(true)) + .hasSameContentAs(ByteArrayInputStream(testDataString.toByteArray())) + } + .verifyComplete() + } + .verifyComplete() + } + + + @Test + fun `should delete stored blob`() { + + val path = Paths.get("foo/bar") + + val deleteResult = blobStore.store(path, blobInput()) + .then(blobStore.delete(path)) + + StepVerifier.create(deleteResult) + .verifyComplete() + + assertThat(tempDir.resolve(path)).all { + transform { it.toFile() }.given { + if (!it.exists()) return@given + expected("to not exist") + } + } + } +}