Skip to content

add validation to description based on the validation annotations#47375

Open
edewit wants to merge 10 commits intokeycloak:mainfrom
edewit:issue-47371
Open

add validation to description based on the validation annotations#47375
edewit wants to merge 10 commits intokeycloak:mainfrom
edewit:issue-47371

Conversation

@edewit
Copy link
Copy Markdown
Contributor

@edewit edewit commented Mar 23, 2026

fixes: #47371
Signed-off-by: Erik Jan de Wit erikjan.dewit@gmail.com

fixes: keycloak#47371
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ValidationAnnotationScanner to read validation annotations (field, type-use, and select class-level) and translate them into OpenAPI schema properties and/or description text.
  • Updates OASModelFilter to invoke the scanner while populating schema property descriptions, appending a Validation: ... 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.

Comment on lines +78 to +81
// @NotEmpty implies minItems: 1 for arrays/collections
AnnotationInstance notEmpty = getFieldAnnotation(field, NOT_EMPTY);
if (notEmpty != null && propertySchema.getMinItems() == null) {
propertySchema.setMinItems(1);
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Suggested change
// @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);
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +164 to +168
// Check for @URL
AnnotationInstance url = getFieldAnnotation(field, URL);
if (url != null) {
constraints.add("must be a valid URL");
}
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +76
// @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);
}
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

@michalvavrik michalvavrik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

edewit added 2 commits March 24, 2026 11:50
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()) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not completely sure what you mean by that, but added this error to the scanner in this commit 8bfc3c3

Copy link
Copy Markdown
Member

@michalvavrik michalvavrik Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not completely sure what you mean by that, but added this error to the scanner in this commit 8bfc3c3

8bfc3c3 is exactly what I meant. it wasn't there when I reviewed. Thanks

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");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you clarify what do you prefer constants over DotName.createSimple(Valid.class)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have an opinion about it the class overload is new could change it to that

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's just my preference, I'll leave it to you to decide.

Copy link
Copy Markdown
Member

@michalvavrik michalvavrik left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

works for me; the only thing I am surprised is that if these changes OpenApi descriptions, I think you didn't build the whole project as if you did, the OpenAPI stored in JS dir would change and we could see clearly effect of these changes.

edewit added 2 commits March 25, 2026 10:41
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
@mabartos
Copy link
Copy Markdown
Contributor

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>
@edewit edewit requested a review from a team as a code owner March 25, 2026 10:21
@edewit
Copy link
Copy Markdown
Contributor Author

edewit commented Mar 25, 2026

Right @mabartos that's why I removed my custom schema property logic for standard Jakarta Validation annotations (@notblank, @notempty, @SiZe, @pattern, @min, @max) -
• Kept @url → format: uri handling (SmallRye doesn't support Hibernate Validator's @url)
• Kept all human-readable description building (SmallRye doesn't do this)

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Copy link
Copy Markdown

@keycloak-github-bot keycloak-github-bot bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unreported flaky test detected, please review

@keycloak-github-bot
Copy link
Copy Markdown

Unreported flaky test detected

If 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#testExceedMaxTemporaryLockouts

Keycloak CI - Forms IT (chrome)

java.lang.AssertionError: Expected LoginPage but was localhost (https://localhost:8543/auth/realms/test/login-actions/authenticate?session_code=ZEaHEttdxxO2_1sbCYSu_G8ORCbChBBg8VNB7IBedbw&execution=1750cf17-4ff5-4b9d-ac9b-234632b7b5d8&client_id=test-app&tab_id=UmiXYvUq1JA&client_data=eyJydSI6Imh0dHBzOi8vbG9jYWxob3N0Ojg1NDMvYXV0aC9yZWFsbXMvbWFzdGVyL2FwcC9hdXRoIiwicnQiOiJjb2RlIn0)
	at org.junit.Assert.fail(Assert.java:89)
	at org.junit.Assert.assertTrue(Assert.java:42)
	at org.keycloak.testsuite.pages.AbstractPage.assertCurrent(AbstractPage.java:39)
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
...

Report flaky test

@vmuzikar vmuzikar requested a review from Pepo48 March 25, 2026 17:24
@vmuzikar
Copy link
Copy Markdown
Contributor

@shawkins @Pepo48 @mabartos Can you please (re-)review as well?

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Comment on lines +141 to +154
// 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);
}
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@mabartos
Copy link
Copy Markdown
Contributor

Right @mabartos that's why I removed my custom schema property logic for standard Jakarta Validation annotations (@notblank, @notempty, @SiZe, @pattern, @min, @max)

@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

@edewit
Copy link
Copy Markdown
Contributor Author

edewit commented Mar 26, 2026

@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 BeanValidationScanner. But I've kept the human-readable descriptions (e.g., "must not be blank"). That was part of the issue to have this!

AnnotationInstance notBlank = getFieldAnnotation(field, NOT_BLANK);
if (notBlank != null) {
String context = getGroupContext(notBlank);
constraints.add(context + "must not be blank");
Copy link
Copy Markdown
Contributor

@vmuzikar vmuzikar Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +75 to +77
DotName.createSimple(CreateClient.class), "on create",
DotName.createSimple(PutClient.class), "on update",
DotName.createSimple(PatchClient.class), "on patch"
Copy link
Copy Markdown
Contributor

@vmuzikar vmuzikar Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

edewit added 2 commits March 26, 2026 13:27
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>
Copy link
Copy Markdown
Contributor

@vmuzikar vmuzikar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@edewit Thanks for the update. It looks better, there are still a few areas that need reworking.

Comment on lines +289 to +290
AnnotationValue messageValue = annotation.value("message");
String messageTemplate = messageValue != null ? messageValue.asString() : getDefaultMessage(annotation.name());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can safely rely the message is alway present. There's not much point in exposing a default message anyway.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, the message is null it only returns explicitly declared annotation values, not default values

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it does, just use the method where you pass the index and you will get the defaults

Comment on lines +309 to +314
if (simpleName.contains("uuid")) {
return "uuid";
}
if (simpleName.contains("secret")) {
return "secret";
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this? Isn't description enough?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still valid.

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");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All fields are always implicitly validated, we don't need this message.

Comment on lines +284 to +287
String affectedField = annotation.name().withoutPackagePrefix().toLowerCase();
if (affectedField != null) {
fieldDescriptions.put(affectedField, context + message);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose validations in OpenAPI spec for Client v2

6 participants