Skip to content

Commit 1fcfa8c

Browse files
committed
add springboot's http interface generator
1 parent 3238070 commit 1fcfa8c

File tree

18 files changed

+1758
-40
lines changed

18 files changed

+1758
-40
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
val fabrikt: Configuration by configurations.creating
2+
3+
val generationDir = "$buildDir/generated"
4+
val apiFile = "${rootProject.projectDir}/src/test/resources/examples/okHttpClient/api.yaml"
5+
6+
sourceSets {
7+
main { java.srcDirs("$generationDir/src/main/kotlin") }
8+
test { java.srcDirs("$generationDir/src/test/kotlin") }
9+
}
10+
11+
plugins {
12+
kotlin("jvm")
13+
}
14+
15+
java {
16+
sourceCompatibility = JavaVersion.VERSION_17
17+
targetCompatibility = JavaVersion.VERSION_17
18+
}
19+
20+
val jacksonVersion: String by rootProject.extra
21+
val junitVersion: String by rootProject.extra
22+
23+
dependencies {
24+
implementation("com.squareup.okhttp3:okhttp:4.10.0")
25+
implementation("org.springframework.boot:spring-boot-starter-web:3.4.3")
26+
implementation("jakarta.validation:jakarta.validation-api:3.0.2")
27+
implementation("javax.validation:validation-api:2.0.1.Final")
28+
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
29+
implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
30+
implementation("com.fasterxml.jackson.core:jackson-core:$jacksonVersion")
31+
implementation("com.fasterxml.jackson.core:jackson-annotations:$jacksonVersion")
32+
33+
testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion")
34+
testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
35+
testImplementation("org.junit.jupiter:junit-jupiter-params:$junitVersion")
36+
testImplementation("org.assertj:assertj-core:3.24.2")
37+
testImplementation("org.wiremock:wiremock:3.3.1")
38+
testImplementation("com.marcinziolo:kotlin-wiremock:2.1.1")
39+
}
40+
41+
tasks {
42+
val generateCode by creating(JavaExec::class) {
43+
inputs.files(apiFile)
44+
outputs.dir(generationDir)
45+
outputs.cacheIf { true }
46+
classpath = rootProject.files("./build/libs/fabrikt-${rootProject.version}.jar")
47+
mainClass.set("com.cjbooms.fabrikt.cli.CodeGen")
48+
args = listOf(
49+
"--output-directory", generationDir,
50+
"--base-package", "com.example",
51+
"--api-file", apiFile,
52+
"--targets", "http_models",
53+
"--targets", "client",
54+
"--http-client-target", "SPRING_HTTP_INTERFACE",
55+
)
56+
dependsOn(":jar")
57+
dependsOn(":shadowJar")
58+
}
59+
60+
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
61+
kotlinOptions.jvmTarget = "17"
62+
dependsOn(generateCode)
63+
}
64+
65+
66+
withType<Test> {
67+
useJUnitPlatform()
68+
jvmArgs = listOf("--add-opens=java.base/java.lang=ALL-UNNAMED", "--add-opens=java.base/java.util=ALL-UNNAMED")
69+
}
70+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.cjbooms.fabrikt.clients
2+
3+
import com.example.client.ExamplePath1Client
4+
import com.example.models.FirstModel
5+
import com.example.models.QueryResult
6+
import com.fasterxml.jackson.databind.ObjectMapper
7+
import com.github.tomakehurst.wiremock.WireMockServer
8+
import com.github.tomakehurst.wiremock.common.ConsoleNotifier
9+
import com.github.tomakehurst.wiremock.core.WireMockConfiguration
10+
import com.marcinziolo.kotlin.wiremock.contains
11+
import com.marcinziolo.kotlin.wiremock.get
12+
import com.marcinziolo.kotlin.wiremock.like
13+
import com.marcinziolo.kotlin.wiremock.returns
14+
import java.net.ServerSocket
15+
import org.assertj.core.api.Assertions
16+
import org.junit.jupiter.api.AfterEach
17+
import org.junit.jupiter.api.BeforeEach
18+
import org.junit.jupiter.api.Test
19+
import org.junit.jupiter.api.TestInstance
20+
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
21+
import org.springframework.web.client.RestClient
22+
import org.springframework.web.client.support.RestClientAdapter
23+
import org.springframework.web.service.invoker.HttpServiceProxyFactory
24+
import org.springframework.web.service.invoker.createClient
25+
26+
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
27+
class AdditionalQueryParametersTest {
28+
private val port: Int = ServerSocket(0).use { socket -> socket.localPort }
29+
private val wiremock: WireMockServer = WireMockServer(
30+
WireMockConfiguration.options().port(port).notifier(ConsoleNotifier(true)))
31+
private val mapper = ObjectMapper()
32+
private val examplePath1Client: ExamplePath1Client = run {
33+
val restClient = RestClient.builder()
34+
.baseUrl("http://localhost:$port")
35+
.messageConverters(listOf(MappingJackson2HttpMessageConverter(mapper)))
36+
.build()
37+
val adapter = RestClientAdapter.create(restClient)
38+
val factory = HttpServiceProxyFactory.builderFor(adapter).build()
39+
factory.createClient()
40+
}
41+
42+
@BeforeEach
43+
fun setUp() {
44+
wiremock.start()
45+
}
46+
47+
@AfterEach
48+
fun afterEach() {
49+
wiremock.resetAll()
50+
wiremock.stop()
51+
}
52+
53+
@Test
54+
fun `additional query parameters are properly appended to requests`() {
55+
val expectedResponse = QueryResult(listOf(FirstModel(id = "the parameter was there!")))
56+
wiremock.get {
57+
urlPath like "/example-path-1"
58+
queryParams contains "unspecified_param" like "some_value"
59+
} returns {
60+
statusCode = 200
61+
header = "Content-Type" to "application/json"
62+
body = mapper.writeValueAsString(expectedResponse)
63+
}
64+
val result = examplePath1Client.getExamplePath1(additionalQueryParameters = mapOf("unspecified_param" to "some_value"))
65+
Assertions.assertThat(result).isEqualTo(expectedResponse)
66+
}
67+
}

