Repository: tipsy/javalin
Pull request #885 (2020-02-22 19:20:00)

Add assertion for multiple documentations of the same explicit path.
In extractDocumentationWithPathScanning multiple documentations with the same path are found. The the method seems to pick the last of the listed, not caring about multiple options. I suggest inserting a check for this, and not failing silently
See #883 for bug discovery

28Smiles: The failing test is a test, that has previously overitten its predecessor in the right order. I do not know what to do with it.
tipsy: > The failing test is a test, that has previously overitten its predecessor in the right order. I do not know what to do with it.

When I added my first OpenAPI feature, I created a standalone test: `TestOpenApiExample` (I was a bit frustrated with the OpenAPI tests since they are all coupled).

Could you move the failing test to a standalone test like this?
28Smiles: sounds good to me
28Smiles: did not work, id adapted the test.

Patch:
diff --git a/src/main/java/io/javalin/plugin/openapi/annotations/AnnotationApi.kt b/src/main/java/io/javalin/plugin/openapi/annotations/AnnotationApi.kt
index 62798943..5f8c7f61 100644
--- a/src/main/java/io/javalin/plugin/openapi/annotations/AnnotationApi.kt
+++ b/src/main/java/io/javalin/plugin/openapi/annotations/AnnotationApi.kt
@@ -45,7 +45,8 @@ annotation class OpenApiParam(
         val description: String = NULL_STRING,
         val deprecated: Boolean = false,
         val required: Boolean = false,
-        val allowEmptyValue: Boolean = false
+        val allowEmptyValue: Boolean = false,
+        val isRepeatable: Boolean = false
 )
 
 @Target()
diff --git a/src/main/java/io/javalin/plugin/openapi/annotations/AnnotationApiMapping.kt b/src/main/java/io/javalin/plugin/openapi/annotations/AnnotationApiMapping.kt
index 2c688de6..49df0988 100644
--- a/src/main/java/io/javalin/plugin/openapi/annotations/AnnotationApiMapping.kt
+++ b/src/main/java/io/javalin/plugin/openapi/annotations/AnnotationApiMapping.kt
@@ -164,7 +164,8 @@ private fun OpenApiDocumentation.applyParamAnnotation(`in`: String, param: OpenA
                 if (param.allowEmptyValue) {
                     paramDocumentation.allowEmptyValue = param.allowEmptyValue
                 }
-            }
+            },
+            isRepeatable = param.isRepeatable
     )
 }
 
diff --git a/src/main/java/io/javalin/plugin/openapi/annotations/scanForAnnotations.kt b/src/main/java/io/javalin/plugin/openapi/annotations/scanForAnnotations.kt
index 3d4912fc..0c991759 100644
--- a/src/main/java/io/javalin/plugin/openapi/annotations/scanForAnnotations.kt
+++ b/src/main/java/io/javalin/plugin/openapi/annotations/scanForAnnotations.kt
@@ -23,6 +23,15 @@ internal fun scanForAnnotations(packagePrefixes: Set<String>): Map<PathInfo, Ope
             .map { it.getAnnotation(OpenApi::class.java) }
             .filter { it.path != NULL_STRING }
 
+    assert(annotations.none { a -> annotations.any { b -> a != b && a.pathInfo == b.pathInfo } }) {
+        val duplicates = annotations.filter { a -> annotations.any { b -> a != b && a.pathInfo == b.pathInfo } }
+                .map { it.pathInfo.path }
+                .distinct()
+                .joinToString(", ")
+        throw IllegalStateException("Found multiple of the name path infos ("
+            + duplicates + ") in packages: " + packagePrefixes.joinToString(", "))
+    }
+
     annotations.forEach {
         documentationByPathInfo[it.pathInfo] = it.asOpenApiDocumentation()
     }
diff --git a/src/main/java/io/javalin/plugin/openapi/dsl/DocumentedParameter.kt b/src/main/java/io/javalin/plugin/openapi/dsl/DocumentedParameter.kt
index 2e3197c7..c3212f5b 100644
--- a/src/main/java/io/javalin/plugin/openapi/dsl/DocumentedParameter.kt
+++ b/src/main/java/io/javalin/plugin/openapi/dsl/DocumentedParameter.kt
@@ -1,6 +1,7 @@
 package io.javalin.plugin.openapi.dsl
 
 import io.javalin.plugin.openapi.external.findSchema
+import io.swagger.v3.oas.models.media.ArraySchema
 import io.swagger.v3.oas.models.parameters.Parameter
 
 class DocumentedParameter(
@@ -14,3 +15,9 @@ fun Parameter.applyDocumentedParameter(documentedParameter: DocumentedParameter)
     name = documentedParameter.name
     schema = findSchema(documentedParameter.type)?.main
 }
