8000 45364 Migrate to adminClient for Java by robson90 · Pull Request #46973 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions integration/admin-client/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-v2-rest</artifactId>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-client</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
package org.keycloak.admin.client;

import java.util.stream.Stream;

import jakarta.ws.rs.core.MediaType;

import org.keycloak.admin.client.deserializer.StreamDeserializer;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import org.jboss.resteasy.plugins.providers.jackson.ResteasyJackson2Provider;

public class JacksonProvider extends ResteasyJackson2Provider {
private static final StreamDeserializer STREAM_DESERIALIZER = new StreamDeserializer();
private static final SimpleModule STREAM_MODULE = new SimpleModule().addDeserializer(Stream.class, STREAM_DESERIALIZER);

@Override
public ObjectMapper locateMapper(Class<?> type, MediaType mediaType) {
Expand All @@ -19,6 +26,7 @@ public ObjectMapper locateMapper(Class<?> type, MediaType mediaType) {
// The client must work with the newer versions of Keycloak server, which might contain the JSON fields not yet known by the client. So unknown fields will be ignored.
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

objectMapper.registerModule(STREAM_MODULE);
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.

As discussed on the meeting, the concerns about including it into the admin client - we could conditionally register it if the Client V2 feature is enabled. It'll help to decrease the probability that something is broken for the admin client core itself.

cc: @robson90 @keycloak/cloud-native WDYT?

@robson90 robson90 Mar 24, 2026 •
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.

IMHO the safest path here would be to make it return List<> for now.

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.

Based upon what the module is doing, it is safe to leave this as is.

Swiching to List is not desirable as that can have a heavier memory footprint server side.

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.

It's an admin client, it doesn't know what features are enabled. We could, hoverer, hide it behind some system property toggle?

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.

If @shawkins has reviewed the logic and has the context, I'm ok to leave it as is.

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.

Swiching to List is not desirable as that can have a heavier memory footprint server side.

I understand that, but you are moving the memory footprint from the Server to the Client, as the Client is creating a list from it and then streams it.

Somewhere above are the Debug logs from the Payload, that Keycloak is returning. For me that looks like a normal List<> as response. Im unsure, if we are getting any real benefit here.

Im fine with Stream<> for now.

return objectMapper;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import org.keycloak.admin.client.resource.ServerInfoResource;
import org.keycloak.admin.client.spi.ResteasyClientProvider;
import org.keycloak.admin.client.token.TokenManager;
import org.keycloak.admin.client.wrapper.Clients;

import static org.keycloak.OAuth2Constants.PASSWORD;

Expand Down Expand Up @@ -240,4 +241,8 @@ public void close() {
public boolean isClosed() {
return closed;
}

public Clients clients(String realmName) {
return new Clients(CLIENT_PROVIDER, target, realmName);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package org.keycloak.admin.client.deserializer;

import java.io.IOException;
import java.util.List;
import java.util.stream.Stream;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.deser.ContextualDeserializer;

public class StreamDeserializer extends JsonDeserializer<Stream<?>> implements ContextualDeserializer {
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.

@shawkins Do you want to review too, especially the deserializer?

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'm trying to understand this better. On the server side we are registering a StreamSerializer (

module.addSerializer(new StreamSerializer(streamType, type));
) - it seems quite odd that Jackson does not provide, yet requires a Stream deserializer.

From the discussion in #46973 (comment) it would be good to understand exactly what quarkus is doing with the Stream (is it creating a full response buffer for error handling purposes?).

I know in some places we have resorted to using Response, and in particular the response stream directly for performance reasons

response.entity((StreamingOutput) consumer::accept);
- these endpoints may also need something more idiomatic on the interface.

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 assume Quarkus is handling Stream<> as like the return type would be List<ClientRepresentation>. Because when I debugged the failing test, I can see this Debug Log:

2026-03-18 14:12:01,626 DEBUG [org.apache.http.headers] (main) http-outgoing-1 >> GET /admin/api/default/clients/v2 HTTP/1.1
2026-03-18 14:12:01,626 DEBUG [org.apache.http.headers] (main) http-outgoing-1 >> Accept: application/json
2026-03-18 14:12:01,626 DEBUG [org.apache.http.headers] (main) http-outgoing-1 >> Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJDYnIxV1lTN2U4cGRJV00wZ1Nrck1qVTVCNi1ITndsb0ZuYk5mQ0otdS13In0.eyJleHAiOjE3NzM4Mzk1ODAsImlhdCI6MTc3MzgzOTUyMCwianRpIjoidHJsdGNjOmU5ZThjOWM2LWM2MWQtZjA5NS0xZmIyLTdlOGU0ZWJmNDk0YSIsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9yZWFsbXMvbWFzdGVyIiwic3ViIjoiZjc2YTQ5MjUtNTQxYi00YTk4LWFkODgtNTkxNDQ3YzQwZTg0IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidGVtcC1hZG1pbiIsInNjb3BlIjoiZW1haWwgcHJvZmlsZSJ9.O-3JuVtzbXOuudmwLNVOJkFpr4SE2P8dRz-AggfKM8zFE98XGyoCO3n1G3h0_WJjwFF9GSMBbuMPBEMNsumfhxKI-tgPUA08stuvCczoV2ZqPFAkbvUcWNX0u9s_RxYhVMRliKdugaSwAH2uD145dGC_YnGy7S8wmAURUo0tFShy7UIslO5_kytXw0G6Cf9fCg327yr0-zzI7S-jdV2qmHHYdNlJXCxRWASs6slVrpAbLlZf55YjmVKkGDyXebmeC6T08kCZ19WnqZjo3knHi6SLVFnAow0hGLVLniFxbGTCn7t7g-HTw_BVsCQdvbjtI4joesj17d6YFgoZrYF1YQ
2026-03-18 14:12:01,626 DEBUG [org.apache.http.headers] (main) http-outgoing-1 >> Host: localhost:8080
2026-03-18 14:12:01,626 DEBUG [org.apache.http.headers] (main) http-outgoing-1 >> Connection: Keep-Alive
2026-03-18 14:12:01,626 DEBUG [org.apache.http.headers] (main) http-outgoing-1 >> User-Agent: Apache-HttpClient/4.5.14 (Java/17.0.18)
2026-03-18 14:12:01,626 DEBUG [org.apache.http.wire] (main) http-outgoing-1 >> "GET /admin/api/default/clients/v2 HTTP/1.1[\r][\n]"
2026-03-18 14:12:01,626 DEBUG [org.apache.http.wire] (main) http-outgoing-1 >> "Accept: application/json[\r][\n]"
2026-03-18 14:12:01,626 DEBUG [org.apache.http.wire] (main) http-outgoing-1 >> "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJDYnIxV1lTN2U4cGRJV00wZ1Nrck1qVTVCNi1ITndsb0ZuYk5mQ0otdS13In0.eyJleHAiOjE3NzM4Mzk1ODAsImlhdCI6MTc3MzgzOTUyMCwianRpIjoidHJsdGNjOmU5ZThjOWM2LWM2MWQtZjA5NS0xZmIyLTdlOGU0ZWJmNDk0YSIsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MC9yZWFsbXMvbWFzdGVyIiwic3ViIjoiZjc2YTQ5MjUtNTQxYi00YTk4LWFkODgtNTkxNDQ3YzQwZTg0IiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidGVtcC1hZG1pbiIsInNjb3BlIjoiZW1haWwgcHJvZmlsZSJ9.O-3JuVtzbXOuudmwLNVOJkFpr4SE2P8dRz-AggfKM8zFE98XGyoCO3n1G3h0_WJjwFF9GSMBbuMPBEMNsumfhxKI-tgPUA08stuvCczoV2ZqPFAkbvUcWNX0u9s_RxYhVMRliKdugaSwAH2uD145dGC_YnGy7S8wmAURUo0tFShy7UIslO5_kytXw0G6Cf9fCg327yr0-zzI7S-jdV2qmHHYdNlJXCxRWASs6slVrpAbLlZf55YjmVKkGDyXebmeC6T08kCZ19WnqZjo3knHi6SLVFnAow0hGLVLniFxbGTCn7t7g-HTw_BVsCQdvbjtI4joesj17d6YFgoZrYF1YQ[\r][\n]"
2026-03-18 14:12:01,626 DEBUG [org.apache.http.wire] (main) http-outgoing-1 >> "Host: localhost:8080[\r][\n]"
2026-03-18 14:12:01,626 DEBUG [org.apache.http.wire] (main) http-outgoing-1 >> "Connection: Keep-Alive[\r][\n]"
2026-03-18 14:12:01,626 DEBUG [org.apache.http.wire] (main) http-outgoing-1 >> "User-Agent: Apache-HttpClient/4.5.14 (Java/17.0.18)[\r][\n]"
2026-03-18 14:12:01,626 DEBUG [org.apache.http.wire] (main) http-outgoing-1 >> "[\r][\n]"
2026-03-18 14:12:01,626 DEBUG [org.apache.http.wire] (main) http-outgoing-1 << "HTTP/1.1 200 OK[\r][\n]"
2026-03-18 14:12:01,626 DEBUG [org.apache.http.wire] (main) http-outgoing-1 << "content-length: 4179[\r][\n]"
2026-03-18 14:12:01,626 DEBUG [org.apache.http.wire] (main) http-outgoing-1 << "Content-Type: application/json;charset=UTF-8[\r][\n]"
2026-03-18 14:12:01,626 DEBUG [org.apache.http.wire] (main) http-outgoing-1 << "Referrer-Policy: no-referrer[\r][\n]"
2026-03-18 14:12:01,626 DEBUG [org.apache.http.wire] (main) http-outgoing-1 << "Strict-Transport-Security: max-age=31536000; includeSubDomains[\r][\n]"
2026-03-18 14:12:01,626 DEBUG [org.apache.http.wire] (main) http-outgoing-1 << "X-Content-Type-Options: nosniff[\r][\n]"
2026-03-18 14:12:01,626 DEBUG [org.apache.http.wire] (main) http-outgoing-1 << "X-Frame-Options: SAMEORIGIN[\r][\n]"
2026-03-18 14:12:01,626 DEBUG [org.apache.http.wire] (main) http-outgoing-1 << "X-Robots-Tag: none[\r][\n]"
2026-03-18 14:12:01,626 DEBUG [org.apache.http.wire] (main) http-outgoing-1 << "[\r][\n]"
2026-03-18 14:12:01,626 DEBUG [org.apache.http.wire] (main) http-outgoing-1 << "[{"clientId":"account","displayName":"${client_account}","enabled":true,"appUrl":"/realms/default/account/","redirectUris":["/realms/default/account/*"],"roles":["manage-account","view-applications","view-consent","view-groups","manage-account-links","manage-consent","delete-account","view-profile"],"loginFlows":["STANDARD"],"protocol":"openid-connect"},{"clientId":"account-console","displayName":"${client_account-console}","enabled":true,"appUrl":"/realms/default/account/","redirectUris":["/realms/default/account/*"],"loginFlows":["STANDARD"],"protocol":"openid-connect"},{"clientId":"admin-cli","displayName":"${client_admin-cli}","enabled":true,"loginFlows":["DIRECT_GRANT"],"protocol":"openid-connect"},{"clientId":"broker","displayName":"${client_broker}","enabled":true,"roles":["read-token"],"loginFlows":["STANDARD"],"auth":{"method":"client-secret"},"protocol":"openid-connect"},{"clientId":"mixed-test-oidc","description":"OIDC client for mixed protocol test","enabled":true,"loginFlows":["STANDARD","DIRECT_GRANT"],"webOrigins":["http://localhost:3000","http://localhost:4000"],"protocol":"openid-connect"},{"clientId":"mixed-test-saml","description":"SAML client for mixed protocol test","enabled":true,"nameIdFormat":"email","forceNameIdFormat":false,"includeAuthnStatement":true,"signDocuments":true,"signAssertions":true,"clientSignatureRequired":true,"forcePostBinding":true,"frontChannelLogout":false,"signatureAlgorithm":"RSA_SHA256","signatureCanonicalizationMethod":"http://www.w3.org/2001/10/xml-exc-c14n#","signingCertificate":"MIIErTCCApUCBgGdARMXLDANBgkqhkiG9w0BAQsFADAaMRgwFgYDVQQDDA9taXhlZC10ZXN0LXNhbWwwHhcNMjYwMzE4MTMxMDIxWhcNMjkwMzE4MTMxMjAwWjAaMRgwFgYDVQQDDA9taXhlZC10ZXN0LXNhbWwwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCiv3/qyQcSTirZ7k4f9gmRwHvcmGU3Cba7EI/no3EVagcBDaeFpZPRax5eUTDgPRQQ7c46L0FEljQ+VAZ8eZpdIhzBTS0jny9c+1ZKCum4H1gGh3ePrLo5iMiNZ9xJRq+iAsU7BhycuR5uteOqH6bRxSlO9rkgcyfUA+2Cl/rQLiOaqSU/HxBVXI62pe+E/2mixQlS1iKH8Jt2KmHK3ELprH2678mU5LlHpznx5iMJX5Ih/rsGLGXTDA3K2MPgPsHn+ayyTFkujcltDG/p71yyCBr5ymhAx/xQ4bSQbFDLIcba8yR7vJpVqxVHk/BjEUrNtMOMm1MQA3AxupqAaP/aRyATSgUlROzEukhz/ghWSRGMo3cEjq3oYXMQgrWbnCVIeWacVzbJzxYpQR5ABlhY9If3VymXgQKGAjA3chDd8rHHwr+oPTzAEk8ZjZkWfOogX5gYFUchK9XDdelv338c94AcSz5/eUImkzNNIyCtNa+PhGqHfBcXOlOLB3J7mH6Hzn/vNXxoyrxsT1hR/Fm4iL8ZVQ8btdXv3jZ14m8uEUoMZc0zJfq+jGDxzV1QF/VJMFFgYSCduawGyycUKG+pGfRQeZxpqFmtV0t+jBgJecTdj/TOXbhXG9+7ptngDIzxEZgBBRqSoptOKYL6mwy1AXa1NCO4TDlinEM+jvhwkQIDAQABMA0GCSqGSIb3DQEBCwUAA4ICAQCGpbWga8PutsKZu5vXWp8iDFQDHnYxvdJuBiiqA8dPuCkqC8sxwqUkQn6SruyLRnry17N+BLYzlECxK3aBeLmCa7fiWdhS92NSDdyowqA5X69XMTStmzK9MuIdqmfhHO+AsrOZVNIizUI/sPRAb+SwITJBLNnIyyKH+LNDNWsB2QG+vLYVje/qDnuXBWkn2bsckf7x9kXq+LydPKzI0Aj4mi+4ge4R4PPTI9VTgxaPd+IASh/XtbvFhWLrEMcS74pppST+QAPMKc6sWAMf6m3OhCbq7DQPPaEg3yqUMNoWlnD4CXkZc4503QHUjy1/wkg1x6p6dzs3RCZRt4nN8tM19PH5K0iiArlKWvjVOB9JAnIfkKhkbCieNmeImSt4bRhKlqkH8KsAfa8HRfEkzh+gHINZEk8Uk0EmNctsz2IbFultKIBLkXoFspb3ILy8WHmggwtlRl19bzQYuimIamOLX/nrnEgiCWd7A9IzqgX/C5fYQaWWVSHPE4EE1wCqxCWz0wPpj98KjTwiaP+GySj3cfb2xlnOe4RUK9vLILWPe0ofWmlVB1oJ/hQjtpOhy6+EO4t6vAKIGKCUz05XNnjV7sPfbiKeTaeOO7USMNRLUTjgR8+UpdEBF5vxV9pQPlKNyYlaE7nPFR8U65s2W4wQH20cdjE5NRdE+MtukZQgdQ==","allowEcpFlow":false,"protocol":"saml"},{"clientId":"myclient","enabled":true,"loginFlows":["SERVICE_ACCOUNT","STANDARD"],"auth":{"method":"client-secret","secret":"mysecret"},"serviceAccountRoles":["default-roles-default"],"protocol":"openid-connect"},{"clientId":"realm-management","displayName":"${client_realm-management}","enabled":true,"roles":["view-realm","view-identity-providers","manage-identity-providers","impersonation","realm-admin","create-client","manage-users","query-realms","view-authorization","query-clients","query-users","manage-events","manage-realm","view-events","view-users","view-clients","manage-authorization","manage-clients","query-groups"],"loginFlows":["STANDARD"],"auth":{"method":"client-secret"},"protocol":"openid-connect"},{"clientId":"security-admin-console","displayName":"${client_security-admin-console}","enabled":true,"appUrl":"/admin/default/console/","redirectUris":["/admin/default/console/*"],"loginFlows":["STANDARD"],"webOrigins":["+"],"protocol":"openid-connect"}]"
2026-03-18 14:12:01,626 DEBUG [org.apache.http.headers] (main) http-outgoing-1 << HTTP/1.1 200 OK
2026-03-18 14:12:01,626 DEBUG [org.apache.http.headers] (main) http-outgoing-1 << content-length: 4179
2026-03-18 14:12:01,626 DEBUG [org.apache.http.headers] (main) http-outgoing-1 << Content-Type: application/json;charset=UTF-8
2026-03-18 14:12:01,626 DEBUG [org.apache.http.headers] (main) http-outgoing-1 << Referrer-Policy: no-referrer
2026-03-18 14:12:01,626 DEBUG [org.apache.http.headers] (main) http-outgoing-1 << Strict-Transport-Security: max-age=31536000; includeSubDomains
2026-03-18 14:12:01,626 DEBUG [org.apache.http.headers] (main) http-outgoing-1 << X-Content-Type-Options: nosniff
2026-03-18 14:12:01,626 DEBUG [org.apache.http.headers] (main) http-outgoing-1 << X-Frame-Options: SAMEORIGIN
2026-03-18 14:12:01,626 DEBUG [org.apache.http.headers] (main) http-outgoing-1 << X-Robots-Tag: none

as the payload is looking like a list.

I think there are two different things that are important:

  1. What is the return type from Keycloak.
  2. How does the adminClient interact with Keycloak. As in the discussion above, the adminClient is calling the endpoint with a synchronize invoke method. Which means every performance benefit is gone, as it is waiting for the complete response.

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 assume Quarkus is handling Stream<> as like the return type would be List

Yes, can confirm the StreamSerializer just writes an array.

From a server side perspective I confirmed that is happening in a streaming manner as the serialization is directly into the output stream.

What is the return type from Keycloak.

When we are returning a "list" the interface will be one of List (array), Stream, or Multi.

Unless I'm missing something, the response body would be identical no matter the type. The client proxy logic only knows about List by default, but we don't want to use that when the server logic is actually streaming.

Based upon what you have seen here, the use of Stream or Multi will require a custom deserializer.

In more exotic scenarios we'll return a Response. I haven't checked how a RestEasy proxy handles a raw Response, but that would at least require additional client side logic to consume.

Switching to an InputStream would not good either as that negates the server side simplicity of directly returning the objects.

How does the adminClient interact with Keycloak. As in the discussion above, the adminClient is calling the endpoint with a synchronize invoke method. Which means every performance benefit is gone, as it is waiting for the complete response.

We'd need async parsing support as well.

The only simple solutions here seem to be:

  1. keep adding deserializers as needed, and double check how Response is handled via a client side proxy.
  2. introduce or generate client side specific interfaces such that Stream is replaced by List

Another thought is that successful response codes are basically not available via the proxy (or can they be obtained somehow?). Our PUT can return differentiated codes (created vs updated), so you won't know which occurred.

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.

2. introduce or generate client side specific interfaces such that Stream is replaced by List

That's something we did with v1. If possible, I'd like to avoid doing that again (especially if not generated but maintained manually).

I'm quite surprised resteasy doesn't support a basic stream deserializer OOTB. So we'll need to add custom one but it's not ideal of course.

In more exotic scenarios we'll return a Response. I haven't checked how a RestEasy proxy handles a raw Response, but that would at least require additional client side logic to consume.

Yes, it seems so:
https://github.com/robson90/keycloak/blob/b301d02b1ea8c1769e820ec1b2f2b14a8aae2a43/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java#L368

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.

Yes, it seems so:
https://github.com/robson90/keycloak/blob/b301d02b1ea8c1769e820ec1b2f2b14a8aae2a43/rest/admin-v2/tests/src/test/java/org/keycloak/tests/admin/client/v2/ClientApiV2Test.java#L368

Ah, so that the concern about alternative success codes as well - any time we have to return one we'll be using a response. So then we'll just need some helper logic for consuming Responses.

Something we need to do in a follow-up is add APIResponses to everything that is returning a Response:

    @Operation(summary = "Create or update a client", description = "Creates or updates a client in the realm")
    @APIResponses(value = {
            @APIResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = BaseClientRepresentation.class))),
            @APIResponse(description = "created", responseCode = "201", content = @Content(schema = @Schema(implementation = BaseClientRepresentation.class))) })

The client side handling of a raw Response is still not ideal.

There is a resteasy reactive RestResponse construct that is typed - are we allowed to use that or do we need to stick to plain jax-rs? I also haven't check yet how that is handled on the client side.

Alternatively if we can't use that, we could consider introducing a TypedResponse class that extends Response (which may not be allowable) and delegates to an actual response, then wrap the target proxy with another proxy that detects when a method is called that returns a TypedResponse and does the conversion there such that getEntity returns the expected type.

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.

Something we need to do in a follow-up is add APIResponses to everything that is returning a Response

Captured as #47365.

The client side handling of a raw Response is still not ideal.

+1

There is a resteasy reactive RestResponse construct that is typed - are we allowed to use that or do we need to stick to plain jax-rs?

AFAIK we need to be as platform/lib agnostic as possible in our endpoints.

Alternatively if we can't use that, we could consider introducing a TypedResponse class

That would sound good. But we could do that as a follow-up.

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.

we could consider introducing a TypedResponse class

Captured as #47367.


private final JavaType contentType;

public StreamDeserializer() {
this(null);
}

public StreamDeserializer(JavaType contentType) {
this.contentType = contentType;
}

@Override
public Stream<?> deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
JavaType streamType = ctxt.getContextualType();
JavaType actualContentType = contentType;

if (actualContentType == null && streamType != null && streamType.containedTypeCount() == 1) {
actualContentType = streamType.containedType(0);
}

if (actualContentType == null) {
actualContentType = ctxt.getTypeFactory().constructType(Object.class);
}

JavaType listType = ctxt.getTypeFactory().constructCollectionType(List.class, actualContentType);
List<?> list = ctxt.readValue(p, listType);
return list.stream();
}

@Override
public JsonDeserializer<?> createContextual(DeserializationContext ctxt, BeanProperty property) {
JavaType contextualType = ctxt.getContextualType();
JavaType actualContentType = null;

if (contextualType != null && contextualType.containedTypeCount() == 1) {
actualContentType = contextualType.containedType(0);
}

return new StreamDeserializer(actualContentType);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.keycloak.admin.client.wrapper;

import jakarta.ws.rs.client.WebTarget;

import org.keycloak.admin.api.AdminRootV2;
import org.keycloak.admin.api.client.ClientsApi;
import org.keycloak.admin.client.spi.ResteasyClientProvider;

public class Clients {

private final ResteasyClientProvider clientProvider;
private final WebTarget target;
private final String realmName;

public Clients(ResteasyClientProvider clientProvider, WebTarget target, String realmName) {
this.clientProvider = clientProvider;
this.target = target;
this.realmName = realmName;
}

public ClientsApi v2() {
return clientProvider.targetProxy(target, AdminRootV2.class).adminApi(realmName).clients("v2");
}
}
Original file line number Diff line number Diff line change
@@ -1,48 +1,21 @@
package org.keycloak.admin.api;

import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.OPTIONS;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;

import org.keycloak.common.Profile;
import org.keycloak.models.KeycloakSession;
import org.keycloak.services.resources.admin.AdminCorsPreflightService;

import org.eclipse.microprofile.openapi.annotations.Operation;

@Provider
@Path("admin/api")
public class AdminRootV2 {

@Context
protected KeycloakSession session;
public interface AdminRootV2 {

@Path("{realmName}")
public AdminApi adminApi(@PathParam("realmName") String realmName) {
checkApiEnabled();
return new DefaultAdminApi(session, realmName);
}
AdminApi adminApi(@PathParam("realmName") String realmName);

// TODO Fix preflights
@Path("{realmName}/{any:.*}")
@OPTIONS
@Operation(hidden = true)
public Response preFlight() {
checkApiEnabled();
return new AdminCorsPreflightService().preflight();
}

private void checkApiEnabled() {
if (!isAdminApiV2Enabled()) {
throw new NotFoundException();
}
}

public static boolean isAdminApiV2Enabled() {
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2); // There's currently only Client API for the new Admin API v2
}
Response preFlight();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package org.keycloak.admin.api;

import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;

import org.keycloak.common.Profile;
import org.keycloak.models.KeycloakSession;
import org.keycloak.services.resources.admin.AdminCorsPreflightService;

@Provider
public class DefaultAdminRootV2 implements AdminRootV2 {

@Context
protected KeycloakSession session;

@Override
public AdminApi adminApi(String realmName) {
checkApiEnabled();
return new DefaultAdminApi(session, realmName);
}

@Override
public Response preFlight() {
checkApiEnabled();
return new AdminCorsPreflightService().preflight();
}

private void checkApiEnabled() {
if (!isAdminApiV2Enabled()) {
throw new NotFoundException();
}
}

public static boolean isAdminApiV2Enabled() {
return Profile.isFeatureEnabled(Profile.Feature.CLIENT_ADMIN_API_V2); // There's currently only Client API for the new Admin API v2
}
}
4 changes: 0 additions & 4 deletions rest/admin-v2/tests/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,6 @@
<groupId>org.keycloak.testframework</groupId>
<artifactId>keycloak-test-framework-junit5-config</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-v2-rest</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak.tests</groupId>
<artifactId>keycloak-tests-custom-providers</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ public String getRealmName() {
public String getClientsApiUrl() {
return "http://localhost:8080/admin/api/%s/clients/v2".formatted(getRealmName());
}

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.

Just a nitpick. Not necessary change.

public String getClientApiUrl(String clientId) {
return "http://localhost:8080/admin/api/%s/clients/v2/%s".formatted(getRealmName(), clientId);
}
Expand Down
Loading
Loading
0