diff --git a/modules/swagger-annotations/src/main/java/io/swagger/v3/oas/annotations/Operation.java b/modules/swagger-annotations/src/main/java/io/swagger/v3/oas/annotations/Operation.java index e307777104..ff7a6993dd 100644 --- a/modules/swagger-annotations/src/main/java/io/swagger/v3/oas/annotations/Operation.java +++ b/modules/swagger-annotations/src/main/java/io/swagger/v3/oas/annotations/Operation.java @@ -148,4 +148,17 @@ * @return whether or not to ignore JsonView annotations */ boolean ignoreJsonView() default false; + + /** + * Specifies the Bean Validation group associated with this operation. + * Validation groups (as defined by JSR-380 Bean Validation) allow a single DTO + * to be reused across multiple operations by specifying which group is active + * for a given operation (e.g., Create.class for POST, Update.class for PUT). + * When specified, this information can be used to conditionally display only + * the schema fields relevant to the active group in the generated OpenAPI documentation. + * + * @since 2.2.54 + * @return the validation group for this operation + */ + Class groups() default Void.class; } diff --git a/modules/swagger-annotations/src/main/java/io/swagger/v3/oas/annotations/media/Schema.java b/modules/swagger-annotations/src/main/java/io/swagger/v3/oas/annotations/media/Schema.java index 775ac5ee07..bfb6d47f8b 100644 --- a/modules/swagger-annotations/src/main/java/io/swagger/v3/oas/annotations/media/Schema.java +++ b/modules/swagger-annotations/src/main/java/io/swagger/v3/oas/annotations/media/Schema.java @@ -650,4 +650,17 @@ enum SchemaResolution { * */ SchemaResolution schemaResolution() default SchemaResolution.AUTO; + + /** + * Provides the validation groups associated with this schema property. + * Validation groups (as defined by JSR-380 Bean Validation) allow a single DTO + * to represent different shapes depending on the operation being performed + * (e.g., Create vs Update). When specified, this information can be used to + * conditionally include or hide fields in the generated OpenAPI documentation + * based on the active validation group of the operation. + * + * @since 2.2.54 + * @return the validation groups for this schema property + */ + Class[] groups() default {}; } diff --git a/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java b/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java index b281d34855..d3766ce981 100644 --- a/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java +++ b/modules/swagger-core/src/main/java/io/swagger/v3/core/jackson/ModelResolver.java @@ -3196,6 +3196,13 @@ protected void resolveSchemaMembers(Schema schema, Annotated a, Annotation[] ann if (StringUtils.isNotBlank(description)) { schema.description(description); } + if (schemaAnnotation != null && schemaAnnotation.groups() != null && schemaAnnotation.groups().length > 0) { + java.util.List groupNames = new java.util.ArrayList<>(); + for (Class group : schemaAnnotation.groups()) { + groupNames.add(group.getSimpleName()); + } + schema.groups(groupNames); + } String title = resolveTitle(a, annotations, schemaAnnotation); if (StringUtils.isNotBlank(title)) { schema.title(title); diff --git a/modules/swagger-core/src/main/java/io/swagger/v3/core/util/AnnotationsUtils.java b/modules/swagger-core/src/main/java/io/swagger/v3/core/util/AnnotationsUtils.java index 372bbcd5f5..dc1b503064 100644 --- a/modules/swagger-core/src/main/java/io/swagger/v3/core/util/AnnotationsUtils.java +++ b/modules/swagger-core/src/main/java/io/swagger/v3/core/util/AnnotationsUtils.java @@ -130,6 +130,7 @@ public static boolean hasSchemaAnnotation(io.swagger.v3.oas.annotations.media.Sc && schema.additionalProperties().equals(io.swagger.v3.oas.annotations.media.Schema.AdditionalPropertiesValue.USE_ADDITIONAL_PROPERTIES_ANNOTATION) && schema.additionalPropertiesSchema().equals(Void.class) && schema.examples().length == 0 + && schema.groups().length == 0 ) { return false; } @@ -661,6 +662,14 @@ public static Optional getSchemaFromAnnotation( if (!Schema.SchemaResolution.DEFAULT.equals(schemaResolution)) { schemaObject = existingSchema; } else { + // apply groups before early return so the property schema carries group info + if (schema != null && schema.groups().length > 0) { + List groupNames = new java.util.ArrayList<>(); + for (Class group : schema.groups()) { + groupNames.add(group.getSimpleName()); + } + existingSchema.setGroups(groupNames); + } return Optional.of(existingSchema); } } @@ -923,6 +932,14 @@ public static Optional getSchemaFromAnnotation( } } + if (schema.groups().length > 0) { + List groupNames = new java.util.ArrayList<>(); + for (Class group : schema.groups()) { + groupNames.add(group.getSimpleName()); + } + schemaObject.setGroups(groupNames); + } + return Optional.of(schemaObject); } @@ -2885,6 +2902,14 @@ public SchemaResolution schemaResolution() { return master.schemaResolution(); } + @Override + public Class[] groups() { + if (master.groups().length > 0 || patch.groups().length == 0) { + return master.groups(); + } + return patch.groups(); + } + }; return (io.swagger.v3.oas.annotations.media.Schema) schema; diff --git a/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/ValidationGroupTest.java b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/ValidationGroupTest.java new file mode 100644 index 0000000000..d11871a0e5 --- /dev/null +++ b/modules/swagger-core/src/test/java/io/swagger/v3/core/resolving/ValidationGroupTest.java @@ -0,0 +1,83 @@ +package io.swagger.v3.core.resolving; + +import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.oas.annotations.media.Schema; +import org.testng.annotations.Test; + +import java.util.List; +import java.util.Map; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; + +/** + * Tests for issue #4928 - Validation Grouping support in @Schema. + * Verifies that groups() in @Schema annotation are correctly resolved + * into the Schema model object's groups field. + */ +public class ValidationGroupTest { + + // --- Marker interfaces representing validation groups --- + interface Create {} + interface Update {} + + // --- Test DTO class --- + static class UserDto { + + @Schema(description = "Primary Key ID", groups = { Update.class }) + public Integer id; + + @Schema(description = "Full name", groups = { Create.class, Update.class }) + public String name; + + @Schema(description = "Email address") // no groups -> should remain null + public String email; + } + + @Test(description = "groups() on @Schema with a single group should be resolved into the model") + public void testSchemaGroupsSingleGroup() { + final Map schemas = + ModelConverters.getInstance().readAll(UserDto.class); + final io.swagger.v3.oas.models.media.Schema model = schemas.get("UserDto"); + assertNotNull(model, "UserDto schema must be resolved"); + + final Map properties = model.getProperties(); + assertNotNull(properties, "properties must not be null"); + + final io.swagger.v3.oas.models.media.Schema idSchema = properties.get("id"); + assertNotNull(idSchema, "id property must exist"); + assertNotNull(idSchema.getGroups(), "id groups must not be null"); + assertEquals(idSchema.getGroups().size(), 1); + assertTrue(idSchema.getGroups().contains("Update"), "id should belong to Update group"); + } + + @Test(description = "groups() on @Schema with multiple groups should all be resolved") + public void testSchemaGroupsMultipleGroups() { + final Map schemas = + ModelConverters.getInstance().readAll(UserDto.class); + final io.swagger.v3.oas.models.media.Schema model = schemas.get("UserDto"); + final io.swagger.v3.oas.models.media.Schema nameSchema = (io.swagger.v3.oas.models.media.Schema) model.getProperties().get("name"); + + assertNotNull(nameSchema, "name property must exist"); + assertNotNull(nameSchema.getGroups(), "name groups must not be null"); + + final List groups = nameSchema.getGroups(); + assertEquals(groups.size(), 2, "name should have exactly 2 groups"); + assertTrue(groups.contains("Create"), "name should belong to Create group"); + assertTrue(groups.contains("Update"), "name should belong to Update group"); + } + + @Test(description = "No groups() on @Schema should leave groups field as null") + public void testSchemaNoGroupsRemainsNull() { + final Map schemas = + ModelConverters.getInstance().readAll(UserDto.class); + final io.swagger.v3.oas.models.media.Schema model = schemas.get("UserDto"); + final io.swagger.v3.oas.models.media.Schema emailSchema = (io.swagger.v3.oas.models.media.Schema) model.getProperties().get("email"); + + assertNotNull(emailSchema, "email property must exist"); + assertNull(emailSchema.getGroups(), + "email groups should be null when no groups() is specified on @Schema"); + } +} diff --git a/modules/swagger-core/src/test/java/io/swagger/v3/core/util/AnnotationsUtilsTest.java b/modules/swagger-core/src/test/java/io/swagger/v3/core/util/AnnotationsUtilsTest.java index 447f22da71..3d575cdef5 100644 --- a/modules/swagger-core/src/test/java/io/swagger/v3/core/util/AnnotationsUtilsTest.java +++ b/modules/swagger-core/src/test/java/io/swagger/v3/core/util/AnnotationsUtilsTest.java @@ -742,6 +742,11 @@ public String _const() { public SchemaResolution schemaResolution() { return schemaAnnotation.schemaResolution(); } + + @Override + public Class[] groups() { + return schemaAnnotation.groups(); + } }; Optional schema = diff --git a/modules/swagger-jaxrs2/src/main/java/io/swagger/v3/jaxrs2/Reader.java b/modules/swagger-jaxrs2/src/main/java/io/swagger/v3/jaxrs2/Reader.java index b15115aaf7..6d0982c199 100644 --- a/modules/swagger-jaxrs2/src/main/java/io/swagger/v3/jaxrs2/Reader.java +++ b/modules/swagger-jaxrs2/src/main/java/io/swagger/v3/jaxrs2/Reader.java @@ -1476,6 +1476,15 @@ protected void setOperationObjectFromApiOperationAnnotation( } } } + + if (apiOperation.groups() != Void.class) { + String groupName = apiOperation.groups().getSimpleName(); + if (openapi31) { + operation.addExtension31("x-groups", groupName); + } else { + operation.addExtension("x-groups", groupName); + } + } } protected String getOperationId(String operationId) { diff --git a/modules/swagger-jaxrs2/src/test/java/io/swagger/v3/jaxrs2/OperationGroupsTest.java b/modules/swagger-jaxrs2/src/test/java/io/swagger/v3/jaxrs2/OperationGroupsTest.java new file mode 100644 index 0000000000..045a5e5144 --- /dev/null +++ b/modules/swagger-jaxrs2/src/test/java/io/swagger/v3/jaxrs2/OperationGroupsTest.java @@ -0,0 +1,88 @@ +package io.swagger.v3.jaxrs2; + +import io.swagger.v3.jaxrs2.Reader; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.PathItem; +import org.testng.annotations.Test; + +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; + +/** + * Tests for issue #4928 - Validation Grouping support in @Operation. + * Verifies that groups() in @Operation is serialized as x-groups extension. + */ +public class OperationGroupsTest { + + // --- Validation group marker interfaces --- + interface Create {} + interface Update {} + + // --- Test JAX-RS resource --- + @Path("/users") + static class UserResource { + + @POST + @Operation(summary = "Create user", groups = Create.class) + public void create() {} + + @PUT + @Operation(summary = "Update user", groups = Update.class) + public void update() {} + + @javax.ws.rs.GET + @Operation(summary = "Get user") // no groups -> no x-groups extension + public void get() {} + } + + @Test(description = "@Operation groups() should be written as x-groups extension on POST operation") + public void testOperationGroupsCreate() { + Reader reader = new Reader(new OpenAPI()); + OpenAPI openAPI = reader.read(UserResource.class); + + assertNotNull(openAPI.getPaths(), "paths must not be null"); + PathItem pathItem = openAPI.getPaths().get("/users"); + assertNotNull(pathItem, "/users path must exist"); + assertNotNull(pathItem.getPost(), "POST operation must exist"); + + Object groupsExt = pathItem.getPost().getExtensions().get("x-groups"); + assertNotNull(groupsExt, "x-groups extension must be set on POST"); + assertEquals(groupsExt.toString(), "Create", + "POST should have x-groups=Create"); + } + + @Test(description = "@Operation groups() should be written as x-groups extension on PUT operation") + public void testOperationGroupsUpdate() { + Reader reader = new Reader(new OpenAPI()); + OpenAPI openAPI = reader.read(UserResource.class); + + PathItem pathItem = openAPI.getPaths().get("/users"); + assertNotNull(pathItem.getPut(), "PUT operation must exist"); + + Object groupsExt = pathItem.getPut().getExtensions().get("x-groups"); + assertNotNull(groupsExt, "x-groups extension must be set on PUT"); + assertEquals(groupsExt.toString(), "Update", + "PUT should have x-groups=Update"); + } + + @Test(description = "No groups() on @Operation should not add x-groups extension") + public void testOperationNoGroupsHasNoExtension() { + Reader reader = new Reader(new OpenAPI()); + OpenAPI openAPI = reader.read(UserResource.class); + + PathItem pathItem = openAPI.getPaths().get("/users"); + assertNotNull(pathItem.getGet(), "GET operation must exist"); + + // Either no extensions at all, or x-groups not present + if (pathItem.getGet().getExtensions() != null) { + assertNull(pathItem.getGet().getExtensions().get("x-groups"), + "GET should NOT have x-groups when no groups() is set"); + } + } +} diff --git a/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/Schema.java b/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/Schema.java index ebe344bda3..dd5fb6f559 100644 --- a/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/Schema.java +++ b/modules/swagger-models/src/main/java/io/swagger/v3/oas/models/media/Schema.java @@ -112,6 +112,11 @@ public String toString() { protected List _enum = null; private Discriminator discriminator = null; + /** + * @since 2.2.54 + */ + private List groups = null; + @JsonIgnore private boolean exampleSetFlag; @JsonIgnore @@ -2223,6 +2228,32 @@ public Schema extensions(java.util.Map extensions) { return this; } + /** + * Returns the validation groups associated with this schema property. + * Group names are stored as simple class name strings (e.g. "Create", "Update"). + * + * @since 2.2.54 + * @return list of validation group names, or null if not specified + */ + public List getGroups() { + return groups; + } + + /** + * @since 2.2.54 + */ + public void setGroups(List groups) { + this.groups = groups; + } + + /** + * @since 2.2.54 + */ + public Schema groups(List groups) { + this.groups = groups; + return this; + } + @Override public String toString() { StringBuilder sb = new StringBuilder();