Repository: cjbooms/fabrikt
Pull request #1 (2020-05-21 14:48:34)

Adding Okhttp Client Code generation and a test harness
Adding Okhttp Client Code generation and a test harness


Patch:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
deleted file mode 100644
index 43d608c..0000000
--- a/.github/workflows/build.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-# This is a basic workflow to help you get started with Actions
-
-name: CI
-
-# Controls when the action will run. Triggers the workflow on push or pull request
-# events but only for the master branch
-on:
-  push:
-    branches: [ master ]
-  pull_request:
-    branches: [ master ]
-
-# A workflow run is made up of one or more jobs that can run sequentially or in parallel
-jobs:
-  # This workflow contains a single job called "build"
-  build:
-    # The type of runner that the job will run on
-    runs-on: ubuntu-latest
-
-    # Steps represent a sequence of tasks that will be executed as part of the job
-    steps:
-    # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
-    - uses: actions/checkout@v2
-
-    # Runs a single command using the runners shell
-    - name: Build Project
-      run: ./gradlew clean build
-
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGen.kt b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGen.kt
index 4e3e5ba..5a0cd2d 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGen.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGen.kt
@@ -1,6 +1,7 @@
 package com.cjbooms.fabrikt.cli
 
 import com.beust.jcommander.ParameterException
+import com.cjbooms.fabrikt.configurations.Packages
 import com.cjbooms.fabrikt.model.SourceApi
 import java.nio.file.Files
 import java.nio.file.Path
@@ -40,17 +41,10 @@ object CodeGen {
 
         logger.info("Generating code and dumping to $outputDir/")
 
-        val sourceApi = SourceApi.create(
-                        baseApi = suppliedApi,
-                        apiFragments = apiFragments,
-                        baseDir = baseDir
-                )
-
-        CodeGenerator.generate(
-                basePackage,
-                sourceApi,
-                codeGenTypes,
-                clientOptions
-        ).forEach { it.writeFileTo(outputDir.toFile()) }
+        val packages = Packages(basePackage)
+        val sourceApi = SourceApi.create(suppliedApi, apiFragments, baseDir)
+        val generator = CodeGenerator(packages, sourceApi, codeGenTypes, clientOptions)
+
+        generator.generate().forEach { it.writeFileTo(outputDir.toFile()) }
     }
 }
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenerator.kt
index bcdfecc..2c57b47 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenerator.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenerator.kt
@@ -1,19 +1,37 @@
 package com.cjbooms.fabrikt.cli
 
+import com.cjbooms.fabrikt.cli.CodeGenerationType.CLIENT
+import com.cjbooms.fabrikt.cli.CodeGenerationType.HTTP_MODELS
+import com.cjbooms.fabrikt.configurations.Packages
 import com.cjbooms.fabrikt.generators.JacksonModelGenerator
+import com.cjbooms.fabrikt.generators.OkHttpClientGenerator
 import com.cjbooms.fabrikt.model.GeneratedFile
 import com.cjbooms.fabrikt.model.KotlinSourceSet
 import com.cjbooms.fabrikt.model.SourceApi
 
