[admin-api-v2] SPIs for Admin APIs v2 (#41943)

Signed-off-by: Martin Bartoš <mabartos@redhat.com>
This commit is contained in:
Martin Bartoš
2025-08-25 13:32:07 +02:00
parent 4f4ed315d3
commit 17e8407230
30 changed files with 420 additions and 77 deletions

View File

@@ -0,0 +1,6 @@
package org.keycloak.admin.api;
import org.keycloak.provider.ProviderFactory;
public interface AdminApiFactory extends ProviderFactory<AdminApi> {
}

View File

@@ -0,0 +1,29 @@
package org.keycloak.admin.api;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class AdminApiSpi implements Spi {
public static final String PROVIDER_ID = "admin-api-root";
@Override
public String getName() {
return PROVIDER_ID;
}
@Override
public Class<? extends Provider> getProviderClass() {
return AdminApi.class;
}
@Override
public Class<? extends ProviderFactory<AdminApi>> getProviderFactoryClass() {
return AdminApiFactory.class;
}
@Override
public boolean isInternal() {
return true;
}
}

View File

@@ -3,11 +3,12 @@ package org.keycloak.admin.api;
import jakarta.ws.rs.OPTIONS;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.ext.Provider;
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.keycloak.models.KeycloakSession;
import org.keycloak.services.resources.admin.AdminCorsPreflightService;
@jakarta.ws.rs.ext.Provider
@Provider
@Path("admin/api")
public class AdminRootV2 {
@@ -17,12 +18,12 @@ public class AdminRootV2 {
@Path("")
public AdminApi latestAdminApi() {
// we could return the latest Admin API if no version is specified
return new DefaultAdminApi(session);
return session.getProvider(AdminApi.class);
}
@Path("v2")
public AdminApi adminApi() {
return new DefaultAdminApi(session);
return session.getProvider(AdminApi.class);
}
@Path("{any:.*}")

View File

@@ -0,0 +1,34 @@
package org.keycloak.admin.api;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class DefaultAdminApiFactory implements AdminApiFactory {
public static final String PROVIDER_ID = "default";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public AdminApi create(KeycloakSession session) {
return new DefaultAdminApi(session);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View File

@@ -5,20 +5,20 @@ import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PATCH;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import org.keycloak.admin.api.FieldValidation;
import org.keycloak.provider.Provider;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import com.fasterxml.jackson.databind.JsonNode;
public interface ClientApi {
public interface ClientApi extends Provider {
// TODO move these
public static final String CONENT_TYPE_MERGE_PATCH = "application/merge-patch+json";
String CONTENT_TYPE_MERGE_PATCH = "application/merge-patch+json";
@GET
@Produces(MediaType.APPLICATION_JSON)
@@ -31,7 +31,7 @@ public interface ClientApi {
@QueryParam("fieldValidation") FieldValidation fieldValidation);
@PATCH
@Consumes({MediaType.APPLICATION_JSON_PATCH_JSON, CONENT_TYPE_MERGE_PATCH})
@Consumes({MediaType.APPLICATION_JSON_PATCH_JSON, CONTENT_TYPE_MERGE_PATCH})
@Produces(MediaType.APPLICATION_JSON)
ClientRepresentation patchClient(JsonNode patch, @QueryParam("fieldValidation") FieldValidation fieldValidation);

View File

@@ -0,0 +1,6 @@
package org.keycloak.admin.api.client;
import org.keycloak.provider.ProviderFactory;
public interface ClientApiFactory extends ProviderFactory<ClientApi> {
}

View File

@@ -0,0 +1,29 @@
package org.keycloak.admin.api.client;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class ClientApiSpi implements Spi {
public static final String NAME = "admin-api-client";
@Override
public String getName() {
return NAME;
}
@Override
public Class<? extends Provider> getProviderClass() {
return ClientApi.class;
}
@Override
public Class<? extends ProviderFactory<ClientApi>> getProviderFactoryClass() {
return ClientApiFactory.class;
}
@Override
public boolean isInternal() {
return true;
}
}

View File

@@ -0,0 +1,6 @@
package org.keycloak.admin.api.client;
import org.keycloak.provider.ProviderFactory;
public interface ClientsApiFactory extends ProviderFactory<ClientsApi> {
}

View File

@@ -0,0 +1,29 @@
package org.keycloak.admin.api.client;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class ClientsApiSpi implements Spi {
public static final String NAME = "admin-api-clients";
@Override
public String getName() {
return NAME;
}
@Override
public Class<? extends Provider> getProviderClass() {
return ClientsApi.class;
}
@Override
public Class<? extends ProviderFactory<ClientsApi>> getProviderFactoryClass() {
return ClientsApiFactory.class;
}
@Override
public boolean isInternal() {
return true;
}
}

View File

@@ -1,9 +1,11 @@
package org.keycloak.admin.api.client;
import java.io.IOException;
import java.util.Objects;
import org.keycloak.admin.api.FieldValidation;
import org.keycloak.http.HttpResponse;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.admin.v2.ClientRepresentation;
@@ -27,21 +29,21 @@ import jakarta.ws.rs.core.Response;
public class DefaultClientApi implements ClientApi {
private final KeycloakSession session;
private final RealmModel realm;
private final String clientId;
private final ClientModel client;
private final ClientService clientService;
private HttpResponse response;
public DefaultClientApi(KeycloakSession session, String clientId) {
public DefaultClientApi(KeycloakSession session) {
this.session = session;
this.clientId = clientId;
this.realm = session.getContext().getRealm();
this.realm = Objects.requireNonNull(session.getContext().getRealm());
this.client = Objects.requireNonNull(session.getContext().getClient());
this.clientService = session.services().clients();
this.response = session.getContext().getHttpResponse();
}
@Override
public ClientRepresentation getClient() {
return clientService.getClient(realm, clientId, null)
return clientService.getClient(realm, client.getClientId(), null)
.orElseThrow(() -> new NotFoundException("Cannot find the specified client"));
}
@@ -96,4 +98,9 @@ public class DefaultClientApi implements ClientApi {
throw ErrorResponse.error("Unknown Error Occurred", Response.Status.INTERNAL_SERVER_ERROR);
}
}
@Override
public void close() {
}
}

View File

@@ -0,0 +1,34 @@
package org.keycloak.admin.api.client;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class DefaultClientApiFactory implements ClientApiFactory {
public static final String PROVIDER_ID = "default";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public ClientApi create(KeycloakSession session) {
return new DefaultClientApi(session);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View File

@@ -1,9 +1,12 @@
package org.keycloak.admin.api.client;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Stream;
import jakarta.validation.Valid;
import jakarta.validation.groups.ConvertGroup;
import jakarta.ws.rs.NotFoundException;
import org.keycloak.admin.api.FieldValidation;
import org.keycloak.http.HttpResponse;
import org.keycloak.models.KeycloakSession;
@@ -24,9 +27,9 @@ public class DefaultClientsApi implements ClientsApi {
private final HttpResponse response;
private final ClientService clientService;
public DefaultClientsApi(KeycloakSession session, RealmModel realm) {
public DefaultClientsApi(KeycloakSession session) {
this.session = session;
this.realm = realm;
this.realm = Objects.requireNonNull(session.getContext().getRealm());
this.clientService = session.services().clients();
this.response = session.getContext().getHttpResponse();
}
@@ -50,7 +53,9 @@ public class DefaultClientsApi implements ClientsApi {
@Override
public ClientApi client(@PathParam("id") String clientId) {
return new DefaultClientApi(session, clientId);
var client = Optional.ofNullable(session.clients().getClientByClientId(realm, clientId)).orElseThrow(() -> new NotFoundException("Client cannot be found"));
session.getContext().setClient(client);
return session.getProvider(ClientApi.class);
}
@Override

View File

@@ -0,0 +1,34 @@
package org.keycloak.admin.api.client;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class DefaultClientsApiFactory implements ClientsApiFactory {
public static final String PROVIDER_ID = "default";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public ClientsApi create(KeycloakSession session) {
return new DefaultClientsApi(session);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View File

@@ -1,27 +1,27 @@
package org.keycloak.admin.api.realm;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.Path;
import org.keycloak.admin.api.client.ClientsApi;
import org.keycloak.admin.api.client.DefaultClientsApi;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import java.util.Optional;
import java.util.Objects;
public class DefaultRealmApi implements RealmApi {
private final KeycloakSession session;
private final RealmModel realm;
public DefaultRealmApi(KeycloakSession session, String name) {
public DefaultRealmApi(KeycloakSession session) {
this.session = session;
this.realm = Optional.ofNullable(session.realms().getRealmByName(name)).orElseThrow(() -> new NotFoundException("Realm cannot be found"));
session.getContext().setRealm(realm);
this.realm = Objects.requireNonNull(session.getContext().getRealm());
}
@Path("clients")
@Override
public ClientsApi clients() {
return new DefaultClientsApi(session, realm);
return session.getProvider(ClientsApi.class);
}
@Override
public void close() {}
}

View File

@@ -0,0 +1,34 @@
package org.keycloak.admin.api.realm;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class DefaultRealmApiFactory implements RealmApiFactory {
public static final String PROVIDER_ID = "default";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public RealmApi create(KeycloakSession session) {
return new DefaultRealmApi(session);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View File

@@ -1,9 +1,12 @@
package org.keycloak.admin.api.realm;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import org.keycloak.models.KeycloakSession;
import java.util.Optional;
public class DefaultRealmsApi implements RealmsApi {
private final KeycloakSession session;
@@ -14,7 +17,9 @@ public class DefaultRealmsApi implements RealmsApi {
@Path("{name}")
@Override
public RealmApi realm(@PathParam("name") String name) {
return new DefaultRealmApi(session, name);
var realm = Optional.ofNullable(session.realms().getRealmByName(name)).orElseThrow(() -> new NotFoundException("Realm cannot be found"));
session.getContext().setRealm(realm);
return session.getProvider(RealmApi.class);
}
@Override

View File

@@ -0,0 +1,34 @@
package org.keycloak.admin.api.realm;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
public class DefaultRealmsApiFactory implements RealmsApiFactory {
public static final String PROVIDER_ID = "default";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public RealmsApi create(KeycloakSession session) {
return new DefaultRealmsApi(session);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}

View File

@@ -2,8 +2,9 @@ package org.keycloak.admin.api.realm;
import jakarta.ws.rs.Path;
import org.keycloak.admin.api.client.ClientsApi;
import org.keycloak.provider.Provider;
public interface RealmApi {
public interface RealmApi extends Provider {
@Path("clients")
ClientsApi clients();

View File

@@ -0,0 +1,6 @@
package org.keycloak.admin.api.realm;
import org.keycloak.provider.ProviderFactory;
public interface RealmApiFactory extends ProviderFactory<RealmApi> {
}

View File

@@ -0,0 +1,29 @@
package org.keycloak.admin.api.realm;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class RealmApiSpi implements Spi {
public static final String NAME = "admin-api-realm";
@Override
public String getName() {
return NAME;
}
@Override
public Class<? extends Provider> getProviderClass() {
return RealmApi.class;
}
@Override
public Class<? extends ProviderFactory<RealmApi>> getProviderFactoryClass() {
return RealmApiFactory.class;
}
@Override
public boolean isInternal() {
return true;
}
}

View File

@@ -0,0 +1,6 @@
package org.keycloak.admin.api.realm;
import org.keycloak.provider.ProviderFactory;
public interface RealmsApiFactory extends ProviderFactory<RealmsApi> {
}

View File

@@ -0,0 +1,29 @@
package org.keycloak.admin.api.realm;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class RealmsApiSpi implements Spi {
public static final String NAME = "admin-api-realms";
@Override
public String getName() {
return NAME;
}
@Override
public Class<? extends Provider> getProviderClass() {
return RealmsApi.class;
}
@Override
public Class<? extends ProviderFactory<RealmsApi>> getProviderFactoryClass() {
return RealmsApiFactory.class;
}
@Override
public boolean isInternal() {
return true;
}
}

View File

@@ -0,0 +1 @@
org.keycloak.admin.api.DefaultAdminApiFactory

View File

@@ -0,0 +1 @@
org.keycloak.admin.api.client.DefaultClientApiFactory

View File

@@ -0,0 +1 @@
org.keycloak.admin.api.client.DefaultClientsApiFactory

View File

@@ -0,0 +1 @@
org.keycloak.admin.api.realm.DefaultRealmApiFactory

View File

@@ -0,0 +1 @@
org.keycloak.admin.api.realm.DefaultRealmsApiFactory

View File

@@ -0,0 +1,5 @@
org.keycloak.admin.api.AdminApiSpi
org.keycloak.admin.api.realm.RealmsApiSpi
org.keycloak.admin.api.realm.RealmApiSpi
org.keycloak.admin.api.client.ClientsApiSpi
org.keycloak.admin.api.client.ClientApiSpi

View File

@@ -1,13 +1,7 @@
package org.keycloak.services.client;
import jakarta.enterprise.inject.spi.CDI;
import jakarta.inject.Inject;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import jakarta.ws.rs.core.Response;
import org.hibernate.validator.HibernateValidator;
import org.hibernate.validator.HibernateValidatorFactory;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
@@ -16,7 +10,6 @@ import org.keycloak.models.mapper.ModelMapper;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.representations.admin.v2.validation.CreateClient;
import org.keycloak.services.ServiceException;
import org.keycloak.validation.jakarta.HibernateValidatorProvider;
import org.keycloak.validation.jakarta.JakartaValidatorProvider;
import java.util.Optional;

View File

@@ -20,26 +20,18 @@ package org.keycloak.tests.admin;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPatch;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.util.EntityUtils;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.keycloak.admin.api.client.ClientApi;
import org.keycloak.representations.admin.v2.ClientRepresentation;
import org.keycloak.testframework.annotations.InjectHttpClient;
import org.keycloak.testframework.annotations.KeycloakIntegrationTest;
import java.nio.charset.StandardCharsets;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.notNullValue;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
@KeycloakIntegrationTest()
@@ -49,7 +41,7 @@ public class AdminV2Test {
private static ObjectMapper mapper;
@InjectHttpClient
private HttpClient client;
CloseableHttpClient client;
@BeforeAll
public static void setupMapper() {
@@ -59,10 +51,11 @@ public class AdminV2Test {
@Test
public void getClient() throws Exception {
HttpGet request = new HttpGet(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients/account");
HttpResponse response = client.execute(request);
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertEquals("account", client.getClientId());
try (var response = client.execute(request)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertEquals("account", client.getClientId());
}
}
@Test
@@ -70,56 +63,39 @@ public class AdminV2Test {
HttpPatch request = new HttpPatch(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients/account");
request.setEntity(new StringEntity("not json"));
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_PATCH_JSON);
HttpResponse response = client.execute(request);
EntityUtils.consumeQuietly(response.getEntity());
assertEquals(400, response.getStatusLine().getStatusCode());
try (var response = client.execute(request)) {
EntityUtils.consumeQuietly(response.getEntity());
assertEquals(400, response.getStatusLine().getStatusCode());
}
request.setEntity(new StringEntity(
"""
[{"op": "add", "path": "/description", "value": "I'm a description"}]
"""));
response = client.execute(request);
assertEquals(200, response.getStatusLine().getStatusCode());
try (var response = client.execute(request)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertEquals("I'm a description", client.getDescription());
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertEquals("I'm a description", client.getDescription());
}
}
@Disabled
@Test
public void jsonMergePatchClient() throws Exception {
HttpPatch request = new HttpPatch(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients/account");
request.setHeader(HttpHeaders.CONTENT_TYPE, ClientApi.CONENT_TYPE_MERGE_PATCH);
request.setHeader(HttpHeaders.CONTENT_TYPE, ClientApi.CONTENT_TYPE_MERGE_PATCH);
ClientRepresentation patch = new ClientRepresentation();
patch.setDescription("I'm also a description");
request.setEntity(new StringEntity(mapper.writeValueAsString(patch)));
HttpResponse response = client.execute(request);
assertEquals(200, response.getStatusLine().getStatusCode());
try (var response = client.execute(request)) {
assertEquals(200, response.getStatusLine().getStatusCode());
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertEquals("I'm also a description", client.getDescription());
ClientRepresentation client = mapper.createParser(response.getEntity().getContent()).readValueAs(ClientRepresentation.class);
assertEquals("I'm also a description", client.getDescription());
}
}
@Test
public void clientRepresentationValidation() throws Exception {
HttpPost request = new HttpPost(HOSTNAME_LOCAL_ADMIN + "/realms/master/clients");
request.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON);
request.setEntity(new StringEntity("""
{
"displayName": "something",
"appUrl": "notUrl"
}
"""));
var response = client.execute(request);
assertThat(response, notNullValue());
System.err.println(EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8));
assertThat(response.getStatusLine().getStatusCode(), is(400));
}
}