diff --git a/end2end-tests/build.gradle.kts b/end2end-tests/build.gradle.kts index bfefe5d4..1191de36 100644 --- a/end2end-tests/build.gradle.kts +++ b/end2end-tests/build.gradle.kts @@ -1,7 +1,7 @@ val fabrikt: Configuration by configurations.creating val generationDir = "$buildDir/generated" -val apiFile = "$buildDir/../../src/test/resources/examples/okHttpClient/api.yaml" +val apiFile = "$buildDir/../../src/test/resources/examples/httpClient/api.yaml" sourceSets { main { java.srcDirs("$generationDir/src/main/kotlin") } @@ -40,7 +40,7 @@ dependencies { tasks { - val generateCode by creating(JavaExec::class) { + val generateOkioCode by creating(JavaExec::class) { inputs.files(apiFile) outputs.dir(generationDir) outputs.cacheIf { true } @@ -58,9 +58,29 @@ tasks { dependsOn(":shadowJar") } + val generateJdkClientCode by creating(JavaExec::class) { + inputs.files(apiFile) + outputs.dir(generationDir) + outputs.cacheIf { true } + classpath = rootProject.files("./build/libs/fabrikt-${rootProject.version}.jar") + mainClass.set("com.cjbooms.fabrikt.cli.CodeGen") + args = listOf( + "--output-directory", generationDir, + "--base-package", "com.example.jdk_client", + "--api-file", apiFile, + "--targets", "http_models", + "--http-client-target", "JDK_HTTP", + "--targets", "client", + "--http-client-opts", "resilience4j" + ) + dependsOn(":jar") + dependsOn(":shadowJar") + } + withType { kotlinOptions.jvmTarget = "17" - dependsOn(generateCode) + dependsOn(generateOkioCode) + dependsOn(generateJdkClientCode) } diff --git a/end2end-tests/src/test/kotlin/com/cjbooms/fabrikt/clients/jdk/JdkTest.kt b/end2end-tests/src/test/kotlin/com/cjbooms/fabrikt/clients/jdk/JdkTest.kt new file mode 100644 index 00000000..5ddca8b2 --- /dev/null +++ b/end2end-tests/src/test/kotlin/com/cjbooms/fabrikt/clients/jdk/JdkTest.kt @@ -0,0 +1,276 @@ +package com.cjbooms.fabrikt.clients.jdk + +import com.example.jdk_client.client.ApiClientException +import com.example.jdk_client.client.ApiRedirectException +import com.example.jdk_client.client.ApiServerException +import com.example.jdk_client.client.ExamplePath2Client +import com.example.jdk_client.client.ExamplePath3SubresourceClient +import com.example.jdk_client.client.ExamplePath1Client +import com.example.jdk_client.models.Failure +import com.example.jdk_client.models.FirstModel +import com.example.jdk_client.models.QueryResult +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.common.ConsoleNotifier +import com.github.tomakehurst.wiremock.core.WireMockConfiguration +import com.marcinziolo.kotlin.wiremock.* +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.* +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.net.ServerSocket +import java.net.http.HttpClient +import java.util.* +import java.util.stream.Stream + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class JdkTest { + private val port: Int = ServerSocket(0).use { socket -> socket.localPort } + + private val wiremock: WireMockServer = WireMockServer( + WireMockConfiguration.options().port(port).notifier( + ConsoleNotifier(true) + )) + + private val mapper = ObjectMapper() + private val httpClient = HttpClient.newHttpClient() + private val examplePath1Client = ExamplePath1Client(mapper, "http://localhost:$port", httpClient) + private val examplePath2Client = ExamplePath2Client(mapper, "http://localhost:$port", httpClient) + private val examplePath3Client = ExamplePath3SubresourceClient(mapper, "http://localhost:$port", httpClient) + + private val uuid = UUID.randomUUID() + private val failure = Failure(traceId = uuid, + error = "testError", + errorCode = "testErrorCode") + + @Suppress("unused") + private fun path2ErrorCodes(): Stream = Stream.of(400, 422, 423) + + @BeforeEach + fun setUp() { + wiremock.start() + } + + @AfterEach + fun afterEach() { + wiremock.resetAll() + wiremock.stop() + } + + @Test + fun `throws an exception if 404 is returned`() { + wiremock.get { + url like "/example-path-1" + } returns { + statusCode = 404 + } + + val result = assertThrows { + examplePath1Client.getExamplePath1() + } + Assertions.assertThat(result.statusCode).isEqualTo(404) + } + + @Test + fun `returns data when no query parameters are send`(testInfo: TestInfo) { + wiremock.get { + url like "/example-path-1" + } returns { + statusCode = 200 + body = mapper.writeValueAsString( + QueryResult( + listOf(FirstModel(id = testInfo.displayName)) + ) + ) + } + + val result = examplePath1Client.getExamplePath1() + + Assertions.assertThat(result.data).isEqualTo( + QueryResult( + listOf(FirstModel(id = testInfo.displayName)) + ) + ) + } + + @Test + fun `adds query_param2 to the query`(testInfo: TestInfo) { + wiremock.get { + urlPath like "/example-path-1" + queryParams contains "query_param2" like "10" + } returns { + statusCode = 200 + body = mapper.writeValueAsString( + QueryResult( + listOf(FirstModel(id = testInfo.displayName)) + ) + ) + } + + val result = examplePath1Client.getExamplePath1(queryParam2 = 10) + + Assertions.assertThat(result.data).isEqualTo( + QueryResult( + listOf(FirstModel(id = testInfo.displayName)) + ) + ) + } + + @Test + fun `adds explode_list_query_param to the query`(testInfo: TestInfo) { + wiremock.get { + urlPath like "/example-path-1" + queryParams contains "explode_list_query_param" like "list" + queryParams contains "explode_list_query_param" like "of" + queryParams contains "explode_list_query_param" like "parameters" + } returns { + statusCode = 200 + body = mapper.writeValueAsString( + QueryResult( + listOf(FirstModel(id = testInfo.displayName)) + ) + ) + } + + val result = examplePath1Client.getExamplePath1(explodeListQueryParam = listOf("list", "of", "parameters")) + + Assertions.assertThat(result.data).isEqualTo( + QueryResult( + listOf(FirstModel(id = testInfo.displayName)) + ) + ) + } + + @Test + fun `adds additional headers to the query`(testInfo: TestInfo) { + wiremock.get { + urlPath like "/example-path-1" + headers contains "awesome" like "header" + } returns { + statusCode = 200 + body = mapper.writeValueAsString( + QueryResult( + listOf(FirstModel(id = testInfo.displayName)) + ) + ) + } + + val result = examplePath1Client.getExamplePath1(additionalHeaders = mapOf("awesome" to "header")) + + Assertions.assertThat(result.data).isEqualTo( + QueryResult( + listOf(FirstModel(id = testInfo.displayName)) + ) + ) + } + + @Test + fun `send body with post request`(testInfo: TestInfo) { + val content = FirstModel(id = testInfo.displayName) + wiremock.post { + urlPath like "/example-path-1" + body equalTo mapper.writeValueAsString(content) + } returns { + statusCode = 201 + } + + val result = examplePath1Client.postExamplePath1(content) + Assertions.assertThat(result.statusCode).isEqualTo(201) + } + + @ParameterizedTest + @MethodSource("path2ErrorCodes") + fun `throws an exception if a 4xx http status code is returned`(errorCode: Int) { + wiremock.get { + urlPath like "/example-path-2/$errorCode" + } returns { + statusCode = errorCode + body = mapper.writeValueAsString(failure) + } + + val result = assertThrows { + examplePath2Client.getExamplePath2PathParam(errorCode.toString(), 10) + } + + Assertions.assertThat(result.statusCode).isEqualTo(errorCode) + Assertions.assertThat(mapper.readValue(result.message, Failure::class.java)).isEqualTo(failure) + } + + @Test + fun `throws an exception if a http status code 500 is returned`() { + wiremock.get { + urlPath like "/example-path-2/500" + } returns { + statusCode = 500 + body = mapper.writeValueAsString(failure) + } + + val result = assertThrows { + examplePath2Client.getExamplePath2PathParam("500", 10) + } + + Assertions.assertThat(result.statusCode).isEqualTo(500) + Assertions.assertThat(mapper.readValue(result.message, Failure::class.java)).isEqualTo(failure) + } + + @Test + fun `throws an exception if a http status code 304 is returned`() { + wiremock.get { + urlPath like "/example-path-2/304" + } returns { + statusCode = 304 + } + + val result = assertThrows { + examplePath2Client.getExamplePath2PathParam("304", 10) + } + + Assertions.assertThat(result.statusCode).isEqualTo(304) + } + + + @Test + fun `head returns 200`() { + wiremock.head { + urlPath like "/example-path-2/head200" + } returns { + statusCode = 200 + } + + val result = examplePath2Client.headOperationIdExample("head200") + + Assertions.assertThat(result.statusCode).isEqualTo(200) + } + + @Test + fun `put returns 204`() { + val model = FirstModel(id = "put", secondAttr = "204") + wiremock.put { + urlPath like "/example-path-2/put204" + body equalTo mapper.writeValueAsString(model) + headers contains "If-Match" like "match" + } returns { + statusCode = 204 + } + + val result = examplePath2Client.putExamplePath2PathParam(firstModel = model, pathParam = "put204", ifMatch = "match") + + Assertions.assertThat(result.statusCode).isEqualTo(204) + } + + @Test + fun `put returns 204 with sub resource`() { + val model = FirstModel(id = "put", secondAttr = "304") + wiremock.put { + urlPath like "/example-path-3/put304/subresource" + body equalTo mapper.writeValueAsString(model) + headers contains "If-Match" like "match" + } returns { + statusCode = 204 + } + + val result = examplePath3Client.putExamplePath3PathParamSubresource(firstModel = model, pathParam = "put304", ifMatch = "match") + + Assertions.assertThat(result.statusCode).isEqualTo(204) + } +} \ No newline at end of file diff --git a/end2end-tests/src/test/kotlin/com/cjbooms/fabrikt/clients/jdk/UrlTest.kt b/end2end-tests/src/test/kotlin/com/cjbooms/fabrikt/clients/jdk/UrlTest.kt new file mode 100644 index 00000000..c92ed748 --- /dev/null +++ b/end2end-tests/src/test/kotlin/com/cjbooms/fabrikt/clients/jdk/UrlTest.kt @@ -0,0 +1,49 @@ +package com.cjbooms.fabrikt.clients.jdk + +import com.example.jdk_client.client.Url +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.net.URI + +class UrlTest { + + @Test + fun testExpansion() { + val url = Url("http://bla.blub/{a}/test/{b}") + .addPathParam("a", "123") + .addPathParam("b", "abc") + .addQueryParam("expand", "true") + .addQueryParam("whatever", "654") + + assertThat(url.toUri()) + .isEqualTo(URI.create("http://bla.blub/123/test/abc?expand=true&whatever=654")) + } + + @Test + fun testWithoutQueryParams() { + val url = Url("http://bla.blub/{a}/test/{b}") + .addPathParam("a", "123") + .addPathParam("b", "abc") + + assertThat(url.toUri()) + .isEqualTo(URI.create("http://bla.blub/123/test/abc")) + } + + @Test + fun testArrayWithoutExplode() { + val url = Url("http://bla.blub/test") + .addQueryParam("queryParam", listOf("a", "b"), true) + + assertThat(url.toUri()) + .isEqualTo(URI.create("http://bla.blub/test?queryParam=a&queryParam=b")) + } + + @Test + fun testArrayWithExplode() { + val url = Url("http://bla.blub/test") + .addQueryParam("queryParam", listOf("a", "b"), false) + + assertThat(url.toUri()) + .isEqualTo(URI.create("http://bla.blub/test?queryParam=a,b")) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenOptions.kt b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenOptions.kt index eee0ec37..675a017a 100644 --- a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenOptions.kt +++ b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenOptions.kt @@ -31,7 +31,8 @@ enum class ClientCodeGenOptionType(private val description: String) { enum class ClientCodeGenTargetType(val description: String) { OK_HTTP("Generate OkHttp client."), - OPEN_FEIGN("Generate OpenFeign client."); + OPEN_FEIGN("Generate OpenFeign client."), + JDK_HTTP("Generate JDK HTTP client."); override fun toString() = "`${super.toString()}` - $description" } diff --git a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenerator.kt index 448347d5..a789e577 100644 --- a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenerator.kt +++ b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenerator.kt @@ -6,6 +6,7 @@ import com.cjbooms.fabrikt.cli.CodeGenerationType.HTTP_MODELS import com.cjbooms.fabrikt.cli.CodeGenerationType.QUARKUS_REFLECTION_CONFIG import com.cjbooms.fabrikt.configurations.Packages import com.cjbooms.fabrikt.generators.MutableSettings +import com.cjbooms.fabrikt.generators.client.JDKHttpClientGenerator import com.cjbooms.fabrikt.generators.client.OkHttpClientGenerator import com.cjbooms.fabrikt.generators.client.OpenFeignInterfaceGenerator import com.cjbooms.fabrikt.generators.controller.KtorControllerInterfaceGenerator @@ -49,6 +50,7 @@ class CodeGenerator( val clientGenerator = when (MutableSettings.clientTarget()) { ClientCodeGenTargetType.OK_HTTP -> OkHttpClientGenerator(packages, sourceApi, srcPath) ClientCodeGenTargetType.OPEN_FEIGN -> OpenFeignInterfaceGenerator(packages, sourceApi) + ClientCodeGenTargetType.JDK_HTTP -> JDKHttpClientGenerator(packages, sourceApi, srcPath) } val options = MutableSettings.clientOptions() val clientFiles = clientGenerator.generate(options).files diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/client/JDKHttpClientGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/JDKHttpClientGenerator.kt new file mode 100644 index 00000000..9e5439ad --- /dev/null +++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/JDKHttpClientGenerator.kt @@ -0,0 +1,27 @@ +package com.cjbooms.fabrikt.generators.client + +import com.cjbooms.fabrikt.cli.ClientCodeGenOptionType +import com.cjbooms.fabrikt.configurations.Packages +import com.cjbooms.fabrikt.model.Clients +import com.cjbooms.fabrikt.model.GeneratedFile +import com.cjbooms.fabrikt.model.SourceApi +import java.nio.file.Path + +class JDKHttpClientGenerator( + packages: Packages, + api: SourceApi, + srcPath: Path, +) : ClientGenerator { + private val simpleClientGenerator = JDKHttpSimpleClientGenerator(packages, api, srcPath) + + override fun generate(options: Set): Clients { + val simpleClient = simpleClientGenerator.generateDynamicClientCode() + + return Clients(simpleClient) + } + + override fun generateLibrary(options: Set): Collection { + + return simpleClientGenerator.generateLibrary() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/client/JDKHttpSimpleClientGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/JDKHttpSimpleClientGenerator.kt new file mode 100644 index 00000000..495c139c --- /dev/null +++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/JDKHttpSimpleClientGenerator.kt @@ -0,0 +1,205 @@ +package com.cjbooms.fabrikt.generators.client + +import com.cjbooms.fabrikt.configurations.Packages +import com.cjbooms.fabrikt.generators.GeneratorUtils.functionName +import com.cjbooms.fabrikt.generators.GeneratorUtils.primaryPropertiesConstructor +import com.cjbooms.fabrikt.generators.GeneratorUtils.toClassName +import com.cjbooms.fabrikt.generators.GeneratorUtils.toKdoc +import com.cjbooms.fabrikt.generators.TypeFactory +import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.ADDITIONAL_HEADERS_PARAMETER_NAME +import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.addIncomingParameters +import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.deriveClientParameters +import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.simpleClientName +import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.toClientReturnType +import com.cjbooms.fabrikt.model.* +import com.cjbooms.fabrikt.util.KaizenParserExtensions.routeToPaths +import com.cjbooms.fabrikt.util.toUpperCase +import com.fasterxml.jackson.databind.ObjectMapper +import com.github.javaparser.utils.CodeGenerationUtils +import com.reprezen.kaizen.oasparser.model3.Operation +import com.squareup.kotlinpoet.* +import java.nio.file.Path + +class JDKHttpSimpleClientGenerator( + private val packages: Packages, + private val api: SourceApi, + private val srcPath: Path = Destinations.MAIN_KT_SOURCE +) { + + fun generateDynamicClientCode(): Collection { + return api.openApi3.routeToPaths().map { (resourceName, paths) -> + val funcSpecs: List = paths.flatMap { (resource, path) -> + path.operations.map { (verb, operation) -> + val parameters = deriveClientParameters(path, operation, packages.base) + FunSpec + .builder(functionName(operation, resource, verb)) + .addModifiers(KModifier.PUBLIC) + .addKdoc(operation.toKdoc(parameters)) + .addAnnotation( + AnnotationSpec.builder(Throws::class) + .addMember("%T::class", "ApiException".toClassName(packages.client)).build() + ) + .addIncomingParameters(parameters) + .addParameter( + ParameterSpec.builder( + ADDITIONAL_HEADERS_PARAMETER_NAME, + TypeFactory.createMapOfStringToType(String::class.asTypeName()) + ) + .defaultValue("emptyMap()") + .build() + ) + .addCode( + SimpleJDKClientOperationStatement( + packages, + resource, + verb, + operation, + parameters, + ).toStatement() + ) + .returns(operation.toClientReturnType(packages)) + .build() + } + } + + val clientType = TypeSpec.classBuilder(simpleClientName(resourceName)) + .primaryPropertiesConstructor( + PropertySpec.builder("objectMapper", ObjectMapper::class.asTypeName(), KModifier.PRIVATE).build(), + PropertySpec.builder("baseUrl", String::class.asTypeName(), KModifier.PRIVATE).build(), + PropertySpec.builder("client", "HttpClient".toClassName("java.net.http"), KModifier.PRIVATE).build() + ) + .addAnnotation(AnnotationSpec.builder(Suppress::class).addMember("%S", "unused").build()) + .addFunctions(funcSpecs) + .build() + + ClientType(clientType, packages.base) + }.toSet() + } + + fun generateLibrary(): Collection { + val codeDir = srcPath.resolve(CodeGenerationUtils.packageToPath(packages.base)) + val clientDir = codeDir.resolve("client") + return setOf( + HandlebarsTemplates.applyTemplate( + template = HandlebarsTemplates.clientJDKApiModels, + input = packages, + path = clientDir, + fileName = "ApiModels.kt" + ), + HandlebarsTemplates.applyTemplate( + template = HandlebarsTemplates.clientJDKHttpUtils, + input = packages, + path = clientDir, + fileName = "HttpUtil.kt" + ), + /*HandlebarsTemplates.applyTemplate( + template = HandlebarsTemplates.clientOAuth, + input = packages, + path = clientDir, + fileName = "OAuth.kt" + )*/ + ) + } +} + +data class SimpleJDKClientOperationStatement( + private val packages: Packages, + private val resource: String, + private val verb: String, + private val operation: Operation, + private val parameters: List +) { + private val basePackage = "java.net" + private val httpBasePackage = "java.net.http" + fun toStatement(): CodeBlock = + CodeBlock.builder() + .addUrlStatement() + .addPathParamStatement() + .addQueryParamStatement() + .addRequestStatement() + .addHeaderParamStatement() + .addRequestExecutionStatement() + .build() + + private fun CodeBlock.Builder.addUrlStatement(): CodeBlock.Builder { + this.add("val url: %T = Url(\"%L\")", "Url".toClassName(packages.client), "\$baseUrl$resource") + return this + } + + private fun CodeBlock.Builder.addPathParamStatement(): CodeBlock.Builder { + parameters + .filterIsInstance() + .filter { it.parameterLocation == PathParam } + .forEach { + this.add("\n.addPathParam(\"${it.originalName}\", ${it.name})") + } + + return this + } + + private fun CodeBlock.Builder.addQueryParamStatement(): CodeBlock.Builder { + parameters + .filterIsInstance() + .filter { it.parameterLocation == QueryParam } + .forEach { + when (it.typeInfo) { + is KotlinTypeInfo.Array -> this.add(".addQueryParam(%S, %N, %L)", + it.originalName, + it.name, + if (it.explode == null || it.explode == true) "true" else "false") + else -> this.add( + "\n.addQueryParam(%S, %N)", + it.originalName, + it.name) + } + } + return this + } + + private fun CodeBlock.Builder.addRequestStatement(): CodeBlock.Builder { + this.add("\nval requestBuilder: %T.Builder = HttpRequest.newBuilder()", "HttpRequest".toClassName(httpBasePackage)) + this.add("\n.uri(url.toUri())") + this.add("\n.version(HttpClient.Version.HTTP_1_1)") + + when (val op = verb.toUpperCase()) { + "PUT" -> { + parameters.filterIsInstance().firstOrNull()?.let { + this.add("\n.PUT(Publishers.jsonBodyPublisher(%N))", it.name) + } + } + "POST" -> { + parameters.filterIsInstance().firstOrNull()?.let { + this.add("\n.POST(Publishers.jsonBodyPublisher(%N))", it.name) + } + } + "PATCH" -> { + parameters.filterIsInstance().firstOrNull()?.let { + this.add("\n.method(\"PATCH\", Publishers.jsonBodyPublisher(%N))", it.name) + } + } + "HEAD" -> this.add("\n.method(\"HEAD\", %T.noBody())", "BodyPublishers".toClassName("$httpBasePackage.HttpRequest")) + "GET" -> this.add("\n.GET()") + "DELETE" -> this.add("\n.DELETE()") + else -> throw NotImplementedError("API operation $op is not supported") + } + + return this + } + + private fun CodeBlock.Builder.addRequestExecutionStatement() = + this.add("\nreturn client.execute(requestBuilder.build())\n") + + private fun CodeBlock.Builder.addHeaderParamStatement(): CodeBlock.Builder { + parameters + .filterIsInstance() + .filter { it.parameterLocation == HeaderParam } + .forEach { + this.add( + "\n${it.name}?.let { requestBuilder.header(%S, it.toString()) }", + it.originalName, + //it.name + if (it.typeInfo is KotlinTypeInfo.Enum) "?.value" else "" + ) + } + return this.add("\nadditionalHeaders.forEach { requestBuilder.header(it.key, it.value) }") + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpSimpleClientGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpSimpleClientGenerator.kt index 2b5a08a9..6df238e8 100644 --- a/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpSimpleClientGenerator.kt +++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpSimpleClientGenerator.kt @@ -67,7 +67,7 @@ class OkHttpSimpleClientGenerator( .build() ) .addCode( - SimpleClientOperationStatement( + SimpleOkioClientOperationStatement( packages, resource, verb, @@ -99,13 +99,13 @@ class OkHttpSimpleClientGenerator( val clientDir = codeDir.resolve("client") return setOf( HandlebarsTemplates.applyTemplate( - template = HandlebarsTemplates.clientApiModels, + template = HandlebarsTemplates.clientOkioApiModels, input = packages, path = clientDir, fileName = "ApiModels.kt" ), HandlebarsTemplates.applyTemplate( - template = HandlebarsTemplates.clientHttpUtils, + template = HandlebarsTemplates.clientOkioHttpUtils, input = packages, path = clientDir, fileName = "HttpUtil.kt" @@ -120,7 +120,7 @@ class OkHttpSimpleClientGenerator( } } -data class SimpleClientOperationStatement( +data class SimpleOkioClientOperationStatement( private val packages: Packages, private val resource: String, private val verb: String, diff --git a/src/main/kotlin/com/cjbooms/fabrikt/model/HandlebarsTemplates.kt b/src/main/kotlin/com/cjbooms/fabrikt/model/HandlebarsTemplates.kt index a515afa5..b7a22833 100644 --- a/src/main/kotlin/com/cjbooms/fabrikt/model/HandlebarsTemplates.kt +++ b/src/main/kotlin/com/cjbooms/fabrikt/model/HandlebarsTemplates.kt @@ -22,9 +22,11 @@ object HandlebarsTemplates { ) // Client templates - val clientApiModels = handlebars.compile("/templates/client-code/api-models.kt")!! + val clientOkioApiModels = handlebars.compile("/templates/client-code/api-models.kt")!! + val clientJDKApiModels = handlebars.compile("/templates/jdk-client-code/api-models.kt")!! val clientOAuth = handlebars.compile("/templates/client-code/oauth.kt")!! - val clientHttpUtils = handlebars.compile("/templates/client-code/http-util.kt")!! + val clientOkioHttpUtils = handlebars.compile("/templates/client-code/http-util.kt")!! + val clientJDKHttpUtils = handlebars.compile("/templates/jdk-client-code/http-util.kt")!! val clientHttpResilience4jUtils = handlebars.compile("/templates/client-code/http-resilience4j-util.kt")!! fun applyTemplate( diff --git a/src/main/resources/templates/jdk-client-code/api-models.kt.hbs b/src/main/resources/templates/jdk-client-code/api-models.kt.hbs new file mode 100644 index 00000000..88bdec3d --- /dev/null +++ b/src/main/resources/templates/jdk-client-code/api-models.kt.hbs @@ -0,0 +1,32 @@ +package {{ client }} + +import java.net.http.HttpHeaders + +/** + * API 2xx success response returned by API call. + * + * @param The type of data that is deserialized from response body + */ +data class ApiResponse(val statusCode: Int, val headers: HttpHeaders, val data: T? = null) + +/** + * API non-2xx failure responses returned by API call. + */ +open class ApiException(override val message: String) : RuntimeException(message) + +/** + * API 3xx redirect response returned by API call. + */ +open class ApiRedirectException(val statusCode: Int, val headers: HttpHeaders, override val message: String) : ApiException(message) + +/** + * API 4xx failure responses returned by API call. + */ +data class ApiClientException(val statusCode: Int, val headers: HttpHeaders, override val message: String) : + ApiException(message) + +/** + * API 5xx failure responses returned by API call. + */ +data class ApiServerException(val statusCode: Int, val headers: HttpHeaders, override val message: String) : + ApiException(message) \ No newline at end of file diff --git a/src/main/resources/templates/jdk-client-code/http-util.kt.hbs b/src/main/resources/templates/jdk-client-code/http-util.kt.hbs new file mode 100644 index 00000000..d2cdda6a --- /dev/null +++ b/src/main/resources/templates/jdk-client-code/http-util.kt.hbs @@ -0,0 +1,132 @@ +package {{ client }} + +import com.fasterxml.jackson.core.exc.StreamReadException +import com.fasterxml.jackson.databind.DatabindException +import com.fasterxml.jackson.databind.ObjectMapper +import java.io.IOException +import java.io.InputStream +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpRequest.BodyPublishers +import java.net.http.HttpResponse.* +import java.util.function.Supplier + +@Throws(ApiException::class) +inline fun HttpClient.execute(httpRequest: HttpRequest): ApiResponse { + val result = this.send(httpRequest, BodyHandlers.ofByteArray()) + + return when (result.statusCode()){ + in 200..299 -> ApiResponse(result.statusCode(), result.headers(), result.body()?.let { readJson(T::class.java, it) }) + in 300..399 -> throw ApiRedirectException(result.statusCode(), result.headers(), result.body()?.let { String(it) } ?: "") + in 400..499 -> throw ApiClientException(result.statusCode(), result.headers(), result.body()?.let { String(it) } ?: "") + in 500..599 -> throw ApiServerException(result.statusCode(), result.headers(), result.body()?.let { String(it) } ?: "") + else -> throw ApiException("[${result.statusCode()}]: "/*${response.errorMessage()}*/) + } +} + +fun readJson(targetType: Class?, data: ByteArray): W? { + return try { + val objectMapper = + ObjectMapper() + objectMapper.readValue(data, targetType) + } catch (_: IOException) { + null + } catch (_: StreamReadException) { + null + } catch (_: DatabindException) { + null + } +} + +fun asJSON(targetType: Class?): BodySubscriber> { + val upstream = BodySubscribers.ofInputStream() + return BodySubscribers.mapping( + upstream + ) { inputStream: InputStream -> + toSupplierOfType( + inputStream, + targetType + ) + } +} + +fun toSupplierOfType(inputStream: InputStream, targetType: Class?): Supplier { + return Supplier { + try { + inputStream.use { stream -> + val objectMapper = + ObjectMapper() + return@Supplier objectMapper.readValue(stream, targetType) + } + } catch (e: IOException) { + return@Supplier null + } + } +} + +class JsonBodyHandler(val wClass: Class): BodyHandler> { + override fun apply(responseInfo: ResponseInfo): BodySubscriber> { + return asJSON(wClass) + } +} + +object Publishers { + fun jsonBodyPublisher(obj: W): HttpRequest.BodyPublisher { + val data = ObjectMapper().writeValueAsBytes(obj) + return BodyPublishers.ofByteArray(data) + } +} + +class Url(val url: String) { + private val pathParams: MutableList> = mutableListOf() + private val queryParams: MutableList> = mutableListOf() + + fun addPathParam(param: String, value: Any?): Url { + if (value != null) { + this.pathParams.add(param to value.toString()) + } + + return this + } + + fun addQueryParam(param: String, value: List?, explode: Boolean = true): Url { + if (value != null) { + if (explode) { + value.forEach { + this.queryParams.add(param to it) + } + } else { + this.queryParams.add( + param to value.joinToString(",") + ) + } + } + + return this + } + + fun addQueryParam(param: String, value: T?): Url { + if (value != null) this.queryParams.add(param to value.toString()) + + return this + } + + fun toUri(): URI { + val templatedUrl = pathParams.fold(url) { acc, value -> + acc.replace("{${value.first}}", value.second) + } + + val queryParams = this.queryParams.joinToString("&") { + "${it.first}=${it.second}" + }.let { + if (it.isNotEmpty()) { + "?$it" + } else { + it + } + } + + return URI.create("$templatedUrl$queryParams") + } +} diff --git a/src/test/kotlin/com/cjbooms/fabrikt/generators/JDKHttpClientGeneratorTest.kt b/src/test/kotlin/com/cjbooms/fabrikt/generators/JDKHttpClientGeneratorTest.kt new file mode 100644 index 00000000..c97424c0 --- /dev/null +++ b/src/test/kotlin/com/cjbooms/fabrikt/generators/JDKHttpClientGeneratorTest.kt @@ -0,0 +1,119 @@ +package com.cjbooms.fabrikt.generators + +import com.cjbooms.fabrikt.cli.ClientCodeGenTargetType +import com.cjbooms.fabrikt.cli.CodeGenerationType +import com.cjbooms.fabrikt.cli.ModelCodeGenOptionType +import com.cjbooms.fabrikt.configurations.Packages +import com.cjbooms.fabrikt.generators.client.JDKHttpSimpleClientGenerator +import com.cjbooms.fabrikt.generators.client.OkHttpSimpleClientGenerator +import com.cjbooms.fabrikt.generators.model.JacksonMetadata +import com.cjbooms.fabrikt.generators.model.JacksonModelGenerator +import com.cjbooms.fabrikt.model.ClientType +import com.cjbooms.fabrikt.model.Models +import com.cjbooms.fabrikt.model.SimpleFile +import com.cjbooms.fabrikt.model.SourceApi +import com.cjbooms.fabrikt.util.Linter +import com.cjbooms.fabrikt.util.ModelNameRegistry +import com.cjbooms.fabrikt.util.ResourceHelper +import com.squareup.kotlinpoet.FileSpec +import org.assertj.core.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import java.nio.file.Paths +import java.util.stream.Stream + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class JDKHttpClientGeneratorTest { + + @Suppress("unused") + private fun fullApiTestCases(): Stream = Stream.of( + "httpClient", + "multiMediaType", + /*"okHttpClientPostWithoutRequestBody", + "pathLevelParameters", + "parameterNameClash"*/ + ) + + @BeforeEach + fun init() { + MutableSettings.updateSettings( + genTypes = setOf(CodeGenerationType.CLIENT), + clientTarget = ClientCodeGenTargetType.JDK_HTTP, + modelOptions = setOf(ModelCodeGenOptionType.X_EXTENSIBLE_ENUMS), + ) + ModelNameRegistry.clear() + } + + @ParameterizedTest + @MethodSource("fullApiTestCases") + fun `correct api simple client is generated from a full API definition`(testCaseName: String) { + val packages = Packages("examples.$testCaseName.jdk") + val apiLocation = javaClass.getResource("/examples/$testCaseName/api.yaml")!! + val sourceApi = SourceApi(apiLocation.readText(), baseDir = Paths.get(apiLocation.toURI())) + + val expectedModel = + ResourceHelper.readTextResource("/examples/$testCaseName/models/jdk/Models.kt") + val expectedClient = + ResourceHelper.readTextResource("/examples/$testCaseName/client/jdk/ApiClient.kt") + + val models = JacksonModelGenerator( + packages, + sourceApi + ).generate().toSingleFile() + val simpleClientCode = JDKHttpSimpleClientGenerator( + packages, + sourceApi + ) + .generateDynamicClientCode() + .toSingleFile() + + Assertions.assertThat(models).isEqualTo(expectedModel) + Assertions.assertThat(simpleClientCode).isEqualTo(expectedClient) + } + + @ParameterizedTest + @MethodSource("fullApiTestCases") + fun `correct http utility libraries are generated`(testCaseName: String) { + val packages = Packages("examples.$testCaseName.jdk") + val apiLocation = javaClass.getResource("/examples/$testCaseName/api.yaml")!! + val sourceApi = SourceApi(apiLocation.readText(), baseDir = Paths.get(apiLocation.toURI())) + + val expectedHttpUtils = + ResourceHelper.readTextResource("/examples/$testCaseName/client/jdk/HttpUtil.kt") + + val generatedHttpUtils = JDKHttpSimpleClientGenerator( + packages, + sourceApi + ).generateLibrary().filterIsInstance() + .first { it.path.fileName.toString() == "HttpUtil.kt" } + + Assertions.assertThat(generatedHttpUtils.content).isEqualTo(expectedHttpUtils) + } + + private fun Collection.toSingleFile(): String { + val destPackage = if (this.isNotEmpty()) first().destinationPackage else "" + val singleFileBuilder = FileSpec.builder(destPackage, "dummyFilename") + this.forEach { + val builder = singleFileBuilder + .addType(it.spec) + .addImport(JacksonMetadata.TYPE_REFERENCE_IMPORT.first, JacksonMetadata.TYPE_REFERENCE_IMPORT.second) + builder.build() + } + return Linter.lintString(singleFileBuilder.build().toString()) + } + + private fun Models.toSingleFile(): String { + val destPackage = if (models.isNotEmpty()) models.first().destinationPackage else "" + val singleFileBuilder = FileSpec.builder(destPackage, "dummyFilename") + models + .sortedBy { it.spec.name } + .forEach { + val builder = singleFileBuilder + .addType(it.spec) + builder.build() + } + return Linter.lintString(singleFileBuilder.build().toString()) + } +} \ No newline at end of file diff --git a/src/test/kotlin/com/cjbooms/fabrikt/generators/OkHttpClientGeneratorTest.kt b/src/test/kotlin/com/cjbooms/fabrikt/generators/OkHttpClientGeneratorTest.kt index b0ce7bfe..58e5826d 100644 --- a/src/test/kotlin/com/cjbooms/fabrikt/generators/OkHttpClientGeneratorTest.kt +++ b/src/test/kotlin/com/cjbooms/fabrikt/generators/OkHttpClientGeneratorTest.kt @@ -32,7 +32,7 @@ class OkHttpClientGeneratorTest { @Suppress("unused") private fun fullApiTestCases(): Stream = Stream.of( - "okHttpClient", + "httpClient", "multiMediaType", "okHttpClientPostWithoutRequestBody", "pathLevelParameters", diff --git a/src/test/resources/examples/okHttpClient/api.yaml b/src/test/resources/examples/httpClient/api.yaml similarity index 92% rename from src/test/resources/examples/okHttpClient/api.yaml rename to src/test/resources/examples/httpClient/api.yaml index 10e7be59..61260a28 100644 --- a/src/test/resources/examples/okHttpClient/api.yaml +++ b/src/test/resources/examples/httpClient/api.yaml @@ -122,6 +122,22 @@ paths: responses: 204: description: "Operation successful" + delete: + summary: "DELETE example path 3" + response: + 200: + description: "Operation successful" + + /example-path-3/{path_param}/subresource/name: + patch: + summary: "PATCH example path 3" + parameters: + - $ref: "#/components/parameters/PathParam" + requestBody: + $ref: "#/components/requestBodies/PatchBody" + responses: + 204: + description: "Operation successful" components: parameters: @@ -224,7 +240,12 @@ components: application/json: schema: $ref: "#/components/schemas/FirstModel" - + PatchBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schema/FirstModelPatch" schemas: QueryResult: @@ -290,11 +311,20 @@ components: second_model: "#/components/schemas/SecondModel" third_model: "#/components/schemas/ThirdModel" + FirstModelPatch: + - type: "object" + properties: + name: + description: "Same random name" + type: string FirstModel: allOf: - $ref: "#/components/schemas/Content" - type: "object" properties: + name: + description: "Same random name" + type: string extra_first_attr: description: "The attribute 1 for model 1" type: array diff --git a/src/test/resources/examples/httpClient/client/ApiClient.kt b/src/test/resources/examples/httpClient/client/ApiClient.kt new file mode 100644 index 00000000..06a10ae6 --- /dev/null +++ b/src/test/resources/examples/httpClient/client/ApiClient.kt @@ -0,0 +1,315 @@ +package examples.httpClient.client + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonTypeRef +import examples.httpClient.models.Content +import examples.httpClient.models.FirstModel +import examples.httpClient.models.QueryResult +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import kotlin.Any +import kotlin.Boolean +import kotlin.Int +import kotlin.String +import kotlin.Suppress +import kotlin.Unit +import kotlin.collections.List +import kotlin.collections.Map +import kotlin.jvm.Throws + +@Suppress("unused") +public class ExamplePath1Client( + private val objectMapper: ObjectMapper, + private val baseUrl: String, + private val client: OkHttpClient, +) { + /** + * GET example path 1 + * + * @param explodeListQueryParam + * @param queryParam2 + */ + @Throws(ApiException::class) + public fun getExamplePath1( + explodeListQueryParam: List? = null, + queryParam2: Int? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse { + val httpUrl: HttpUrl = "$baseUrl/example-path-1" + .toHttpUrl() + .newBuilder() + .queryParam("explode_list_query_param", explodeListQueryParam, true) + .queryParam("query_param2", queryParam2) + .build() + + val headerBuilder = Headers.Builder() + additionalHeaders.forEach { headerBuilder.header(it.key, it.value) } + val httpHeaders: Headers = headerBuilder.build() + + val request: Request = Request.Builder() + .url(httpUrl) + .headers(httpHeaders) + .get() + .build() + + return request.execute(client, objectMapper, jacksonTypeRef()) + } + + /** + * POST example path 1 + * + * @param content + * @param explodeListQueryParam + */ + @Throws(ApiException::class) + public fun postExamplePath1( + content: Content, + explodeListQueryParam: List? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse { + val httpUrl: HttpUrl = "$baseUrl/example-path-1" + .toHttpUrl() + .newBuilder() + .queryParam("explode_list_query_param", explodeListQueryParam, true) + .build() + + val headerBuilder = Headers.Builder() + additionalHeaders.forEach { headerBuilder.header(it.key, it.value) } + val httpHeaders: Headers = headerBuilder.build() + + val request: Request = Request.Builder() + .url(httpUrl) + .headers(httpHeaders) + .post(objectMapper.writeValueAsString(content).toRequestBody("application/json".toMediaType())) + .build() + + return request.execute(client, objectMapper, jacksonTypeRef()) + } +} + +@Suppress("unused") +public class ExamplePath2Client( + private val objectMapper: ObjectMapper, + private val baseUrl: String, + private val client: OkHttpClient, +) { + /** + * GET example path 2 + * + * @param pathParam The resource id + * @param limit + * @param queryParam2 + * @param ifNoneMatch The RFC7232 If-None-Match header field + */ + @Throws(ApiException::class) + public fun getExamplePath2PathParam( + pathParam: String, + limit: Int = 500, + queryParam2: Int? = null, + ifNoneMatch: String? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse { + val httpUrl: HttpUrl = "$baseUrl/example-path-2/{path_param}" + .pathParam("{path_param}" to pathParam) + .toHttpUrl() + .newBuilder() + .queryParam("limit", limit) + .queryParam("query_param2", queryParam2) + .build() + + val headerBuilder = Headers.Builder() + .`header`("If-None-Match", ifNoneMatch) + additionalHeaders.forEach { headerBuilder.header(it.key, it.value) } + val httpHeaders: Headers = headerBuilder.build() + + val request: Request = Request.Builder() + .url(httpUrl) + .headers(httpHeaders) + .get() + .build() + + return request.execute(client, objectMapper, jacksonTypeRef()) + } + + /** + * HEAD example path 2 + * + * @param pathParam The resource id + * @param queryParam3 + * @param ifNoneMatch The RFC7232 If-None-Match header field + */ + @Throws(ApiException::class) + public fun headOperationIdExample( + pathParam: String, + queryParam3: Boolean? = null, + ifNoneMatch: String? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse { + val httpUrl: HttpUrl = "$baseUrl/example-path-2/{path_param}" + .pathParam("{path_param}" to pathParam) + .toHttpUrl() + .newBuilder() + .queryParam("query_param3", queryParam3) + .build() + + val headerBuilder = Headers.Builder() + .`header`("If-None-Match", ifNoneMatch) + additionalHeaders.forEach { headerBuilder.header(it.key, it.value) } + val httpHeaders: Headers = headerBuilder.build() + + val request: Request = Request.Builder() + .url(httpUrl) + .headers(httpHeaders) + .head() + .build() + + return request.execute(client, objectMapper, jacksonTypeRef()) + } + + /** + * PUT example path 2 + * + * @param firstModel + * @param pathParam The resource id + * @param ifMatch The RFC7232 If-Match header field + */ + @Throws(ApiException::class) + public fun putExamplePath2PathParam( + firstModel: FirstModel, + pathParam: String, + ifMatch: String, + additionalHeaders: Map = emptyMap(), + ): ApiResponse { + val httpUrl: HttpUrl = "$baseUrl/example-path-2/{path_param}" + .pathParam("{path_param}" to pathParam) + .toHttpUrl() + .newBuilder() + .build() + + val headerBuilder = Headers.Builder() + .`header`("If-Match", ifMatch) + additionalHeaders.forEach { headerBuilder.header(it.key, it.value) } + val httpHeaders: Headers = headerBuilder.build() + + val request: Request = Request.Builder() + .url(httpUrl) + .headers(httpHeaders) + .put(objectMapper.writeValueAsString(firstModel).toRequestBody("application/json".toMediaType())) + .build() + + return request.execute(client, objectMapper, jacksonTypeRef()) + } +} + +@Suppress("unused") +public class ExamplePath3SubresourceClient( + private val objectMapper: ObjectMapper, + private val baseUrl: String, + private val client: OkHttpClient, +) { + /** + * PUT example path 3 + * + * @param firstModel + * @param pathParam The resource id + * @param ifMatch The RFC7232 If-Match header field + * @param csvListQueryParam + */ + @Throws(ApiException::class) + public fun putExamplePath3PathParamSubresource( + firstModel: FirstModel, + pathParam: String, + ifMatch: String, + csvListQueryParam: List? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse { + val httpUrl: HttpUrl = "$baseUrl/example-path-3/{path_param}/subresource" + .pathParam("{path_param}" to pathParam) + .toHttpUrl() + .newBuilder() + .queryParam("csv_list_query_param", csvListQueryParam, false) + .build() + + val headerBuilder = Headers.Builder() + .`header`("If-Match", ifMatch) + additionalHeaders.forEach { headerBuilder.header(it.key, it.value) } + val httpHeaders: Headers = headerBuilder.build() + + val request: Request = Request.Builder() + .url(httpUrl) + .headers(httpHeaders) + .put(objectMapper.writeValueAsString(firstModel).toRequestBody("application/json".toMediaType())) + .build() + + return request.execute(client, objectMapper, jacksonTypeRef()) + } + + /** + * DELETE example path 3 + */ + @Throws(ApiException::class) + public fun deleteExamplePath3PathParamSubresource( + additionalHeaders: Map = + emptyMap(), + ): ApiResponse { + val httpUrl: HttpUrl = "$baseUrl/example-path-3/{path_param}/subresource" + .toHttpUrl() + .newBuilder() + .build() + + val headerBuilder = Headers.Builder() + additionalHeaders.forEach { headerBuilder.header(it.key, it.value) } + val httpHeaders: Headers = headerBuilder.build() + + val request: Request = Request.Builder() + .url(httpUrl) + .headers(httpHeaders) + .delete() + .build() + + return request.execute(client, objectMapper, jacksonTypeRef()) + } +} + +@Suppress("unused") +public class ExamplePath3SubresourceNameClient( + private val objectMapper: ObjectMapper, + private val baseUrl: String, + private val client: OkHttpClient, +) { + /** + * PATCH example path 3 + * + * @param firstModelPatch + * @param pathParam The resource id + */ + @Throws(ApiException::class) + public fun patchExamplePath3PathParamSubresourceName( + firstModelPatch: Any, + pathParam: String, + additionalHeaders: Map = emptyMap(), + ): ApiResponse { + val httpUrl: HttpUrl = "$baseUrl/example-path-3/{path_param}/subresource/name" + .pathParam("{path_param}" to pathParam) + .toHttpUrl() + .newBuilder() + .build() + + val headerBuilder = Headers.Builder() + additionalHeaders.forEach { headerBuilder.header(it.key, it.value) } + val httpHeaders: Headers = headerBuilder.build() + + val request: Request = Request.Builder() + .url(httpUrl) + .headers(httpHeaders) + .patch(objectMapper.writeValueAsString(firstModelPatch).toRequestBody("application/json".toMediaType())) + .build() + + return request.execute(client, objectMapper, jacksonTypeRef()) + } +} diff --git a/src/test/resources/examples/okHttpClient/client/ApiModels.kt b/src/test/resources/examples/httpClient/client/ApiModels.kt similarity index 96% rename from src/test/resources/examples/okHttpClient/client/ApiModels.kt rename to src/test/resources/examples/httpClient/client/ApiModels.kt index 1a905d7e..7c6e3d22 100644 --- a/src/test/resources/examples/okHttpClient/client/ApiModels.kt +++ b/src/test/resources/examples/httpClient/client/ApiModels.kt @@ -1,4 +1,4 @@ -package examples.okHttpClient.client +package examples.httpClient.client import okhttp3.Headers diff --git a/src/test/resources/examples/httpClient/client/ApiService.kt b/src/test/resources/examples/httpClient/client/ApiService.kt new file mode 100644 index 00000000..29ca9b36 --- /dev/null +++ b/src/test/resources/examples/httpClient/client/ApiService.kt @@ -0,0 +1,187 @@ +package examples.httpClient.client + +import com.fasterxml.jackson.databind.ObjectMapper +import examples.httpClient.models.Content +import examples.httpClient.models.FirstModel +import examples.httpClient.models.QueryResult +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry +import okhttp3.OkHttpClient +import kotlin.Any +import kotlin.Boolean +import kotlin.Int +import kotlin.String +import kotlin.Suppress +import kotlin.Unit +import kotlin.collections.List +import kotlin.collections.Map +import kotlin.jvm.Throws + +/** + * The circuit breaker registry should have the proper configuration to correctly action on circuit + * breaker transitions based on the client exceptions [ApiClientException], [ApiServerException] and + * [IOException]. + * + * @see ApiClientException + * @see ApiServerException + */ +@Suppress("unused") +public class ExamplePath1Service( + private val circuitBreakerRegistry: CircuitBreakerRegistry, + objectMapper: ObjectMapper, + baseUrl: String, + client: OkHttpClient, +) { + public var circuitBreakerName: String = "examplePath1Client" + + private val apiClient: ExamplePath1Client = ExamplePath1Client(objectMapper, baseUrl, client) + + @Throws(ApiException::class) + public fun getExamplePath1( + explodeListQueryParam: List? = null, + queryParam2: Int? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse = + withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) { + apiClient.getExamplePath1(explodeListQueryParam, queryParam2, additionalHeaders) + } + + @Throws(ApiException::class) + public fun postExamplePath1( + content: Content, + explodeListQueryParam: List? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse = + withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) { + apiClient.postExamplePath1(content, explodeListQueryParam, additionalHeaders) + } +} + +/** + * The circuit breaker registry should have the proper configuration to correctly action on circuit + * breaker transitions based on the client exceptions [ApiClientException], [ApiServerException] and + * [IOException]. + * + * @see ApiClientException + * @see ApiServerException + */ +@Suppress("unused") +public class ExamplePath2Service( + private val circuitBreakerRegistry: CircuitBreakerRegistry, + objectMapper: ObjectMapper, + baseUrl: String, + client: OkHttpClient, +) { + public var circuitBreakerName: String = "examplePath2Client" + + private val apiClient: ExamplePath2Client = ExamplePath2Client(objectMapper, baseUrl, client) + + @Throws(ApiException::class) + public fun getExamplePath2PathParam( + pathParam: String, + limit: Int = 500, + queryParam2: Int? = null, + ifNoneMatch: String? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse = + withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) { + apiClient.getExamplePath2PathParam(pathParam, limit, queryParam2, ifNoneMatch, additionalHeaders) + } + + @Throws(ApiException::class) + public fun headOperationIdExample( + pathParam: String, + queryParam3: Boolean? = null, + ifNoneMatch: String? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse = + withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) { + apiClient.headOperationIdExample(pathParam, queryParam3, ifNoneMatch, additionalHeaders) + } + + @Throws(ApiException::class) + public fun putExamplePath2PathParam( + firstModel: FirstModel, + pathParam: String, + ifMatch: String, + additionalHeaders: Map = emptyMap(), + ): ApiResponse = + withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) { + apiClient.putExamplePath2PathParam(firstModel, pathParam, ifMatch, additionalHeaders) + } +} + +/** + * The circuit breaker registry should have the proper configuration to correctly action on circuit + * breaker transitions based on the client exceptions [ApiClientException], [ApiServerException] and + * [IOException]. + * + * @see ApiClientException + * @see ApiServerException + */ +@Suppress("unused") +public class ExamplePath3SubresourceService( + private val circuitBreakerRegistry: CircuitBreakerRegistry, + objectMapper: ObjectMapper, + baseUrl: String, + client: OkHttpClient, +) { + public var circuitBreakerName: String = "examplePath3SubresourceClient" + + private val apiClient: ExamplePath3SubresourceClient = ExamplePath3SubresourceClient( + objectMapper, + baseUrl, + client, + ) + + @Throws(ApiException::class) + public fun putExamplePath3PathParamSubresource( + firstModel: FirstModel, + pathParam: String, + ifMatch: String, + csvListQueryParam: List? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse = + withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) { + apiClient.putExamplePath3PathParamSubresource(firstModel, pathParam, ifMatch, csvListQueryParam, additionalHeaders) + } + + @Throws(ApiException::class) + public fun deleteExamplePath3PathParamSubresource( + additionalHeaders: Map = + emptyMap(), + ): ApiResponse = + withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) { + apiClient.deleteExamplePath3PathParamSubresource(additionalHeaders) + } +} + +/** + * The circuit breaker registry should have the proper configuration to correctly action on circuit + * breaker transitions based on the client exceptions [ApiClientException], [ApiServerException] and + * [IOException]. + * + * @see ApiClientException + * @see ApiServerException + */ +@Suppress("unused") +public class ExamplePath3SubresourceNameService( + private val circuitBreakerRegistry: CircuitBreakerRegistry, + objectMapper: ObjectMapper, + baseUrl: String, + client: OkHttpClient, +) { + public var circuitBreakerName: String = "examplePath3SubresourceNameClient" + + private val apiClient: ExamplePath3SubresourceNameClient = + ExamplePath3SubresourceNameClient(objectMapper, baseUrl, client) + + @Throws(ApiException::class) + public fun patchExamplePath3PathParamSubresourceName( + firstModelPatch: Any, + pathParam: String, + additionalHeaders: Map = emptyMap(), + ): ApiResponse = + withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) { + apiClient.patchExamplePath3PathParamSubresourceName(firstModelPatch, pathParam, additionalHeaders) + } +} diff --git a/src/test/resources/examples/httpClient/client/HttpResilience4jUtil.kt b/src/test/resources/examples/httpClient/client/HttpResilience4jUtil.kt new file mode 100644 index 00000000..36b83a56 --- /dev/null +++ b/src/test/resources/examples/httpClient/client/HttpResilience4jUtil.kt @@ -0,0 +1,13 @@ +package examples.httpClient.client + +import io.github.resilience4j.circuitbreaker.CircuitBreaker +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry + +fun withCircuitBreaker( + circuitBreakerRegistry: CircuitBreakerRegistry, + apiClientName: String, + apiCall: () -> ApiResponse +): ApiResponse { + val circuitBreaker = circuitBreakerRegistry.circuitBreaker(apiClientName) + return CircuitBreaker.decorateSupplier(circuitBreaker, apiCall).get() +} diff --git a/src/test/resources/examples/okHttpClient/client/HttpUtil.kt b/src/test/resources/examples/httpClient/client/HttpUtil.kt similarity index 98% rename from src/test/resources/examples/okHttpClient/client/HttpUtil.kt rename to src/test/resources/examples/httpClient/client/HttpUtil.kt index 364cbcf1..279f5fe1 100644 --- a/src/test/resources/examples/okHttpClient/client/HttpUtil.kt +++ b/src/test/resources/examples/httpClient/client/HttpUtil.kt @@ -1,4 +1,4 @@ -package examples.okHttpClient.client +package examples.httpClient.client import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.ObjectMapper diff --git a/src/test/resources/examples/httpClient/client/OAuth.kt b/src/test/resources/examples/httpClient/client/OAuth.kt new file mode 100644 index 00000000..3b9edc22 --- /dev/null +++ b/src/test/resources/examples/httpClient/client/OAuth.kt @@ -0,0 +1,22 @@ +package examples.httpClient.client + +import okhttp3.Authenticator +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route + +class OAuth2(val accessToken: () -> String) : Authenticator, Interceptor { + + override fun authenticate(route: Route?, response: Response): Request = + response.request.newBuilder() + .header("Authorization", "Bearer ${accessToken().trim()}") + .build() + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request().newBuilder() + .header("Authorization", "Bearer ${accessToken().trim()}") + .build() + return chain.proceed(request) + } +} diff --git a/src/test/resources/examples/httpClient/client/jdk/ApiClient.kt b/src/test/resources/examples/httpClient/client/jdk/ApiClient.kt new file mode 100644 index 00000000..20812f47 --- /dev/null +++ b/src/test/resources/examples/httpClient/client/jdk/ApiClient.kt @@ -0,0 +1,247 @@ +package examples.httpClient.jdk.client + +import com.fasterxml.jackson.databind.ObjectMapper +import examples.httpClient.jdk.models.Content +import examples.httpClient.jdk.models.FirstModel +import examples.httpClient.jdk.models.QueryResult +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpRequest.BodyPublishers +import kotlin.Any +import kotlin.Boolean +import kotlin.Int +import kotlin.String +import kotlin.Suppress +import kotlin.Unit +import kotlin.collections.List +import kotlin.collections.Map +import kotlin.jvm.Throws + +@Suppress("unused") +public class ExamplePath1Client( + private val objectMapper: ObjectMapper, + private val baseUrl: String, + private val client: HttpClient, +) { + /** + * GET example path 1 + * + * @param explodeListQueryParam + * @param queryParam2 + */ + @Throws(ApiException::class) + public fun getExamplePath1( + explodeListQueryParam: List? = null, + queryParam2: Int? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse { + val url: Url = Url("$baseUrl/example-path-1").addQueryParam( + "explode_list_query_param", + explodeListQueryParam, + true, + ) + .addQueryParam("query_param2", queryParam2) + val requestBuilder: HttpRequest.Builder = HttpRequest.newBuilder() + .uri(url.toUri()) + .version(HttpClient.Version.HTTP_1_1) + .GET() + additionalHeaders.forEach { requestBuilder.header(it.key, it.value) } + return client.execute(requestBuilder.build()) + } + + /** + * POST example path 1 + * + * @param content + * @param explodeListQueryParam + */ + @Throws(ApiException::class) + public fun postExamplePath1( + content: Content, + explodeListQueryParam: List? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse { + val url: Url = Url("$baseUrl/example-path-1").addQueryParam( + "explode_list_query_param", + explodeListQueryParam, + true, + ) + val requestBuilder: HttpRequest.Builder = HttpRequest.newBuilder() + .uri(url.toUri()) + .version(HttpClient.Version.HTTP_1_1) + .POST(Publishers.jsonBodyPublisher(content)) + additionalHeaders.forEach { requestBuilder.header(it.key, it.value) } + return client.execute(requestBuilder.build()) + } +} + +@Suppress("unused") +public class ExamplePath2Client( + private val objectMapper: ObjectMapper, + private val baseUrl: String, + private val client: HttpClient, +) { + /** + * GET example path 2 + * + * @param pathParam The resource id + * @param limit + * @param queryParam2 + * @param ifNoneMatch The RFC7232 If-None-Match header field + */ + @Throws(ApiException::class) + public fun getExamplePath2PathParam( + pathParam: String, + limit: Int = 500, + queryParam2: Int? = null, + ifNoneMatch: String? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse { + val url: Url = Url("$baseUrl/example-path-2/{path_param}") + .addPathParam("path_param", pathParam) + .addQueryParam("limit", limit) + .addQueryParam("query_param2", queryParam2) + val requestBuilder: HttpRequest.Builder = HttpRequest.newBuilder() + .uri(url.toUri()) + .version(HttpClient.Version.HTTP_1_1) + .GET() + ifNoneMatch?.let { requestBuilder.header("If-None-Match", it.toString()) } + additionalHeaders.forEach { requestBuilder.header(it.key, it.value) } + return client.execute(requestBuilder.build()) + } + + /** + * HEAD example path 2 + * + * @param pathParam The resource id + * @param queryParam3 + * @param ifNoneMatch The RFC7232 If-None-Match header field + */ + @Throws(ApiException::class) + public fun headOperationIdExample( + pathParam: String, + queryParam3: Boolean? = null, + ifNoneMatch: String? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse { + val url: Url = Url("$baseUrl/example-path-2/{path_param}") + .addPathParam("path_param", pathParam) + .addQueryParam("query_param3", queryParam3) + val requestBuilder: HttpRequest.Builder = HttpRequest.newBuilder() + .uri(url.toUri()) + .version(HttpClient.Version.HTTP_1_1) + .method("HEAD", BodyPublishers.noBody()) + ifNoneMatch?.let { requestBuilder.header("If-None-Match", it.toString()) } + additionalHeaders.forEach { requestBuilder.header(it.key, it.value) } + return client.execute(requestBuilder.build()) + } + + /** + * PUT example path 2 + * + * @param firstModel + * @param pathParam The resource id + * @param ifMatch The RFC7232 If-Match header field + */ + @Throws(ApiException::class) + public fun putExamplePath2PathParam( + firstModel: FirstModel, + pathParam: String, + ifMatch: String, + additionalHeaders: Map = emptyMap(), + ): ApiResponse { + val url: Url = Url("$baseUrl/example-path-2/{path_param}") + .addPathParam("path_param", pathParam) + val requestBuilder: HttpRequest.Builder = HttpRequest.newBuilder() + .uri(url.toUri()) + .version(HttpClient.Version.HTTP_1_1) + .PUT(Publishers.jsonBodyPublisher(firstModel)) + ifMatch?.let { requestBuilder.header("If-Match", it.toString()) } + additionalHeaders.forEach { requestBuilder.header(it.key, it.value) } + return client.execute(requestBuilder.build()) + } +} + +@Suppress("unused") +public class ExamplePath3SubresourceClient( + private val objectMapper: ObjectMapper, + private val baseUrl: String, + private val client: HttpClient, +) { + /** + * PUT example path 3 + * + * @param firstModel + * @param pathParam The resource id + * @param ifMatch The RFC7232 If-Match header field + * @param csvListQueryParam + */ + @Throws(ApiException::class) + public fun putExamplePath3PathParamSubresource( + firstModel: FirstModel, + pathParam: String, + ifMatch: String, + csvListQueryParam: List? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse { + val url: Url = Url("$baseUrl/example-path-3/{path_param}/subresource") + .addPathParam("path_param", pathParam).addQueryParam( + "csv_list_query_param", + csvListQueryParam, + false, + ) + val requestBuilder: HttpRequest.Builder = HttpRequest.newBuilder() + .uri(url.toUri()) + .version(HttpClient.Version.HTTP_1_1) + .PUT(Publishers.jsonBodyPublisher(firstModel)) + ifMatch?.let { requestBuilder.header("If-Match", it.toString()) } + additionalHeaders.forEach { requestBuilder.header(it.key, it.value) } + return client.execute(requestBuilder.build()) + } + + /** + * DELETE example path 3 + */ + @Throws(ApiException::class) + public fun deleteExamplePath3PathParamSubresource( + additionalHeaders: Map = + emptyMap(), + ): ApiResponse { + val url: Url = Url("$baseUrl/example-path-3/{path_param}/subresource") + val requestBuilder: HttpRequest.Builder = HttpRequest.newBuilder() + .uri(url.toUri()) + .version(HttpClient.Version.HTTP_1_1) + .DELETE() + additionalHeaders.forEach { requestBuilder.header(it.key, it.value) } + return client.execute(requestBuilder.build()) + } +} + +@Suppress("unused") +public class ExamplePath3SubresourceNameClient( + private val objectMapper: ObjectMapper, + private val baseUrl: String, + private val client: HttpClient, +) { + /** + * PATCH example path 3 + * + * @param firstModelPatch + * @param pathParam The resource id + */ + @Throws(ApiException::class) + public fun patchExamplePath3PathParamSubresourceName( + firstModelPatch: Any, + pathParam: String, + additionalHeaders: Map = emptyMap(), + ): ApiResponse { + val url: Url = Url("$baseUrl/example-path-3/{path_param}/subresource/name") + .addPathParam("path_param", pathParam) + val requestBuilder: HttpRequest.Builder = HttpRequest.newBuilder() + .uri(url.toUri()) + .version(HttpClient.Version.HTTP_1_1) + .method("PATCH", Publishers.jsonBodyPublisher(firstModelPatch)) + additionalHeaders.forEach { requestBuilder.header(it.key, it.value) } + return client.execute(requestBuilder.build()) + } +} diff --git a/src/test/resources/examples/httpClient/client/jdk/ApiModels.kt b/src/test/resources/examples/httpClient/client/jdk/ApiModels.kt new file mode 100644 index 00000000..ad243738 --- /dev/null +++ b/src/test/resources/examples/httpClient/client/jdk/ApiModels.kt @@ -0,0 +1,32 @@ +package examples.httpClient.jdk.client + +import java.net.http.HttpHeaders + +/** + * API 2xx success response returned by API call. + * + * @param The type of data that is deserialized from response body + */ +data class ApiResponse(val statusCode: Int, val headers: HttpHeaders, val data: T? = null) + +/** + * API non-2xx failure responses returned by API call. + */ +open class ApiException(override val message: String) : RuntimeException(message) + +/** + * API 3xx redirect response returned by API call. + */ +open class ApiRedirectException(val statusCode: Int, val headers: HttpHeaders, override val message: String) : ApiException(message) + +/** + * API 4xx failure responses returned by API call. + */ +data class ApiClientException(val statusCode: Int, val headers: HttpHeaders, override val message: String) : + ApiException(message) + +/** + * API 5xx failure responses returned by API call. + */ +data class ApiServerException(val statusCode: Int, val headers: HttpHeaders, override val message: String) : + ApiException(message) diff --git a/src/test/resources/examples/httpClient/client/jdk/HttpUtil.kt b/src/test/resources/examples/httpClient/client/jdk/HttpUtil.kt new file mode 100644 index 00000000..df7f1daa --- /dev/null +++ b/src/test/resources/examples/httpClient/client/jdk/HttpUtil.kt @@ -0,0 +1,132 @@ +package examples.httpClient.jdk.client + +import com.fasterxml.jackson.core.exc.StreamReadException +import com.fasterxml.jackson.databind.DatabindException +import com.fasterxml.jackson.databind.ObjectMapper +import java.io.IOException +import java.io.InputStream +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpRequest.BodyPublishers +import java.net.http.HttpResponse.* +import java.util.function.Supplier + +@Throws(ApiException::class) +inline fun HttpClient.execute(httpRequest: HttpRequest): ApiResponse { + val result = this.send(httpRequest, BodyHandlers.ofByteArray()) + + return when (result.statusCode()){ + in 200..299 -> ApiResponse(result.statusCode(), result.headers(), result.body()?.let { readJson(T::class.java, it) }) + in 300..399 -> throw ApiRedirectException(result.statusCode(), result.headers(), result.body()?.let { String(it) } ?: "") + in 400..499 -> throw ApiClientException(result.statusCode(), result.headers(), result.body()?.let { String(it) } ?: "") + in 500..599 -> throw ApiServerException(result.statusCode(), result.headers(), result.body()?.let { String(it) } ?: "") + else -> throw ApiException("[${result.statusCode()}]: "/*${response.errorMessage()}*/) + } +} + +fun readJson(targetType: Class?, data: ByteArray): W? { + return try { + val objectMapper = + ObjectMapper() + objectMapper.readValue(data, targetType) + } catch (_: IOException) { + null + } catch (_: StreamReadException) { + null + } catch (_: DatabindException) { + null + } +} + +fun asJSON(targetType: Class?): BodySubscriber> { + val upstream = BodySubscribers.ofInputStream() + return BodySubscribers.mapping( + upstream + ) { inputStream: InputStream -> + toSupplierOfType( + inputStream, + targetType + ) + } +} + +fun toSupplierOfType(inputStream: InputStream, targetType: Class?): Supplier { + return Supplier { + try { + inputStream.use { stream -> + val objectMapper = + ObjectMapper() + return@Supplier objectMapper.readValue(stream, targetType) + } + } catch (e: IOException) { + return@Supplier null + } + } +} + +class JsonBodyHandler(val wClass: Class): BodyHandler> { + override fun apply(responseInfo: ResponseInfo): BodySubscriber> { + return asJSON(wClass) + } +} + +object Publishers { + fun jsonBodyPublisher(obj: W): HttpRequest.BodyPublisher { + val data = ObjectMapper().writeValueAsBytes(obj) + return BodyPublishers.ofByteArray(data) + } +} + +class Url(val url: String) { + private val pathParams: MutableList> = mutableListOf() + private val queryParams: MutableList> = mutableListOf() + + fun addPathParam(param: String, value: Any?): Url { + if (value != null) { + this.pathParams.add(param to value.toString()) + } + + return this + } + + fun addQueryParam(param: String, value: List?, explode: Boolean = true): Url { + if (value != null) { + if (explode) { + value.forEach { + this.queryParams.add(param to it) + } + } else { + this.queryParams.add( + param to value.joinToString(",") + ) + } + } + + return this + } + + fun addQueryParam(param: String, value: T?): Url { + if (value != null) this.queryParams.add(param to value.toString()) + + return this + } + + fun toUri(): URI { + val templatedUrl = pathParams.fold(url) { acc, value -> + acc.replace("{${value.first}}", value.second) + } + + val queryParams = this.queryParams.joinToString("&") { + "${it.first}=${it.second}" + }.let { + if (it.isNotEmpty()) { + "?$it" + } else { + it + } + } + + return URI.create("$templatedUrl$queryParams") + } +} diff --git a/src/test/resources/examples/okHttpClient/models/Models.kt b/src/test/resources/examples/httpClient/models/Models.kt similarity index 97% rename from src/test/resources/examples/okHttpClient/models/Models.kt rename to src/test/resources/examples/httpClient/models/Models.kt index 7ba9cc85..76faae64 100644 --- a/src/test/resources/examples/okHttpClient/models/Models.kt +++ b/src/test/resources/examples/httpClient/models/Models.kt @@ -1,4 +1,4 @@ -package examples.okHttpClient.models +package examples.httpClient.models import com.fasterxml.jackson.`annotation`.JsonProperty import com.fasterxml.jackson.`annotation`.JsonSubTypes @@ -111,6 +111,9 @@ public data class FirstModel( @param:JsonProperty("etag") @get:JsonProperty("etag") override val etag: String? = null, + @param:JsonProperty("name") + @get:JsonProperty("name") + public val name: String? = null, @param:JsonProperty("extra_first_attr") @get:JsonProperty("extra_first_attr") public val extraFirstAttr: List? = null, diff --git a/src/test/resources/examples/httpClient/models/jdk/Models.kt b/src/test/resources/examples/httpClient/models/jdk/Models.kt new file mode 100644 index 00000000..6c81cccc --- /dev/null +++ b/src/test/resources/examples/httpClient/models/jdk/Models.kt @@ -0,0 +1,189 @@ +package examples.httpClient.jdk.models + +import com.fasterxml.jackson.`annotation`.JsonProperty +import com.fasterxml.jackson.`annotation`.JsonSubTypes +import com.fasterxml.jackson.`annotation`.JsonTypeInfo +import com.fasterxml.jackson.`annotation`.JsonValue +import java.time.OffsetDateTime +import java.util.UUID +import javax.validation.Valid +import javax.validation.constraints.NotNull +import javax.validation.constraints.Size +import kotlin.Boolean +import kotlin.Int +import kotlin.String +import kotlin.collections.List +import kotlin.collections.Map + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "model_type", + visible = true, +) +@JsonSubTypes( + JsonSubTypes.Type( + value = FirstModel::class, + name = + "first_model", + ), + JsonSubTypes.Type( + value = SecondModel::class, + name = + "second_model", + ), + JsonSubTypes.Type(value = ThirdModel::class, name = "third_model"), +) +public sealed class Content( + public open val id: String? = null, + public open val firstAttr: OffsetDateTime? = null, + public open val secondAttr: String? = null, + public open val thirdAttr: ContentThirdAttr? = null, + public open val etag: String? = null, +) { + public abstract val modelType: ContentModelType +} + +public enum class ContentModelType( + @JsonValue + public val `value`: String, +) { + FIRST_MODEL("first_model"), + SECOND_MODEL("second_model"), + THIRD_MODEL("third_model"), + ; + + public companion object { + private val mapping: Map = + values().associateBy(ContentModelType::value) + + public fun fromValue(`value`: String): ContentModelType? = mapping[value] + } +} + +public enum class ContentThirdAttr( + @JsonValue + public val `value`: String, +) { + ENUM_TYPE_1("enum_type_1"), + ENUM_TYPE_2("enum_type_2"), + ; + + public companion object { + private val mapping: Map = + values().associateBy(ContentThirdAttr::value) + + public fun fromValue(`value`: String): ContentThirdAttr? = mapping[value] + } +} + +public data class Failure( + @param:JsonProperty("traceId") + @get:JsonProperty("traceId") + @get:NotNull + public val traceId: UUID, + @param:JsonProperty("errorCode") + @get:JsonProperty("errorCode") + @get:NotNull + public val errorCode: String, + @param:JsonProperty("error") + @get:JsonProperty("error") + @get:NotNull + public val error: String, + @param:JsonProperty("subType") + @get:JsonProperty("subType") + public val subType: String? = null, +) + +public data class FirstModel( + @param:JsonProperty("id") + @get:JsonProperty("id") + override val id: String? = null, + @param:JsonProperty("first_attr") + @get:JsonProperty("first_attr") + override val firstAttr: OffsetDateTime? = null, + @param:JsonProperty("second_attr") + @get:JsonProperty("second_attr") + override val secondAttr: String? = null, + @param:JsonProperty("third_attr") + @get:JsonProperty("third_attr") + override val thirdAttr: ContentThirdAttr? = null, + @param:JsonProperty("etag") + @get:JsonProperty("etag") + override val etag: String? = null, + @param:JsonProperty("name") + @get:JsonProperty("name") + public val name: String? = null, + @param:JsonProperty("extra_first_attr") + @get:JsonProperty("extra_first_attr") + public val extraFirstAttr: List? = null, + @get:JsonProperty("model_type") + @get:NotNull + @param:JsonProperty("model_type") + override val modelType: ContentModelType = ContentModelType.FIRST_MODEL, +) : Content(id, firstAttr, secondAttr, thirdAttr, etag) + +public data class QueryResult( + @param:JsonProperty("items") + @get:JsonProperty("items") + @get:NotNull + @get:Size(min = 0) + @get:Valid + public val items: List, +) + +public data class SecondModel( + @param:JsonProperty("id") + @get:JsonProperty("id") + override val id: String? = null, + @param:JsonProperty("first_attr") + @get:JsonProperty("first_attr") + override val firstAttr: OffsetDateTime? = null, + @param:JsonProperty("second_attr") + @get:JsonProperty("second_attr") + override val secondAttr: String? = null, + @param:JsonProperty("third_attr") + @get:JsonProperty("third_attr") + override val thirdAttr: ContentThirdAttr? = null, + @param:JsonProperty("etag") + @get:JsonProperty("etag") + override val etag: String? = null, + @param:JsonProperty("extra_first_attr") + @get:JsonProperty("extra_first_attr") + public val extraFirstAttr: String? = null, + @param:JsonProperty("extra_second_attr") + @get:JsonProperty("extra_second_attr") + public val extraSecondAttr: Boolean? = null, + @get:JsonProperty("model_type") + @get:NotNull + @param:JsonProperty("model_type") + override val modelType: ContentModelType = ContentModelType.SECOND_MODEL, +) : Content(id, firstAttr, secondAttr, thirdAttr, etag) + +public data class ThirdModel( + @param:JsonProperty("id") + @get:JsonProperty("id") + override val id: String? = null, + @param:JsonProperty("first_attr") + @get:JsonProperty("first_attr") + override val firstAttr: OffsetDateTime? = null, + @param:JsonProperty("second_attr") + @get:JsonProperty("second_attr") + override val secondAttr: String? = null, + @param:JsonProperty("third_attr") + @get:JsonProperty("third_attr") + override val thirdAttr: ContentThirdAttr? = null, + @param:JsonProperty("etag") + @get:JsonProperty("etag") + override val etag: String? = null, + @param:JsonProperty("extra_first_attr") + @get:JsonProperty("extra_first_attr") + public val extraFirstAttr: OffsetDateTime? = null, + @param:JsonProperty("extra_second_attr") + @get:JsonProperty("extra_second_attr") + public val extraSecondAttr: Int? = null, + @get:JsonProperty("model_type") + @get:NotNull + @param:JsonProperty("model_type") + override val modelType: ContentModelType = ContentModelType.THIRD_MODEL, +) : Content(id, firstAttr, secondAttr, thirdAttr, etag) diff --git a/src/test/resources/examples/multiMediaType/client/jdk/ApiClient.kt b/src/test/resources/examples/multiMediaType/client/jdk/ApiClient.kt new file mode 100644 index 00000000..97977d71 --- /dev/null +++ b/src/test/resources/examples/multiMediaType/client/jdk/ApiClient.kt @@ -0,0 +1,138 @@ +package examples.multiMediaType.jdk.client + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import examples.multiMediaType.jdk.models.ContentType +import examples.multiMediaType.jdk.models.QueryResult +import examples.multiMediaType.jdk.models.SuccessResponse +import java.net.http.HttpClient +import java.net.http.HttpRequest +import kotlin.Int +import kotlin.String +import kotlin.Suppress +import kotlin.collections.List +import kotlin.collections.Map +import kotlin.jvm.Throws + +@Suppress("unused") +public class ExamplePath1Client( + private val objectMapper: ObjectMapper, + private val baseUrl: String, + private val client: HttpClient, +) { + /** + * GET example path 1 + * + * @param explodeListQueryParam + * @param queryParam2 + * @param acceptHeader + */ + @Throws(ApiException::class) + public fun getExamplePath1( + explodeListQueryParam: List? = null, + queryParam2: Int? = null, + acceptHeader: String = "application/vnd.custom.media+xml", + additionalHeaders: Map = emptyMap(), + ): ApiResponse { + val url: Url = Url("$baseUrl/example-path-1").addQueryParam( + "explode_list_query_param", + explodeListQueryParam, + true, + ) + .addQueryParam("query_param2", queryParam2) + val requestBuilder: HttpRequest.Builder = HttpRequest.newBuilder() + .uri(url.toUri()) + .version(HttpClient.Version.HTTP_1_1) + .GET() + acceptHeader?.let { requestBuilder.header("Accept", it.toString()) } + additionalHeaders.forEach { requestBuilder.header(it.key, it.value) } + return client.execute(requestBuilder.build()) + } +} + +@Suppress("unused") +public class ExamplePath2Client( + private val objectMapper: ObjectMapper, + private val baseUrl: String, + private val client: HttpClient, +) { + /** + * GET example path 1 + * + * @param explodeListQueryParam + * @param queryParam2 + * @param accept the content type accepted by the client + */ + @Throws(ApiException::class) + public fun getExamplePath2( + explodeListQueryParam: List? = null, + queryParam2: Int? = null, + accept: ContentType? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse { + val url: Url = Url("$baseUrl/example-path-2").addQueryParam( + "explode_list_query_param", + explodeListQueryParam, + true, + ) + .addQueryParam("query_param2", queryParam2) + val requestBuilder: HttpRequest.Builder = HttpRequest.newBuilder() + .uri(url.toUri()) + .version(HttpClient.Version.HTTP_1_1) + .GET() + accept?.let { requestBuilder.header("Accept", it.toString()) } + additionalHeaders.forEach { requestBuilder.header(it.key, it.value) } + return client.execute(requestBuilder.build()) + } +} + +@Suppress("unused") +public class MultipleResponseSchemasClient( + private val objectMapper: ObjectMapper, + private val baseUrl: String, + private val client: HttpClient, +) { + /** + * GET with multiple response content schemas + * + * @param accept the content type accepted by the client + */ + @Throws(ApiException::class) + public fun getMultipleResponseSchemas( + accept: ContentType? = null, + additionalHeaders: Map = emptyMap(), + ): ApiResponse { + val url: Url = Url("$baseUrl/multiple-response-schemas") + val requestBuilder: HttpRequest.Builder = HttpRequest.newBuilder() + .uri(url.toUri()) + .version(HttpClient.Version.HTTP_1_1) + .GET() + accept?.let { requestBuilder.header("Accept", it.toString()) } + additionalHeaders.forEach { requestBuilder.header(it.key, it.value) } + return client.execute(requestBuilder.build()) + } +} + +@Suppress("unused") +public class DifferentSuccessAndErrorResponseSchemaClient( + private val objectMapper: ObjectMapper, + private val baseUrl: String, + private val client: HttpClient, +) { + /** + * + */ + @Throws(ApiException::class) + public fun getDifferentSuccessAndErrorResponseSchema( + additionalHeaders: Map = + emptyMap(), + ): ApiResponse { + val url: Url = Url("$baseUrl/different-success-and-error-response-schema") + val requestBuilder: HttpRequest.Builder = HttpRequest.newBuilder() + .uri(url.toUri()) + .version(HttpClient.Version.HTTP_1_1) + .GET() + additionalHeaders.forEach { requestBuilder.header(it.key, it.value) } + return client.execute(requestBuilder.build()) + } +} diff --git a/src/test/resources/examples/multiMediaType/client/jdk/ApiModels.kt b/src/test/resources/examples/multiMediaType/client/jdk/ApiModels.kt new file mode 100644 index 00000000..ee58443b --- /dev/null +++ b/src/test/resources/examples/multiMediaType/client/jdk/ApiModels.kt @@ -0,0 +1,32 @@ +package examples.multiMediaType.jdk.client + +import java.net.http.HttpHeaders + +/** + * API 2xx success response returned by API call. + * + * @param The type of data that is deserialized from response body + */ +data class ApiResponse(val statusCode: Int, val headers: HttpHeaders, val data: T? = null) + +/** + * API non-2xx failure responses returned by API call. + */ +open class ApiException(override val message: String) : RuntimeException(message) + +/** + * API 3xx redirect response returned by API call. + */ +open class ApiRedirectException(val statusCode: Int, val headers: HttpHeaders, override val message: String) : ApiException(message) + +/** + * API 4xx failure responses returned by API call. + */ +data class ApiClientException(val statusCode: Int, val headers: HttpHeaders, override val message: String) : + ApiException(message) + +/** + * API 5xx failure responses returned by API call. + */ +data class ApiServerException(val statusCode: Int, val headers: HttpHeaders, override val message: String) : + ApiException(message) diff --git a/src/test/resources/examples/multiMediaType/client/jdk/HttpUtil.kt b/src/test/resources/examples/multiMediaType/client/jdk/HttpUtil.kt new file mode 100644 index 00000000..f3973fbf --- /dev/null +++ b/src/test/resources/examples/multiMediaType/client/jdk/HttpUtil.kt @@ -0,0 +1,132 @@ +package examples.multiMediaType.jdk.client + +import com.fasterxml.jackson.core.exc.StreamReadException +import com.fasterxml.jackson.databind.DatabindException +import com.fasterxml.jackson.databind.ObjectMapper +import java.io.IOException +import java.io.InputStream +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpRequest.BodyPublishers +import java.net.http.HttpResponse.* +import java.util.function.Supplier + +@Throws(ApiException::class) +inline fun HttpClient.execute(httpRequest: HttpRequest): ApiResponse { + val result = this.send(httpRequest, BodyHandlers.ofByteArray()) + + return when (result.statusCode()){ + in 200..299 -> ApiResponse(result.statusCode(), result.headers(), result.body()?.let { readJson(T::class.java, it) }) + in 300..399 -> throw ApiRedirectException(result.statusCode(), result.headers(), result.body()?.let { String(it) } ?: "") + in 400..499 -> throw ApiClientException(result.statusCode(), result.headers(), result.body()?.let { String(it) } ?: "") + in 500..599 -> throw ApiServerException(result.statusCode(), result.headers(), result.body()?.let { String(it) } ?: "") + else -> throw ApiException("[${result.statusCode()}]: "/*${response.errorMessage()}*/) + } +} + +fun readJson(targetType: Class?, data: ByteArray): W? { + return try { + val objectMapper = + ObjectMapper() + objectMapper.readValue(data, targetType) + } catch (_: IOException) { + null + } catch (_: StreamReadException) { + null + } catch (_: DatabindException) { + null + } +} + +fun asJSON(targetType: Class?): BodySubscriber> { + val upstream = BodySubscribers.ofInputStream() + return BodySubscribers.mapping( + upstream + ) { inputStream: InputStream -> + toSupplierOfType( + inputStream, + targetType + ) + } +} + +fun toSupplierOfType(inputStream: InputStream, targetType: Class?): Supplier { + return Supplier { + try { + inputStream.use { stream -> + val objectMapper = + ObjectMapper() + return@Supplier objectMapper.readValue(stream, targetType) + } + } catch (e: IOException) { + return@Supplier null + } + } +} + +class JsonBodyHandler(val wClass: Class): BodyHandler> { + override fun apply(responseInfo: ResponseInfo): BodySubscriber> { + return asJSON(wClass) + } +} + +object Publishers { + fun jsonBodyPublisher(obj: W): HttpRequest.BodyPublisher { + val data = ObjectMapper().writeValueAsBytes(obj) + return BodyPublishers.ofByteArray(data) + } +} + +class Url(val url: String) { + private val pathParams: MutableList> = mutableListOf() + private val queryParams: MutableList> = mutableListOf() + + fun addPathParam(param: String, value: Any?): Url { + if (value != null) { + this.pathParams.add(param to value.toString()) + } + + return this + } + + fun addQueryParam(param: String, value: List?, explode: Boolean = true): Url { + if (value != null) { + if (explode) { + value.forEach { + this.queryParams.add(param to it) + } + } else { + this.queryParams.add( + param to value.joinToString(",") + ) + } + } + + return this + } + + fun addQueryParam(param: String, value: T?): Url { + if (value != null) this.queryParams.add(param to value.toString()) + + return this + } + + fun toUri(): URI { + val templatedUrl = pathParams.fold(url) { acc, value -> + acc.replace("{${value.first}}", value.second) + } + + val queryParams = this.queryParams.joinToString("&") { + "${it.first}=${it.second}" + }.let { + if (it.isNotEmpty()) { + "?$it" + } else { + it + } + } + + return URI.create("$templatedUrl$queryParams") + } +} diff --git a/src/test/resources/examples/multiMediaType/models/jdk/Models.kt b/src/test/resources/examples/multiMediaType/models/jdk/Models.kt new file mode 100644 index 00000000..1749775b --- /dev/null +++ b/src/test/resources/examples/multiMediaType/models/jdk/Models.kt @@ -0,0 +1,212 @@ +package examples.multiMediaType.jdk.models + +import com.fasterxml.jackson.`annotation`.JsonProperty +import com.fasterxml.jackson.`annotation`.JsonSubTypes +import com.fasterxml.jackson.`annotation`.JsonTypeInfo +import com.fasterxml.jackson.`annotation`.JsonValue +import java.time.OffsetDateTime +import javax.validation.Valid +import javax.validation.constraints.NotNull +import javax.validation.constraints.Size +import kotlin.Boolean +import kotlin.Int +import kotlin.String +import kotlin.collections.List +import kotlin.collections.Map + +public data class AlternateResponseModel( + @param:JsonProperty("extra_first_attr") + @get:JsonProperty("extra_first_attr") + public val extraFirstAttr: OffsetDateTime? = null, + @param:JsonProperty("extra_second_attr") + @get:JsonProperty("extra_second_attr") + public val extraSecondAttr: Int? = null, +) + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXISTING_PROPERTY, + property = "model_type", + visible = true, +) +@JsonSubTypes( + JsonSubTypes.Type( + value = FirstModel::class, + name = + "first_model", + ), + JsonSubTypes.Type( + value = SecondModel::class, + name = + "second_model", + ), + JsonSubTypes.Type(value = ThirdModel::class, name = "third_model"), +) +public sealed class Content( + public open val id: String? = null, + public open val firstAttr: OffsetDateTime? = null, + public open val secondAttr: String? = null, + public open val thirdAttr: ContentThirdAttr? = null, + public open val etag: String? = null, +) { + public abstract val modelType: ContentModelType +} + +public enum class ContentModelType( + @JsonValue + public val `value`: String, +) { + FIRST_MODEL("first_model"), + SECOND_MODEL("second_model"), + THIRD_MODEL("third_model"), + ; + + public companion object { + private val mapping: Map = + values().associateBy(ContentModelType::value) + + public fun fromValue(`value`: String): ContentModelType? = mapping[value] + } +} + +public enum class ContentThirdAttr( + @JsonValue + public val `value`: String, +) { + ENUM_TYPE_1("enum_type_1"), + ENUM_TYPE_2("enum_type_2"), + ; + + public companion object { + private val mapping: Map = + values().associateBy(ContentThirdAttr::value) + + public fun fromValue(`value`: String): ContentThirdAttr? = mapping[value] + } +} + +public enum class ContentType( + @JsonValue + public val `value`: String, +) { + APPLICATION_JSON("application/json"), + APPLICATION_VND_CUSTOM_MEDIA_JSON("application/vnd.custom.media+json"), + ; + + public companion object { + private val mapping: Map = values().associateBy(ContentType::value) + + public fun fromValue(`value`: String): ContentType? = mapping[value] + } +} + +public data class ErrorResponse( + @param:JsonProperty("errorMessage") + @get:JsonProperty("errorMessage") + public val errorMessage: String? = null, +) + +public data class FirstModel( + @param:JsonProperty("id") + @get:JsonProperty("id") + override val id: String? = null, + @param:JsonProperty("first_attr") + @get:JsonProperty("first_attr") + override val firstAttr: OffsetDateTime? = null, + @param:JsonProperty("second_attr") + @get:JsonProperty("second_attr") + override val secondAttr: String? = null, + @param:JsonProperty("third_attr") + @get:JsonProperty("third_attr") + override val thirdAttr: ContentThirdAttr? = null, + @param:JsonProperty("etag") + @get:JsonProperty("etag") + override val etag: String? = null, + @param:JsonProperty("extra_first_attr") + @get:JsonProperty("extra_first_attr") + public val extraFirstAttr: List? = null, + @get:JsonProperty("model_type") + @get:NotNull + @param:JsonProperty("model_type") + override val modelType: ContentModelType = ContentModelType.FIRST_MODEL, +) : Content(id, firstAttr, secondAttr, thirdAttr, etag) + +public data class OtherQueryResult( + @param:JsonProperty("items") + @get:JsonProperty("items") + @get:NotNull + @get:Size(min = 0) + @get:Valid + public val items: List, +) + +public data class QueryResult( + @param:JsonProperty("items") + @get:JsonProperty("items") + @get:NotNull + @get:Size(min = 0) + @get:Valid + public val items: List, +) + +public data class SecondModel( + @param:JsonProperty("id") + @get:JsonProperty("id") + override val id: String? = null, + @param:JsonProperty("first_attr") + @get:JsonProperty("first_attr") + override val firstAttr: OffsetDateTime? = null, + @param:JsonProperty("second_attr") + @get:JsonProperty("second_attr") + override val secondAttr: String? = null, + @param:JsonProperty("third_attr") + @get:JsonProperty("third_attr") + override val thirdAttr: ContentThirdAttr? = null, + @param:JsonProperty("etag") + @get:JsonProperty("etag") + override val etag: String? = null, + @param:JsonProperty("extra_first_attr") + @get:JsonProperty("extra_first_attr") + public val extraFirstAttr: String? = null, + @param:JsonProperty("extra_second_attr") + @get:JsonProperty("extra_second_attr") + public val extraSecondAttr: Boolean? = null, + @get:JsonProperty("model_type") + @get:NotNull + @param:JsonProperty("model_type") + override val modelType: ContentModelType = ContentModelType.SECOND_MODEL, +) : Content(id, firstAttr, secondAttr, thirdAttr, etag) + +public data class SuccessResponse( + @param:JsonProperty("successMessage") + @get:JsonProperty("successMessage") + public val successMessage: String? = null, +) + +public data class ThirdModel( + @param:JsonProperty("id") + @get:JsonProperty("id") + override val id: String? = null, + @param:JsonProperty("first_attr") + @get:JsonProperty("first_attr") + override val firstAttr: OffsetDateTime? = null, + @param:JsonProperty("second_attr") + @get:JsonProperty("second_attr") + override val secondAttr: String? = null, + @param:JsonProperty("third_attr") + @get:JsonProperty("third_attr") + override val thirdAttr: ContentThirdAttr? = null, + @param:JsonProperty("etag") + @get:JsonProperty("etag") + override val etag: String? = null, + @param:JsonProperty("extra_first_attr") + @get:JsonProperty("extra_first_attr") + public val extraFirstAttr: OffsetDateTime? = null, + @param:JsonProperty("extra_second_attr") + @get:JsonProperty("extra_second_attr") + public val extraSecondAttr: Int? = null, + @get:JsonProperty("model_type") + @get:NotNull + @param:JsonProperty("model_type") + override val modelType: ContentModelType = ContentModelType.THIRD_MODEL, +) : Content(id, firstAttr, secondAttr, thirdAttr, etag) diff --git a/src/test/resources/examples/okHttpClient/client/ApiClient.kt b/src/test/resources/httpClient/client/ApiClient.kt similarity index 98% rename from src/test/resources/examples/okHttpClient/client/ApiClient.kt rename to src/test/resources/httpClient/client/ApiClient.kt index ba8aba4c..b5c22fd4 100644 --- a/src/test/resources/examples/okHttpClient/client/ApiClient.kt +++ b/src/test/resources/httpClient/client/ApiClient.kt @@ -1,9 +1,9 @@ -package examples.okHttpClient.client +package examples.httpClient.client import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonTypeRef -import examples.okHttpClient.models.Content -import examples.okHttpClient.models.FirstModel +import examples.httpClient.models.Content +import examples.httpClient.models.FirstModel import examples.okHttpClient.models.QueryResult import okhttp3.Headers import okhttp3.HttpUrl diff --git a/src/test/resources/httpClient/client/ApiModels.kt b/src/test/resources/httpClient/client/ApiModels.kt new file mode 100644 index 00000000..7f1efce1 --- /dev/null +++ b/src/test/resources/httpClient/client/ApiModels.kt @@ -0,0 +1,27 @@ +package examples.okHttpClient.client + +import okhttp3.Headers + +/** + * API 2xx success response returned by API call. + * + * @param The type of data that is deserialized from response body + */ +data class ApiResponse(val statusCode: Int, val headers: Headers, val data: T? = null) + +/** + * API non-2xx failure responses returned by API call. + */ +open class ApiException(override val message: String) : RuntimeException(message) + +/** + * API 4xx failure responses returned by API call. + */ +data class ApiClientException(val statusCode: Int, val headers: Headers, override val message: String) : + ApiException(message) + +/** + * API 5xx failure responses returned by API call. + */ +data class ApiServerException(val statusCode: Int, val headers: Headers, override val message: String) : + ApiException(message) diff --git a/src/test/resources/examples/okHttpClient/client/ApiService.kt b/src/test/resources/httpClient/client/ApiService.kt similarity index 100% rename from src/test/resources/examples/okHttpClient/client/ApiService.kt rename to src/test/resources/httpClient/client/ApiService.kt diff --git a/src/test/resources/examples/okHttpClient/client/HttpResilience4jUtil.kt b/src/test/resources/httpClient/client/HttpResilience4jUtil.kt similarity index 100% rename from src/test/resources/examples/okHttpClient/client/HttpResilience4jUtil.kt rename to src/test/resources/httpClient/client/HttpResilience4jUtil.kt diff --git a/src/test/resources/httpClient/client/HttpUtil.kt b/src/test/resources/httpClient/client/HttpUtil.kt new file mode 100644 index 00000000..88e71955 --- /dev/null +++ b/src/test/resources/httpClient/client/HttpUtil.kt @@ -0,0 +1,65 @@ +package examples.okHttpClient.client + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import okhttp3.FormBody +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody + +@Suppress("unused") +fun HttpUrl.Builder.queryParam(key: String, value: T?): HttpUrl.Builder = this.apply { + if (value != null) this.addQueryParameter(key, value.toString()) +} + +@Suppress("unused") +fun FormBody.Builder.formParam(key: String, value: T?): FormBody.Builder = this.apply { + if (value != null) this.add(key, value.toString()) +} + +@Suppress("unused") +fun HttpUrl.Builder.queryParam(key: String, values: List?, explode: Boolean = true) = this.apply { + if (values != null) { + if (explode) values.forEach { addQueryParameter(key, it) } + else addQueryParameter(key, values.joinToString(",")) + } +} + +@Suppress("unused") +fun Headers.Builder.header(key: String, value: String?): Headers.Builder = this.apply { + if (value != null) this.add(key, value) +} + +@Throws(ApiException::class) +fun Request.execute(client: OkHttpClient, objectMapper: ObjectMapper, typeRef: TypeReference): ApiResponse = + client.newCall(this).execute().use { response -> + when { + response.isSuccessful -> + ApiResponse(response.code, response.headers, response.body?.deserialize(objectMapper, typeRef)) + response.isBadRequest() -> + throw ApiClientException(response.code, response.headers, response.errorMessage()) + response.isServerError() -> + throw ApiServerException(response.code, response.headers, response.errorMessage()) + else -> throw ApiException("[${response.code}]: ${response.errorMessage()}") + } + } + +@Suppress("unused") +fun String.pathParam(vararg params: Pair): String = params.asSequence() + .joinToString { param -> + this.replace(param.first, param.second.toString()) + } + +fun ResponseBody.deserialize(objectMapper: ObjectMapper, typeRef: TypeReference): T? = + this.string().isNotBlankOrNull()?.let { objectMapper.readValue(it, typeRef) } + +fun String?.isNotBlankOrNull() = if (this.isNullOrBlank()) null else this + +private fun Response.errorMessage(): String = this.body?.string() ?: this.message + +private fun Response.isBadRequest(): Boolean = this.code in 400..499 + +private fun Response.isServerError(): Boolean = this.code in 500..599 diff --git a/src/test/resources/examples/okHttpClient/client/OAuth.kt b/src/test/resources/httpClient/client/OAuth.kt similarity index 100% rename from src/test/resources/examples/okHttpClient/client/OAuth.kt rename to src/test/resources/httpClient/client/OAuth.kt