-object CodeGenerator {
+class CodeGenerator(
+    private val packages: Packages,
+    private val sourceApi: SourceApi,
+    private val generationTypes: Set<CodeGenerationType>,
+    private val codeGenOptions: Set<ClientCodeGenOptionType>
+) {
 
-    fun generate(
-        basePackage: String,
-        sourceApi: SourceApi,
-        generationTypes: Set<CodeGenerationType>,
-        codeGenOptions: Set<ClientCodeGenOptionType>
-    ): Collection<GeneratedFile> {
-        val models = JacksonModelGenerator(basePackage, sourceApi).generate()
-        return setOf(KotlinSourceSet(models.files))
+    fun generate(): Collection<GeneratedFile> = generationTypes.map(::generateCode).flatten()
+
+    private fun generateCode(generationType: CodeGenerationType): Collection<GeneratedFile> =
+        when (generationType) {
+            CLIENT -> generateClient()
+            HTTP_MODELS -> generateModels()
+        }
+
+    private fun generateModels(): Collection<GeneratedFile> =
+            setOf(KotlinSourceSet(JacksonModelGenerator(packages.base, sourceApi).generate().files))
+
+    private fun generateClient(): Collection<GeneratedFile> {
+        val clientGenerator = OkHttpClientGenerator(packages, sourceApi)
+        val models = generateModels()
+        val client = clientGenerator.generate(codeGenOptions).files
+        val lib = clientGenerator.generateLibrary(codeGenOptions)
+        return setOf(KotlinSourceSet(client)).plus(lib).plus(models)
     }
 }
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/configurations/Packages.kt b/src/main/kotlin/com/cjbooms/fabrikt/configurations/Packages.kt
new file mode 100644
index 0000000..70679b3
--- /dev/null
+++ b/src/main/kotlin/com/cjbooms/fabrikt/configurations/Packages.kt
@@ -0,0 +1,9 @@
+package com.cjbooms.fabrikt.configurations
+
+import com.cjbooms.fabrikt.model.Destinations.clientPackage
+import com.cjbooms.fabrikt.model.Destinations.modelsPackage
+
+class Packages(val base: String) {
+    val models: String = modelsPackage(base)
+    val client: String = clientPackage(base)
+}
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/ClientGeneratorUtils.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/ClientGeneratorUtils.kt
new file mode 100644
index 0000000..3a2c8ba
--- /dev/null
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/ClientGeneratorUtils.kt
@@ -0,0 +1,136 @@
+package com.cjbooms.fabrikt.generators
+
+import com.cjbooms.fabrikt.configurations.Packages
+import com.cjbooms.fabrikt.generators.JacksonModelGenerator.Companion.toModelType
+import com.cjbooms.fabrikt.model.ClientType
+import com.cjbooms.fabrikt.model.KotlinTypeInfo
+import com.reprezen.kaizen.oasparser.model3.MediaType
+import com.reprezen.kaizen.oasparser.model3.Operation
+import com.reprezen.kaizen.oasparser.model3.Parameter
+import com.reprezen.kaizen.oasparser.model3.RequestBody
+import com.reprezen.kaizen.oasparser.model3.Response
+import com.reprezen.kaizen.oasparser.model3.Schema
+import com.squareup.kotlinpoet.ClassName
+import com.squareup.kotlinpoet.CodeBlock
+import com.squareup.kotlinpoet.FunSpec
+import com.squareup.kotlinpoet.ParameterSpec
+import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
+import com.squareup.kotlinpoet.PropertySpec
+import com.squareup.kotlinpoet.TypeName
+import com.squareup.kotlinpoet.TypeSpec
+import com.squareup.kotlinpoet.asTypeName
+
+object ClientGeneratorUtils {
+    /**
+     * It resolves the API operation body request to its body type. If multiple content medias are found, then it will
+     * resolve to the schema reference of the first media type, otherwise it assumes no request body defined for
+     * the given operation.
+     */
+    fun RequestBody.toBodyParameterSpec(basePackage: String): List<ParameterSpec> =
+        this.toBodyRequestSchema().map {
+            val modelType = toModelType(
+                basePackage,
+                KotlinTypeInfo.from(it),
+                this.isRequired
+            )
+            ParameterSpec.builder(
+                it.toVarName(),
+                modelType
+            ).build()
+        }
+
+    /**
+     * It resolves the given either `query` of `form` parameter to its corresponding function param specification.
+     */
+    fun Parameter.toParameterSpec(basePackage: String): ParameterSpec =
+        ParameterSpec.builder(
+            this.name.toKCodeName(),
+            toModelType(basePackage, KotlinTypeInfo.from(this.schema), this.isRequired)
+        ).build()
+
+    /**
+     * It resolves the API operation response to its body type. It iterates over all non-default responses
+     * by filtering those ones with content media. If multiple medias are found, then it will resolve to the schema
+     * reference of the first media type found, otherwise it'll resolve to Unit by assuming no response body
+     * for the given operation.
+     */
+    fun Operation.toReturnType(packages: Packages): TypeName {
+        val returnType = this.getPrimaryAcceptMediaType()?.let {
+            toModelType(
+                packages.base,
+                    KotlinTypeInfo.from(it.value.schema)
+            )
+        } ?: Unit::class.asTypeName()
+        return "ApiResponse".toClassName(packages.client)
+            .parameterizedBy(returnType.copy(nullable = true))
+    }
+
+    /**
+     * It converts any string to a variable or function name by removing all non-letter-or-digit characters and transforms
+     * the in-between resulting words into `camel case`.
+     * e.g. `GET /my-resource/path/{param}` -> getMyResourcePathParam
+     */
+    fun String.toKCodeName(): String {
+        val delimiters = this.partition(Char::isLetterOrDigit).second.toCharArray().map(Char::toString).toTypedArray()
+        return this.splitToSequence(*delimiters)
+            .mapNotNull(String::capitalize)
+            .joinToString("")
+            .decapitalize()
+    }
+
+    /**
+     * It resolves the schema for the given API operation. If multiple content medias are found, then it will
+     * resolve to the schema reference of the first media type.
+     */
+    fun RequestBody.toBodyRequestSchema(): List<Schema> =
+        listOfNotNull(this.getPrimaryContentMediaType()?.value?.schema)
+
+    fun Operation.toKdoc(): CodeBlock {
+        val kdoc = CodeBlock.builder().add("${this.summary.orEmpty()}\n${this.description.orEmpty()}\n")
+
+        this.parameters.forEach {
+            kdoc.add("@param %L %L\n", it.name.toKCodeName(), it.description.orEmpty()).build()
+        }
+
+        return kdoc.build()
+    }
+
+    fun TypeSpec.Builder.primaryPropertiesConstructor(vararg properties: PropertySpec): TypeSpec.Builder {
+        val propertySpecs = properties.map { it.toBuilder().initializer(it.name).build() }
+        val parameters = propertySpecs.map { ParameterSpec.builder(it.name, it.type).build() }
+        val constructor = FunSpec.constructorBuilder().addParameters(parameters).build()
+        return this.primaryConstructor(constructor).addProperties(propertySpecs)
+    }
+
+    fun simpleClientName(resourceName: String) = "$resourceName${ClientType.SIMPLE_CLIENT_SUFFIX}"
+
+    fun enhancedClientName(resourceName: String) = "$resourceName${ClientType.ENHANCED_CLIENT_SUFFIX}"
+
+    fun functionName(resource: String, verb: String) = "$verb $resource".toKCodeName()
+
+    fun Schema.toVarName() = this.name?.toKCodeName() ?: this.toClassName().simpleName.toKCodeName()
+
+    private fun Schema.toClassName() = KotlinTypeInfo.from(this).modelKClass.asTypeName()
+
+    fun String.toClassName(basePackage: String) = ClassName(packageName = basePackage, simpleName = this)
+
+    fun RequestBody.getPrimaryContentMediaType(): Map.Entry<String, MediaType>? =
+        this.contentMediaTypes.entries.firstOrNull()
+
+    fun Response.getPrimaryAcceptMediaType(): Map.Entry<String, MediaType>? =
+        this.contentMediaTypes.entries.firstOrNull()
+
+    fun Operation.getBodyResponses(): List<Response> =
+        this.responses.filter { it.key != "default" }.values.filter(Response::hasContentMediaTypes)
+
+    fun Operation.getPrimaryAcceptMediaType(): Map.Entry<String, MediaType>? =
+        this.getBodyResponses().map { response -> response.getPrimaryAcceptMediaType() }.firstOrNull()
+
+    fun Operation.getPathParams(): List<Parameter> = this.filterParams("path")
+
+    fun Operation.getQueryParams(): List<Parameter> = this.filterParams("query")
+
+    fun Operation.getHeaderParams(): List<Parameter> = this.filterParams("header")
+
+    private fun Operation.filterParams(paramType: String): List<Parameter> = this.parameters.filter { it.`in` == paramType }
+}
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpClientGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpClientGenerator.kt
new file mode 100644
index 0000000..03ff2b6
--- /dev/null
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpClientGenerator.kt
@@ -0,0 +1,29 @@
+package com.cjbooms.fabrikt.generators
+
+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
+
+class OkHttpClientGenerator(
+    packages: Packages,
+    api: SourceApi
+) {
+    private val simpleClientGenerator = OkHttpSimpleClientGenerator(packages, api)
+    private val enhancedClientGenerator = OkHttpEnhancedClientGenerator(packages, api)
+
+    fun generate(options: Set<ClientCodeGenOptionType>): Clients {
+        val simpleClient = simpleClientGenerator.generateDynamicClientCode()
+        val enhancedClient = enhancedClientGenerator.generateDynamicClientCode(options)
+
+        return Clients(enhancedClient.plus(simpleClient).toSet())
+    }
+
+    fun generateLibrary(options: Set<ClientCodeGenOptionType>): Collection<GeneratedFile> {
+        val simpleClientLibrary = simpleClientGenerator.generateLibrary()
+        val enhancedClientLibrary = enhancedClientGenerator.generateLibrary(options)
+
+        return simpleClientLibrary.plus(enhancedClientLibrary)
+    }
+}
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpEnhancedClientGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpEnhancedClientGenerator.kt
new file mode 100644
index 0000000..c724072
--- /dev/null
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpEnhancedClientGenerator.kt
@@ -0,0 +1,171 @@
+package com.cjbooms.fabrikt.generators
+
+import com.cjbooms.fabrikt.cli.ClientCodeGenOptionType
+import com.cjbooms.fabrikt.configurations.Packages
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.enhancedClientName
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.functionName
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.simpleClientName
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.toBodyParameterSpec
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.toClassName
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.toKCodeName
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.toParameterSpec
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.toReturnType
+import com.cjbooms.fabrikt.model.ClientType
+import com.cjbooms.fabrikt.model.Destinations
+import com.cjbooms.fabrikt.model.GeneratedFile
+import com.cjbooms.fabrikt.model.HandlebarsTemplates
+import com.cjbooms.fabrikt.model.SimpleFile
+import com.cjbooms.fabrikt.model.SourceApi
+import com.cjbooms.fabrikt.util.KaizenParserExtensions.routeToPaths
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.github.javaparser.utils.CodeGenerationUtils
+import com.reprezen.kaizen.oasparser.model3.Operation
+import com.squareup.kotlinpoet.AnnotationSpec
+import com.squareup.kotlinpoet.CodeBlock
+import com.squareup.kotlinpoet.FunSpec
+import com.squareup.kotlinpoet.KModifier
+import com.squareup.kotlinpoet.ParameterSpec
+import com.squareup.kotlinpoet.PropertySpec
+import com.squareup.kotlinpoet.TypeSpec
+import com.squareup.kotlinpoet.asTypeName
+
+class OkHttpEnhancedClientGenerator(
+    private val packages: Packages,
+    private val api: SourceApi
+) {
+
+    fun generateDynamicClientCode(options: Set<ClientCodeGenOptionType>): Collection<ClientType> =
+        options.ifResilience4jIsEnabled {
+            generateResilience4jClientCode()
+        }
+
+    private fun generateResilience4jClientCode(): Collection<ClientType> {
+        return api.openApi3.routeToPaths().map { (resourceName, paths) ->
+            val funSpecs: List<FunSpec> = paths.flatMap { (resource, path) ->
+                path.operations.map { (verb, operation) ->
+                    FunSpec
+                        .builder(functionName(resource, verb))
+                        .addModifiers(KModifier.PUBLIC)
+                        .addAnnotation(
+                            AnnotationSpec.builder(Throws::class)
+                                .addMember("%T::class", "ApiException".toClassName(packages.client)).build()
+                        )
+                        .addParameters(operation.requestBody.toBodyParameterSpec(packages.base))
+                        .addParameters(operation.parameters.map { it.toParameterSpec(packages.base) })
+                        .addCode(Resilience4jClientOperationStatement(packages, resource, verb, operation).toStatement())
+                        .returns(operation.toReturnType(packages))
+                        .build()
+                }
+            }
+
+            generateCircuitBreakerClientCode(resourceName, funSpecs)
+        }.toSet()
+    }
+
+    private fun generateCircuitBreakerClientCode(resourceName: String, funSpecs: List<FunSpec>): ClientType {
+        val apiClientClassName = simpleClientName(resourceName).toClassName(packages.client)
+        val circuitBreakerRegistryClassName =
+            "CircuitBreakerRegistry".toClassName("io.github.resilience4j.circuitbreaker")
+
+        val configurableCircuitBreakerNameProperty = PropertySpec.builder("circuitBreakerName", String::class.asTypeName())
+            .mutable()
+            .initializer("%S", apiClientClassName.simpleName.toKCodeName())
+            .build()
+
+        val clientProperty = PropertySpec.builder("apiClient", apiClientClassName, KModifier.PRIVATE)
+            .initializer("%T(objectMapper, baseUrl, client)", apiClientClassName)
+            .build()
+
+        val circuitBreakerRegistryProperty =
+            PropertySpec.builder("circuitBreakerRegistry", circuitBreakerRegistryClassName, KModifier.PRIVATE)
+                .initializer("circuitBreakerRegistry")
+                .build()
+
+        val constructor = FunSpec.constructorBuilder()
+            .addParameters(
+                listOf(
+                    ParameterSpec.builder(
+                        circuitBreakerRegistryProperty.name,
+                        circuitBreakerRegistryProperty.type
+                    ).build(),
+                    ParameterSpec.builder("objectMapper", ObjectMapper::class.asTypeName()).build(),
+                    ParameterSpec.builder("baseUrl", String::class.asTypeName()).build(),
+                    ParameterSpec.builder("client", "OkHttpClient".toClassName("okhttp3")).build()
+                )
+            ).build()
+
+        val clientType = TypeSpec.classBuilder(enhancedClientName(resourceName))
+            .addKdoc(addClientKDoc())
+            .primaryConstructor(constructor)
+            .addProperty(circuitBreakerRegistryProperty)
+            .addAnnotation(AnnotationSpec.builder(Suppress::class).addMember("%S", "unused").build())
+            .addProperty(configurableCircuitBreakerNameProperty)
+            .addProperty(clientProperty)
+            .addFunctions(funSpecs)
+            .build()
+
+        return ClientType(clientType, packages.base)
+    }
+
+    fun generateLibrary(options: Set<ClientCodeGenOptionType>): Collection<GeneratedFile> {
+        val codeDir = Destinations.MAIN_KT_SRC.resolve(CodeGenerationUtils.packageToPath(packages.base))
+        val clientDir = codeDir.resolve("client")
+        return listOfNotNull(
+            applyTemplateIfOptionIsEnabled(options, ClientCodeGenOptionType.RESILIENCE4J) {
+                HandlebarsTemplates.applyTemplate(
+                    HandlebarsTemplates.clientHttpResilience4jUtils,
+                    packages,
+                    clientDir,
+                    "HttpResilience4jUtil.kt"
+                )
+            }
+        )
+    }
+
+    private fun addClientKDoc(): CodeBlock =
+        CodeBlock.builder()
+            .add("The circuit breaker registry should have the proper configuration to correctly action on circuit breaker ")
+            .add("transitions based on the client exceptions [ApiClientException], [ApiServerException] and [IOException].\n")
+            .add("\n@see ApiClientException")
+            .add("\n@see ApiServerException")
+            .build()
+
+    private fun applyTemplateIfOptionIsEnabled(
+        options: Set<ClientCodeGenOptionType>,
+        target: ClientCodeGenOptionType,
+        applyTemplate: () -> SimpleFile
+    ): SimpleFile? =
+        options.find { it == target }?.let { applyTemplate() }
+
+    private fun Set<ClientCodeGenOptionType>.ifResilience4jIsEnabled(
+        block: (Set<ClientCodeGenOptionType>) -> Collection<ClientType>
+    ): Collection<ClientType> =
+        if (this.any { it == ClientCodeGenOptionType.RESILIENCE4J }) block(this) else emptySet()
+}
+
+class Resilience4jClientOperationStatement(
+    private val packages: Packages,
+    private val resource: String,
+    private val verb: String,
+    private val operation: Operation
+) {
+    fun toStatement(): CodeBlock =
+        CodeBlock.builder()
+            .addCircuitBreakerStatement()
+            .build()
+
+    private fun CodeBlock.Builder.addCircuitBreakerStatement(): CodeBlock.Builder {
+        this.add("return \n withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) {\n")
+        this.addClientCallStatement()
+        this.add("\n}\n")
+        return this
+    }
+
+    private fun CodeBlock.Builder.addClientCallStatement(): CodeBlock.Builder {
+        val params = operation.requestBody.toBodyParameterSpec(packages.base).plus(
+            operation.parameters.map { it.toParameterSpec(packages.base) }
+        )
+        this.add("apiClient.%N(%L)", functionName(resource, verb), params.joinToString(",") { it.name })
+        return this
+    }
+}
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpSimpleClientGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpSimpleClientGenerator.kt
new file mode 100644
index 0000000..310cb54
--- /dev/null
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpSimpleClientGenerator.kt
@@ -0,0 +1,197 @@
+package com.cjbooms.fabrikt.generators
+
+import com.cjbooms.fabrikt.configurations.Packages
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.functionName
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.getBodyResponses
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.getHeaderParams
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.getPathParams
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.getPrimaryAcceptMediaType
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.getPrimaryContentMediaType
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.getQueryParams
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.primaryPropertiesConstructor
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.simpleClientName
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.toBodyParameterSpec
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.toBodyRequestSchema
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.toClassName
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.toKCodeName
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.toKdoc
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.toParameterSpec
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.toReturnType
+import com.cjbooms.fabrikt.generators.ClientGeneratorUtils.toVarName
+import com.cjbooms.fabrikt.generators.JacksonModelGenerator.Companion.toModelType
+import com.cjbooms.fabrikt.model.ClientType
+import com.cjbooms.fabrikt.model.Destinations
+import com.cjbooms.fabrikt.model.GeneratedFile
+import com.cjbooms.fabrikt.model.HandlebarsTemplates
+import com.cjbooms.fabrikt.model.KotlinTypeInfo
+import com.cjbooms.fabrikt.model.SourceApi
+import com.cjbooms.fabrikt.util.KaizenParserExtensions.routeToPaths
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.github.javaparser.utils.CodeGenerationUtils
+import com.reprezen.kaizen.oasparser.model3.Operation
+import com.squareup.kotlinpoet.AnnotationSpec
+import com.squareup.kotlinpoet.CodeBlock
+import com.squareup.kotlinpoet.FunSpec
+import com.squareup.kotlinpoet.KModifier
+import com.squareup.kotlinpoet.PropertySpec
+import com.squareup.kotlinpoet.TypeSpec
+import com.squareup.kotlinpoet.asTypeName
+
+class OkHttpSimpleClientGenerator(
+    private val packages: Packages,
+    private val api: SourceApi
+) {
+
+    fun generateDynamicClientCode(): Collection<ClientType> {
+        return api.openApi3.routeToPaths().map { (resourceName, paths) ->
+            val funcSpecs: List<FunSpec> = paths.flatMap { (resource, path) ->
+                path.operations.map { (verb, operation) ->
+                    FunSpec
+                        .builder(functionName(resource, verb))
+                        .addModifiers(KModifier.PUBLIC)
+                        .addKdoc(operation.toKdoc())
+                        .addAnnotation(AnnotationSpec.builder(Throws::class)
+                            .addMember("%T::class", "ApiException".toClassName(packages.client)).build()
+                        )
+                        .addParameters(operation.requestBody.toBodyParameterSpec(packages.base))
+                        .addParameters(operation.parameters.map { it.toParameterSpec(packages.base) })
+                        .addCode(SimpleClientOperationStatement(packages, resource, verb, operation).toStatement())
+                        .returns(operation.toReturnType(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", "OkHttpClient".toClassName("okhttp3"), KModifier.PRIVATE).build()
+                )
+                .addAnnotation(AnnotationSpec.builder(Suppress::class).addMember("%S", "unused").build())
+                .addFunctions(funcSpecs)
+                .build()
+
+            ClientType(clientType, packages.base)
+        }.toSet()
+    }
+
+    fun generateLibrary(): Collection<GeneratedFile> {
+        val codeDir = Destinations.MAIN_KT_SRC.resolve(CodeGenerationUtils.packageToPath(packages.base))
+        val clientDir = codeDir.resolve("client")
+        return setOf(
+            HandlebarsTemplates.applyTemplate(
+                HandlebarsTemplates.clientApiModels,
+                packages,
+                clientDir,
+                "ApiModels.kt"
+            ),
+            HandlebarsTemplates.applyTemplate(
+                HandlebarsTemplates.clientHttpUtils,
+                packages,
+                clientDir,
+                "HttpUtil.kt"
+            ),
+            HandlebarsTemplates.applyTemplate(
+                HandlebarsTemplates.clientOAuth,
+                packages,
+                clientDir,
+                "OAuth.kt"
+            ),
+            HandlebarsTemplates.applyTemplate(
+                HandlebarsTemplates.clientLoggingInterceptor,
+                packages,
+                clientDir,
+                "LoggingInterceptor.kt"
+            )
+        )
+    }
+}
+
+data class SimpleClientOperationStatement(
+    private val packages: Packages,
+    private val resource: String,
+    private val verb: String,
+    private val operation: Operation
+) {
+    fun toStatement(): CodeBlock =
+        CodeBlock.builder()
+            .addUrlStatement()
+            .addPathParamStatement()
+            .addQueryParamStatement()
+            .addHeaderParamStatement()
+            .addRequestStatement()
+            .addRequestExecutionStatement()
+            .build()
+
+    private fun CodeBlock.Builder.addUrlStatement(): CodeBlock.Builder {
+        this.add("val httpUrl: %T = \"%L\"", "HttpUrl".toClassName("okhttp3"), "\$baseUrl$resource")
+        return this
+    }
+
+    private fun CodeBlock.Builder.addPathParamStatement(): CodeBlock.Builder {
+        operation.getPathParams().map { this.add("\n.pathParam(%S to %N)", "{${it.name}}", it.name.toKCodeName()) }
+        this.add("\n.%T()\n.newBuilder()", "toHttpUrl".toClassName("okhttp3.HttpUrl.Companion"))
+        return this
+    }
+
+    /**
+     * By now it's only supported `form` style query params with a comma-separated delimiter. See [Open API 3.0
+     * serialization](https://swagger.io/docs/specification/serialization) query parameters style values
+     */
+    private fun CodeBlock.Builder.addQueryParamStatement(): CodeBlock.Builder {
+        operation.getQueryParams().map {
+            when (KotlinTypeInfo.from(it.schema)) {
+                is KotlinTypeInfo.Array -> this.add("\n.%T(%S, %N, %S)", "queryParam".toClassName(packages.client), it.name, it.name.toKCodeName(), ",")
+                else -> this.add("\n.%T(%S, %N)", "queryParam".toClassName(packages.client), it.name, it.name.toKCodeName())
+            }
+        }
+        return this.add("\n.build()\n")
+    }
+
+    private fun CodeBlock.Builder.addHeaderParamStatement(): CodeBlock.Builder {
+        this.add("\nval httpHeaders: %T = Headers.Builder()", "Headers".toClassName("okhttp3"))
+        operation.getHeaderParams().map {
+            this.add("\n.%T(%S, %N)", "header".toClassName(packages.client), it.name, it.name.toKCodeName())
+        }
+        operation.getBodyResponses().firstOrNull()?.let {
+            this.add("\n.%T(%S, %S)", "header".toClassName(packages.client), "Accept", "application/json")
+        }
+        return this.add("\n.build()\n")
+    }
+
+    private fun CodeBlock.Builder.addRequestStatement(): CodeBlock.Builder {
+        this.add("\nval request: %T = Request.Builder()", "Request".toClassName("okhttp3"))
+        this.add("\n.url(httpUrl)\n.headers(httpHeaders)")
+        when (val op = verb.toUpperCase()) {
+            "PUT" -> this.addRequestSerializerStatement("put")
+            "POST" -> this.addRequestSerializerStatement("post")
+            "PATCH" -> this.addRequestSerializerStatement("patch")
+            "HEAD" -> this.add("\n.head()")
+            "GET" -> this.add("\n.get()")
+            "DELETE" -> this.add("\n.delete()")
+            else -> throw NotImplementedError("API operation $op is not supported")
+        }
+        return this.add("\n.build()\n")
+    }
+
+    private fun CodeBlock.Builder.addRequestExecutionStatement(): CodeBlock.Builder {
+        val returnType = operation.getPrimaryAcceptMediaType()?.value?.schema?.let {
+            toModelType(packages.base, KotlinTypeInfo.from(it))
+        }
+        return this.add("\nreturn request.execute(client, objectMapper, %T::class.java)", returnType ?: Unit::class.asTypeName())
+    }
+
+    private fun CodeBlock.Builder.addRequestSerializerStatement(verb: String) {
+        val requestBody = operation.requestBody
+        requestBody.toBodyRequestSchema().firstOrNull()?.let {
+            this.add(
+                "\n.%N(objectMapper.writeValueAsString(%N).%T(%S.%T()))",
+                verb,
+                it.toVarName(),
+                "toRequestBody".toClassName("okhttp3.RequestBody.Companion"),
+                requestBody.getPrimaryContentMediaType()?.key,
+                "toMediaType".toClassName("okhttp3.MediaType.Companion")
+            )
+        } ?: this.add("\n%N()", verb)
+    }
+}
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/PropertyUtils.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/PropertyUtils.kt
index 6f9e291..c9f0304 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/generators/PropertyUtils.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/PropertyUtils.kt
@@ -124,7 +124,7 @@ object PropertyUtils {
     /**
      * Current Open API v3 Spec validation keys:
      *
-     *   multipleOf             - Not Supported. No equivalent javax validation. We could add our own as a 
+     *   multipleOf             - Not Supported. No equivalent javax validation. We could add our own as a
      *   maximum                - Supported
      *   minimum                - Supported
      *   exclusiveMaximum       - Support - Used to mark the above as exclusive checks
@@ -134,10 +134,10 @@ object PropertyUtils {
      *   pattern                - Supported
      *   maxItems               - Supported
      *   minItems               - Supported
-     *   uniqueItems            - Not Supported. No equivalent javax validation. We could add our own as a 
-     *   maxProperties          - Not Supported. No equivalent javax validation. We could add our own as a 
-     *   minProperties          - Not Supported. No equivalent javax validation. We could add our own as a 
-     *   enum                   - Not currently supported. Possible to do as a regex maybe. 
+     *   uniqueItems            - Not Supported. No equivalent javax validation. We could add our own as a
+     *   maxProperties          - Not Supported. No equivalent javax validation. We could add our own as a
+     *   minProperties          - Not Supported. No equivalent javax validation. We could add our own as a
+     *   enum                   - Not currently supported. Possible to do as a regex maybe.
      */
     private fun PropertySpec.Builder.addValidationAnnotations(info: PropertyInfo) {
         if (info.isRequired) addAnnotation(ValidationAnnotations.NON_NULL_ANNOTATION)
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/model/HandlebarsTemplates.kt b/src/main/kotlin/com/cjbooms/fabrikt/model/HandlebarsTemplates.kt
new file mode 100644
index 0000000..98edcf4
--- /dev/null
+++ b/src/main/kotlin/com/cjbooms/fabrikt/model/HandlebarsTemplates.kt
@@ -0,0 +1,68 @@
+package com.cjbooms.fabrikt.model
+
+import com.github.javaparser.utils.CodeGenerationUtils.packageToPath
+import com.github.jknack.handlebars.Handlebars
+import com.github.jknack.handlebars.Helper
+import com.github.jknack.handlebars.Options
+import com.github.jknack.handlebars.Template
+import com.github.jknack.handlebars.helper.ConditionalHelpers
+import com.github.jknack.handlebars.helper.StringHelpers
+import java.nio.file.Path
+
+object HandlebarsTemplates {
+
+    private val handlebars = Handlebars()
+        .registerHelper("lower", StringHelpers.lower)
+        .registerHelper("capitalizeFirst", StringHelpers.capitalizeFirst)
+        .registerHelper("eq", ConditionalHelpers.eq)
+        .registerHelper("neq", ConditionalHelpers.neq)
+        .registerHelper("and", ConditionalHelpers.and)
+        .registerHelper(
+            FirstOccurrenceChecker.NAME,
+            FirstOccurrenceChecker.INSTANCE
+        )
+
+    // Client templates
+    val clientApiModels = handlebars.compile("/templates/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 clientHttpResilience4jUtils = handlebars.compile("/templates/client-code/http-resilience4j-util.kt")!!
+    val clientLoggingInterceptor = handlebars.compile("/templates/client-code/logging-interceptor.kt")!!
+
+    fun applyTemplate(
+        template: Template,
+        input: Any,
+        outputDirPath: Path,
+        fileName: String,
+        postProcessor: (String) -> String = { it }
+    ): SimpleFile {
+        val fileContents = postProcessor(template.apply(input))
+        FirstOccurrenceChecker.STATE_BAG.clear()
+        return SimpleFile(outputDirPath.resolve(fileName), fileContents)
+    }
+
+    fun applyTemplate(
+        template: Template,
+        input: Any,
+        srcDir: Path,
+        basePackage: String,
+        fileName: String,
+        postProcessor: (String) -> String = { it }
+    ) = applyTemplate(template, input, srcDir.resolve(packageToPath(basePackage)), fileName, postProcessor)
+}
+
+private class FirstOccurrenceChecker : Helper<String> {
+
+    companion object {
+        const val NAME: String = "firstOccurrence"
+        val INSTANCE: Helper<String> = FirstOccurrenceChecker()
+        val STATE_BAG: MutableSet<String> = mutableSetOf()
+    }
+
+    override fun apply(context: String, options: Options): Any {
+        return if (!STATE_BAG.contains(context)) {
+            STATE_BAG.add(context)
+            options.fn()
+        } else options.inverse()
+    }
+}
diff --git a/src/main/resources/templates/client-code/api-models.kt.hbs b/src/main/resources/templates/client-code/api-models.kt.hbs
new file mode 100644
index 0000000..86c5400
--- /dev/null
+++ b/src/main/resources/templates/client-code/api-models.kt.hbs
@@ -0,0 +1,25 @@
+package {{ client }}
+
+import okhttp3.Headers
+
+/**
+ * API 2xx success response returned by API call.
+ *
+ * @param <T> The type of data that is deserialized from response body
+ */
+data class ApiResponse<T>(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) : Exception(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/main/resources/templates/client-code/http-resilience4j-util.kt.hbs b/src/main/resources/templates/client-code/http-resilience4j-util.kt.hbs
new file mode 100644
index 0000000..495613b
--- /dev/null
+++ b/src/main/resources/templates/client-code/http-resilience4j-util.kt.hbs
@@ -0,0 +1,12 @@
+package {{ client }}
+
+import io.github.resilience4j.circuitbreaker.CircuitBreaker
+import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
+
+fun <T> withCircuitBreaker(
+    circuitBreakerRegistry: CircuitBreakerRegistry,
+    apiClientName: String, apiCall: () -> ApiResponse<T>
+): ApiResponse<T> {
+    val circuitBreaker = circuitBreakerRegistry.circuitBreaker(apiClientName)
+    return CircuitBreaker.decorateSupplier(circuitBreaker, apiCall).get()
+}
diff --git a/src/main/resources/templates/client-code/http-util.kt.hbs b/src/main/resources/templates/client-code/http-util.kt.hbs
new file mode 100644
index 0000000..be9dfd0
--- /dev/null
+++ b/src/main/resources/templates/client-code/http-util.kt.hbs
@@ -0,0 +1,65 @@
+package {{ client }}
+
+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 <T : Any> HttpUrl.Builder.queryParam(key: String, value: T?): HttpUrl.Builder {
+    value?.let { this.addQueryParameter(key, it.toString()) }
+    return this
+}
+
+@Suppress("unused")
+fun <T : Any> FormBody.Builder.formParam(key: String, value: T?): FormBody.Builder {
+    value?.let { this.add(key, it.toString()) }
+    return this
+}
+
+@Suppress("unused")
+fun HttpUrl.Builder.queryParam(key: String, values: List<String>?, delimiter: String): HttpUrl.Builder {
+    values?.let { this.addQueryParameter(key, it.joinToString(delimiter)) }
+    return this
+}
+
+@Suppress("unused")
+fun Headers.Builder.header(key: String, value: String?): Headers.Builder {
+    value?.let { this.add(key, it) }
+    return this
+}
+
+@Throws(ApiException::class)
+fun <T> Request.execute(client: OkHttpClient, objectMapper: ObjectMapper, classOfT: Class<T>): ApiResponse<T?> =
+    client.newCall(this).execute().use { response ->
+        when {
+            response.isSuccessful ->
+                ApiResponse(response.code, response.headers, response.body?.deserialize(objectMapper, classOfT))
+            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, Any>): String = params.asSequence()
+    .joinToString { param ->
+        this.replace(param.first, param.second.toString())
+    }
+
+fun <T> ResponseBody.deserialize(objectMapper: ObjectMapper, classOfT: Class<T>) =
+    this.string().isNotBlankOrNull()?.let { objectMapper.readValue(it, classOfT) }
+
+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
\ No newline at end of file
diff --git a/src/main/resources/templates/client-code/logging-interceptor.kt.hbs b/src/main/resources/templates/client-code/logging-interceptor.kt.hbs
new file mode 100644
index 0000000..6df9283
--- /dev/null
+++ b/src/main/resources/templates/client-code/logging-interceptor.kt.hbs
@@ -0,0 +1,25 @@
+package {{ client }}
+
+import okhttp3.Interceptor
+import okhttp3.Response
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+class LoggingInterceptor : Interceptor {
+
+    private val logger: Logger = LoggerFactory.getLogger("GeneratedClient")
+
+    override fun intercept(chain: Interceptor.Chain): Response {
+        val request = chain.request()
+
+        val t1 = System.nanoTime()
+        logger.info("Client Request: ${request}")
+
+        val response = chain.proceed(request)
+
+        val t2 = System.nanoTime()
+        logger.info("Client Response after ${(t2 - t1) / 100_000}ms: ${response}")
+
+        return response
+    }
+}
\ No newline at end of file
diff --git a/src/main/resources/templates/client-code/oauth.kt.hbs b/src/main/resources/templates/client-code/oauth.kt.hbs
new file mode 100644
index 0000000..9d29db8
--- /dev/null
+++ b/src/main/resources/templates/client-code/oauth.kt.hbs
@@ -0,0 +1,17 @@
+package {{ client }}
+
+import okhttp3.Authenticator
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.Route
+import okio.IOException
+
+class OAuth2 : Authenticator {
+    var accessToken: String? = null
+
+    @Throws(IOException::class)
+    override fun authenticate(route: Route?, response: Response): Request? =
+        response.request.newBuilder()
+                .header("Authorization", "Bearer $accessToken")
+                .build()
+}
\ No newline at end of file


Test patch:
diff --git a/src/test/kotlin/com/cjbooms/fabrikt/client/OkHttpClientGeneratorTest.kt b/src/test/kotlin/com/cjbooms/fabrikt/client/OkHttpClientGeneratorTest.kt
new file mode 100644
index 0000000..b85e3c3
--- /dev/null
+++ b/src/test/kotlin/com/cjbooms/fabrikt/client/OkHttpClientGeneratorTest.kt
@@ -0,0 +1,125 @@
+package com.cjbooms.fabrikt.client
+
+import com.cjbooms.fabrikt.cli.ClientCodeGenOptionType
+import com.cjbooms.fabrikt.configurations.Packages
+import com.cjbooms.fabrikt.generators.JacksonModelGenerator
+import com.cjbooms.fabrikt.generators.OkHttpEnhancedClientGenerator
+import com.cjbooms.fabrikt.generators.OkHttpSimpleClientGenerator
+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.validation.Linter
+import com.squareup.kotlinpoet.FileSpec
+import java.util.stream.Stream
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.MethodSource
+
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class OkHttpClientGeneratorTest {
+
+    private fun fullApiTestCases(): Stream<String> = Stream.of(
+        "okHttpClient"
+    )
+
+    @ParameterizedTest
+    @MethodSource("fullApiTestCases")
+    fun `correct api simple client is generated from a full API definition`(testCaseName: String) {
+        val packages = Packages("examples.$testCaseName")
+        val sourceApi = SourceApi(javaClass.getResource("/examples/$testCaseName/api.yaml").readText())
+
+        val expectedModel = javaClass.getResource("/examples/$testCaseName/models/Models.kt").readText()
+        val expectedClient = javaClass.getResource("/examples/$testCaseName/client/ApiClient.kt").readText()
+
+        val models = JacksonModelGenerator(packages.base, sourceApi).generate().toSingleFile()
+        val simpleClientCode = OkHttpSimpleClientGenerator(packages, sourceApi)
+            .generateDynamicClientCode()
+            .toSingleFile()
+
+        assertThat(models).isEqualTo(expectedModel)
+        assertThat(simpleClientCode).isEqualTo(expectedClient)
+    }
+
+    @ParameterizedTest
+    @MethodSource("fullApiTestCases")
+    fun `correct api fault-tolerant service client is generated when the resilience4j option is set`(testCaseName: String) {
+        val packages = Packages("examples.$testCaseName")
+        val sourceApi = SourceApi(javaClass.getResource("/examples/$testCaseName/api.yaml").readText())
+
+        val expectedLibUtil = javaClass.getResource("/examples/$testCaseName/client/HttpResilience4jUtil.kt").readText()
+        val expectedClientCode = javaClass.getResource("/examples/$testCaseName/client/ApiService.kt").readText()
+
+        val generator = OkHttpEnhancedClientGenerator(packages, sourceApi)
+        val enhancedLibUtil = generator.generateLibrary(setOf(ClientCodeGenOptionType.RESILIENCE4J))
+                .filterIsInstance<SimpleFile>()
+                .first { it.path.fileName.toString() == "HttpResilience4jUtil.kt" }
+        val enhancedClientCode = generator.generateDynamicClientCode(setOf(ClientCodeGenOptionType.RESILIENCE4J))
+
+        assertThat(enhancedLibUtil.content).isEqualTo(expectedLibUtil)
+        assertThat(enhancedClientCode.toSingleFile()).isEqualTo(expectedClientCode)
+    }
+
+    @ParameterizedTest
+    @MethodSource("fullApiTestCases")
+    fun `the enhanced client is not generated when no specific options are provided`(testCaseName: String) {
+        val packages = Packages("examples.$testCaseName")
+        val sourceApi = SourceApi(javaClass.getResource("/examples/$testCaseName/api.yaml").readText())
+
+        val enhancedClientCode = OkHttpEnhancedClientGenerator(packages, sourceApi)
+            .generateDynamicClientCode(emptySet())
+
+        assertThat(enhancedClientCode).isEqualTo(emptySet<ClientType>())
+    }
+
+    @ParameterizedTest
+    @MethodSource("fullApiTestCases")
+    fun `correct http utitlity libraries are generated`(testCaseName: String) {
+        val packages = Packages("examples.$testCaseName")
+        val sourceApi = SourceApi(javaClass.getResource("/examples/$testCaseName/api.yaml").readText())
+
+        val expectedHttpUtils = javaClass.getResource("/examples/$testCaseName/client/HttpUtil.kt").readText()
+
+        val generatedHttpUtils = OkHttpSimpleClientGenerator(packages, sourceApi).generateLibrary().filterIsInstance<SimpleFile>()
+            .first { it.path.fileName.toString() == "HttpUtil.kt" }
+
+        assertThat(generatedHttpUtils.content).isEqualTo(expectedHttpUtils)
+    }
+
+    @ParameterizedTest
+    @MethodSource("fullApiTestCases")
+    fun `correct logging interceptor libraries are generated`(testCaseName: String) {
+        val packages = Packages("examples.$testCaseName")
+        val sourceApi = SourceApi(javaClass.getResource("/examples/$testCaseName/api.yaml").readText())
+
+        val expectedLoggingInterceptor = javaClass.getResource("/examples/$testCaseName/client/LoggingInterceptor.kt").readText()
+
+        val generatedLoggingInterceptor = OkHttpSimpleClientGenerator(packages, sourceApi).generateLibrary().filterIsInstance<SimpleFile>()
+            .first { it.path.fileName.toString() == "LoggingInterceptor.kt" }
+
+        assertThat(generatedLoggingInterceptor.content).isEqualTo(expectedLoggingInterceptor)
+    }
+
+    private fun Collection<ClientType>.toSingleFile(): String {
+        val destPackage = if (this.isNotEmpty()) first().destinationPackage else ""
+        val singleFileBuilder = FileSpec.builder(destPackage, "dummyFilename")
+        this.forEach {
+            val builder = singleFileBuilder
+                .addType(it.spec)
+            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.forEach {
+            val builder = singleFileBuilder
+                .addType(it.spec)
+            builder.build()
+        }
+        return Linter.lintString(singleFileBuilder.build().toString())
+    }
+}
diff --git a/src/test/resources/examples/okHttpClient/api.yaml b/src/test/resources/examples/okHttpClient/api.yaml
new file mode 100644
index 0000000..2fa8b07
--- /dev/null
+++ b/src/test/resources/examples/okHttpClient/api.yaml
@@ -0,0 +1,287 @@
+openapi: "3.0.0"
+info:
+  title: "API Example"
+  version: "1.0"
+paths:
+  /example-path-1:
+    get:
+      summary: "GET example path 1"
+      parameters:
+        - $ref: "#/components/parameters/QueryParam1"
+        - $ref: "#/components/parameters/QueryParam2"
+      responses:
+        200:
+          description: "successful operation"
+          headers:
+            Cache-Control:
+              $ref: "#/components/headers/CacheControl"
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/QueryResult"
+
+    post:
+      summary: "POST example path 1"
+      parameters:
+        - $ref: "#/components/parameters/QueryParam1"
+      requestBody:
+        $ref: "#/components/requestBodies/PostBody"
+      responses:
+        201:
+          headers:
+            Location:
+              $ref: "#/components/headers/Location"
+          description: "Successful operation"
+
+  /example-path-2/{path_param}:
+    get:
+      summary: "GET example path 2"
+      parameters:
+        - $ref: "#/components/parameters/PathParam"
+        - $ref: "#/components/parameters/QueryParam2"
+        - $ref: "#/components/parameters/IfNoneMatch"
+      responses:
+        200:
+          description: "successful operation"
+          headers:
+            Cache-Control:
+              $ref: "#/components/headers/CacheControl"
+          content:
+            application/json:
+              schema:
+                discriminator:
+                  propertyName: "model_type"
+                oneOf:
+                  - $ref: "#/components/schemas/FirstModel"
+                  - $ref: "#/components/schemas/SecondModel"
+                  - $ref: "#/components/schemas/ThirdModel"
+        304:
+          description: "Returned in conjunction with use of the If-None-Match header.\
+            \ Indicates that the resource as described by the passed etag value has\
+            \ not changed"
+    head:
+      summary: "HEAD example path 2"
+      parameters:
+        - $ref: "#/components/parameters/PathParam"
+        - $ref: "#/components/parameters/QueryParam3"
+        - $ref: "#/components/parameters/IfNoneMatch"
+      responses:
+        200:
+          description: "Record Exists"
+        304:
+          description: "Returned in conjunction with use of the If-None-Match header.\
+            \ Indicates that the resource as described by the passed etag value has\
+            \ not changed"
+
+    put:
+      summary: "PUT example path 2"
+      parameters:
+        - $ref: "#/components/parameters/PathParam"
+        - $ref: "#/components/parameters/IfMatch"
+      requestBody:
+        $ref: "#/components/requestBodies/PutBody"
+      responses:
+        204:
+          description: "Operation successful"
+
+  /example-path-3/{path_param}/subresource:
+    put:
+      summary: "PUT example path 3"
+      parameters:
+        - $ref: "#/components/parameters/QueryParam1"
+        - $ref: "#/components/parameters/PathParam"
+        - $ref: "#/components/parameters/IfMatch"
+      requestBody:
+        $ref: "#/components/requestBodies/PutBody"
+      responses:
+        204:
+          description: "Operation successful"
+
+components:
+  parameters:
+    PathParam:
+      name: "path_param"
+      description: "The resource id"
+      in: "path"
+      required: true
+      schema:
+        type: "string"
+
+    IfMatch:
+      name: "If-Match"
+      description: "The RFC7232 If-Match header field"
+      in: "header"
+      schema:
+        type: "string"
+      required: true
+
+    IfNoneMatch:
+      name: "If-None-Match"
+      description: "The RFC7232 If-None-Match header field"
+      in: "header"
+      schema:
+        type: "string"
+      required: false
+
+    QueryParam1:
+      name: "query_param1"
+      in: "query"
+      schema:
+        type: "array"
+        items:
+          type: "string"
+      required: false
+      allowEmptyValue: false
+
+    QueryParam2:
+      name: "query_param2"
+      in: "query"
+      schema:
+        type: integer
+      required: false
+
+    QueryParam3:
+      name: "query_param3"
+      in: "query"
+      schema:
+        type: boolean
+      required: false
+
+  headers:
+    Location:
+      description: "The Location header indicates the URL of a newly created resource"
+      schema:
+        type: "string"
+
+    CacheControl:
+      description: "The RFC7234 Cache Control header"
+      schema:
+        type: "string"
+      example: "must-revalidate, max-age=5"
+
+  requestBodies:
+    PostBody:
+      required: true
+      content:
+        application/json:
+          schema:
+            discriminator:
+              propertyName: "model_type"
+            oneOf:
+              - $ref: "#/components/schemas/FirstModel"
+              - $ref: "#/components/schemas/SecondModel"
+              - $ref: "#/components/schemas/ThirdModel"
+    PutBody:
+      required: true
+      content:
+        application/json:
+          schema:
+            $ref: "#/components/schemas/FirstModel"
+
+
+  schemas:
+    QueryResult:
+      type: "object"
+      required:
+        - "items"
+      properties:
+        items:
+          type: "array"
+          minItems: 0
+          items:
+            oneOf:
+              - $ref: "#/components/schemas/FirstModel"
+              - $ref: "#/components/schemas/SecondModel"
+              - $ref: "#/components/schemas/ThirdModel"
+    Content:
+      type: "object"
+      required:
+        - "id"
+        - "attr_1"
+        - "attr_2"
+        - "attr_3"
+        - "etag"
+        - "model_type"
+      properties:
+        id:
+          description: "The unique resource id"
+          type: "string"
+          readOnly: true
+        first_attr:
+          description: "The attribute 1"
+          type: "string"
+          format: "date-time"
+          example: "2016-01-27T10:52:46.406Z"
+          readOnly: true
+        second_attr:
+          description: "The attribute 2"
+          type: "string"
+          readOnly: true
+        third_attr:
+          type: "string"
+          x-extensible-enum:
+            - "enum_type_1"
+            - "enum_type_2"
+          description: "Enum types for attribute 3"
+          example: "enum_type_2"
+        etag:
+          type: "string"
+          description: "Etag value to be used in conjunction with If-Match headers for optimistic locking purposes"
+          readOnly: true
+        model_type:
+          type: "string"
+          description: "The model discrimination type"
+          x-extensible-enum:
+            - "first_model"
+            - "second_model"
+            - "third_model"
+          example: "third_model"
+      discriminator:
+        propertyName: "model_type"
+        mapping:
+          first_model: "#/components/schemas/FirstModel"
+          second_model: "#/components/schemas/SecondModel"
+          third_model: "#/components/schemas/ThirdModel"
+
+    FirstModel:
+      allOf:
+        - $ref: "#/components/schemas/Content"
+        - type: "object"
+          properties:
+            extra_first_attr:
+              description: "The attribute 1 for model 1"
+              type: array
+              items:
+                type: "string"
+                minItems: 1
+                maxItems: 10
+              readOnly: true
+    SecondModel:
+      allOf:
+        - $ref: "#/components/schemas/Content"
+        - type: "object"
+          properties:
+            extra_first_attr:
+              description: "The attribute 1 for model 2"
+              type: "string"
+              readOnly: true
+            extra_second_attr:
+              description: "The attribute 2 for model 2"
+              type: boolean
+              readOnly: true
+
+    ThirdModel:
+      allOf:
+        - $ref: "#/components/schemas/Content"
+        - type: "object"
+          properties:
+            extra_first_attr:
+              description: "The attribute 1 for model 3"
+              type: "string"
+              format: "date-time"
+              example: "2016-01-27T10:52:46.406Z"
+              readOnly: true
+            extra_second_attr:
+              description: "The attribute 2 for model 3"
+              type: integer
+              readOnly: true
diff --git a/src/test/resources/examples/okHttpClient/client/ApiClient.kt b/src/test/resources/examples/okHttpClient/client/ApiClient.kt
new file mode 100644
index 0000000..5a9cefb
--- /dev/null
+++ b/src/test/resources/examples/okHttpClient/client/ApiClient.kt
@@ -0,0 +1,219 @@
+package examples.okHttpClient.client
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import examples.okHttpClient.models.Content
+import examples.okHttpClient.models.FirstModel
+import examples.okHttpClient.models.QueryResult
+import kotlin.Boolean
+import kotlin.Int
+import kotlin.String
+import kotlin.Suppress
+import kotlin.Unit
+import kotlin.collections.List
+import kotlin.jvm.Throws
+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
+
+@Suppress("unused")
+class ExamplePathClient(
+    private val objectMapper: ObjectMapper,
+    private val baseUrl: String,
+    private val client: OkHttpClient
+) {
+    /**
+     * GET example path 1
+     *
+     * @param queryParam1
+     * @param queryParam2
+     */
+    @Throws(ApiException::class)
+    fun getExamplePath1(queryParam1: List<String>?, queryParam2: Int?): ApiResponse<QueryResult?> {
+        val httpUrl: HttpUrl = "$baseUrl/example-path-1"
+            .toHttpUrl()
+            .newBuilder()
+            .queryParam("query_param1", queryParam1, ",")
+            .queryParam("query_param2", queryParam2)
+            .build()
+
+        val httpHeaders: Headers = Headers.Builder()
+            .header("Accept", "application/json")
+            .build()
+
+        val request: Request = Request.Builder()
+            .url(httpUrl)
+            .headers(httpHeaders)
+            .get()
+            .build()
+
+        return request.execute(client, objectMapper, QueryResult::class.java)
+    }
+
+    /**
+     * POST example path 1
+     *
+     * @param queryParam1
+     */
+    @Throws(ApiException::class)
+    fun postExamplePath1(generatedType: Content, queryParam1: List<String>?): ApiResponse<Unit?> {
+        val httpUrl: HttpUrl = "$baseUrl/example-path-1"
+            .toHttpUrl()
+            .newBuilder()
+            .queryParam("query_param1", queryParam1, ",")
+            .build()
+
+        val httpHeaders: Headers = Headers.Builder()
+            .build()
+
+        val request: Request = Request.Builder()
+            .url(httpUrl)
+            .headers(httpHeaders)
+            .post(objectMapper.writeValueAsString(generatedType).toRequestBody("application/json".toMediaType()))
+            .build()
+
+        return request.execute(client, objectMapper, Unit::class.java)
+    }
+
+    /**
+     * GET example path 2
+     *
+     * @param pathParam The resource id
+     * @param queryParam2
+     * @param ifNoneMatch The RFC7232 If-None-Match header field
+     */
+    @Throws(ApiException::class)
+    fun getExamplePath2PathParam(
+        pathParam: String,
+        queryParam2: Int?,
+        ifNoneMatch: String?
+    ): ApiResponse<Content?> {
+        val httpUrl: HttpUrl = "$baseUrl/example-path-2/{path_param}"
+            .pathParam("{path_param}" to pathParam)
+            .toHttpUrl()
+            .newBuilder()
+            .queryParam("query_param2", queryParam2)
+            .build()
+
+        val httpHeaders: Headers = Headers.Builder()
+            .header("If-None-Match", ifNoneMatch)
+            .header("Accept", "application/json")
+            .build()
+
+        val request: Request = Request.Builder()
+            .url(httpUrl)
+            .headers(httpHeaders)
+            .get()
+            .build()
+
+        return request.execute(client, objectMapper, Content::class.java)
+    }
+
+    /**
+     * HEAD example path 2
+     *
+     * @param pathParam The resource id
+     * @param queryParam3
+     * @param ifNoneMatch The RFC7232 If-None-Match header field
+     */
+    @Throws(ApiException::class)
+    fun headExamplePath2PathParam(
+        pathParam: String,
+        queryParam3: Boolean?,
+        ifNoneMatch: String?
+    ): ApiResponse<Unit?> {
+        val httpUrl: HttpUrl = "$baseUrl/example-path-2/{path_param}"
+            .pathParam("{path_param}" to pathParam)
+            .toHttpUrl()
+            .newBuilder()
+            .queryParam("query_param3", queryParam3)
+            .build()
+
+        val httpHeaders: Headers = Headers.Builder()
+            .header("If-None-Match", ifNoneMatch)
+            .build()
+
+        val request: Request = Request.Builder()
+            .url(httpUrl)
+            .headers(httpHeaders)
+            .head()
+            .build()
+
+        return request.execute(client, objectMapper, Unit::class.java)
+    }
+
+    /**
+     * PUT example path 2
+     *
+     * @param pathParam The resource id
+     * @param ifMatch The RFC7232 If-Match header field
+     */
+    @Throws(ApiException::class)
+    fun putExamplePath2PathParam(
+        firstModel: FirstModel,
+        pathParam: String,
+        ifMatch: String
+    ): ApiResponse<Unit?> {
+        val httpUrl: HttpUrl = "$baseUrl/example-path-2/{path_param}"
+            .pathParam("{path_param}" to pathParam)
+            .toHttpUrl()
+            .newBuilder()
+            .build()
+
+        val httpHeaders: Headers = Headers.Builder()
+            .header("If-Match", ifMatch)
+            .build()
+
+        val request: Request = Request.Builder()
+            .url(httpUrl)
+            .headers(httpHeaders)
+            .put(objectMapper.writeValueAsString(firstModel).toRequestBody("application/json".toMediaType()))
+            .build()
+
+        return request.execute(client, objectMapper, Unit::class.java)
+    }
+}
+
+@Suppress("unused")
+class ExamplePathSubresourceClient(
+    private val objectMapper: ObjectMapper,
+    private val baseUrl: String,
+    private val client: OkHttpClient
+) {
+    /**
+     * PUT example path 3
+     *
+     * @param queryParam1
+     * @param pathParam The resource id
+     * @param ifMatch The RFC7232 If-Match header field
+     */
+    @Throws(ApiException::class)
+    fun putExamplePath3PathParamSubresource(
+        firstModel: FirstModel,
+        queryParam1: List<String>?,
+        pathParam: String,
+        ifMatch: String
+    ): ApiResponse<Unit?> {
+        val httpUrl: HttpUrl = "$baseUrl/example-path-3/{path_param}/subresource"
+            .pathParam("{path_param}" to pathParam)
+            .toHttpUrl()
+            .newBuilder()
+            .queryParam("query_param1", queryParam1, ",")
+            .build()
+
+        val httpHeaders: Headers = Headers.Builder()
+            .header("If-Match", ifMatch)
+            .build()
+
+        val request: Request = Request.Builder()
+            .url(httpUrl)
+            .headers(httpHeaders)
+            .put(objectMapper.writeValueAsString(firstModel).toRequestBody("application/json".toMediaType()))
+            .build()
+
+        return request.execute(client, objectMapper, Unit::class.java)
+    }
+}
diff --git a/src/test/resources/examples/okHttpClient/client/ApiModels.kt b/src/test/resources/examples/okHttpClient/client/ApiModels.kt
new file mode 100644
index 0000000..861c507
--- /dev/null
+++ b/src/test/resources/examples/okHttpClient/client/ApiModels.kt
@@ -0,0 +1,26 @@
+package examples.okHttpClient.client
+
+import okhttp3.Headers
+
+/**
+ * API 2xx success response returned by API call.
+ *
+ * @param <T> The type of data that is deserialized from response body
+ */
+data class ApiResponse<T>(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) : Exception(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/examples/okHttpClient/client/ApiService.kt
new file mode 100644
index 0000000..aed769d
--- /dev/null
+++ b/src/test/resources/examples/okHttpClient/client/ApiService.kt
@@ -0,0 +1,111 @@
+package examples.okHttpClient.client
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import examples.okHttpClient.models.Content
+import examples.okHttpClient.models.FirstModel
+import examples.okHttpClient.models.QueryResult
+import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
+import kotlin.Boolean
+import kotlin.Int
+import kotlin.String
+import kotlin.Suppress
+import kotlin.Unit
+import kotlin.collections.List
+import kotlin.jvm.Throws
+import okhttp3.OkHttpClient
+
+/**
+ * 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")
+class ExamplePathService(
+    private val circuitBreakerRegistry: CircuitBreakerRegistry,
+    objectMapper: ObjectMapper,
+    baseUrl: String,
+    client: OkHttpClient
+) {
+    var circuitBreakerName: String = "examplePathClient"
+
+    private val apiClient: ExamplePathClient = ExamplePathClient(objectMapper, baseUrl, client)
+
+    @Throws(ApiException::class)
+    fun getExamplePath1(queryParam1: List<String>?, queryParam2: Int?): ApiResponse<QueryResult?> =
+        withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) {
+            apiClient.getExamplePath1(queryParam1, queryParam2)
+        }
+
+    @Throws(ApiException::class)
+    fun postExamplePath1(generatedType: Content, queryParam1: List<String>?): ApiResponse<Unit?> =
+        withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) {
+            apiClient.postExamplePath1(generatedType, queryParam1)
+        }
+
+    @Throws(ApiException::class)
+    fun getExamplePath2PathParam(
+        pathParam: String,
+        queryParam2: Int?,
+        ifNoneMatch: String?
+    ): ApiResponse<Content?> =
+        withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) {
+            apiClient.getExamplePath2PathParam(pathParam, queryParam2, ifNoneMatch)
+        }
+
+    @Throws(ApiException::class)
+    fun headExamplePath2PathParam(
+        pathParam: String,
+        queryParam3: Boolean?,
+        ifNoneMatch: String?
+    ): ApiResponse<Unit?> =
+        withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) {
+            apiClient.headExamplePath2PathParam(pathParam, queryParam3, ifNoneMatch)
+        }
+
+    @Throws(ApiException::class)
+    fun putExamplePath2PathParam(
+        firstModel: FirstModel,
+        pathParam: String,
+        ifMatch: String
+    ): ApiResponse<Unit?> =
+        withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) {
+            apiClient.putExamplePath2PathParam(firstModel, pathParam, ifMatch)
+        }
+}
+
+/**
+ * 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")
+class ExamplePathSubresourceService(
+    private val circuitBreakerRegistry: CircuitBreakerRegistry,
+    objectMapper: ObjectMapper,
+    baseUrl: String,
+    client: OkHttpClient
+) {
+    var circuitBreakerName: String = "examplePathSubresourceClient"
+
+    private val apiClient: ExamplePathSubresourceClient = ExamplePathSubresourceClient(
+        objectMapper,
+        baseUrl, client
+    )
+
+    @Throws(ApiException::class)
+    fun putExamplePath3PathParamSubresource(
+        firstModel: FirstModel,
+        queryParam1: List<String>?,
+        pathParam: String,
+        ifMatch: String
+    ): ApiResponse<Unit?> =
+        withCircuitBreaker(circuitBreakerRegistry, circuitBreakerName) {
+            apiClient.putExamplePath3PathParamSubresource(firstModel, queryParam1, pathParam, ifMatch)
+        }
+}
diff --git a/src/test/resources/examples/okHttpClient/client/HttpResilience4jUtil.kt b/src/test/resources/examples/okHttpClient/client/HttpResilience4jUtil.kt
new file mode 100644
index 0000000..21de2a2
--- /dev/null
+++ b/src/test/resources/examples/okHttpClient/client/HttpResilience4jUtil.kt
@@ -0,0 +1,12 @@
+package examples.okHttpClient.client
+
+import io.github.resilience4j.circuitbreaker.CircuitBreaker
+import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
+
+fun <T> withCircuitBreaker(
+    circuitBreakerRegistry: CircuitBreakerRegistry,
+    apiClientName: String, apiCall: () -> ApiResponse<T>
+): ApiResponse<T> {
+    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/okHttpClient/client/HttpUtil.kt
new file mode 100644
index 0000000..fd81b38
--- /dev/null
+++ b/src/test/resources/examples/okHttpClient/client/HttpUtil.kt
@@ -0,0 +1,65 @@
+package examples.okHttpClient.client
+
+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 <T : Any> HttpUrl.Builder.queryParam(key: String, value: T?): HttpUrl.Builder {
+    value?.let { this.addQueryParameter(key, it.toString()) }
+    return this
+}
+
+@Suppress("unused")
+fun <T : Any> FormBody.Builder.formParam(key: String, value: T?): FormBody.Builder {
+    value?.let { this.add(key, it.toString()) }
+    return this
+}
+
+@Suppress("unused")
+fun HttpUrl.Builder.queryParam(key: String, values: List<String>?, delimiter: String): HttpUrl.Builder {
+    values?.let { this.addQueryParameter(key, it.joinToString(delimiter)) }
+    return this
+}
+
+@Suppress("unused")
+fun Headers.Builder.header(key: String, value: String?): Headers.Builder {
+    value?.let { this.add(key, it) }
+    return this
+}
+
+@Throws(ApiException::class)
+fun <T> Request.execute(client: OkHttpClient, objectMapper: ObjectMapper, classOfT: Class<T>): ApiResponse<T?> =
+    client.newCall(this).execute().use { response ->
+        when {
+            response.isSuccessful ->
+                ApiResponse(response.code, response.headers, response.body?.deserialize(objectMapper, classOfT))
+            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, Any>): String = params.asSequence()
+    .joinToString { param ->
+        this.replace(param.first, param.second.toString())
+    }
+
+fun <T> ResponseBody.deserialize(objectMapper: ObjectMapper, classOfT: Class<T>) =
+    this.string().isNotBlankOrNull()?.let { objectMapper.readValue(it, classOfT) }
+
+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
\ No newline at end of file
diff --git a/src/test/resources/examples/okHttpClient/client/LoggingInterceptor.kt b/src/test/resources/examples/okHttpClient/client/LoggingInterceptor.kt
new file mode 100644
index 0000000..59ba11d
--- /dev/null
+++ b/src/test/resources/examples/okHttpClient/client/LoggingInterceptor.kt
@@ -0,0 +1,25 @@
+package examples.okHttpClient.client
+
+import okhttp3.Interceptor
+import okhttp3.Response
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+class LoggingInterceptor : Interceptor {
+
+    private val logger: Logger = LoggerFactory.getLogger("GeneratedClient")
+
+    override fun intercept(chain: Interceptor.Chain): Response {
+        val request = chain.request()
+
+        val t1 = System.nanoTime()
+        logger.info("Client Request: ${request}")
+
+        val response = chain.proceed(request)
+
+        val t2 = System.nanoTime()
+        logger.info("Client Response after ${(t2 - t1) / 100_000}ms: ${response}")
+
+        return response
+    }
+}
\ No newline at end of file
diff --git a/src/test/resources/examples/okHttpClient/client/OAuth.kt b/src/test/resources/examples/okHttpClient/client/OAuth.kt
new file mode 100644
index 0000000..500a4d4
--- /dev/null
+++ b/src/test/resources/examples/okHttpClient/client/OAuth.kt
@@ -0,0 +1,17 @@
+package examples.okHttpClient.client
+
+import okhttp3.Authenticator
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.Route
+import okio.IOException
+
+class OAuth2 : Authenticator {
+    var accessToken: String? = null
+
+    @Throws(IOException::class)
+    override fun authenticate(route: Route?, response: Response): Request? =
+        response.request.newBuilder()
+                .header("Authorization", "Bearer $accessToken")
+                .build()
+}
\ No newline at end of file
diff --git a/src/test/resources/examples/okHttpClient/models/Models.kt b/src/test/resources/examples/okHttpClient/models/Models.kt
new file mode 100644
index 0000000..7d5ee89
--- /dev/null
+++ b/src/test/resources/examples/okHttpClient/models/Models.kt
@@ -0,0 +1,153 @@
+package examples.okHttpClient.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
+
+data class QueryResult(
+    @param:JsonProperty("items")
+    @get:JsonProperty("items")
+    @get:NotNull
+    @get:Size(min = 0)
+    @get:Valid
+    val items: List<Content>
+)
+
+@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")
+)
+sealed class Content(
+    open val id: String? = null,
+    open val firstAttr: OffsetDateTime? = null,
+    open val secondAttr: String? = null,
+    open val thirdAttr: ThirdAttr? = null,
+    open val etag: String? = null
+) {
+    abstract val modelType: ModelType
+}
+
+enum class ThirdAttr(
+    @JsonValue
+    val value: String
+) {
+    ENUM_TYPE_1("enum_type_1"),
+
+    ENUM_TYPE_2("enum_type_2");
+}
+
+enum class ModelType(
+    @JsonValue
+    val value: String
+) {
+    FIRST_MODEL("first_model"),
+
+    SECOND_MODEL("second_model"),
+
+    THIRD_MODEL("third_model");
+}
+
+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: ThirdAttr? = null,
+    @param:JsonProperty("etag")
+    @get:JsonProperty("etag")
+    override val etag: String? = null,
+    @param:JsonProperty("extra_first_attr")
+    @get:JsonProperty("extra_first_attr")
+    val extraFirstAttr: List<String>? = null
+) : Content(id, firstAttr, secondAttr, thirdAttr, etag) {
+    @get:JsonProperty("model_type")
+    @get:NotNull
+    override val modelType: ModelType = ModelType.FIRST_MODEL
+}
+
+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: ThirdAttr? = null,
+    @param:JsonProperty("etag")
+    @get:JsonProperty("etag")
+    override val etag: String? = null,
+    @param:JsonProperty("extra_first_attr")
+    @get:JsonProperty("extra_first_attr")
+    val extraFirstAttr: String? = null,
+    @param:JsonProperty("extra_second_attr")
+    @get:JsonProperty("extra_second_attr")
+    val extraSecondAttr: Boolean? = null
+) : Content(id, firstAttr, secondAttr, thirdAttr, etag) {
+    @get:JsonProperty("model_type")
+    @get:NotNull
+    override val modelType: ModelType = ModelType.SECOND_MODEL
+}
+
+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: ThirdAttr? = null,
+    @param:JsonProperty("etag")
+    @get:JsonProperty("etag")
+    override val etag: String? = null,
+    @param:JsonProperty("extra_first_attr")
+    @get:JsonProperty("extra_first_attr")
+    val extraFirstAttr: OffsetDateTime? = null,
+    @param:JsonProperty("extra_second_attr")
+    @get:JsonProperty("extra_second_attr")
+    val extraSecondAttr: Int? = null
+) : Content(id, firstAttr, secondAttr, thirdAttr, etag) {
+    @get:JsonProperty("model_type")
+    @get:NotNull
+    override val modelType: ModelType = ModelType.THIRD_MODEL
+}