Add OpenAPI and OpenAPI-UI to management interface (#41853)

Signed-off-by: Robin Meese <39960884+robson90@users.noreply.github.com>
This commit is contained in:
Robin Meese 2025-08-18 17:53:07 +02:00 committed by Martin Bartoš
parent eca1333027
commit 4f4ed315d3
20 changed files with 337 additions and 4 deletions

View File

@ -0,0 +1,17 @@
package org.keycloak.config;
public class OpenApiOptions {
public static final Option<Boolean> OPENAPI_ENABLED = new OptionBuilder<>("openapi-enabled", Boolean.class)
.category(OptionCategory.OPENAPI)
.description("If the server should expose OpenAPI Endpoint. If enabled, OpenAPI is available at '/openapi'.")
.buildTime(true)
.defaultValue(Boolean.FALSE)
.build();
public static final Option<Boolean> OPENAPI_UI_ENABLED = new OptionBuilder<>("openapi-ui-enabled", Boolean.class)
.category(OptionCategory.OPENAPI)
.description("If the server should expose OpenApi-UI Endpoint. If enabled, OpenAPI UI is available at '/openapi/ui'.")
.buildTime(true)
.defaultValue(Boolean.FALSE)
.build();
}

View File

@ -23,6 +23,7 @@ public enum OptionCategory {
SECURITY("Security", 120, ConfigSupportLevel.SUPPORTED),
EXPORT("Export", 130, ConfigSupportLevel.SUPPORTED),
IMPORT("Import", 140, ConfigSupportLevel.SUPPORTED),
OPENAPI("OpenAPI configuration", 150, ConfigSupportLevel.PREVIEW),
BOOTSTRAP_ADMIN("Bootstrap Admin", 998, ConfigSupportLevel.SUPPORTED),
GENERAL("General", 999, ConfigSupportLevel.SUPPORTED);

View File

@ -240,6 +240,14 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health-deployment</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi-deployment</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-swagger-ui-deployment</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-micrometer-deployment</artifactId>

View File

@ -138,6 +138,7 @@
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<!-- Hibernate validator -->
@ -151,7 +152,14 @@
<groupId>io.smallrye.config</groupId>
<artifactId>smallrye-config-source-keystore</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-swagger-ui</artifactId>
</dependency>
<!-- CLI -->
<dependency>
<groupId>info.picocli</groupId>
@ -408,6 +416,7 @@
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-api</artifactId>
<version>${project.version}</version>
<exclusions>
<exclusion>
<groupId>*</groupId>

View File

@ -21,6 +21,7 @@ import org.keycloak.config.HttpOptions;
import org.keycloak.config.ManagementOptions;
import org.keycloak.config.ManagementOptions.Scheme;
import org.keycloak.config.MetricsOptions;
import org.keycloak.config.OpenApiOptions;
import org.keycloak.quarkus.runtime.configuration.Configuration;
import static org.keycloak.config.ManagementOptions.LEGACY_OBSERVABILITY_INTERFACE;
@ -126,9 +127,9 @@ public class ManagementPropertyMappers {
if (isTrue(LEGACY_OBSERVABILITY_INTERFACE)) {
return false;
}
var isManagementOccupied = isTrue(MetricsOptions.METRICS_ENABLED)
|| (isTrue(HealthOptions.HEALTH_ENABLED) && isTrue(ManagementOptions.HTTP_MANAGEMENT_HEALTH_ENABLED));
return isManagementOccupied;
return (isTrue(HealthOptions.HEALTH_ENABLED) && isTrue(ManagementOptions.HTTP_MANAGEMENT_HEALTH_ENABLED))
|| isTrue(MetricsOptions.METRICS_ENABLED)
|| isTrue(OpenApiOptions.OPENAPI_ENABLED);
}
private static String managementEnabledTransformer() {

View File

@ -0,0 +1,28 @@
package org.keycloak.quarkus.runtime.configuration.mappers;
import org.keycloak.config.OpenApiOptions;
import static org.keycloak.quarkus.runtime.configuration.Configuration.isTrue;
import static org.keycloak.quarkus.runtime.configuration.mappers.PropertyMapper.fromOption;
public final class OpenApiPropertyMappers {
private OpenApiPropertyMappers() {
}
public static PropertyMapper<?>[] getOpenApiPropertyMappers() {
return new PropertyMapper[]{
fromOption(OpenApiOptions.OPENAPI_ENABLED)
.to("quarkus.smallrye-openapi.enable")
.build(),
fromOption(OpenApiOptions.OPENAPI_UI_ENABLED)
.isEnabled(OpenApiPropertyMappers::isUiEnabled, "OpenAPI Endpoint is enabled")
.to("quarkus.swagger-ui.enable")
.build(),
};
}
private static boolean isUiEnabled() {
return isTrue(OpenApiOptions.OPENAPI_ENABLED);
}
}

View File

@ -62,6 +62,7 @@ public final class PropertyMappers {
MAPPERS.addAll(ConfigKeystorePropertyMappers.getConfigKeystorePropertyMappers());
MAPPERS.addAll(ManagementPropertyMappers.getManagementPropertyMappers());
MAPPERS.addAll(MetricsPropertyMappers.getMetricsPropertyMappers());
MAPPERS.addAll(OpenApiPropertyMappers.getOpenApiPropertyMappers());
MAPPERS.addAll(EventPropertyMappers.getMetricsPropertyMappers());
MAPPERS.addAll(ProxyPropertyMappers.getProxyPropertyMappers());
MAPPERS.addAll(VaultPropertyMappers.getVaultPropertyMappers());

View File

@ -0,0 +1,101 @@
package org.keycloak.quarkus.runtime.oas;
import io.quarkus.smallrye.openapi.OpenApiFilter;
import org.eclipse.microprofile.openapi.OASFactory;
import org.eclipse.microprofile.openapi.OASFilter;
import org.eclipse.microprofile.openapi.models.OpenAPI;
import org.eclipse.microprofile.openapi.models.Operation;
import org.eclipse.microprofile.openapi.models.PathItem;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
@OpenApiFilter(OpenApiFilter.RunStage.BUILD)
public class OASModelFilter implements OASFilter {
@Override
public void filterOpenAPI(OpenAPI openAPI) {
// Filter Paths that have the '/admin/api/v2' prefix
Map<String, PathItem> newPaths = openAPI.getPaths().getPathItems().entrySet().stream()
.filter(entry -> entry.getKey().startsWith("/admin/api/v2"))
.collect(Collectors.toMap(
Map.Entry::getKey,
entry -> sortOperationsByMethod(entry.getValue())
));
// Replace ALL Paths with filtered Paths
var paths = OASFactory.createPaths();
newPaths.forEach(paths::addPathItem);
openAPI.setPaths(paths);
// Compute tags that are actually used by remaining operations
Set<String> usedTags = newPaths.values().stream()
.flatMap(pi -> operationsOf(pi).stream())
.flatMap(op -> Optional.ofNullable(op.getTags()).orElseGet(List::of).stream())
.collect(Collectors.toSet());
// Drop top-level tags not used anywhere
if (openAPI.getTags() != null) {
var filteredTags = openAPI.getTags().stream()
.filter(t -> t.getName() != null && usedTags.contains(t.getName()))
.collect(Collectors.toList());
openAPI.setTags(filteredTags.isEmpty() ? null : filteredTags);
}
}
private PathItem sortOperationsByMethod(PathItem pathItem) {
PathItem sortedPathItem = OASFactory.createPathItem();
// Add operations order: GET -> POST -> PUT -> PATCH -> DELETE -> HEAD -> OPTIONS -> TRACE
if (pathItem.getGET() != null) {
sortedPathItem.setGET(pathItem.getGET());
}
if (pathItem.getPOST() != null) {
sortedPathItem.setPOST(pathItem.getPOST());
}
if (pathItem.getPUT() != null) {
sortedPathItem.setPUT(pathItem.getPUT());
}
if (pathItem.getPATCH() != null) {
sortedPathItem.setPATCH(pathItem.getPATCH());
}
if (pathItem.getDELETE() != null) {
sortedPathItem.setDELETE(pathItem.getDELETE());
}
if (pathItem.getHEAD() != null) {
sortedPathItem.setHEAD(pathItem.getHEAD());
}
if (pathItem.getOPTIONS() != null) {
sortedPathItem.setOPTIONS(pathItem.getOPTIONS());
}
if (pathItem.getTRACE() != null) {
sortedPathItem.setTRACE(pathItem.getTRACE());
}
sortedPathItem.setSummary(pathItem.getSummary());
sortedPathItem.setDescription(pathItem.getDescription());
sortedPathItem.setServers(pathItem.getServers());
sortedPathItem.setParameters(pathItem.getParameters());
return sortedPathItem;
}
private List<Operation> operationsOf(PathItem pi) {
List<Operation> ops = new ArrayList<>(8);
if (pi.getGET() != null) ops.add(pi.getGET());
if (pi.getPOST() != null) ops.add(pi.getPOST());
if (pi.getPUT() != null) ops.add(pi.getPUT());
if (pi.getPATCH() != null) ops.add(pi.getPATCH());
if (pi.getDELETE() != null) ops.add(pi.getDELETE());
if (pi.getHEAD() != null) ops.add(pi.getHEAD());
if (pi.getOPTIONS() != null) ops.add(pi.getOPTIONS());
if (pi.getTRACE() != null) ops.add(pi.getTRACE());
return ops;
}
}

View File

@ -87,3 +87,16 @@ quarkus.http.limits.max-header-size=65535
#logging defaults
kc.log-console-output=default
kc.log-file=${kc.home.dir:default}${file.separator}data${file.separator}log${file.separator}keycloak.log
#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.filter=org.keycloak.quarkus.runtime.oas.OASModelFilter
mp.openapi.extensions.smallrye.remove-unused-schemas.enable=true
# 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

View File

@ -40,6 +40,10 @@
<artifactId>importmap</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>
</dependencies>
<properties>
@ -70,6 +74,7 @@
<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>

View File

@ -0,0 +1,79 @@
/*
* Copyright 2021 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.it.cli.dist;
import io.quarkus.test.junit.main.Launch;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.keycloak.it.junit5.extension.CLIResult;
import org.keycloak.it.junit5.extension.DistributionTest;
import org.keycloak.it.junit5.extension.DryRun;
import org.keycloak.it.utils.KeycloakDistribution;
import java.io.IOException;
import static io.restassured.RestAssured.when;
import static org.hamcrest.Matchers.containsString;
import static org.junit.jupiter.api.Assertions.assertThrows;
@DistributionTest(keepAlive = true,
requestPort = 9000,
containerExposedPorts = {8080, 9000})
@Tag(DistributionTest.SLOW)
public class OpenApiDistTest {
private static final String OPENAPI_ENDPOINT = "/openapi";
private static final String OPENAPI_UI_ENDPOINT = "/openapi/ui";
@Test
@Launch({"start-dev"})
void testOpenApiEndpointNotEnabled(KeycloakDistribution distribution) {
assertThrows(IOException.class, () -> when().get(OPENAPI_ENDPOINT), "Connection refused must be thrown");
assertThrows(IOException.class, () -> when().get(OPENAPI_UI_ENDPOINT), "Connection refused must be thrown");
distribution.setRequestPort(8080);
when().get(OPENAPI_ENDPOINT).then()
.statusCode(404);
when().get(OPENAPI_UI_ENDPOINT).then()
.statusCode(404);
}
@Test
@Launch({"start-dev", "--openapi-enabled=true"})
void testOpenApiEndpointEnabled(KeycloakDistribution distribution) {
when().get(OPENAPI_ENDPOINT)
.then()
.statusCode(200);
}
@Test
@Launch({"start-dev", "--openapi-ui-enabled=true", "--openapi-enabled=true"})
void testOpenApiUiEndpointEnabled(KeycloakDistribution distribution) {
when().get(OPENAPI_UI_ENDPOINT)
.then()
.statusCode(200);
}
@DryRun
@Test
@Launch({ "start", "--openapi-ui-enabled=true"})
void testOpenApiUiFailsWhenOpenApiIsNotEnabled(CLIResult cliResult) {
cliResult.assertError("Disabled option: '--openapi-ui-enabled'. Available only when OpenAPI Endpoint is enabled");
}
}

View File

@ -457,6 +457,16 @@ Export:
'different_files'. Increasing this number leads to exponentially increasing
export times. Default: 50.
OpenAPI configuration (Preview):
--openapi-enabled <true|false>
Preview: If the server should expose OpenAPI Endpoint. If enabled, OpenAPI is
available at '/openapi'. Default: false.
--openapi-ui-enabled <true|false>
Preview: If the server should expose OpenApi-UI Endpoint. If enabled, OpenAPI
UI is available at '/openapi/ui'. Default: false. Available only when
OpenAPI Endpoint is enabled.
Bootstrap Admin:
--bootstrap-admin-client-id <client id>

View File

@ -451,6 +451,16 @@ Import:
Set if existing data should be overwritten. If set to false, data will be
ignored. Default: true.
OpenAPI configuration (Preview):
--openapi-enabled <true|false>
Preview: If the server should expose OpenAPI Endpoint. If enabled, OpenAPI is
available at '/openapi'. Default: false.
--openapi-ui-enabled <true|false>
Preview: If the server should expose OpenApi-UI Endpoint. If enabled, OpenAPI
UI is available at '/openapi/ui'. Default: false. Available only when
OpenAPI Endpoint is enabled.
Bootstrap Admin:
--bootstrap-admin-client-id <client id>

View File

@ -689,6 +689,16 @@ Security:
feature is enabled. Possible values are: non-strict, strict. Default:
disabled.
OpenAPI configuration (Preview):
--openapi-enabled <true|false>
Preview: If the server should expose OpenAPI Endpoint. If enabled, OpenAPI is
available at '/openapi'. Default: false.
--openapi-ui-enabled <true|false>
Preview: If the server should expose OpenApi-UI Endpoint. If enabled, OpenAPI
UI is available at '/openapi/ui'. Default: false. Available only when
OpenAPI Endpoint is enabled.
Bootstrap Admin:
--bootstrap-admin-client-id <client id>

View File

@ -690,6 +690,16 @@ Security:
feature is enabled. Possible values are: non-strict, strict. Default:
disabled.
OpenAPI configuration (Preview):
--openapi-enabled <true|false>
Preview: If the server should expose OpenAPI Endpoint. If enabled, OpenAPI is
available at '/openapi'. Default: false.
--openapi-ui-enabled <true|false>
Preview: If the server should expose OpenApi-UI Endpoint. If enabled, OpenAPI
UI is available at '/openapi/ui'. Default: false. Available only when
OpenAPI Endpoint is enabled.
Bootstrap Admin:
--bootstrap-admin-client-id <client id>

View File

@ -689,6 +689,16 @@ Security:
feature is enabled. Possible values are: non-strict, strict. Default:
disabled.
OpenAPI configuration (Preview):
--openapi-enabled <true|false>
Preview: If the server should expose OpenAPI Endpoint. If enabled, OpenAPI is
available at '/openapi'. Default: false.
--openapi-ui-enabled <true|false>
Preview: If the server should expose OpenApi-UI Endpoint. If enabled, OpenAPI
UI is available at '/openapi/ui'. Default: false. Available only when
OpenAPI Endpoint is enabled.
Bootstrap Admin:
--bootstrap-admin-client-id <client id>

View File

@ -687,6 +687,16 @@ Security:
feature is enabled. Possible values are: non-strict, strict. Default:
disabled.
OpenAPI configuration (Preview):
--openapi-enabled <true|false>
Preview: If the server should expose OpenAPI Endpoint. If enabled, OpenAPI is
available at '/openapi'. Default: false.
--openapi-ui-enabled <true|false>
Preview: If the server should expose OpenApi-UI Endpoint. If enabled, OpenAPI
UI is available at '/openapi/ui'. Default: false. Available only when
OpenAPI Endpoint is enabled.
Bootstrap Admin:
--bootstrap-admin-client-id <client id>

View File

@ -62,6 +62,7 @@
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>

View File

@ -5,6 +5,9 @@ import java.util.stream.Stream;
import jakarta.validation.Valid;
import jakarta.validation.groups.ConvertGroup;
import jakarta.ws.rs.QueryParam;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.extensions.Extension;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
import org.keycloak.admin.api.FieldValidation;
import org.keycloak.provider.Provider;
import org.keycloak.representations.admin.v2.ClientRepresentation;
@ -17,17 +20,22 @@ import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.keycloak.representations.admin.v2.validation.CreateClient;
import org.keycloak.services.resources.KeycloakOpenAPI;
@Tag(name = KeycloakOpenAPI.Admin.Tags.CLIENTS_V2)
@Extension(name = KeycloakOpenAPI.Profiles.ADMIN, value = "")
public interface ClientsApi extends Provider {
@GET
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Operation(summary = "Get all clients", description = "Returns a list of all clients in the realm")
Stream<ClientRepresentation> getClients();
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Create a new client", description = "Creates a new client in the realm")
ClientRepresentation createClient(@Valid @ConvertGroup(to = CreateClient.class) ClientRepresentation client,
@QueryParam("fieldValidation") FieldValidation fieldValidation);

View File

@ -38,6 +38,7 @@ public class KeycloakOpenAPI {
public static final String ATTACK_DETECTION = "Attack Detection";
public static final String AUTHENTICATION_MANAGEMENT = "Authentication Management";
public static final String CLIENTS = "Clients";
public static final String CLIENTS_V2 = "Clients (v2)";
public static final String CLIENT_ATTRIBUTE_CERTIFICATE = "Client Attribute Certificate";
public static final String CLIENT_INITIAL_ACCESS = "Client Initial Access";
public static final String CLIENT_REGISTRATION_POLICY = "Client Registration Policy";