Repository: cjbooms/fabrikt
Pull request #10 (2020-07-29 16:40:45)

Issue #9: Add support for the code generation of Spring Controllers
Issue #9: Add support for the code generation of Spring Controllers


Patch:
diff --git a/README.md b/README.md
index 795f5d3..3e4be01 100644
--- a/README.md
+++ b/README.md
@@ -3,6 +3,8 @@
 This library generates Kotlin code from OpenApi3 specifications to ensure your code matches your API contract. The library currently has support for:
 
 * **Jackson annotated data classes**
+* **Spring MVC annotated controllers**
+* **Service interfaces**
 * **OkHttp Client** - with the option for a resilience4j fault-tolerance wrapper
 
 ## Configuration
@@ -21,6 +23,7 @@ This section documents the available CLI parameters for controlling what gets ge
  |   `--targets`          | Targets are the parts of the application that you want to be generated.
  |                        | CHOOSE ANY OF:
  |                        |   `HTTP_MODELS` - Jackson annotated data classes to represent the schema objects defined in the input.
+ |                        |   `CONTROLLERS` - Spring annotated HTTP controllers for each of the endpoints defined in the input.
  |                        |   `CLIENT` - Simple http rest client.
 
 ## Examples
diff --git a/build.gradle.kts b/build.gradle.kts
index 699a7a8..7e7469c 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -92,7 +92,7 @@ tasks {
 
     val printCodeGenUsage by creating(JavaExec::class) {
         dependsOn(shadowJar)
-        classpath = project.files("./build/libs/$executableName.jar")
+        classpath = project.files("./build/libs/$executableName-$version.jar")
         main = "com.cjbooms.fabrikt.cli.CodeGen"
         args = listOf("--help")
     }
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenOptions.kt b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenOptions.kt
index af95b0b..335a34c 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenOptions.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenOptions.kt
@@ -5,6 +5,14 @@ enum class CodeGenerationType(val description: String, val requires: Set<Interna
         "Jackson annotated data classes to represent the schema objects defined in the input.",
         setOf(InternalCodeGenGenType.MODELS)
     ),
+    CONTROLLERS(
+        "Spring annotated HTTP controllers for each of the endpoints defined in the input.",
+        setOf(
+            InternalCodeGenGenType.MODELS,
+            InternalCodeGenGenType.SERVICE_INTERFACES,
+            InternalCodeGenGenType.CONTROLLERS
+        )
+    ),
     CLIENT(
         "Simple http rest client.",
         setOf(
@@ -19,6 +27,8 @@ enum class CodeGenerationType(val description: String, val requires: Set<Interna
 // Ordering is important here...
 enum class InternalCodeGenGenType {
     MODELS,
+    CONTROLLERS,
+    SERVICE_INTERFACES,
     CLIENT
 }
 
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenerator.kt
index 2c57b47..0d5f2a3 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenerator.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenerator.kt
@@ -1,13 +1,21 @@
 package com.cjbooms.fabrikt.cli
 
 import com.cjbooms.fabrikt.cli.CodeGenerationType.CLIENT
+import com.cjbooms.fabrikt.cli.CodeGenerationType.CONTROLLERS
 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.generators.client.OkHttpClientGenerator
+import com.cjbooms.fabrikt.generators.controller.SpringControllerGenerator
+import com.cjbooms.fabrikt.generators.model.JacksonModelGenerator
+import com.cjbooms.fabrikt.generators.service.SpringServiceInterfaceGenerator
+import com.cjbooms.fabrikt.model.Clients
+import com.cjbooms.fabrikt.model.Controllers
 import com.cjbooms.fabrikt.model.GeneratedFile
 import com.cjbooms.fabrikt.model.KotlinSourceSet
+import com.cjbooms.fabrikt.model.Models
+import com.cjbooms.fabrikt.model.Services
 import com.cjbooms.fabrikt.model.SourceApi
+import com.squareup.kotlinpoet.FileSpec
 
 class CodeGenerator(
     private val packages: Packages,
@@ -21,17 +29,34 @@ class CodeGenerator(
     private fun generateCode(generationType: CodeGenerationType): Collection<GeneratedFile> =
         when (generationType) {
             CLIENT -> generateClient()
+            CONTROLLERS -> generateControllers()
             HTTP_MODELS -> generateModels()
         }
 
-    private fun generateModels(): Collection<GeneratedFile> =
-            setOf(KotlinSourceSet(JacksonModelGenerator(packages.base, sourceApi).generate().files))
+    private fun generateModels(): Collection<GeneratedFile> = sourceSet(models().files)
+
+    private fun generateServiceInterfaces(): Collection<GeneratedFile> =
+        sourceSet(services().files).plus(sourceSet(models().files))
+
+    private fun generateControllers(): Collection<GeneratedFile> =
+        sourceSet(controllers().files).plus(generateServiceInterfaces())
 
     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)
+        val lib = OkHttpClientGenerator(packages, sourceApi).generateLibrary(codeGenOptions)
+        return sourceSet(client().files).plus(lib).plus(sourceSet(models().files))
     }
+
+    private fun sourceSet(fileSpec: Collection<FileSpec>) = setOf(KotlinSourceSet(fileSpec))
+
+    private fun models(): Models =
+        JacksonModelGenerator(packages, sourceApi).generate()
+
+    private fun services(): Services =
+        SpringServiceInterfaceGenerator(packages, sourceApi, models().models).generate()
+
+    private fun controllers(): Controllers =
+        SpringControllerGenerator(packages, sourceApi, services().services, models().models).generate()
+
+    private fun client(): Clients =
+        OkHttpClientGenerator(packages, sourceApi).generate(codeGenOptions)
 }
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/configurations/Packages.kt b/src/main/kotlin/com/cjbooms/fabrikt/configurations/Packages.kt
index 70679b3..9d95517 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/configurations/Packages.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/configurations/Packages.kt
@@ -1,9 +1,13 @@
 package com.cjbooms.fabrikt.configurations
 
 import com.cjbooms.fabrikt.model.Destinations.clientPackage
+import com.cjbooms.fabrikt.model.Destinations.controllersPackage
 import com.cjbooms.fabrikt.model.Destinations.modelsPackage
+import com.cjbooms.fabrikt.model.Destinations.servicesPackage
 
 class Packages(val base: String) {
     val models: String = modelsPackage(base)
     val client: String = clientPackage(base)
+    val services: String = servicesPackage(base)
+    val controllers: String = controllersPackage(base)
 }
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/ClientGeneratorUtils.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/GeneratorUtils.kt
similarity index 78%
rename from src/main/kotlin/com/cjbooms/fabrikt/generators/ClientGeneratorUtils.kt
rename to src/main/kotlin/com/cjbooms/fabrikt/generators/GeneratorUtils.kt
index 3a2c8ba..aa078ef 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/generators/ClientGeneratorUtils.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/GeneratorUtils.kt
@@ -1,8 +1,6 @@
 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.generators.model.JacksonModelGenerator.Companion.toModelType
 import com.cjbooms.fabrikt.model.KotlinTypeInfo
 import com.reprezen.kaizen.oasparser.model3.MediaType
 import com.reprezen.kaizen.oasparser.model3.Operation
@@ -14,13 +12,11 @@ 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 {
+object GeneratorUtils {
     /**
      * 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
@@ -48,23 +44,6 @@ object ClientGeneratorUtils {
             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`.
@@ -102,10 +81,6 @@ object ClientGeneratorUtils {
         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()
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/PropertyUtils.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/PropertyUtils.kt
index c9f0304..8419c54 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/generators/PropertyUtils.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/PropertyUtils.kt
@@ -1,6 +1,7 @@
 package com.cjbooms.fabrikt.generators
 
 import com.cjbooms.fabrikt.generators.ValidationAnnotations.fieldValid
+import com.cjbooms.fabrikt.generators.model.JacksonAnnotations
 import com.cjbooms.fabrikt.model.KotlinTypeInfo
 import com.cjbooms.fabrikt.model.PropertyInfo
 import com.squareup.kotlinpoet.ClassName
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/client/ClientGeneratorUtils.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/ClientGeneratorUtils.kt
new file mode 100644
index 0000000..ee4bb97
--- /dev/null
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/ClientGeneratorUtils.kt
@@ -0,0 +1,35 @@
+package com.cjbooms.fabrikt.generators.client
+
+import com.cjbooms.fabrikt.configurations.Packages
+import com.cjbooms.fabrikt.generators.GeneratorUtils.getPrimaryAcceptMediaType
+import com.cjbooms.fabrikt.generators.GeneratorUtils.toClassName
+import com.cjbooms.fabrikt.generators.model.JacksonModelGenerator.Companion.toModelType
+import com.cjbooms.fabrikt.model.ClientType
+import com.cjbooms.fabrikt.model.KotlinTypeInfo
+import com.reprezen.kaizen.oasparser.model3.Operation
+import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
+import com.squareup.kotlinpoet.TypeName
+import com.squareup.kotlinpoet.asTypeName
+
+object ClientGeneratorUtils {
+    /**
+     * 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.toClientReturnType(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))
+    }
+
+    fun simpleClientName(resourceName: String) = "$resourceName${ClientType.SIMPLE_CLIENT_SUFFIX}"
+
+    fun enhancedClientName(resourceName: String) = "$resourceName${ClientType.ENHANCED_CLIENT_SUFFIX}"
+}
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpClientGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpClientGenerator.kt
similarity index 96%
rename from src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpClientGenerator.kt
rename to src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpClientGenerator.kt
index 03ff2b6..35bc1b6 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpClientGenerator.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpClientGenerator.kt
@@ -1,4 +1,4 @@
-package com.cjbooms.fabrikt.generators
+package com.cjbooms.fabrikt.generators.client
 
 import com.cjbooms.fabrikt.cli.ClientCodeGenOptionType
 import com.cjbooms.fabrikt.configurations.Packages
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpEnhancedClientGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpEnhancedClientGenerator.kt
similarity index 87%
rename from src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpEnhancedClientGenerator.kt
rename to src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpEnhancedClientGenerator.kt
index c724072..8fcf0db 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpEnhancedClientGenerator.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpEnhancedClientGenerator.kt
@@ -1,15 +1,15 @@
-package com.cjbooms.fabrikt.generators
+package com.cjbooms.fabrikt.generators.client
 
 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.generators.GeneratorUtils.functionName
+import com.cjbooms.fabrikt.generators.GeneratorUtils.toBodyParameterSpec
+import com.cjbooms.fabrikt.generators.GeneratorUtils.toClassName
+import com.cjbooms.fabrikt.generators.GeneratorUtils.toKCodeName
+import com.cjbooms.fabrikt.generators.GeneratorUtils.toParameterSpec
+import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.enhancedClientName
+import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.simpleClientName
+import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.toClientReturnType
 import com.cjbooms.fabrikt.model.ClientType
 import com.cjbooms.fabrikt.model.Destinations
 import com.cjbooms.fabrikt.model.GeneratedFile
@@ -52,8 +52,14 @@ class OkHttpEnhancedClientGenerator(
                         )
                         .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))
+                        .addCode(
+                            Resilience4jClientOperationStatement(
+                                packages,
+                                resource,
+                                verb,
+                                operation
+                            ).toStatement())
+                        .returns(operation.toClientReturnType(packages))
                         .build()
                 }
             }
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpSimpleClientGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpSimpleClientGenerator.kt
similarity index 81%
rename from src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpSimpleClientGenerator.kt
rename to src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpSimpleClientGenerator.kt
index 310cb54..88fffee 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/generators/OkHttpSimpleClientGenerator.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/client/OkHttpSimpleClientGenerator.kt
@@ -1,24 +1,24 @@
-package com.cjbooms.fabrikt.generators
+package com.cjbooms.fabrikt.generators.client
 
 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.generators.GeneratorUtils.functionName
+import com.cjbooms.fabrikt.generators.GeneratorUtils.getBodyResponses
+import com.cjbooms.fabrikt.generators.GeneratorUtils.getHeaderParams
+import com.cjbooms.fabrikt.generators.GeneratorUtils.getPathParams
+import com.cjbooms.fabrikt.generators.GeneratorUtils.getPrimaryAcceptMediaType
+import com.cjbooms.fabrikt.generators.GeneratorUtils.getPrimaryContentMediaType
+import com.cjbooms.fabrikt.generators.GeneratorUtils.getQueryParams
+import com.cjbooms.fabrikt.generators.GeneratorUtils.primaryPropertiesConstructor
+import com.cjbooms.fabrikt.generators.GeneratorUtils.toBodyParameterSpec
+import com.cjbooms.fabrikt.generators.GeneratorUtils.toBodyRequestSchema
+import com.cjbooms.fabrikt.generators.GeneratorUtils.toClassName
+import com.cjbooms.fabrikt.generators.GeneratorUtils.toKCodeName
+import com.cjbooms.fabrikt.generators.GeneratorUtils.toKdoc
+import com.cjbooms.fabrikt.generators.GeneratorUtils.toParameterSpec
+import com.cjbooms.fabrikt.generators.GeneratorUtils.toVarName
+import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.simpleClientName
+import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.toClientReturnType
+import com.cjbooms.fabrikt.generators.model.JacksonModelGenerator.Companion.toModelType
 import com.cjbooms.fabrikt.model.ClientType
 import com.cjbooms.fabrikt.model.Destinations
 import com.cjbooms.fabrikt.model.GeneratedFile
@@ -55,8 +55,14 @@ class OkHttpSimpleClientGenerator(
                         )
                         .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))
+                        .addCode(
+                            SimpleClientOperationStatement(
+                                packages,
+                                resource,
+                                verb,
+                                operation
+                            ).toStatement())
+                        .returns(operation.toClientReturnType(packages))
                         .build()
                 }
             }
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/ControllerGeneratorUtils.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/ControllerGeneratorUtils.kt
new file mode 100644
index 0000000..4539d01
--- /dev/null
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/ControllerGeneratorUtils.kt
@@ -0,0 +1,84 @@
+package com.cjbooms.fabrikt.generators.controller
+
+import com.cjbooms.fabrikt.generators.model.JacksonModelGenerator.Companion.toModelType
+import com.cjbooms.fabrikt.model.BodyParameter
+import com.cjbooms.fabrikt.model.ControllerType
+import com.cjbooms.fabrikt.model.IncomingParameter
+import com.cjbooms.fabrikt.model.KotlinTypeInfo
+import com.cjbooms.fabrikt.model.RequestParameter
+import com.cjbooms.fabrikt.util.KaizenParserExtensions.safeName
+import com.reprezen.kaizen.oasparser.model3.Operation
+import com.reprezen.kaizen.oasparser.model3.Parameter
+import com.reprezen.kaizen.oasparser.model3.Response
+import com.squareup.kotlinpoet.TypeName
+import com.squareup.kotlinpoet.asTypeName
+
+object ControllerGeneratorUtils {
+    /**
+     * This maps the OpenAPI response code with a ClassName object.
+     * Pulling out the first response. This assumes first is happy path
+     * may need to revisit if we want to have conditional responses
+     */
+    fun Operation.happyPathResponse(basePackage: String): Pair<Int, TypeName> {
+        // Map of response code to nullable name of schema
+        val responseDetails = happyPathResponseObject()
+        return responseDetails.first to (responseDetails
+            .second
+            .contentMediaTypes
+            .map { it.value?.schema }
+            .filterNotNull()
+            .firstOrNull()
+            ?.let { toModelType(basePackage, KotlinTypeInfo.from(it)) }
+            ?: Unit::class.asTypeName())
+    }
+
+    fun Operation.happyPathResponseObject(): Pair<Int, Response> {
+        val toResponseMapping: Map<Int, Response> = responses
+            .filter { it.key != "default" }
+            .map { (code, body) ->
+                code.toInt() to body
+            }.toMap()
+
+        // Happy path, just pull out the http code with the lowest value. Later we may have conditional responses
+        val code: Int = toResponseMapping.keys.min() ?: throw IllegalStateException("Could not extract the response for $this")
+        val response = toResponseMapping[code]!!
+
+        return code to response
+    }
+
+    /**
+     * Returns a list of IncomingParameters, ordering logic should be
+     * encapsulated here to ensure the order of parameters align between
+     * services and controllers
+     */
+    fun Operation.toIncomingParameters(basePackage: String): List<IncomingParameter> {
+
+        val bodies = requestBody.contentMediaTypes.values
+            .map {
+                BodyParameter(
+                    it.schema.safeName(),
+                    toModelType(basePackage, KotlinTypeInfo.from(it.schema)),
+                    it.schema
+                )
+            }
+
+        val parameters = parameters
+            .map {
+                RequestParameter(
+                    it.name,
+                    toModelType(basePackage, KotlinTypeInfo.from(it.schema), isRequired(it)),
+                    it
+                )
+            }
+            .sortedBy { it.type.isNullable }
+
+        return bodies + parameters
+    }
+
+    private fun isRequired(parameter: Parameter): Boolean = parameter.isRequired || parameter.schema.default != null
+
+    fun controllerName(resourceName: String) = "$resourceName${ControllerType.SUFFIX}"
+
+    fun methodName(verb: String, isSingleResource: Boolean) =
+        if (isSingleResource) "${verb}ById" else verb
+}
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/SpringControllerCodeBlocks.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/SpringControllerCodeBlocks.kt
new file mode 100644
index 0000000..94ede31
--- /dev/null
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/SpringControllerCodeBlocks.kt
@@ -0,0 +1,71 @@
+package com.cjbooms.fabrikt.generators.controller
+
+import com.cjbooms.fabrikt.generators.controller.metadata.SpringImports
+import com.cjbooms.fabrikt.model.IncomingParameter
+import com.squareup.kotlinpoet.CodeBlock
+
+data class SimpleCodeBlock(val body: String, val args: List<Any> = emptyList())
+
+fun SimpleCodeBlock.toCodeBlock() = CodeBlock.of(body, *args.toTypedArray())
+
+object ControllerCodeBlocks {
+
+    fun postWithResponsebody(serviceFuncName: String, parameters: List<IncomingParameter>): SimpleCodeBlock =
+        SimpleCodeBlock(
+            """return ResponseEntity
+                .ok(${serviceCallLine(serviceFuncName, parameters)})
+            """
+        )
+
+    fun postWithLocationResponse(serviceFuncName: String, parameters: List<IncomingParameter>, withBody: Boolean): SimpleCodeBlock {
+        val conditionalObjectResponse = "if (svcResp.second != null) response.body(svcResp.second)"
+        return SimpleCodeBlock(
+            """val svcResp = ${serviceCallLine(serviceFuncName, parameters)}
+                val response = %T.created(svcResp.first) ${if (withBody) "\n$conditionalObjectResponse" else "" }
+                return response.build()
+               """.trimIndent(),
+            listOf(
+                SpringImports.RESPONSE_ENTITY
+            )
+        )
+    }
+
+    fun getSingleResource(serviceFuncName: String, parameters: List<IncomingParameter>): SimpleCodeBlock =
+        SimpleCodeBlock(
+            """val svcResp = ${serviceCallLine(serviceFuncName, parameters)}
+               return %T
+                   .ok()
+                   .body(svcResp)
+               """.trimIndent(),
+            listOf(
+                SpringImports.RESPONSE_ENTITY
+            )
+        )
+
+    fun putWithNoContent(serviceFuncName: String, parameters: List<IncomingParameter>): SimpleCodeBlock {
+        return SimpleCodeBlock(
+            """${serviceCallLine(serviceFuncName, parameters)}
+               return ResponseEntity.noContent().build()
+            """
+        )
+    }
+
+    fun deleteWithNoContent(serviceFuncName: String, parameters: List<IncomingParameter>): SimpleCodeBlock {
+        return SimpleCodeBlock(
+            """${serviceCallLine(serviceFuncName, parameters)}
+               return ResponseEntity.noContent().build()
+            """
+        )
+    }
+
+    fun genericBody(serviceFuncName: String, parameters: List<IncomingParameter>, responseCode: Int): SimpleCodeBlock {
+        return SimpleCodeBlock(
+            """${serviceCallLine(serviceFuncName, parameters)}
+            return ResponseEntity.status($responseCode).build()
+            """, listOf(SpringImports.URI_BUILDER)
+        )
+    }
+
+    private fun serviceCallLine(serviceFuncName: String, parameters: List<IncomingParameter>): String =
+        "service.$serviceFuncName(${parameters.joinToString(", ") { it.name }})"
+}
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/SpringControllerGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/SpringControllerGenerator.kt
new file mode 100644
index 0000000..dd41771
--- /dev/null
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/SpringControllerGenerator.kt
@@ -0,0 +1,217 @@
+package com.cjbooms.fabrikt.generators.controller
+
+import com.cjbooms.fabrikt.configurations.Packages
+import com.cjbooms.fabrikt.generators.GeneratorUtils.toKdoc
+import com.cjbooms.fabrikt.generators.controller.ControllerCodeBlocks.deleteWithNoContent
+import com.cjbooms.fabrikt.generators.controller.ControllerCodeBlocks.genericBody
+import com.cjbooms.fabrikt.generators.controller.ControllerCodeBlocks.getSingleResource
+import com.cjbooms.fabrikt.generators.controller.ControllerCodeBlocks.postWithLocationResponse
+import com.cjbooms.fabrikt.generators.controller.ControllerCodeBlocks.postWithResponsebody
+import com.cjbooms.fabrikt.generators.controller.ControllerCodeBlocks.putWithNoContent
+import com.cjbooms.fabrikt.generators.controller.ControllerGeneratorUtils.controllerName
+import com.cjbooms.fabrikt.generators.controller.ControllerGeneratorUtils.happyPathResponse
+import com.cjbooms.fabrikt.generators.controller.ControllerGeneratorUtils.methodName
+import com.cjbooms.fabrikt.generators.controller.ControllerGeneratorUtils.toIncomingParameters
+import com.cjbooms.fabrikt.generators.controller.metadata.JavaXAnnotations
+import com.cjbooms.fabrikt.generators.controller.metadata.SpringAnnotations
+import com.cjbooms.fabrikt.generators.controller.metadata.SpringImports
+import com.cjbooms.fabrikt.generators.service.SpringServiceInterfaceGenerator
+import com.cjbooms.fabrikt.model.BodyParameter
+import com.cjbooms.fabrikt.model.ControllerType
+import com.cjbooms.fabrikt.model.Controllers
+import com.cjbooms.fabrikt.model.HeaderParam
+import com.cjbooms.fabrikt.model.ModelType
+import com.cjbooms.fabrikt.model.PathParam
+import com.cjbooms.fabrikt.model.QueryParam
+import com.cjbooms.fabrikt.model.RequestParameter
+import com.cjbooms.fabrikt.model.ServiceType
+import com.cjbooms.fabrikt.model.SourceApi
+import com.cjbooms.fabrikt.util.KaizenParserExtensions.basePath
+import com.cjbooms.fabrikt.util.KaizenParserExtensions.isSingleResource
+import com.cjbooms.fabrikt.util.KaizenParserExtensions.routeToPaths
+import com.reprezen.kaizen.oasparser.model3.Operation
+import com.reprezen.kaizen.oasparser.model3.Path
+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.TypeSpec
+
+class SpringControllerGenerator(
+    private val packages: Packages,
+    private val api: SourceApi,
+    private val services: Collection<ServiceType>,
+    private val models: Collection<ModelType>
+) {
+
+    fun generate(): Controllers =
+        Controllers(
+            api.openApi3.routeToPaths().map { (resourceName, paths) ->
+                buildController(resourceName, paths.values)
+            }.toSet()
+        )
+
+    private fun buildController(resourceName: String, paths: Collection<Path>): ControllerType {
+        val service = services.first { it.spec.name == "$resourceName${ServiceType.SUFFIX}" }
+
+        val typeBuilder: TypeSpec.Builder = controllerBuilder(
+            className = controllerName(resourceName),
+            service = service,
+            basePath = api.openApi3.basePath()
+        )
+
+        paths.flatMap { path ->
+            path.operations
+                .filter { it.key.toUpperCase() != "HEAD" }
+                .map { op ->
+                    val serviceFunction = SpringServiceInterfaceGenerator.toMethod(packages, op.key, op.value, path, models, api)
+                            ?.let { method ->
+                                service.spec.funSpecs.firstOrNull { it.name == method.name }
+                            }
+                    buildFunction(
+                        op.value,
+                        op.key,
+                        path.pathString,
+                        serviceFunction
+                    )
+                }
+        }.forEach { typeBuilder.addFunction(it) }
+
+        return ControllerType(
+            typeBuilder.build(),
+            packages.base
+        )
+    }
+
+    private fun controllerBuilder(
+        className: String,
+        service: ServiceType,
+        basePath: String
+    ) =
+        TypeSpec
+            .classBuilder(className)
+            .withConstructorParams(service)
+            .addAnnotation(SpringAnnotations.CONTROLLER)
+            .addAnnotation(SpringAnnotations.VALIDATED)
+            .addAnnotation(SpringAnnotations.requestMappingBuilder().addMember("%S", basePath).build())
+
+    private fun TypeSpec.Builder.withConstructorParams(service: ServiceType): TypeSpec.Builder {
+        val parameters = mutableListOf(ParameterSpec.builder("service", service.className).build())
+        val properties =
+            mutableListOf(PropertySpec.builder("service", service.className).initializer("service").build())
+
+        return primaryConstructor(
+            FunSpec.constructorBuilder().addParameters(parameters).build()
+        ).addProperties(properties)
+    }
+
+    private fun buildFunction(
+        op: Operation,
+        verb: String,
+        pathString: String,
+        serviceFunc: FunSpec?
+    ): FunSpec {
+        val methodName = methodName(verb, pathString.isSingleResource())
+        val (_, returnType) = op.happyPathResponse(packages.base)
+        val parameters = op.toIncomingParameters(packages.base)
+        val happyResponseCodes = op.responses.keys.filter { it.startsWith("2") }.mapNotNull { it.toIntOrNull() }
+
+        val functionCode: SimpleCodeBlock = if (happyResponseCodes.size == 1 && serviceFunc != null) {
+            val upperCaseVerb = verb.toUpperCase()
+            val happyResponseCode = happyResponseCodes.first()
+            val isResponseBodyPresent = (op.responses["$happyResponseCode"]?.contentMediaTypes?.isNotEmpty()) ?: false
+            when (upperCaseVerb) {
+                "POST" -> when (happyResponseCode) {
+                    200 -> postWithResponsebody(serviceFunc.name, parameters)
+                    201 -> postWithLocationResponse(serviceFunc.name, parameters, isResponseBodyPresent)
+                    else -> genericBody(serviceFunc.name, parameters, happyResponseCode)
+                }
+                "GET" -> getSingleResource(serviceFunc.name, parameters)
+                "PUT" -> putWithNoContent(serviceFunc.name, parameters)
+                "DELETE" -> deleteWithNoContent(serviceFunc.name, parameters)
+                else -> genericBody(serviceFunc.name, parameters, happyResponseCode)
+            }
+        } else SimpleCodeBlock("""TODO("Implement me")""")
+
+        val functionCodeBlock = functionCode.toCodeBlock()
+
+        // Main method builder
+        val funcSpec = FunSpec
+            .builder(methodName)
+            .addKdoc(op.toKdoc())
+            .addCode(functionCodeBlock)
+            .addSpringFunAnnotation(op, verb, pathString)
+            .returns(SpringImports.RESPONSE_ENTITY.parameterizedBy(returnType))
+
+        parameters
+            .map {
+                when (it) {
+                    is BodyParameter -> it
+                        .toParameterSpecBuilder()
+                        .addAnnotation(SpringAnnotations.requestBodyBuilder().build())
+                        .addAnnotation(JavaXAnnotations.validBuilder().build())
+                        .build()
+                    is RequestParameter -> it
+                        .toParameterSpecBuilder()
+                        .addValidationAnnotations(it)
+                        .addSpringParamAnnotation(it)
+                        .build()
+                }
+            }
+            .forEach { funcSpec.addParameter(it) }
+
+        return funcSpec.build()
+    }
+
+    private fun FunSpec.Builder.addSpringFunAnnotation(op: Operation, verb: String, path: String): FunSpec.Builder {
+        val produces = op.responses
+            .flatMap { it.value.contentMediaTypes.keys }
+            .toTypedArray()
+
+        val consumes = op.requestBody
+            .contentMediaTypes.keys
+            .toTypedArray()
+
+        val funcAnnotation =
+            SpringAnnotations
+                .requestMappingBuilder()
+                .addMember("value = [%S]", path)
+                .addMember(
+                    "produces = %L",
+                    produces.joinToString(prefix = "[", postfix = "]", separator = ", ", transform = { "\"$it\"" })
+                )
+                .addMember("method = [RequestMethod.%L]", verb.toUpperCase())
+
+        if (consumes.isNotEmpty()) {
+            funcAnnotation.addMember(
+                "consumes = %L",
+                consumes.joinToString(prefix = "[", postfix = "]", separator = ", ", transform = { "\"$it\"" })
+            )
+        }
+
+        this.addAnnotation(funcAnnotation.build())
+        return this
+    }
+
+    private fun ParameterSpec.Builder.addValidationAnnotations(parameter: RequestParameter): ParameterSpec.Builder {
+        if (parameter.minimum != null) this.addAnnotation(JavaXAnnotations.min(parameter.minimum.toInt()))
+        if (parameter.maximum != null) this.addAnnotation(JavaXAnnotations.max(parameter.maximum.toInt()))
+        if (parameter.typeInfo.isComplexType) this.addAnnotation(JavaXAnnotations.validBuilder().build())
+        return this
+    }
+
+    private fun ParameterSpec.Builder.addSpringParamAnnotation(parameter: RequestParameter): ParameterSpec.Builder =
+        when (parameter.parameterLocation) {
+            QueryParam -> SpringAnnotations.requestParamBuilder()
+            HeaderParam -> SpringAnnotations.requestHeaderBuilder()
+            PathParam -> SpringAnnotations.requestPathVariableBuilder()
+        }.let {
+            it.addMember("value = %S", parameter.oasName)
+            it.addMember("required = %L", parameter.isRequired)
+
+            if (parameter.defaultValue != null)
+                it.addMember("defaultValue = %S", parameter.defaultValue)
+
+            this.addAnnotation(it.build())
+        }
+}
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/metadata/Java.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/metadata/Java.kt
new file mode 100644
index 0000000..633c46f
--- /dev/null
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/metadata/Java.kt
@@ -0,0 +1,41 @@
+package com.cjbooms.fabrikt.generators.controller.metadata
+
+import com.squareup.kotlinpoet.AnnotationSpec
+import com.squareup.kotlinpoet.ClassName
+
+object Imports {
+    val MIN =
+        ClassName("javax.validation.constraints", "Min")
+
+    val MAX =
+        ClassName("javax.validation.constraints", "Max")
+
+    val VALID =
+        ClassName("javax.validation", "Valid")
+}
+
+object JavaXAnnotations {
+    fun validBuilder(): AnnotationSpec.Builder =
+        AnnotationSpec
+            .builder(Imports.VALID)
+
+    fun fieldValid(): AnnotationSpec = validBuilder().useSiteTarget(AnnotationSpec.UseSiteTarget.GET).build()
+
+    private fun minBuilder(): AnnotationSpec.Builder =
+        AnnotationSpec
+            .builder(Imports.MIN)
+
+    private fun maxBuilder(): AnnotationSpec.Builder =
+        AnnotationSpec
+            .builder(Imports.MAX)
+
+    fun min(value: Int): AnnotationSpec =
+        minBuilder()
+            .addMember("%L", value)
+            .build()
+
+    fun max(value: Int): AnnotationSpec =
+        maxBuilder()
+            .addMember("%L", value)
+            .build()
+}
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/metadata/Spring.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/metadata/Spring.kt
new file mode 100644
index 0000000..73ce5ed
--- /dev/null
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/controller/metadata/Spring.kt
@@ -0,0 +1,77 @@
+package com.cjbooms.fabrikt.generators.controller.metadata
+
+import com.squareup.kotlinpoet.AnnotationSpec
+import com.squareup.kotlinpoet.ClassName
+
+object SpringImports {
+
+    private object Packages {
+        private const val SPRING_BASE = "org.springframework"
+
+        const val HTTP = "$SPRING_BASE.http"
+
+        const val STEREOTYPE = "$SPRING_BASE.stereotype"
+
+        const val WEB_BIND_ANNOTATION = "$SPRING_BASE.web.bind.annotation"
+
+        const val SERVLET_SUPPORT = "$SPRING_BASE.web.servlet.support"
+
+        const val VALIDATION_ANNOTATION = "$SPRING_BASE.validation.annotation"
+    }
+
+    val CONTROLLER = ClassName(Packages.STEREOTYPE, "Controller")
+
+    val RESPONSE_ENTITY = ClassName(Packages.HTTP, "ResponseEntity")
+
+    val REQUEST_HEADER = ClassName(Packages.WEB_BIND_ANNOTATION, "RequestHeader")
+
+    val REQUEST_BODY = ClassName(Packages.WEB_BIND_ANNOTATION, "RequestBody")
+
+    val REQUEST_PARAM = ClassName(Packages.WEB_BIND_ANNOTATION, "RequestParam")
+
+    val PATH_VARIABLE = ClassName(Packages.WEB_BIND_ANNOTATION, "PathVariable")
+
+    val REQUEST_MAPPING = ClassName(Packages.WEB_BIND_ANNOTATION, "RequestMapping")
+
+    val VALIDATED = ClassName(Packages.VALIDATION_ANNOTATION, "Validated")
+
+    val URI_BUILDER = ClassName(Packages.SERVLET_SUPPORT, "ServletUriComponentsBuilder")
+
+    object Static {
+        val RESPONSE_STATUS = Pair(Packages.HTTP, "HttpStatus")
+
+        val REQUEST_METHOD = Pair(Packages.WEB_BIND_ANNOTATION, "RequestMethod")
+    }
+}
+
+object SpringAnnotations {
+    val CONTROLLER: AnnotationSpec =
+        AnnotationSpec
+            .builder(SpringImports.CONTROLLER)
+            .build()
+
+    val VALIDATED: AnnotationSpec =
+        AnnotationSpec
+            .builder(SpringImports.VALIDATED)
+            .build()
+
+    fun requestMappingBuilder(): AnnotationSpec.Builder =
+        AnnotationSpec
+            .builder(SpringImports.REQUEST_MAPPING)
+
+    fun requestHeaderBuilder(): AnnotationSpec.Builder =
+        AnnotationSpec
+            .builder(SpringImports.REQUEST_HEADER)
+
+    fun requestBodyBuilder(): AnnotationSpec.Builder =
+        AnnotationSpec
+            .builder(SpringImports.REQUEST_BODY)
+
+    fun requestParamBuilder(): AnnotationSpec.Builder =
+        AnnotationSpec
+            .builder(SpringImports.REQUEST_PARAM)
+
+    fun requestPathVariableBuilder(): AnnotationSpec.Builder =
+        AnnotationSpec
+            .builder(SpringImports.PATH_VARIABLE)
+}
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/JacksonAnnotations.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/model/JacksonAnnotations.kt
similarity index 97%
rename from src/main/kotlin/com/cjbooms/fabrikt/generators/JacksonAnnotations.kt
rename to src/main/kotlin/com/cjbooms/fabrikt/generators/model/JacksonAnnotations.kt
index 4db14ed..5dd7049 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/generators/JacksonAnnotations.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/model/JacksonAnnotations.kt
@@ -1,4 +1,4 @@
-package com.cjbooms.fabrikt.generators
+package com.cjbooms.fabrikt.generators.model
 
 import com.squareup.kotlinpoet.AnnotationSpec
 import com.squareup.kotlinpoet.ClassName
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/JacksonModelGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/model/JacksonModelGenerator.kt
similarity index 76%
rename from src/main/kotlin/com/cjbooms/fabrikt/generators/JacksonModelGenerator.kt
rename to src/main/kotlin/com/cjbooms/fabrikt/generators/model/JacksonModelGenerator.kt
index 2476bd8..d940842 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/generators/JacksonModelGenerator.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/model/JacksonModelGenerator.kt
@@ -1,13 +1,15 @@
-package com.cjbooms.fabrikt.generators
+package com.cjbooms.fabrikt.generators.model
 
-import com.cjbooms.fabrikt.generators.JacksonAnnotations.JSON_VALUE
-import com.cjbooms.fabrikt.generators.JacksonAnnotations.basePolymorphicType
-import com.cjbooms.fabrikt.generators.JacksonAnnotations.polymorphicSubTypes
+import com.cjbooms.fabrikt.configurations.Packages
+import com.cjbooms.fabrikt.generators.ClassType
 import com.cjbooms.fabrikt.generators.PropertyUtils.addToClass
 import com.cjbooms.fabrikt.generators.TypeFactory.createList
 import com.cjbooms.fabrikt.generators.TypeFactory.createMapOfMapsStringToStringAny
 import com.cjbooms.fabrikt.generators.TypeFactory.createMapOfStringToType
 import com.cjbooms.fabrikt.generators.TypeFactory.createMutableMapOfStringToType
+import com.cjbooms.fabrikt.generators.model.JacksonAnnotations.JSON_VALUE
+import com.cjbooms.fabrikt.generators.model.JacksonAnnotations.basePolymorphicType
+import com.cjbooms.fabrikt.generators.model.JacksonAnnotations.polymorphicSubTypes
 import com.cjbooms.fabrikt.model.Destinations.modelsPackage
 import com.cjbooms.fabrikt.model.GeneratedType
 import com.cjbooms.fabrikt.model.KotlinTypeInfo
@@ -36,20 +38,44 @@ import com.squareup.kotlinpoet.TypeName
 import com.squareup.kotlinpoet.TypeSpec
 import com.squareup.kotlinpoet.asTypeName
 
-class JacksonModelGenerator(private val basePackage: String, private val sourceApi: SourceApi) {
+class JacksonModelGenerator(private val packages: Packages, private val sourceApi: SourceApi) {
     companion object {
         fun toModelType(basePackage: String, typeInfo: KotlinTypeInfo, isRequired: Boolean = true): TypeName {
-            val className = toClassName(basePackage, typeInfo)
+            val className =
+                toClassName(
+                    basePackage,
+                    typeInfo
+                )
             val typeName = when (typeInfo) {
-                is KotlinTypeInfo.Array -> createList(toModelType(basePackage, typeInfo.parameterizedType))
+                is KotlinTypeInfo.Array -> createList(
+                    toModelType(
+                        basePackage,
+                        typeInfo.parameterizedType
+                    )
+                )
                 is KotlinTypeInfo.Map ->
                     when (val paramType = typeInfo.parameterizedType) {
                         is KotlinTypeInfo.UntypedProperties -> createMapOfMapsStringToStringAny()
                         is KotlinTypeInfo.UnknownProperties ->
-                            createMapOfStringToType(toClassName(basePackage, paramType))
+                            createMapOfStringToType(
+                                toClassName(
+                                    basePackage,
+                                    paramType
+                                )
+                            )
                         is KotlinTypeInfo.TypedProperties ->
-                            createMapOfStringToType(toClassName(basePackage, paramType))
-                        else -> createMapOfStringToType(toModelType(basePackage, paramType))
+                            createMapOfStringToType(
+                                toClassName(
+                                    basePackage,
+                                    paramType
+                                )
+                            )
+                        else -> createMapOfStringToType(
+                            toModelType(
+                                basePackage,
+                                paramType
+                            )
+                        )
                     }
                 is KotlinTypeInfo.UntypedObject -> createMapOfStringToType(className)
                 is KotlinTypeInfo.UnknownProperties -> createMutableMapOfStringToType(className)
@@ -62,7 +88,10 @@ class JacksonModelGenerator(private val basePackage: String, private val sourceA
 
         private fun toClassName(basePackage: String, typeInfo: KotlinTypeInfo): ClassName =
             if (typeInfo.modelKClass == GeneratedType::class)
-                generatedType(basePackage, typeInfo.generatedModelClassName!!)
+                generatedType(
+                    basePackage,
+                    typeInfo.generatedModelClassName!!
+                )
             else typeInfo.modelKClass.asTypeName()
 
         fun generatedType(basePackage: String, modelName: String) = ClassName(modelsPackage(basePackage), modelName)
@@ -78,7 +107,7 @@ class JacksonModelGenerator(private val basePackage: String, private val sourceA
                     } else emptyList()
                 }.toMutableSet()
 
-        return Models(models.map { ModelType(it, basePackage) })
+        return Models(models.map { ModelType(it, packages.base) })
     }
 
     private fun buildPrimaryModel(modelInfo: ModelInfo, properties: Collection<PropertyInfo>): TypeSpec {
@@ -141,7 +170,12 @@ class JacksonModelGenerator(private val basePackage: String, private val sourceA
 
     private fun buildEnumClass(enum: KotlinTypeInfo.Enum): TypeSpec {
         val classBuilder = TypeSpec
-            .enumBuilder(generatedType(basePackage, enum.enumClassName))
+            .enumBuilder(
+                generatedType(
+                    packages.base,
+                    enum.enumClassName
+                )
+            )
             .primaryConstructor(
                 FunSpec.constructorBuilder()
                     .addParameter("value", String::class)
@@ -175,7 +209,12 @@ class JacksonModelGenerator(private val basePackage: String, private val sourceA
     private fun standardDataClass(modelName: String, properties: Collection<PropertyInfo>): TypeSpec =
         properties.addToClass(
             TypeSpec
-                .classBuilder(generatedType(basePackage, modelName))
+                .classBuilder(
+                    generatedType(
+                        packages.base,
+                        modelName
+                    )
+                )
                 .addModifiers(KModifier.DATA),
             ClassType.VANILLA_MODEL
         )
@@ -185,7 +224,12 @@ class JacksonModelGenerator(private val basePackage: String, private val sourceA
         properties: Collection<PropertyInfo>,
         discriminator: Discriminator
     ): TypeSpec {
-        val classBuilder = TypeSpec.classBuilder(generatedType(basePackage, modelName))
+        val classBuilder = TypeSpec.classBuilder(
+            generatedType(
+                packages.base,
+                modelName
+            )
+        )
             .addModifiers(KModifier.SEALED)
         classBuilder.addAnnotation(basePolymorphicType(discriminator.propertyName))
 
@@ -193,14 +237,16 @@ class JacksonModelGenerator(private val basePackage: String, private val sourceA
             .filter { model -> model.schema.allOfSchemas.any { allOfRef -> allOfRef.discriminator == discriminator } }
             .map {
                 discriminator.mappingKey(it.schema) to toModelType(
-                    basePackage,
+                    packages.base,
                     KotlinTypeInfo.from(it.schema, it.name)
                 )
             }
             .toMap()
         classBuilder.addAnnotation(polymorphicSubTypes(subTypes))
 
-        return properties.addToClass(classBuilder, ClassType.SUPER_MODEL)
+        return properties.addToClass(classBuilder,
+            ClassType.SUPER_MODEL
+        )
     }
 
     private fun polymorphicSubType(
@@ -210,9 +256,19 @@ class JacksonModelGenerator(private val basePackage: String, private val sourceA
     ): TypeSpec =
         properties.addToClass(
             TypeSpec
-                .classBuilder(generatedType(basePackage, modelName))
+                .classBuilder(
+                    generatedType(
+                        packages.base,
+                        modelName
+                    )
+                )
                 .addModifiers(KModifier.DATA)
-                .superclass(toModelType(basePackage, KotlinTypeInfo.from(superType.schema, superType.name))),
+                .superclass(
+                    toModelType(
+                        packages.base,
+                        KotlinTypeInfo.from(superType.schema, superType.name)
+                    )
+                ),
             ClassType.SUB_MODEL
         )
 
@@ -220,8 +276,15 @@ class JacksonModelGenerator(private val basePackage: String, private val sourceA
         val constructorBuilder = FunSpec.constructorBuilder()
         this.forEach {
             it.addToClass(
-                toModelType(basePackage, it.typeInfo, it.isRequired),
-                toClassName(basePackage, it.typeInfo),
+                toModelType(
+                    packages.base,
+                    it.typeInfo,
+                    it.isRequired
+                ),
+                toClassName(
+                    packages.base,
+                    it.typeInfo
+                ),
                 classBuilder,
                 constructorBuilder,
                 classType
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/generators/service/SpringServiceInterfaceGenerator.kt b/src/main/kotlin/com/cjbooms/fabrikt/generators/service/SpringServiceInterfaceGenerator.kt
new file mode 100644
index 0000000..dea9a36
--- /dev/null
+++ b/src/main/kotlin/com/cjbooms/fabrikt/generators/service/SpringServiceInterfaceGenerator.kt
@@ -0,0 +1,193 @@
+package com.cjbooms.fabrikt.generators.service
+
+import com.cjbooms.fabrikt.configurations.Packages
+import com.cjbooms.fabrikt.generators.controller.ControllerGeneratorUtils.toIncomingParameters
+import com.cjbooms.fabrikt.generators.model.JacksonModelGenerator.Companion.toModelType
+import com.cjbooms.fabrikt.model.Destinations.modelsPackage
+import com.cjbooms.fabrikt.model.KotlinTypeInfo
+import com.cjbooms.fabrikt.model.MethodNames
+import com.cjbooms.fabrikt.model.MethodSignature
+import com.cjbooms.fabrikt.model.ModelType
+import com.cjbooms.fabrikt.model.ServiceType
+import com.cjbooms.fabrikt.model.Services
+import com.cjbooms.fabrikt.model.SourceApi
+import com.cjbooms.fabrikt.util.KaizenParserExtensions.isSimpleType
+import com.cjbooms.fabrikt.util.KaizenParserExtensions.routeToPaths
+import com.cjbooms.fabrikt.util.KaizenParserExtensions.safeName
+import com.cjbooms.fabrikt.util.OperationUtils
+import com.cjbooms.fabrikt.util.OperationUtils.maybeListResponseType
+import com.cjbooms.fabrikt.util.SupportedOperation
+import com.reprezen.kaizen.oasparser.model3.Operation
+import com.reprezen.kaizen.oasparser.model3.Path
+import com.reprezen.kaizen.oasparser.model3.Schema
+import com.squareup.kotlinpoet.ClassName
+import com.squareup.kotlinpoet.FunSpec
+import com.squareup.kotlinpoet.KModifier
+import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
+import com.squareup.kotlinpoet.TypeSpec
+import com.squareup.kotlinpoet.asTypeName
+import java.net.URI
+
+class SpringServiceInterfaceGenerator(
+    private val packages: Packages,
+    private val api: SourceApi,
+    private val models: Collection<ModelType>
+) {
+
+    fun generate() = Services(
+        api.openApi3.routeToPaths().map { (resourceName, paths) ->
+            val typeBuilder = TypeSpec.interfaceBuilder("$resourceName${ServiceType.SUFFIX}")
+
+            paths.values
+                .flatMap { pathToMethods(it) }
+                .map { toFunSpec(it) }
+                .forEach { typeBuilder.addFunction(it) }
+
+            ServiceType(typeBuilder.build(), packages.base)
+        }.toSet()
+    )
+
+    fun pathToMethods(path: Path): List<MethodSignature> =
+        path.operations
+            .map { (verb, operation) ->
+                toMethod(
+                    packages,
+                    verb,
+                    operation,
+                    path,
+                    models,
+                    api
+                )
+            }
+            .filterNotNull()
+            .distinct()
+
+    private fun toFunSpec(method: MethodSignature): FunSpec {
+        val spec = FunSpec
+            .builder(method.name)
+            .addModifiers(KModifier.ABSTRACT)
+            .returns(method.returnType)
+
+        method
+            .parameters
+            .map { it.toParameterSpecBuilder().build() }
+            .forEach { spec.addParameter(it) }
+
+        return spec.build()
+    }
+
+    companion object {
+        fun toMethod(
+            packages: Packages,
+            verb: String,
+            operation: Operation,
+            path: Path,
+            models: Collection<ModelType>,
+            api: SourceApi
+        ): MethodSignature? {
+            val supportedOperation = OperationUtils.supportedOperationFrom(verb, operation, path, api)
+
+            return toMethod(packages, operation, models, supportedOperation)
+        }
+
+        /**
+         * This method relies heavily on a lot of fabrikt assumptions in order to
+         * build a service like interface representation
+         */
+        private fun toMethod(
+            packages: Packages,
+            operation: Operation,
+            models: Collection<ModelType>,
+            supportedOperation: SupportedOperation?
+        ): MethodSignature? = supportedOperation?.let { supportedOp ->
+            when (supportedOp) {
+                SupportedOperation.CREATE, SupportedOperation.CREATE_SUBRESOURCE, SupportedOperation.DEFAULT_POST -> {
+                    val type =
+                        if (OperationUtils.isResponseBodyPresent(operation)) responseType(operation)
+                        else requestType(operation)
+
+                    val objectResponseType =
+                        if (type != null) {
+                            if (type.isSimpleType()) toModelType(packages.base, KotlinTypeInfo.from(type))
+                            else models.find { it.spec.name == type.safeName() }?.className
+                                ?: throw RuntimeException(
+                                    "Could not find `${type.safeName()}` schema when trying to create Service Interface for `$supportedOp` " +
+                                        "operation. Available schemata are: ${models.map { it.spec.name }.joinToString()}"
+                                )
+                        } else Unit::class.asTypeName()
+
+                    val returnType = Pair::class.asTypeName()
+                        .parameterizedBy(URI::class.asTypeName(), objectResponseType.copy(nullable = true))
+
+                    MethodSignature(
+                        name = MethodNames.CREATE,
+                        returnType = returnType,
+                        parameters = operation.toIncomingParameters(packages.base)
+                    )
+                }
+                SupportedOperation.READ, SupportedOperation.READ_SUBRESOURCE, SupportedOperation.READ_TOP_LEVEL_SUBRESOURCE, SupportedOperation.DEFAULT_GET -> {
+                    responseType(operation)
+                        ?.let { responseType ->
+                            MethodSignature(
+                                name = MethodNames.READ,
+                                returnType = models.first { it.spec.name == responseType.safeName() }.className,
+                                parameters = operation.toIncomingParameters(packages.base)
+                            )
+                        }
+                }
+                SupportedOperation.UPDATE, SupportedOperation.UPDATE_SUBRESOURCE, SupportedOperation.DEFAULT_PUT -> {
+                    val requestType = requestType(operation)
+                    val returnType =
+                        if (requestType != null) models.first { it.spec.name == requestType.safeName() }.className
+                        else Unit::class.asTypeName()
+
+                    MethodSignature(
+                        name = MethodNames.UPDATE,
+                        returnType = returnType,
+                        parameters = operation.toIncomingParameters(packages.base)
+                    )
+                }
+                SupportedOperation.QUERY, SupportedOperation.QUERY_SUBRESOURCE, SupportedOperation.QUERY_TOP_LEVEL_SUBRESOURCE -> {
+                    operation.maybeListResponseType()
+                        ?.let { modelName ->
+                            MethodSignature(
+                                name = MethodNames.QUERY,
+                                returnType = queryResultOrListType(modelName, packages.base),
+                                parameters = operation.toIncomingParameters(packages.base)
+                            )
+                        }
+                }
+                SupportedOperation.ADD_TOP_LEVEL_SUBRESOURCE -> {
+                    MethodSignature(
+                        name = MethodNames.ADD_SUBRESOURCE,
+                        returnType = Unit::class.asTypeName(),
+                        parameters = operation.toIncomingParameters(packages.base)
+                    )
+                }
+                SupportedOperation.REMOVE_TOP_LEVEL_SUBRESOURCE, SupportedOperation.DEFAULT_DELETE -> {
+                    MethodSignature(
+                        name = MethodNames.REMOVE_SUBRESOURCE,
+                        returnType = Unit::class.asTypeName(),
+                        parameters = operation.toIncomingParameters(packages.base)
+                    )
+                }
+            }
+        }
+
+        private fun queryResultOrListType(modelName: String, basePackage: String) =
+            List::class.asTypeName().parameterizedBy(ClassName(modelsPackage(basePackage), modelName))
+
+        private fun responseType(operation: Operation): Schema? = operation.responses
+            .filter { it.key != "default" }
+            .flatMap { resp -> resp.value.contentMediaTypes.map { it.value?.schema } }
+            .asSequence()
+            .filterNotNull()
+            .firstOrNull()
+
+        private fun requestType(operation: Operation): Schema? = operation.requestBody.contentMediaTypes
+            .map { it.value?.schema }
+            .asSequence()
+            .filterNotNull()
+            .firstOrNull()
+    }
+}
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/model/Destinations.kt b/src/main/kotlin/com/cjbooms/fabrikt/model/Destinations.kt
index 35fc606..8a264c3 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/model/Destinations.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/model/Destinations.kt
@@ -9,17 +9,9 @@ object Destinations {
     const val MAIN_KT_SOURCE = "src/main/kotlin/"
     val MAIN_KT_SRC: Path = PATH_ROOT.resolve(MAIN_KT_SOURCE)
     private const val MAIN_RESOURCES_STRING = "src/main/resources"
-    val MAIN_RESOURCES: Path = PATH_ROOT.resolve(MAIN_RESOURCES_STRING)
 
-    fun entitiesPackage(basePackage: String): String = "$basePackage.entities"
-    fun repositoriesPackage(basePackage: String): String = "$basePackage.repositories"
     fun servicesPackage(basePackage: String): String = "$basePackage.service"
-    fun servicesImplPackage(basePackage: String): String = "$basePackage.service.impl"
     fun modelsPackage(basePackage: String): String = "$basePackage.models"
     fun controllersPackage(basePackage: String): String = "$basePackage.controllers"
-    fun convertersPackage(basePackage: String): String = "$basePackage.converters"
-    fun utilsPackage(basePackage: String) = "$basePackage.utils"
-    fun extensionsPackage(basePackage: String) = "$basePackage.extensions"
     fun clientPackage(basePackage: String) = "$basePackage.client"
-    fun configurationPackage(basePackage: String) = "$basePackage.configuration"
 }
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/model/KotlinGenModels.kt b/src/main/kotlin/com/cjbooms/fabrikt/model/KotlinGenModels.kt
index 67085ed..9e81360 100644
--- a/src/main/kotlin/com/cjbooms/fabrikt/model/KotlinGenModels.kt
+++ b/src/main/kotlin/com/cjbooms/fabrikt/model/KotlinGenModels.kt
@@ -1,11 +1,18 @@
 package com.cjbooms.fabrikt.model
 
+import com.cjbooms.fabrikt.generators.controller.metadata.SpringImports
 import com.cjbooms.fabrikt.model.Destinations.clientPackage
+import com.cjbooms.fabrikt.model.Destinations.controllersPackage
 import com.cjbooms.fabrikt.model.Destinations.modelsPackage
 import com.cjbooms.fabrikt.model.Destinations.servicesPackage
+import com.cjbooms.fabrikt.util.NormalisedString.toKotlinParameterName
+import com.reprezen.kaizen.oasparser.model3.Parameter
+import com.reprezen.kaizen.oasparser.model3.Schema
 import com.squareup.kotlinpoet.ClassName
 import com.squareup.kotlinpoet.FileSpec
 import com.squareup.kotlinpoet.KModifier
+import com.squareup.kotlinpoet.ParameterSpec
+import com.squareup.kotlinpoet.TypeName
 import com.squareup.kotlinpoet.TypeSpec
 
 sealed class GeneratedType(val spec: TypeSpec, val destinationPackage: String) {
@@ -20,20 +27,24 @@ abstract class KotlinTypes(types: Collection<GeneratedType>) {
     }.toSet()
 }
 
+class ModelType(spec: TypeSpec, basePackage: String) : GeneratedType(spec, modelsPackage(basePackage))
+
+class ClientType(spec: TypeSpec, basePackage: String) : GeneratedType(spec, clientPackage(basePackage)) {
+    companion object {
+        const val SIMPLE_CLIENT_SUFFIX = "Client"
+        const val ENHANCED_CLIENT_SUFFIX = "Service"
+    }
+}
+
 class ServiceType(spec: TypeSpec, basePackage: String) : GeneratedType(spec, servicesPackage(basePackage)) {
     companion object {
         const val SUFFIX = "Service"
     }
 }
 
-class Services(val services: Collection<ServiceType>) : KotlinTypes(services)
-
-class ModelType(spec: TypeSpec, basePackage: String) : GeneratedType(spec, modelsPackage(basePackage))
-
-class ClientType(spec: TypeSpec, basePackage: String) : GeneratedType(spec, clientPackage(basePackage)) {
+class ControllerType(spec: TypeSpec, basePackage: String) : GeneratedType(spec, controllersPackage(basePackage)) {
     companion object {
-        const val SIMPLE_CLIENT_SUFFIX = "Client"
-        const val ENHANCED_CLIENT_SUFFIX = "Service"
+        const val SUFFIX = "Controller"
     }
 }
 
@@ -45,7 +56,25 @@ data class Clients(val clients: Collection<ClientType>) : KotlinTypes(clients) {
     override val files: Collection<FileSpec> = clients.toFileSpec()
 }
 
-private fun <T : GeneratedType> Collection<T>.toFileSpec(): Collection<FileSpec> = groupClasses(this)
+data class Services(val services: Collection<ServiceType>) : KotlinTypes(services) {
+    override val files: Collection<FileSpec> = services.toFileSpec()
+}
+
+data class Controllers(val controllers: Collection<ControllerType>) : KotlinTypes(controllers) {
+    override val files: Collection<FileSpec> = super.files.map {
+        it.toBuilder()
+            .addImport(SpringImports.Static.REQUEST_METHOD.first, SpringImports.Static.REQUEST_METHOD.second)
+            .addImport(
+                SpringImports.Static.RESPONSE_STATUS.first,
+                SpringImports.Static.RESPONSE_STATUS.second
+            )
+            .build()
+    }
+}
+
+data class MethodSignature(val name: String, val returnType: TypeName, val parameters: List<IncomingParameter>)
+
+fun <T : GeneratedType> Collection<T>.toFileSpec(): Collection<FileSpec> = groupClasses(this)
     .map {
         val builder = FileSpec.builder(it.key.destinationPackage, it.key.className.simpleName)
             .addType(it.key.spec)
@@ -68,3 +97,46 @@ private fun <T : GeneratedType> groupClasses(allModels: Collection<T>): Map<T, L
         .toMap()
     return sealedClasses.plus(otherClasses)
 }
+
+/**
+ * The IncomingParameter class is intended to represent a given name and type
+ * of an incoming request, be it either a header, url param, path param, or body
+ */
+sealed class IncomingParameter(val oasName: String, val type: TypeName) {
+    val name: String = oasName.toKotlinParameterName()
+    fun toParameterSpecBuilder(): ParameterSpec.Builder =
+        ParameterSpec.builder(name, type)
+}
+
+class BodyParameter(oasName: String, type: TypeName, val schema: Schema) : IncomingParameter(oasName, type)
+
+class RequestParameter(
+    oasName: String,
+    type: TypeName,
+    val parameterLocation: RequestParameterLocation,
+    val typeInfo: KotlinTypeInfo,
+    val minimum: Number? = null,
+    val maximum: Number? = null,
+    val isRequired: Boolean = false,
+    val defaultValue: Any? = null
+) : IncomingParameter(oasName, type) {
+    constructor(oasName: String, type: TypeName, parameter: Parameter) : this(
+        oasName = oasName,
+        type = type,
+        typeInfo = KotlinTypeInfo.from(parameter.schema, oasName),
+        minimum = parameter.schema.minimum,
+        maximum = parameter.schema.maximum,
+        parameterLocation = RequestParameterLocation(parameter.`in`),
+        isRequired = parameter.isRequired,
+        defaultValue = parameter.schema.default
+    )
+}
+
+object MethodNames {
+    const val READ = "read"
+    const val QUERY = "query"
+    const val CREATE = "create"
+    const val UPDATE = "update"
+    const val REMOVE_SUBRESOURCE = "removeSubresource"
+    const val ADD_SUBRESOURCE = "addSubresource"
+}
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/model/ParameterInfo.kt b/src/main/kotlin/com/cjbooms/fabrikt/model/ParameterInfo.kt
new file mode 100644
index 0000000..d195719
--- /dev/null
+++ b/src/main/kotlin/com/cjbooms/fabrikt/model/ParameterInfo.kt
@@ -0,0 +1,17 @@
+package com.cjbooms.fabrikt.model
+
+sealed class RequestParameterLocation {
+    companion object {
+        operator fun invoke(location: String): RequestParameterLocation =
+            when (location) {
+                "query" -> QueryParam
+                "header" -> HeaderParam
+                "path" -> PathParam
+                else -> throw IllegalStateException("Invalid request parameter location: $location")
+            }
+    }
+}
+
+object QueryParam : RequestParameterLocation()
+object HeaderParam : RequestParameterLocation()
+object PathParam : RequestParameterLocation()
diff --git a/src/main/kotlin/com/cjbooms/fabrikt/util/OperationUtils.kt b/src/main/kotlin/com/cjbooms/fabrikt/util/OperationUtils.kt
new file mode 100644
index 0000000..bd60fa6
--- /dev/null
+++ b/src/main/kotlin/com/cjbooms/fabrikt/util/OperationUtils.kt
@@ -0,0 +1,235 @@
+package com.cjbooms.fabrikt.util
+
+import com.cjbooms.fabrikt.model.SourceApi
+import com.cjbooms.fabrikt.util.KaizenParserExtensions.getResourceName
+import com.cjbooms.fabrikt.util.KaizenParserExtensions.isSingleResource
+import com.cjbooms.fabrikt.util.KaizenParserExtensions.safeName
+import com.reprezen.kaizen.oasparser.model3.Operation
+import com.reprezen.kaizen.oasparser.model3.Path
+
+enum class SupportedOperation {
+    // Top level resources
+    CREATE,
+    READ,
+    UPDATE,
+    QUERY,
+    // Subresource which is not a top level resource
+    CREATE_SUBRESOURCE,
+    QUERY_SUBRESOURCE,
+    UPDATE_SUBRESOURCE,
+    READ_SUBRESOURCE,
+    // Subresource which is a top level resource
+    ADD_TOP_LEVEL_SUBRESOURCE,
+    READ_TOP_LEVEL_SUBRESOURCE,
+    QUERY_TOP_LEVEL_SUBRESOURCE,
+    REMOVE_TOP_LEVEL_SUBRESOURCE,
+
+    // Fallback operations when we can't understand what's happening with the API
+    DEFAULT_GET,
+    DEFAULT_PUT,
+    DEFAULT_POST,
+    DEFAULT_DELETE
+}
+
+object OperationUtils {
+
+    private fun isTopLevelResource(path: Path): Boolean =
+        path.pathString.split("/").filterNot { it.isEmpty() }.count() <= 2
+
+    private fun isTopLevelSubResource(path: Path, api: SourceApi): Boolean =
+        !isTopLevelResource(path) && isAlsoExposedAsTopLevelResource(
+            path,
+            api
+        )
+
+    private fun isNonTopLevelSubresource(path: Path, api: SourceApi): Boolean =
+        !isTopLevelResource(path) && !isAlsoExposedAsTopLevelResource(
+            path,
+            api
+        )
+
+    private fun isAlsoExposedAsTopLevelResource(path: Path, api: SourceApi): Boolean {
+        val resourceName = path.getResourceName()
+        val indexInPath = path.pathString.split("/").indexOf(resourceName)
+        return api.openApi3.paths
+            .map { it.key }
+            .filterNot { it == path.pathString }
+            .map { it.split("/") }
+            .any {
+                val indexInDifferentPath = it.indexOf(resourceName)
+                indexInDifferentPath > -1 && indexInDifferentPath < indexInPath
+            }
+    }
+
+    private fun isGet(verb: String) = "GET" == verb.toUpperCase()
+    private fun isPost(verb: String) = "POST" == verb.toUpperCase()
+    private fun isPut(verb: String) = "PUT" == verb.toUpperCase()
+    private fun isHead(verb: String) = "HEAD" == verb.toUpperCase()
+    private fun isDelete(verb: String) = "DELETE" == verb.toUpperCase()
+
+    // Top level resources
+    private fun isRead(verb: String, path: Path): Boolean =
+        isTopLevelResource(path) && path.isSingleResource() && isGet(
+            verb
+        )
+
+    private fun isQuery(verb: String, path: Path, operation: Operation): Boolean =
+        isTopLevelResource(path) && !path.isSingleResource() && isGet(
+            verb
+        ) && operation.isListResponseType()
+
+    fun Operation.isListResponseType(): Boolean = maybeListResponseType() != null
+
+    fun Operation.maybeListResponseType(): String? = responses
+        .filter { it.key != "default" }
+        .flatMap { resp -> resp.value.contentMediaTypes.map { it.value.schema.properties["items"]?.itemsSchema?.safeName() } }
+        .asSequence()
+        .filterNotNull()
+        .firstOrNull()
+
+    private fun isCreate(verb: String, path: Path): Boolean =
+        isTopLevelResource(path) && !path.isSingleResource() && isPost(
+            verb
+        )
+
+    private fun isUpdate(verb: String, path: Path): Boolean =
+        isTopLevelResource(path) && path.isSingleResource() && isPut(
+            verb
+        )
+
+    // Subresource which is not a top level resource
+
+    private fun isReadSubresource(verb: String, path: Path, api: SourceApi): Boolean =
+        isNonTopLevelSubresource(
+            path,
+            api
+        ) && path.isSingleResource() && isGet(verb)
+
+    private fun isQuerySubresource(verb: String, path: Path, api: SourceApi, operation: Operation): Boolean =
+        isNonTopLevelSubresource(
+            path,
+            api
+        ) && !path.isSingleResource() && isGet(verb) && operation.isListResponseType()
+
+    private fun isCreateSubresource(verb: String, path: Path, api: SourceApi): Boolean =
+        isNonTopLevelSubresource(
+            path,
+            api
+        ) && !path.isSingleResource() && isPost(verb)
+
+    private fun isUpdateSubresource(verb: String, path: Path, api: SourceApi): Boolean =
+        isNonTopLevelSubresource(
+            path,
+            api
+        ) && path.isSingleResource() && isPut(verb)
+
+    // Subresource which is a top level resource
+
+    private fun isReadTopLevelSubresource(verb: String, path: Path, api: SourceApi): Boolean =
+        isTopLevelSubResource(
+            path,
+            api
+        ) && path.isSingleResource() && isGet(verb)
+
+    private fun isQueryTopLevelSubresource(verb: String, path: Path, api: SourceApi): Boolean =
+        isTopLevelSubResource(
+            path,
+            api
+        ) && !path.isSingleResource() && isGet(verb)
+
+    private fun isRemoveSubresource(verb: String, path: Path, api: SourceApi): Boolean =
+        isTopLevelSubResource(
+            path,
+            api
+        ) && path.isSingleResource() && isDelete(verb)
+
+    private fun isAddSubresource(verb: String, path: Path, api: SourceApi): Boolean =
+        isTopLevelSubResource(
+            path,
+            api
+        ) && path.isSingleResource() && isPut(verb)
+
+    private fun isHead(verb: String, path: Path): Boolean =
+        path.isSingleResource() && isHead(verb)
+
+    fun supportedOperationFrom(verb: String, operation: Operation, path: Path, api: SourceApi): SupportedOperation? {
+        return when {
+            isRead(
+                verb,
+                path
+            ) -> SupportedOperation.READ
+            isHead(
+                verb,
+                path
+            ) -> SupportedOperation.READ
+            isQuery(
+                verb,
+                path,
+                operation
+            ) -> SupportedOperation.QUERY
+            isCreate(
+                verb,
+                path
+            ) -> SupportedOperation.CREATE
+            isUpdate(
+                verb,
+                path
+            ) -> SupportedOperation.UPDATE
+
+            isReadSubresource(
+                verb,
+                path,
+                api
+            ) -> SupportedOperation.READ_SUBRESOURCE
+            isQuerySubresource(
+                verb,
+                path,
+                api,
+                operation
+            ) -> SupportedOperation.QUERY_SUBRESOURCE
+            isCreateSubresource(
+                verb,
+                path,
+                api
+            ) -> SupportedOperation.CREATE_SUBRESOURCE
+            isUpdateSubresource(
+                verb,
+                path,
+                api
+            ) -> SupportedOperation.UPDATE_SUBRESOURCE
+
+            isReadTopLevelSubresource(
+                verb,
+                path,
+                api
+            ) -> SupportedOperation.READ_TOP_LEVEL_SUBRESOURCE
+            isQueryTopLevelSubresource(
+                verb,
+                path,
+                api
+            ) -> SupportedOperation.QUERY_TOP_LEVEL_SUBRESOURCE
+            isAddSubresource(
+                verb,
+                path,
+                api
+            ) -> SupportedOperation.ADD_TOP_LEVEL_SUBRESOURCE
+            isRemoveSubresource(
+                verb,
+                path,
+                api
+            ) -> SupportedOperation.REMOVE_TOP_LEVEL_SUBRESOURCE
+
+            else -> when {
+                isGet(verb) -> SupportedOperation.DEFAULT_GET
+                isHead(verb) -> SupportedOperation.DEFAULT_GET
+                isPut(verb) -> SupportedOperation.DEFAULT_PUT
+                isPost(verb) -> SupportedOperation.DEFAULT_POST
+                isDelete(verb) -> SupportedOperation.DEFAULT_DELETE
+                else -> null
+            }
+        }
+    }
+
+    fun isResponseBodyPresent(operation: Operation): Boolean =
+        operation.responses["200"] != null
+}


Test patch:
diff --git a/src/test/kotlin/com/cjbooms/fabrikt/generators/ModelGeneratorTest.kt b/src/test/kotlin/com/cjbooms/fabrikt/generators/ModelGeneratorTest.kt
index aa0bfe6..be6ab13 100644
--- a/src/test/kotlin/com/cjbooms/fabrikt/generators/ModelGeneratorTest.kt
+++ b/src/test/kotlin/com/cjbooms/fabrikt/generators/ModelGeneratorTest.kt
@@ -1,5 +1,7 @@
 package com.cjbooms.fabrikt.generators
 
+import com.cjbooms.fabrikt.configurations.Packages
+import com.cjbooms.fabrikt.generators.model.JacksonModelGenerator
 import com.cjbooms.fabrikt.model.Models
 import com.cjbooms.fabrikt.model.SourceApi
 import com.cjbooms.fabrikt.validation.Linter
@@ -57,7 +59,10 @@ class ModelGeneratorTest {
         val sourceApi = SourceApi(apiLocation.readText(), baseDir = Paths.get(apiLocation.toURI()))
         val expectedModels = javaClass.getResource("/examples/$testCaseName/models/Models.kt").readText()
 
-        val models = JacksonModelGenerator(basePackage, sourceApi).generate().toSingleFile()
+        val models = JacksonModelGenerator(
+            Packages(basePackage),
+            sourceApi
+        ).generate().toSingleFile()
 
         assertThat(models).isEqualTo(expectedModels)
     }
@@ -68,7 +73,10 @@ class ModelGeneratorTest {
         val spec = javaClass.getResource("/examples/polymorphicModels/api.yaml").readText()
         val expectedModels = javaClass.getResource("/examples/polymorphicModels/models/Models.kt").readText()
 
-        val models = JacksonModelGenerator(basePackage, SourceApi(spec)).generate()
+        val models = JacksonModelGenerator(
+            Packages(basePackage),
+            SourceApi(spec)
+        ).generate()
 
         assertThat(Linter.lintString(models.files.first().toString())).isEqualTo(expectedModels)
     }
diff --git a/src/test/kotlin/com/cjbooms/fabrikt/client/OkHttpClientGeneratorTest.kt b/src/test/kotlin/com/cjbooms/fabrikt/generators/OkHttpClientGeneratorTest.kt
similarity index 84%
rename from src/test/kotlin/com/cjbooms/fabrikt/client/OkHttpClientGeneratorTest.kt
rename to src/test/kotlin/com/cjbooms/fabrikt/generators/OkHttpClientGeneratorTest.kt
index b85e3c3..6f2d146 100644
--- a/src/test/kotlin/com/cjbooms/fabrikt/client/OkHttpClientGeneratorTest.kt
+++ b/src/test/kotlin/com/cjbooms/fabrikt/generators/OkHttpClientGeneratorTest.kt
@@ -1,10 +1,10 @@
-package com.cjbooms.fabrikt.client
+package com.cjbooms.fabrikt.generators
 
 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.generators.client.OkHttpEnhancedClientGenerator
+import com.cjbooms.fabrikt.generators.client.OkHttpSimpleClientGenerator
+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
@@ -33,8 +33,14 @@ class OkHttpClientGeneratorTest {
         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)
+        val models = JacksonModelGenerator(
+            packages,
+            sourceApi
+        ).generate().toSingleFile()
+        val simpleClientCode = OkHttpSimpleClientGenerator(
+            packages,
+            sourceApi
+        )
             .generateDynamicClientCode()
             .toSingleFile()
 
@@ -51,7 +57,8 @@ class OkHttpClientGeneratorTest {
         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 generator =
+            OkHttpEnhancedClientGenerator(packages, sourceApi)
         val enhancedLibUtil = generator.generateLibrary(setOf(ClientCodeGenOptionType.RESILIENCE4J))
                 .filterIsInstance<SimpleFile>()
                 .first { it.path.fileName.toString() == "HttpResilience4jUtil.kt" }
@@ -67,7 +74,10 @@ class OkHttpClientGeneratorTest {
         val packages = Packages("examples.$testCaseName")
         val sourceApi = SourceApi(javaClass.getResource("/examples/$testCaseName/api.yaml").readText())
 
-        val enhancedClientCode = OkHttpEnhancedClientGenerator(packages, sourceApi)
+        val enhancedClientCode = OkHttpEnhancedClientGenerator(
+            packages,
+            sourceApi
+        )
             .generateDynamicClientCode(emptySet())
 
         assertThat(enhancedClientCode).isEqualTo(emptySet<ClientType>())
@@ -81,7 +91,10 @@ class OkHttpClientGeneratorTest {
 
         val expectedHttpUtils = javaClass.getResource("/examples/$testCaseName/client/HttpUtil.kt").readText()
 
-        val generatedHttpUtils = OkHttpSimpleClientGenerator(packages, sourceApi).generateLibrary().filterIsInstance<SimpleFile>()
+        val generatedHttpUtils = OkHttpSimpleClientGenerator(
+            packages,
+            sourceApi
+        ).generateLibrary().filterIsInstance<SimpleFile>()
             .first { it.path.fileName.toString() == "HttpUtil.kt" }
 
         assertThat(generatedHttpUtils.content).isEqualTo(expectedHttpUtils)
@@ -95,7 +108,10 @@ class OkHttpClientGeneratorTest {
 
         val expectedLoggingInterceptor = javaClass.getResource("/examples/$testCaseName/client/LoggingInterceptor.kt").readText()
 
-        val generatedLoggingInterceptor = OkHttpSimpleClientGenerator(packages, sourceApi).generateLibrary().filterIsInstance<SimpleFile>()
+        val generatedLoggingInterceptor = OkHttpSimpleClientGenerator(
+            packages,
+            sourceApi
+        ).generateLibrary().filterIsInstance<SimpleFile>()
             .first { it.path.fileName.toString() == "LoggingInterceptor.kt" }
 
         assertThat(generatedLoggingInterceptor.content).isEqualTo(expectedLoggingInterceptor)
diff --git a/src/test/kotlin/com/cjbooms/fabrikt/generators/SpringControllerGeneratorTest.kt b/src/test/kotlin/com/cjbooms/fabrikt/generators/SpringControllerGeneratorTest.kt
new file mode 100644
index 0000000..c5d1925
--- /dev/null
+++ b/src/test/kotlin/com/cjbooms/fabrikt/generators/SpringControllerGeneratorTest.kt
@@ -0,0 +1,192 @@
+package com.cjbooms.fabrikt.generators
+
+import com.cjbooms.fabrikt.configurations.Packages
+import com.cjbooms.fabrikt.generators.controller.SpringControllerGenerator
+import com.cjbooms.fabrikt.generators.controller.metadata.SpringImports
+import com.cjbooms.fabrikt.generators.model.JacksonModelGenerator
+import com.cjbooms.fabrikt.generators.service.SpringServiceInterfaceGenerator
+import com.cjbooms.fabrikt.model.Controllers
+import com.cjbooms.fabrikt.model.Destinations.controllersPackage
+import com.cjbooms.fabrikt.model.Destinations.servicesPackage
+import com.cjbooms.fabrikt.model.SourceApi
+import com.cjbooms.fabrikt.validation.Linter
+import com.squareup.kotlinpoet.FileSpec
+import com.squareup.kotlinpoet.TypeSpec
+import java.util.stream.Stream
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+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 SpringControllerGeneratorTest {
+    private val basePackage = "ie.zalando"
+    private lateinit var generated: Collection<FileSpec>
+
+    private fun testCases(): Stream<String> = Stream.of(
+        "simpleRequestBody",
+        "putApi",
+        "customerExampleApi"
+    )
+
+    private fun setupGithubApiTestEnv() {
+        val api = SourceApi(javaClass.getResource("/examples/githubApi/open-api/api.yaml").readText())
+        val models = JacksonModelGenerator(Packages(basePackage), api).generate().models
+        val services = SpringServiceInterfaceGenerator(Packages(basePackage), api, models).generate().services
+        generated = SpringControllerGenerator(Packages(basePackage), api, services, models).generate().files
+    }
+
+    @Test
+    fun `should contain correct number of controller classes`() {
+        setupGithubApiTestEnv()
+        assertThat(generated.size).isEqualTo(5)
+    }
+
+    @Test
+    fun `the correct controllers are generated`() {
+        setupGithubApiTestEnv()
+        assertThat(generated.map { it.name })
+            .containsOnly(
+                "ContributorsController",
+                "OrganisationsController",
+                "OrganisationsContributorsController",
+                "RepositoriesController",
+                "RepositoriesPullRequestsController"
+            )
+    }
+
+    @Test
+    fun `ensure package is correct`() {
+        setupGithubApiTestEnv()
+        assertThat(generated.map { it.packageName }.distinct())
+            .containsOnly(controllersPackage(basePackage))
+    }
+
+    @Test
+    fun `ensure controller has correct annotations`() {
+        setupGithubApiTestEnv()
+        val controllerAnnotations =
+            generated.flatMap { it.members.flatMap { (it as TypeSpec).annotationSpecs.map { it.className.toString() } } }
+                .distinct()
+
+        assertThat(controllerAnnotations).containsOnly(
+            "org.springframework.stereotype.Controller",
+            "org.springframework.validation.annotation.Validated",
+            "org.springframework.web.bind.annotation.RequestMapping"
+        )
+    }
+
+    @Test
+    fun `ensure controller has correct service parameter`() {
+        setupGithubApiTestEnv()
+        val controllerServices =
+            generated.flatMap { it.members.flatMap { (it as TypeSpec).propertySpecs.map { it.type.toString() } } }
+        val servicePackage = servicesPackage(basePackage)
+        assertThat(controllerServices).containsOnly(
+            "$servicePackage.ContributorsService",
+            "$servicePackage.OrganisationsService",
+            "$servicePackage.OrganisationsContributorsService",
+            "$servicePackage.RepositoriesService",
+            "$servicePackage.RepositoriesPullRequestsService"
+        )
+    }
+
+    @Test
+    fun `ensure controllers have all functions`() {
+        setupGithubApiTestEnv()
+        val manyToMany = listOf("OrganisationsContributorsController")
+        val manyToOne = listOf(
+            "ContributorsController",
+            "OrganisationsController",
+            "RepositoriesController",
+            "RepositoriesPullRequestsController"
+        )
+
+        assertThat(generated.map { it.name }).containsExactlyInAnyOrderElementsOf(manyToMany.union(manyToOne))
+
+        generated.forEach { gen ->
+            if (manyToMany.contains(gen.name))
+                assertThat(gen.members.flatMap { mem -> (mem as TypeSpec).funSpecs.map { it.name } })
+                    .containsExactlyInAnyOrder("get", "deleteById", "getById", "putById")
+            else
+                assertThat(gen.members.flatMap { mem -> (mem as TypeSpec).funSpecs.map { it.name } })
+                    .containsExactlyInAnyOrder("get", "post", "getById", "putById")
+        }
+    }
+
+    @Test
+    fun `ensure that subresource specific controllers are created`() {
+        val api = SourceApi(javaClass.getResource("/examples/githubApi/open-api/api.yaml").readText())
+        val models = JacksonModelGenerator(Packages(basePackage), api).generate().models
+        val services = SpringServiceInterfaceGenerator(Packages(basePackage), api, models).generate().services
+        val controllers = SpringControllerGenerator(Packages(basePackage), api, services, models).generate()
+
+        assertThat(controllers.files).size().isEqualTo(5)
+        assertThat(controllers.files.map { it.name }).containsAll(
+            listOf(
+                "ContributorsController",
+                "OrganisationsController",
+                "OrganisationsContributorsController",
+                "RepositoriesController",
+                "RepositoriesPullRequestsController"
+            )
+        )
+
+        val linkedFunctionNames = controllers.files
+            .filter { it.name == "OrganisationsContributorsController" }
+            .flatMap { it.members }
+            .flatMap { (it as TypeSpec).funSpecs.map { it.name } }
+        val ownedFunctionNames = controllers.files
+            .filter { it.name == "RepositoriesPullRequestsController" }
+            .flatMap { it.members }
+            .flatMap { (it as TypeSpec).funSpecs.map { it.name } }
+        assertThat(linkedFunctionNames).containsExactlyInAnyOrder(
+            "get",
+            "getById",
+            "putById",
+            "deleteById"
+        )
+        assertThat(ownedFunctionNames).containsExactlyInAnyOrder(
+            "get",
+            "getById",
+            "post",
+            "putById"
+        )
+    }
+
+    @ParameterizedTest
+    @MethodSource("testCases")
+    fun `correct models are generated for different OpenApi Specifications`(testCaseName: String) {
+        val basePackage = "examples.$testCaseName"
+        val api = SourceApi(javaClass.getResource("/examples/$testCaseName/open-api/api.yaml").readText())
+        val expectedControllers = javaClass.getResource("/examples/$testCaseName/controllers/Controllers.kt").readText()
+
+        val models = JacksonModelGenerator(Packages(basePackage), api).generate().models
+        val services = SpringServiceInterfaceGenerator(Packages(basePackage), api, models).generate().services
+        val controllers = SpringControllerGenerator(
+            Packages(basePackage),
+            api,
+            services,
+            models
+        ).generate().toSingleFile()
+
+        assertThat(controllers).isEqualTo(expectedControllers)
+    }
+
+    private fun Controllers.toSingleFile(): String {
+        val destPackage = if (controllers.isNotEmpty()) controllers.first().destinationPackage else ""
+        val singleFileBuilder = FileSpec.builder(destPackage, "dummyFilename")
+        controllers.forEach {
+            val builder = singleFileBuilder
+                .addImport(SpringImports.Static.REQUEST_METHOD.first, SpringImports.Static.REQUEST_METHOD.second)
+                .addImport(
+                    SpringImports.Static.RESPONSE_STATUS.first,
+                    SpringImports.Static.RESPONSE_STATUS.second
+                )
+                .addType(it.spec)
+            builder.build()
+        }
+        return Linter.lintString(singleFileBuilder.build().toString())
+    }
+}
diff --git a/src/test/kotlin/com/cjbooms/fabrikt/generators/SpringServiceInterfaceGeneratorTest.kt b/src/test/kotlin/com/cjbooms/fabrikt/generators/SpringServiceInterfaceGeneratorTest.kt
new file mode 100644
index 0000000..6272fe5
--- /dev/null
+++ b/src/test/kotlin/com/cjbooms/fabrikt/generators/SpringServiceInterfaceGeneratorTest.kt
@@ -0,0 +1,272 @@
+package com.cjbooms.fabrikt.generators
+
+import com.cjbooms.fabrikt.configurations.Packages
+import com.cjbooms.fabrikt.generators.model.JacksonModelGenerator
+import com.cjbooms.fabrikt.generators.service.SpringServiceInterfaceGenerator
+import com.cjbooms.fabrikt.model.Destinations.modelsPackage
+import com.cjbooms.fabrikt.model.Destinations.servicesPackage
+import com.cjbooms.fabrikt.model.Services
+import com.cjbooms.fabrikt.model.SourceApi
+import com.cjbooms.fabrikt.util.OperationUtils
+import com.cjbooms.fabrikt.util.SupportedOperation
+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.Test
+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 SpringServiceInterfaceGeneratorTest {
+
+    val api = SourceApi(javaClass.getResource("/examples/githubApi/open-api/api.yaml").readText())
+
+    private fun testCases(): Stream<String> = Stream.of(
+        "putApi",
+        "simpleRequestBody",
+        "customerExampleApi"
+    )
+
+    private val basePackage = "testdata"
+
+    @Test
+    fun `the correct services are generated`() {
+        val models = JacksonModelGenerator(Packages(basePackage), api).generate().models
+
+        val generated = SpringServiceInterfaceGenerator(Packages(basePackage), api, models).generate().files
+
+        assertThat(generated.map { it.name })
+            .containsOnly(
+                "ContributorsService",
+                "OrganisationsService",
+                "OrganisationsContributorsService",
+                "RepositoriesService",
+                "RepositoriesPullRequestsService"
+            )
+    }
+
+    @Test
+    fun `ensure package is correct`() {
+        val models = JacksonModelGenerator(Packages(basePackage), api).generate().models
+
+        val generated = SpringServiceInterfaceGenerator(Packages(basePackage), api, models).generate().files
+
+        assertThat(generated.map { it.packageName }.distinct())
+            .containsOnly(servicesPackage(basePackage))
+    }
+
+    private fun supportedOperationFrom(verb: String, path: String, apiSeed: SourceApi): SupportedOperation? {
+        val operation = apiSeed.openApi3.paths.flatMap { p ->
+            p.value.operations.map { o ->
+                if (p.key == path && o.key == verb) o.value
+                else null
+            }
+        }.filterNotNull().first()
+
+        return OperationUtils.supportedOperationFrom(verb, operation, apiSeed.openApi3.paths[path]!!, apiSeed)
+    }
+
+    @Test
+    fun `test service operations map correctly`() {
+        val apiSeed = SourceApi(javaClass.getResource("/examples/githubApi/open-api/api.yaml").readText())
+
+        assertThat(supportedOperationFrom("get", "/contributors/{id}", apiSeed))
+            .isEqualTo(SupportedOperation.READ)
+        assertThat(supportedOperationFrom("head", "/contributors/{id}", apiSeed))
+            .isEqualTo(SupportedOperation.READ)
+        assertThat(supportedOperationFrom("put", "/contributors/{id}", apiSeed))
+            .isEqualTo(SupportedOperation.UPDATE)
+        assertThat(supportedOperationFrom("get", "/contributors", apiSeed))
+            .isEqualTo(SupportedOperation.QUERY)
+        assertThat(supportedOperationFrom("post", "/contributors", apiSeed))
+            .isEqualTo(SupportedOperation.CREATE)
+
+        assertThat(supportedOperationFrom("post", "/repositories/{parent-id}/pull-requests", apiSeed))
+            .isEqualTo(SupportedOperation.CREATE_SUBRESOURCE)
+        assertThat(supportedOperationFrom("get", "/repositories/{parent-id}/pull-requests", apiSeed))
+            .isEqualTo(SupportedOperation.QUERY_SUBRESOURCE)
+        assertThat(supportedOperationFrom("put", "/repositories/{parent-id}/pull-requests/{id}", apiSeed))
+            .isEqualTo(SupportedOperation.UPDATE_SUBRESOURCE)
+        assertThat(supportedOperationFrom("get", "/repositories/{parent-id}/pull-requests/{id}", apiSeed))
+            .isEqualTo(SupportedOperation.READ_SUBRESOURCE)
+
+        assertThat(supportedOperationFrom("put", "/organisations/{parent-id}/contributors/{id}", apiSeed))
+            .isEqualTo(SupportedOperation.ADD_TOP_LEVEL_SUBRESOURCE)
+        assertThat(supportedOperationFrom("get", "/organisations/{parent-id}/contributors/{id}", apiSeed))
+            .isEqualTo(SupportedOperation.READ_TOP_LEVEL_SUBRESOURCE)
+        assertThat(supportedOperationFrom("get", "/organisations/{parent-id}/contributors", apiSeed))
+            .isEqualTo(SupportedOperation.QUERY_TOP_LEVEL_SUBRESOURCE)
+        assertThat(supportedOperationFrom("delete", "/organisations/{parent-id}/contributors/{id}", apiSeed))
+            .isEqualTo(SupportedOperation.REMOVE_TOP_LEVEL_SUBRESOURCE)
+    }
+
+    @Test
+    fun `test method signatures`() {
+        val spec = """
+            openapi: "3.0.0"
+            paths:
+              /contributors:
+                get:
+                  parameters:
+                  - ${'$'}ref: "#/components/parameters/FlowId"
+                  - ${'$'}ref: "#/components/parameters/Cursor"
+                  responses:
+                    200:
+                      content:
+                        application/json:
+                          schema:
+                            ${'$'}ref: "#/components/schemas/ContributorQueryResult"
+                post:
+                  parameters:
+                  - ${'$'}ref: "#/components/parameters/FlowId"
+                  requestBody:
+                    ${'$'}ref: "#/components/requestBodies/ContributorBody"
+                  responses:
+                    201:
+                      description: ""
+
+              /contributors/{id}:
+                get:
+                  parameters:
+                  - ${'$'}ref: "#/components/parameters/FlowId"
+                  - ${'$'}ref: "#/components/parameters/IdPathParam"
+                  responses:
+                    200:
+                      content:
+                        application/json:
+                          schema:
+                            ${'$'}ref: "#/components/schemas/Contributor"
+                head:
+                  parameters:
+                  - ${'$'}ref: "#/components/parameters/FlowId"
+                  - ${'$'}ref: "#/components/parameters/IdPathParam"
+                  responses:
+                    200:
+                      description: ""
+                put:
+                  parameters:
+                  - ${'$'}ref: "#/components/parameters/FlowId"
+                  - ${'$'}ref: "#/components/parameters/IdPathParam"
+                  requestBody:
+                    ${'$'}ref: "#/components/requestBodies/ContributorBody"
+                  responses:
+                    204:
+                      description: ""
+
+            components:
+              parameters:
+                IdPathParam:
+                  name: "id"
+                  in: "path"
+                  required: true
+                  schema:
+                    type: "string"
+                FlowId:
+                  name: "X-Flow-Id"
+                  in: "header"
+                  required: false
+                  schema:
+                    type: "string"
+                Cursor:
+                  name: "cursor"
+                  in: "query"
+                  required: false
+                  schema:
+                    type: "string"
+              headers:
+                Location:
+                  schema:
+                    type: "string"
+              requestBodies:
+                ContributorBody:
+                  required: true
+                  content:
+                    application/json:
+                      schema:
+                        ${'$'}ref: "#/components/schemas/Contributor"
+              schemas:
+                ContributorQueryResult:
+                  type: "object"
+                  required:
+                  - "items"
+                  properties:
+                    items:
+                      type: "array"
+                      minItems: 0
+                      items:
+                        ${'$'}ref: "#/components/schemas/Contributor"
+                Contributor:
+                  type: "object"
+                  properties:
+                    id:
+                      type: "string"
+                      readOnly: true
+                    username:
+                      type: "string"
+                    name:
+                      type: "string"""".trimIndent()
+
+        val apiSeed = SourceApi(spec)
+        val models = JacksonModelGenerator(Packages(basePackage), apiSeed).generate().models
+
+        val methods =
+            apiSeed.openApi3.paths.flatMap {
+                SpringServiceInterfaceGenerator(
+                    Packages(basePackage),
+                    apiSeed,
+                    models
+                ).pathToMethods(it.value)
+            }
+
+        val list = methods.first { it.name == "query" }
+        val create = methods.first { it.name == "create" }
+        val update = methods.first { it.name == "update" }
+        val get = methods.first { it.name == "read" }
+
+        val modelsPackage = modelsPackage(basePackage)
+        assertThat(list.returnType.toString()).isEqualTo("kotlin.collections.List<$modelsPackage.Contributor>")
+        assertThat(create.returnType.toString()).isEqualTo("kotlin.Pair<java.net.URI, $modelsPackage.Contributor?>")
+        assertThat(get.returnType.toString()).isEqualTo("$modelsPackage.Contributor")
+        assertThat(update.returnType.toString()).isEqualTo("$modelsPackage.Contributor")
+
+        assertThat(list.parameters.map { it.name }).containsExactly("xFlowId", "cursor")
+        assertThat(create.parameters.map { it.name }).containsExactly("contributor", "xFlowId")
+        assertThat(get.parameters.map { it.name }).containsExactly("id", "xFlowId")
+        assertThat(update.parameters.map { it.name }).containsExactly("contributor", "id", "xFlowId")
+    }
+
+    @ParameterizedTest
+    @MethodSource("testCases")
+    fun `correct services are generated for different OpenApi Specifications`(testCaseName: String) {
+        val basePackage = "examples.${testCaseName.decapitalize()}"
+        val api = SourceApi(javaClass.getResource("/examples/$testCaseName/open-api/api.yaml").readText())
+        val expectedServices = javaClass.getResource("/examples/$testCaseName/service/Services.kt").readText()
+        val jackson = JacksonModelGenerator(Packages(basePackage), api).generate()
+
+        val services = SpringServiceInterfaceGenerator(Packages(basePackage), api, jackson.models).generate()
+
+        assertThat(services.toSingleFile()).isEqualTo(expectedServices)
+    }
+
+    @Test
+    fun `schema only code gen for Service Interfaces is not Empty`() {
+        val rawApiSeed = SourceApi(javaClass.getResource("/examples/schemaOnlyApi/api.yaml").readText())
+        val models = JacksonModelGenerator(Packages(basePackage), rawApiSeed).generate().models
+
+        val generated = SpringServiceInterfaceGenerator(Packages(basePackage), rawApiSeed, models).generate().files
+
+        assertThat(generated.isNotEmpty())
+    }
+
+    private fun Services.toSingleFile(): String {
+        val destPackage = if (services.isNotEmpty()) services.first().destinationPackage else ""
+        val singleFileBuilder = FileSpec.builder(destPackage, "dummyFilename")
+        services.forEach {
+            val builder = singleFileBuilder.addType(it.spec)
+            builder.build()
+        }
+        return Linter.lintString(singleFileBuilder.build().toString())
+    }
+}
diff --git a/src/test/resources/examples/customerExampleApi/controllers/Controllers.kt b/src/test/resources/examples/customerExampleApi/controllers/Controllers.kt
new file mode 100644
index 0000000..71b22c8
--- /dev/null
+++ b/src/test/resources/examples/customerExampleApi/controllers/Controllers.kt
@@ -0,0 +1,316 @@
+package examples.customerExampleApi.controllers
+
+import examples.customerExampleApi.models.Customer
+import examples.customerExampleApi.models.DataAccessRequest
+import examples.customerExampleApi.models.DataAccessRequestQueryResult
+import examples.customerExampleApi.models.DeleteRequest
+import examples.customerExampleApi.models.DeleteRequestQueryResult
+import examples.customerExampleApi.service.CustomersService
+import examples.customerExampleApi.service.DataAccessRequestsService
+import examples.customerExampleApi.service.DeleteRequestsService
+import javax.validation.Valid
+import javax.validation.constraints.Max
+import javax.validation.constraints.Min
+import kotlin.Int
+import kotlin.String
+import kotlin.Unit
+import org.springframework.http.ResponseEntity
+import org.springframework.stereotype.Controller
+import org.springframework.validation.annotation.Validated
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestHeader
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestMethod
+import org.springframework.web.bind.annotation.RequestParam
+
+@Controller
+@Validated
+@RequestMapping("/api")
+class CustomersController(
+    val service: CustomersService
+) {
+    /**
+     * Information about a customer.
+     *
+     * @param xFlowId A custom header that will be passed onto any further requests and can be used
+     * for diagnosing.
+     *
+     * @param customerNumber The unique customer number
+     */
+    @RequestMapping(
+        value = ["/customers/{customer_number}"],
+        produces = ["application/json"],
+        method = [RequestMethod.GET]
+    )
+    fun getById(
+        @PathVariable(value = "customer_number", required = true)
+        customerNumber: String,
+        @RequestHeader(value = "X-Flow-Id", required = false)
+        xFlowId: String?
+    ):
+    ResponseEntity<Customer> {
+        val svcResp = service.read(customerNumber, xFlowId)
+        return ResponseEntity
+            .ok()
+            .body(svcResp)
+    }
+}
+
+@Controller
+@Validated
+@RequestMapping("/api")
+class DeleteRequestsController(
+    val service: DeleteRequestsService
+) {
+    /**
+     * Page through all the DeleteRequest resources matching the query filters
+     *
+     * @param xFlowId A custom header that will be passed onto any further requests and can be used
+     * for diagnosing.
+     *
+     * @param customerNumber Filters search results to those matching the customer number
+     *
+     * @param limit Upper bound for number of results to be returned from query api. A default limit
+     * will be applied if this is not present
+     *
+     * @param cursor An encoded value which represents a point in the data from where pagination will
+     * commence. The pagination direction (either forward or backward) will also be encoded within the
+     * cursor value. The cursor value will always be generated server side
+     *
+     */
+    @RequestMapping(
+        value = ["/delete-requests"],
+        produces = ["application/json"],
+        method = [RequestMethod.GET]
+    )
+    fun get(
+        @Min(1)
+        @Max(100)
+        @RequestParam(value = "limit", required = false, defaultValue = "10")
+        limit: Int,
+        @RequestHeader(value = "X-Flow-Id", required = false)
+        xFlowId: String?,
+        @RequestParam(value = "customer_number", required = false)
+        customerNumber: String?,
+        @RequestParam(value = "cursor", required = false)
+        cursor: String?
+    ): ResponseEntity<DeleteRequestQueryResult> {
+        val svcResp = service.query(limit, xFlowId, customerNumber, cursor)
+        return ResponseEntity
+            .ok()
+            .body(svcResp)
+    }
+
+    /**
+     * Create a new DeleteRequest
+     *
+     * @param xFlowId A custom header that will be passed onto any further requests and can be used
+     * for diagnosing.
+     *
+     */
+    @RequestMapping(
+        value = ["/delete-requests"],
+        produces = [],
+        method = [RequestMethod.POST],
+        consumes = ["application/json"]
+    )
+    fun post(
+        @RequestBody @Valid
+        deleteRequest: DeleteRequest,
+        @RequestHeader(
+            value = "X-Flow-Id",
+            required = false
+        )
+        xFlowId: String?
+    ): ResponseEntity<Unit> {
+        val svcResp = service.create(deleteRequest, xFlowId)
+        val response = ResponseEntity.created(svcResp.first)
+        return response.build()
+    }
+
+    /**
+     * Get a DeleteRequest by ID
+     *
+     * @param xFlowId A custom header that will be passed onto any further requests and can be used
+     * for diagnosing.
+     *
+     * @param id The unique id for the resource
+     */
+    @RequestMapping(
+        value = ["/delete-requests/{id}"],
+        produces = ["application/json"],
+        method = [RequestMethod.GET]
+    )
+    fun getById(
+        @PathVariable(value = "id", required = true)
+        id: String,
+        @RequestHeader(
+            value =
+                "X-Flow-Id",
+            required = false
+        )
+        xFlowId: String?
+    ): ResponseEntity<DeleteRequest> {
+        val svcResp = service.read(id, xFlowId)
+        return ResponseEntity
+            .ok()
+            .body(svcResp)
+    }
+
+    /**
+     * Update an existing DeleteRequest
+     *
+     * @param xFlowId A custom header that will be passed onto any further requests and can be used
+     * for diagnosing.
+     *
+     * @param id The unique id for the resource
+     */
+    @RequestMapping(
+        value = ["/delete-requests/{id}"],
+        produces = [],
+        method = [RequestMethod.PUT],
+        consumes = ["application/json"]
+    )
+    fun putById(
+        @RequestBody @Valid
+        deleteRequest: DeleteRequest,
+        @PathVariable(value = "id", required = true)
+        id: String,
+        @RequestHeader(value = "X-Flow-Id", required = false)
+        xFlowId: String?
+    ): ResponseEntity<Unit> {
+        service.update(deleteRequest, id, xFlowId)
+        return ResponseEntity.noContent().build()
+    }
+}
+
+@Controller
+@Validated
+@RequestMapping("/api")
+class DataAccessRequestsController(
+    val service: DataAccessRequestsService
+) {
+    /**
+     * Page through all the DataAccessRequest resources matching the query filters
+     *
+     * @param xFlowId A custom header that will be passed onto any further requests and can be used
+     * for diagnosing.
+     *
+     * @param customerNumber Filters search results to those matching the customer number
+     *
+     * @param limit Upper bound for number of results to be returned from query api. A default limit
+     * will be applied if this is not present
+     *
+     * @param cursor An encoded value which represents a point in the data from where pagination will
+     * commence. The pagination direction (either forward or backward) will also be encoded within the
+     * cursor value. The cursor value will always be generated server side
+     *
+     */
+    @RequestMapping(
+        value = ["/data-access-requests"],
+        produces = ["application/json"],
+        method = [RequestMethod.GET]
+    )
+    fun get(
+        @Min(1)
+        @Max(100)
+        @RequestParam(value = "limit", required = false, defaultValue = "10")
+        limit: Int,
+        @RequestHeader(value = "X-Flow-Id", required = false)
+        xFlowId: String?,
+        @RequestParam(value = "customer_number", required = false)
+        customerNumber: String?,
+        @RequestParam(value = "cursor", required = false)
+        cursor: String?
+    ): ResponseEntity<DataAccessRequestQueryResult> {
+        val svcResp = service.query(limit, xFlowId, customerNumber, cursor)
+        return ResponseEntity
+            .ok()
+            .body(svcResp)
+    }
+
+    /**
+     * Create a new DataAccessRequest
+     *
+     * @param xFlowId A custom header that will be passed onto any further requests and can be used
+     * for diagnosing.
+     *
+     */
+    @RequestMapping(
+        value = ["/data-access-requests"],
+        produces = ["application/json"],
+        method = [RequestMethod.POST],
+        consumes = ["application/json"]
+    )
+    fun post(
+        @RequestBody @Valid
+        dataAccessRequest: DataAccessRequest,
+        @RequestHeader(
+            value =
+                "X-Flow-Id",
+            required = false
+        )
+        xFlowId: String?
+    ): ResponseEntity<DataAccessRequest> {
+        val svcResp = service.create(dataAccessRequest, xFlowId)
+        val response = ResponseEntity.created(svcResp.first)
+        if (svcResp.second != null) response.body(svcResp.second)
+        return response.build()
+    }
+
+    /**
+     * Get a DataAccessRequest by ID
+     *
+     * @param xFlowId A custom header that will be passed onto any further requests and can be used
+     * for diagnosing.
+     *
+     * @param id The unique id for the resource
+     */
+    @RequestMapping(
+        value = ["/data-access-requests/{id}"],
+        produces = ["application/json"],
+        method = [RequestMethod.GET]
+    )
+    fun getById(
+        @PathVariable(value = "id", required = true)
+        id: String,
+        @RequestHeader(
+            value =
+                "X-Flow-Id",
+            required = false
+        )
+        xFlowId: String?
+    ): ResponseEntity<DataAccessRequest> {
+        val svcResp = service.read(id, xFlowId)
+        return ResponseEntity
+            .ok()
+            .body(svcResp)
+    }
+
+    /**
+     * Update an existing DataAccessRequest
+     *
+     * @param xFlowId A custom header that will be passed onto any further requests and can be used
+     * for diagnosing.
+     *
+     * @param id The unique id for the resource
+     */
+    @RequestMapping(
+        value = ["/data-access-requests/{id}"],
+        produces = [],
+        method = [RequestMethod.PUT],
+        consumes = ["application/json"]
+    )
+    fun putById(
+        @RequestBody @Valid
+        dataAccessRequest: DataAccessRequest,
+        @PathVariable(value = "id", required = true)
+        id: String,
+        @RequestHeader(value = "X-Flow-Id", required = false)
+        xFlowId: String?
+    ): ResponseEntity<Unit> {
+        service.update(dataAccessRequest, id, xFlowId)
+        return ResponseEntity.noContent().build()
+    }
+}
diff --git a/src/test/resources/examples/customerExampleApi/models/Models.kt b/src/test/resources/examples/customerExampleApi/models/Models.kt
new file mode 100644
index 0000000..fddcddc
--- /dev/null
+++ b/src/test/resources/examples/customerExampleApi/models/Models.kt
@@ -0,0 +1,142 @@
+package examples.customerExampleApi.models
+
+import com.fasterxml.jackson.annotation.JsonProperty
+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 DeleteRequestQueryResult(
+    @param:JsonProperty("prev")
+    @get:JsonProperty("prev")
+    val prev: String? = null,
+    @param:JsonProperty("next")
+    @get:JsonProperty("next")
+    val next: String? = null,
+    @param:JsonProperty("items")
+    @get:JsonProperty("items")
+    @get:NotNull
+    @get:Size(min = 0)
+    @get:Valid
+    val items: List<DeleteRequest>
+)
+
+data class DataAccessRequestQueryResult(
+    @param:JsonProperty("prev")
+    @get:JsonProperty("prev")
+    val prev: String? = null,
+    @param:JsonProperty("next")
+    @get:JsonProperty("next")
+    val next: String? = null,
+    @param:JsonProperty("items")
+    @get:JsonProperty("items")
+    @get:NotNull
+    @get:Size(min = 0)
+    @get:Valid
+    val items: List<DataAccessRequest>
+)
+
+data class Customer(
+    @param:JsonProperty("is_eligible")
+    @get:JsonProperty("is_eligible")
+    val isEligible: Boolean? = null,
+    @param:JsonProperty("eligibility_results")
+    @get:JsonProperty("eligibility_results")
+    @get:Valid
+    val eligibilityResults: List<EligibilityRule>? = null
+)
+
+data class DeleteRequest(
+    @param:JsonProperty("id")
+    @get:JsonProperty("id")
+    val id: String? = null,
+    @param:JsonProperty("customer_number")
+    @get:JsonProperty("customer_number")
+    val customerNumber: Int? = null,
+    @param:JsonProperty("status")
+    @get:JsonProperty("status")
+    val status: RequestStatus? = null,
+    @param:JsonProperty("expiry_date")
+    @get:JsonProperty("expiry_date")
+    val expiryDate: OffsetDateTime? = null,
+    @param:JsonProperty("created")
+    @get:JsonProperty("created")
+    val created: OffsetDateTime? = null,
+    @param:JsonProperty("modified")
+    @get:JsonProperty("modified")
+    val modified: OffsetDateTime? = null,
+    @param:JsonProperty("created_by_uid")
+    @get:JsonProperty("created_by_uid")
+    val createdByUid: String? = null,
+    @param:JsonProperty("modified_by_uid")
+    @get:JsonProperty("modified_by_uid")
+    val modifiedByUid: String? = null
+)
+
+enum class RequestStatus(
+    @JsonValue
+    val value: String
+) {
+    OPEN("OPEN"),
+
+    IN_PROGRESS("IN_PROGRESS"),
+
+    DONE("DONE"),
+
+    CANCELLED("CANCELLED"),
+
+    ERROR("ERROR");
+}
+
+data class DataAccessRequest(
+    @param:JsonProperty("id")
+    @get:JsonProperty("id")
+    val id: String? = null,
+    @param:JsonProperty("customer_number")
+    @get:JsonProperty("customer_number")
+    val customerNumber: Int? = null,
+    @param:JsonProperty("status")
+    @get:JsonProperty("status")
+    val status: RequestStatus? = null,
+    @param:JsonProperty("expiry_date")
+    @get:JsonProperty("expiry_date")
+    val expiryDate: OffsetDateTime? = null,
+    @param:JsonProperty("created")
+    @get:JsonProperty("created")
+    val created: OffsetDateTime? = null,
+    @param:JsonProperty("modified")
+    @get:JsonProperty("modified")
+    val modified: OffsetDateTime? = null,
+    @param:JsonProperty("created_by_uid")
+    @get:JsonProperty("created_by_uid")
+    val createdByUid: String? = null,
+    @param:JsonProperty("modified_by_uid")
+    @get:JsonProperty("modified_by_uid")
+    val modifiedByUid: String? = null
+)
+
+data class EligibilityRule(
+    @param:JsonProperty("code")
+    @get:JsonProperty("code")
+    val code: String? = null,
+    @param:JsonProperty("message")
+    @get:JsonProperty("message")
+    val message: String? = null,
+    @param:JsonProperty("status")
+    @get:JsonProperty("status")
+    val status: EligibilityRuleStatus? = null
+)
+
+enum class EligibilityRuleStatus(
+    @JsonValue
+    val value: String
+) {
+    FAIL("fail"),
+
+    PASS("pass");
+}
diff --git a/src/test/resources/examples/customerExampleApi/open-api/api.yaml b/src/test/resources/examples/customerExampleApi/open-api/api.yaml
new file mode 100644
index 0000000..4f2a54d
--- /dev/null
+++ b/src/test/resources/examples/customerExampleApi/open-api/api.yaml
@@ -0,0 +1,466 @@
+openapi: 3.0.0
+info:
+  description: Eample API
+  version: 1.0.0
+  title: example-api
+  contact:
+    name: cjbooms
+    email: example@cjbooms.com
+    url: http://example.docs.cjbooms.com/
+externalDocs:
+  description: Example API Docs
+  url: https://example.docs.cjbooms.com/
+servers:
+  - url: https://example.docs.cjbooms.com/api
+    description: API service url
+
+paths:
+  /customers/{customer_number}:
+    get:
+      summary: Information about a customer.
+      parameters:
+        - $ref: '#/components/parameters/FlowId'
+        - $ref: '#/components/parameters/CustomerNumberPathParameter'
+      responses:
+        200:
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Customer'
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+  /delete-requests:
+    get:
+      summary: Page through all the DeleteRequest resources matching the query filters
+      parameters:
+        - $ref: '#/components/parameters/FlowId'
+        - $ref: '#/components/parameters/CustomerNumberQueryParam'
+        - $ref: '#/components/parameters/Limit'
+        - $ref: '#/components/parameters/Cursor'
+      responses:
+        200:
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/DeleteRequestQueryResult'
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    post:
+      summary: Create a new DeleteRequest
+      parameters:
+        - $ref: '#/components/parameters/FlowId'
+      requestBody:
+        $ref: '#/components/requestBodies/DeleteRequestBody'
+      responses:
+        201:
+          headers:
+            Location:
+              $ref: '#/components/headers/Location'
+          description: DeleteRequest successfully created
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+  /delete-requests/{id}:
+    get:
+      summary: Get a DeleteRequest by ID
+      tags:
+        - deleterequest
+      parameters:
+        - $ref: '#/components/parameters/FlowId'
+        - $ref: '#/components/parameters/IdPathParam'
+      responses:
+        200:
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/DeleteRequest'
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    put:
+      summary: Update an existing DeleteRequest
+      tags:
+        - deleterequest
+      parameters:
+        - $ref: '#/components/parameters/FlowId'
+        - $ref: '#/components/parameters/IdPathParam'
+      requestBody:
+        $ref: '#/components/requestBodies/DeleteRequestBody'
+      responses:
+        204:
+          description: DeleteRequest successfully updated
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+  /data-access-requests:
+    get:
+      summary: Page through all the DataAccessRequest resources matching the query filters
+      parameters:
+        - $ref: '#/components/parameters/FlowId'
+        - $ref: '#/components/parameters/CustomerNumberQueryParam'
+        - $ref: '#/components/parameters/Limit'
+        - $ref: '#/components/parameters/Cursor'
+      responses:
+        200:
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/DataAccessRequestQueryResult'
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    post:
+      summary: Create a new DataAccessRequest
+      parameters:
+        - $ref: '#/components/parameters/FlowId'
+      requestBody:
+        $ref: '#/components/requestBodies/DataAccessRequestBody'
+      responses:
+        201:
+          headers:
+            Location:
+              $ref: '#/components/headers/Location'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/DataAccessRequest'
+          description: DataAccessRequest successfully created
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+  /data-access-requests/{id}:
+    get:
+      summary: Get a DataAccessRequest by ID
+      tags:
+        - dataaccessrequest
+      parameters:
+        - $ref: '#/components/parameters/FlowId'
+        - $ref: '#/components/parameters/IdPathParam'
+      responses:
+        200:
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/DataAccessRequest'
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    put:
+      summary: Update an existing DataAccessRequest
+      parameters:
+        - $ref: '#/components/parameters/FlowId'
+        - $ref: '#/components/parameters/IdPathParam'
+      requestBody:
+        $ref: '#/components/requestBodies/DataAccessRequestBody'
+      responses:
+        204:
+          description: DataAccessRequest successfully updated
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+components:
+  parameters:
+    IdPathParam:
+      name: id
+      description: The unique id for the resource
+      in: path
+      required: true
+      schema:
+        type: string
+    CustomerNumberPathParameter:
+      name: customer_number
+      description: The unique customer number
+      in: path
+      required: true
+      schema:
+        type: string
+    FlowId:
+      name: X-Flow-Id
+      description: |
+        A custom header that will be passed onto any further requests and can be used for diagnosing.
+      in: header
+      required: false
+      schema:
+        type: string
+    Limit:
+      name: limit
+      description: |
+        Upper bound for number of results to be returned from query api. A default limit will be applied if this is not present
+      in: query
+      required: false
+      schema:
+        type: integer
+        minimum: 1
+        maximum: 100
+        default: 10
+    Cursor:
+      name: cursor
+      description: |
+        An encoded value which represents a point in the data from where pagination will commence. The pagination direction (either forward or backward) will also be encoded within the cursor value. The cursor value will always be generated server side
+      in: query
+      required: false
+      schema:
+        type: string
+    CustomerNumberQueryParam:
+      name: customer_number
+      description: |
+        Filters search results to those matching the customer number
+      in: query
+      schema:
+        type: string
+
+  headers:
+    Location:
+      description: The Location header indicates the URL of a newly created resource
+      schema:
+        type: string
+      example: /eligibility-rule-statuses/{id}
+    RetryAfter:
+      description: |
+        This header will be retrieved in tandem with a '429' HTTP response code. The value of the header will be the number of seconds that a client should wait before attempting a new request.
+      schema:
+        type: integer
+    RateLimit:
+      description: |
+        This header will be retrieved in tandem with a '429' HTTP response code. The value of the header indicates how many requests per hour the client is allowed to make. The actual rate limiting will typically be applied per minute though. So if your hourly limit is 1800, you will be allowed to make (1800 / 60 = 30) requests per minute before being rate limited for the remainder of that minute window.
+      schema:
+        type: integer
+
+  requestBodies:
+    DeleteRequestBody:
+      required: true
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/DeleteRequest'
+
+    DataAccessRequestBody:
+      required: true
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/DataAccessRequest'
+  schemas:
+    PaginationLink:
+      type: string
+      format: uri
+      description: The hyperlink to a page of data
+      readOnly: true
+      example: https://example.docs.cjbooms.com/api/{resource}?cursor=NDI%3D&limit=5
+
+    DeleteRequestQueryResult:
+      type: object
+      required:
+        - items
+      properties:
+        prev:
+          $ref: '#/components/schemas/PaginationLink'
+        next:
+          $ref: '#/components/schemas/PaginationLink'
+        items:
+          type: array
+          minItems: 0
+          items:
+            $ref: '#/components/schemas/DeleteRequest'
+
+    DataAccessRequestQueryResult:
+      type: object
+      required:
+        - items
+      properties:
+        prev:
+          $ref: '#/components/schemas/PaginationLink'
+        next:
+          $ref: '#/components/schemas/PaginationLink'
+        items:
+          type: array
+          minItems: 0
+          items:
+            $ref: '#/components/schemas/DataAccessRequest'
+
+    Customer:
+      type: object
+      description: Customer data in the context of data deletion
+      properties:
+        is_eligible:
+          type: boolean
+        eligibility_results:
+          type: array
+          description: List of all rules checked and their statuses that make a customer
+            eligible or not for deletion.
+          items:
+            $ref: '#/components/schemas/EligibilityRule'
+
+    DeleteRequest:
+      type: object
+      description: Deletion request object.
+      required:
+        - id
+        - created
+        - created_by_uid
+        - modified
+        - modified_by_uid
+      properties:
+        id:
+          description: This unique server-generated security token will always be present
+            in the response.
+          type: string
+          readOnly: true
+        customer_number:
+          type: integer
+          format: int32
+        status:
+          $ref: '#/components/schemas/RequestStatus'
+        expiry_date:
+          type: string
+          format: date-time
+        created:
+          description: Indicates the timestamp that this resource was initially created
+            at
+          type: string
+          format: date-time
+          readOnly: true
+        modified:
+          description: Indicates the timestamp that this resource was last modified
+            at
+          type: string
+          format: date-time
+          readOnly: true
+        created_by_uid:
+          description: Represents the uid from the auth token that first created this
+            resource
+          type: string
+          readOnly: true
+        modified_by_uid:
+          description: Represents the uid from the auth token that last modified this
+            resource
+          type: string
+          readOnly: true
+
+    DataAccessRequest:
+      type: object
+      description: Data access request object.
+      required:
+        - id
+        - created
+        - created_by_uid
+        - modified
+        - modified_by_uid
+      properties:
+        id:
+          description: This unique server-generated security token identifier will always be present
+            in the response.
+          type: string
+          readOnly: true
+        customer_number:
+          type: integer
+          format: int32
+        status:
+          $ref: '#/components/schemas/RequestStatus'
+        expiry_date:
+          type: string
+          format: date-time
+        created:
+          description: Indicates the timestamp that this resource was initially created at
+          type: string
+          format: date-time
+          readOnly: true
+        modified:
+          description: Indicates the timestamp that this resource was last modified
+            at
+          type: string
+          format: date-time
+          readOnly: true
+        created_by_uid:
+          description: Represents the uid from the auth token that first created this
+            resource
+          type: string
+          readOnly: true
+        modified_by_uid:
+          description: Represents the uid from the auth token that last modified this
+            resource
+          type: string
+          readOnly: true
+
+    EligibilityRule:
+      type: object
+      description: Deletion eligibility rule
+      properties:
+        code:
+          type: string
+          description: Eligibility rule code
+          example: 444
+        message:
+          type: string
+          description: Definition of the error code
+          example: Customer is blacklisted and cannot receive emails
+        status:
+          $ref: '#/components/schemas/EligibilityRuleStatus'
+
+    EligibilityRuleStatus:
+      type: string
+      description: Status of the rule
+      x-extensible-enum:
+        - fail
+        - pass
+    RequestStatus:
+      type: string
+      x-extensible-enum:
+        - OPEN
+        - IN_PROGRESS
+        - DONE
+        - CANCELLED
+        - ERROR
+
diff --git a/src/test/resources/examples/customerExampleApi/service/Services.kt b/src/test/resources/examples/customerExampleApi/service/Services.kt
new file mode 100644
index 0000000..e37e63d
--- /dev/null
+++ b/src/test/resources/examples/customerExampleApi/service/Services.kt
@@ -0,0 +1,52 @@
+package examples.customerExampleApi.service
+
+import examples.customerExampleApi.models.Customer
+import examples.customerExampleApi.models.DataAccessRequest
+import examples.customerExampleApi.models.DeleteRequest
+import java.net.URI
+import kotlin.Int
+import kotlin.Pair
+import kotlin.String
+import kotlin.collections.List
+
+interface CustomersService {
+    fun read(customerNumber: String, xFlowId: String?): Customer
+}
+
+interface DeleteRequestsService {
+    fun query(
+        limit: Int,
+        xFlowId: String?,
+        customerNumber: String?,
+        cursor: String?
+    ): List<DeleteRequest>
+
+    fun create(deleteRequest: DeleteRequest, xFlowId: String?): Pair<URI, DeleteRequest?>
+
+    fun read(id: String, xFlowId: String?): DeleteRequest
+
+    fun update(
+        deleteRequest: DeleteRequest,
+        id: String,
+        xFlowId: String?
+    ): DeleteRequest
+}
+
+interface DataAccessRequestsService {
+    fun query(
+        limit: Int,
+        xFlowId: String?,
+        customerNumber: String?,
+        cursor: String?
+    ): List<DataAccessRequest>
+
+    fun create(dataAccessRequest: DataAccessRequest, xFlowId: String?): Pair<URI, DataAccessRequest?>
+
+    fun read(id: String, xFlowId: String?): DataAccessRequest
+
+    fun update(
+        dataAccessRequest: DataAccessRequest,
+        id: String,
+        xFlowId: String?
+    ): DataAccessRequest
+}
diff --git a/src/test/resources/examples/githubApi/open-api/api.yaml b/src/test/resources/examples/githubApi/open-api/api.yaml
new file mode 100644
index 0000000..6e9702c
--- /dev/null
+++ b/src/test/resources/examples/githubApi/open-api/api.yaml
@@ -0,0 +1,1198 @@
+openapi: 3.0.0
+info:
+  description: Some description
+  version: 1.0.0
+  title: The API title
+  contact:
+    name: cjbooms
+    email: api@cjbooms.com
+externalDocs:
+  description: The docs are great.
+  url: http://find.them.here
+
+paths:
+  /contributors:
+    get:
+      summary: Page through all the Contributor resources matching the query filters
+      tags:
+      - contributor
+      parameters:
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IncludeInactive'
+      - $ref: '#/components/parameters/Limit'
+      - $ref: '#/components/parameters/Cursor'
+      responses:
+        200:
+          description: successful operation
+          headers:
+            Cache-Control:
+              $ref: '#/components/headers/CacheControl'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ContributorQueryResult'
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    post:
+      summary: Create a new Contributor
+      tags:
+      - contributor
+      parameters:
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdempotencyKey'
+      requestBody:
+        $ref: '#/components/requestBodies/ContributorBody'
+      responses:
+        201:
+          headers:
+            Location:
+              $ref: '#/components/headers/Location'
+          description: Contributor successfully created
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+  /contributors/{id}:
+    get:
+      summary: Get a Contributor by ID
+      tags:
+      - contributor
+      parameters:
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdPathParam'
+      - $ref: '#/components/parameters/IfNoneMatch'
+      - $ref: '#/components/parameters/StatusQueryParam'
+      responses:
+        200:
+          description: successful operation
+          headers:
+            Cache-Control:
+              $ref: '#/components/headers/CacheControl'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Contributor'
+        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
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    head:
+      summary: Validate that a Contributor exists
+      tags:
+      - contributor
+      parameters:
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdPathParam'
+      - $ref: '#/components/parameters/IfNoneMatch'
+      - $ref: '#/components/parameters/StatusQueryParam'
+      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
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    put:
+      summary: Update an existing Contributor
+      tags:
+      - contributor
+      parameters:
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdPathParam'
+      - $ref: '#/components/parameters/IfMatch'
+      - $ref: '#/components/parameters/IdempotencyKey'
+      requestBody:
+        $ref: '#/components/requestBodies/ContributorBody'
+      responses:
+        204:
+          description: Contributor successfully updated
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+  /organisations:
+    get:
+      summary: Page through all the Organisation resources matching the query filters
+      tags:
+      - organisation
+      parameters:
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IncludeInactive'
+      - $ref: '#/components/parameters/Limit'
+      - $ref: '#/components/parameters/Cursor'
+      responses:
+        200:
+          description: successful operation
+          headers:
+            Cache-Control:
+              $ref: '#/components/headers/CacheControl'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/OrganisationQueryResult'
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    post:
+      summary: Create a new Organisation
+      tags:
+      - organisation
+      parameters:
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdempotencyKey'
+      requestBody:
+        $ref: '#/components/requestBodies/OrganisationBody'
+      responses:
+        201:
+          headers:
+            Location:
+              $ref: '#/components/headers/Location'
+          description: Organisation successfully created
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+  /organisations/{id}:
+    get:
+      summary: Get a Organisation by ID
+      tags:
+      - organisation
+      parameters:
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdPathParam'
+      - $ref: '#/components/parameters/IfNoneMatch'
+      - $ref: '#/components/parameters/StatusQueryParam'
+      responses:
+        200:
+          description: successful operation
+          headers:
+            Cache-Control:
+              $ref: '#/components/headers/CacheControl'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Organisation'
+        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
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    head:
+      summary: Validate that a Organisation exists
+      tags:
+      - organisation
+      parameters:
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdPathParam'
+      - $ref: '#/components/parameters/IfNoneMatch'
+      - $ref: '#/components/parameters/StatusQueryParam'
+      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
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    put:
+      summary: Update an existing Organisation
+      tags:
+      - organisation
+      parameters:
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdPathParam'
+      - $ref: '#/components/parameters/IfMatch'
+      - $ref: '#/components/parameters/IdempotencyKey'
+      requestBody:
+        $ref: '#/components/requestBodies/OrganisationBody'
+      responses:
+        204:
+          description: Organisation successfully updated
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+  /organisations/{parent-id}/contributors:
+    get:
+      summary: Page through all the Contributor resources for this parent Organisation
+        matching the query filters
+      tags:
+      - organisation
+      - contributor
+      parameters:
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/ParentIdPathParam'
+      - $ref: '#/components/parameters/IncludeInactive'
+      - $ref: '#/components/parameters/Limit'
+      - $ref: '#/components/parameters/Cursor'
+      responses:
+        200:
+          description: successful operation
+          headers:
+            Cache-Control:
+              $ref: '#/components/headers/CacheControl'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ContributorQueryResult'
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+  /organisations/{parent-id}/contributors/{id}:
+    get:
+      summary: Get a Contributor for this Organisation by ID
+      tags:
+      - organisation
+      - contributor
+      parameters:
+      - $ref: '#/components/parameters/ParentIdPathParam'
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdPathParam'
+      - $ref: '#/components/parameters/IfNoneMatch'
+      responses:
+        200:
+          description: successful operation
+          headers:
+            Cache-Control:
+              $ref: '#/components/headers/CacheControl'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Contributor'
+        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
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    head:
+      summary: Validate that a Contributor exists for this parent Organisation
+      tags:
+      - organisation
+      - contributor
+      parameters:
+      - $ref: '#/components/parameters/ParentIdPathParam'
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdPathParam'
+      - $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
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    put:
+      summary: Add an existing Contributor to this Organisation
+      tags:
+      - organisation
+      - contributor
+      parameters:
+      - $ref: '#/components/parameters/ParentIdPathParam'
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdPathParam'
+      - $ref: '#/components/parameters/IfMatch'
+      - $ref: '#/components/parameters/IdempotencyKey'
+      responses:
+        204:
+          description: Contributor successfully updated
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    delete:
+      summary: Remove Contributor from this Organisation. Does not delete the underlying
+        Contributor.
+      tags:
+      - organisation
+      - contributor
+      parameters:
+      - $ref: '#/components/parameters/ParentIdPathParam'
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdPathParam'
+      responses:
+        200:
+          description: Contributor successfully removed from Organisation
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+  /repositories:
+    get:
+      summary: Page through all the Repository resources matching the query filters
+      tags:
+      - repository
+      parameters:
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/SlugQueryParam'
+      - $ref: '#/components/parameters/NameQueryParam'
+      - $ref: '#/components/parameters/IncludeInactive'
+      - $ref: '#/components/parameters/Limit'
+      - $ref: '#/components/parameters/Cursor'
+      responses:
+        200:
+          description: successful operation
+          headers:
+            Cache-Control:
+              $ref: '#/components/headers/CacheControl'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/RepositoryQueryResult'
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    post:
+      summary: Create a new Repository
+      tags:
+      - repository
+      parameters:
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdempotencyKey'
+      requestBody:
+        $ref: '#/components/requestBodies/RepositoryBody'
+      responses:
+        201:
+          headers:
+            Location:
+              $ref: '#/components/headers/Location'
+          description: Repository successfully created
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+  /repositories/{id}:
+    get:
+      summary: Get a Repository by ID
+      tags:
+      - repository
+      parameters:
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdPathParam'
+      - $ref: '#/components/parameters/IfNoneMatch'
+      - $ref: '#/components/parameters/StatusQueryParam'
+      responses:
+        200:
+          description: successful operation
+          headers:
+            Cache-Control:
+              $ref: '#/components/headers/CacheControl'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/Repository'
+        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
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    head:
+      summary: Validate that a Repository exists
+      tags:
+      - repository
+      parameters:
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdPathParam'
+      - $ref: '#/components/parameters/IfNoneMatch'
+      - $ref: '#/components/parameters/StatusQueryParam'
+      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
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    put:
+      summary: Update an existing Repository
+      tags:
+      - repository
+      parameters:
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdPathParam'
+      - $ref: '#/components/parameters/IfMatch'
+      - $ref: '#/components/parameters/IdempotencyKey'
+      requestBody:
+        $ref: '#/components/requestBodies/RepositoryBody'
+      responses:
+        204:
+          description: Repository successfully updated
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+  /repositories/{parent-id}/pull-requests:
+    get:
+      summary: Page through all the PullRequest resources for this parent Repository
+        matching the query filters
+      tags:
+      - repository
+      - pullrequest
+      parameters:
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/ParentIdPathParam'
+      - $ref: '#/components/parameters/IncludeInactive'
+      - $ref: '#/components/parameters/Limit'
+      - $ref: '#/components/parameters/Cursor'
+      responses:
+        200:
+          description: successful operation
+          headers:
+            Cache-Control:
+              $ref: '#/components/headers/CacheControl'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/PullRequestQueryResult'
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    post:
+      summary: Create a new PullRequest for this parent Repository
+      tags:
+      - repository
+      - pullrequest
+      parameters:
+      - $ref: '#/components/parameters/ParentIdPathParam'
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdempotencyKey'
+      requestBody:
+        $ref: '#/components/requestBodies/PullRequestBody'
+      responses:
+        201:
+          headers:
+            Location:
+              $ref: '#/components/headers/Location'
+          description: PullRequest successfully created
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+  /repositories/{parent-id}/pull-requests/{id}:
+    get:
+      summary: Get a PullRequest for this Repository by ID
+      tags:
+      - repository
+      - pullrequest
+      parameters:
+      - $ref: '#/components/parameters/ParentIdPathParam'
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdPathParam'
+      - $ref: '#/components/parameters/IfNoneMatch'
+      responses:
+        200:
+          description: successful operation
+          headers:
+            Cache-Control:
+              $ref: '#/components/headers/CacheControl'
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/PullRequest'
+        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
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    head:
+      summary: Validate that a PullRequest exists for this parent Repository
+      tags:
+      - repository
+      - pullrequest
+      parameters:
+      - $ref: '#/components/parameters/ParentIdPathParam'
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdPathParam'
+      - $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
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+    put:
+      summary: Update the PullRequest owned by this Repository
+      tags:
+      - repository
+      - pullrequest
+      parameters:
+      - $ref: '#/components/parameters/ParentIdPathParam'
+      - $ref: '#/components/parameters/FlowId'
+      - $ref: '#/components/parameters/IdPathParam'
+      - $ref: '#/components/parameters/IfMatch'
+      - $ref: '#/components/parameters/IdempotencyKey'
+      requestBody:
+        $ref: '#/components/requestBodies/PullRequestBody'
+      responses:
+        204:
+          description: PullRequest successfully updated
+        429:
+          description: User requests are being throttled
+          headers:
+            Retry-After:
+              $ref: '#/components/headers/RetryAfter'
+            X-Rate-Limit:
+              $ref: '#/components/headers/RateLimit'
+
+components:
+  parameters:
+    IdPathParam:
+      name: id
+      description: The unique id for the resource
+      in: path
+      required: true
+      schema:
+        type: string
+    ParentIdPathParam:
+      name: parent-id
+      description: The unique id for the parent resource
+      in: path
+      required: true
+      schema:
+        type: string
+    IncludeInactive:
+      name: include_inactive
+      description: A query parameter to request both active and inactive entities
+      in: query
+      required: false
+      schema:
+        type: boolean
+    IdempotencyKey:
+      name: Idempotency-Key
+      description: |
+        This unique identifier can be used to allow for clients to safely retry requests without accidentally performing the same operation twice. This is useful in cases such as network failures. This Id should remain the same for a given operation, so that the server can use it to recognise subsequent retries of the same request. Clients should be careful in using this key as subsequent requests with the same key return the same result. How the key is generated is up to the client, but it is suggested to use UUID (v4), or any other random string with enough entropy to avoid collisions.
+      in: header
+      required: false
+      schema:
+        type: string
+      example: 84035561-4e44-4a06-98d2-1744eeefca6d
+    FlowId:
+      name: X-Flow-Id
+      description: |
+        A custom header that will be passed onto any further requests and can be used for diagnosing.
+      in: header
+      required: false
+      schema:
+        type: string
+    Limit:
+      name: limit
+      description: |
+        Upper bound for number of results to be returned from query api. A default limit will be applied if this is not present
+      in: query
+      required: false
+      schema:
+        type: integer
+        minimum: 1
+        maximum: 100
+        default: 10
+    Cursor:
+      name: cursor
+      description: |
+        An encoded value which represents a point in the data from where pagination will commence. The pagination direction (either forward or backward) will also be encoded within the cursor value. The cursor value will always be generated server side
+      in: query
+      required: false
+      schema:
+        type: string
+    StatusQueryParam:
+      name: status
+      description: |
+        Changes the behavior of GET | HEAD based on the value of the status property. Will return a 404 if the status property does not match the query parameter."
+      in: query
+      schema:
+        type: string
+        x-extensible-enum:
+        - active
+        - inactive
+        - all
+        default: all
+    IfMatch:
+      name: If-Match
+      description: |
+        The RFC7232 If-Match header field in a request requires the server to only operate on the resource that matches at least one of the provided entity-tags. This allows clients express a precondition that prevent the method from being applied if there have been any changes to the resource.
+      in: header
+      schema:
+        type: string
+      required: true
+      example: 7da7a728-f910-11e6-942a-68f728c1ba70
+    IfNoneMatch:
+      name: If-None-Match
+      description: |
+        The RFC7232 If-None-Match header field in a request requires the server to only operate on the resource if it does not match any of the provided entity-tags. If the provided entity-tag is `*`, it is required that the resource does not exist at all.
+      in: header
+      schema:
+        type: string
+      required: false
+      example: 7da7a728-f910-11e6-942a-68f728c1ba70
+    SlugQueryParam:
+      name: slug
+      description: |
+        Filters resources by the [slug] property. Accepts comma-delimited values
+      in: query
+      schema:
+        type: array
+        items:
+          type: string
+      required: false
+      allowEmptyValue: false
+      example: ?slug=value1,value2,value3
+    NameQueryParam:
+      name: name
+      description: |
+        Filters resources by the [name] property. Accepts comma-delimited values
+      in: query
+      schema:
+        type: array
+        items:
+          type: string
+      required: false
+      allowEmptyValue: false
+      example: ?name=value1,value2,value3
+  headers:
+    Location:
+      description: The Location header indicates the URL of a newly created resource
+      schema:
+        type: string
+      example: /organisations/{id}
+    CacheControl:
+      description: |
+        As per the [RFC](https://tools.ietf.org/html/rfc7234#section-5.2.2) a Cache Control header will be returned by the server indicating whether or not it is possible for clients to use stale data (if must-revalidate is included then client may not use cached data after it has expired) and also, the recommended amount of time that a client would cache for
+      schema:
+        type: string
+      example: must-revalidate, max-age=300
+    RetryAfter:
+      description: |
+        This header will be retrieved in tandem with a '429' HTTP response code. The value of the header will be the number of seconds that a client should wait before attempting a new request.
+      schema:
+        type: integer
+    RateLimit:
+      description: |
+        This header will be retrieved in tandem with a '429' HTTP response code. The value of the header indicates how many requests per hour the client is allowed to make. The actual rate limiting will typically be applied per minute though. So if your hourly limit is 1800, you will be allowed to make (1800 / 60 = 30) requests per minute before being rate limited for the remainder of that minute window.
+      schema:
+        type: integer
+
+  requestBodies:
+    ContributorBody:
+      required: true
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/Contributor'
+    OrganisationBody:
+      required: true
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/Organisation'
+    RepositoryBody:
+      required: true
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/Repository'
+    PullRequestBody:
+      required: true
+      content:
+        application/json:
+          schema:
+            $ref: '#/components/schemas/PullRequest'
+  schemas:
+    PaginationLink:
+      type: string
+      format: uri
+      description: The hyperlink to a page of data
+      readOnly: true
+      example: https://github.cjbooms.com/api/{resource}?cursor=NDI%3D&limit=5
+    ContributorQueryResult:
+      type: object
+      required:
+      - items
+      properties:
+        prev:
+          $ref: '#/components/schemas/PaginationLink'
+        next:
+          $ref: '#/components/schemas/PaginationLink'
+        items:
+          type: array
+          minItems: 0
+          items:
+            $ref: '#/components/schemas/Contributor'
+    OrganisationQueryResult:
+      type: object
+      required:
+      - items
+      properties:
+        prev:
+          $ref: '#/components/schemas/PaginationLink'
+        next:
+          $ref: '#/components/schemas/PaginationLink'
+        items:
+          type: array
+          minItems: 0
+          items:
+            $ref: '#/components/schemas/Organisation'
+    RepositoryQueryResult:
+      type: object
+      required:
+      - items
+      properties:
+        prev:
+          $ref: '#/components/schemas/PaginationLink'
+        next:
+          $ref: '#/components/schemas/PaginationLink'
+        items:
+          type: array
+          minItems: 0
+          items:
+            $ref: '#/components/schemas/Repository'
+    PullRequestQueryResult:
+      type: object
+      required:
+      - items
+      properties:
+        prev:
+          $ref: '#/components/schemas/PaginationLink'
+        next:
+          $ref: '#/components/schemas/PaginationLink'
+        items:
+          type: array
+          minItems: 0
+          items:
+            $ref: '#/components/schemas/PullRequest'
+    Contributor:
+      type: object
+      required:
+      - id
+      - created
+      - created_by
+      - created_by_uid
+      - modified
+      - modified_by
+      - modified_by_uid
+      - status
+      - etag
+      - audit_actor
+      - username
+      properties:
+        id:
+          description: This unique server-generated identity will always be present
+            in the response.
+          type: string
+          readOnly: true
+        audit_actor:
+          description: |
+            The actor authoring a write to a resource
+          type: string
+          writeOnly: true
+        created:
+          description: Indicates the timestamp that this resource was initially created
+            at
+          type: string
+          format: date-time
+          readOnly: true
+        created_by:
+          description: Represents the actor/user who first created this resource
+          type: string
+          readOnly: true
+        created_by_uid:
+          description: Represents the uid from the auth token that first created this
+            resource
+          type: string
+          readOnly: true
+        modified:
+          description: Indicates the timestamp that this resource was last modified
+            at
+          type: string
+          format: date-time
+          readOnly: true
+        modified_by:
+          description: Represents the actor/user who last modified this resource
+          type: string
+          readOnly: true
+        modified_by_uid:
+          description: Represents the uid from the auth token that last modified this
+            resource
+          type: string
+          readOnly: true
+        status:
+          type: string
+          x-extensible-enum:
+          - active
+          - inactive
+          description: States whether the entity is currently active or inactive
+        etag:
+          type: string
+          description: |
+            Server generated value which is used as a version for the resource. This value is to be used in conjunction with If-Match headers for optimistic locking purposes
+          readOnly: true
+        username:
+          type: string
+        name:
+          type: string
+    Organisation:
+      type: object
+      required:
+      - id
+      - created
+      - created_by
+      - created_by_uid
+      - modified
+      - modified_by
+      - modified_by_uid
+      - status
+      - etag
+      - audit_actor
+      - name
+      properties:
+        id:
+          description: This unique server-generated identity will always be present
+            in the response.
+          type: string
+          readOnly: true
+        audit_actor:
+          description: |
+            The actor authoring a write to a resource
+          type: string
+          writeOnly: true
+        created:
+          description: Indicates the timestamp that this resource was initially created
+            at
+          type: string
+          format: date-time
+          readOnly: true
+        created_by:
+          description: Represents the actor/user who first created this resource
+          type: string
+          readOnly: true
+        created_by_uid:
+          description: Represents the uid from the auth token that first created this
+            resource
+          type: string
+          readOnly: true
+        modified:
+          description: Indicates the timestamp that this resource was last modified
+            at
+          type: string
+          format: date-time
+          readOnly: true
+        modified_by:
+          description: Represents the actor/user who last modified this resource
+          type: string
+          readOnly: true
+        modified_by_uid:
+          description: Represents the uid from the auth token that last modified this
+            resource
+          type: string
+          readOnly: true
+        status:
+          type: string
+          x-extensible-enum:
+          - active
+          - inactive
+          description: States whether the entity is currently active or inactive
+        etag:
+          type: string
+          description: |
+            Server generated value which is used as a version for the resource. This value is to be used in conjunction with If-Match headers for optimistic locking purposes
+          readOnly: true
+        name:
+          type: string
+        icon:
+          type: string
+        hooks:
+          type: array
+          items:
+            $ref: '#/components/schemas/Webhook'
+    Repository:
+      type: object
+      required:
+      - id
+      - created
+      - created_by
+      - created_by_uid
+      - modified
+      - modified_by
+      - modified_by_uid
+      - status
+      - etag
+      - audit_actor
+      - slug
+      - name
+      properties:
+        id:
+          description: This unique server-generated identity will always be present
+            in the response.
+          type: string
+          readOnly: true
+        audit_actor:
+          description: |
+            The actor authoring a write to a resource
+          type: string
+          writeOnly: true
+        created:
+          description: Indicates the timestamp that this resource was initially created
+            at
+          type: string
+          format: date-time
+          readOnly: true
+        created_by:
+          description: Represents the actor/user who first created this resource
+          type: string
+          readOnly: true
+        created_by_uid:
+          description: Represents the uid from the auth token that first created this
+            resource
+          type: string
+          readOnly: true
+        modified:
+          description: Indicates the timestamp that this resource was last modified
+            at
+          type: string
+          format: date-time
+          readOnly: true
+        modified_by:
+          description: Represents the actor/user who last modified this resource
+          type: string
+          readOnly: true
+        modified_by_uid:
+          description: Represents the uid from the auth token that last modified this
+            resource
+          type: string
+          readOnly: true
+        status:
+          type: string
+          x-extensible-enum:
+          - active
+          - inactive
+          description: States whether the entity is currently active or inactive
+        etag:
+          type: string
+          description: |
+            Server generated value which is used as a version for the resource. This value is to be used in conjunction with If-Match headers for optimistic locking purposes
+          readOnly: true
+        slug:
+          type: string
+        name:
+          type: string
+        visibility:
+          type: string
+          x-extensible-enum:
+          - Private
+          - Public
+        tags:
+          type: array
+          items:
+            type: string
+    PullRequest:
+      type: object
+      required:
+      - id
+      - created
+      - created_by
+      - created_by_uid
+      - modified
+      - modified_by
+      - modified_by_uid
+      - status
+      - etag
+      - audit_actor
+      - title
+      properties:
+        id:
+          description: This unique server-generated identity will always be present
+            in the response.
+          type: string
+          readOnly: true
+        audit_actor:
+          description: |
+            The actor authoring a write to a resource
+          type: string
+          writeOnly: true
+        created:
+          description: Indicates the timestamp that this resource was initially created
+            at
+          type: string
+          format: date-time
+          readOnly: true
+        created_by:
+          description: Represents the actor/user who first created this resource
+          type: string
+          readOnly: true
+        created_by_uid:
+          description: Represents the uid from the auth token that first created this
+            resource
+          type: string
+          readOnly: true
+        modified:
+          description: Indicates the timestamp that this resource was last modified
+            at
+          type: string
+          format: date-time
+          readOnly: true
+        modified_by:
+          description: Represents the actor/user who last modified this resource
+          type: string
+          readOnly: true
+        modified_by_uid:
+          description: Represents the uid from the auth token that last modified this
+            resource
+          type: string
+          readOnly: true
+        status:
+          type: string
+          x-extensible-enum:
+          - active
+          - inactive
+          description: States whether the entity is currently active or inactive
+        etag:
+          type: string
+          description: |
+            Server generated value which is used as a version for the resource. This value is to be used in conjunction with If-Match headers for optimistic locking purposes
+          readOnly: true
+        title:
+          type: string
+        description:
+          type: string
+        author:
+          $ref: '#/components/schemas/Author'
+    Webhook:
+      type: object
+      required:
+      - url
+      properties:
+        url:
+          type: string
+        name:
+          type: string
+    Author:
+      type: object
+      properties:
+        name:
+          type: string
+        email:
+          type: string
diff --git a/src/test/resources/examples/putApi/controllers/Controllers.kt b/src/test/resources/examples/putApi/controllers/Controllers.kt
new file mode 100644
index 0000000..44ea425
--- /dev/null
+++ b/src/test/resources/examples/putApi/controllers/Controllers.kt
@@ -0,0 +1,50 @@
+package examples.putApi.controllers
+
+import examples.putApi.models.Contributor
+import examples.putApi.service.ContributorsService
+import javax.validation.Valid
+import kotlin.String
+import kotlin.Unit
+import org.springframework.http.ResponseEntity
+import org.springframework.stereotype.Controller
+import org.springframework.validation.annotation.Validated
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestHeader
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestMethod
+
+@Controller
+@Validated
+@RequestMapping("")
+class ContributorsController(
+    val service: ContributorsService
+) {
+    /**
+     * Update an existing Contributor
+     *
+     * @param id The unique id for entity
+     * @param ifMatch The RFC7232 If-Match header field in a request requires the server to only
+     * operate on the resource that matches at least one of the provided entity-tags. This allows clients
+     * express a precondition that prevent the method from being applied if there have been any changes
+     * to the resource.
+     *
+     */
+    @RequestMapping(
+        value = ["/contributors/{id}"],
+        produces = [],
+        method = [RequestMethod.PUT],
+        consumes = ["application/json"]
+    )
+    fun putById(
+        @RequestBody @Valid
+        contributor: Contributor,
+        @PathVariable(value = "id", required = true)
+        id: String,
+        @RequestHeader(value = "If-Match", required = true)
+        ifMatch: String
+    ): ResponseEntity<Unit> {
+        service.update(contributor, id, ifMatch)
+        return ResponseEntity.noContent().build()
+    }
+}
diff --git a/src/test/resources/examples/putApi/models/Models.kt b/src/test/resources/examples/putApi/models/Models.kt
new file mode 100644
index 0000000..067d53a
--- /dev/null
+++ b/src/test/resources/examples/putApi/models/Models.kt
@@ -0,0 +1,15 @@
+package examples.putApi.models
+
+import com.fasterxml.jackson.annotation.JsonProperty
+import javax.validation.constraints.NotNull
+import kotlin.String
+
+data class Contributor(
+    @param:JsonProperty("username")
+    @get:JsonProperty("username")
+    @get:NotNull
+    val username: String,
+    @param:JsonProperty("name")
+    @get:JsonProperty("name")
+    val name: String? = null
+)
diff --git a/src/test/resources/examples/putApi/open-api/api.yaml b/src/test/resources/examples/putApi/open-api/api.yaml
new file mode 100644
index 0000000..56cdcd9
--- /dev/null
+++ b/src/test/resources/examples/putApi/open-api/api.yaml
@@ -0,0 +1,54 @@
+openapi: 3.0.0
+paths:
+  /contributors/{id}:
+    put:
+      summary: "Update an existing Contributor"
+      tags:
+        - "contributor"
+      parameters:
+        - $ref: "#/components/parameters/IdPathParam"
+        - $ref: "#/components/parameters/IfMatch"
+      requestBody:
+        $ref: "#/components/requestBodies/ContributorBody"
+      responses:
+        204:
+          description: "Contributor successfully updated"
+
+components:
+  requestBodies:
+    ContributorBody:
+      required: true
+      content:
+        application/json:
+          schema:
+            $ref: "#/components/schemas/Contributor"
+  schemas:
+    Contributor:
+      type: object
+      x-fabric-resource-definition: true
+      required:
+        - username
+      properties:
+        username:
+          type: string
+        name:
+          type: string
+  parameters:
+    IdPathParam:
+      name: "id"
+      description: "The unique id for entity"
+      in: "path"
+      required: true
+      schema:
+        type: "string"
+    IfMatch:
+      name: "If-Match"
+      description: "The RFC7232 If-Match header field in a request requires the server\
+            \ to only operate on the resource that matches at least one of the provided\
+            \ entity-tags. This allows clients express a precondition that prevent the\
+            \ method from being applied if there have been any changes to the resource.\n"
+      in: "header"
+      schema:
+        type: "string"
+      required: true
+      example: "7da7a728-f910-11e6-942a-68f728c1ba70"
\ No newline at end of file
diff --git a/src/test/resources/examples/putApi/service/Services.kt b/src/test/resources/examples/putApi/service/Services.kt
new file mode 100644
index 0000000..a8ed84c
--- /dev/null
+++ b/src/test/resources/examples/putApi/service/Services.kt
@@ -0,0 +1,12 @@
+package examples.putApi.service
+
+import examples.putApi.models.Contributor
+import kotlin.String
+
+interface ContributorsService {
+    fun update(
+        contributor: Contributor,
+        id: String,
+        ifMatch: String
+    ): Contributor
+}
diff --git a/src/test/resources/examples/schemaOnlyApi/api.yaml b/src/test/resources/examples/schemaOnlyApi/api.yaml
new file mode 100644
index 0000000..40bf041
--- /dev/null
+++ b/src/test/resources/examples/schemaOnlyApi/api.yaml
@@ -0,0 +1,140 @@
+openapi: 3.0.0
+info:
+  x-api-id: 6c65f9b1-20ca-42dd-953e-ebbdf25e024e
+  x-audience: company-internal
+  description: Some description
+  version: 1.0.0
+  title: The API title
+externalDocs:
+  description: The docs are great.
+  url: http://find.them.here
+components:
+  schemas:
+    SomeProduct:
+      type: object
+      x-fabric-resource-definition: true
+      description: "Generic representation of Some Product information."
+      required:
+        - product_type
+        - product_id
+      x-fabric-query-params:
+        - product_ids
+      properties:
+        product_id:
+          type: string
+          description: The unique identifier for a single product data.
+        product_type:
+          type: string
+          description: "product data for which type product. It will always be present in the response."
+          x-extensible-enum:
+            - "TypeA"
+            - "TypeB"
+            - "TypeC"
+      discriminator:
+        propertyName: product_type
+    TypeA:
+      allOf:
+        - $ref: '#/components/schemas/SomeProduct'
+        - type: object
+          description: "Represents the Some Product type A information"
+          properties:
+            shared_attribute:
+              $ref: "#/components/schemas/SharedAttribute"
+    TypeB:
+      allOf:
+        - $ref: '#/components/schemas/SomeProduct'
+        - type: object
+          description: "Represents the Some Product type B information"
+          properties:
+            shared_attribute:
+              $ref: "#/components/schemas/SharedAttribute"
+            type_b_attribute:
+              description: Various product flags
+              type: array
+              items:
+                type: string
+                x-extensible-enum:
+                  - "FLAG A"
+                  - "FLAG B"
+            product_customs_numbers:
+              type: array
+              items:
+                $ref: '#/components/schemas/CustomsNumber'
+    TypeC:
+      allOf:
+        - $ref: '#/components/schemas/SomeProduct'
+        - type: object
+          description: "Represents the Some Product type C information"
+          properties:
+            shared_attribute:
+              $ref: "#/components/schemas/SharedAttribute"
+            attribute_a:
+              $ref: "#/components/schemas/AttributeA"
+            attribute_a:
+              $ref: "#/components/schemas/AttributeB"
+            attribute_a:
+              $ref: "#/components/schemas/AttributeC"
+            product_flags:
+              description: Various product flags
+              type: array
+              items:
+                type: string
+                x-extensible-enum:
+                  - "NOTIFICATION_REQUIRED"
+                  - "BLOCKED"
+                  - "LOCKED"
+                  - "RECEIVE"
+                  - "SORTABLE"
+            logistics_simple_preferential_articles:
+              type: array
+              items:
+                $ref: '#/components/schemas/OriginProduct'
+    CustomsNumber:
+      type: object
+      required:
+        - country
+        - number
+      properties:
+        country:
+          type: string
+          description: 2 letter country code
+          format: ISO Alpha-2
+          example: IE
+        number:
+          type: string
+          description: Customs tariff number of the country (also know as Statistical article number)
+          example: "11111111"
+        description:
+          type: string
+          description: Customs tariff number description
+          example: "Eine T-Shirt"
+    OriginProduct:
+      type: object
+      required:
+        - country
+      properties:
+        country:
+          type: string
+          description: 2 letter country code
+          format: ISO Alpha-2
+          example: IE
+    SharedAttribute:
+      type: string
+      description: 2 letter country code
+      format: ISO Alpha-2
+      example: IE
+    AttributeA:
+      type: number
+      format: decimal
+      description: attribute a for product
+      example: 100
+    AttributeB:
+      type: number
+      format: decimal
+      description: attribute b for product
+      example: 400
+    AttributeC:
+      type: number
+      format: decimal
+      description: attribute c for product
+      example: 200
diff --git a/src/test/resources/examples/simpleRequestBody/controllers/Controllers.kt b/src/test/resources/examples/simpleRequestBody/controllers/Controllers.kt
new file mode 100644
index 0000000..5ff790f
--- /dev/null
+++ b/src/test/resources/examples/simpleRequestBody/controllers/Controllers.kt
@@ -0,0 +1,33 @@
+package examples.simpleRequestBody.controllers
+
+import examples.simpleRequestBody.service.UploadService
+import javax.validation.Valid
+import kotlin.String
+import org.springframework.http.ResponseEntity
+import org.springframework.stereotype.Controller
+import org.springframework.validation.annotation.Validated
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestMethod
+
+@Controller
+@Validated
+@RequestMapping("/api")
+class UploadController(
+    val service: UploadService
+) {
+    /**
+     * Create a new Contributor
+     */
+    @RequestMapping(
+        value = ["/upload"],
+        produces = ["application/x-yaml"],
+        method = [RequestMethod.POST],
+        consumes = ["application/x-yaml"]
+    )
+    fun post(
+        @RequestBody @Valid
+        oASDocument: String
+    ): ResponseEntity<String> = ResponseEntity
+        .ok(service.create(oASDocument))
+}
diff --git a/src/test/resources/examples/simpleRequestBody/open-api/api.yaml b/src/test/resources/examples/simpleRequestBody/open-api/api.yaml
new file mode 100644
index 0000000..aa81d58
--- /dev/null
+++ b/src/test/resources/examples/simpleRequestBody/open-api/api.yaml
@@ -0,0 +1,35 @@
+openapi: "3.0.0"
+info:
+  title: "API Example"
+  version: "1.0"
+servers:
+  - url: https://example.docs.cjbooms.com/api
+    description: API service url
+paths:
+  /upload:
+    post:
+      summary: Create a new Contributor
+      tags:
+        - contributor
+      requestBody:
+        $ref: '#/components/requestBodies/TransformationRequestBody'
+      responses:
+        200:
+          description: Contributor successfully created
+          content:
+            application/x-yaml:
+              schema:
+                $ref: '#/components/schemas/GatewayCRD'
+components:
+  requestBodies:
+    TransformationRequestBody:
+      required: true
+      content:
+        application/x-yaml:
+          schema:
+            $ref: '#/components/schemas/OASDocument'
+  schemas:
+    OASDocument:
+      type: string
+    GatewayCRD:
+      type: string
diff --git a/src/test/resources/examples/simpleRequestBody/service/Services.kt b/src/test/resources/examples/simpleRequestBody/service/Services.kt
new file mode 100644
index 0000000..741ae0f
--- /dev/null
+++ b/src/test/resources/examples/simpleRequestBody/service/Services.kt
@@ -0,0 +1,9 @@
+package examples.simpleRequestBody.service
+
+import java.net.URI
+import kotlin.Pair
+import kotlin.String
+
+interface UploadService {
+    fun create(oASDocument: String): Pair<URI, String?>
+}