diff --git a/modules/openapi-generator/src/main/resources/kotlin-spring/queryParams.mustache b/modules/openapi-generator/src/main/resources/kotlin-spring/queryParams.mustache index 27d7e286bb33..a97eba9e7921 100644 --- a/modules/openapi-generator/src/main/resources/kotlin-spring/queryParams.mustache +++ b/modules/openapi-generator/src/main/resources/kotlin-spring/queryParams.mustache @@ -1 +1 @@ -{{#isQueryParam}}{{#useBeanValidation}}{{>beanValidationQueryParams}}{{/useBeanValidation}}{{#swagger2AnnotationLibrary}}@Parameter(description = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}{{#defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]{{^isContainer}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/isContainer}}){{/defaultValue}}{{/allowableValues}}{{#allowableValues}}{{^defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]){{/defaultValue}}{{/allowableValues}}{{^allowableValues}}{{#defaultValue}}{{^isContainer}}, schema = Schema(defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}){{/isContainer}}{{/defaultValue}}{{/allowableValues}}) {{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}@ApiParam(value = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}, allowableValues = "{{#values}}{{{.}}}{{^-last}}, {{/-last}}{{/values}}"{{/allowableValues}}{{^isContainer}}{{#defaultValue}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/defaultValue}}{{/isContainer}}) {{/swagger1AnnotationLibrary}}{{#useBeanValidation}}@Valid{{/useBeanValidation}}{{^isModel}} @RequestParam(value = "{{baseName}}"{{#required}}, required = true{{/required}}{{^required}}, required = false{{/required}}{{^isContainer}}{{#defaultValue}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/defaultValue}}{{/isContainer}}){{/isModel}}{{#isDate}} @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE){{/isDate}}{{#isDateTime}} @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE_TIME){{/isDateTime}} {{{paramName}}}: {{>optionalDataType}}{{/isQueryParam}} \ No newline at end of file +{{#isQueryParam}}{{#useBeanValidation}}{{>beanValidationQueryParams}}{{/useBeanValidation}}{{#swagger2AnnotationLibrary}}@Parameter(description = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}{{#defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]{{^isContainer}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/isContainer}}){{/defaultValue}}{{/allowableValues}}{{#allowableValues}}{{^defaultValue}}, schema = Schema(allowableValues = [{{#values}}"{{{.}}}"{{^-last}}, {{/-last}}{{/values}}]){{/defaultValue}}{{/allowableValues}}{{^allowableValues}}{{#defaultValue}}{{^isContainer}}, schema = Schema(defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}){{/isContainer}}{{/defaultValue}}{{/allowableValues}}) {{/swagger2AnnotationLibrary}}{{#swagger1AnnotationLibrary}}@ApiParam(value = "{{{description}}}"{{#required}}, required = true{{/required}}{{#allowableValues}}, allowableValues = "{{#values}}{{{.}}}{{^-last}}, {{/-last}}{{/values}}"{{/allowableValues}}{{^isContainer}}{{#defaultValue}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/defaultValue}}{{/isContainer}}) {{/swagger1AnnotationLibrary}}{{#useBeanValidation}}@Valid{{/useBeanValidation}}{{^isModel}} @RequestParam(value = "{{baseName}}"{{#required}}, required = true{{/required}}{{^required}}, required = false{{/required}}{{^isContainer}}{{#defaultValue}}, defaultValue = {{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{{defaultValue}}}{{^isString}}"{{/isString}}{{#isString}}{{#isEnum}}"{{/isEnum}}{{/isString}}{{/defaultValue}}{{/isContainer}}){{/isModel}}{{#isModel}}{{^queryIsJsonMimeType}} @ModelAttribute("{{baseName}}"){{/queryIsJsonMimeType}}{{#queryIsJsonMimeType}} @RequestParam(value = "{{baseName}}"{{#required}}, required = true{{/required}}{{^required}}, required = false{{/required}}){{/queryIsJsonMimeType}}{{/isModel}}{{#isDate}} @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE){{/isDate}}{{#isDateTime}} @org.springframework.format.annotation.DateTimeFormat(iso = org.springframework.format.annotation.DateTimeFormat.ISO.DATE_TIME){{/isDateTime}} {{{paramName}}}: {{>optionalDataType}}{{/isQueryParam}} \ No newline at end of file diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java index 0e83e957927d..0677b9b9ecce 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/spring/KotlinSpringServerCodegenTest.java @@ -6431,6 +6431,56 @@ public void suspendFunctionsDefaultsToFalse() throws Exception { "suspend should not be present when suspendFunctions is not enabled"); } + // ========== MODEL QUERY PARAM @ModelAttribute TESTS ========== + + @Test + public void modelQueryParamsGetModelAttributeAnnotation() throws Exception { + // Complex query params (isModel=true) must get @ModelAttribute("baseName") so that: + // - Spring binds nested fields (e.g. filter[statuses]=...) correctly + // - The exact wire name is preserved when paramName is sanitized + // (e.g. 'order-status' → paramName 'orderStatus'; the annotation keeps the wire name) + Map additionalProperties = new HashMap<>(); + additionalProperties.put(INTERFACE_ONLY, "true"); + additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true"); + additionalProperties.put(USE_TAGS, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/spring/petstore-sort-validation.yaml", additionalProperties); + + File ordersApi = files.get("OrdersApi.kt"); + // valid Kotlin identifier — @ModelAttribute still required for binding + assertFileContains(ordersApi.toPath(), "@ModelAttribute(\"filter\")"); + // non-Kotlin-identifier names — wire name must be explicit in the annotation + assertFileContains(ordersApi.toPath(), "@ModelAttribute(\"order-status\")"); + assertFileContains(ordersApi.toPath(), "@ModelAttribute(\"item-status\")"); + // @RequestParam must NOT be used for any model param + assertFileNotContains(ordersApi.toPath(), "@RequestParam(value = \"filter\""); + assertFileNotContains(ordersApi.toPath(), "@RequestParam(value = \"order-status\""); + assertFileNotContains(ordersApi.toPath(), "@RequestParam(value = \"item-status\""); + } + + @Test + public void contentJsonQueryParamGetsRequestParamNotModelAttribute() throws Exception { + // A query param declared with `content: application/json` (queryIsJsonMimeType=true) carries a + // single JSON-encoded string on the wire (e.g. ?filter={"ids":[1,2]}). + // @ModelAttribute is for form-field/deepObject binding and would break JSON params even when a + // custom Converter is registered — Spring would try to bind filter.ids=... style fields + // rather than deserializing the whole string. + // The correct annotation is @RequestParam, which delivers the raw string to the converter. + Map additionalProperties = new HashMap<>(); + additionalProperties.put(INTERFACE_ONLY, "true"); + additionalProperties.put(SKIP_DEFAULT_INTERFACE, "true"); + + Map files = generateFromContract( + "src/test/resources/3_0/query-param-json.yaml", additionalProperties); + + File searchApi = files.get("SearchApi.kt"); + // @RequestParam must be used so the raw JSON string is passed to a Converter + assertFileContains(searchApi.toPath(), "@RequestParam(value = \"filter\""); + // @ModelAttribute must NOT be used — it would attempt deepObject field binding, not JSON parsing + assertFileNotContains(searchApi.toPath(), "@ModelAttribute(\"filter\")"); + } + @Test public void suspendFunctionsWithServiceInterface() throws Exception { Path root = generateApiSources(Map.of( diff --git a/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml index dcfa3797c804..dd8cb5fcacf3 100644 --- a/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/spring/petstore-sort-validation.yaml @@ -8,6 +8,8 @@ servers: tags: - name: pet description: Everything about your Pets + - name: orders + description: Order filtering endpoints paths: /pet/findByStatusWithSort: get: @@ -540,6 +542,94 @@ paths: type: array items: $ref: '#/components/schemas/Pet' + # ---- deepObject model query param test cases ---- + + /orders: + get: + tags: + - orders + summary: List orders filtered by status — tests @ModelAttribute binding for model query params + operationId: listOrders + parameters: + - name: filter + in: query + description: Filter by order status (valid Kotlin identifier) + style: deepObject + explode: true + schema: + $ref: '#/components/schemas/OrderStatus' + - name: order-status + in: query + description: Filter by order status (non-Kotlin-identifier wire name, hyphenated) + style: deepObject + explode: true + schema: + $ref: '#/components/schemas/OrderStatus' + - name: item-status + in: query + description: Filter by item status (non-Kotlin-identifier wire name, hyphenated) + style: deepObject + explode: true + schema: + $ref: '#/components/schemas/OrderStatus' + responses: + '200': + description: OK + + /orders/filter-only: + get: + tags: + - orders + summary: List orders with only the filter param — impl asserts filter is bound correctly + operationId: listOrdersFilterOnly + parameters: + - name: filter + in: query + description: Filter by order status (valid Kotlin identifier, isolated test) + style: deepObject + explode: true + schema: + $ref: '#/components/schemas/OrderStatus' + responses: + '200': + description: OK — impl assertion passed + + /orders/order-status-only: + get: + tags: + - orders + summary: List orders with only the order-status param — impl asserts order-status is bound correctly + operationId: listOrdersOrderStatusOnly + parameters: + - name: order-status + in: query + description: Filter by order status (non-Kotlin-identifier wire name, isolated test) + style: deepObject + explode: true + schema: + $ref: '#/components/schemas/OrderStatus' + responses: + '200': + description: OK — impl assertion passed + + /orders/item-status-only: + get: + tags: + - orders + summary: List orders with only the item-status param — impl asserts item-status is bound correctly + operationId: listOrdersItemStatusOnly + parameters: + - name: item-status + in: query + description: Filter by item status (non-Kotlin-identifier wire name, isolated test) + style: deepObject + explode: true + schema: + $ref: '#/components/schemas/OrderStatus' + responses: + '200': + description: OK — impl assertion passed + # ---- openApiNullable / 4-state required×nullable test cases ---- /nullable/check-required-only: @@ -627,6 +717,14 @@ components: - "id,desc" - "createdAt,asc" - "createdAt,desc" + OrderStatus: + type: object + properties: + statuses: + type: array + uniqueItems: true + items: + type: string Pet: type: object required: diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/FILES b/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/FILES index dfb5385de94b..33ac47765059 100644 --- a/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/FILES +++ b/samples/server/petstore/kotlin-springboot-sort-validation/.openapi-generator/FILES @@ -9,11 +9,13 @@ settings.gradle src/main/kotlin/org/openapitools/api/ApiUtil.kt src/main/kotlin/org/openapitools/api/Exceptions.kt src/main/kotlin/org/openapitools/api/NullableApi.kt +src/main/kotlin/org/openapitools/api/OrdersApi.kt src/main/kotlin/org/openapitools/api/PetApi.kt src/main/kotlin/org/openapitools/configuration/EnumConverterConfiguration.kt src/main/kotlin/org/openapitools/configuration/ValidPageable.kt src/main/kotlin/org/openapitools/configuration/ValidSort.kt src/main/kotlin/org/openapitools/model/NullableModel.kt +src/main/kotlin/org/openapitools/model/OrderStatus.kt src/main/kotlin/org/openapitools/model/Pet.kt src/main/kotlin/org/openapitools/model/PetSort.kt src/main/kotlin/org/openapitools/model/PetSortEnum.kt diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/OrdersApi.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/OrdersApi.kt new file mode 100644 index 000000000000..4383cfb65409 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/OrdersApi.kt @@ -0,0 +1,94 @@ +/** + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.23.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. +*/ +package org.openapitools.api + +import org.openapitools.model.OrderStatus +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity + +import org.springframework.web.bind.annotation.* +import org.springframework.validation.annotation.Validated +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.beans.factory.annotation.Autowired + +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +import kotlin.collections.List +import kotlin.collections.Map + +@RestController +@Validated +@RequestMapping("\${api.base-path:/v2}") +interface OrdersApi { + + + @RequestMapping( + method = [RequestMethod.GET], + // "/orders" + value = [PATH_LIST_ORDERS] + ) + fun listOrders( + @Valid @ModelAttribute("filter") filter: OrderStatus?, + @Valid @ModelAttribute("order-status") orderStatus: OrderStatus?, + @Valid @ModelAttribute("item-status") itemStatus: OrderStatus? + ): ResponseEntity { + return ResponseEntity(HttpStatus.NOT_IMPLEMENTED) + } + + + @RequestMapping( + method = [RequestMethod.GET], + // "/orders/filter-only" + value = [PATH_LIST_ORDERS_FILTER_ONLY] + ) + fun listOrdersFilterOnly( + @Valid @ModelAttribute("filter") filter: OrderStatus? + ): ResponseEntity { + return ResponseEntity(HttpStatus.NOT_IMPLEMENTED) + } + + + @RequestMapping( + method = [RequestMethod.GET], + // "/orders/item-status-only" + value = [PATH_LIST_ORDERS_ITEM_STATUS_ONLY] + ) + fun listOrdersItemStatusOnly( + @Valid @ModelAttribute("item-status") itemStatus: OrderStatus? + ): ResponseEntity { + return ResponseEntity(HttpStatus.NOT_IMPLEMENTED) + } + + + @RequestMapping( + method = [RequestMethod.GET], + // "/orders/order-status-only" + value = [PATH_LIST_ORDERS_ORDER_STATUS_ONLY] + ) + fun listOrdersOrderStatusOnly( + @Valid @ModelAttribute("order-status") orderStatus: OrderStatus? + ): ResponseEntity { + return ResponseEntity(HttpStatus.NOT_IMPLEMENTED) + } + + companion object { + //for your own safety never directly reuse these path definitions in tests + const val BASE_PATH: String = "/v2" + const val PATH_LIST_ORDERS: String = "/orders" + const val PATH_LIST_ORDERS_FILTER_ONLY: String = "/orders/filter-only" + const val PATH_LIST_ORDERS_ITEM_STATUS_ONLY: String = "/orders/item-status-only" + const val PATH_LIST_ORDERS_ORDER_STATUS_ONLY: String = "/orders/order-status-only" + } +} diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/OrdersApiImpl.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/OrdersApiImpl.kt new file mode 100644 index 000000000000..9d82de9ee858 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/api/OrdersApiImpl.kt @@ -0,0 +1,89 @@ +package org.openapitools.api + +import org.openapitools.model.OrderStatus +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.RestController + +/** + * Sample implementation of [OrdersApi] demonstrating that the generated + * `@ModelAttribute("baseName")` annotations correctly bind deepObject query + * parameters to their respective controller method parameters at runtime — + * even when multiple parameters share the same type ([OrderStatus]) and when + * the wire name is not a valid Kotlin identifier (e.g. `order-status`). + * + * Each method receives its parameters already resolved by Spring's + * `ModelAttributeMethodProcessor` and asserts the expected state. + * When binding is correct the assertion passes and HTTP 200 is returned. + * If a parameter was routed to the wrong argument (or not bound at all), + * the assertion throws [IllegalStateException] and the request fails with + * HTTP 500, which causes any calling test to fail with a clear message. + * + * Sentinel values are chosen to be distinct per wire name so that a + * mis-routing (e.g. `order-status.statuses` landing in `filter`) is caught + * by the wrong-value check rather than going unnoticed. + */ +@RestController +class OrdersApiImpl : OrdersApi { + + /** + * All three params present simultaneously with distinct sentinel values. + * + * Verifies that `filter.statuses`, `order-status.statuses` and `item-status.statuses` + * are each routed to their own parameter despite all three having the same type. + */ + override fun listOrders( + filter: OrderStatus?, + orderStatus: OrderStatus?, + itemStatus: OrderStatus?, + ): ResponseEntity { + check(filter?.statuses?.contains("sent-via-filter") == true) { + "filter.statuses: expected to contain 'sent-via-filter', got '${filter?.statuses}'" + } + check(orderStatus?.statuses?.contains("sent-via-order-status") == true) { + "orderStatus.statuses: expected to contain 'sent-via-order-status', got '${orderStatus?.statuses}'" + } + check(itemStatus?.statuses?.contains("sent-via-item-status") == true) { + "itemStatus.statuses: expected to contain 'sent-via-item-status', got '${itemStatus?.statuses}'" + } + return ResponseEntity(HttpStatus.OK) + } + + /** + * Only `filter` present — verifies that `filter.statuses` binds to the `filter` param. + * + * Two values (`active`, `pending`) are sent to confirm that multi-value `Set` + * binding works as well as single-value routing. + */ + override fun listOrdersFilterOnly(filter: OrderStatus?): ResponseEntity { + check(filter?.statuses?.containsAll(setOf("active", "pending")) == true) { + "filter.statuses: expected to contain both 'active' and 'pending', got '${filter?.statuses}'" + } + return ResponseEntity(HttpStatus.OK) + } + + /** + * Only `order-status` present — verifies that `order-status.statuses` binds to + * the `orderStatus` param via `@ModelAttribute("order-status")`. + * + * Without the explicit annotation value the hyphenated wire name cannot be + * matched to any Kotlin identifier and `orderStatus` would remain `null`. + */ + override fun listOrdersOrderStatusOnly(orderStatus: OrderStatus?): ResponseEntity { + check(orderStatus?.statuses?.contains("sent-via-order-status") == true) { + "orderStatus.statuses: expected to contain 'sent-via-order-status', got '${orderStatus?.statuses}'" + } + return ResponseEntity(HttpStatus.OK) + } + + /** + * Only `item-status` present — verifies that `item-status.statuses` binds to + * the `itemStatus` param via `@ModelAttribute("item-status")`. + */ + override fun listOrdersItemStatusOnly(itemStatus: OrderStatus?): ResponseEntity { + check(itemStatus?.statuses?.contains("sent-via-item-status") == true) { + "itemStatus.statuses: expected to contain 'sent-via-item-status', got '${itemStatus?.statuses}'" + } + return ResponseEntity(HttpStatus.OK) + } +} diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/model/OrderStatus.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/model/OrderStatus.kt new file mode 100644 index 000000000000..3630ad4643d0 --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/main/kotlin/org/openapitools/model/OrderStatus.kt @@ -0,0 +1,31 @@ +package org.openapitools.model + +import java.util.Objects +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter +import com.fasterxml.jackson.annotation.Nulls +import jakarta.validation.constraints.DecimalMax +import jakarta.validation.constraints.DecimalMin +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern +import jakarta.validation.constraints.Size +import jakarta.validation.Valid + +/** + * + * @param statuses + */ +data class OrderStatus( + + @field:JsonSetter(nulls = Nulls.FAIL) + @get:JsonProperty("statuses") val statuses: kotlin.collections.Set? = null +) : java.io.Serializable { + + companion object { + private const val serialVersionUID: kotlin.Long = 1 + } +} + diff --git a/samples/server/petstore/kotlin-springboot-sort-validation/src/test/kotlin/org/openapitools/api/OrdersApiValidationTest.kt b/samples/server/petstore/kotlin-springboot-sort-validation/src/test/kotlin/org/openapitools/api/OrdersApiValidationTest.kt new file mode 100644 index 000000000000..1209444676cd --- /dev/null +++ b/samples/server/petstore/kotlin-springboot-sort-validation/src/test/kotlin/org/openapitools/api/OrdersApiValidationTest.kt @@ -0,0 +1,81 @@ +package org.openapitools.api + +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get + +/** + * Verifies the runtime behaviour of the `@ModelAttribute("baseName")` annotations + * generated onto [OrdersApi]: + * + * - **Valid Kotlin identifier** (`filter`) — `filter.statuses=X` is bound to the + * `filter` parameter, not to any other parameter of the same type. Two values + * are sent to also exercise multi-value `Set` binding. + * - **Hyphenated wire name** (`order-status`) — `order-status.statuses=X` is bound + * to the `orderStatus` parameter via the explicit `@ModelAttribute("order-status")` + * annotation. Without it the wire name cannot be matched to any Kotlin identifier + * and the parameter would remain `null`. + * - **Second hyphenated wire name** (`item-status`) — same as above for a second + * non-identifier name to confirm there is no cross-contamination between params. + * - **All three simultaneously** — each param receives its own distinct sentinel + * value, proving that the three `@ModelAttribute` scopes are independent. + * + * HTTP 200 confirms both that the request was accepted *and* that [OrdersApiImpl]'s + * internal assertions about the bound values passed. + * HTTP 500 would indicate a failed `check()` inside the impl, meaning a param was + * either not bound at all or routed to the wrong argument. + */ +@SpringBootTest +@AutoConfigureMockMvc +class OrdersApiValidationTest { + + @Autowired + lateinit var mockMvc: MockMvc + + // ── filter (valid Kotlin identifier) ───────────────────────────────────── + // Endpoint: GET /orders/filter-only — impl asserts filter.statuses contains both "active" and "pending" + + @Test + fun `filter deepObject param binds correctly to filter parameter`() { + mockMvc.get( + "${OrdersApi.BASE_PATH}${OrdersApi.PATH_LIST_ORDERS_FILTER_ONLY}?filter.statuses=active&filter.statuses=pending" + ).andExpect { status { isOk() } } + } + + // ── order-status (non-Kotlin-identifier, hyphenated) ────────────────────── + // Endpoint: GET /orders/order-status-only — impl asserts orderStatus.statuses contains "sent-via-order-status" + + @Test + fun `order-status deepObject param binds correctly to orderStatus parameter`() { + mockMvc.get( + "${OrdersApi.BASE_PATH}${OrdersApi.PATH_LIST_ORDERS_ORDER_STATUS_ONLY}?order-status.statuses=sent-via-order-status" + ).andExpect { status { isOk() } } + } + + // ── item-status (non-Kotlin-identifier, hyphenated) ─────────────────────── + // Endpoint: GET /orders/item-status-only — impl asserts itemStatus.statuses contains "sent-via-item-status" + + @Test + fun `item-status deepObject param binds correctly to itemStatus parameter`() { + mockMvc.get( + "${OrdersApi.BASE_PATH}${OrdersApi.PATH_LIST_ORDERS_ITEM_STATUS_ONLY}?item-status.statuses=sent-via-item-status" + ).andExpect { status { isOk() } } + } + + // ── all three simultaneously ────────────────────────────────────────────── + // Endpoint: GET /orders — impl asserts all three are non-null with their distinct sentinels. + // If any param were mis-routed (e.g. order-status landing in filter), the wrong-value + // check in the impl would fire and return HTTP 500. + + @Test + fun `all three deepObject params bind to their respective parameters`() { + mockMvc.get( + "${OrdersApi.BASE_PATH}${OrdersApi.PATH_LIST_ORDERS}?filter.statuses=sent-via-filter" + + "&order-status.statuses=sent-via-order-status" + + "&item-status.statuses=sent-via-item-status" + ).andExpect { status { isOk() } } + } +} diff --git a/samples/server/petstore/springboot-sort-validation/.openapi-generator/FILES b/samples/server/petstore/springboot-sort-validation/.openapi-generator/FILES index da2d4c6d3b44..6ef252928752 100644 --- a/samples/server/petstore/springboot-sort-validation/.openapi-generator/FILES +++ b/samples/server/petstore/springboot-sort-validation/.openapi-generator/FILES @@ -4,12 +4,14 @@ src/main/java/org/openapitools/OpenApiGeneratorApplication.java src/main/java/org/openapitools/RFC3339DateFormat.java src/main/java/org/openapitools/api/ApiUtil.java src/main/java/org/openapitools/api/NullableApi.java +src/main/java/org/openapitools/api/OrdersApi.java src/main/java/org/openapitools/api/PetApi.java src/main/java/org/openapitools/configuration/EnumConverterConfiguration.java src/main/java/org/openapitools/configuration/HomeController.java src/main/java/org/openapitools/configuration/ValidPageable.java src/main/java/org/openapitools/configuration/ValidSort.java src/main/java/org/openapitools/model/NullableModel.java +src/main/java/org/openapitools/model/OrderStatus.java src/main/java/org/openapitools/model/Pet.java src/main/java/org/openapitools/model/PetSort.java src/main/java/org/openapitools/model/PetSortEnum.java diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/OrdersApi.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/OrdersApi.java new file mode 100644 index 000000000000..f5fca692036a --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/OrdersApi.java @@ -0,0 +1,94 @@ +/* + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.23.0-SNAPSHOT). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +package org.openapitools.api; + +import org.springframework.lang.Nullable; +import org.openapitools.model.OrderStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import jakarta.annotation.Generated; + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +@Validated +@RequestMapping("${openapi.openAPIPetstoreSortValidationTest.base-path:/v2}") +public interface OrdersApi { + + String PATH_LIST_ORDERS = "/orders"; + /** + * GET /orders : List orders filtered by status — tests @ModelAttribute binding for model query params + * + * @param filter Filter by order status (valid Kotlin identifier) (optional) + * @param orderStatus Filter by order status (non-Kotlin-identifier wire name, hyphenated) (optional) + * @param itemStatus Filter by item status (non-Kotlin-identifier wire name, hyphenated) (optional) + * @return OK (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = OrdersApi.PATH_LIST_ORDERS + ) + ResponseEntity listOrders( + @Valid @Nullable OrderStatus filter, + @Valid @Nullable OrderStatus orderStatus, + @Valid @Nullable OrderStatus itemStatus + ); + + + String PATH_LIST_ORDERS_FILTER_ONLY = "/orders/filter-only"; + /** + * GET /orders/filter-only : List orders with only the filter param — impl asserts filter is bound correctly + * + * @param filter Filter by order status (valid Kotlin identifier, isolated test) (optional) + * @return OK — impl assertion passed (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = OrdersApi.PATH_LIST_ORDERS_FILTER_ONLY + ) + ResponseEntity listOrdersFilterOnly( + @Valid @Nullable OrderStatus filter + ); + + + String PATH_LIST_ORDERS_ITEM_STATUS_ONLY = "/orders/item-status-only"; + /** + * GET /orders/item-status-only : List orders with only the item-status param — impl asserts item-status is bound correctly + * + * @param itemStatus Filter by item status (non-Kotlin-identifier wire name, isolated test) (optional) + * @return OK — impl assertion passed (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = OrdersApi.PATH_LIST_ORDERS_ITEM_STATUS_ONLY + ) + ResponseEntity listOrdersItemStatusOnly( + @Valid @Nullable OrderStatus itemStatus + ); + + + String PATH_LIST_ORDERS_ORDER_STATUS_ONLY = "/orders/order-status-only"; + /** + * GET /orders/order-status-only : List orders with only the order-status param — impl asserts order-status is bound correctly + * + * @param orderStatus Filter by order status (non-Kotlin-identifier wire name, isolated test) (optional) + * @return OK — impl assertion passed (status code 200) + */ + @RequestMapping( + method = RequestMethod.GET, + value = OrdersApi.PATH_LIST_ORDERS_ORDER_STATUS_ONLY + ) + ResponseEntity listOrdersOrderStatusOnly( + @Valid @Nullable OrderStatus orderStatus + ); + +} diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/OrdersApiController.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/OrdersApiController.java new file mode 100644 index 000000000000..aaccb64e7b5a --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/api/OrdersApiController.java @@ -0,0 +1,57 @@ +package org.openapitools.api; + +import org.springframework.lang.Nullable; +import org.openapitools.model.OrderStatus; + + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.context.request.NativeWebRequest; + +import jakarta.validation.constraints.*; +import jakarta.validation.Valid; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import jakarta.annotation.Generated; + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +@Controller +public class OrdersApiController implements OrdersApi { + + @Override + public ResponseEntity listOrders( + @Valid @Nullable OrderStatus filter, + @Valid @Nullable OrderStatus orderStatus, + @Valid @Nullable OrderStatus itemStatus) { + return new ResponseEntity<>(HttpStatus.OK); + } + + @Override + public ResponseEntity listOrdersFilterOnly(@Valid @Nullable OrderStatus filter) { + return new ResponseEntity<>(HttpStatus.OK); + } + + @Override + public ResponseEntity listOrdersOrderStatusOnly(@Valid @Nullable OrderStatus orderStatus) { + return new ResponseEntity<>(HttpStatus.OK); + } + + @Override + public ResponseEntity listOrdersItemStatusOnly(@Valid @Nullable OrderStatus itemStatus) { + return new ResponseEntity<>(HttpStatus.OK); + } + +} diff --git a/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/model/OrderStatus.java b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/model/OrderStatus.java new file mode 100644 index 000000000000..f53ab38dd623 --- /dev/null +++ b/samples/server/petstore/springboot-sort-validation/src/main/java/org/openapitools/model/OrderStatus.java @@ -0,0 +1,96 @@ +package org.openapitools.model; + +import java.net.URI; +import java.util.Objects; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; +import org.springframework.lang.Nullable; +import org.openapitools.jackson.nullable.JsonNullable; +import java.io.Serializable; +import java.time.OffsetDateTime; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; + + +import java.util.*; +import jakarta.annotation.Generated; + +/** + * OrderStatus + */ + +@Generated(value = "org.openapitools.codegen.languages.SpringCodegen", comments = "Generator version: 7.23.0-SNAPSHOT") +public class OrderStatus implements Serializable { + + private static final long serialVersionUID = 1L; + + private Set statuses = new LinkedHashSet<>(); + + public OrderStatus statuses(Set statuses) { + this.statuses = statuses; + return this; + } + + public OrderStatus addStatusesItem(String statusesItem) { + if (this.statuses == null) { + this.statuses = new LinkedHashSet<>(); + } + this.statuses.add(statusesItem); + return this; + } + + /** + * Get statuses + * @return statuses + */ + + @JsonProperty("statuses") + public Set getStatuses() { + return statuses; + } + + @JsonDeserialize(as = LinkedHashSet.class) + @JsonProperty("statuses") + public void setStatuses(Set statuses) { + this.statuses = statuses; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + OrderStatus orderStatus = (OrderStatus) o; + return Objects.equals(this.statuses, orderStatus.statuses); + } + + @Override + public int hashCode() { + return Objects.hash(statuses); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append("class OrderStatus {\n"); + sb.append(" statuses: ").append(toIndentedString(statuses)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(@Nullable Object o) { + return o == null ? "null" : o.toString().replace("\n", "\n "); + } +} + diff --git a/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml b/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml index 5763ce44186b..d6d7518ff4c7 100644 --- a/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml +++ b/samples/server/petstore/springboot-sort-validation/src/main/resources/openapi.yaml @@ -8,6 +8,8 @@ servers: tags: - description: Everything about your Pets name: pet +- description: Order filtering endpoints + name: orders paths: /pet/findByStatusWithSort: get: @@ -757,6 +759,116 @@ paths: - application/json x-tags: - tag: pet + /orders: + get: + operationId: listOrders + parameters: + - description: Filter by order status (valid Kotlin identifier) + explode: true + in: query + name: filter + required: false + schema: + $ref: "#/components/schemas/OrderStatus" + style: deepObject + - description: "Filter by order status (non-Kotlin-identifier wire name, hyphenated)" + explode: true + in: query + name: order-status + required: false + schema: + $ref: "#/components/schemas/OrderStatus" + style: deepObject + - description: "Filter by item status (non-Kotlin-identifier wire name, hyphenated)" + explode: true + in: query + name: item-status + required: false + schema: + $ref: "#/components/schemas/OrderStatus" + style: deepObject + responses: + "200": + description: OK + summary: List orders filtered by status — tests @ModelAttribute binding for + model query params + tags: + - orders + x-accepts: + - application/json + x-tags: + - tag: orders + /orders/filter-only: + get: + operationId: listOrdersFilterOnly + parameters: + - description: "Filter by order status (valid Kotlin identifier, isolated test)" + explode: true + in: query + name: filter + required: false + schema: + $ref: "#/components/schemas/OrderStatus" + style: deepObject + responses: + "200": + description: OK — impl assertion passed + summary: List orders with only the filter param — impl asserts filter is bound + correctly + tags: + - orders + x-accepts: + - application/json + x-tags: + - tag: orders + /orders/order-status-only: + get: + operationId: listOrdersOrderStatusOnly + parameters: + - description: "Filter by order status (non-Kotlin-identifier wire name, isolated\ + \ test)" + explode: true + in: query + name: order-status + required: false + schema: + $ref: "#/components/schemas/OrderStatus" + style: deepObject + responses: + "200": + description: OK — impl assertion passed + summary: List orders with only the order-status param — impl asserts order-status + is bound correctly + tags: + - orders + x-accepts: + - application/json + x-tags: + - tag: orders + /orders/item-status-only: + get: + operationId: listOrdersItemStatusOnly + parameters: + - description: "Filter by item status (non-Kotlin-identifier wire name, isolated\ + \ test)" + explode: true + in: query + name: item-status + required: false + schema: + $ref: "#/components/schemas/OrderStatus" + style: deepObject + responses: + "200": + description: OK — impl assertion passed + summary: List orders with only the item-status param — impl asserts item-status + is bound correctly + tags: + - orders + x-accepts: + - application/json + x-tags: + - tag: orders /nullable/check-required-only: post: operationId: checkRequiredOnly @@ -881,6 +993,14 @@ components: - "createdAt,asc" - "createdAt,desc" type: string + OrderStatus: + properties: + statuses: + items: + type: string + type: array + uniqueItems: true + type: object Pet: example: id: 0