feat(admin-api-v2): automatically update openapi file used by JS client (#46472)

* Closes: https://github.com/keycloak/keycloak/issues/46388

Signed-off-by: Michal Vavřík <michal.vavrik@aol.com>
This commit is contained in:
Michal Vavřík
2026-02-27 17:24:15 +01:00
committed by GitHub
parent 9430a3f928
commit 94560cb8e1
7 changed files with 182 additions and 74 deletions
@@ -11,17 +11,10 @@ components:
type: string
certificate:
type: string
BaseRepresentation:
type: object
properties:
id:
type: string
description: Resource identifier
additionalProperties: true
BaseClientRepresentation:
description: Base client representation with common properties for all client
types
type: object
allOf:
- $ref: "#/components/schemas/BaseRepresentation"
properties:
clientId:
type: string
@@ -43,9 +36,13 @@ components:
uniqueItems: true
items:
type: string
additionalFields:
type: object
additionalProperties: {}
protocol:
type: string
discriminator:
propertyName: protocol
mapping:
openid-connect: "#/components/schemas/OIDCClientRepresentation"
saml: "#/components/schemas/SAMLClientRepresentation"
Flow:
type: string
enum:
@@ -58,8 +55,6 @@ components:
- CIBA
OIDCClientRepresentation:
type: object
allOf:
- $ref: "#/components/schemas/BaseClientRepresentation"
properties:
loginFlows:
type: array
@@ -80,11 +75,11 @@ components:
type: string
protocol:
type: string
SAMLClientRepresentation:
type: object
description: SAML Client configuration
allOf:
- $ref: "#/components/schemas/BaseClientRepresentation"
SAMLClientRepresentation:
description: SAML Client configuration
type: object
properties:
nameIdFormat:
type: string
@@ -112,6 +107,8 @@ components:
type: boolean
protocol:
type: string
allOf:
- $ref: "#/components/schemas/BaseClientRepresentation"
tags:
- name: Clients (v2)
x-smallrye-profile-admin: ""
@@ -131,14 +128,7 @@ paths:
schema:
type: array
items:
oneOf:
- $ref: "#/components/schemas/OIDCClientRepresentation"
- $ref: "#/components/schemas/SAMLClientRepresentation"
discriminator:
propertyName: protocol
mapping:
openid-connect: "#/components/schemas/OIDCClientRepresentation"
saml: "#/components/schemas/SAMLClientRepresentation"
$ref: "#/components/schemas/BaseClientRepresentation"
post:
summary: Create a new client
description: Creates a new client in the realm
@@ -149,14 +139,7 @@ paths:
content:
application/json:
schema:
oneOf:
- $ref: "#/components/schemas/OIDCClientRepresentation"
- $ref: "#/components/schemas/SAMLClientRepresentation"
discriminator:
propertyName: protocol
mapping:
openid-connect: "#/components/schemas/OIDCClientRepresentation"
saml: "#/components/schemas/SAMLClientRepresentation"
$ref: "#/components/schemas/BaseClientRepresentation"
required: true
responses:
"200":
@@ -187,14 +170,7 @@ paths:
content:
application/json:
schema:
oneOf:
- $ref: "#/components/schemas/OIDCClientRepresentation"
- $ref: "#/components/schemas/SAMLClientRepresentation"
discriminator:
propertyName: protocol
mapping:
openid-connect: "#/components/schemas/OIDCClientRepresentation"
saml: "#/components/schemas/SAMLClientRepresentation"
$ref: "#/components/schemas/BaseClientRepresentation"
put:
operationId: createOrUpdateClient
tags:
@@ -203,14 +179,7 @@ paths:
content:
application/json:
schema:
oneOf:
- $ref: "#/components/schemas/OIDCClientRepresentation"
- $ref: "#/components/schemas/SAMLClientRepresentation"
discriminator:
propertyName: protocol
mapping:
openid-connect: "#/components/schemas/OIDCClientRepresentation"
saml: "#/components/schemas/SAMLClientRepresentation"
$ref: "#/components/schemas/BaseClientRepresentation"
required: true
responses:
"200":
@@ -232,14 +201,7 @@ paths:
content:
application/json:
schema:
oneOf:
- $ref: "#/components/schemas/OIDCClientRepresentation"
- $ref: "#/components/schemas/SAMLClientRepresentation"
discriminator:
propertyName: protocol
mapping:
openid-connect: "#/components/schemas/OIDCClientRepresentation"
saml: "#/components/schemas/SAMLClientRepresentation"
$ref: "#/components/schemas/BaseClientRepresentation"
delete:
operationId: deleteClient
tags:
+2 -2
View File
@@ -37,9 +37,9 @@
]
},
"generate:openapi": {
"command": "kiota generate -l typescript -d api.yml -c AdminClient -o ./src/generated",
"command": "kiota generate -l typescript -d openapi.yaml -c AdminClient -o ./src/generated",
"files": [
"api.yml"
"openapi.yaml"
],
"output": [
"src/generated"
+15
View File
@@ -23,6 +23,21 @@
<module>themes-vendor</module>
</modules>
<dependencies>
<!-- Enforce build order: openapi.yaml must be generated before JS build -->
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-v2-rest</artifactId>
<scope>provided</scope>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<properties>
<node.version>v24.9.0</node.version>
<pnpm.version>10.14.0</pnpm.version>
@@ -91,18 +91,10 @@ kc.log-file=${kc.home.dir:default}${file.separator}data${file.separator}log${fil
#OpenAPI defaults
quarkus.smallrye-openapi.path=/openapi
quarkus.smallrye-openapi.store-schema-directory=${openapi.schema.target}
quarkus.swagger-ui.path=${quarkus.smallrye-openapi.path}/ui
quarkus.swagger-ui.always-include=true
quarkus.swagger-ui.filter=true
mp.openapi.scan.packages=org.keycloak.representations.admin.v2,org.keycloak.admin.api,org.keycloak.quarkus.runtime.oas,io.quarkus.smallrye.openapi
mp.openapi.extensions.smallrye.auto-inheritance=PARENT_ONLY
mp.openapi.extensions.smallrye.operationIdStrategy=METHOD
mp.openapi.extensions.smallrye.duplicateOperationIdBehavior=FAIL
# Disable Error messages from smallrye.openapi
# related issue: https://github.com/keycloak/keycloak/issues/41871
quarkus.log.category."io.smallrye.openapi.runtime.scanner.dataobject".level=off
mp.openapi.scan.disable=true
# 3.31.1 appears to have a hibernate bug
quarkus.log.category."org.hibernate.orm.sql.exec".level=info
# Enable shutdown delay by default as it is a built-time property, and timeout are then configured at runtime
-1
View File
@@ -75,7 +75,6 @@
<kc.home.dir>${kc.home.dir}</kc.home.dir>
<kc.db>dev-file</kc.db>
<java.util.concurrent.ForkJoinPool.common.threadFactory>io.quarkus.bootstrap.forkjoin.QuarkusForkJoinWorkerThreadFactory</java.util.concurrent.ForkJoinPool.common.threadFactory>
<openapi.schema.target>${project.build.directory}</openapi.schema.target>
</systemProperties>
</configuration>
<executions>
+83
View File
@@ -17,6 +17,8 @@
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Use OpenAPI 4.x compatible plugin version -->
<smallrye.openapi.generator.plugin.version>4.2.4</smallrye.openapi.generator.plugin.version>
</properties>
<dependencies>
@@ -40,5 +42,86 @@
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
</dependency>
<!-- used by OAS Filter during OpenAPI spec generation -->
<dependency>
<groupId>io.smallrye</groupId>
<artifactId>jandex</artifactId>
<scope>provided</scope>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>io.smallrye</groupId>
<artifactId>smallrye-open-api-maven-plugin</artifactId>
<configuration>
<scanPackages>org.keycloak.representations.admin.v2,org.keycloak.admin.api</scanPackages>
<filter>org.keycloak.admin.internal.openapi.OASModelFilter</filter>
<outputDirectory>${project.build.directory}</outputDirectory>
<outputFileTypeFilter>ALL</outputFileTypeFilter>
<infoTitle>Keycloak API</infoTitle>
<infoVersion>${project.version}</infoVersion>
<systemPropertyVariables>
<mp.openapi.extensions.smallrye.auto-inheritance>PARENT_ONLY</mp.openapi.extensions.smallrye.auto-inheritance>
<mp.openapi.extensions.smallrye.operationIdStrategy>METHOD</mp.openapi.extensions.smallrye.operationIdStrategy>
<mp.openapi.extensions.smallrye.duplicateOperationIdBehavior>FAIL</mp.openapi.extensions.smallrye.duplicateOperationIdBehavior>
</systemPropertyVariables>
</configuration>
<executions>
<execution>
<phase>process-classes</phase>
<goals>
<goal>generate-schema</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-openapi-to-jar</id>
<phase>prepare-package</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<!-- include generated openapi documents in JAR, so that Quarkus server can see and serve them -->
<outputDirectory>${project.build.directory}/classes/META-INF</outputDirectory>
<resources>
<resource>
<directory>${project.build.directory}</directory>
<includes>
<include>openapi.json</include>
<include>openapi.yaml</include>
</includes>
</resource>
</resources>
</configuration>
</execution>
<execution>
<id>copy-openapi-to-js-client</id>
<phase>process-classes</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${maven.multiModuleProjectDirectory}/js/libs/keycloak-admin-client</outputDirectory>
<resources>
<resource>
<directory>${project.build.directory}</directory>
<includes>
<include>openapi.yaml</include>
</includes>
<filtering>false</filtering>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -1,4 +1,5 @@
package org.keycloak.quarkus.runtime.oas;
package org.keycloak.admin.internal.openapi;
import java.util.HashMap;
import java.util.HashSet;
@@ -12,7 +13,7 @@ import java.util.stream.Collectors;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.quarkus.smallrye.openapi.OpenApiFilter;
import com.fasterxml.jackson.databind.JsonNode;
import org.eclipse.microprofile.openapi.OASFactory;
import org.eclipse.microprofile.openapi.OASFilter;
import org.eclipse.microprofile.openapi.models.OpenAPI;
@@ -22,20 +23,23 @@ import org.eclipse.microprofile.openapi.models.media.Schema;
import org.jboss.jandex.AnnotationInstance;
import org.jboss.jandex.AnnotationValue;
import org.jboss.jandex.ClassInfo;
import org.jboss.jandex.DotName;
import org.jboss.jandex.IndexView;
import org.jboss.jandex.MethodInfo;
import org.jboss.jandex.Type;
import org.jboss.logging.Logger;
@OpenApiFilter(OpenApiFilter.RunStage.BUILD)
import static org.keycloak.services.PatchTypeNames.JSON_MERGE;
public class OASModelFilter implements OASFilter {
private final IndexView index;
private final Logger log = Logger.getLogger(OASModelFilter.class);
private final Map<String, ClassInfo> simpleNameToClassInfoMap = new HashMap<>();
public static final String REF_PREFIX = "#/components/schemas/";
private static final DotName JSON_NODE = DotName.createSimple(JsonNode.class);
public OASModelFilter(IndexView indexView) {
this.index = indexView;
log.debug("Index size: " + indexView.getKnownClasses().size());
indexView.getKnownClasses().forEach(classInfo -> {
@@ -59,6 +63,8 @@ public class OASModelFilter implements OASFilter {
removeSchemaAndRefs(openAPI, "BaseRepresentation");
fixJsonMergePatchRequestObject(openAPI);
Map<String, Set<Schema>> discriminatorPropertiesToBeAdded = new HashMap<>();
// Follows https://swagger.io/docs/specification/v3_0/data-models/inheritance-and-polymorphism/
@@ -113,6 +119,57 @@ public class OASModelFilter implements OASFilter {
setter.accept(filtered.isEmpty() ? null : filtered);
}
/**
* Currently, if endpoint consumes 'application/merge-patch+json' and the request object is 'JsonNode',
* SmallRye OpenAPI generates array type schema for the endpoint.
* See <a href="https://github.com/smallrye/smallrye-open-api/issues/2494">issue 2494</a> for more context.
* What we need is either no schema, or an object with additional properties, so that the generated client
* doesn't have empty body. This method removes schema.
*/
private void fixJsonMergePatchRequestObject(OpenAPI openAPI) {
if (openAPI.getPaths() == null) {
return;
}
openAPI.getPaths().getPathItems().forEach((path, pathItem) -> {
if (pathItem.getPATCH() != null && pathItem.getPATCH().getRequestBody() != null) {
var patchOp = pathItem.getPATCH();
var requestBody = patchOp.getRequestBody();
if (requestBody.getContent() != null && requestBody.getContent().getMediaType(JSON_MERGE) != null
&& hasJsonNodeParameter(patchOp.getOperationId())) {
var mediaTypeObject = requestBody.getContent().getMediaType(JSON_MERGE);
mediaTypeObject.setSchema(null);
log.debugf("Removed request body schema from PATCH path '%s' operation '%s' using content type '%s'", path, patchOp.getOperationId(), JSON_MERGE);
}
}
});
}
/**
* Detects REST interface method which name matches the operation name.
*/
private boolean hasJsonNodeParameter(String operationId) {
if (operationId == null) {
return false;
}
for (ClassInfo classInfo : simpleNameToClassInfoMap.values()) {
for (MethodInfo method : classInfo.methods()) {
if (method.name().equals(operationId)) {
for (Type paramType : method.parameterTypes()) {
if (JSON_NODE.equals(paramType.name())) {
log.debugf("Method '%s#%s' has parameter with type '%s'", classInfo.name(), method.name());
return true;
}
}
}
}
}
return false;
}
/**
* Adds discriminator and oneOf references to parent schemas that have Jackson @JsonTypeInfo
* and @JsonSubTypes annotations. This enables OpenAPI generators to create proper class