settings.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ include(
44
"end2end-tests:okhttp",
55
"end2end-tests:openfeign",
66
"end2end-tests:ktor",
7+
"end2end-tests:spring-http-interface",
78
"end2end-tests:models-jackson",
89
"end2end-tests:models-kotlinx",
910
"playground",

src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenOptions.kt

+16-4
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import com.cjbooms.fabrikt.generators.JakartaAnnotations
44
import com.cjbooms.fabrikt.generators.JavaxValidationAnnotations
55
import com.cjbooms.fabrikt.generators.NoValidationAnnotations
66
import com.cjbooms.fabrikt.generators.ValidationAnnotations
7-
import com.cjbooms.fabrikt.model.SerializationAnnotations
87
import com.cjbooms.fabrikt.model.JacksonAnnotations
98
import com.cjbooms.fabrikt.model.KotlinxSerializationAnnotations
9+
import com.cjbooms.fabrikt.model.SerializationAnnotations
1010

1111
enum class CodeGenerationType(val description: String) {
1212
HTTP_MODELS(
@@ -33,14 +33,17 @@ enum class ClientCodeGenOptionType(private val description: String) {
3333
;
3434

3535
override fun toString() = "`${super.toString()}` - $description"
36+
3637
companion object {
3738
const val DEFAULT_OPEN_FEIGN_CLIENT_NAME = "fabrikt-client"
3839
}
3940
}
4041

4142
enum class ClientCodeGenTargetType(val description: String) {
4243
OK_HTTP("Generate OkHttp client."),
43-
OPEN_FEIGN("Generate OpenFeign client.");
44+
OPEN_FEIGN("Generate OpenFeign client."),
45+
SPRING_HTTP_INTERFACE("Generate Spring HTTP Interface."),
46+
;
4447

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

@@ -67,6 +70,7 @@ enum class ControllerCodeGenOptionType(val description: String) {
6770
SUSPEND_MODIFIER("This option adds the suspend modifier to the generated controller functions"),
6871
AUTHENTICATION("This option adds the authentication parameter to the generated controller functions"),
6972
COMPLETION_STAGE("This option makes generated controller functions have Type CompletionStage<T> (works only with Spring Controller generator)");
73+
7074
override fun toString() = "`${super.toString()}` - $description"
7175
}
7276

@@ -92,13 +96,18 @@ enum class CodeGenTypeOverride(val description: String) {
9296
DATE_AS_STRING("Ignore string format `date` and use `String` as the type"),
9397
DATETIME_AS_STRING("Ignore string format `date-time` and use `String` as the type"),
9498
BYTEARRAY_AS_INPUTSTREAM("Use `InputStream` as ByteArray type. Defaults to `ByteArray`");
99+
95100
override fun toString() = "`${super.toString()}` - $description"
96101
}
97102

98103
enum class ValidationLibrary(val description: String, val annotations: ValidationAnnotations) {
99-
JAVAX_VALIDATION("Use `javax.validation` annotations in generated model classes (default)", JavaxValidationAnnotations),
104+
JAVAX_VALIDATION(
105+
"Use `javax.validation` annotations in generated model classes (default)",
106+
JavaxValidationAnnotations
107+
),
100108
JAKARTA_VALIDATION("Use `jakarta.validation` annotations in generated model classes", JakartaAnnotations),
101109
NO_VALIDATION("Use no validation annotations in generated model classes", NoValidationAnnotations);
110+
102111
override fun toString() = "`${super.toString()}` - $description"
103112

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

120129
enum class SerializationLibrary(val description: String, val serializationAnnotations: SerializationAnnotations) {
121130
JACKSON("Use Jackson for serialization and deserialization", JacksonAnnotations),
122-
KOTLINX_SERIALIZATION("Use kotlinx.serialization for serialization and deserialization", KotlinxSerializationAnnotations);
131+
KOTLINX_SERIALIZATION(
132+
"Use kotlinx.serialization for serialization and deserialization",
133+
KotlinxSerializationAnnotations
134+
);
123135

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

src/main/kotlin/com/cjbooms/fabrikt/cli/CodeGenerator.kt

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.cjbooms.fabrikt.configurations.Packages
88
import com.cjbooms.fabrikt.generators.MutableSettings
99
import com.cjbooms.fabrikt.generators.client.OkHttpClientGenerator
1010
import com.cjbooms.fabrikt.generators.client.OpenFeignInterfaceGenerator
11+
import com.cjbooms.fabrikt.generators.client.SpringHttpInterfaceGenerator
1112
import com.cjbooms.fabrikt.generators.controller.KtorControllerInterfaceGenerator
1213
import com.cjbooms.fabrikt.generators.controller.MicronautControllerInterfaceGenerator
1314
import com.cjbooms.fabrikt.generators.controller.SpringControllerInterfaceGenerator
@@ -48,6 +49,7 @@ class CodeGenerator(
4849
val clientGenerator = when (MutableSettings.clientTarget()) {
4950
ClientCodeGenTargetType.OK_HTTP -> OkHttpClientGenerator(packages, sourceApi, srcPath)
5051
ClientCodeGenTargetType.OPEN_FEIGN -> OpenFeignInterfaceGenerator(packages, sourceApi)
52+
ClientCodeGenTargetType.SPRING_HTTP_INTERFACE -> SpringHttpInterfaceGenerator(packages, sourceApi)
5153
}
5254
val options = MutableSettings.clientOptions()
5355
val clientFiles = clientGenerator.generate(options).files

src/main/kotlin/com/cjbooms/fabrikt/generators/client/ClientGeneratorUtils.kt

+47-11
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.cjbooms.fabrikt.generators.client
22

3+
import com.cjbooms.fabrikt.cli.ClientCodeGenOptionType
34
import com.cjbooms.fabrikt.configurations.Packages
45
import com.cjbooms.fabrikt.generators.GeneratorUtils
56
import com.cjbooms.fabrikt.generators.GeneratorUtils.getPrimaryContentMediaType
@@ -9,7 +10,9 @@ import com.cjbooms.fabrikt.generators.GeneratorUtils.hasMultipleSuccessResponseS
910
import com.cjbooms.fabrikt.generators.GeneratorUtils.toClassName
1011
import com.cjbooms.fabrikt.generators.GeneratorUtils.toIncomingParameters
1112
import com.cjbooms.fabrikt.generators.OasDefault
13+
import com.cjbooms.fabrikt.generators.controller.metadata.SpringImports.RESPONSE_ENTITY
1214
import com.cjbooms.fabrikt.generators.model.ModelGenerator.Companion.toModelType
15+
import com.cjbooms.fabrikt.model.BodyParameter
1316
import com.cjbooms.fabrikt.model.ClientType
1417
import com.cjbooms.fabrikt.model.HeaderParam
1518
import com.cjbooms.fabrikt.model.IncomingParameter
@@ -20,13 +23,15 @@ import com.reprezen.kaizen.oasparser.model3.Operation
2023
import com.reprezen.kaizen.oasparser.model3.Path
2124
import com.squareup.kotlinpoet.AnnotationSpec
2225
import com.squareup.kotlinpoet.FunSpec
26+
import com.squareup.kotlinpoet.KModifier
2327
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
2428
import com.squareup.kotlinpoet.TypeName
2529
import com.squareup.kotlinpoet.asTypeName
2630

2731
object ClientGeneratorUtils {
2832
const val ACCEPT_HEADER_NAME = "Accept"
2933
const val ACCEPT_HEADER_VARIABLE_NAME = "acceptHeader"
34+
const val CONTENT_TYPE_HEADER_NAME = "Content-Type"
3035
const val ADDITIONAL_HEADERS_PARAMETER_NAME = "additionalHeaders"
3136
const val ADDITIONAL_QUERY_PARAMETERS_PARAMETER_NAME = "additionalQueryParameters"
3237

@@ -38,15 +43,15 @@ object ClientGeneratorUtils {
3843
*/
3944
fun Operation.getReturnType(packages: Packages): TypeName {
4045
return if (hasMultipleSuccessResponseSchemas()) {
41-
JsonNode::class.asTypeName()
42-
} else {
43-
this.getPrimaryContentMediaType()?.let {
44-
toModelType(
45-
packages.base,
46-
KotlinTypeInfo.from(it.value.schema)
47-
)
48-
} ?: Unit::class.asTypeName()
49-
}
46+
JsonNode::class.asTypeName()
47+
} else {
48+
this.getPrimaryContentMediaType()?.let {
49+
toModelType(
50+
packages.base,
51+
KotlinTypeInfo.from(it.value.schema)
52+
)
53+
} ?: Unit::class.asTypeName()
54+
}
5055
}
5156

5257
fun Operation.toClientReturnType(packages: Packages): TypeName {
@@ -60,7 +65,12 @@ object ClientGeneratorUtils {
6065
fun deriveClientParameters(path: Path, operation: Operation, basePackage: String): List<IncomingParameter> {
6166
fun needsAcceptHeaderParameter(path: Path, operation: Operation): Boolean {
6267
val hasAcceptParameter = GeneratorUtils.mergeParameters(path.parameters, operation.parameters)
63-
.any { parameter -> parameter.`in` == "header" && parameter.name.equals(ACCEPT_HEADER_NAME, ignoreCase = true) }
68+
.any { parameter ->
69+
parameter.`in` == "header" && parameter.name.equals(
70+
ACCEPT_HEADER_NAME,
71+
ignoreCase = true
72+
)
73+
}
6474
return operation.hasMultipleContentMediaTypes() == true && !hasAcceptParameter
6575
}
6676

@@ -88,7 +98,8 @@ object ClientGeneratorUtils {
8898

8999
fun FunSpec.Builder.addIncomingParameters(
90100
parameters: List<IncomingParameter>,
91-
annotateRequestParameterWith: ((parameter: RequestParameter) -> AnnotationSpec?)? = null
101+
annotateRequestParameterWith: ((parameter: RequestParameter) -> AnnotationSpec?)? = null,
102+
annotateBodyParameterWith: ((parameter: BodyParameter) -> AnnotationSpec?)? = null,
92103
): FunSpec.Builder {
93104
val specs = parameters.map {
94105
val builder = it.toParameterSpecBuilder()
@@ -100,8 +111,33 @@ object ClientGeneratorUtils {
100111
builder.addAnnotation(annotationSpec)
101112
}
102113
}
114+
if (it is BodyParameter) {
115+
annotateBodyParameterWith?.invoke(it)?.let { annotationSpec ->
116+
builder.addAnnotation(annotationSpec)
117+
}
118+
}
103119
builder.build()
104120
}
105121
return this.addParameters(specs)
106122
}
123+
124+
/**
125+
* Adds suspend as modified to the func spec so that i can be used with CoroutineFeign
126+
*/
127+
fun FunSpec.Builder.addSuspendModifier(options: Set<ClientCodeGenOptionType>): FunSpec.Builder {
128+
if (options.contains(ClientCodeGenOptionType.SUSPEND_MODIFIER)) {
129+
this.addModifiers(KModifier.SUSPEND)
130+
}
131+
return this
132+
}
133+
134+
/**
135+
* Adds a ResponseEntity around the returned object so that we can get headers and statuscodes
136+
*/
137+
fun TypeName.optionallyParameterizeWithResponseEntity(options: Set<ClientCodeGenOptionType>): TypeName {
138+
if (options.contains(ClientCodeGenOptionType.SPRING_RESPONSE_ENTITY_WRAPPER)) {
139+
return RESPONSE_ENTITY.parameterizedBy(this)
140+
}
141+
return this
142+
}
107143
}

0 commit comments

Comments
 (0)