Repository: tipsy/javalin
Pull request #578 (2019-05-11 15:40:05)

[openapi] add annotation api (#254)
Add an api to define the OpenApi documentation with annotations.

tipsy: Looks good @TobiasWalle. Have you also tested this from Java?
TobiasWalle: > Looks good @TobiasWalle. Have you also tested this from Java?

No, but this is a good point. I'm going to create a minimal test for java to see if the api works as expected.
TobiasWalle: > > Looks good @TobiasWalle. Have you also tested this from Java?
> 
> No, but this is a good point. I'm going to create a minimal test for java to see if the api works as expected.

Very good that I added this test, because there was a bug in java. Now everything works perfectly fine. The other comments should be also resolved.
tipsy: Great! Do you consider this one ready now?
TobiasWalle: Yes :)
tipsy: Perfect, then I'll merge this and wait for the final one.

Patch:
diff --git a/src/main/java/io/javalin/plugin/openapi/JavalinOpenApi.kt b/src/main/java/io/javalin/plugin/openapi/JavalinOpenApi.kt
index 219f95dd..f7bd903f 100644
--- a/src/main/java/io/javalin/plugin/openapi/JavalinOpenApi.kt
+++ b/src/main/java/io/javalin/plugin/openapi/JavalinOpenApi.kt
@@ -22,12 +22,14 @@ class CreateSchemaOptions(
 )
 
 object JavalinOpenApi {
+    @JvmStatic
     fun createSchema(javalin: Javalin): OpenAPI {
         val handler = javalin.config.inner.openApiHandler
         return handler?.createOpenAPISchema()
                 ?: throw IllegalStateException("You need to activate the \"enableOpenApi\" option before you can create the OpenAPI schema");
     }
 
+    @JvmStatic
     fun createSchema(options: CreateSchemaOptions): OpenAPI {
         val baseConfiguration = options.createBaseConfiguration.create()
         return runWithObjectMapper(options.objectMapper) {
diff --git a/src/main/java/io/javalin/plugin/openapi/OpenApiOptions.kt b/src/main/java/io/javalin/plugin/openapi/OpenApiOptions.kt
index bdc21243..98200307 100644
--- a/src/main/java/io/javalin/plugin/openapi/OpenApiOptions.kt
+++ b/src/main/java/io/javalin/plugin/openapi/OpenApiOptions.kt
@@ -4,7 +4,7 @@ import io.javalin.core.security.Role
 import io.swagger.v3.oas.models.OpenAPI
 import io.swagger.v3.oas.models.info.Info
 
-class OpenApiOptions @JvmOverloads constructor(
+class OpenApiOptions constructor(
         /** Creates a GET route to get the schema as a json if not null */
         val path: String? = null,
         val roles: Set<Role> = setOf(),
@@ -19,20 +19,15 @@ class OpenApiOptions @JvmOverloads constructor(
             createBaseConfiguration: () -> OpenAPI
     ) : this(path, setOf(), CreateBaseConfiguration(createBaseConfiguration))
 
-
     constructor(
             path: String? = null,
             roles: Set<Role> = setOf(),
             createBaseConfiguration: () -> OpenAPI
     ) : this(path, roles, CreateBaseConfiguration(createBaseConfiguration))
 
-    @JvmOverloads
-    constructor(
-            path: String? = null,
-            info: Info
-    ) : this(path, setOf(), {
-        OpenAPI().info(info)
-    })
+    constructor(info: Info) : this(null, setOf(), info)
+
+    constructor(path: String? = null, info: Info) : this(path, setOf(), info)
 
     constructor(
             path: String? = null,
diff --git a/src/main/java/io/javalin/plugin/openapi/annotations/AnnotationApi.kt b/src/main/java/io/javalin/plugin/openapi/annotations/AnnotationApi.kt
new file mode 100644
index 00000000..5284871e
--- /dev/null
+++ b/src/main/java/io/javalin/plugin/openapi/annotations/AnnotationApi.kt
@@ -0,0 +1,71 @@
+package io.javalin.plugin.openapi.annotations
+
+import kotlin.reflect.KClass
+
+/**
+ * Provide metadata for the generation of the open api documentation to the annotated Handler.
+ */
+@Target(AnnotationTarget.FUNCTION)
+annotation class OpenApi(
+        val summary: String = NULL_STRING,
+        val description: String = NULL_STRING,
+        val operationId: String = NULL_STRING,
+        val deprecated: Boolean = false,
+        val tags: Array<String> = [],
+        val cookies: Array<OpenApiParam> = [],
+        val headers: Array<OpenApiParam> = [],
+        val pathParams: Array<OpenApiParam> = [],
+        val queryParams: Array<OpenApiParam> = [],
+        val requestBodies: Array<OpenApiRequestBody> = [],
+        val fileUploads: Array<OpenApiFileUpload> = [],
+        val responses: Array<OpenApiResponse> = []
+)
+
+@Target()
+annotation class OpenApiResponse(
+        val status: String,
+        val returnType: KClass<*> = NULL_CLASS::class,
+        val contentType: String = ContentType.AUTODETECT,
+        /** Whenever the returnType should be wrapped in an array */
+        val isArray: Boolean = false,
+        val description: String = NULL_STRING
+)
+
+@Target()
+annotation class OpenApiParam(
+        val name: String,
+        val type: KClass<*> = String::class,
+        val description: String = NULL_STRING,
+        val deprecated: Boolean = false,
+        val required: Boolean = false,
+        val allowEmptyValue: Boolean = false
+)
+
+@Target()
+annotation class OpenApiRequestBody(
+        val type: KClass<*>,
+        val contentType: String = ContentType.AUTODETECT,
+        val required: Boolean = false,
+        val description: String = NULL_STRING
+)
+
+@Target()
+annotation class OpenApiFileUpload(
+        val name: String,
+        val isArray: Boolean = false,
+        val description: String = NULL_STRING,
+        val required: Boolean = false
+)
+
+
+/** Null string because annotations do not support null values */
+const val NULL_STRING = "-- This string represents a null value and shouldn't be used --"
+
+/** Null class because annotations do not support null values */
+class NULL_CLASS
+
+object ContentType {
+    const val JSON = "application/json"
+    const val HTML = "text/html"
+    const val AUTODETECT = "AUTODETECT - Will be replaced later"
+}
diff --git a/src/main/java/io/javalin/plugin/openapi/annotations/AnnotationApiMapping.kt b/src/main/java/io/javalin/plugin/openapi/annotations/AnnotationApiMapping.kt
new file mode 100644
index 00000000..f49c466a
--- /dev/null
+++ b/src/main/java/io/javalin/plugin/openapi/annotations/AnnotationApiMapping.kt
@@ -0,0 +1,137 @@
+package io.javalin.plugin.openapi.annotations
+
+import io.javalin.plugin.openapi.dsl.DocumentedContent
+import io.javalin.plugin.openapi.dsl.DocumentedParameter
+import io.javalin.plugin.openapi.dsl.DocumentedResponse
+import io.javalin.plugin.openapi.dsl.OpenApiDocumentation
+import io.swagger.v3.oas.models.Operation
+import io.swagger.v3.oas.models.parameters.RequestBody
+import kotlin.reflect.KClass
+
+fun OpenApi.asOpenApiDocumentation(): OpenApiDocumentation {
+    val documentation = OpenApiDocumentation()
+    val annotation = this
+
+    documentation.operation { it.applyAnnotation(annotation) }
+
+    annotation.cookies.forEach { documentation.applyParamAnnotation("cookie", it) }
+    annotation.headers.forEach { documentation.applyParamAnnotation("header", it) }
+    annotation.pathParams.forEach { documentation.applyParamAnnotation("path", it) }
+    annotation.queryParams.forEach { documentation.applyParamAnnotation("query", it) }
+
+    annotation.requestBodies.forEach { requestBody ->
+        documentation.body(requestBody.type.java, requestBody.contentType.correctNullValue()) { it.applyAnnotation(requestBody) }
+    }
+
+    annotation.fileUploads.forEach { fileUpload ->
+        if (fileUpload.isArray) {
+            documentation.uploadedFiles(name = fileUpload.name) { it.applyAnnotation(fileUpload) }
+        } else {
+            documentation.uploadedFile(name = fileUpload.name) { it.applyAnnotation(fileUpload) }
+        }
+    }
+
+    annotation.responses.forEach { documentation.applyResponseAnnotation(it) }
+
+    return documentation
+}
+
+private fun resolveNullValueFromContentType(returnType: KClass<*>, contentType: String): KClass<out Any> {
+    return if (returnType == NULL_CLASS::class) {
+        if (contentType.startsWith("text/")) {
+            // Default for text/html, etc is string
+            String::class
+        } else {
+            // Default for everything else is unit
+            Unit::class
+        }
+    } else {
+        returnType
+    }
+}
+
+private fun Operation.applyAnnotation(annotation: OpenApi) {
+    if (annotation.description.isNotNullString()) {
+        this.description = annotation.description
+    }
+    if (annotation.summary.isNotNullString()) {
+        this.summary = annotation.summary
+    }
+    if (annotation.operationId.isNotNullString()) {
+        this.operationId = annotation.operationId
+    }
+    if (annotation.deprecated) {
+        this.deprecated = annotation.deprecated
+    }
+    annotation.tags.forEach { tag -> this.addTagsItem(tag) }
+}
+
+private fun RequestBody.applyAnnotation(annotation: OpenApiRequestBody) {
+    if (annotation.required) {
+        this.required = annotation.required
+    }
+    if (annotation.description.isNotNullString()) {
+        this.description = annotation.description
+    }
+}
+
+private fun RequestBody.applyAnnotation(annotation: OpenApiFileUpload) {
+    if (annotation.required) {
+        this.required = annotation.required
+    }
+    if (annotation.description.isNotNullString()) {
+        this.description = annotation.description
+    }
+}
+
+private fun OpenApiDocumentation.applyResponseAnnotation(it: OpenApiResponse) {
+    val documentation = this
+    val returnType = resolveNullValueFromContentType(it.returnType, it.contentType)
+    val returnTypeIsUnit = returnType == Unit::class
+    documentation.result(
+            documentedResponse = DocumentedResponse(
+                    status = it.status,
+                    content = if (returnTypeIsUnit) null else DocumentedContent(
+                            returnType = returnType.java,
+                            isArray = it.isArray,
+                            contentType = it.contentType
+                    )
+            ),
+            applyUpdates = { responseDocumentation ->
+                if (it.description.isNotNullString()) {
+                    responseDocumentation.description = it.description
+                }
+            }
+    )
+}
+
+private fun OpenApiDocumentation.applyParamAnnotation(`in`: String, param: OpenApiParam) {
+    val documentation = this
+    documentation.param(
+            documentedParameter = createDocumentedParam(`in`, param),
+            applyUpdates = { paramDocumentation ->
+                if (param.description.isNotNullString()) {
+                    paramDocumentation.description = param.description
+                }
+                if (param.required) {
+                    paramDocumentation.required = param.required
+                }
+                if (param.deprecated) {
+                    paramDocumentation.deprecated = param.deprecated
+                }
+                if (param.allowEmptyValue) {
+                    paramDocumentation.allowEmptyValue = param.allowEmptyValue
+                }
+            }
+    )
+}
+
+private fun createDocumentedParam(`in`: String, param: OpenApiParam) = DocumentedParameter(
+        `in` = `in`,
+        name = param.name,
+        type = param.type.java
+)
+
+private fun String.isNullString() = this == NULL_STRING
+private fun String.isNotNullString() = !this.isNullString()
+private fun String.correctNullValue(): String? = if (this.isNullString()) null else this
diff --git a/src/main/java/io/javalin/plugin/openapi/dsl/DocumentedContent.kt b/src/main/java/io/javalin/plugin/openapi/dsl/DocumentedContent.kt
index 8695dc2c..1258fde9 100644
--- a/src/main/java/io/javalin/plugin/openapi/dsl/DocumentedContent.kt
+++ b/src/main/java/io/javalin/plugin/openapi/dsl/DocumentedContent.kt
@@ -1,6 +1,11 @@
 package io.javalin.plugin.openapi.dsl
 
-import io.javalin.plugin.openapi.external.*
+import io.javalin.plugin.openapi.annotations.ContentType
+import io.javalin.plugin.openapi.external.mediaType
+import io.javalin.plugin.openapi.external.mediaTypeArrayOf
+import io.javalin.plugin.openapi.external.mediaTypeArrayOfRef
+import io.javalin.plugin.openapi.external.mediaTypeRef
+import io.javalin.plugin.openapi.external.schema
 import io.swagger.v3.oas.models.Components
 import io.swagger.v3.oas.models.media.Content
 import io.swagger.v3.oas.models.media.MediaType
@@ -16,7 +21,11 @@ class DocumentedContent(
         contentType: String? = null,
         val schema: Schema<*>? = null
 ) {
-    val contentType: String = contentType ?: returnType.guessContentType()
+    val contentType: String = if (contentType == null || contentType == ContentType.AUTODETECT) {
+        returnType.guessContentType()
+    } else {
+        contentType
+    }
 
     private val returnTypeIsArray = returnType.isArray
 
diff --git a/src/main/java/io/javalin/plugin/openapi/dsl/OpenApiBuilder.kt b/src/main/java/io/javalin/plugin/openapi/dsl/OpenApiBuilder.kt
index 3ade7b1e..308174b5 100644
--- a/src/main/java/io/javalin/plugin/openapi/dsl/OpenApiBuilder.kt
+++ b/src/main/java/io/javalin/plugin/openapi/dsl/OpenApiBuilder.kt
@@ -6,6 +6,9 @@ import io.javalin.apibuilder.CrudHandler
 import io.javalin.apibuilder.CrudHandlerLambdaKey
 import io.javalin.http.Context
 import io.javalin.http.Handler
+import io.javalin.plugin.openapi.annotations.OpenApi
+import io.javalin.plugin.openapi.annotations.asOpenApiDocumentation
+import java.lang.reflect.Method
 
 /** Creates an instance of the OpenApiDocumentation */
 fun document() = OpenApiDocumentation()
@@ -30,27 +33,55 @@ fun documented(documentation: OpenApiCrudHandlerDocumentation, handler: CrudHand
 
 /** Converts a map of handlers and documentations into a map of documented handlers */
 fun <T> documented(documentation: Map<T, OpenApiDocumentation>, handlers: Map<T, Handler>): Map<T, Handler> {
-    if (documentation.keys != handlers.keys) {
-        throw IllegalArgumentException("The keys of the documentation and the handlers don't match. ${documentation.keys} != ${handlers.keys}")
-    }
+    documentation.assertSameKeys(handlers)
     return handlers.mapValues { (key, value) -> documented(documentation.getValue(key), value) }
 }
 
 internal fun documented(crudHandler: CrudHandler, handlers: Map<CrudHandlerLambdaKey, Handler>): Map<CrudHandlerLambdaKey, Handler> {
     return if (crudHandler is DocumentedCrudHandler) {
-        documented(crudHandler.crudHandlerDocumentation, handlers)
+        documented(crudHandler.crudHandlerDocumentation.asMap(), handlers)
     } else {
-        handlers
+        moveDocumentationFromAnnotationToHandler(crudHandler::class.java, handlers)
+    }
+}
+
+internal fun moveDocumentationFromAnnotationToHandler(
+        crudHandlerClass: Class<out CrudHandler>,
+        handlers: Map<CrudHandlerLambdaKey, Handler>
+): Map<CrudHandlerLambdaKey, Handler> {
+    return handlers.mapValues { (key, handler) ->
+        moveDocumentationFromAnnotationToHandler(crudHandlerClass, key.value, handler)
     }
 }
 
-internal fun documented(crudHandlerDocumentation: OpenApiCrudHandlerDocumentation, handlers: Map<CrudHandlerLambdaKey, Handler>): Map<CrudHandlerLambdaKey, Handler> {
-    val documentations = mapOf(
-            CrudHandlerLambdaKey.GET_ALL to crudHandlerDocumentation.getAllDocumentation,
-            CrudHandlerLambdaKey.GET_ONE to crudHandlerDocumentation.getOneDocumentation,
-            CrudHandlerLambdaKey.CREATE to crudHandlerDocumentation.createDocumentation,
-            CrudHandlerLambdaKey.UPDATE to crudHandlerDocumentation.updateDocumentation,
-            CrudHandlerLambdaKey.DELETE to crudHandlerDocumentation.deleteDocumentation
-    )
-    return documented(documentations, handlers)
+/**
+ * Moves the documentation from the annotation to a DocumentedHandler.
+ * If the method is not documented, just returns the handler.
+ */
+fun moveDocumentationFromAnnotationToHandler(javaClass: Class<*>, methodName: String, handler: Handler): Handler {
+    val method = javaClass.declaredMethods.find { it.name === methodName } ?: throw NoSuchMethodException(methodName)
+    return moveDocumentationFromAnnotationToHandler(method, handler)
+}
+
+/**
+ * Moves the documentation from the annotation to a DocumentedHandler.
+ * If the method is not documented, just returns the handler.
+ */
+fun moveDocumentationFromAnnotationToHandler(methodWithAnnotation: Method, handler: Handler): Handler {
+    val openApi: OpenApi? = methodWithAnnotation.getDeclaredAnnotation(OpenApi::class.java)
+    return openApi?.asOpenApiDocumentation()?.let { documented(it, handler) } ?: handler
 }
+
+private fun <K> Map<K, *>.assertSameKeys(other: Map<K, *>) {
+    if (this.keys != other.keys) {
+        throw IllegalArgumentException("The keys of the documentation and the handlers don't match. ${this.keys} != ${other.keys}")
+    }
+}
+
+private fun OpenApiCrudHandlerDocumentation.asMap(): Map<CrudHandlerLambdaKey, OpenApiDocumentation> = mapOf(
+        CrudHandlerLambdaKey.GET_ALL to getAllDocumentation,
+        CrudHandlerLambdaKey.GET_ONE to getOneDocumentation,
+        CrudHandlerLambdaKey.CREATE to createDocumentation,
+        CrudHandlerLambdaKey.UPDATE to updateDocumentation,
+        CrudHandlerLambdaKey.DELETE to deleteDocumentation
+)
diff --git a/src/main/java/io/javalin/plugin/openapi/dsl/extractDocumentation.kt b/src/main/java/io/javalin/plugin/openapi/dsl/extractDocumentation.kt
index 90c26bca..465a7b19 100644
--- a/src/main/java/io/javalin/plugin/openapi/dsl/extractDocumentation.kt
+++ b/src/main/java/io/javalin/plugin/openapi/dsl/extractDocumentation.kt
@@ -1,11 +1,55 @@
 package io.javalin.plugin.openapi.dsl
 
 import io.javalin.core.event.HandlerMetaInfo
+import io.javalin.plugin.openapi.annotations.OpenApi
+import io.javalin.plugin.openapi.annotations.asOpenApiDocumentation
+import java.lang.NullPointerException
+import java.util.logging.Logger
+import kotlin.reflect.KFunction
+import kotlin.reflect.jvm.javaMethod
 
 fun HandlerMetaInfo.extractDocumentation(): OpenApiDocumentation? {
     return if (handler is DocumentedHandler) {
         handler.documentation
     } else {
+        val openApiAnnotation: OpenApi? = getOpenApiAnnotationFromKotlin() ?: getOpenApiAnnotationFromJava()
+        openApiAnnotation?.asOpenApiDocumentation()
+    }
+}
+
+private fun HandlerMetaInfo.getOpenApiAnnotationFromKotlin(): OpenApi? {
+    return try {
+        // It is required to work access private fields, because kotlin wraps
+        // the lambda into a "SAM" class and we need to access the original lambda
+        // with the annotations
+        val functionValue = getFieldValue(handler, "function") as KFunction<*>
+        val javaMethod = functionValue.javaMethod!!
+        javaMethod.getAnnotation(OpenApi::class.java)
+    } catch (e: NoSuchFieldException) {
         null
+    } catch (e: Error) {
+        return if (e::class.qualifiedName == "kotlin.reflect.jvm.internal.KotlinReflectionInternalError") {
+            Logger.getGlobal()
+                    .warning("Local functions, lambdas, anonymous functions and local variables with @OpenApi annotations are currently not supported. " +
+                            "The annotation of the handler for \"$httpMethod $path\" will be ignored. To fix this, move the handler into a global function.")
+            null
+        } else {
+            throw e
+        }
     }
 }
+
+private fun HandlerMetaInfo.getOpenApiAnnotationFromJava(): OpenApi? {
+    return try {
+        val method = handler::class.java.declaredMethods.find { it.name == "handle" }!!
+        method.getAnnotation(OpenApi::class.java)
+    } catch (e: NullPointerException) {
+        null
+    }
+}
+
+private fun getFieldValue(obj: Any, fieldName: String): Any {
+    val field = obj::class.java.getDeclaredField(fieldName)
+    field.isAccessible = true
+    return field.get(obj)
+}


Test patch:
diff --git a/src/test/java/io/javalin/openapi/TestOpenApiAnnotations.kt b/src/test/java/io/javalin/openapi/TestOpenApiAnnotations.kt
new file mode 100644
index 00000000..38c38199
--- /dev/null
+++ b/src/test/java/io/javalin/openapi/TestOpenApiAnnotations.kt
@@ -0,0 +1,201 @@
+/*
+ * Javalin - https://javalin.io
+ * Copyright 2017 David Åse
+ * Licensed under Apache 2.0: https://github.com/tipsy/javalin/blob/master/LICENSE
+ */
+package io.javalin.openapi
+
+import com.fasterxml.jackson.annotation.JsonInclude
+import com.fasterxml.jackson.databind.SerializationFeature
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import io.javalin.Javalin
+import io.javalin.apibuilder.ApiBuilder.crud
+import io.javalin.apibuilder.CrudHandler
+import io.javalin.http.Context
+import io.javalin.plugin.json.JavalinJackson
+import io.javalin.plugin.openapi.JavalinOpenApi
+import io.javalin.plugin.openapi.OpenApiOptions
+import io.javalin.plugin.openapi.annotations.*
+import io.swagger.v3.oas.models.info.Info
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Before
+import org.junit.Test
+
+// region complexExampleWithAnnotationsHandler
+@OpenApi(
+        description = "Get a specific user",
+        summary = "Get current user",
+        operationId = "getCurrentUser",
+        deprecated = true,
+        tags = ["user"],
+        responses = [
+            OpenApiResponse(status = "200", returnType = User::class, description = "Request successful")
+        ]
+)
+fun getUserHandler(ctx: Context) {
+}
+
+@OpenApi(
+        tags = ["user"],
+        cookies = [
+            OpenApiParam(name = "my-cookie", type = String::class, description = "My cookie")
+        ],
+        headers = [
+            OpenApiParam(name = "x-my-header", type = String::class, description = "My header")
+        ],
+        pathParams = [
+            OpenApiParam(name = "my-path-param", type = String::class, description = "My path param")
+        ],
+        queryParams = [
+            OpenApiParam(
+                    name = "name",
+                    type = String::class,
+                    description = "The name of the users you want to filter",
+                    required = true,
+                    deprecated = true,
+                    allowEmptyValue = true
+            ),
+            OpenApiParam(name = "age", type = Int::class)
+        ],
+        responses = [
+            OpenApiResponse(status = "200", returnType = User::class, isArray = true)
+        ]
+)
+fun getUsersHandler(ctx: Context) {
+}
+
+@OpenApi(
+        tags = ["user"],
+        responses = [
+            OpenApiResponse(status = "200", returnType = Array<User>::class)
+        ]
+)
+fun getUsers2Handler(ctx: Context) {
+}
+
+@OpenApi(
+        tags = ["user"],
+        requestBodies = [
+            OpenApiRequestBody(type = String::class, required = true, description = "body description"),
+            OpenApiRequestBody(type = User::class),
+            OpenApiRequestBody(type = ByteArray::class),
+            OpenApiRequestBody(type = ByteArray::class, contentType = "image/png")
+        ]
+)
+fun putUserHandler(ctx: Context) {
+}
+
+@OpenApi(
+        responses = [
+            OpenApiResponse(status = "200", returnType = String::class)
+        ]
+)
+fun getStringHandler(ctx: Context) {
+}
+
+@OpenApi(
+        responses = [
+            OpenApiResponse(status = "200", contentType = ContentType.HTML, description = "My Homepage")
+        ]
+)
+fun getHomepageHandler(ctx: Context) {
+}
+
+@OpenApi(
+        fileUploads = [
+            OpenApiFileUpload(name = "file", description = "MyFile", required = true)
+        ]
+)
+fun getUploadHandler(ctx: Context) {
+}
+
+@OpenApi(
+        fileUploads = [
+            OpenApiFileUpload(name = "files", description = "MyFiles", isArray = true, required = true)
+        ]
+)
+fun getUploadsHandler(ctx: Context) {
+}
+
+@OpenApi(
+        responses = [
+            OpenApiResponse(status = "200")
+        ]
+)
+fun getResources(ctx: Context) {
+}
+// endregion complexExampleWithAnnotationsHandler
+
+class TestOpenApiAnnotations {
+    @Before
+    fun resetObjectMapper() {
+        JavalinJackson.configure(
+                jacksonObjectMapper()
+                        .enable(SerializationFeature.INDENT_OUTPUT)
+                        .setSerializationInclusion(JsonInclude.Include.NON_NULL)
+        )
+    }
+
+    @Test
+    fun `createOpenApiSchema() work with complexExample and annotations`() {
+        val app = Javalin.create {
+            it.enableOpenApi(OpenApiOptions(::createComplexExampleBaseConfiguration))
+        }
+
+        app.get("/user", ::getUserHandler)
+        app.get("/users", ::getUsersHandler)
+        app.get("/users2", ::getUsers2Handler)
+        app.put("/user", ::putUserHandler)
+        app.get("/string", ::getStringHandler)
+        app.get("/homepage", ::getHomepageHandler)
+        app.get("/upload", ::getUploadHandler)
+        app.get("/uploads", ::getUploadsHandler)
+        app.get("/resources/*", ::getResources)
+
+        val actual = JavalinOpenApi.createSchema(app)
+
+        assertThat(actual.asJsonString()).isEqualTo(complexExampleJson)
+    }
+
+    @Test
+    fun `createOpenApiSchema() work with crudHandler and annotations`() {
+        val app = Javalin.create {
+            it.enableOpenApi(OpenApiOptions(Info().title("Example").version("1.0.0")))
+        }
+
+        class UserCrudHandlerWithAnnotations : CrudHandler {
+            @OpenApi(
+                    responses = [
+                        OpenApiResponse("200", returnType = User::class, isArray = true)
+                    ]
+            )
+            override fun getAll(ctx: Context) {
+            }
+
+            @OpenApi(
+                    responses = [
+                        OpenApiResponse("200", returnType = User::class)
+                    ]
+            )
+            override fun getOne(ctx: Context, resourceId: String) {
+            }
+
+            override fun create(ctx: Context) {
+            }
+
+            override fun update(ctx: Context, resourceId: String) {
+            }
+
+            override fun delete(ctx: Context, resourceId: String) {
+            }
+        }
+
+        app.routes {
+            crud("users/:user-id", UserCrudHandlerWithAnnotations())
+        }
+
+        val actual = JavalinOpenApi.createSchema(app)
+
+        assertThat(actual.asJsonString()).isEqualTo(crudExampleJson)
+    }
+}
diff --git a/src/test/java/io/javalin/openapi/TestOpenApiAnnotations_Java.java b/src/test/java/io/javalin/openapi/TestOpenApiAnnotations_Java.java
new file mode 100644
index 00000000..d2e6b8a6
--- /dev/null
+++ b/src/test/java/io/javalin/openapi/TestOpenApiAnnotations_Java.java
@@ -0,0 +1,125 @@
+package io.javalin.openapi;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import io.javalin.Javalin;
+import io.javalin.apibuilder.ApiBuilder;
+import io.javalin.apibuilder.CrudHandler;
+import io.javalin.http.Context;
+import io.javalin.http.Handler;
+import io.javalin.plugin.json.JavalinJackson;
+import io.javalin.plugin.openapi.JavalinOpenApi;
+import io.javalin.plugin.openapi.OpenApiOptions;
+import io.javalin.plugin.openapi.annotations.OpenApi;
+import io.javalin.plugin.openapi.annotations.OpenApiResponse;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Info;
+import org.jetbrains.annotations.NotNull;
+import org.junit.Before;
+import org.junit.Test;
+
+import static com.fasterxml.jackson.module.kotlin.ExtensionsKt.jacksonObjectMapper;
+import static org.assertj.core.api.Assertions.assertThat;
+
+class JavaCrudHandler implements CrudHandler {
+    @OpenApi(
+        responses = {
+            @OpenApiResponse(status = "200", returnType = User.class, isArray = true)
+        }
+    )
+    @Override public void getAll(@NotNull Context ctx) {
+    }
+
+    @OpenApi(
+        responses = {
+            @OpenApiResponse(status = "200", returnType = User.class)
+        }
+    )
+    @Override public void getOne(@NotNull Context ctx, @NotNull String resourceId) {
+
+    }
+
+    @Override public void create(@NotNull Context ctx) {
+
+    }
+
+    @Override public void update(@NotNull Context ctx, @NotNull String resourceId) {
+
+    }
+
+    @Override public void delete(@NotNull Context ctx, @NotNull String resourceId) {
+
+    }
+}
+
+class GetAllHandler implements Handler {
+    @OpenApi(
+        responses = {
+            @OpenApiResponse(status = "200", returnType = User.class, isArray = true)
+        }
+    )
+    @Override public void handle(@NotNull Context ctx) throws Exception {}
+}
+
+class GetOneHandler implements Handler {
+    @OpenApi(
+        responses = {
+            @OpenApiResponse(status = "200", returnType = User.class)
+        }
+    )
+    @Override public void handle(@NotNull Context ctx) throws Exception {}
+}
+
+class CreateHandler implements Handler {
+    @Override public void handle(@NotNull Context ctx) throws Exception {}
+}
+
+class UpdateHandler implements Handler {
+    @Override public void handle(@NotNull Context ctx) throws Exception {}
+}
+
+class DeleteHandler implements Handler {
+    @Override public void handle(@NotNull Context ctx) throws Exception {}
+}
+
+public class TestOpenApiAnnotations_Java {
+    @Before
+    public void setup() {
+        JavalinJackson.configure(
+            jacksonObjectMapper()
+                .enable(SerializationFeature.INDENT_OUTPUT)
+                .setSerializationInclusion(JsonInclude.Include.NON_NULL)
+        );
+    }
+
+    @Test
+    public void testWithSimpleExample() {
+        OpenApiOptions openApiOptions = new OpenApiOptions(
+            new Info().title("Example").version("1.0.0")
+        );
+        Javalin app = Javalin.create(config -> config.enableOpenApi(openApiOptions));
+
+        app.get("/users/:user-id", new GetOneHandler());
+        app.delete("/users/:user-id", new DeleteHandler());
+        app.patch("/users/:user-id", new UpdateHandler());
+        app.get("/users", new GetAllHandler());
+        app.post("/users", new CreateHandler());
+
+        OpenAPI actual = JavalinOpenApi.createSchema(app);
+        assertThat(JsonKt.asJsonString(actual)).isEqualTo(JsonKt.getCrudExampleJson());
+    }
+
+
+    @Test
+    public void testWithCrudHandler() {
+        OpenApiOptions openApiOptions = new OpenApiOptions(
+            new Info().title("Example").version("1.0.0")
+        );
+        Javalin app = Javalin.create(config -> config.enableOpenApi(openApiOptions));
+
+        app.routes(() -> ApiBuilder.crud("users/:user-id", new JavaCrudHandler()));
+
+        OpenAPI actual = JavalinOpenApi.createSchema(app);
+        assertThat(JsonKt.asJsonString(actual)).isEqualTo(JsonKt.getCrudExampleJson());
+    }
+}