+
+fun Parameter.applyRepeatableDocumentedParameter(documentedParameter: DocumentedParameter) {
+    `in` = documentedParameter.`in`
+    name = documentedParameter.name
+    schema = findSchema(documentedParameter.type)?.main?.let { ArraySchema().items(it) }
+}
diff --git a/src/main/java/io/javalin/plugin/openapi/dsl/OpenApiDocumentation.kt b/src/main/java/io/javalin/plugin/openapi/dsl/OpenApiDocumentation.kt
index 932aabc1..381a29fb 100644
--- a/src/main/java/io/javalin/plugin/openapi/dsl/OpenApiDocumentation.kt
+++ b/src/main/java/io/javalin/plugin/openapi/dsl/OpenApiDocumentation.kt
@@ -45,18 +45,18 @@ class OpenApiDocumentation {
     @JvmOverloads
     fun pathParam(name: String, clazz: Class<*>, openApiUpdater: OpenApiUpdater<Parameter>? = null) = apply {
         val documentedQueryParameter = DocumentedParameter("path", name, clazz)
-        param(documentedQueryParameter, openApiUpdater)
+        param(documentedQueryParameter, openApiUpdater = openApiUpdater)
     }
 
     // --- QUERY PARAM ---
-    inline fun <reified T> queryParam(name: String, noinline applyUpdates: ApplyUpdates<Parameter>? = null): OpenApiDocumentation = apply {
-        queryParam(name, T::class.java, createUpdaterIfNotNull(applyUpdates))
+    inline fun <reified T> queryParam(name: String, isRepeatable: Boolean = false, noinline applyUpdates: ApplyUpdates<Parameter>? = null): OpenApiDocumentation = apply {
+        queryParam(name, T::class.java, isRepeatable, createUpdaterIfNotNull(applyUpdates))
     }
 
     @JvmOverloads
-    fun queryParam(name: String, clazz: Class<*>, openApiUpdater: OpenApiUpdater<Parameter>? = null) = apply {
+    fun queryParam(name: String, clazz: Class<*>, isRepeatable: Boolean = false, openApiUpdater: OpenApiUpdater<Parameter>? = null) = apply {
         val documentedQueryParameter = DocumentedParameter("query", name, clazz)
-        param(documentedQueryParameter, openApiUpdater)
+        param(documentedQueryParameter, isRepeatable, openApiUpdater)
     }
 
     // --- HEADER ---
@@ -67,7 +67,7 @@ class OpenApiDocumentation {
     @JvmOverloads
     fun header(name: String, clazz: Class<*>, openApiUpdater: OpenApiUpdater<Parameter>? = null) = apply {
         val documentedQueryParameter = DocumentedParameter("header", name, clazz)
-        param(documentedQueryParameter, openApiUpdater)
+        param(documentedQueryParameter, openApiUpdater = openApiUpdater)
     }
 
     // --- COOKIE ---
@@ -78,18 +78,23 @@ class OpenApiDocumentation {
     @JvmOverloads
     fun cookie(name: String, clazz: Class<*>, openApiUpdater: OpenApiUpdater<Parameter>? = null) = apply {
         val documentedQueryParameter = DocumentedParameter("cookie", name, clazz)
-        param(documentedQueryParameter, openApiUpdater)
+        param(documentedQueryParameter, openApiUpdater = openApiUpdater)
     }
 
     // --- PARAM ---
-    fun param(documentedParameter: DocumentedParameter, applyUpdates: ApplyUpdates<Parameter>? = null) = apply {
-        param(documentedParameter, createUpdaterIfNotNull(applyUpdates))
+    fun param(documentedParameter: DocumentedParameter, isRepeatable: Boolean = false, applyUpdates: ApplyUpdates<Parameter>? = null) = apply {
+        param(documentedParameter, isRepeatable, createUpdaterIfNotNull(applyUpdates))
     }
 
     @JvmOverloads
-    fun param(documentedParameter: DocumentedParameter, openApiUpdater: OpenApiUpdater<Parameter>? = null) = apply {
+    fun param(documentedParameter: DocumentedParameter, isRepeatable: Boolean = false, openApiUpdater: OpenApiUpdater<Parameter>? = null) = apply {
         val parameterUpdaterList = parameterUpdaterListMapping.getOrSetDefault(documentedParameter.name, mutableListOf())
-        parameterUpdaterList.add { it.applyDocumentedParameter(documentedParameter) }
+        parameterUpdaterList.add {
+            if (isRepeatable)
+                it.applyRepeatableDocumentedParameter(documentedParameter)
+            else
+                it.applyDocumentedParameter(documentedParameter)
+        }
         parameterUpdaterList.addIfNotNull(openApiUpdater)
     }
 


Test patch:
diff --git a/src/test/java/io/javalin/openapi/TestOpenApi.kt b/src/test/java/io/javalin/openapi/TestOpenApi.kt
index 2b6c32aa..8bb5e775 100644
--- a/src/test/java/io/javalin/openapi/TestOpenApi.kt
+++ b/src/test/java/io/javalin/openapi/TestOpenApi.kt
@@ -366,6 +366,23 @@ class TestOpenApi {
         assertThat(actual.asJsonString()).isEqualTo(defaultOperationExampleJson)
     }
 
+    @Test
+    fun `createSchema works with repeatable query param and dsl`() {
+        val openApiOptions = OpenApiOptions(
+                Info().title("Example").version("1.0.0")
+        )
+        val app = Javalin.create {
+            it.registerPlugin(OpenApiPlugin(openApiOptions))
+        }
+
+        with(app) {
+            get("/test", documented(document().queryParam("id", Long::class.java, isRepeatable = true)) {})
+        }
+        val actual = JavalinOpenApi.createSchema(app)
+
+        assertThat(actual.asJsonString()).isEqualTo(simpleExampleWithRepeatableQueryParam)
+    }
+
     @Test
     fun `createSchema works with query param and reified dsl`() {
         val openApiOptions = OpenApiOptions(
diff --git a/src/test/java/io/javalin/openapi/TestOpenApiAnnotations.kt b/src/test/java/io/javalin/openapi/TestOpenApiAnnotations.kt
index 5a941c80..45b4a977 100644
--- a/src/test/java/io/javalin/openapi/TestOpenApiAnnotations.kt
+++ b/src/test/java/io/javalin/openapi/TestOpenApiAnnotations.kt
@@ -274,6 +274,14 @@ fun getBodyOneOfHandler(ctx: Context) {
 fun getResponseOneOfHandler(ctx: Context) {
 }
 
+@OpenApi(
+        queryParams = [
+            OpenApiParam("id", Long::class, isRepeatable = true)
+        ]
+)
+fun getQueryParamListHandler(ctx: Context) {
+}
+
 // endregion composed body
 
 class TestOpenApiAnnotations {
@@ -347,6 +355,13 @@ class TestOpenApiAnnotations {
         }.assertEqualTo(simpleExample)
     }
 
+    @Test
+    fun `createOpenApiSchema with repeatable query param`() {
+        extractSchemaForTest {
+            it.get("/test", ::getQueryParamListHandler)
+        }.assertEqualTo(simpleExampleWithRepeatableQueryParam)
+    }
+
     @Test
     fun `createOpenApiSchema with extended class`() {
         extractSchemaForTest {
diff --git a/src/test/java/io/javalin/openapi/TestOpenApiAnnotations_Java.java b/src/test/java/io/javalin/openapi/TestOpenApiAnnotations_Java.java
index 9cf4c9f2..ece87744 100644
--- a/src/test/java/io/javalin/openapi/TestOpenApiAnnotations_Java.java
+++ b/src/test/java/io/javalin/openapi/TestOpenApiAnnotations_Java.java
@@ -148,7 +148,6 @@ class JavaMethodReference3 {
 
 class JavaStaticMethodReference {
     @OpenApi(
-        path = "/test",
         method = HttpMethod.GET,
         responses = {@OpenApiResponse(status = "200")}
     )
@@ -276,7 +275,7 @@ public class TestOpenApiAnnotations_Java {
             app.get("/test", JavaStaticMethodReference::createStaticHandler);
             return Unit.INSTANCE;
         });
-        OpenApiTestUtils.assertEqualTo(schema, JsonKt.getSimpleExample());
+        OpenApiTestUtils.assertEqualTo(schema, JsonKt.getSimpleExampleWithDescription());
     }
 
     @Test
diff --git a/src/test/java/io/javalin/openapi/json.kt b/src/test/java/io/javalin/openapi/json.kt
index 2ceb379c..867bfa94 100644
--- a/src/test/java/io/javalin/openapi/json.kt
+++ b/src/test/java/io/javalin/openapi/json.kt
@@ -108,6 +108,32 @@ val simpleExample = """
   }
 """.formatJson()
 
+@Language("JSON")
+val simpleExampleWithDescription = """
+  {
+    "openapi": "3.0.1",
+    "info": {
+      "title": "Example",
+      "version": "1.0.0"
+    },
+    "paths": {
+      "/test": {
+        "get": {
+          "summary": "Get test",
+          "description" : "Test1",
+          "operationId": "getTest",
+          "responses" : {
+            "200" : {
+              "description" : "OK"
+            }
+          }
+        }
+      }
+    },
+    "components": {}
+  }
+""".formatJson()
+
 @Language("JSON")
 val simpleExampleWithPrimitiveQueryParam = """
   {
@@ -179,6 +205,42 @@ val simpleExampleWithMultipleGets = """
   }
 """.formatJson()
 
+@Language("JSON")
+val simpleExampleWithRepeatableQueryParam = """
+  {
+  "openapi" : "3.0.1",
+    "info" : {
+      "title" : "Example",
+      "version" : "1.0.0"
+    },
+    "paths" : {
+      "/test" : {
+        "get" : {
+          "summary" : "Get test",
+          "operationId" : "getTest",
+          "parameters" : [ {
+            "name" : "id",
+            "in" : "query",
+            "schema" : {
+              "type" : "array",
+              "items" : {
+                "type" : "integer",
+                "format" : "int64"
+              }
+            }
+          } ],
+          "responses" : {
+            "200" : {
+              "description" : "Default response"
+            }
+          }
+        }
+      }
+    },
+    "components" : { }
+}
+""".formatJson()
+
 @Language("JSON")
 val simpleExampleWithMultipleHttpMethods = """
   {