-| ignoreCase |
+ignoreCase |
Boolean! |
@@ -376,12 +539,39 @@ If true, the filter is case-insensitive.
## Enums
+### Gamification
+
+
+
+
+| Value |
+Description |
+
+
+
+
+| GAMIFICATION_ENABLED |
+ |
+
+
+| ADAPTIVE_GAMIFICATION_ENABLED |
+ |
+
+
+| ALL_GAMIFICATION_DISABLED |
+ |
+
+
+
+
### GlobalUserRole
+
| Value |
Description |
+
@@ -401,8 +591,10 @@ Specifies the sort direction, either ascending or descending.
+
| Value |
Description |
+
@@ -428,15 +620,13 @@ The `Boolean` scalar type represents `true` or `false`.
### Int
-The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31)
-and 2^31 - 1.
+The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.
### LocalTime
### String
-The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most
-often used by GraphQL to represent free-form human-readable text.
+The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text.
### Time
diff --git a/build.gradle b/build.gradle
index d4be657..8cd8a05 100644
--- a/build.gradle
+++ b/build.gradle
@@ -109,6 +109,8 @@ repositories {
dependencies {
implementation 'de.unistuttgart.iste.meitrex:meitrex-common:1.2.6'
+ implementation 'org.springframework.boot:spring-boot-starter-graphql'
+ implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-graphql'
implementation 'org.springframework.boot:spring-boot-starter-validation'
@@ -132,7 +134,7 @@ dependencies {
testImplementation "org.mockito:mockito-core:5.+"
testImplementation 'org.hamcrest:hamcrest:2.+'
testImplementation "org.testcontainers:postgresql:1.+"
- testImplementation "org.testcontainers:junit-jupiter:1.+"
+ testImplementation "org.testcontainers:junit-jupiter:1.18.3"
}
tasks.named('test') {
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java
new file mode 100644
index 0000000..e8542eb
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/QueryDefinitions.java
@@ -0,0 +1,31 @@
+package de.unistuttgart.iste.meitrex.user_service.client;
+
+import lombok.NoArgsConstructor;
+
+/**
+ * GraphQL documents used by SettingsServiceClient.
+ */
+@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE)
+public final class QueryDefinitions {
+
+ public static final String FIND_USER_SETTINGS_QUERY_NAME = "findUserSettings";
+ public static final String FIND_USERS_SETTINGS_QUERY_NAME = "findUsersSettings";
+
+ public static final String FIND_USER_SETTINGS_QUERY = """
+ query($userId: UUID!) {
+ findUserSettings(userId: $userId) {
+ gamification
+ notification { gamification lecture }
+ }
+ }
+ """;
+
+ public static final String FIND_USERS_SETTINGS_QUERY = """
+ query($usersIds: [UUID!]!) {
+ findUsersSettings(usersIds: $usersIds) {
+ gamification
+ notification { gamification lecture }
+ }
+ }
+ """;
+}
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java
new file mode 100644
index 0000000..7072f7c
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/client/SettingsServiceClient.java
@@ -0,0 +1,111 @@
+package de.unistuttgart.iste.meitrex.user_service.client;
+
+import de.unistuttgart.iste.meitrex.generated.dto.Settings;
+import de.unistuttgart.iste.meitrex.user_service.exception.UserServiceConnectionException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.graphql.client.ClientGraphQlResponse;
+import org.springframework.graphql.client.GraphQlClient;
+import reactor.core.publisher.SynchronousSink;
+
+import java.util.*;
+import java.util.UUID;
+
+/**
+ * Client for the user-service to query user settings over GraphQL.
+ */
+@Slf4j
+public class SettingsServiceClient {
+
+ private static final long RETRY_COUNT = 3;
+ private final GraphQlClient graphQlClient;
+
+ public SettingsServiceClient(final GraphQlClient graphQlClient) {
+ this.graphQlClient = graphQlClient;
+ }
+
+ /**
+ * Query settings for a single user.
+ *
+ * @param userId the user id
+ * @return settings of the user (never null on success)
+ * @throws UserServiceConnectionException if the response has GraphQL errors, the field is missing/null, or mapping fails
+ */
+ public Settings queryUserSettings(final UUID userId) throws UserServiceConnectionException {
+ try {
+ return graphQlClient.document(QueryDefinitions.FIND_USER_SETTINGS_QUERY)
+ .variable("userId", userId)
+ .execute()
+ .handle((ClientGraphQlResponse result, SynchronousSink sink) -> {
+ if (!result.isValid()) {
+ sink.error(new UserServiceConnectionException(
+ "Invalid response from user-service (findUserSettings).",
+ result.getErrors()));
+ return;
+ }
+ try {
+ final Settings s = result
+ .field(QueryDefinitions.FIND_USER_SETTINGS_QUERY_NAME)
+ .toEntity(Settings.class);
+ if (s == null) {
+ sink.error(new UserServiceConnectionException(
+ "Missing field 'findUserSettings' in user-service response."));
+ return;
+ }
+ sink.next(s);
+ sink.complete();
+ } catch (Exception mappingEx) {
+ sink.error(new UserServiceConnectionException(
+ "Failed to map 'findUserSettings' from user-service response: "
+ + mappingEx.getMessage()));
+ }
+ })
+ .retry(RETRY_COUNT)
+ .block();
+ } catch (final RuntimeException e) {
+ UserServiceConnectionException.unwrapAndThrow(e);
+ return null;
+ }
+ }
+
+ /**
+ * Query settings for multiple users.
+ *
+ * @param userIds list of user ids
+ * @return list of settings (never null; may be empty)
+ * @throws UserServiceConnectionException if the response has GraphQL errors or mapping fails
+ */
+ public List queryUsersSettings(final List userIds) throws UserServiceConnectionException {
+ if (userIds == null || userIds.isEmpty()) {
+ return List.of();
+ }
+ try {
+ return graphQlClient.document(QueryDefinitions.FIND_USERS_SETTINGS_QUERY)
+ .variable("usersIds", userIds)
+ .execute()
+ .handle((ClientGraphQlResponse result, SynchronousSink> sink) -> {
+ if (!result.isValid()) {
+ sink.error(new UserServiceConnectionException(
+ "Invalid response from user-service (findUsersSettings).",
+ result.getErrors()));
+ return;
+ }
+ try {
+ final List list = result
+ .field(QueryDefinitions.FIND_USERS_SETTINGS_QUERY_NAME)
+ .toEntityList(Settings.class);
+ sink.next(list != null ? list : List.of());
+ sink.complete();
+ } catch (Exception mappingEx) {
+ sink.error(new UserServiceConnectionException(
+ "Failed to map 'findUsersSettings' from user-service response: "
+ + mappingEx.getMessage()));
+ }
+ })
+ .retry(RETRY_COUNT)
+ .block();
+ } catch (final RuntimeException e) {
+ UserServiceConnectionException.unwrapAndThrow(e);
+ return List.of(); // unreachable
+ }
+ }
+}
diff --git a/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java
new file mode 100644
index 0000000..cef8a87
--- /dev/null
+++ b/src/main/java/de/unistuttgart/iste/meitrex/user_service/exception/UserServiceConnectionException.java
@@ -0,0 +1,52 @@
+package de.unistuttgart.iste.meitrex.user_service.exception;
+
+import org.springframework.graphql.ResponseError;
+
+import java.util.List;
+
+/**
+ * Exception thrown when the connection to the user-service fails or returns an invalid response.
+ */
+public class UserServiceConnectionException extends Exception {
+
+ private final String message;
+
+ public UserServiceConnectionException(final String message) {
+ super(message);
+ this.message = message;
+ }
+
+ public UserServiceConnectionException(final String message, final List errors) {
+ super(withErrors(message, errors));
+ this.message = withErrors(message, errors);
+ }
+
+ private static String withErrors(final String base, final List errors) {
+ if (errors == null || errors.isEmpty()) return base;
+ final StringBuilder sb = new StringBuilder(base).append('\n')
+ .append("GraphQL Response Errors:\n");
+ for (final ResponseError err : errors) {
+ sb.append(err.getMessage()).append(" at path ").append(err.getPath()).append('\n');
+ }
+ return sb.toString();
+ }
+
+ @Override
+ public String getMessage() {
+ return this.message;
+ }
+
+ /**
+ * Unwraps a RuntimeException thrown by reactive pipelines and rethrows as UserServiceConnectionException when possible.
+ */
+ public static void unwrapAndThrow(final RuntimeException e) throws UserServiceConnectionException {
+ Throwable t = e;
+ while (t != null) {
+ if (t instanceof UserServiceConnectionException uce) {
+ throw uce;
+ }
+ t = t.getCause();
+ }
+ throw e;
+ }
+}
diff --git a/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java b/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java
new file mode 100644
index 0000000..a01ed83
--- /dev/null
+++ b/src/test/java/de/unistuttgart/iste/meitrex/client/SettingsServiceClientTest.java
@@ -0,0 +1,339 @@
+package de.unistuttgart.iste.meitrex.client;
+
+import de.unistuttgart.iste.meitrex.generated.dto.Gamification;
+import de.unistuttgart.iste.meitrex.generated.dto.Settings;
+import de.unistuttgart.iste.meitrex.user_service.client.SettingsServiceClient;
+import de.unistuttgart.iste.meitrex.user_service.exception.UserServiceConnectionException;
+import graphql.ErrorType;
+import org.junit.jupiter.api.Test;
+import org.springframework.graphql.ResponseError;
+
+import java.lang.reflect.Proxy;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+import org.springframework.graphql.client.GraphQlClient;
+import org.springframework.graphql.client.HttpGraphQlClient;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.reactive.function.client.ClientResponse;
+import org.springframework.web.reactive.function.client.ExchangeFunction;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Mono;
+
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for SettingsServiceClient
+ */
+class SettingsServiceClientTest {
+
+ @Test
+ void queryUserSettings_success() throws Exception {
+ String json = """
+ {
+ "data": {
+ "findUserSettings": {
+ "gamification": "GAMIFICATION_ENABLED",
+ "notification": { "gamification": true, "lecture": false }
+ }
+ }
+ }
+ """;
+
+ SettingsServiceClient client = new SettingsServiceClient(mockGraphQl(json));
+
+ UUID userId = UUID.randomUUID();
+ Settings s = client.queryUserSettings(userId);
+
+ assertNotNull(s);
+ assertEquals(Gamification.GAMIFICATION_ENABLED, s.getGamification());
+ assertNotNull(s.getNotification());
+ assertEquals(Boolean.TRUE, s.getNotification().getGamification());
+ assertEquals(Boolean.FALSE, s.getNotification().getLecture());
+ }
+
+ @Test
+ void queryUsersSettings_success() throws Exception {
+ String json = """
+ {
+ "data": {
+ "findUsersSettings": [
+ {
+ "gamification": "GAMIFICATION_ENABLED",
+ "notification": { "gamification": true, "lecture": false }
+ },
+ {
+ "gamification": "ALL_GAMIFICATION_DISABLED",
+ "notification": { "gamification": false, "lecture": true }
+ }
+ ]
+ }
+ }
+ """;
+
+ SettingsServiceClient client = new SettingsServiceClient(mockGraphQl(json));
+
+ var u1 = UUID.randomUUID();
+ var u2 = UUID.randomUUID();
+ List list = client.queryUsersSettings(List.of(u1, u2));
+
+ assertNotNull(list);
+ assertEquals(2, list.size());
+
+ assertEquals(Gamification.GAMIFICATION_ENABLED, list.get(0).getGamification());
+ assertEquals(Boolean.TRUE, list.get(0).getNotification().getGamification());
+ assertEquals(Boolean.FALSE, list.get(0).getNotification().getLecture());
+
+ assertEquals(Gamification.ALL_GAMIFICATION_DISABLED, list.get(1).getGamification());
+ assertEquals(Boolean.FALSE, list.get(1).getNotification().getGamification());
+ assertEquals(Boolean.TRUE, list.get(1).getNotification().getLecture());
+ }
+
+ @Test
+ void queryUserSettings_graphQlErrors_throws() {
+ String json = """
+ {
+ "data": { "findUserSettings": null },
+ "errors": [ { "message": "boom", "path": ["findUserSettings"] } ]
+ }
+ """;
+
+ ExchangeFunction fx = req -> {
+ ClientResponse resp = ClientResponse.create(HttpStatus.OK)
+ .header("Content-Type", "application/json")
+ .body(json)
+ .build();
+ return Mono.just(resp);
+ };
+ WebClient webClient = WebClient.builder().exchangeFunction(fx).build();
+ GraphQlClient gql = HttpGraphQlClient.builder(webClient).build();
+
+ SettingsServiceClient client = new SettingsServiceClient(gql);
+
+ assertThrows(UserServiceConnectionException.class,
+ () -> client.queryUserSettings(UUID.randomUUID()));
+ }
+
+ @Test
+ void queryUserSettings_fieldMissing_throws() {
+ String json = "{ \"data\": { } }";
+ SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json));
+
+ assertThrows(UserServiceConnectionException.class,
+ () -> client.queryUserSettings(UUID.randomUUID()));
+ }
+
+
+ @Test
+ void queryUserSettings_retriesThenSuccess() throws Exception {
+ String err = "{ \"data\": { \"findUserSettings\": null }, \"errors\": [ { \"message\": \"e\" } ] }";
+ String ok = """
+ { "data": { "findUserSettings": {
+ "gamification": "ALL_GAMIFICATION_DISABLED",
+ "notification": { "gamification": false, "lecture": true }
+ } } }
+ """;
+ SettingsServiceClient client = new SettingsServiceClient(gqlWithSequence(err, err, ok));
+
+ Settings s = client.queryUserSettings(UUID.randomUUID());
+
+ assertNotNull(s);
+ assertEquals(Gamification.ALL_GAMIFICATION_DISABLED, s.getGamification());
+ assertEquals(Boolean.FALSE, s.getNotification().getGamification());
+ assertEquals(Boolean.TRUE, s.getNotification().getLecture());
+ }
+
+ @Test
+ void queryUsersSettings_emptyInput_noHttpCall() {
+ AtomicInteger calls = new AtomicInteger(0);
+ SettingsServiceClient client = new SettingsServiceClient(gqlCounting(calls));
+
+ try {
+ List list = client.queryUsersSettings(List.of());
+ assertNotNull(list);
+ assertTrue(list.isEmpty());
+ assertEquals(0, calls.get(), "No HTTP call should be made for empty input");
+ } catch (UserServiceConnectionException e) {
+ fail("Unexpected exception: " + e.getMessage());
+ }
+ }
+
+
+
+ @Test
+ void queryUsersSettings_mappingError_throws() {
+ String json = "{ \"data\": { \"findUsersSettings\": { \"not\": \"a list\" } } }";
+ SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json));
+
+ assertThrows(UserServiceConnectionException.class,
+ () -> client.queryUsersSettings(List.of(UUID.randomUUID())));
+ }
+
+
+ /**
+ * Creates a GraphQlClient backed by a WebClient that always returns the given JSON.
+ * No server, no extra deps.
+ */
+ private static GraphQlClient mockGraphQl(String jsonResponse) {
+ ExchangeFunction fx = request -> {
+ ClientResponse resp = ClientResponse
+ .create(HttpStatus.OK)
+ .header("Content-Type", "application/json")
+ .body(jsonResponse)
+ .build();
+ return Mono.just(resp);
+ };
+ WebClient webClient = WebClient.builder()
+ .exchangeFunction(fx)
+ .build();
+ return HttpGraphQlClient.builder(webClient).build();
+ }
+
+ private static GraphQlClient gqlWithJson(String json) {
+ ExchangeFunction fx = req -> Mono.just(
+ ClientResponse.create(HttpStatus.OK)
+ .header("Content-Type", "application/json")
+ .body(json)
+ .build()
+ );
+ WebClient webClient = WebClient.builder().exchangeFunction(fx).build();
+ return HttpGraphQlClient.builder(webClient).build();
+ }
+
+ private static GraphQlClient gqlWithSequence(String... jsonResponses) {
+ AtomicInteger idx = new AtomicInteger(0);
+ ExchangeFunction fx = req -> {
+ String body = jsonResponses[Math.min(idx.getAndIncrement(), jsonResponses.length - 1)];
+ return Mono.just(ClientResponse.create(HttpStatus.OK)
+ .header("Content-Type", "application/json")
+ .body(body)
+ .build());
+ };
+ WebClient webClient = WebClient.builder().exchangeFunction(fx).build();
+ return HttpGraphQlClient.builder(webClient).build();
+ }
+
+ private static GraphQlClient gqlCounting(AtomicInteger counter) {
+ ExchangeFunction fx = req -> {
+ counter.incrementAndGet();
+ return Mono.just(ClientResponse.create(HttpStatus.OK)
+ .header("Content-Type", "application/json")
+ .body("{ \"data\": { \"findUsersSettings\": [] } }")
+ .build());
+ };
+ WebClient webClient = WebClient.builder().exchangeFunction(fx).build();
+ return HttpGraphQlClient.builder(webClient).build();
+ }
+
+ private static GraphQlClient gqlErrorWrapped(String message) {
+ ExchangeFunction fx = req -> {
+ return Mono.error(new RuntimeException(
+ new UserServiceConnectionException(message)
+ ));
+ };
+ WebClient webClient = WebClient.builder().exchangeFunction(fx).build();
+ return HttpGraphQlClient.builder(webClient).build();
+ }
+
+ @Test
+ void queryUserSettings_runtimeWrapped_unwrapsToUserServiceConnectionException() {
+ SettingsServiceClient client = new SettingsServiceClient(gqlErrorWrapped("wrapped-error"));
+ assertThrows(UserServiceConnectionException.class,
+ () -> client.queryUserSettings(UUID.randomUUID()));
+ }
+
+ @Test
+ void queryUsersSettings_runtimeWrapped_unwrapsToUserServiceConnectionException() {
+ SettingsServiceClient client = new SettingsServiceClient(gqlErrorWrapped("wrapped-error"));
+ assertThrows(UserServiceConnectionException.class,
+ () -> client.queryUsersSettings(List.of(UUID.randomUUID())));
+ }
+
+ @Test
+ void queryUsersSettings_nullList_normalizedToEmpty() throws UserServiceConnectionException {
+ String json = """
+ { "data": { "findUsersSettings": null } }
+ """;
+ SettingsServiceClient client = new SettingsServiceClient(gqlWithJson(json));
+
+ List list = client.queryUsersSettings(List.of(UUID.randomUUID()));
+ org.junit.jupiter.api.Assertions.assertNotNull(list);
+ org.junit.jupiter.api.Assertions.assertTrue(list.isEmpty());
+ }
+
+ private static ResponseError responseError(String message) {
+ return (ResponseError) Proxy.newProxyInstance(
+ ResponseError.class.getClassLoader(),
+ new Class[]{ResponseError.class},
+ (proxy, method, args) -> {
+ String name = method.getName();
+ if ("getMessage".equals(name)) {
+ return message;
+ }
+ if ("toSpecification".equals(name)) {
+ return Map.of("message", message);
+ }
+ return null;
+ }
+ );
+ }
+
+
+ @Test
+ void constructor_withMessage_keepsMessage() {
+ var ex = new UserServiceConnectionException("plain");
+ assertEquals("plain", ex.getMessage());
+ }
+
+ @Test
+ void constructor_withErrors_formatsMessage() {
+ var base = "Invalid response";
+ var ex = new UserServiceConnectionException(base, java.util.List.of(
+ responseError("boom1"),
+ responseError("boom2")
+ ));
+
+ String msg = ex.getMessage();
+ assertTrue(msg.contains(base));
+ assertTrue(msg.contains("boom1"));
+ assertTrue(msg.contains("boom2"));
+ }
+
+ @Test
+ void constructor_withNullErrors_fallsBackToBaseMessage() {
+ var ex = new UserServiceConnectionException("only-base", null);
+ assertEquals("only-base", ex.getMessage());
+ }
+
+ @Test
+ void unwrapAndThrow_directWrapped_throwsInnerUserEx() {
+ var inner = new UserServiceConnectionException("inner");
+ var outer = new RuntimeException(inner);
+
+ var thrown = assertThrows(UserServiceConnectionException.class,
+ () -> UserServiceConnectionException.unwrapAndThrow(outer));
+ assertEquals("inner", thrown.getMessage());
+ }
+
+ @Test
+ void unwrapAndThrow_deeplyNested_throwsInnerUserEx() {
+ var inner = new UserServiceConnectionException("deep");
+ var outer = new RuntimeException(new IllegalStateException(inner));
+
+ var thrown = assertThrows(UserServiceConnectionException.class,
+ () -> UserServiceConnectionException.unwrapAndThrow(outer));
+ assertEquals("deep", thrown.getMessage());
+ }
+
+ @Test
+ void unwrapAndThrow_noUserEx_rethrowsOriginalRuntime() {
+ var outer = new RuntimeException(new IllegalArgumentException("no-user-ex"));
+ var rethrown = assertThrows(RuntimeException.class,
+ () -> UserServiceConnectionException.unwrapAndThrow(outer));
+ assertSame(outer, rethrown);
+ }
+}
|