Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add springboot's http interface generator #383

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ This section documents the available CLI parameters for controlling what gets ge
| | CHOOSE ONE OF: |
| | `OK_HTTP` - Generate OkHttp client. |
| | `OPEN_FEIGN` - Generate OpenFeign client. |
| | `SPRING_HTTP_INTERFACE` - Generate Spring HTTP Interface. |
| `--http-controller-opts` | Select the options for the controllers that you want to be generated. |
| | CHOOSE ANY OF: |
| | `SUSPEND_MODIFIER` - This option adds the suspend modifier to the generated controller functions |
Expand Down
70 changes: 70 additions & 0 deletions end2end-tests/spring-http-interface/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
val fabrikt: Configuration by configurations.creating

val generationDir = "$buildDir/generated"
val apiFile = "${rootProject.projectDir}/src/test/resources/examples/okHttpClient/api.yaml"

sourceSets {
main { java.srcDirs("$generationDir/src/main/kotlin") }
test { java.srcDirs("$generationDir/src/test/kotlin") }
}

plugins {
kotlin("jvm")
}

java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

val jacksonVersion: String by rootProject.extra
val junitVersion: String by rootProject.extra

dependencies {
implementation("com.squareup.okhttp3:okhttp:4.10.0")
implementation("org.springframework.boot:spring-boot-starter-web:3.4.3")
implementation("jakarta.validation:jakarta.validation-api:3.0.2")
implementation("javax.validation:validation-api:2.0.1.Final")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
implementation("com.fasterxml.jackson.core:jackson-core:$jacksonVersion")
implementation("com.fasterxml.jackson.core:jackson-annotations:$jacksonVersion")

testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion")
testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
testImplementation("org.junit.jupiter:junit-jupiter-params:$junitVersion")
testImplementation("org.assertj:assertj-core:3.24.2")
testImplementation("org.wiremock:wiremock:3.3.1")
testImplementation("com.marcinziolo:kotlin-wiremock:2.1.1")
}

tasks {
val generateCode by creating(JavaExec::class) {
inputs.files(apiFile)
outputs.dir(generationDir)
outputs.cacheIf { true }
classpath = rootProject.files("./build/libs/fabrikt-${rootProject.version}.jar")
mainClass.set("com.cjbooms.fabrikt.cli.CodeGen")
args = listOf(
"--output-directory", generationDir,
"--base-package", "com.example",
"--api-file", apiFile,
"--targets", "http_models",
"--targets", "client",
"--http-client-target", "SPRING_HTTP_INTERFACE",
)
dependsOn(":jar")
dependsOn(":shadowJar")
}

withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions.jvmTarget = "17"
dependsOn(generateCode)
}


