This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26196-keycloak-pojo-response in repository https://gitbox.apache.org/repos/asf/camel.git
commit 7b461286f7c4989386d2e905c93c8ea311db483d Author: Claus Ibsen <[email protected]> AuthorDate: Tue Nov 3 00:12:33 2026 +0100 CAMEL-26196: camel-keycloak - convert a pojoRequest body, fail a create or delete with a non-2xx status, and close its Response With pojoRequest=true, a body that was not the Keycloak representation of the operation did nothing: no call, no error, and the body was kept. The body is now converted (type converter, or a Map or JSON text), and fails with an InvalidPayloadException when it does not convert. The operations that give the JAX-RS Response of Keycloak (createUser, deleteUser, createGroup, createOrganization, ...) completed on a 409 or 404, and never closed the Response. A status other than 2xx now fails with a WebApplicationException, and the Response is closed (its status and Location can still be read). deleteOrganization, removeOrganizationMember and unlinkOrganizationIdentityProvider ignored their Response the same way. bulkCreateUsers and bulkUpdateUsers cast a List of Map to UserRepresentation and failed with a raw ClassCastException; each element is now converted. bulkCreateUsers and bulkDeleteUsers counted a 409 or 404 as a success. Co-Authored-By: Claude Opus 5.5 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../apache/camel/catalog/components/keycloak.json | 4 +- .../camel/catalog/docs/keycloak-producer.adoc | 26 +- .../apache/camel/component/keycloak/keycloak.json | 4 +- .../src/main/docs/keycloak-producer.adoc | 26 +- .../camel/component/keycloak/KeycloakEndpoint.java | 15 +- .../camel/component/keycloak/KeycloakProducer.java | 417 ++++++++++++--------- ...KeycloakProducerPojoRequestAndResponseTest.java | 344 +++++++++++++++++ .../component/keycloak/KeycloakProducerTest.java | 10 + .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 15 + 9 files changed, 672 insertions(+), 189 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/keycloak.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/keycloak.json index 3c9bd12bf19d..d6844636797c 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/keycloak.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/keycloak.json @@ -129,8 +129,8 @@ }, "bodyTypes": { "consumerOut": { "carriers": [ "pojo" ], "javaType": "org.keycloak.representations.idm.EventRepresentation", "byOption": [ { "option": "eventType", "value": "admin-events", "carriers": [ "pojo" ], "javaType": "org.keycloak.representations.idm.AdminEventRepresentation" } ], "description": "One message per event: the EventRepresentation of a user event such as a login, or the AdminEventRepresentation with eventType=admin-events. The CamelKeycloakEventType header says event or admin-event" }, - "producerIn": { "carriers": [ "any" ], "byOption": [ { "option": "pojoRequest", "value": "true", "carriers": [ "pojo" ] } ], "description": "Mostly not read: the operations take their input from headers, such as CamelKeycloakRealmName and CamelKeycloakUserId. With pojoRequest=true the create and update operations take the Keycloak representation, such as a UserRepresentation for createUser; a body of another type does nothing and keeps the body. The bulk operations take a List when t [...] - "producerOut": { "carriers": [ "unknown" ], "byOption": [ { "option": "operation", "value": "createRealm", "carriers": [ "text" ], "javaType": "java.lang.String" }, { "option": "operation", "value": "deleteRealm", "carriers": [ "text" ], "javaType": "java.lang.String" }, { "option": "operation", "value": "getRealm", "carriers": [ "pojo" ], "javaType": "org.keycloak.representations.idm.RealmRepresentation" }, { "option": "operation", "value": "updateRealm", "carriers": [ "text" ], "ja [...] + "producerIn": { "carriers": [ "any" ], "byOption": [ { "option": "pojoRequest", "value": "true", "carriers": [ "pojo" ] } ], "description": "Mostly not read: the operations take their input from headers, such as CamelKeycloakRealmName and CamelKeycloakUserId. With pojoRequest=true the create and update operations take the Keycloak representation, such as a UserRepresentation for createUser, or a Map or JSON text of it; a body that does not convert fails. The bulk operations take a Li [...] + "producerOut": { "carriers": [ "unknown" ], "byOption": [ { "option": "operation", "value": "createRealm", "carriers": [ "text" ], "javaType": "java.lang.String" }, { "option": "operation", "value": "deleteRealm", "carriers": [ "text" ], "javaType": "java.lang.String" }, { "option": "operation", "value": "getRealm", "carriers": [ "pojo" ], "javaType": "org.keycloak.representations.idm.RealmRepresentation" }, { "option": "operation", "value": "updateRealm", "carriers": [ "text" ], "ja [...] }, "properties": { "label": { "index": 0, "kind": "path", "displayName": "Label", "group": "common", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.keycloak.KeycloakConfiguration", "configurationField": "configuration", "description": "Logical name" }, diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keycloak-producer.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keycloak-producer.adoc index 320d98815f57..980b9e7bf553 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keycloak-producer.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keycloak-producer.adoc @@ -132,6 +132,29 @@ All producer operations follow the same pattern: set the operation name in the U template.sendBodyAndHeaders("keycloak:admin?operation=<operationName>", null, headers); ---- +=== Representation bodies + +With `pojoRequest=true`, the create and update operations take the Keycloak representation as the body, such as a +`UserRepresentation` for `createUser` and `updateUser`. A body of another type is converted: a `Map` (such as +unmarshalled JSON) or JSON text is mapped to the representation. A body that does not convert fails the exchange +with an `InvalidPayloadException`. + +The elements of the `List` of `bulkCreateUsers` and `bulkUpdateUsers` are converted the same way; an element that +does not convert fails the exchange before any user is created or updated. + +=== Results of create and delete operations + +`createUser`, `deleteUser`, `createGroup`, `createClient`, `createClientScope`, `createIdentityProvider`, +`createResource`, `createResourcePolicy`, `createResourcePermission`, `createOrganization`, `updateOrganization`, +`addOrganizationMember` and `linkOrganizationIdentityProvider` give the JAX-RS `Response` of Keycloak as the body. +The Response is closed, but its status and headers can still be read, such as the `Location` header with the URL +(ending with the id) of the created resource. + +When Keycloak answers with a status other than 2xx, such as 409 (already exists) or 404 (not found), the exchange +fails with a `jakarta.ws.rs.WebApplicationException`, whose `getResponse()` gives the status. The same goes for +`deleteOrganization`, `removeOrganizationMember` and `unlinkOrganizationIdentityProvider`. The bulk operations +record such a status as a failed item. + == User Operations [tabs] @@ -672,7 +695,8 @@ All bulk operations return a consistent response format: { "username": "user2", "status": "failed", - "error": "User already exists" + "statusCode": 409, + "error": "Keycloak createUser failed with status 409: {\"errorMessage\":\"User exists with same username\"}" } ] } diff --git a/components/camel-keycloak/src/generated/resources/META-INF/org/apache/camel/component/keycloak/keycloak.json b/components/camel-keycloak/src/generated/resources/META-INF/org/apache/camel/component/keycloak/keycloak.json index 3c9bd12bf19d..d6844636797c 100644 --- a/components/camel-keycloak/src/generated/resources/META-INF/org/apache/camel/component/keycloak/keycloak.json +++ b/components/camel-keycloak/src/generated/resources/META-INF/org/apache/camel/component/keycloak/keycloak.json @@ -129,8 +129,8 @@ }, "bodyTypes": { "consumerOut": { "carriers": [ "pojo" ], "javaType": "org.keycloak.representations.idm.EventRepresentation", "byOption": [ { "option": "eventType", "value": "admin-events", "carriers": [ "pojo" ], "javaType": "org.keycloak.representations.idm.AdminEventRepresentation" } ], "description": "One message per event: the EventRepresentation of a user event such as a login, or the AdminEventRepresentation with eventType=admin-events. The CamelKeycloakEventType header says event or admin-event" }, - "producerIn": { "carriers": [ "any" ], "byOption": [ { "option": "pojoRequest", "value": "true", "carriers": [ "pojo" ] } ], "description": "Mostly not read: the operations take their input from headers, such as CamelKeycloakRealmName and CamelKeycloakUserId. With pojoRequest=true the create and update operations take the Keycloak representation, such as a UserRepresentation for createUser; a body of another type does nothing and keeps the body. The bulk operations take a List when t [...] - "producerOut": { "carriers": [ "unknown" ], "byOption": [ { "option": "operation", "value": "createRealm", "carriers": [ "text" ], "javaType": "java.lang.String" }, { "option": "operation", "value": "deleteRealm", "carriers": [ "text" ], "javaType": "java.lang.String" }, { "option": "operation", "value": "getRealm", "carriers": [ "pojo" ], "javaType": "org.keycloak.representations.idm.RealmRepresentation" }, { "option": "operation", "value": "updateRealm", "carriers": [ "text" ], "ja [...] + "producerIn": { "carriers": [ "any" ], "byOption": [ { "option": "pojoRequest", "value": "true", "carriers": [ "pojo" ] } ], "description": "Mostly not read: the operations take their input from headers, such as CamelKeycloakRealmName and CamelKeycloakUserId. With pojoRequest=true the create and update operations take the Keycloak representation, such as a UserRepresentation for createUser, or a Map or JSON text of it; a body that does not convert fails. The bulk operations take a Li [...] + "producerOut": { "carriers": [ "unknown" ], "byOption": [ { "option": "operation", "value": "createRealm", "carriers": [ "text" ], "javaType": "java.lang.String" }, { "option": "operation", "value": "deleteRealm", "carriers": [ "text" ], "javaType": "java.lang.String" }, { "option": "operation", "value": "getRealm", "carriers": [ "pojo" ], "javaType": "org.keycloak.representations.idm.RealmRepresentation" }, { "option": "operation", "value": "updateRealm", "carriers": [ "text" ], "ja [...] }, "properties": { "label": { "index": 0, "kind": "path", "displayName": "Label", "group": "common", "label": "", "required": true, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.keycloak.KeycloakConfiguration", "configurationField": "configuration", "description": "Logical name" }, diff --git a/components/camel-keycloak/src/main/docs/keycloak-producer.adoc b/components/camel-keycloak/src/main/docs/keycloak-producer.adoc index 320d98815f57..980b9e7bf553 100644 --- a/components/camel-keycloak/src/main/docs/keycloak-producer.adoc +++ b/components/camel-keycloak/src/main/docs/keycloak-producer.adoc @@ -132,6 +132,29 @@ All producer operations follow the same pattern: set the operation name in the U template.sendBodyAndHeaders("keycloak:admin?operation=<operationName>", null, headers); ---- +=== Representation bodies + +With `pojoRequest=true`, the create and update operations take the Keycloak representation as the body, such as a +`UserRepresentation` for `createUser` and `updateUser`. A body of another type is converted: a `Map` (such as +unmarshalled JSON) or JSON text is mapped to the representation. A body that does not convert fails the exchange +with an `InvalidPayloadException`. + +The elements of the `List` of `bulkCreateUsers` and `bulkUpdateUsers` are converted the same way; an element that +does not convert fails the exchange before any user is created or updated. + +=== Results of create and delete operations + +`createUser`, `deleteUser`, `createGroup`, `createClient`, `createClientScope`, `createIdentityProvider`, +`createResource`, `createResourcePolicy`, `createResourcePermission`, `createOrganization`, `updateOrganization`, +`addOrganizationMember` and `linkOrganizationIdentityProvider` give the JAX-RS `Response` of Keycloak as the body. +The Response is closed, but its status and headers can still be read, such as the `Location` header with the URL +(ending with the id) of the created resource. + +When Keycloak answers with a status other than 2xx, such as 409 (already exists) or 404 (not found), the exchange +fails with a `jakarta.ws.rs.WebApplicationException`, whose `getResponse()` gives the status. The same goes for +`deleteOrganization`, `removeOrganizationMember` and `unlinkOrganizationIdentityProvider`. The bulk operations +record such a status as a failed item. + == User Operations [tabs] @@ -672,7 +695,8 @@ All bulk operations return a consistent response format: { "username": "user2", "status": "failed", - "error": "User already exists" + "statusCode": 409, + "error": "Keycloak createUser failed with status 409: {\"errorMessage\":\"User exists with same username\"}" } ] } diff --git a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/KeycloakEndpoint.java b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/KeycloakEndpoint.java index a85258706031..0cc36f10b725 100644 --- a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/KeycloakEndpoint.java +++ b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/KeycloakEndpoint.java @@ -50,10 +50,11 @@ import org.keycloak.admin.client.KeycloakBuilder; description = "Mostly not read: the operations take their input from headers, such as" + " CamelKeycloakRealmName and CamelKeycloakUserId. With pojoRequest=true the" + " create and update operations take the Keycloak representation, such as a" - + " UserRepresentation for createUser; a body of another type does nothing" - + " and keeps the body. The bulk operations take a List when" - + " the header is not set: of UserRepresentation for bulkCreateUsers and" - + " bulkUpdateUsers, of String ids or role names for the others." + + " UserRepresentation for createUser, or a Map or JSON text of it; a body" + + " that does not convert fails. The bulk operations take a List when" + + " the header is not set: of UserRepresentation (or a Map or JSON text of" + + " it) for bulkCreateUsers and bulkUpdateUsers, of String ids or role names" + + " for the others." + " introspectToken, revokeAccessToken and revokeRefreshToken take the token" + " as text when the CamelKeycloakToken header is not set"), producerOut = @BodyType(value = Carrier.UNKNOWN, @@ -299,8 +300,10 @@ import org.keycloak.admin.client.KeycloakBuilder; description = "Depends on the operation, which the CamelKeycloakOperation header can set:" + " get gives the Keycloak representation, such as a UserRepresentation, and" + " list and search a List of them. Most create operations, deleteUser and" - + " some organization operations give the JAX-RS Response, whose status says" - + " whether it worked (a 409 or 404 is no error); the others" + + " some organization operations give the closed JAX-RS Response, whose" + + " status and Location header (the URL of the created resource) can be" + + " read; a status other than 2xx fails with a WebApplicationException. The" + + " others give" + " a confirmation text such as Realm deleted successfully. The bulk" + " operations give a Map with total, success, failed and results")) public class KeycloakEndpoint extends ScheduledPollEndpoint implements EndpointServiceLocation { diff --git a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/KeycloakProducer.java b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/KeycloakProducer.java index 0a9913dc2cbb..9a9c5f5cea29 100644 --- a/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/KeycloakProducer.java +++ b/components/camel-keycloak/src/main/java/org/apache/camel/component/keycloak/KeycloakProducer.java @@ -16,6 +16,8 @@ */ package org.apache.camel.component.keycloak; +import java.io.IOException; +import java.io.InputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; @@ -23,6 +25,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import jakarta.ws.rs.WebApplicationException; import jakarta.ws.rs.core.Response; import org.apache.camel.Endpoint; @@ -63,6 +66,7 @@ import org.keycloak.representations.idm.authorization.Permission; import org.keycloak.representations.idm.authorization.PolicyRepresentation; import org.keycloak.representations.idm.authorization.ResourcePermissionRepresentation; import org.keycloak.representations.idm.authorization.ResourceRepresentation; +import org.keycloak.util.JsonSerialization; /** * A Producer which sends messages to Keycloak Admin API @@ -442,14 +446,107 @@ public class KeycloakProducer extends DefaultProducer { return (KeycloakEndpoint) super.getEndpoint(); } + /** + * The body of a pojoRequest operation as the given Keycloak representation. Another type is converted: by a type + * converter, or from a Map (such as unmarshalled JSON) or JSON text. A body that does not convert fails. + */ + private static <T> T getPojoRequest(Exchange exchange, Class<T> type) throws InvalidPayloadException { + Message in = exchange.getIn(); + Object body = in.getMandatoryBody(); + T answer; + try { + answer = toRepresentation(exchange, body, type); + } catch (IOException | IllegalArgumentException e) { + throw new InvalidPayloadException(exchange, type, in, e); + } + if (answer == null) { + throw new InvalidPayloadException(exchange, type, in); + } + return answer; + } + + /** + * Converts each element of a List body or header to the given Keycloak representation, the same way as + * {@link #getPojoRequest(Exchange, Class)}. An element that does not convert fails before any call to Keycloak. + */ + private static <T> List<T> toRepresentations(Exchange exchange, List<?> list, Class<T> type) { + List<T> answer = new ArrayList<>(list.size()); + for (int i = 0; i < list.size(); i++) { + Object element = list.get(i); + T value = null; + if (element != null) { + try { + value = toRepresentation(exchange, element, type); + } catch (IOException | IllegalArgumentException e) { + throw new IllegalArgumentException( + "Element " + i + " of the list cannot be converted to " + type.getSimpleName() + ": " + + e.getMessage(), + e); + } + } + if (value == null) { + throw new IllegalArgumentException( + "Element " + i + " of the list must be a " + type.getSimpleName() + ", a Map or JSON text, but was: " + + (element != null ? element.getClass().getName() : "null")); + } + answer.add(value); + } + return answer; + } + + private static <T> T toRepresentation(Exchange exchange, Object value, Class<T> type) throws IOException { + if (type.isInstance(value)) { + return type.cast(value); + } + T answer = exchange.getContext().getTypeConverter().tryConvertTo(type, exchange, value); + if (answer != null) { + return answer; + } + if (value instanceof Map) { + return JsonSerialization.mapper.convertValue(value, type); + } + if (value instanceof CharSequence || value instanceof byte[] || value instanceof InputStream) { + String json = exchange.getContext().getTypeConverter().tryConvertTo(String.class, exchange, value); + if (json != null) { + return JsonSerialization.readValue(json, type); + } + } + return null; + } + + /** + * Fails on a non-2xx status of a Response that Keycloak returned, and closes it. The closed Response is returned: + * its status and headers, such as the Location of a created resource, can still be read. + */ + private static Response checkResponse(Response response, String operation) { + try { + int status = response.getStatus(); + if (status < 200 || status >= 300) { + String error = null; + try { + if (response.hasEntity()) { + error = response.readEntity(String.class); + } + } catch (Exception e) { + // ignore, the status is enough + } + throw new WebApplicationException( + "Keycloak " + operation + " failed with status " + status + + (ObjectHelper.isNotEmpty(error) ? ": " + error : ""), + response); + } + } finally { + response.close(); + } + return response; + } + private void createRealm(Keycloak keycloakClient, Exchange exchange) throws InvalidPayloadException { if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof RealmRepresentation rr) { - keycloakClient.realms().create(rr); - Message message = getMessageForResponse(exchange); - message.setBody("Realm created successfully"); - } + RealmRepresentation rr = getPojoRequest(exchange, RealmRepresentation.class); + keycloakClient.realms().create(rr); + Message message = getMessageForResponse(exchange); + message.setBody("Realm created successfully"); } else { RealmRepresentation realm = new RealmRepresentation(); if (ObjectHelper.isNotEmpty(exchange.getIn().getHeader(KeycloakConstants.REALM_NAME))) { @@ -494,12 +591,10 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof RealmRepresentation rr) { - keycloakClient.realm(realmName).update(rr); - Message message = getMessageForResponse(exchange); - message.setBody("Realm updated successfully"); - } + RealmRepresentation rr = getPojoRequest(exchange, RealmRepresentation.class); + keycloakClient.realm(realmName).update(rr); + Message message = getMessageForResponse(exchange); + message.setBody("Realm updated successfully"); } else { throw new IllegalArgumentException("Update realm requires POJO request with RealmRepresentation"); } @@ -512,12 +607,10 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof UserRepresentation ur) { - Response response = keycloakClient.realm(realmName).users().create(ur); - Message message = getMessageForResponse(exchange); - message.setBody(response); - } + UserRepresentation ur = getPojoRequest(exchange, UserRepresentation.class); + Response response = keycloakClient.realm(realmName).users().create(ur); + Message message = getMessageForResponse(exchange); + message.setBody(checkResponse(response, "createUser")); } else { UserRepresentation user = new UserRepresentation(); if (ObjectHelper.isNotEmpty(exchange.getIn().getHeader(KeycloakConstants.USERNAME))) { @@ -539,7 +632,7 @@ public class KeycloakProducer extends DefaultProducer { } Response response = keycloakClient.realm(realmName).users().create(user); Message message = getMessageForResponse(exchange); - message.setBody(response); + message.setBody(checkResponse(response, "createUser")); } } @@ -556,7 +649,7 @@ public class KeycloakProducer extends DefaultProducer { Response response = keycloakClient.realm(realmName).users().delete(userId); Message message = getMessageForResponse(exchange); - message.setBody(response); + message.setBody(checkResponse(response, "deleteUser")); } private void getUser(Keycloak keycloakClient, Exchange exchange) { @@ -587,12 +680,10 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof UserRepresentation ur) { - keycloakClient.realm(realmName).users().get(userId).update(ur); - Message message = getMessageForResponse(exchange); - message.setBody("User updated successfully"); - } + UserRepresentation ur = getPojoRequest(exchange, UserRepresentation.class); + keycloakClient.realm(realmName).users().get(userId).update(ur); + Message message = getMessageForResponse(exchange); + message.setBody("User updated successfully"); } else { throw new IllegalArgumentException("Update user requires POJO request with UserRepresentation"); } @@ -616,12 +707,10 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof RoleRepresentation rr) { - keycloakClient.realm(realmName).roles().create(rr); - Message message = getMessageForResponse(exchange); - message.setBody("Role created successfully"); - } + RoleRepresentation rr = getPojoRequest(exchange, RoleRepresentation.class); + keycloakClient.realm(realmName).roles().create(rr); + Message message = getMessageForResponse(exchange); + message.setBody("Role created successfully"); } else { RoleRepresentation role = new RoleRepresentation(); if (ObjectHelper.isNotEmpty(exchange.getIn().getHeader(KeycloakConstants.ROLE_NAME))) { @@ -684,12 +773,10 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof RoleRepresentation rr) { - keycloakClient.realm(realmName).roles().get(roleName).update(rr); - Message message = getMessageForResponse(exchange); - message.setBody("Role updated successfully"); - } + RoleRepresentation rr = getPojoRequest(exchange, RoleRepresentation.class); + keycloakClient.realm(realmName).roles().get(roleName).update(rr); + Message message = getMessageForResponse(exchange); + message.setBody("Role updated successfully"); } else { throw new IllegalArgumentException("Update role requires POJO request with RoleRepresentation"); } @@ -757,12 +844,10 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof GroupRepresentation gr) { - Response response = keycloakClient.realm(realmName).groups().add(gr); - Message message = getMessageForResponse(exchange); - message.setBody(response); - } + GroupRepresentation gr = getPojoRequest(exchange, GroupRepresentation.class); + Response response = keycloakClient.realm(realmName).groups().add(gr); + Message message = getMessageForResponse(exchange); + message.setBody(checkResponse(response, "createGroup")); } else { GroupRepresentation group = new GroupRepresentation(); if (ObjectHelper.isNotEmpty(exchange.getIn().getHeader(KeycloakConstants.GROUP_NAME))) { @@ -773,7 +858,7 @@ public class KeycloakProducer extends DefaultProducer { } Response response = keycloakClient.realm(realmName).groups().add(group); Message message = getMessageForResponse(exchange); - message.setBody(response); + message.setBody(checkResponse(response, "createGroup")); } } @@ -821,12 +906,10 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof GroupRepresentation gr) { - keycloakClient.realm(realmName).groups().group(groupId).update(gr); - Message message = getMessageForResponse(exchange); - message.setBody("Group updated successfully"); - } + GroupRepresentation gr = getPojoRequest(exchange, GroupRepresentation.class); + keycloakClient.realm(realmName).groups().group(groupId).update(gr); + Message message = getMessageForResponse(exchange); + message.setBody("Group updated successfully"); } else { throw new IllegalArgumentException("Update group requires POJO request with GroupRepresentation"); } @@ -908,12 +991,10 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof ClientRepresentation cr) { - Response response = keycloakClient.realm(realmName).clients().create(cr); - Message message = getMessageForResponse(exchange); - message.setBody(response); - } + ClientRepresentation cr = getPojoRequest(exchange, ClientRepresentation.class); + Response response = keycloakClient.realm(realmName).clients().create(cr); + Message message = getMessageForResponse(exchange); + message.setBody(checkResponse(response, "createClient")); } else { ClientRepresentation client = new ClientRepresentation(); if (ObjectHelper.isNotEmpty(exchange.getIn().getHeader(KeycloakConstants.CLIENT_ID))) { @@ -925,7 +1006,7 @@ public class KeycloakProducer extends DefaultProducer { } Response response = keycloakClient.realm(realmName).clients().create(client); Message message = getMessageForResponse(exchange); - message.setBody(response); + message.setBody(checkResponse(response, "createClient")); } } @@ -973,12 +1054,10 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof ClientRepresentation cr) { - keycloakClient.realm(realmName).clients().get(clientUuid).update(cr); - Message message = getMessageForResponse(exchange); - message.setBody("Client updated successfully"); - } + ClientRepresentation cr = getPojoRequest(exchange, ClientRepresentation.class); + keycloakClient.realm(realmName).clients().get(clientUuid).update(cr); + Message message = getMessageForResponse(exchange); + message.setBody("Client updated successfully"); } else { throw new IllegalArgumentException("Update client requires POJO request with ClientRepresentation"); } @@ -1052,12 +1131,10 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof RoleRepresentation rr) { - keycloakClient.realm(realmName).clients().get(clientUuid).roles().create(rr); - Message message = getMessageForResponse(exchange); - message.setBody("Client role created successfully"); - } + RoleRepresentation rr = getPojoRequest(exchange, RoleRepresentation.class); + keycloakClient.realm(realmName).clients().get(clientUuid).roles().create(rr); + Message message = getMessageForResponse(exchange); + message.setBody("Client role created successfully"); } else { RoleRepresentation role = new RoleRepresentation(); if (ObjectHelper.isNotEmpty(exchange.getIn().getHeader(KeycloakConstants.ROLE_NAME))) { @@ -1136,13 +1213,11 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof RoleRepresentation rr) { - keycloakClient.realm(realmName).clients().get(clientUuid).roles().get(roleName) - .update(rr); - Message message = getMessageForResponse(exchange); - message.setBody("Client role updated successfully"); - } + RoleRepresentation rr = getPojoRequest(exchange, RoleRepresentation.class); + keycloakClient.realm(realmName).clients().get(clientUuid).roles().get(roleName) + .update(rr); + Message message = getMessageForResponse(exchange); + message.setBody("Client role updated successfully"); } else { throw new IllegalArgumentException("Update client role requires POJO request with RoleRepresentation"); } @@ -1407,12 +1482,10 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof ClientScopeRepresentation csr) { - Response response = keycloakClient.realm(realmName).clientScopes().create(csr); - Message message = getMessageForResponse(exchange); - message.setBody(response); - } + ClientScopeRepresentation csr = getPojoRequest(exchange, ClientScopeRepresentation.class); + Response response = keycloakClient.realm(realmName).clientScopes().create(csr); + Message message = getMessageForResponse(exchange); + message.setBody(checkResponse(response, "createClientScope")); } else { ClientScopeRepresentation clientScope = new ClientScopeRepresentation(); if (ObjectHelper.isNotEmpty(exchange.getIn().getHeader(KeycloakConstants.CLIENT_SCOPE_NAME))) { @@ -1423,7 +1496,7 @@ public class KeycloakProducer extends DefaultProducer { } Response response = keycloakClient.realm(realmName).clientScopes().create(clientScope); Message message = getMessageForResponse(exchange); - message.setBody(response); + message.setBody(checkResponse(response, "createClientScope")); } } @@ -1472,12 +1545,10 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof ClientScopeRepresentation csr) { - keycloakClient.realm(realmName).clientScopes().get(clientScopeId).update(csr); - Message message = getMessageForResponse(exchange); - message.setBody("Client scope updated successfully"); - } + ClientScopeRepresentation csr = getPojoRequest(exchange, ClientScopeRepresentation.class); + keycloakClient.realm(realmName).clientScopes().get(clientScopeId).update(csr); + Message message = getMessageForResponse(exchange); + message.setBody("Client scope updated successfully"); } else { throw new IllegalArgumentException("Update client scope requires POJO request with ClientScopeRepresentation"); } @@ -1502,13 +1573,11 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof IdentityProviderRepresentation idpRepresentation) { - Response response - = keycloakClient.realm(realmName).identityProviders().create(idpRepresentation); - Message message = getMessageForResponse(exchange); - message.setBody(response); - } + IdentityProviderRepresentation idpRepresentation = getPojoRequest(exchange, IdentityProviderRepresentation.class); + Response response + = keycloakClient.realm(realmName).identityProviders().create(idpRepresentation); + Message message = getMessageForResponse(exchange); + message.setBody(checkResponse(response, "createIdentityProvider")); } else { throw new IllegalArgumentException( "Create identity provider requires POJO request with IdentityProviderRepresentation"); @@ -1560,13 +1629,11 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof IdentityProviderRepresentation idpRepresentation) { - keycloakClient.realm(realmName).identityProviders().get(idpAlias) - .update(idpRepresentation); - Message message = getMessageForResponse(exchange); - message.setBody("Identity provider updated successfully"); - } + IdentityProviderRepresentation idpRepresentation = getPojoRequest(exchange, IdentityProviderRepresentation.class); + keycloakClient.realm(realmName).identityProviders().get(idpAlias) + .update(idpRepresentation); + Message message = getMessageForResponse(exchange); + message.setBody("Identity provider updated successfully"); } else { throw new IllegalArgumentException( "Update identity provider requires POJO request with IdentityProviderRepresentation"); @@ -1597,13 +1664,11 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof ResourceRepresentation resourceRepresentation) { - Response response = keycloakClient.realm(realmName).clients().get(clientUuid).authorization().resources() - .create(resourceRepresentation); - Message message = getMessageForResponse(exchange); - message.setBody(response); - } + ResourceRepresentation resourceRepresentation = getPojoRequest(exchange, ResourceRepresentation.class); + Response response = keycloakClient.realm(realmName).clients().get(clientUuid).authorization().resources() + .create(resourceRepresentation); + Message message = getMessageForResponse(exchange); + message.setBody(checkResponse(response, "createResource")); } else { throw new IllegalArgumentException("Create resource requires POJO request with ResourceRepresentation"); } @@ -1670,13 +1735,11 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof ResourceRepresentation resourceRepresentation) { - keycloakClient.realm(realmName).clients().get(clientUuid).authorization().resources().resource(resourceId) - .update(resourceRepresentation); - Message message = getMessageForResponse(exchange); - message.setBody("Resource updated successfully"); - } + ResourceRepresentation resourceRepresentation = getPojoRequest(exchange, ResourceRepresentation.class); + keycloakClient.realm(realmName).clients().get(clientUuid).authorization().resources().resource(resourceId) + .update(resourceRepresentation); + Message message = getMessageForResponse(exchange); + message.setBody("Resource updated successfully"); } else { throw new IllegalArgumentException("Update resource requires POJO request with ResourceRepresentation"); } @@ -1711,13 +1774,11 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof PolicyRepresentation policyRepresentation) { - Response response = keycloakClient.realm(realmName).clients().get(clientUuid).authorization().policies() - .create(policyRepresentation); - Message message = getMessageForResponse(exchange); - message.setBody(response); - } + PolicyRepresentation policyRepresentation = getPojoRequest(exchange, PolicyRepresentation.class); + Response response = keycloakClient.realm(realmName).clients().get(clientUuid).authorization().policies() + .create(policyRepresentation); + Message message = getMessageForResponse(exchange); + message.setBody(checkResponse(response, "createResourcePolicy")); } else { throw new IllegalArgumentException("Create policy requires POJO request with PolicyRepresentation"); } @@ -1784,13 +1845,11 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof PolicyRepresentation policyRepresentation) { - keycloakClient.realm(realmName).clients().get(clientUuid).authorization().policies().policy(policyId) - .update(policyRepresentation); - Message message = getMessageForResponse(exchange); - message.setBody("Policy updated successfully"); - } + PolicyRepresentation policyRepresentation = getPojoRequest(exchange, PolicyRepresentation.class); + keycloakClient.realm(realmName).clients().get(clientUuid).authorization().policies().policy(policyId) + .update(policyRepresentation); + Message message = getMessageForResponse(exchange); + message.setBody("Policy updated successfully"); } else { throw new IllegalArgumentException("Update policy requires POJO request with PolicyRepresentation"); } @@ -1825,13 +1884,12 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof ResourcePermissionRepresentation permissionRepresentation) { - Response response = keycloakClient.realm(realmName).clients().get(clientUuid).authorization().permissions() - .resource().create(permissionRepresentation); - Message message = getMessageForResponse(exchange); - message.setBody(response); - } + ResourcePermissionRepresentation permissionRepresentation + = getPojoRequest(exchange, ResourcePermissionRepresentation.class); + Response response = keycloakClient.realm(realmName).clients().get(clientUuid).authorization().permissions() + .resource().create(permissionRepresentation); + Message message = getMessageForResponse(exchange); + message.setBody(checkResponse(response, "createResourcePermission")); } else { throw new IllegalArgumentException("Create permission requires POJO request with ResourcePermissionRepresentation"); } @@ -1900,14 +1958,12 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof PolicyRepresentation policyRepresentation) { - // Use policy endpoint for permissions - keycloakClient.realm(realmName).clients().get(clientUuid).authorization().policies().policy(permissionId) - .update(policyRepresentation); - Message message = getMessageForResponse(exchange); - message.setBody("Permission updated successfully"); - } + PolicyRepresentation policyRepresentation = getPojoRequest(exchange, PolicyRepresentation.class); + // Use policy endpoint for permissions + keycloakClient.realm(realmName).clients().get(clientUuid).authorization().policies().policy(permissionId) + .update(policyRepresentation); + Message message = getMessageForResponse(exchange); + message.setBody("Permission updated successfully"); } else { throw new IllegalArgumentException("Update permission requires POJO request with PolicyRepresentation"); } @@ -2330,16 +2386,18 @@ public class KeycloakProducer extends DefaultProducer { throw new IllegalArgumentException(MISSING_REALM_NAME); } - List<UserRepresentation> users = exchange.getIn().getHeader(KeycloakConstants.USERS, List.class); - if (users == null || users.isEmpty()) { + List<?> list = exchange.getIn().getHeader(KeycloakConstants.USERS, List.class); + if (list == null || list.isEmpty()) { // Try to get from body Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof List) { - users = CastUtils.cast((List<?>) payload); + if (payload instanceof List<?> l) { + list = l; } else { throw new IllegalArgumentException("Users list must be provided via header or body"); } } + // the elements can be Maps (such as unmarshalled JSON) or JSON text as well + List<UserRepresentation> users = toRepresentations(exchange, list, UserRepresentation.class); boolean continueOnError = exchange.getIn().getHeader(KeycloakConstants.CONTINUE_ON_ERROR, Boolean.FALSE, Boolean.class); @@ -2350,13 +2408,16 @@ public class KeycloakProducer extends DefaultProducer { for (UserRepresentation user : users) { Map<String, Object> result = new HashMap<>(); result.put("username", user.getUsername()); - try (Response response = keycloakClient.realm(realmName).users().create(user)) { + try { + Response response = checkResponse(keycloakClient.realm(realmName).users().create(user), "createUser"); result.put("status", "success"); result.put("statusCode", response.getStatus()); successCount++; - response.close(); } catch (Exception e) { result.put("status", "failed"); + if (e instanceof WebApplicationException wae) { + result.put("statusCode", wae.getResponse().getStatus()); + } result.put("error", e.getMessage()); failureCount++; if (!continueOnError) { @@ -2417,13 +2478,15 @@ public class KeycloakProducer extends DefaultProducer { Map<String, Object> result = new HashMap<>(); result.put("userId", userId); try { - Response response = keycloakClient.realm(realmName).users().delete(userId); + Response response = checkResponse(keycloakClient.realm(realmName).users().delete(userId), "deleteUser"); result.put("status", "success"); result.put("statusCode", response.getStatus()); successCount++; - response.close(); } catch (Exception e) { result.put("status", "failed"); + if (e instanceof WebApplicationException wae) { + result.put("statusCode", wae.getResponse().getStatus()); + } result.put("error", e.getMessage()); failureCount++; if (!continueOnError) { @@ -2597,16 +2660,18 @@ public class KeycloakProducer extends DefaultProducer { throw new IllegalArgumentException(MISSING_REALM_NAME); } - List<UserRepresentation> users = exchange.getIn().getHeader(KeycloakConstants.USERS, List.class); - if (users == null || users.isEmpty()) { + List<?> list = exchange.getIn().getHeader(KeycloakConstants.USERS, List.class); + if (list == null || list.isEmpty()) { // Try to get from body Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof List) { - users = CastUtils.cast((List<?>) payload); + if (payload instanceof List<?> l) { + list = l; } else { throw new IllegalArgumentException("Users list must be provided via header or body"); } } + // the elements can be Maps (such as unmarshalled JSON) or JSON text as well + List<UserRepresentation> users = toRepresentations(exchange, list, UserRepresentation.class); boolean continueOnError = exchange.getIn().getHeader(KeycloakConstants.CONTINUE_ON_ERROR, Boolean.FALSE, Boolean.class); @@ -2654,12 +2719,10 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof OrganizationRepresentation orgRepresentation) { - Response response = keycloakClient.realm(realmName).organizations().create(orgRepresentation); - Message message = getMessageForResponse(exchange); - message.setBody(response); - } + OrganizationRepresentation orgRepresentation = getPojoRequest(exchange, OrganizationRepresentation.class); + Response response = keycloakClient.realm(realmName).organizations().create(orgRepresentation); + Message message = getMessageForResponse(exchange); + message.setBody(checkResponse(response, "createOrganization")); } else { String orgName = exchange.getIn().getHeader(KeycloakConstants.ORGANIZATION_NAME, String.class); if (ObjectHelper.isEmpty(orgName)) { @@ -2690,7 +2753,7 @@ public class KeycloakProducer extends DefaultProducer { Response response = keycloakClient.realm(realmName).organizations().create(org); Message message = getMessageForResponse(exchange); - message.setBody(response); + message.setBody(checkResponse(response, "createOrganization")); } } @@ -2706,12 +2769,10 @@ public class KeycloakProducer extends DefaultProducer { } if (getConfiguration().isPojoRequest()) { - Object payload = exchange.getIn().getMandatoryBody(); - if (payload instanceof OrganizationRepresentation orgRepresentation) { - Response response = keycloakClient.realm(realmName).organizations().get(orgId).update(orgRepresentation); - Message message = getMessageForResponse(exchange); - message.setBody(response); - } + OrganizationRepresentation orgRepresentation = getPojoRequest(exchange, OrganizationRepresentation.class); + Response response = keycloakClient.realm(realmName).organizations().get(orgId).update(orgRepresentation); + Message message = getMessageForResponse(exchange); + message.setBody(checkResponse(response, "updateOrganization")); } else { throw new IllegalArgumentException("Update organization requires POJO request with OrganizationRepresentation"); } @@ -2728,7 +2789,7 @@ public class KeycloakProducer extends DefaultProducer { throw new IllegalArgumentException(MISSING_ORGANIZATION_ID); } - keycloakClient.realm(realmName).organizations().get(orgId).delete(); + checkResponse(keycloakClient.realm(realmName).organizations().get(orgId).delete(), "deleteOrganization"); Message message = getMessageForResponse(exchange); message.setBody("Organization deleted successfully"); } @@ -2811,7 +2872,7 @@ public class KeycloakProducer extends DefaultProducer { Response response = keycloakClient.realm(realmName).organizations().get(orgId).members().addMember(userId); Message message = getMessageForResponse(exchange); - message.setBody(response); + message.setBody(checkResponse(response, "addOrganizationMember")); } private void removeOrganizationMember(Keycloak keycloakClient, Exchange exchange) { @@ -2830,7 +2891,8 @@ public class KeycloakProducer extends DefaultProducer { throw new IllegalArgumentException(MISSING_ORGANIZATION_MEMBER_ID); } - keycloakClient.realm(realmName).organizations().get(orgId).members().member(userId).delete(); + checkResponse(keycloakClient.realm(realmName).organizations().get(orgId).members().member(userId).delete(), + "removeOrganizationMember"); Message message = getMessageForResponse(exchange); message.setBody("Organization member removed successfully"); } @@ -2878,7 +2940,7 @@ public class KeycloakProducer extends DefaultProducer { Response response = keycloakClient.realm(realmName).organizations().get(orgId).identityProviders().addIdentityProvider(idpAlias); Message message = getMessageForResponse(exchange); - message.setBody(response); + message.setBody(checkResponse(response, "linkOrganizationIdentityProvider")); } private void unlinkOrganizationIdentityProvider(Keycloak keycloakClient, Exchange exchange) { @@ -2897,7 +2959,8 @@ public class KeycloakProducer extends DefaultProducer { throw new IllegalArgumentException(MISSING_ORGANIZATION_IDP_ALIAS); } - keycloakClient.realm(realmName).organizations().get(orgId).identityProviders().get(idpAlias).delete(); + checkResponse(keycloakClient.realm(realmName).organizations().get(orgId).identityProviders().get(idpAlias).delete(), + "unlinkOrganizationIdentityProvider"); Message message = getMessageForResponse(exchange); message.setBody("Organization identity provider unlinked successfully"); } diff --git a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/KeycloakProducerPojoRequestAndResponseTest.java b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/KeycloakProducerPojoRequestAndResponseTest.java new file mode 100644 index 000000000000..2c7445518e48 --- /dev/null +++ b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/KeycloakProducerPojoRequestAndResponseTest.java @@ -0,0 +1,344 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.apache.camel.component.keycloak; + +import java.util.List; +import java.util.Map; + +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; + +import org.apache.camel.BindToRegistry; +import org.apache.camel.Exchange; +import org.apache.camel.InvalidPayloadException; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.GroupsResource; +import org.keycloak.admin.client.resource.OrganizationResource; +import org.keycloak.admin.client.resource.OrganizationsResource; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.admin.client.resource.UsersResource; +import org.keycloak.representations.idm.GroupRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * A pojoRequest body of another type is converted or fails, and the Response of a create or delete is checked and + * closed. + */ +public class KeycloakProducerPojoRequestAndResponseTest extends CamelTestSupport { + + @BindToRegistry("keycloakClient") + private Keycloak keycloakClient = Mockito.mock(Keycloak.class); + + private RealmResource realmResource = Mockito.mock(RealmResource.class); + private UsersResource usersResource = Mockito.mock(UsersResource.class); + private UserResource userResource = Mockito.mock(UserResource.class); + private GroupsResource groupsResource = Mockito.mock(GroupsResource.class); + private OrganizationsResource organizationsResource = Mockito.mock(OrganizationsResource.class); + private OrganizationResource organizationResource = Mockito.mock(OrganizationResource.class); + + @BeforeEach + void setupMocks() { + when(keycloakClient.realm(anyString())).thenReturn(realmResource); + when(realmResource.users()).thenReturn(usersResource); + when(usersResource.get(anyString())).thenReturn(userResource); + when(realmResource.groups()).thenReturn(groupsResource); + when(realmResource.organizations()).thenReturn(organizationsResource); + when(organizationsResource.get(anyString())).thenReturn(organizationResource); + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + @Override + public void configure() { + from("direct:createUserPojo") + .to("keycloak:test?keycloakClient=#keycloakClient&operation=createUser&pojoRequest=true"); + from("direct:updateUserPojo") + .to("keycloak:test?keycloakClient=#keycloakClient&operation=updateUser&pojoRequest=true"); + from("direct:createGroup") + .to("keycloak:test?keycloakClient=#keycloakClient&operation=createGroup"); + from("direct:deleteUser") + .to("keycloak:test?keycloakClient=#keycloakClient&operation=deleteUser"); + from("direct:deleteOrganization") + .to("keycloak:test?keycloakClient=#keycloakClient&operation=deleteOrganization"); + from("direct:bulkCreateUsers") + .to("keycloak:test?keycloakClient=#keycloakClient&operation=bulkCreateUsers"); + from("direct:bulkUpdateUsers") + .to("keycloak:test?keycloakClient=#keycloakClient&operation=bulkUpdateUsers"); + from("direct:bulkDeleteUsers") + .to("keycloak:test?keycloakClient=#keycloakClient&operation=bulkDeleteUsers"); + } + }; + } + + private static Response response(int status) { + Response response = Mockito.mock(Response.class); + when(response.getStatus()).thenReturn(status); + return response; + } + + @Test + public void testCreateUserPojoFromJsonText() { + Response created = response(201); + when(usersResource.create(any(UserRepresentation.class))).thenReturn(created); + + Exchange out = template.request("direct:createUserPojo", e -> { + e.getIn().setHeader(KeycloakConstants.REALM_NAME, "testRealm"); + e.getIn().setBody("{\"username\":\"alice\",\"email\":\"[email protected]\",\"enabled\":true}"); + }); + + assertNull(out.getException()); + ArgumentCaptor<UserRepresentation> captor = ArgumentCaptor.forClass(UserRepresentation.class); + verify(usersResource).create(captor.capture()); + assertEquals("alice", captor.getValue().getUsername()); + assertEquals("[email protected]", captor.getValue().getEmail()); + assertSame(created, out.getMessage().getBody()); + verify(created).close(); + } + + @Test + public void testUpdateUserPojoFromMap() { + Exchange out = template.request("direct:updateUserPojo", e -> { + e.getIn().setHeader(KeycloakConstants.REALM_NAME, "testRealm"); + e.getIn().setHeader(KeycloakConstants.USER_ID, "user-1"); + e.getIn().setBody(Map.of("firstName", "Alice", "lastName", "Smith")); + }); + + assertNull(out.getException()); + ArgumentCaptor<UserRepresentation> captor = ArgumentCaptor.forClass(UserRepresentation.class); + verify(userResource).update(captor.capture()); + assertEquals("Alice", captor.getValue().getFirstName()); + assertEquals("Smith", captor.getValue().getLastName()); + assertEquals("User updated successfully", out.getMessage().getBody()); + } + + @Test + public void testCreateUserPojoWrongBodyFails() { + Exchange out = template.request("direct:createUserPojo", e -> { + e.getIn().setHeader(KeycloakConstants.REALM_NAME, "testRealm"); + e.getIn().setBody(42); + }); + + InvalidPayloadException cause = assertInstanceOf(InvalidPayloadException.class, out.getException()); + assertEquals(UserRepresentation.class, cause.getType()); + verify(usersResource, never()).create(any(UserRepresentation.class)); + } + + @Test + public void testCreateUserPojoInvalidJsonFails() { + Exchange out = template.request("direct:createUserPojo", e -> { + e.getIn().setHeader(KeycloakConstants.REALM_NAME, "testRealm"); + e.getIn().setBody("not json"); + }); + + InvalidPayloadException cause = assertInstanceOf(InvalidPayloadException.class, out.getException()); + assertEquals(UserRepresentation.class, cause.getType()); + assertNotNull(cause.getCause()); + verify(usersResource, never()).create(any(UserRepresentation.class)); + } + + @Test + public void testCreateUserConflictFails() { + Response conflict = response(409); + when(conflict.hasEntity()).thenReturn(true); + when(conflict.readEntity(String.class)).thenReturn("{\"errorMessage\":\"User exists with same username\"}"); + when(usersResource.create(any(UserRepresentation.class))).thenReturn(conflict); + + Exchange out = template.request("direct:createUserPojo", e -> { + e.getIn().setHeader(KeycloakConstants.REALM_NAME, "testRealm"); + e.getIn().setBody(new UserRepresentation()); + }); + + WebApplicationException cause = assertInstanceOf(WebApplicationException.class, out.getException()); + assertEquals(409, cause.getResponse().getStatus()); + assertTrue(cause.getMessage().contains("createUser failed with status 409"), cause.getMessage()); + assertTrue(cause.getMessage().contains("User exists with same username"), cause.getMessage()); + verify(conflict).close(); + } + + @Test + public void testCreateGroupConflictFails() { + Response conflict = response(409); + when(groupsResource.add(any(GroupRepresentation.class))).thenReturn(conflict); + + Exchange out = template.request("direct:createGroup", e -> { + e.getIn().setHeader(KeycloakConstants.REALM_NAME, "testRealm"); + e.getIn().setHeader(KeycloakConstants.GROUP_NAME, "admins"); + }); + + WebApplicationException cause = assertInstanceOf(WebApplicationException.class, out.getException()); + assertEquals(409, cause.getResponse().getStatus()); + verify(conflict).close(); + } + + @Test + public void testDeleteUserNotFoundFails() { + Response notFound = response(404); + when(usersResource.delete("missing")).thenReturn(notFound); + + Exchange out = template.request("direct:deleteUser", e -> { + e.getIn().setHeader(KeycloakConstants.REALM_NAME, "testRealm"); + e.getIn().setHeader(KeycloakConstants.USER_ID, "missing"); + }); + + WebApplicationException cause = assertInstanceOf(WebApplicationException.class, out.getException()); + assertEquals(404, cause.getResponse().getStatus()); + verify(notFound).close(); + } + + @Test + public void testDeleteUserClosesResponse() { + Response noContent = response(204); + when(usersResource.delete("user-1")).thenReturn(noContent); + + Exchange out = template.request("direct:deleteUser", e -> { + e.getIn().setHeader(KeycloakConstants.REALM_NAME, "testRealm"); + e.getIn().setHeader(KeycloakConstants.USER_ID, "user-1"); + }); + + assertNull(out.getException()); + assertSame(noContent, out.getMessage().getBody()); + verify(noContent).close(); + } + + @Test + public void testDeleteOrganizationNotFoundFails() { + Response notFound = response(404); + when(organizationResource.delete()).thenReturn(notFound); + + Exchange out = template.request("direct:deleteOrganization", e -> { + e.getIn().setHeader(KeycloakConstants.REALM_NAME, "testRealm"); + e.getIn().setHeader(KeycloakConstants.ORGANIZATION_ID, "missing"); + }); + + WebApplicationException cause = assertInstanceOf(WebApplicationException.class, out.getException()); + assertEquals(404, cause.getResponse().getStatus()); + verify(notFound).close(); + } + + @Test + public void testBulkCreateUsersFromListOfMap() { + Response created = response(201); + when(usersResource.create(any(UserRepresentation.class))).thenReturn(created); + + Exchange out = template.request("direct:bulkCreateUsers", e -> { + e.getIn().setHeader(KeycloakConstants.REALM_NAME, "testRealm"); + e.getIn().setBody(List.of(Map.of("username", "alice"), "{\"username\":\"bob\"}")); + }); + + assertNull(out.getException()); + ArgumentCaptor<UserRepresentation> captor = ArgumentCaptor.forClass(UserRepresentation.class); + verify(usersResource, times(2)).create(captor.capture()); + assertEquals("alice", captor.getAllValues().get(0).getUsername()); + assertEquals("bob", captor.getAllValues().get(1).getUsername()); + verify(created, times(2)).close(); + + Map<?, ?> summary = out.getMessage().getBody(Map.class); + assertEquals(2, summary.get("success")); + assertEquals(0, summary.get("failed")); + } + + @Test + public void testBulkCreateUsersWrongElementFails() { + Exchange out = template.request("direct:bulkCreateUsers", e -> { + e.getIn().setHeader(KeycloakConstants.REALM_NAME, "testRealm"); + e.getIn().setBody(List.of(Map.of("username", "alice"), 42)); + }); + + IllegalArgumentException cause = assertInstanceOf(IllegalArgumentException.class, out.getException()); + assertTrue(cause.getMessage().contains("Element 1 of the list must be a UserRepresentation"), cause.getMessage()); + verify(usersResource, never()).create(any(UserRepresentation.class)); + } + + @Test + public void testBulkCreateUsersConflictIsFailed() { + Response created = response(201); + Response conflict = response(409); + when(usersResource.create(any(UserRepresentation.class))).thenReturn(created, conflict); + + Exchange out = template.request("direct:bulkCreateUsers", e -> { + e.getIn().setHeader(KeycloakConstants.REALM_NAME, "testRealm"); + e.getIn().setHeader(KeycloakConstants.CONTINUE_ON_ERROR, true); + e.getIn().setBody(List.of(Map.of("username", "alice"), Map.of("username", "bob"))); + }); + + assertNull(out.getException()); + Map<?, ?> summary = out.getMessage().getBody(Map.class); + assertEquals(1, summary.get("success")); + assertEquals(1, summary.get("failed")); + List<?> results = (List<?>) summary.get("results"); + Map<?, ?> second = (Map<?, ?>) results.get(1); + assertEquals("failed", second.get("status")); + assertEquals(409, second.get("statusCode")); + verify(created).close(); + verify(conflict).close(); + } + + @Test + public void testBulkUpdateUsersFromListOfMap() { + Exchange out = template.request("direct:bulkUpdateUsers", e -> { + e.getIn().setHeader(KeycloakConstants.REALM_NAME, "testRealm"); + e.getIn().setBody(List.of(Map.of("id", "user-1", "firstName", "Alice"))); + }); + + assertNull(out.getException()); + ArgumentCaptor<UserRepresentation> captor = ArgumentCaptor.forClass(UserRepresentation.class); + verify(userResource).update(captor.capture()); + assertEquals("Alice", captor.getValue().getFirstName()); + } + + @Test + public void testBulkDeleteUsersNotFoundIsFailed() { + Response noContent = response(204); + Response notFound = response(404); + when(usersResource.delete("user-1")).thenReturn(noContent); + when(usersResource.delete("missing")).thenReturn(notFound); + + Exchange out = template.request("direct:bulkDeleteUsers", e -> { + e.getIn().setHeader(KeycloakConstants.REALM_NAME, "testRealm"); + e.getIn().setHeader(KeycloakConstants.CONTINUE_ON_ERROR, true); + e.getIn().setBody(List.of("user-1", "missing")); + }); + + assertNull(out.getException()); + Map<?, ?> summary = out.getMessage().getBody(Map.class); + assertEquals(1, summary.get("success")); + assertEquals(1, summary.get("failed")); + verify(noContent).close(); + verify(notFound).close(); + } +} diff --git a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/KeycloakProducerTest.java b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/KeycloakProducerTest.java index f149a7e7fd94..aa547c25e3d6 100644 --- a/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/KeycloakProducerTest.java +++ b/components/camel-keycloak/src/test/java/org/apache/camel/component/keycloak/KeycloakProducerTest.java @@ -25,6 +25,7 @@ import org.apache.camel.BindToRegistry; import org.apache.camel.builder.RouteBuilder; import org.apache.camel.component.mock.MockEndpoint; import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.keycloak.admin.client.Keycloak; import org.keycloak.admin.client.resource.ClientsResource; @@ -79,6 +80,12 @@ public class KeycloakProducerTest extends CamelTestSupport { = Mockito.mock(OrganizationIdentityProviderResource.class); private Response response = Mockito.mock(Response.class); + @BeforeEach + void stubCreatedResponse() { + // a failed status fails the exchange, so the shared Response is a success + when(response.getStatus()).thenReturn(201); + } + @Override protected RouteBuilder createRouteBuilder() throws Exception { return new RouteBuilder() { @@ -462,6 +469,7 @@ public class KeycloakProducerTest extends CamelTestSupport { when(keycloakClient.realm(anyString())).thenReturn(realmResource); when(realmResource.organizations()).thenReturn(organizationsResource); when(organizationsResource.get(anyString())).thenReturn(organizationResource); + when(organizationResource.delete()).thenReturn(response); MockEndpoint mock = getMockEndpoint("mock:result"); mock.expectedMessageCount(1); @@ -515,6 +523,7 @@ public class KeycloakProducerTest extends CamelTestSupport { when(organizationsResource.get(anyString())).thenReturn(organizationResource); when(organizationResource.members()).thenReturn(organizationMembersResource); when(organizationMembersResource.member(anyString())).thenReturn(organizationMemberResource); + when(organizationMemberResource.delete()).thenReturn(response); MockEndpoint mock = getMockEndpoint("mock:result"); mock.expectedMessageCount(1); @@ -571,6 +580,7 @@ public class KeycloakProducerTest extends CamelTestSupport { when(organizationsResource.get(anyString())).thenReturn(organizationResource); when(organizationResource.identityProviders()).thenReturn(organizationIdpsResource); when(organizationIdpsResource.get(anyString())).thenReturn(organizationIdpResource); + when(organizationIdpResource.delete()).thenReturn(response); MockEndpoint mock = getMockEndpoint("mock:result"); mock.expectedMessageCount(1); diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc index 10f5a47c1b89..98bc43a4c25c 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc @@ -804,6 +804,21 @@ CamelContext of that provider. It now closes each of them only when no other `JC it returned the closed cache, whose operations throw `IllegalStateException`. This matters for code that calls a `JCacheManager` (for example one created by a custom `JCacheManagerFactory`) after it was closed. +=== camel-keycloak - a failed create or delete fails the exchange, and a pojoRequest body is converted + +The producer operations that give the JAX-RS `Response` of Keycloak as the body, such as `createUser`, `deleteUser`, +`createGroup`, `createClient` and `createOrganization`, now fail the exchange with a +`jakarta.ws.rs.WebApplicationException` when Keycloak answers with a status other than 2xx, such as 409 (already +exists) or 404 (not found). Before, the exchange completed and the route had to check the status of the `Response`. +`deleteOrganization`, `removeOrganizationMember` and `unlinkOrganizationIdentityProvider` fail the same way. The +`Response` body is now closed: its status and headers, such as `Location`, can still be read, but not its entity. The +bulk operations count such a status as a failed item. + +With `pojoRequest=true`, a body that is not the Keycloak representation of the operation is converted: a `Map` or +JSON text is mapped to it. A body that does not convert fails with an `InvalidPayloadException`; before, the operation +did nothing and the body was kept. The elements of the `List` of `bulkCreateUsers` and `bulkUpdateUsers` are +converted the same way; an element that does not convert fails the exchange before any call to Keycloak. + === camel-spring-batch - the CamelSpringBatchJobName header needs jobFromHeader=true The producer now takes the job name from the `CamelSpringBatchJobName` header only when the endpoint has
