Skip to content

New generator: kotlin-retrofit (lightweight, Android, coroutines) #3195

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

Closed
wants to merge 15 commits into from
Closed
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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ org.openapitools.codegen.languages.GraphQLSchemaCodegen
org.openapitools.codegen.languages.GraphQLNodeJSExpressServerCodegen
org.openapitools.codegen.languages.GroovyClientCodegen
org.openapitools.codegen.languages.KotlinClientCodegen
org.openapitools.codegen.languages.KotlinRetrofitCodegen
org.openapitools.codegen.languages.KotlinServerCodegen
org.openapitools.codegen.languages.KotlinSpringServerCodegen
org.openapitools.codegen.languages.KotlinVertxServerCodegen
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="{{packageName}}">

<uses-permission android:name="android.permission.INTERNET" />
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package {{packageName}}

import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response

typealias AuthGeneratorFun = (authName: String, Request) -> String?

sealed class AuthInterceptor(
private val authName: String,
private val generator: AuthGeneratorFun
) : Interceptor {

override fun intercept(chain: Interceptor.Chain): Response {
val apiKey = generator(authName, chain.request()) ?: return chain.proceed(chain.request())
return handleApiKey(chain, apiKey)
}

protected abstract fun handleApiKey(chain: Interceptor.Chain, apiKey: String): Response
}

class HeaderParamInterceptor(
authName: String,
private val paramName: String,
generator: AuthGeneratorFun
) : AuthInterceptor(authName, generator) {

override fun handleApiKey(chain: Interceptor.Chain, apiKey: String): Response {
val newRequest = chain.request()
.newBuilder()
.addHeader(paramName, apiKey)
.build()

return chain.proceed(newRequest)
}
}

