8000 add validation to description based on the validation annotations by edewit · Pull Request #47375 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions js/libs/keycloak-admin-client/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ components:
description: Which authentication method is used for this client
secret:
type: string
description: Secret used to authenticate this client with Secret authentication
description: "Secret used to authenticate this client with Secret authentication.\
\ Validation: on update: Client secret must not be blank"
certificate:
type: string
description: Public key used to authenticate this client with Signed JWT
Expand All @@ -22,12 +23,14 @@ components:
properties:
uuid:
type: string
description: UUID generated by the server
description: "UUID generated by the server. Validation: on update/on patch:\
\ UUID is server-managed and must not be user-specified"
Comment thread
vmuzikar marked this conversation as resolved.
Resolved
readOnly: true
clientId:
type: string
pattern: \S
description: ID uniquely identifying this client
description: "ID uniquely identifying this client. Validation: must not\
\ be blank"
displayName:
type: string
description: Human readable name of the client
Expand All @@ -39,25 +42,30 @@ components:
description: Whether this client is enabled
appUrl:
type: string
description: URL to the application's homepage that is represented by this
client
description: "URL to the application's homepage that is represented by this\
\ client. Validation: must be a valid URL"
format: uri
redirectUris:
type: array
uniqueItems: true
items:
type: string
pattern: \S
description: URIs that the browser can redirect to after login
description: "URIs that the browser can redirect to after login. Validation:\
\ each element must not be blank; Invalid redirect URI"
roles:
type: array
uniqueItems: true
items:
type: string
pattern: \S
description: Roles associated with this client
description: "Roles associated with this client. Validation: each element\
\ must not be blank"
protocol:
type: string
pattern: \S
description: "Validation: on update/on patch: protocol cannot be changed\
\ for an existing client"
required:
- clientId
- protocol
Expand Down Expand Up @@ -94,14 +102,16 @@ components:
items:
type: string
pattern: \S
description: Web origins that are allowed to make requests to this client
description: "Web origins that are allowed to make requests to this client.\
\ Validation: each element must not be blank"
serviceAccountRoles:
type: array
uniqueItems: true
items:
type: string
pattern: \S
description: Roles assigned to the service account
description: "Roles assigned to the service account. Validation: each element\
\ must not be blank"
protocol:
type: string
allOf:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
@JsonSubTypes.Type(value = OIDCClientRepresentation.class, name = OIDCClientRepresentation.PROTOCOL),
@JsonSubTypes.Type(value = SAMLClientRepresentation.class, name = SAMLClientRepresentation.PROTOCOL)
})
@UuidUnmodified(groups = {PutClient.class, PatchClient.class})
@UuidUnmodified(groups = {PutClient.class, PatchClient.class}, affectedFieldNames = {"uuid"})
@ProtocolUnmodified(groups = {PutClient.class, PatchClient.class})
@ValidRedirectUris
public abstract class BaseClientRepresentation extends BaseRepresentation implements RepresentationWithUuid {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ public String getProtocol() {
return PROTOCOL;
}

@ClientSecretNotBlank(groups = PutClient.class)
@ClientSecretNotBlank(groups = PutClient.class, affectedFieldNames = {"secret"})
public static class Auth extends BaseRepresentation {

@JsonPropertyDescription("Which authentication method is used for this client")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@

Class<? extends Payload>[] payload() default {};

String[] affectedFieldNames();
}
< 10000 button type="button" class="btn-link color-fg-muted no-underline js-collapse-diff tooltipped tooltipped-se" aria-label="Collapse expanded lines" hidden >
1 change: 1 addition & 0 deletions ...pi/src/main/java/org/keycloak/representations/admin/v2/validation/ProtocolUnmodified.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@
String message() default "protocol cannot be changed for an existing client";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String[] affectedFieldNames() default { "protocol" };
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@
String message() default "UUID is server-managed and must not be user-specified";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String[] affectedFieldNames();
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@
String message() default "Invalid redirect URI";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String[] affectedFieldNames() default { "redirectUris" };
}
14 changes: 13 additions & 1 deletion rest/admin-v2/services/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@
<scope>provided</scope>
<optional>true</optional>
</dependency>

<!-- Test dependencies -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.smallrye</groupId>
<artifactId>smallrye-open-api-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down Expand Up @@ -153,4 +165,4 @@
</plugin>
</plugins>
</build>
</project>
</project>
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
Expand All @@ -11,8 +12,6 @@
import java.util.function.Supplier;
import java.util.stream.Collectors;

import org.keycloak.OAuth2Constants;

