add validation to description based on the validation annotations#47375
add validation to description based on the validation annotations#47375edewit wants to merge 10 commits intokeycloak:mainfrom
Conversation
fixes: keycloak#47371 Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
There was a problem hiding this comment.
Pull request overview
This PR enhances the Admin Client v2 OpenAPI generation so that Jakarta Validation (and a couple of Keycloak custom constraints) are reflected in the published schema, primarily by appending constraint information to field descriptions and additionally by setting some machine-readable schema constraints.
Changes:
- Introduces
ValidationAnnotationScannerto read validation annotations (field, type-use, and select class-level) and translate them into OpenAPI schema properties and/or description text. - Updates
OASModelFilterto invoke the scanner while populating schema property descriptions, appending aValidation: ...suffix and setting basic schema constraints (e.g.,minLength,pattern,minimum).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| rest/admin-v2/rest/src/main/java/org/keycloak/admin/internal/openapi/ValidationAnnotationScanner.java | New scanner that inspects Jandex metadata for validation annotations and produces schema property updates + human-readable validation text. |
| rest/admin-v2/rest/src/main/java/org/keycloak/admin/internal/openapi/OASModelFilter.java | Integrates the scanner into OpenAPI model filtering, combining field-level and class-level validations into property descriptions and applying schema constraints. |
| // @NotEmpty implies minItems: 1 for arrays/collections | ||
| AnnotationInstance notEmpty = getFieldAnnotation(field, NOT_EMPTY); | ||
| if (notEmpty != null && propertySchema.getMinItems() == null) { | ||
| propertySchema.setMinItems(1); |
There was a problem hiding this comment.
@NotEmpty is translated to minItems=1 unconditionally, but @NotEmpty can also apply to String/CharSequence where the equivalent OpenAPI constraint is minLength=1. Please branch based on the schema/field type (string vs array/collection vs map) to avoid emitting invalid or misleading OpenAPI constraints.
| // @NotEmpty implies minItems: 1 for arrays/collections | |
| AnnotationInstance notEmpty = getFieldAnnotation(field, NOT_EMPTY); | |
| if (notEmpty != null && propertySchema.getMinItems() == null) { | |
| propertySchema.setMinItems(1); | |
| // @NotEmpty: behavior depends on schema type | |
| AnnotationInstance notEmpty = getFieldAnnotation(field, NOT_EMPTY); | |
| if (notEmpty != null) { | |
| Schema.SchemaType schemaType = propertySchema.getType(); | |
| if (schemaType == Schema.SchemaType.STRING) { | |
| if (propertySchema.getMinLength() == null) { | |
| propertySchema.setMinLength(1); | |
| } | |
| } else if (schemaType == Schema.SchemaType.ARRAY) { | |
| if (propertySchema.getMinItems() == null) { | |
| propertySchema.setMinItems(1); | |
| } | |
| } else if (schemaType == Schema.SchemaType.OBJECT) { | |
| if (propertySchema.getMinProperties() == null) { | |
| propertySchema.setMinProperties(1); | |
| } | |
| } |
...n-v2/rest/src/main/java/org/keycloak/admin/internal/openapi/ValidationAnnotationScanner.java
Show resolved
Hide resolved
| // Check for @URL | ||
| AnnotationInstance url = getFieldAnnotation(field, URL); | ||
| if (url != null) { | ||
| constraints.add("must be a valid URL"); | ||
| } |
There was a problem hiding this comment.
buildDescription claims to include validation group context, but group context is only prepended for @NotBlank/@NotNull/@NotEmpty (and class-level constraints). For consistency (and to match the Javadoc), either prepend getGroupContext(...) for the other supported constraints too (@URL, @Size, @Pattern, @Min, @Max, @Valid), or adjust the documentation to reflect the current behavior.
| // @NotBlank implies minLength: 1 for strings (only for field-level, not type args) | ||
| AnnotationInstance notBlank = getFieldAnnotation(field, NOT_BLANK); | ||
| if (notBlank != null && propertySchema.getMinLength() == null) { | ||
| propertySchema.setMinLength(1); | ||
| } |
There was a problem hiding this comment.
applySchemaProperties applies machine-readable constraints (e.g., minLength) even when the underlying validation annotation is restricted to specific validation groups. This can make the schema overly strict for operations where the constraint does not apply (e.g., BaseClientRepresentation.clientId is @NotBlank(groups = CreateClient.class) but would still get minLength=1 globally). Consider only applying machine-readable constraints when groups is empty/default, and rely on the human-readable description for group-scoped constraints (or otherwise encode the group scope without tightening the base schema).
...n-v2/rest/src/main/java/org/keycloak/admin/internal/openapi/ValidationAnnotationScanner.java
Outdated
Show resolved
Hide resolved
michalvavrik
left a comment
There was a problem hiding this comment.
I think it would be nice to have this tested. I realize that if something changes (like field is removed or named) then tests would break, but I'd really prefer if for each validation constraint we already use, there was one description test, e.g. added to OpenApiDistTest. Or if you prefer, you can create a unit test and test ValidationAnnotationScanner in isolation with manually created index and test classes.
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
SmallRye handles this Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
| } | ||
|
|
||
| private String getOperationContext(String groupSimpleName) { | ||
| for (Map.Entry<String, String> entry : OPERATION_PREFIXES.entrySet()) { |
There was a problem hiding this comment.
Do I understand it right that at this place, you cannot reference org.keycloak.representations.admin.v2.validation.PutClient, org.keycloak.representations.admin.v2.validation.PatchClient and org.keycloak.representations.admin.v2.validation.CreateClient directly or are you trying to make this generic?
I don't really have hard opinion on this, but personally, if I were writing it and I could reference org.keycloak.representations.admin.v2.validation, I'd just hardcode known groups and fail build if we hit unknown options. Thus whoever adds a new group, would see failing tests and link group to description like "on patch".
But it is just opinion, if you like that, fine.
There was a problem hiding this comment.
I'm not completely sure what you mean by that, but added this error to the scanner in this commit 8bfc3c3
| private static final DotName PATTERN = DotName.createSimple("jakarta.validation.constraints.Pattern"); | ||
| private static final DotName MIN = DotName.createSimple("jakarta.validation.constraints.Min"); | ||
| private static final DotName MAX = DotName.createSimple("jakarta.validation.constraints.Max"); | ||
| private static final DotName VALID = DotName.createSimple("jakarta.validation.Valid"); |
There was a problem hiding this comment.
Can you clarify what do you prefer constants over DotName.createSimple(Valid.class)?
There was a problem hiding this comment.
I don't have an opinion about it the class overload is new could change it to that
There was a problem hiding this comment.
that's just my preference, I'll leave it to you to decide.
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
|
There's already this logic on the SmallRye side - https://github.com/smallrye/smallrye-open-api/blob/main/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/BeanValidationScanner.java#L32 It'd be good if we could leverage it. I'd like to avoid any maintenance of this custom code. |
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
|
Right @mabartos that's why I removed my custom schema property logic for standard Jakarta Validation annotations (@notblank, @notempty, @SiZe, @pattern, @min, @max) - |
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Unreported flaky test detectedIf the flaky tests below are affected by the changes, please review and update the changes accordingly. Otherwise, a maintainer should report the flaky tests prior to merging the PR. org.keycloak.testsuite.forms.BruteForceTest#testExceedMaxTemporaryLockoutsKeycloak CI - Forms IT (chrome) |
.../rest/src/test/java/org/keycloak/admin/internal/openapi/ValidationAnnotationScannerTest.java
Show resolved
Hide resolved
| // Check for @URL | ||
| AnnotationInstance url = getFieldAnnotation(field, URL); | ||
| if (url != null) { | ||
| constraints.add("must be a valid URL"); | ||
| } | ||
|
|
||
| // Check for @Size | ||
| AnnotationInstance size = getFieldAnnotation(field, SIZE); | ||
| if (size != null) { | ||
| String sizeDesc = buildSizeDescription(size); | ||
| if (sizeDesc != null) { | ||
| constraints.add(sizeDesc); | ||
| } | ||
| } |
There was a problem hiding this comment.
buildDescription adds validation group context only for @NotBlank/@NotNull/@NotEmpty, but not for other constraints like @Size, @Pattern, @Min/@Max, @URL, etc. This makes group-scoped constraints for those annotations lose their "on create/update/patch" context and also contradicts the method Javadoc. Consider prefixing every constraint description with getGroupContext(annotation) consistently.
...n-v2/rest/src/main/java/org/keycloak/admin/internal/openapi/ValidationAnnotationScanner.java
Show resolved
Hide resolved
@edewit I don't understand. Did you remove even more custom logic for the annotations? Because I still see these are still handled in the custom code: https://github.com/keycloak/keycloak/pull/47375/changes#diff-b665b37f69099ad25bfc076ec3551293d93f67e7b724a41a7b374fa29b4aedb8R166 Have you checked if could reuse the https://github.com/smallrye/smallrye-open-api/blob/main/core/src/main/java/io/smallrye/openapi/runtime/scanner/dataobject/BeanValidationScanner.java#L32? Thanks |
|
@mabartos yes, like I said I removed it, it no longer sets the constraints (minLength, maxLength, pattern, minimum, etc.) that's done by SmallRye's |
| AnnotationInstance notBlank = getFieldAnnotation(field, NOT_BLANK); | ||
| if (notBlank != null) { | ||
| String context = getGroupContext(notBlank); | ||
| constraints.add(context + "must not be blank"); |
There was a problem hiding this comment.
We should not be hardcoding the individual validations in the OAS filter, that would not scale well. The logic needs to be universal. I'd propose scanning the annotations and if we detect it's a validation annotation (based on the presence of @Constraint), we'd extract the message field. We'd just need to figure out how to extract the message as it's sometimes just a placeholder like {org.hibernate.validator.constraints.URL.message}.
This is a blocker from my perspective.
| DotName.createSimple(CreateClient.class), "on create", | ||
| DotName.createSimple(PutClient.class), "on update", | ||
| DotName.createSimple(PatchClient.class), "on patch" |
There was a problem hiding this comment.
We should consider renaming the groups to not include Client in the names, again to be universal. But something we can do in a follow-up.
| * <li>Adding validation group context (e.g., "on create:", "on update:")</li> | ||
| * </ul> | ||
| */ | ||
| public class ValidationAnnotationScanner { |
There was a problem hiding this comment.
I'd prefer this to be included in the OASModelFilter, I don't think it needs a dedicated class, it just wraps some basically static methods, it doesn't hold a state. But that's not blocking, it might be subjective.
kept special processing for constraints that have parameters Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
...n-v2/rest/src/main/java/org/keycloak/admin/internal/openapi/ValidationAnnotationScanner.java
Outdated
Show resolved
Hide resolved
| AnnotationValue messageValue = annotation.value("message"); | ||
| String messageTemplate = messageValue != null ? messageValue.asString() : getDefaultMessage(annotation.name()); |
There was a problem hiding this comment.
I think we can safely rely the message is alway present. There's not much point in exposing a default message anyway.
There was a problem hiding this comment.
no, the message is null it only returns explicitly declared annotation values, not default values
There was a problem hiding this comment.
You mean the index doesn't capture the default field values on annotations? That will be certainly a problem, we use that in custom validations too.
There was a problem hiding this comment.
it does, just use the method where you pass the index and you will get the defaults
| if (simpleName.contains("uuid")) { | ||
| return "uuid"; | ||
| } | ||
| if (simpleName.contains("secret")) { | ||
| return "secret"; | ||
| } |
There was a problem hiding this comment.
As mentioned above, the filter should be unaware of specific validators if possible. If the validation needs to be on the class level, we could add a new field to that annotation hinting what field in the representation it belongs to. I.e. the mapping should be captured in the annotation, not hardcoded in the filter.
| * @param fieldName the name of the field | ||
| * @param propertySchema the OpenAPI schema to modify | ||
| */ | ||
| public void applySchemaProperties(ClassInfo classInfo, String fieldName, Schema propertySchema) { |
There was a problem hiding this comment.
Why do we need this? Isn't description enough?
...n-v2/rest/src/main/java/org/keycloak/admin/internal/openapi/ValidationAnnotationScanner.java
Outdated
Show resolved
Hide resolved
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
|
|
||
| // Handle @Valid (nested validation) - not a constraint but useful to document | ||
| if (VALID.equals(annotationName)) { | ||
| constraints.add("nested fields are validated"); |
There was a problem hiding this comment.
All fields are always implicitly validated, we don't need this message.
| String affectedField = annotation.name().withoutPackagePrefix().toLowerCase(); | ||
| if (affectedField != null) { | ||
| fieldDescriptions.put(affectedField, context + message); | ||
| } |
There was a problem hiding this comment.
As hinted in #47375 (comment), we'll probably need to add some dedicated field to the class level validation annotations to let the filter know which field name it belongs to. Class level annotations are typically our custom ones, so it should be fine. Other validations coming from Jakarta/Hibernate are field level, we don't need it there.
fixes: #47371
Signed-off-by: Erik Jan de Wit erikjan.dewit@gmail.com