Repository: tipsy/javalin
Pull request #1540 (2022-05-10 20:54:54)

[openapi] Fix openapi custom annotation handling
Error in Openapi with custom annotations (see regression test)


Patch:
diff --git a/javalin-openapi/src/main/java/io/javalin/plugin/openapi/jackson/JavalinModelResolver.kt b/javalin-openapi/src/main/java/io/javalin/plugin/openapi/jackson/JavalinModelResolver.kt
index 27610090..fc6c7ab6 100644
--- a/javalin-openapi/src/main/java/io/javalin/plugin/openapi/jackson/JavalinModelResolver.kt
+++ b/javalin-openapi/src/main/java/io/javalin/plugin/openapi/jackson/JavalinModelResolver.kt
@@ -19,7 +19,7 @@ class JavalinModelResolver(mapper: ObjectMapper) : ModelResolver(mapper) {
         val type = extractJavaType(annotatedType)
 
         if (type.isTypeOrSubTypeOf(Instant::class.java) && _mapper.isEnabled(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
-            && annotatedType.ctxAnnotations.isEmpty()
+            && annotatedType.ctxAnnotations?.none { it.annotationClass == io.swagger.v3.oas.annotations.media.Schema::class.java } != false
         ) {
             return PrimitiveType.LONG.createProperty()
         }
diff --git a/javalin/src/main/java/io/javalin/core/JavalinConfig.java b/javalin/src/main/java/io/javalin/core/JavalinConfig.java
index 76d40884..e24bbf07 100644
--- a/javalin/src/main/java/io/javalin/core/JavalinConfig.java
+++ b/javalin/src/main/java/io/javalin/core/JavalinConfig.java
@@ -21,6 +21,7 @@ import io.javalin.core.util.CorsPlugin;
 import io.javalin.core.util.Header;
 import io.javalin.core.util.Headers;
 import io.javalin.core.util.HeadersPlugin;
+import io.javalin.core.util.HttpAllowedMethodsOnRoutesUtil;
 import io.javalin.core.util.LogUtil;
 import io.javalin.http.ContentType;
 import io.javalin.http.ContextResolver;
@@ -240,4 +241,8 @@ public class JavalinConfig {
         userResolver.accept(finalResolver);
         inner.appAttributes.put(CONTEXT_RESOLVER_KEY, finalResolver);
     }
+
+    public void enableHttpAllowedMethodsOnRoutes() {
+        registerPlugin(new HttpAllowedMethodsOnRoutesUtil());
+    }
 }
diff --git a/javalin/src/main/java/io/javalin/core/util/HttpAllowedMethodsOnRoutesUtil.kt b/javalin/src/main/java/io/javalin/core/util/HttpAllowedMethodsOnRoutesUtil.kt
new file mode 100644
index 00000000..f1ae14e1
--- /dev/null
+++ b/javalin/src/main/java/io/javalin/core/util/HttpAllowedMethodsOnRoutesUtil.kt
@@ -0,0 +1,50 @@
+/*
+ * Javalin - https://javalin.io
+ * Copyright 2021 David Åse
+ * Licensed under Apache 2.0: https://github.com/tipsy/javalin/blob/master/LICENSE
+ */
+
+package io.javalin.core.util
+
+import io.javalin.Javalin
+import io.javalin.core.event.HandlerMetaInfo
+import io.javalin.core.plugin.Plugin
+import io.javalin.core.plugin.PluginLifecycleInit
+
+class HttpAllowedMethodsOnRoutesUtil() : Plugin, PluginLifecycleInit {
+    private val endpoints = mutableMapOf<String, MutableSet<HandlerMetaInfo>>()
+
+    override fun init(app: Javalin) {
+        app.events {
+            it.handlerAdded { handlerInfo ->
+                addOptionsToList(handlerInfo)
+            }
+        }
+    }
+
+    override fun apply(app: Javalin) {
+        app.events {
+            it.serverStarted {
+                createOptionsEndPoint(app)
+            }
+        }
+    }
+
+    private fun addOptionsToList(handlerMetaInfo: HandlerMetaInfo) {
+        val endpoint = endpoints.getOrPut(handlerMetaInfo.path) { mutableSetOf(handlerMetaInfo) }
+        endpoint.add(handlerMetaInfo)
+    }
+
+    private fun createOptionsEndPoint(app: Javalin) {
+        endpoints.forEach { endpoint ->
+            app.options(
+                endpoint.key
+            ) { context ->
+                context.header(
+                    Header.ACCESS_CONTROL_ALLOW_METHODS,
+                    endpoint.value.joinToString(",") { it.httpMethod.toString() }
+                )
+            }
+        }
+    }
+}


Test patch:
diff --git a/javalin-openapi/src/test/java/io/javalin/plugin/openapi/TestOpenApi.kt b/javalin-openapi/src/test/java/io/javalin/plugin/openapi/TestOpenApi.kt
index bfc51f2f..85af4726 100644
--- a/javalin-openapi/src/test/java/io/javalin/plugin/openapi/TestOpenApi.kt
+++ b/javalin-openapi/src/test/java/io/javalin/plugin/openapi/TestOpenApi.kt
@@ -38,6 +38,7 @@ import io.javalin.testing.TestUtil
 import io.swagger.v3.oas.annotations.media.Schema
 import io.swagger.v3.oas.models.OpenAPI
 import io.swagger.v3.oas.models.info.Info
+import io.swagger.v3.oas.models.media.ArraySchema
 import io.swagger.v3.oas.models.security.SecurityRequirement
 import io.swagger.v3.oas.models.security.SecurityScheme
 import io.swagger.v3.oas.models.servers.Server
@@ -53,6 +54,12 @@ class User(val name: String, val address: Address? = null, val userType: UserTyp
 
 class Log(val timestamp: Instant, val message: String)
 
+annotation class TestAnnotation
+
+class NonSchemaAnnotatedLog(@TestAnnotation val timestamp: Instant, val message: String)
+
+class ListLog(val timestamps: List<Instant>, val message: String)
+
 class AnnotatedLog(@Schema(type = "string", example = "test example") val timestamp: Instant, val message: String)
 
 
@@ -525,6 +532,62 @@ class TestOpenApi {
         assertThat(timestampSchemaType.example).isEqualTo("test example")
     }
 
+    @Test
+    fun `createSchema Instants respect annotations but only the right ones`() {
+        val openApiOptions = OpenApiOptions(Info().title("Example").version("1.0.0"))
+            .modelConverterFactory(
+                JacksonModelConverterFactory(
+                    JavalinJackson.defaultMapper().configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true)
+                )
+            )
+
+        val app = Javalin.create {
+            it.registerPlugin(OpenApiPlugin(openApiOptions))
+        }
+
+        val logsDocumentation = document()
+            .jsonArray<NonSchemaAnnotatedLog>("200")
+
+        with(app) {
+            get("/logs", documented(logsDocumentation) {})
+        }
+
+        val actual = JavalinOpenApi.createSchema(app)
+        val schema = actual.components.schemas["NonSchemaAnnotatedLog"]!!
+        val timestampSchemaType = schema.properties["timestamp"]!!
+        assertThat(timestampSchemaType.type).isEqualTo("integer")
+        assertThat(timestampSchemaType.format).isEqualTo("int64")
+    }
+
+    @Test
+    fun `createSchema list of Instants respects annotations`() {
+        val openApiOptions = OpenApiOptions(Info().title("Example").version("1.0.0"))
+            .modelConverterFactory(
+                JacksonModelConverterFactory(
+                    JavalinJackson.defaultMapper().configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, true)
+                )
+            )
+
+        val app = Javalin.create {
+            it.registerPlugin(OpenApiPlugin(openApiOptions))
+        }
+
+        val logsDocumentation = document()
+            .jsonArray<ListLog>("200")
+
+        with(app) {
+            get("/logs", documented(logsDocumentation) {})
+        }
+
+        val actual = JavalinOpenApi.createSchema(app)
+        val schema = actual.components.schemas["ListLog"]!!
+        val timestampSchemaType = schema.properties["timestamps"]!! as ArraySchema
+        assertThat(timestampSchemaType.type).isEqualTo("array")
+        val innerSchema = timestampSchemaType.items
+        assertThat(innerSchema.type).isEqualTo("integer")
+        assertThat(innerSchema.format).isEqualTo("int64")
+    }
+
     @Test
     fun `createSchema works with Instants as strings`() {
         val openApiOptions = OpenApiOptions(Info().title("Example").version("1.0.0"))
diff --git a/javalin/src/test/java/io/javalin/TestHttpAllowedMethodsOnRoutesUtil.kt b/javalin/src/test/java/io/javalin/TestHttpAllowedMethodsOnRoutesUtil.kt
new file mode 100644
index 00000000..a898e8d3
--- /dev/null
+++ b/javalin/src/test/java/io/javalin/TestHttpAllowedMethodsOnRoutesUtil.kt
@@ -0,0 +1,40 @@
+/*
+ * Javalin - https://javalin.io
+ * Copyright 2017 David Åse
+ * Licensed under Apache 2.0: https://github.com/tipsy/javalin/blob/master/LICENSE
+ */
+
+package io.javalin
+
+import io.javalin.core.util.Header
+import io.javalin.testing.TestUtil
+import kong.unirest.Unirest
+import org.assertj.core.api.Assertions
+import org.junit.jupiter.api.Test
+
+class TestHttpAllowedMethodsOnRoutesUtil {
+    @Test
+    fun `enableHttpOptionsForRoutes allows possible methods on routes`() {
+        val javalin = Javalin.create { it.enableHttpAllowedMethodsOnRoutes() }
+        javalin.get("/") { it.result("Hello") }
+        javalin.delete("/") { it.status(200) }
+
+        javalin.get("/users") { it.result("Users") }
+        javalin.post("/users") { it.status(201) }
+        javalin.patch("/users") { it.status(204) }
+
+        TestUtil.test(javalin) { app, http ->
+            val response = Unirest.options(http.origin)
+                .header(Header.ACCESS_CONTROL_REQUEST_HEADERS, "123")
+                .header(Header.ACCESS_CONTROL_REQUEST_METHOD, "TEST")
+                .asString()
+
+            Assertions.assertThat(response.headers[Header.ACCESS_CONTROL_ALLOW_METHODS]!![0])
+                .isEqualTo("GET,DELETE,OPTIONS")
+
+            val usersResponse = Unirest.options(http.origin + "/users").asString()
+            Assertions.assertThat(usersResponse.headers[Header.ACCESS_CONTROL_ALLOW_METHODS]!![0])
+                .isEqualTo("GET,POST,PATCH,OPTIONS")
+        }
+    }
+}