import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
Expand All @@ -37,12 +36,14 @@ public class OASModelFilter implements OASFilter {

private final Logger log = Logger.getLogger(OASModelFilter.class);
private final Map<String, ClassInfo> simpleNameToClassInfoMap = new HashMap<>();
private final ValidationAnnotationScanner validationScanner;

public static final String REF_PREFIX = "#/components/schemas/";

public OASModelFilter(IndexView indexView) {
log.debug("Index size: " + indexView.getKnownClasses().size());

this.validationScanner = new ValidationAnnotationScanner(indexView);
indexView.getKnownClasses().forEach(classInfo -> {
simpleNameToClassInfoMap.put(classInfo.simpleName(), classInfo);
});
Expand Down Expand Up @@ -226,7 +227,7 @@ private void addSecurityScheme(OpenAPI openAPI) {
SecurityScheme bearerScheme = OASFactory.createSecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat(OAuth2Constants.JWT)
.bearerFormat("JWT")
.description("Bearer token authentication using a Keycloak access token");

if (openAPI.getComponents() == null) {
Expand Down Expand Up @@ -285,18 +286,106 @@ private void addDescriptionsToSchemasProperties(OpenAPI openAPI) {
if (schema.getProperties() != null) {
ClassInfo classInfo = simpleNameToClassInfoMap.get(schemaName);
if (classInfo == null) {
log.debugf("No Java class found for schema '%s' — property descriptions from the '%s' annotation will not be added", schemaName, JsonPropertyDescription.class.getName());
log.debugf("No Java class found for schema '%s' — property descriptions from the '%s' annotation will not be added", schemaName, JsonPropertyDescription.class.getName());
} else {
// Get class-level validation descriptions (e.g., @UuidUnmodified, @ClientSecretNotBlank)
Map<String, String> classLevelValidations = validationScanner.buildClassLevelDescriptions(classInfo);

schema.getProperties().forEach((propertyName, propertySchema) -> {
// First, ensure we have the base description from @JsonPropertyDescription
if (isNullOrEmpty(propertySchema.getDescription())) {
propertySchema.setDescription(findJsonPropertyDescription(classInfo, propertyName));
}

// Add machine-readable validation schema properties
validationScanner.applySchemaProperties(classInfo, propertyName, propertySchema);

// Build validation description from field-level annotations
String fieldValidation = validationScanner.buildDescription(classInfo, propertyName);

// Include class-level validation if this field is affected
String classLevelValidation = classLevelValidations.get(propertyName);
if (classLevelValidation != null) {
fieldValidation = fieldValidation != null
? fieldValidation + "; " + classLevelValidation
: classLevelValidation;
}

// Append validation description if any (strip prior suffix: SmallRye may invoke the filter repeatedly)
if (fieldValidation != null) {
fieldValidation = dedupeConstraintPhrases(fieldValidation);
String existingDesc = propertySchema.getDescription();
String base = stripValidationSuffix(existingDesc);
String newDesc = isNullOrEmpty(base)
? "Validation: " + fieldValidation
: base + ". Validation: " + fieldValidation;
propertySchema.setDescription(collapseMirroredValidationClause(newDesc));
log.debugf("Added validation description to '%s.%s': %s", schemaName, propertyName, fieldValidation);
}
});
}
}
});
}

/**
* Removes validation clauses previously appended by this filter so repeated OpenAPI filter passes
* do not stack duplicate {@code Validation:} text.
*/
private static String stripValidationSuffix(String description) {
if (isNullOrEmpty(description)) {
return description;
}
int idx = description.indexOf(". Validation: ");
if (idx >= 0) {
return description.substring(0, idx);
}
if (description.startsWith("Validation: ")) {
return "";
}
return description;
}

private static String dedupeConstraintPhrases(String joined) {
if (joined == null) {
return null;
}
String[] parts = joined.split("; ");
Set<String> seen = new LinkedHashSet<>();
for (String part : parts) {
String trimmed = part.trim();
if (!trimmed.isEmpty()) {
seen.add(trimmed);
}
}
return String.join("; ", seen);
}

/**
* Collapses {@code X. Validation: X} when {@code X} is repeated verbatim (duplicate filter passes).
*/
private static String collapseMirroredValidationClause(String description) {
if (isNullOrEmpty(description)) {
return description;
}
String marker = ". Validation: ";
String current = description;
while (true) {
int i = current.indexOf(marker);
if (i <= 0) {
break;
}
String first = current.substring(0, i);
String second = current.substring(i + marker.length());
if (first.equals(second)) {
current = first;
} else {
break;
}
}
return current;
}

private String findJsonPropertyDescription(ClassInfo classInfo, String fieldName) {
FieldInfo field = classInfo.field(fieldName);
if (field != null) {
Expand Down
Loading
Loading
0