Repository: tipsy/javalin
Pull request #883 (2020-02-22 18:26:40)

Add repeatable query params for api type enforcement
In my oppinion it would be nice to be able to create type specs for more than just generic lists of query parameters, but for typed too. this is why I started this pull request.

warning: could break legacy code due to new parameter on queryParam function, the annotations should not be affected

28Smiles: I may have broken something, due to the new parameter, I will investigate
28Smiles: This bug is gladly not introduced my me, but it seems very concerning.
In `extractDocumentationWithPathScanning` multiple documentations with the same path are found. The the method seems to pick the first of the listed, not caring about multiple options. I suggest inserting a check for this:
io.javalin.plugin.openapi.dsl.extractDocumentation:55
tipsy: Nice! If you fix the conflict, I can merge it.
tipsy: Great, thank you!

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/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/json.kt b/src/test/java/io/javalin/openapi/json.kt
index 2ceb379c..6b9e7c79 100644
--- a/src/test/java/io/javalin/openapi/json.kt
+++ b/src/test/java/io/javalin/openapi/json.kt
@@ -179,6 +179,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 = """
   {