class QueryParamInterceptor(
authName: String,
private val paramName: String,
generator: AuthGeneratorFun
) : AuthInterceptor(authName, generator) {

override fun handleApiKey(chain: Interceptor.Chain, apiKey: String): Response {
val newUrl = chain.request().url
.newBuilder()
.addQueryParameter(paramName, apiKey)
.build()

val newRequest = chain.request()
.newBuilder()
.url(newUrl)
.build()

return chain.proceed(newRequest)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package {{packageName}}

import com.squareup.moshi.*
import retrofit2.Converter
import retrofit2.Retrofit
import java.lang.reflect.Type

interface EnumWithValue<T> {
val value: T
}

object EnumJsonAdapterFactory : JsonAdapter.Factory {

override fun create(type: Type, annotations: MutableSet<out Annotation>, moshi: Moshi): JsonAdapter<*>? {
if (type !is Class<*> || !type.isEnum) {
return null
}

val constants = type.enumConstants?.mapNotNull { it as? EnumWithValue<*> } ?: return null
val first = constants.firstOrNull()?.value ?: return null
val valueAdapter = moshi.adapter<Any>(first::class.java)

return object : JsonAdapter<EnumWithValue<*>>() {
override fun fromJson(reader: JsonReader): EnumWithValue<*>? {
if (reader.peek() == JsonReader.Token.NULL) return reader.nextNull()
val value = valueAdapter.fromJson(reader)
return constants.firstOrNull { it.value == value }
?: throw JsonDataException("Expected one of ${constants.map { it.value }} but was $value at path ${reader.path}")
}

override fun toJson(writer: JsonWriter, value: EnumWithValue<*>?) {
if (value == null) {
writer.nullValue()
} else {
val innerValue = value.value
valueAdapter.toJson(writer, innerValue)
}
}

}
}

}

object EnumRetrofitConverterFactory : Converter.Factory() {
override fun stringConverter(type: Type, annotations: Array<Annotation>, retrofit: Retrofit): Converter<*, String>? {
if (type is Class<*> && type.isEnum) {
return Converter<Enum<*>, String> { value ->
value.javaClass.getField(value.name).getAnnotation(Json::class.java)?.name ?: value.name
}
}
return null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# {{packageName}} - Android library module for {{appName}}

## Dependencies

* Kotlin
* Gradle
* Coroutines
* Moshi
* OkHttp
* Retrofit

## Usage

Just add the generated folder to your Android Studio project.

#### Generator State: **pre-alpha**

## Todo

* fix api.mustache: double @Header generation possible
* add better support for json schema inheritance (allOf, anyOf, etc.)
** allOf: done
* add OAuth functionality
* update proguard/p8 rules for okhttp/moshi
* option to switch coroutine generation on/off
* add documentation
* add tests
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package {{packageName}}

import com.squareup.moshi.Moshi
import okhttp3.Call
import okhttp3.Credentials
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.converter.scalars.ScalarsConverterFactory

object RetrofitHolder {

{{#version}}const val API_VERSION = "{{{version}}}"{{/version}}
const val BASE_URL = "{{basePath}}/"

val clientBuilder: OkHttpClient.Builder by lazy {
OkHttpClient().newBuilder(){{#authMethods}}{{#-first}}
.addNetworkInterceptor { chain ->
val newRequest = chain.request().newBuilder()
.removeHeader(AUTH_NAME_HEADER)
.build()
chain.proceed(newRequest)
}{{/-first}}{{/authMethods}}
{{#authMethods}}
{{>auth_method}}{{/authMethods}}
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.HEADERS
})
}
}
}

val retrofitBuilder: Retrofit.Builder by lazy {
val moshi = Moshi.Builder()
.add(EnumJsonAdapterFactory)
.build()

Retrofit.Builder()
.callFactory(object : Call.Factory {
//create client lazy on demand in background thread
//see https://www.zacsweers.dev/dagger-party-tricks-deferred-okhttp-init/
private val client by lazy { clientBuilder.build() }
override fun newCall(request: Request): Call = client.newCall(request)
})
.baseUrl(BASE_URL)
.addConverterFactory(ScalarsConverterFactory.create())
.addConverterFactory(EnumRetrofitConverterFactory)
.addConverterFactory(MoshiConverterFactory.create(moshi))
}

val retrofit: Retrofit by lazy { retrofitBuilder.build() }

{{#authMethods}}{{#-first}}
private val securityDefinitions = HashMap<String, String>()

fun setApiKey(authMethod: AuthMethod, apiKey: String) {
securityDefinitions[authMethod.authName] = apiKey
}

fun removeAuthInfo(authMethod: AuthMethod) {
securityDefinitions -= authMethod.authName
}

fun setBasicAuth(authMethod: AuthMethod, username: String, password: String) {
securityDefinitions[authMethod.authName] = Credentials.basic(username, password)
}

fun setBearerAuth(authMethod: AuthMethod, bearer: String) {
securityDefinitions[authMethod.authName] = "Bearer $bearer"
}

private fun apiKeyGenerator(authName: String, request: Request): String? =
if (requestHasAuth(request, authName))
securityDefinitions[authName]
else
null


private fun requestHasAuth(request: Request, authName: String): Boolean {
val headers = request.headers(AUTH_NAME_HEADER)
return headers.contains(authName)
}

const val AUTH_NAME_HEADER = "X-Auth-Name"
{{/-first}}{{/authMethods}}
}

{{#authMethods}}{{#-first}}enum class AuthMethod(internal val authName: String) { {{/-first}}
{{{name}}}("{{{name}}}"){{^-last}},{{/-last}}{{#-last}}
}{{/-last}}
{{/authMethods}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{{>licenseInfo}}
package {{apiPackage}}

{{#imports}}import {{import}}
{{/imports}}

import {{packageName}}.RetrofitHolder
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.create
import retrofit2.http.*
import okhttp3.*
import retrofit2.http.Headers
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessary with the glob import above?


{{#operations}}
interface {{classname}} {

{{#operation}}
/**
* {{summary}}
* {{notes}}
* Responses:{{#responses}}
* - {{code}}: {{{message}}}{{/responses}}
*
{{#allParams}}* @param {{paramName}} {{description}} {{^required}}(optional{{#defaultValue}}, default to {{{.}}}{{/defaultValue}}){{/required}}
{{/allParams}}* @return {{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}void{{/returnType}}
*/
@{{httpMethod}}("{{{path}}}")
{{#isDeprecated}}
@Deprecated("")
{{/isDeprecated}}
{{#formParams}}
{{#-first}}
{{#isMultipart}}@Multipart{{/isMultipart}}{{^isMultipart}}@FormUrlEncoded{{/isMultipart}}
{{/-first}}
{{/formParams}}
{{^formParams}}
{{#prioritizedContentTypes}}
{{#-first}}
@Headers(
"Content-Type:{{{mediaType}}}"
)
{{/-first}}
{{/prioritizedContentTypes}}
{{/formParams}}
{{#authMethods}}{{#-first}}@Headers({{/-first}}RetrofitHolder.AUTH_NAME_HEADER + ": {{{name}}}"{{^-last}}, {{/-last}}{{#-last}}){{/-last}}{{/authMethods}}
suspend fun {{operationId}}(
{{#allParams}}{{>queryParams}}{{>pathParams}}{{>headerParams}}{{>bodyParams}}{{>formParams}}{{#hasMore}},
{{/hasMore}}{{/allParams}}
): Response<{{#isResponseFile}}ResponseBody{{/isResponseFile}}{{^isResponseFile}}{{#returnType}}{{{.}}}{{/returnType}}{{^returnType}}Unit{{/returnType}}{{/isResponseFile}}>

{{/operation}}

companion object {
fun create(retrofit: Retrofit = RetrofitHolder.retrofit): {{classname}} = retrofit.create()
}
}
{{/operations}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{#isKeyInHeader}}.addInterceptor(HeaderParamInterceptor("{{{name}}}", "{{{keyParamName}}}", this::apiKeyGenerator)){{/isKeyInHeader}}{{#isKeyInQuery}}.addInterceptor(QueryParamInterceptor("{{{name}}}", "{{{keyParamName}}}", this::apiKeyGenerator)){{/isKeyInQuery}}{{#isBasicBasic}}.addInterceptor(HeaderParamInterceptor("{{{name}}}", "Authorization", this::apiKeyGenerator)){{/isBasicBasic}}{{#isBasicBearer}}.addInterceptor(HeaderParamInterceptor("{{{name}}}", "Authorization", this::apiKeyGenerator)){{/isBasicBearer}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{#isBodyParam}}@Body {{paramName}}: {{{dataType}}}{{#required}}{{#defaultValue}} = {{{.}}}{{/defaultValue}}{{/required}}{{^required}}?{{#defaultValue}} = {{{.}}}{{/defaultValue}}{{^defaultValue}} = null{{/defaultValue}}{{/required}}{{/isBodyParam}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'

android {
compileSdkVersion 29

defaultConfig {
minSdkVersion 21
{{#version}}versionName "{{{version}}}"{{/version}}
}
compileOptions {
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}

ext {
retrofitVersion = "2.6.2"
moshiVersion = "1.8.0"
coroutinesVersion = "1.3.2"
okHttpVersion = "4.2.2"
}

dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"

implementation "com.squareup.okhttp3:logging-interceptor:$okHttpVersion"

api "com.squareup.retrofit2:retrofit:$retrofitVersion"
{{#threetenbp}}
api 'com.jakewharton.threetenabp:threetenabp:1.2.1'
{{/threetenbp}}

implementation "com.squareup.retrofit2:converter-moshi:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-scalars:$retrofitVersion"
api "com.squareup.moshi:moshi:$moshiVersion"
kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshiVersion"

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion"

}
Loading