From 8c7d26adbe25392931e9f0f22ed004971360955b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?O=C4=9Fuzhan=20Soykan?= Date: Mon, 22 Apr 2024 12:33:35 +0200 Subject: [PATCH] Replace Java HttpClient with Ktor Http Client and add support for multi-part form data post (#404) --- detekt.yml | 672 ++++++++++++++++++ .../infrastructure/api/ProductController.kt | 16 + .../stove/spring/example/e2e/ExampleTest.kt | 24 +- gradle/libs.versions.toml | 10 +- lib/stove-testing-e2e-http/build.gradle.kts | 5 + .../stove/testing/e2e/http/HttpSystem.kt | 301 ++++---- .../testing/e2e/http/StoveMultiPartContent.kt | 28 + .../stove/testing/e2e/http/HttpSystemTests.kt | 58 ++ .../build.gradle.kts | 2 +- .../testing/e2e/http/StoveHttpResponse.kt | 2 +- 10 files changed, 959 insertions(+), 159 deletions(-) create mode 100644 detekt.yml create mode 100644 lib/stove-testing-e2e-http/src/main/kotlin/com/trendyol/stove/testing/e2e/http/StoveMultiPartContent.kt diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 00000000..d7c7e79c --- /dev/null +++ b/detekt.yml @@ -0,0 +1,672 @@ +build: + maxIssues: 0 + excludeCorrectable: false + +config: + validation: true + warningsAsErrors: true + excludes: '' + +processors: + active: true + exclude: + - 'DetektProgressListener' + +console-reports: + active: true + exclude: + - 'ProjectStatisticsReport' + - 'ComplexityReport' + - 'NotificationReport' + - 'FindingsReport' + - 'FileBasedFindingsReport' + +output-reports: + active: false + +comments: + active: true + AbsentOrWrongFileLicense: + active: false + licenseTemplateFile: 'license.template' + licenseTemplateIsRegex: false + CommentOverPrivateFunction: + active: false + CommentOverPrivateProperty: + active: false + DeprecatedBlockTag: + active: false + EndOfSentenceFormat: + active: false + endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' + KDocReferencesNonPublicProperty: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + OutdatedDocumentation: + active: false + matchTypeParameters: true + matchDeclarationsOrder: true + allowParamOnConstructorProperties: false + UndocumentedPublicClass: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + searchInNestedClass: true + searchInInnerClass: true + searchInInnerObject: true + searchInInnerInterface: true + UndocumentedPublicFunction: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + UndocumentedPublicProperty: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + +complexity: + active: true + ComplexCondition: + active: true + threshold: 4 + ComplexInterface: + active: false + threshold: 10 + includeStaticDeclarations: false + includePrivateDeclarations: false + CyclomaticComplexMethod: + active: true + threshold: 15 + ignoreSingleWhenExpression: false + ignoreSimpleWhenEntries: false + ignoreNestingFunctions: false + nestingFunctions: + - 'also' + - 'apply' + - 'forEach' + - 'isNotNull' + - 'ifNull' + - 'let' + - 'run' + - 'use' + - 'with' + LabeledExpression: + active: false + ignoredLabels: [ ] + LargeClass: + active: true + threshold: 600 + LongMethod: + active: true + threshold: 60 + LongParameterList: + active: true + functionThreshold: 20 + constructorThreshold: 20 + ignoreDefaultParameters: false + ignoreDataClasses: true + ignoreAnnotatedParameter: [ ] + MethodOverloading: + active: false + threshold: 6 + NamedArguments: + active: false + threshold: 3 + ignoreArgumentsMatchingNames: false + NestedBlockDepth: + active: true + threshold: 4 + NestedScopeFunctions: + active: false + threshold: 1 + functions: + - 'kotlin.apply' + - 'kotlin.run' + - 'kotlin.with' + - 'kotlin.let' + - 'kotlin.also' + ReplaceSafeCallChainWithRun: + active: false + StringLiteralDuplication: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + threshold: 3 + ignoreAnnotation: true + excludeStringsWithLessThan5Characters: true + ignoreStringsRegex: '$^' + TooManyFunctions: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + thresholdInFiles: 20 + thresholdInClasses: 20 + thresholdInInterfaces: 11 + thresholdInObjects: 11 + thresholdInEnums: 11 + ignoreDeprecated: false + ignorePrivate: false + ignoreOverridden: false + +coroutines: + active: true + GlobalCoroutineUsage: + active: false + InjectDispatcher: + active: false + dispatcherNames: + - 'IO' + - 'Default' + - 'Unconfined' + RedundantSuspendModifier: + active: false + SleepInsteadOfDelay: + active: true + SuspendFunWithCoroutineScopeReceiver: + active: false + SuspendFunWithFlowReturnType: + active: true + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '_|(ignore|expected).*' + EmptyClassBlock: + active: true + EmptyDefaultConstructor: + active: true + EmptyDoWhileBlock: + active: true + EmptyElseBlock: + active: true + EmptyFinallyBlock: + active: true + EmptyForBlock: + active: true + EmptyFunctionBlock: + active: true + ignoreOverridden: false + EmptyIfBlock: + active: true + EmptyInitBlock: + active: true + EmptyKtFile: + active: true + EmptySecondaryConstructor: + active: true + EmptyTryBlock: + active: true + EmptyWhenBlock: + active: true + EmptyWhileBlock: + active: true + +exceptions: + active: true + ExceptionRaisedInUnexpectedLocation: + active: true + methodNames: + - 'equals' + - 'finalize' + - 'hashCode' + - 'toString' + InstanceOfCheckForException: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + NotImplementedDeclaration: + active: false + ObjectExtendsThrowable: + active: false + PrintStackTrace: + active: true + RethrowCaughtException: + active: true + ReturnFromFinally: + active: true + ignoreLabeled: false + SwallowedException: + active: true + ignoredExceptionTypes: + - 'InterruptedException' + - 'MalformedURLException' + - 'NumberFormatException' + - 'ParseException' + allowedExceptionNameRegex: '_|(ignore|expected).*' + ThrowingExceptionFromFinally: + active: true + ThrowingExceptionInMain: + active: false + ThrowingExceptionsWithoutMessageOrCause: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + exceptions: + - 'ArrayIndexOutOfBoundsException' + - 'Exception' + - 'IllegalArgumentException' + - 'IllegalMonitorStateException' + - 'IllegalStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + ThrowingNewInstanceOfSameException: + active: true + TooGenericExceptionCaught: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + exceptionNames: + - 'ArrayIndexOutOfBoundsException' + - 'Error' + - 'Exception' + - 'IllegalMonitorStateException' + - 'IndexOutOfBoundsException' + - 'NullPointerException' + - 'RuntimeException' + - 'Throwable' + allowedExceptionNameRegex: '_|(ignore|expected).*' + TooGenericExceptionThrown: + active: true + exceptionNames: + - 'Error' + - 'Exception' + - 'RuntimeException' + - 'Throwable' + +naming: + active: true + BooleanPropertyNaming: + active: false + allowedPattern: '^(is|has|are)' + ClassNaming: + active: true + classPattern: '[A-Z][a-zA-Z0-9]*' + EnumNaming: + active: true + enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' + ForbiddenClassName: + active: false + forbiddenName: [ ] + FunctionMaxLength: + active: false + maximumFunctionNameLength: 30 + FunctionMinLength: + active: false + minimumFunctionNameLength: 3 + InvalidPackageDeclaration: + active: true + rootPackage: '' + requireRootInDeclaration: false + LambdaParameterNaming: + active: false + parameterPattern: '[a-z][A-Za-z0-9]*|_' + MatchingDeclarationName: + active: false + mustBeFirst: true + MemberNameEqualsClassName: + active: true + ignoreOverridden: true + NoNameShadowing: + active: true + NonBooleanPropertyPrefixedWithIs: + active: false + ObjectPropertyNaming: + active: true + constantPattern: '[A-Za-z][_A-Za-z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' + PackageNaming: + active: true + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' + TopLevelPropertyNaming: + active: true + constantPattern: '[A-Z][_A-Z0-9]*' + propertyPattern: '[A-Za-z][_A-Za-z0-9]*' + privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*' + VariableMaxLength: + active: false + maximumVariableNameLength: 64 + VariableMinLength: + active: false + minimumVariableNameLength: 1 + ConstructorParameterNaming: + active: false + parameterPattern: '[a-z][A-Za-z0-9]*|_' + + +performance: + active: true + ArrayPrimitive: + active: true + CouldBeSequence: + active: false + threshold: 3 + ForEachOnRange: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + SpreadOperator: + active: false + excludes: [ + '**/test/**', + '**/androidTest/**', + '**/commonTest/**', + '**/jvmTest/**', + '**/jsTest/**', + '**/iosTest/**', + '**/otel/**', + ] + UnnecessaryTemporaryInstantiation: + active: true + +potential-bugs: + active: true + AvoidReferentialEquality: + active: true + forbiddenTypePatterns: + - 'kotlin.String' + CastToNullableType: + active: false + Deprecation: + active: false + DontDowncastCollectionTypes: + active: false + DoubleMutabilityForCollection: + active: true + mutableTypes: + - 'kotlin.collections.MutableList' + - 'kotlin.collections.MutableMap' + - 'kotlin.collections.MutableSet' + - 'java.util.ArrayList' + - 'java.util.LinkedHashSet' + - 'java.util.HashSet' + - 'java.util.LinkedHashMap' + - 'java.util.HashMap' + ElseCaseInsteadOfExhaustiveWhen: + active: false + EqualsAlwaysReturnsTrueOrFalse: + active: true + EqualsWithHashCodeExist: + active: true + ExitOutsideMain: + active: false + ExplicitGarbageCollectionCall: + active: true + HasPlatformType: + active: true + IgnoredReturnValue: + active: true + restrictToConfig: true + returnValueAnnotations: + - '*.CheckResult' + - '*.CheckReturnValue' + ignoreReturnValueAnnotations: + - '*.CanIgnoreReturnValue' + ignoreFunctionCall: [ ] + ImplicitDefaultLocale: + active: true + ImplicitUnitReturnType: + active: false + allowExplicitReturnType: true + InvalidRange: + active: true + IteratorHasNextCallsNextMethod: + active: true + IteratorNotThrowingNoSuchElementException: + active: true + LateinitUsage: + active: false + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + ignoreOnClassesPattern: '' + MapGetWithNotNullAssertionOperator: + active: true + MissingPackageDeclaration: + active: false + excludes: [ '**/*.kts' ] + + NullCheckOnMutableProperty: + active: false + NullableToStringCall: + active: false + UnconditionalJumpStatementInLoop: + active: false + UnnecessaryNotNullOperator: + active: true + UnnecessarySafeCall: + active: true + UnreachableCatchBlock: + active: true + UnreachableCode: + active: true + UnsafeCallOnNullableType: + active: true + excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + UnsafeCast: + active: true + UnusedUnaryOperator: + active: true + UselessPostfixExpression: + active: true + WrongEqualsTypeParameter: + active: true + +style: + active: true + CanBeNonNullable: + active: false + CascadingCallWrapping: + active: false + includeElvis: true + ClassOrdering: + active: false + CollapsibleIfStatements: + active: false + DataClassContainsFunctions: + active: false + conversionFunctionPrefix: + - 'to' + DataClassShouldBeImmutable: + active: false + DestructuringDeclarationWithTooManyEntries: + active: true + maxDestructuringEntries: 6 + EqualsNullCall: + active: true + EqualsOnSignatureLine: + active: false + ExplicitCollectionElementAccessMethod: + active: false + ExplicitItLambdaParameter: + active: true + ExpressionBodySyntax: + active: false + includeLineWrapping: false + ForbiddenComment: + active: false + comments: + - 'FIXME:' + - 'STOPSHIP:' + - 'TODO:' + ForbiddenImport: + active: false + imports: [ ] + forbiddenPatterns: '' + ForbiddenMethodCall: + active: false + methods: + - 'kotlin.io.print' + - 'kotlin.io.println' + ForbiddenSuppress: + active: false + rules: [ ] + ForbiddenVoid: + active: true + ignoreOverridden: false + ignoreUsageInGenerics: false + FunctionOnlyReturningConstant: + active: true + ignoreOverridableFunction: true + ignoreActualFunction: true + excludedFunctions: + - '' + LoopWithTooManyJumpStatements: + active: true + maxJumpCount: 1 + MagicNumber: + active: true + excludes: [ + '**/test/**', + '**/test-e2e/**', + '**/androidTest/**', + '**/commonTest/**', + '**/jvmTest/**', + '**/jsTest/**', + '**/iosTest/**', + '**/domain/**', + '**/core/**', + '**/*.kts' ] + ignoreNumbers: + - '-1' + - '0' + - '1' + - '2' + ignoreHashCodeFunction: true + ignorePropertyDeclaration: false + ignoreLocalVariableDeclaration: false + ignoreConstantDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreAnnotation: false + ignoreNamedArgument: true + ignoreEnums: false + ignoreRanges: false + ignoreExtensionFunctions: true + BracesOnIfStatements: + active: false + MandatoryBracesLoops: + active: false + MaxChainedCallsOnSameLine: + active: false + maxChainedCalls: 5 + MaxLineLength: + active: true + maxLineLength: 140 + excludePackageStatements: true + excludeImportStatements: true + excludeCommentStatements: false + excludes: + - '**/test/**' + - '**/test-e2e/**' + - '**/test-integration/**' + MayBeConst: + active: true + ModifierOrder: + active: true + MultilineLambdaItParameter: + active: false + NestedClassesVisibility: + active: true + NewLineAtEndOfFile: + active: true + NoTabs: + active: false + NullableBooleanCheck: + active: false + ObjectLiteralToLambda: + active: true + OptionalAbstractKeyword: + active: true + OptionalUnit: + active: false + BracesOnWhenStatements: + active: false + PreferToOverPairSyntax: + active: false + ProtectedMemberInFinalClass: + active: true + RedundantExplicitType: + active: false + RedundantHigherOrderMapUsage: + active: true + RedundantVisibilityModifierRule: + active: false + ReturnCount: + active: true + max: 5 + excludedFunctions: + - 'equals' + excludeLabeled: false + excludeReturnFromLambda: true + excludeGuardClauses: false + SafeCast: + active: true + SerialVersionUIDInSerializableClass: + active: true + SpacingBetweenPackageAndImports: + active: false + ThrowsCount: + active: true + max: 2 + excludeGuardClauses: false + TrailingWhitespace: + active: false + UnderscoresInNumericLiterals: + active: false + acceptableLength: 4 + allowNonStandardGrouping: false + UnnecessaryAbstractClass: + active: true + UnnecessaryAnnotationUseSiteTarget: + active: false + UnnecessaryApply: + active: true + UnnecessaryBackticks: + active: false + UnnecessaryFilter: + active: true + UnnecessaryInheritance: + active: true + UnnecessaryInnerClass: + active: false + UnnecessaryLet: + active: false + UnnecessaryParentheses: + active: false + UntilInsteadOfRangeTo: + active: false + UnusedImports: + active: false + UnusedPrivateClass: + active: true + UnusedPrivateMember: + active: true + allowedNames: '(_|ignored|expected|serialVersionUID)' + UseAnyOrNoneInsteadOfFind: + active: true + UseArrayLiteralsInAnnotations: + active: true + UseCheckNotNull: + active: true + UseCheckOrError: + active: true + UseDataClass: + active: false + allowVars: false + UseEmptyCounterpart: + active: false + UseIfEmptyOrIfBlank: + active: false + UseIfInsteadOfWhen: + active: false + UseIsNullOrEmpty: + active: true + UseOrEmpty: + active: true + UseRequire: + active: true + UseRequireNotNull: + active: true + UselessCallOnNotNull: + active: true + UtilityClassWithPublicConstructor: + active: true + VarCouldBeVal: + active: true + ignoreLateinitVar: false + WildcardImport: + active: false + excludeImports: + - 'java.util.*' diff --git a/examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/api/ProductController.kt b/examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/api/ProductController.kt index 28e48e31..b40c6584 100644 --- a/examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/api/ProductController.kt +++ b/examples/spring-example/src/main/kotlin/stove/spring/example/infrastructure/api/ProductController.kt @@ -1,10 +1,14 @@ package stove.spring.example.infrastructure.api +import kotlinx.coroutines.reactive.* +import kotlinx.coroutines.reactor.mono +import org.springframework.http.codec.multipart.FilePart import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RestController import stove.spring.example.application.handlers.ProductCreateRequest import stove.spring.example.application.handlers.ProductCreator @@ -25,4 +29,16 @@ class ProductController(private val productCreator: ProductCreator) { ): String { return productCreator.create(productCreateRequest) } + + @PostMapping("/product/import") + suspend fun importFile( + @RequestPart(name = "name") name: String, + @RequestPart(name = "file") file: FilePart + ): String { + val content = file.content() + .flatMap { mono { it.asInputStream().readAllBytes() } } + .awaitSingle() + .let { String(it) } + return "File ${file.filename()} is imported with $name and content: $content" + } } diff --git a/examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/ExampleTest.kt b/examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/ExampleTest.kt index ac9886e7..78f01fbb 100644 --- a/examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/ExampleTest.kt +++ b/examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/ExampleTest.kt @@ -2,7 +2,7 @@ package com.stove.spring.example.e2e import arrow.core.some import com.trendyol.stove.testing.e2e.couchbase.couchbase -import com.trendyol.stove.testing.e2e.http.http +import com.trendyol.stove.testing.e2e.http.* import com.trendyol.stove.testing.e2e.kafka.kafka import com.trendyol.stove.testing.e2e.system.TestSystem import com.trendyol.stove.testing.e2e.using @@ -10,6 +10,7 @@ import com.trendyol.stove.testing.e2e.wiremock.wiremock import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain +import org.springframework.http.MediaType import stove.spring.example.application.handlers.* import stove.spring.example.application.services.SupplierPermission import stove.spring.example.infrastructure.couchbase.CouchbaseProperties @@ -165,4 +166,25 @@ class ExampleTest : FunSpec({ } } } + + test("file import should work") { + TestSystem.validate { + http { + postMultipartAndExpectResponse( + "/api/product/import", + body = listOf( + StoveMultiPartContent.Text("name", "product name"), + StoveMultiPartContent.File( + "file", + "file.txt", + "file".toByteArray(), + contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE + ) + ) + ) { actual -> + actual.body() shouldBe "File file.txt is imported with product name and content: file" + } + } + } + } }) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d80c371c..61e019d5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ r2dbc-spi = "1.0.0.RELEASE" r2dbc-postgresql = "0.8.13.RELEASE" elastic = "8.13.2" mongodb = "5.0.1" -wiremock = "3.5.3" +wiremock = "3.5.2" testcontainers = "1.19.7" r2dbc-mssql = "1.0.2.RELEASE" spotless = "6.25.0" @@ -59,6 +59,12 @@ ktor-server = { module = "io.ktor:ktor-server", version.ref = "ktor" } ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" } ktor-server-netty = { module = "io.ktor:ktor-server-netty", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-plugins-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-jackson-json = { module = "io.ktor:ktor-serialization-jackson", version.ref = "ktor" } koin-ktor = { module = "io.insert-koin:koin-ktor", version.ref = "koin" } koin-logger-slf4j = { module = "io.insert-koin:koin-logger-slf4j", version.ref = "koin" } r2dbc-spi = { module = "io.r2dbc:r2dbc-spi", version.ref = "r2dbc-spi" } @@ -66,7 +72,7 @@ r2dbc-postgresql = { module = "io.r2dbc:r2dbc-postgresql", version.ref = "r2dbc- elastic = { module = "co.elastic.clients:elasticsearch-java", version.ref = "elastic" } mongodb-reactivestreams = { module = "org.mongodb:mongodb-driver-reactivestreams", version.ref = "mongodb" } -wiremock = { module = "org.wiremock:wiremock-standalone", version.ref = "wiremock" } +wiremock-standalone = { module = "org.wiremock:wiremock-standalone", version.ref = "wiremock" } testcontainers = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers" } testcontainers-jdbc = { module = "org.testcontainers:jdbc", version.ref = "testcontainers" } testcontainers-kafka = { module = "org.testcontainers:kafka", version.ref = "testcontainers" } diff --git a/lib/stove-testing-e2e-http/build.gradle.kts b/lib/stove-testing-e2e-http/build.gradle.kts index 124dc7aa..9a4492f4 100644 --- a/lib/stove-testing-e2e-http/build.gradle.kts +++ b/lib/stove-testing-e2e-http/build.gradle.kts @@ -1,8 +1,13 @@ dependencies { api(projects.lib.stoveTestingE2e) + api(libs.ktor.client.core) + api(libs.ktor.client.okhttp) implementation(libs.kotlinx.core) implementation(libs.kotlinx.io.reactor) implementation(libs.kotlinx.reactive) implementation(libs.kotlinx.jdk8) + implementation(libs.ktor.client.plugins.logging) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.jackson.json) testImplementation(projects.lib.stoveTestingE2eWiremock) } diff --git a/lib/stove-testing-e2e-http/src/main/kotlin/com/trendyol/stove/testing/e2e/http/HttpSystem.kt b/lib/stove-testing-e2e-http/src/main/kotlin/com/trendyol/stove/testing/e2e/http/HttpSystem.kt index dabfcb89..ebb5fabf 100644 --- a/lib/stove-testing-e2e-http/src/main/kotlin/com/trendyol/stove/testing/e2e/http/HttpSystem.kt +++ b/lib/stove-testing-e2e-http/src/main/kotlin/com/trendyol/stove/testing/e2e/http/HttpSystem.kt @@ -1,22 +1,28 @@ -@file:Suppress("UNCHECKED_CAST", "MemberVisibilityCanBePrivate") +@file:Suppress("MemberVisibilityCanBePrivate") package com.trendyol.stove.testing.e2e.http import arrow.core.* -import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.ObjectMapper import com.trendyol.stove.testing.e2e.serialization.StoveObjectMapper import com.trendyol.stove.testing.e2e.system.* import com.trendyol.stove.testing.e2e.system.abstractions.* import com.trendyol.stove.testing.e2e.system.annotations.StoveDsl -import kotlinx.coroutines.future.await -import java.net.* -import java.net.http.* -import java.net.http.HttpClient.Redirect.ALWAYS -import java.net.http.HttpClient.Version.HTTP_2 -import java.net.http.HttpRequest.BodyPublishers -import java.net.http.HttpResponse.BodyHandlers -import java.time.Duration +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.okhttp.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.http.* +import io.ktor.serialization.jackson.* +import io.ktor.util.* +import org.slf4j.LoggerFactory +import java.net.http.HttpClient +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration @HttpDsl data class HttpClientSystemOptions(val objectMapper: ObjectMapper = StoveObjectMapper.Default) : SystemOptions @@ -26,10 +32,9 @@ internal fun TestSystem.withHttpClient(options: HttpClientSystemOptions = HttpCl return this } -internal fun TestSystem.http(): HttpSystem = - getOrNone().getOrElse { - throw SystemNotRegisteredException(HttpSystem::class) - } +internal fun TestSystem.http(): HttpSystem = getOrNone().getOrElse { + throw SystemNotRegisteredException(HttpSystem::class) +} @StoveDsl fun WithDsl.httpClient(configure: @StoveDsl () -> HttpClientSystemOptions = { HttpClientSystemOptions() }): TestSystem = @@ -45,8 +50,10 @@ class HttpSystem( override val testSystem: TestSystem, @PublishedApi internal val objectMapper: ObjectMapper ) : PluggedSystem { + private val logger: org.slf4j.Logger = LoggerFactory.getLogger(javaClass) + @PublishedApi - internal val httpClient: HttpClient = httpClient() + internal val ktorHttpClient: io.ktor.client.HttpClient = createHttpClient() @HttpDsl suspend fun getResponse( @@ -55,18 +62,9 @@ class HttpSystem( headers: Map = mapOf(), token: Option = None, expect: suspend (StoveHttpResponse) -> Unit - ): HttpSystem = httpClient.send(uri, headers = headers, queryParams = queryParams) { request -> - token.map { request.setHeader(Headers.AUTHORIZATION, Headers.bearer(it)) } - request - }.let { - expect( - StoveHttpResponse.Bodiless( - it.statusCode(), - it.headers().map() - ) - ) - this - } + ): HttpSystem = get(uri, headers, queryParams, token).also { + expect(StoveHttpResponse.Bodiless(it.status.value, it.headers.toMap())) + }.let { this } @HttpDsl suspend inline fun getResponse( @@ -75,18 +73,9 @@ class HttpSystem( headers: Map = mapOf(), token: Option = None, expect: (StoveHttpResponse.WithBody) -> Unit - ): HttpSystem = httpClient.send(uri, headers = headers, queryParams = queryParams) { request -> - token.map { request.setHeader(Headers.AUTHORIZATION, Headers.bearer(it)) } - request - }.let { - expect( - StoveHttpResponse.WithBody( - it.statusCode(), - it.headers().map() - ) { deserialize(it) } - ) - this - } + ): HttpSystem = get(uri, headers, queryParams, token).also { + expect(StoveHttpResponse.WithBody(it.status.value, it.headers.toMap()) { it.body() }) + }.let { this } @HttpDsl suspend inline fun get( @@ -95,13 +84,7 @@ class HttpSystem( headers: Map = mapOf(), token: Option = None, expect: (TExpected) -> Unit - ): HttpSystem = httpClient.send(uri, headers = headers, queryParams = queryParams) { request -> - token.map { request.setHeader(Headers.AUTHORIZATION, Headers.bearer(it)) } - request.GET() - }.let { - expect(deserialize(it)) - this - } + ): HttpSystem = get(uri, headers, queryParams, token).also { expect(it.body()) }.let { this } @HttpDsl suspend inline fun getMany( @@ -110,13 +93,7 @@ class HttpSystem( headers: Map = mapOf(), token: Option = None, expect: (List) -> Unit - ): HttpSystem = httpClient.send(uri, headers = headers, queryParams = queryParams) { request -> - token.map { request.setHeader(Headers.AUTHORIZATION, Headers.bearer(it)) } - request.GET() - }.let { - expect(deserialize(it)) - this - } + ): HttpSystem = get(uri, headers, queryParams, token).also { expect(it.body()) }.let { this } @HttpDsl suspend fun postAndExpectBodilessResponse( @@ -125,10 +102,11 @@ class HttpSystem( token: Option = None, headers: Map = mapOf(), expect: suspend (StoveHttpResponse) -> Unit - ): HttpSystem = doPostReq(uri, headers, token, body).let { - expect(StoveHttpResponse.Bodiless(it.statusCode(), it.headers().map())) - this - } + ): HttpSystem = ktorHttpClient.post(relative(uri)) { + headers.forEach { (key, value) -> header(key, value) } + token.map { header(HeaderConstants.AUTHORIZATION, HeaderConstants.bearer(it)) } + body.map { setBody(it) } + }.also { expect(StoveHttpResponse.Bodiless(it.status.value, it.headers.toMap())) }.let { this } @HttpDsl suspend inline fun postAndExpectJson( @@ -137,10 +115,11 @@ class HttpSystem( headers: Map = mapOf(), token: Option = None, expect: (actual: TExpected) -> Unit - ): HttpSystem = doPostReq(uri, headers, token, body).let { - expect(deserialize(it)) - this - } + ): HttpSystem = ktorHttpClient.post(relative(uri)) { + headers.forEach { (key, value) -> header(key, value) } + token.map { header(HeaderConstants.AUTHORIZATION, HeaderConstants.bearer(it)) } + body.map { setBody(it) } + }.also { expect(it.body()) }.let { this } /** * Posts the given [body] to the given [uri] and expects the response to have a body. @@ -152,10 +131,11 @@ class HttpSystem( headers: Map = mapOf(), token: Option = None, expect: (actual: StoveHttpResponse.WithBody) -> Unit - ): HttpSystem = doPostReq(uri, headers, token, body).let { - expect(StoveHttpResponse.WithBody(it.statusCode(), it.headers().map()) { deserialize(it) }) - this - } + ): HttpSystem = ktorHttpClient.post(relative(uri)) { + headers.forEach { (key, value) -> header(key, value) } + token.map { header(HeaderConstants.AUTHORIZATION, HeaderConstants.bearer(it)) } + body.map { setBody(it) } + }.also { expect(StoveHttpResponse.WithBody(it.status.value, it.headers.toMap()) { it.body() }) }.let { this } @HttpDsl suspend fun putAndExpectBodilessResponse( @@ -164,10 +144,12 @@ class HttpSystem( token: Option = None, headers: Map = mapOf(), expect: suspend (StoveHttpResponse) -> Unit - ): HttpSystem = doPUTReq(uri, headers, token, body).let { - expect(StoveHttpResponse.Bodiless(it.statusCode(), it.headers().map())) - this - } + ): HttpSystem = ktorHttpClient.put(relative(uri)) { + headers.forEach { (key, value) -> header(key, value) } + token.map { header(HeaderConstants.AUTHORIZATION, HeaderConstants.bearer(it)) } + body.map { setBody(it) } + }.also { expect(StoveHttpResponse.Bodiless(it.status.value, it.headers.toMap())) } + .let { this } @HttpDsl suspend inline fun putAndExpectJson( @@ -176,10 +158,12 @@ class HttpSystem( headers: Map = mapOf(), token: Option = None, expect: (actual: TExpected) -> Unit - ): HttpSystem = doPUTReq(uri, headers, token, body).let { - expect(deserialize(it)) - this - } + ): HttpSystem = ktorHttpClient.put(relative(uri)) { + headers.forEach { (key, value) -> header(key, value) } + token.map { header(HeaderConstants.AUTHORIZATION, HeaderConstants.bearer(it)) } + body.map { setBody(it) } + }.also { expect(it.body()) } + .let { this } @HttpDsl suspend inline fun putAndExpectBody( @@ -188,10 +172,12 @@ class HttpSystem( headers: Map = mapOf(), token: Option = None, expect: (actual: StoveHttpResponse.WithBody) -> Unit - ): HttpSystem = doPUTReq(uri, headers, token, body).let { - expect(StoveHttpResponse.WithBody(it.statusCode(), it.headers().map()) { deserialize(it) }) - this - } + ): HttpSystem = ktorHttpClient.put(relative(uri)) { + headers.forEach { (key, value) -> header(key, value) } + token.map { header(HeaderConstants.AUTHORIZATION, HeaderConstants.bearer(it)) } + body.map { setBody(it) } + }.also { expect(StoveHttpResponse.WithBody(it.status.value, it.headers.toMap()) { it.body() }) } + .let { this } @HttpDsl suspend fun deleteAndExpectBodilessResponse( @@ -199,102 +185,109 @@ class HttpSystem( token: Option = None, headers: Map = mapOf(), expect: suspend (StoveHttpResponse) -> Unit - ): HttpSystem = httpClient.send(uri, headers = headers) { request -> - token.map { request.setHeader(Headers.AUTHORIZATION, Headers.bearer(it)) } - request.DELETE() - }.let { - expect(StoveHttpResponse.Bodiless(it.statusCode(), it.headers().map())) - this - } + ): HttpSystem = ktorHttpClient.delete(relative(uri)) { + headers.forEach { (key, value) -> header(key, value) } + token.map { header(HeaderConstants.AUTHORIZATION, HeaderConstants.bearer(it)) } + }.also { expect(StoveHttpResponse.Bodiless(it.status.value, it.headers.toMap())) } + .let { this } @HttpDsl - override fun then(): TestSystem = testSystem - - @PublishedApi - internal suspend fun HttpClient.send( + suspend inline fun postMultipartAndExpectResponse( uri: String, + body: List, headers: Map = mapOf(), - queryParams: Map = mapOf(), - configureRequest: (request: HttpRequest.Builder) -> HttpRequest.Builder - ): HttpResponse { - val requestBuilder = HttpRequest.newBuilder() - .uri(relative(uri, queryParams)) - .addHeaders(headers) - return sendAsync(configureRequest(requestBuilder).build(), BodyHandlers.ofByteArray()).await() - } + token: Option = None, + expect: (StoveHttpResponse.WithBody) -> Unit + ): HttpSystem = ktorHttpClient.submitForm { + url(relative(uri)) + headers.forEach { (key, value) -> header(key, value) } + token.map { header(HeaderConstants.AUTHORIZATION, HeaderConstants.bearer(it)) } + setBody(MultiPartFormDataContent(toFormData(body))) + }.also { expect(StoveHttpResponse.WithBody(it.status.value, it.headers.toMap()) { it.body() }) }.let { this } + + @HttpDsl + override fun then(): TestSystem = testSystem @PublishedApi - internal suspend fun doPUTReq( + internal suspend fun get( uri: String, headers: Map, - token: Option, - body: Option - ): HttpResponse = httpClient.send(uri, headers = headers) { request -> - token.map { request.setHeader(Headers.AUTHORIZATION, Headers.bearer(it)) } - body.fold( - ifEmpty = { request.PUT(BodyPublishers.noBody()) }, - ifSome = { request.PUT(BodyPublishers.ofString(objectMapper.writeValueAsString(it))) } - ) + queryParams: Map, + token: Option + ) = ktorHttpClient.get(relative(uri)) { + headers.forEach { (key, value) -> header(key, value) } + queryParams.forEach { (key, value) -> parameter(key, value) } + token.map { header(HeaderConstants.AUTHORIZATION, HeaderConstants.bearer(it)) } } @PublishedApi - internal suspend fun doPostReq( - uri: String, - headers: Map, - token: Option, - body: Option - ): HttpResponse = httpClient.send(uri, headers = headers) { request -> - token.map { request.setHeader(Headers.AUTHORIZATION, Headers.bearer(it)) } - body.fold( - ifEmpty = { request.POST(BodyPublishers.noBody()) }, - ifSome = { request.POST(BodyPublishers.ofString(objectMapper.writeValueAsString(it))) } - ) - } + internal fun relative(uri: String): Url = URLBuilder(testSystem.baseUrl).apply { path(uri) }.build() - private fun HttpRequest.Builder.addHeaders( - headers: Map - ): HttpRequest.Builder = headers - .toMutableMap() - .apply { this[Headers.CONTENT_TYPE] = MediaType.APPLICATION_JSON } - .forEach { (key, value) -> setHeader(key, value) } - .let { this } + @PublishedApi + internal fun toFormData( + body: List + ) = formData { + body.forEach { + when (it) { + is StoveMultiPartContent.Text -> append(it.param, it.value) + is StoveMultiPartContent.Binary -> append( + it.param, + it.content, + Headers.build { + append(HttpHeaders.ContentType, ContentType.Application.OctetStream) + } + ) + is StoveMultiPartContent.File -> append( + it.param, + it.content, + Headers.build { + append(HttpHeaders.ContentType, ContentType.parse(it.contentType)) + append(HttpHeaders.ContentDisposition, "filename=${it.fileName}") + } + ) + } + } + } - private fun relative( - uri: String, - queryParams: Map = mapOf() - ): URI = URI.create(testSystem.baseUrl) - .resolve(uri + queryParams.toParamsString()) + private fun createHttpClient(): io.ktor.client.HttpClient = HttpClient(OkHttp) { + engine { + config { + followRedirects(true) + followSslRedirects(true) + connectTimeout(5.seconds.toJavaDuration()) + readTimeout(5.seconds.toJavaDuration()) + callTimeout(5.seconds.toJavaDuration()) + } + } + install(Logging) { + logger = object : Logger { + override fun log(message: String) { + this@HttpSystem.logger.info(message) + } + } + } - private fun Map.toParamsString(): String = when { - this.any() -> "?${this.map { "${it.key}=${URLEncoder.encode(it.value, Charsets.UTF_8)}" }.joinToString("&")}" - else -> "" - } + install(ContentNegotiation) { + jackson { + setTypeFactory(objectMapper.typeFactory) + setConfig(objectMapper.deserializationConfig) + setConfig(objectMapper.serializationConfig) + setSerializerFactory(objectMapper.serializerFactory) + setNodeFactory(objectMapper.nodeFactory) + } + } - private fun httpClient(): HttpClient { - val builder = HttpClient.newBuilder() - builder.connectTimeout(Duration.ofSeconds(5)) - builder.followRedirects(ALWAYS) - builder.version(HTTP_2) - return builder.build() + defaultRequest { + header(HttpHeaders.ContentType, ContentType.Application.Json) + } } - @PublishedApi - internal inline fun deserialize( - it: HttpResponse - ): TExpected = when { - TExpected::class.java.isAssignableFrom(String::class.java) -> String(it.body()) as TExpected - else -> objectMapper.readValue(it.body(), object : TypeReference() {}) + override fun close() { + ktorHttpClient.close() } - override fun close() {} - companion object { - object MediaType { - const val APPLICATION_JSON = "application/json" - } - - object Headers { - const val CONTENT_TYPE = "Content-Type" + object HeaderConstants { const val AUTHORIZATION = "Authorization" fun bearer(token: String) = "Bearer $token" @@ -305,6 +298,6 @@ class HttpSystem( */ @Suppress("unused") @HttpDsl - fun HttpSystem.client(): HttpClient = this.httpClient + fun HttpSystem.client(): io.ktor.client.HttpClient = this.ktorHttpClient } } diff --git a/lib/stove-testing-e2e-http/src/main/kotlin/com/trendyol/stove/testing/e2e/http/StoveMultiPartContent.kt b/lib/stove-testing-e2e-http/src/main/kotlin/com/trendyol/stove/testing/e2e/http/StoveMultiPartContent.kt new file mode 100644 index 00000000..f5e371d0 --- /dev/null +++ b/lib/stove-testing-e2e-http/src/main/kotlin/com/trendyol/stove/testing/e2e/http/StoveMultiPartContent.kt @@ -0,0 +1,28 @@ +@file:Suppress("ArrayInDataClass") + +package com.trendyol.stove.testing.e2e.http + +/** + * Represents a multi-part content for a HTTP request. + */ +sealed class StoveMultiPartContent { + /** + * Represents a text content for a multi-part request. + */ + data class Text(val param: String, val value: String) : StoveMultiPartContent() + + /** + * Represents a file content for a multi-part request. + */ + data class File( + val param: String, + val fileName: String, + val content: ByteArray, + val contentType: String + ) : StoveMultiPartContent() + + /** + * Represents a binary content for a multi-part request. + */ + data class Binary(val param: String, val content: ByteArray) : StoveMultiPartContent() +} diff --git a/lib/stove-testing-e2e-http/src/test/kotlin/com/trendyol/stove/testing/e2e/http/HttpSystemTests.kt b/lib/stove-testing-e2e-http/src/test/kotlin/com/trendyol/stove/testing/e2e/http/HttpSystemTests.kt index 9cc3c56c..45f3ab56 100644 --- a/lib/stove-testing-e2e-http/src/test/kotlin/com/trendyol/stove/testing/e2e/http/HttpSystemTests.kt +++ b/lib/stove-testing-e2e-http/src/test/kotlin/com/trendyol/stove/testing/e2e/http/HttpSystemTests.kt @@ -1,6 +1,8 @@ package com.trendyol.stove.testing.e2e.http import arrow.core.* +import com.github.tomakehurst.wiremock.client.WireMock.* +import com.github.tomakehurst.wiremock.matching.MultipartValuePattern import com.trendyol.stove.testing.e2e.system.TestSystem import com.trendyol.stove.testing.e2e.system.abstractions.ApplicationUnderTest import com.trendyol.stove.testing.e2e.wiremock.* @@ -177,6 +179,62 @@ class HttpSystemTests : FunSpec({ } } } + + test("get with query params should work") { + val expectedGetDtoName = UUID.randomUUID().toString() + TestSystem.validate { + wiremock { + mockGet("/get?param=1", 200, responseBody = TestDto(expectedGetDtoName).some()) + } + + http { + get("/get", queryParams = mapOf("param" to "1")) { actual -> + actual.name shouldBe expectedGetDtoName + } + } + } + } + + test("multipart post should work") { + val expectedPostDtoName = UUID.randomUUID().toString() + TestSystem.validate { + wiremock { + mockPostConfigure("/post-with-multipart") { req, _ -> + req.withMultipartRequestBody( + aMultipart() + .matchingType(MultipartValuePattern.MatchingType.ANY) + .withHeader("Content-Disposition", equalTo("form-data; name=name")) + .withBody(equalTo(expectedPostDtoName)) + ) + req.withMultipartRequestBody( + aMultipart() + .matchingType(MultipartValuePattern.MatchingType.ANY) + .withHeader("Content-Disposition", equalTo("form-data; name=file; filename=file.png")) + .withBody(equalTo("file")) + ) + req.willReturn(aResponse().withStatus(200).withBody("hoi!")) + } + } + + http { + postMultipartAndExpectResponse( + "/post-with-multipart", + body = listOf( + StoveMultiPartContent.Text("name", expectedPostDtoName), + StoveMultiPartContent.File( + param = "file", + fileName = "file.png", + content = "file".toByteArray(), + contentType = "application/octet-stream" + ) + ) + ) { actual -> + actual.body() shouldBe "hoi!" + actual.status shouldBe 200 + } + } + } + } }) data class TestDto( diff --git a/lib/stove-testing-e2e-wiremock/build.gradle.kts b/lib/stove-testing-e2e-wiremock/build.gradle.kts index 65c7e00e..ba5a8bf8 100644 --- a/lib/stove-testing-e2e-wiremock/build.gradle.kts +++ b/lib/stove-testing-e2e-wiremock/build.gradle.kts @@ -1,4 +1,4 @@ dependencies { api(projects.lib.stoveTestingE2e) - api(libs.wiremock) + api(libs.wiremock.standalone) } diff --git a/lib/stove-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/http/StoveHttpResponse.kt b/lib/stove-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/http/StoveHttpResponse.kt index 20d8002e..498622ec 100644 --- a/lib/stove-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/http/StoveHttpResponse.kt +++ b/lib/stove-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/http/StoveHttpResponse.kt @@ -12,6 +12,6 @@ sealed class StoveHttpResponse( data class WithBody( override val status: Int, override val headers: Map, - val body: () -> T + val body: suspend () -> T ) : StoveHttpResponse(status, headers) }