withType<Test> {
useJUnitPlatform()
jvmArgs = listOf("--add-opens=java.base/java.lang=ALL-UNNAMED", "--add-opens=java.base/java.util=ALL-UNNAMED")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.cjbooms.fabrikt.clients

import com.example.client.ExamplePath1Client
import com.example.models.FirstModel
import com.example.models.QueryResult
import com.fasterxml.jackson.databind.ObjectMapper
import com.github.tomakehurst.wiremock.WireMockServer
import com.github.tomakehurst.wiremock.common.ConsoleNotifier
import com.github.tomakehurst.wiremock.core.WireMockConfiguration
import com.marcinziolo.kotlin.wiremock.contains
import com.marcinziolo.kotlin.wiremock.get
import com.marcinziolo.kotlin.wiremock.like
import com.marcinziolo.kotlin.wiremock.returns
import java.net.ServerSocket
import org.assertj.core.api.Assertions
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
import org.springframework.web.client.RestClient
import org.springframework.web.client.support.RestClientAdapter
import org.springframework.web.service.invoker.HttpServiceProxyFactory
import org.springframework.web.service.invoker.createClient

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class AdditionalQueryParametersTest {
private val port: Int = ServerSocket(0).use { socket -> socket.localPort }
private val wiremock: WireMockServer = WireMockServer(
WireMockConfiguration.options().port(port).notifier(ConsoleNotifier(true)))
private val mapper = ObjectMapper()
private val examplePath1Client: ExamplePath1Client = run {
val restClient = RestClient.builder()
.baseUrl("http://localhost:$port")
.messageConverters(listOf(MappingJackson2HttpMessageConverter(mapper)))
.build()
val adapter = RestClientAdapter.create(restClient)
val factory = HttpServiceProxyFactory.builderFor(adapter).build()
factory.createClient()
}

@BeforeEach
fun setUp() {
wiremock.start()
}

@AfterEach
fun afterEach() {
wiremock.resetAll()
wiremock.stop()
}

@Test
fun `additional query parameters are properly appended to requests`() {
val expectedResponse = QueryResult(listOf(FirstModel(id = "the parameter was there!")))
wiremock.get {
urlPath like "/example-path-1"
queryParams contains "unspecified_param" like "some_value"
} returns {
statusCode = 200
header = "Content-Type" to "application/json"
body = mapper.writeValueAsString(expectedResponse)
}
val result = examplePath1Client.getExamplePath1(additionalQueryParameters = mapOf("unspecified_param" to "some_value"))
Assertions.assertThat(result).isEqualTo(expectedResponse)
}
}
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ include(
"end2end-tests:okhttp",
"end2end-tests:openfeign",
"end2end-tests:ktor",
"end2end-tests:spring-http-interface",
"end2end-tests:models-jackson",
"end2end-tests:models-kotlinx",
"playground",
Expand Down
20 changes: 16 additions & 4 deletions src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenOptions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import com.cjbooms.fabrikt.generators.JakartaAnnotations
import com.cjbooms.fabrikt.generators.JavaxValidationAnnotations
import com.cjbooms.fabrikt.generators.NoValidationAnnotations
import com.cjbooms.fabrikt.generators.ValidationAnnotations
import com.cjbooms.fabrikt.model.SerializationAnnotations
import com.cjbooms.fabrikt.model.JacksonAnnotations
import com.cjbooms.fabrikt.model.KotlinxSerializationAnnotations
import com.cjbooms.fabrikt.model.SerializationAnnotations

enum class CodeGenerationType(val description: String) {
HTTP_MODELS(
Expand All @@ -33,14 +33,17 @@ enum class ClientCodeGenOptionType(private val description: String) {
;

override fun toString() = "`${super.toString()}` - $description"

companion object {
const val DEFAULT_OPEN_FEIGN_CLIENT_NAME = "fabrikt-client"
}
}

enum class ClientCodeGenTargetType(val description: String) {
OK_HTTP("Generate OkHttp client."),
OPEN_FEIGN("Generate OpenFeign client.");
OPEN_FEIGN("Generate OpenFeign client."),
SPRING_HTTP_INTERFACE("Generate Spring HTTP Interface."),
;

override fun toString() = "`${super.toString()}` - $description"

Expand All @@ -67,6 +70,7 @@ enum class ControllerCodeGenOptionType(val description: String) {
SUSPEND_MODIFIER("This option adds the suspend modifier to the generated controller functions"),
AUTHENTICATION("This option adds the authentication parameter to the generated controller functions"),
COMPLETION_STAGE("This option makes generated controller functions have Type CompletionStage<T> (works only with Spring Controller generator)");

override fun toString() = "`${super.toString()}` - $description"
}

Expand All @@ -92,13 +96,18 @@ enum class CodeGenTypeOverride(val description: String) {
DATE_AS_STRING("Ignore string format `date` and use `String` as the type"),
DATETIME_AS_STRING("Ignore string format `date-time` and use `String` as the type"),
BYTEARRAY_AS_INPUTSTREAM("Use `InputStream` as ByteArray type. Defaults to `ByteArray`");

override fun toString() = "`${super.toString()}` - $description"
}

enum class ValidationLibrary(val description: String, val annotations: ValidationAnnotations) {
JAVAX_VALIDATION("Use `javax.validation` annotations in generated model classes (default)", JavaxValidationAnnotations),
JAVAX_VALIDATION(
"Use `javax.validation` annotations in generated model classes (default)",
JavaxValidationAnnotations
),
JAKARTA_VALIDATION("Use `jakarta.validation` annotations in generated model classes", JakartaAnnotations),
NO_VALIDATION("Use no validation annotations in generated model classes", NoValidationAnnotations);

override fun toString() = "`${super.toString()}` - $description"

companion object {
Expand All @@ -119,7 +128,10 @@ enum class ExternalReferencesResolutionMode(val description: String) {

enum class SerializationLibrary(val description: String, val serializationAnnotations: SerializationAnnotations) {
JACKSON("Use Jackson for serialization and deserialization", JacksonAnnotations),
KOTLINX_SERIALIZATION("Use kotlinx.serialization for serialization and deserialization", KotlinxSerializationAnnotations);
KOTLINX_SERIALIZATION(
"Use kotlinx.serialization for serialization and deserialization",
KotlinxSerializationAnnotations
);

override fun toString() = "`${super.toString()}` - $description"

Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenerator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.cjbooms.fabrikt.configurations.Packages
import com.cjbooms.fabrikt.generators.MutableSettings
import com.cjbooms.fabrikt.generators.client.OkHttpClientGenerator
import com.cjbooms.fabrikt.generators.client.OpenFeignInterfaceGenerator
import com.cjbooms.fabrikt.generators.client.SpringHttpInterfaceGenerator
import com.cjbooms.fabrikt.generators.controller.KtorControllerInterfaceGenerator
import com.cjbooms.fabrikt.generators.controller.MicronautControllerInterfaceGenerator
import com.cjbooms.fabrikt.generators.controller.SpringControllerInterfaceGenerator
Expand Down Expand Up @@ -48,6 +49,7 @@ class CodeGenerator(
val clientGenerator = when (MutableSettings.clientTarget()) {
ClientCodeGenTargetType.OK_HTTP -> OkHttpClientGenerator(packages, sourceApi, srcPath)
ClientCodeGenTargetType.OPEN_FEIGN -> OpenFeignInterfaceGenerator(packages, sourceApi)
ClientCodeGenTargetType.SPRING_HTTP_INTERFACE -> SpringHttpInterfaceGenerator(packages, sourceApi)
}
val options = MutableSettings.clientOptions()
val clientFiles = clientGenerator.generate(options).files
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.cjbooms.fabrikt.generators.client

import com.cjbooms.fabrikt.cli.ClientCodeGenOptionType
import com.cjbooms.fabrikt.configurations.Packages
import com.cjbooms.fabrikt.generators.GeneratorUtils
import com.cjbooms.fabrikt.generators.GeneratorUtils.getPrimaryContentMediaType
Expand All @@ -10,7 +11,9 @@ import com.cjbooms.fabrikt.generators.GeneratorUtils.hasMultipleSuccessResponseS
import com.cjbooms.fabrikt.generators.GeneratorUtils.toClassName
import com.cjbooms.fabrikt.generators.GeneratorUtils.toIncomingParameters
import com.cjbooms.fabrikt.generators.OasDefault
import com.cjbooms.fabrikt.generators.controller.metadata.SpringImports.RESPONSE_ENTITY
import com.cjbooms.fabrikt.generators.model.ModelGenerator.Companion.toModelType
import com.cjbooms.fabrikt.model.BodyParameter
import com.cjbooms.fabrikt.model.ClientType
import com.cjbooms.fabrikt.model.HeaderParam
import com.cjbooms.fabrikt.model.IncomingParameter
Expand All @@ -21,13 +24,15 @@ import com.reprezen.kaizen.oasparser.model3.Operation
import com.reprezen.kaizen.oasparser.model3.Path
import com.squareup.kotlinpoet.AnnotationSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.KModifier
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import com.squareup.kotlinpoet.TypeName
import com.squareup.kotlinpoet.asTypeName

object ClientGeneratorUtils {
const val ACCEPT_HEADER_NAME = "Accept"
const val ACCEPT_HEADER_VARIABLE_NAME = "acceptHeader"
const val CONTENT_TYPE_HEADER_NAME = "Content-Type"
const val ADDITIONAL_HEADERS_PARAMETER_NAME = "additionalHeaders"
const val ADDITIONAL_QUERY_PARAMETERS_PARAMETER_NAME = "additionalQueryParameters"

Expand Down Expand Up @@ -63,7 +68,12 @@ object ClientGeneratorUtils {
fun deriveClientParameters(path: Path, operation: Operation, basePackage: String): List<IncomingParameter> {
fun needsAcceptHeaderParameter(path: Path, operation: Operation): Boolean {
val hasAcceptParameter = GeneratorUtils.mergeParameters(path.parameters, operation.parameters)
.any { parameter -> parameter.`in` == "header" && parameter.name.equals(ACCEPT_HEADER_NAME, ignoreCase = true) }
.any { parameter ->
parameter.`in` == "header" && parameter.name.equals(
ACCEPT_HEADER_NAME,
ignoreCase = true
)
}
return operation.hasMultipleContentMediaTypes() == true && !hasAcceptParameter
}

Expand Down Expand Up @@ -91,7 +101,8 @@ object ClientGeneratorUtils {

fun FunSpec.Builder.addIncomingParameters(
parameters: List<IncomingParameter>,
annotateRequestParameterWith: ((parameter: RequestParameter) -> AnnotationSpec?)? = null
annotateRequestParameterWith: ((parameter: RequestParameter) -> AnnotationSpec?)? = null,
annotateBodyParameterWith: ((parameter: BodyParameter) -> AnnotationSpec?)? = null,
): FunSpec.Builder {
val specs = parameters.map {
val builder = it.toParameterSpecBuilder()
Expand All @@ -103,8 +114,33 @@ object ClientGeneratorUtils {
builder.addAnnotation(annotationSpec)
}
}
if (it is BodyParameter) {
annotateBodyParameterWith?.invoke(it)?.let { annotationSpec ->
builder.addAnnotation(annotationSpec)
}
}
builder.build()
}
return this.addParameters(specs)
}

/**
* Add suspend as modified to method definitions on supported clients, ex. CoroutineFeign, Spring HTTP Interface.
*/
fun FunSpec.Builder.addSuspendModifier(options: Set<ClientCodeGenOptionType>): FunSpec.Builder {
if (options.contains(ClientCodeGenOptionType.SUSPEND_MODIFIER)) {
this.addModifiers(KModifier.SUSPEND)
}
return this
}

/**
* Adds a ResponseEntity around the returned object so that we can get headers and statuscodes
*/
fun TypeName.optionallyParameterizeWithResponseEntity(options: Set<ClientCodeGenOptionType>): TypeName {
if (options.contains(ClientCodeGenOptionType.SPRING_RESPONSE_ENTITY_WRAPPER)) {
return RESPONSE_ENTITY.parameterizedBy(this)
}
return this
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ import com.cjbooms.fabrikt.generators.TypeFactory
import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.ADDITIONAL_HEADERS_PARAMETER_NAME
import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.ADDITIONAL_QUERY_PARAMETERS_PARAMETER_NAME
import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.addIncomingParameters
import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.addSuspendModifier
import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.deriveClientParameters
import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.getReturnType
import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.optionallyParameterizeWithResponseEntity
import com.cjbooms.fabrikt.generators.client.ClientGeneratorUtils.simpleClientName
import com.cjbooms.fabrikt.generators.client.metadata.OpenFeignAnnotations
import com.cjbooms.fabrikt.generators.controller.metadata.SpringImports.RESPONSE_ENTITY
import com.cjbooms.fabrikt.model.ClientType
import com.cjbooms.fabrikt.model.Clients
import com.cjbooms.fabrikt.model.GeneratedFile
Expand All @@ -34,12 +35,9 @@ import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.KModifier
import com.squareup.kotlinpoet.ParameterSpec
import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.TypeName
import com.squareup.kotlinpoet.asTypeName
import com.squareup.kotlinpoet.buildCodeBlock

import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy

class OpenFeignInterfaceGenerator(
private val packages: Packages,
private val api: SourceApi,
Expand Down Expand Up @@ -160,7 +158,7 @@ class OpenFeignInterfaceGenerator(
}

private fun List<IncomingParameter>.getPathAndQueryParameters():
Pair<List<RequestParameter>, List<RequestParameter>> {
Pair<List<RequestParameter>, List<RequestParameter>> {
val queryParameters = mutableListOf<RequestParameter>()
val pathVariables = mutableListOf<RequestParameter>()
for (parameter in this) {
Expand Down Expand Up @@ -287,24 +285,4 @@ class OpenFeignInterfaceGenerator(
}
}
}

/**
* Adds suspend as modified to the func spec so that i can be used with CoroutineFeign
*/
private fun FunSpec.Builder.addSuspendModifier(options: Set<ClientCodeGenOptionType>): FunSpec.Builder {
if (options.contains(ClientCodeGenOptionType.SUSPEND_MODIFIER)) {
this.addModifiers(KModifier.SUSPEND)
}
return this
}

/**
* Adds a ResponseEntity around the returned object so that we can get headers and statuscodes
*/
private fun TypeName.optionallyParameterizeWithResponseEntity(options: Set<ClientCodeGenOptionType>): TypeName {
if (options.contains(ClientCodeGenOptionType.SPRING_RESPONSE_ENTITY_WRAPPER)) {
return RESPONSE_ENTITY.parameterizedBy(this)
}
return this
}
}
Loading