diff --git a/README.md b/README.md
index 46740835..29880874 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,10 @@ List of permissions:
| `{"entity": "AI_SECRET", "action": "DELETE"}` | Allows users to delete a specific AI secret in `leto-modelizer-admin`. |
| `{"entity": "AI_SECRET", "action": "UPDATE"}` | Allows users to update a specific AI secret in `leto-modelizer-admin`. |
| `{"entity": "AI_SECRET", "action": "ACCESS"}` | Allows users to access a AI secret ui in `leto-modelizer-admin`. |
+| `{"entity": "AI_CONFIGURATION", "action": "CREATE"}` | Allows user to register a AI configuration in `leto-modelizer-admin`. |
+| `{"entity": "AI_CONFIGURATION", "action": "DELETE"}` | Allows users to delete a specific AI configuration in `leto-modelizer-admin`. |
+| `{"entity": "AI_CONFIGURATION", "action": "UPDATE"}` | Allows users to update a specific AI configuration in `leto-modelizer-admin`. |
+| `{"entity": "AI_CONFIGURATION", "action": "ACCESS"}` | Allows users to access a AI configuration ui in `leto-modelizer-admin`. |
### Manage roles
@@ -252,6 +256,7 @@ enabling secure and streamlined user authentication.
| SUPER_ADMINISTRATOR_LOGIN | No | A configuration parameter that defines the username on Github of the SUPER_ADMINISTRATOR. It will create user if it doesn't exist and associate it to the `SUPER_ADMINISTRATOR` role. |
| AI_HOST | No, default: `http://localhost:8585/` | A configuration parameter that defines the host of the ia server, example: http://localhost:8585/api/. If it's not set, users will not be approve to use ia in application. |
| AI_SECRETS_ENCRYPTION_KEY | Yes | The passphrase to encrypt AI secrets in database. |
+| AI_CONFIGURATION_ENCRYPTION_KEY | Yes | The passphrase to encrypt AI configuration for securely sharing it with the AI proxy. |
> Notes: `GITHUB_ENTERPRISE_*` variables are only required on self-hosted GitHub.
@@ -282,7 +287,8 @@ LIBRARY_HOST_WHITELIST=https://github.com/ditrit/
CSRF_TOKEN_TIMEOUT=3600
USER_SESSION_TIMEOUT=3600
AI_HOST=http://locahost:8585/
-AI_SECRETS_ENCRYPTION_KEY=THE MOST SECURE PASSPHRASE EVER
+AI_SECRETS_ENCRYPTION_KEY=the most secure key for secrets
+AI_CONFIGURATION_ENCRYPTION_KEY=the most secure key for configuration
```
See Configuration section for more details.
diff --git a/build.gradle b/build.gradle
index 22266a97..7ef9ec20 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,6 +1,6 @@
plugins {
id 'java'
- id 'org.springframework.boot' version '3.3.4'
+ id 'org.springframework.boot' version '3.3.5'
id 'io.spring.dependency-management' version '1.1.6'
id 'checkstyle'
id 'com.github.ben-manes.versions' version '0.51.0'
@@ -44,19 +44,20 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
- implementation 'org.springframework.session:spring-session-jdbc:3.3.2'
- implementation 'org.flywaydb:flyway-core:10.20.0'
- implementation "org.flywaydb:flyway-database-postgresql:10.20.0"
+ implementation 'org.springframework.session:spring-session-jdbc:3.3.3'
+ implementation 'org.flywaydb:flyway-core:10.20.1'
+ implementation "org.flywaydb:flyway-database-postgresql:10.20.1"
implementation 'commons-lang:commons-lang:2.6'
implementation 'commons-beanutils:commons-beanutils:1.9.4'
implementation 'com.github.erosb:json-sKema:0.18.0'
+ implementation 'com.hubspot.jinjava:jinjava:2.7.3'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql:42.7.4'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.cucumber:cucumber-java:7.20.1'
testImplementation 'io.cucumber:cucumber-junit:7.20.1'
- testImplementation 'org.junit.vintage:junit-vintage-engine:5.11.2'
+ testImplementation 'org.junit.vintage:junit-vintage-engine:5.11.3'
}
tasks.named('test') {
diff --git a/changelog.md b/changelog.md
index 6c6cb0d5..fb1b58c6 100644
--- a/changelog.md
+++ b/changelog.md
@@ -10,8 +10,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
* Add api endpoints:
+ * For AI configuration actions on proxy:
+ * `GET /api/ai/proxy/configuration`, to send configuration on the proxy.
+ * `GET /api/ai/proxy/descriptions`, to get all configurations descriptions on the proxy.
+ * For AI configurations:
+ * `GET /api/ai/configurations`, to get all AI configurations.
+ * `POST /api/ai/configurations`, to get create an AI configuration.
+ * `PUT /api/ai/configurations/`, to update multiple AI configurations.
+ * `GET /api/ai/configurations/[CONFIGURATION_ID]`, to get an AI configuration.
+ * `PUT /api/ai/configurations/[CONFIGURATION_ID]`, to update an AI configuration.
+ * `DELETE /api/ai/configurations/[CONFIGURATION_ID]`, to delete an AI configuration.
* For AI secrets:
- * `GET /api/ai/secrets`, to get all AI secret keys.
+ * `GET /api/ai/secrets`, to get all AI secret keys.
* `POST /api/ai/secrets`, to create an AI secret.
* `GET /api/ai/secrets/[SECRET_ID]`, to get an AI secret.
* `PUT /api/ai/secrets/[SECRET_ID]`, to update an AI secret.
diff --git a/docker-compose-e2e.yml b/docker-compose-e2e.yml
index e5fc3b9f..366be17a 100644
--- a/docker-compose-e2e.yml
+++ b/docker-compose-e2e.yml
@@ -48,6 +48,7 @@ services:
CSRF_TOKEN_TIMEOUT: ${CSRF_TOKEN_TIMEOUT:-3600}
USER_SESSION_TIMEOUT: ${USER_SESSION_TIMEOUT:-3600}
AI_SECRETS_ENCRYPTION_KEY: the most secure key for secrets
+ AI_CONFIGURATION_ENCRYPTION_KEY: the most secure key for configuration
ports:
- "8443:8443"
diff --git a/docker-compose.yml b/docker-compose.yml
index 1a5ef3f3..ced550fe 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -34,6 +34,7 @@ services:
LIBRARY_HOST_WHITELIST: http://libraries/
SUPER_ADMINISTRATOR_LOGIN: ${SUPER_ADMINISTRATOR_LOGIN}
AI_SECRETS_ENCRYPTION_KEY: ${AI_SECRETS_ENCRYPTION_KEY}
+ AI_CONFIGURATION_ENCRYPTION_KEY: ${AI_CONFIGURATION_ENCRYPTION_KEY}
ports:
- "8443:8443"
depends_on:
diff --git a/src/main/java/com/ditrit/letomodelizerapi/config/JerseyConfig.java b/src/main/java/com/ditrit/letomodelizerapi/config/JerseyConfig.java
index 3dc261a4..79922e9c 100644
--- a/src/main/java/com/ditrit/letomodelizerapi/config/JerseyConfig.java
+++ b/src/main/java/com/ditrit/letomodelizerapi/config/JerseyConfig.java
@@ -1,11 +1,12 @@
package com.ditrit.letomodelizerapi.config;
+import com.ditrit.letomodelizerapi.controller.AIConfigurationController;
+import com.ditrit.letomodelizerapi.controller.AIController;
import com.ditrit.letomodelizerapi.controller.AISecretController;
import com.ditrit.letomodelizerapi.controller.CsrfController;
import com.ditrit.letomodelizerapi.controller.CurrentUserController;
import com.ditrit.letomodelizerapi.controller.GroupController;
import com.ditrit.letomodelizerapi.controller.HomeController;
-import com.ditrit.letomodelizerapi.controller.AIController;
import com.ditrit.letomodelizerapi.controller.LibraryController;
import com.ditrit.letomodelizerapi.controller.PermissionController;
import com.ditrit.letomodelizerapi.controller.RoleController;
@@ -45,6 +46,7 @@ public JerseyConfig(@Value("${ai.host}") final String aiHost) {
register(CsrfController.class);
register(PermissionController.class);
register(AISecretController.class);
+ register(AIConfigurationController.class);
if (StringUtils.isNotBlank(aiHost)) {
register(AIController.class);
diff --git a/src/main/java/com/ditrit/letomodelizerapi/controller/AIConfigurationController.java b/src/main/java/com/ditrit/letomodelizerapi/controller/AIConfigurationController.java
new file mode 100644
index 00000000..f8f2a7f2
--- /dev/null
+++ b/src/main/java/com/ditrit/letomodelizerapi/controller/AIConfigurationController.java
@@ -0,0 +1,288 @@
+package com.ditrit.letomodelizerapi.controller;
+
+import com.ditrit.letomodelizerapi.controller.model.QueryFilter;
+import com.ditrit.letomodelizerapi.model.BeanMapper;
+import com.ditrit.letomodelizerapi.model.ai.AIConfigurationDTO;
+import com.ditrit.letomodelizerapi.model.ai.AIConfigurationRecord;
+import com.ditrit.letomodelizerapi.model.ai.UpdateMultipleAIConfigurationRecord;
+import com.ditrit.letomodelizerapi.model.permission.ActionPermission;
+import com.ditrit.letomodelizerapi.model.permission.EntityPermission;
+import com.ditrit.letomodelizerapi.persistence.model.User;
+import com.ditrit.letomodelizerapi.service.AIConfigurationService;
+import com.ditrit.letomodelizerapi.service.AISecretService;
+import com.ditrit.letomodelizerapi.service.AIService;
+import com.ditrit.letomodelizerapi.service.UserPermissionService;
+import com.ditrit.letomodelizerapi.service.UserService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpSession;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+import jakarta.ws.rs.BeanParam;
+import jakarta.ws.rs.DELETE;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.PUT;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.Context;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.core.UriInfo;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Controller;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+/**
+ * REST Controller for managing ai and configurations.
+ * Provides endpoints for CRUD operations on roles, including listing, retrieving, creating, updating, and deleting
+ * roles.
+ * Only accessible by users with administrative permissions.
+ */
+@Slf4j
+@Path("/ai/configurations")
+@Produces(MediaType.APPLICATION_JSON)
+@Controller
+@RequiredArgsConstructor(onConstructor = @__(@Autowired))
+public class AIConfigurationController implements DefaultController {
+
+ /**
+ * Service to manage AI configuration.
+ */
+ private final AIConfigurationService aiConfigurationService;
+
+ /**
+ * Service to manage AI secrets.
+ */
+ private final AISecretService aiSecretService;
+
+ /**
+ * Service to manage AI configuration.
+ */
+ private final AIService aiService;
+
+ /**
+ * Service to manage user.
+ */
+ private final UserService userService;
+
+ /**
+ * Service to manage user permissions.
+ */
+ private final UserPermissionService userPermissionService;
+
+ /**
+ * Retrieves the scopes of a specified role.
+ *
+ *
This method processes a GET request to obtain scopes associated with a given role ID. It filters the scopes
+ * based on the provided query parameters and pagination settings.
+ *
+ * @param request the HttpServletRequest from which to obtain the HttpSession for user validation.
+ * @param uriInfo UriInfo context to extract query parameters for filtering results.
+ * @param queryFilter bean parameter encapsulating filtering and pagination criteria.
+ * @return a Response object containing the requested page of AIConfigurationDTO objects representing the
+ * configurations. The status of the response can vary based on the outcome of the request.
+ */
+ @GET
+ public Response getAllConfigurations(final @Context HttpServletRequest request,
+ final @Context UriInfo uriInfo,
+ final @BeanParam @Valid QueryFilter queryFilter) {
+ HttpSession session = request.getSession();
+ User user = userService.getFromSession(session);
+ userPermissionService.checkPermission(user, "id", EntityPermission.AI_CONFIGURATION, ActionPermission.ACCESS);
+
+ Map filters = new HashMap<>(this.getFilters(uriInfo));
+
+ log.info("[{}] Received GET request to get configurations with the following filters: {}", user.getLogin(),
+ filters);
+
+ var resources = aiConfigurationService.findAll(filters, queryFilter.getPagination())
+ .map(new BeanMapper<>(AIConfigurationDTO.class));
+
+ return Response.status(this.getStatus(resources)).entity(resources).build();
+ }
+
+ /**
+ * Get configuration by id.
+ *
+ * @param request the HttpServletRequest from which to obtain the HttpSession for user validation.
+ * @param id the ID of the configuration to retrieve. Must be a valid and non-null UUID value.
+ * @return a Response object containing theAIConfigurationDTO object representing the configuration.
+ * The status of the response can vary based on the outcome of the request.
+ */
+ @GET
+ @Path("/{id}")
+ public Response getConfigurationById(final @Context HttpServletRequest request,
+ final @PathParam("id") @Valid @NotNull UUID id) {
+ HttpSession session = request.getSession();
+ User user = userService.getFromSession(session);
+ userPermissionService.checkPermission(user, "id", EntityPermission.AI_CONFIGURATION, ActionPermission.ACCESS);
+
+ log.info("[{}] Received GET request to get configuration {}", user.getLogin(), id);
+
+ var aiConfiguration = aiConfigurationService.findById(id);
+
+ return Response.status(HttpStatus.OK.value())
+ .entity(new BeanMapper<>(AIConfigurationDTO.class).apply(aiConfiguration))
+ .build();
+ }
+
+ /**
+ * Create a configuration.
+ *
+ * This method handles a POST request to create a configuration.
+ * It validates the user's session and ensures the user has administrative privileges before proceeding with the
+ * association.
+ *
+ * @param request the HttpServletRequest from which to obtain the HttpSession for user validation.
+ * @param aiConfigurationRecord the record containing the details of the configuration to be created,
+ * validated for correctness.
+ * @return a Response object indicating the outcome of the configuration creation. A successful operation returns
+ * a status of CREATED.
+ */
+ @POST
+ public Response createConfiguration(final @Context HttpServletRequest request,
+ final @Valid AIConfigurationRecord aiConfigurationRecord) {
+ HttpSession session = request.getSession();
+ User user = userService.getFromSession(session);
+ userPermissionService.checkPermission(user, null, EntityPermission.AI_CONFIGURATION, ActionPermission.CREATE);
+
+ log.info("[{}] Received POST request to create configuration with key {}", user.getLogin(),
+ aiConfigurationRecord.key());
+ var aiConfiguration = aiConfigurationService.create(aiConfigurationRecord);
+
+ var configuration = aiSecretService.generateConfiguration();
+
+ aiService.sendConfiguration(configuration);
+
+ return Response.status(HttpStatus.CREATED.value())
+ .entity(new BeanMapper<>(AIConfigurationDTO.class).apply(aiConfiguration))
+ .build();
+ }
+
+ /**
+ * Update multiple configurations.
+ *
+ *
This method handles a PUT request to update multiple configurations.
+ * It validates the user's session and ensures the user has administrative privileges before proceeding with the
+ * association.
+ *
+ * @param request the HttpServletRequest from which to obtain the HttpSession for user validation.
+ * @param aiConfigurationRecords the record containing list of configurations to be updated,
+ * validated for correctness.
+ * @return a Response object indicating the outcome of configurations update. A successful operation returns
+ * a status of OK.
+ */
+ @PUT
+ public Response updateConfiguration(final @Context HttpServletRequest request,
+ final @Valid List aiConfigurationRecords) {
+ HttpSession session = request.getSession();
+ User user = userService.getFromSession(session);
+ userPermissionService.checkPermission(user, "id", EntityPermission.AI_CONFIGURATION, ActionPermission.UPDATE);
+
+ log.info("[{}] Received PUT request to update configurations {}", user.getLogin(),
+ aiConfigurationRecords.stream()
+ .map(UpdateMultipleAIConfigurationRecord::id)
+ .map(UUID::toString)
+ .collect(Collectors.joining(",")));
+
+ List configurations = new ArrayList<>();
+
+ aiConfigurationRecords.forEach(aiConfigurationRecord -> {
+
+ var aiConfiguration = aiConfigurationService.update(aiConfigurationRecord.id(), new AIConfigurationRecord(
+ aiConfigurationRecord.handler(),
+ aiConfigurationRecord.key(),
+ aiConfigurationRecord.value()
+ ));
+
+ configurations.add(new BeanMapper<>(AIConfigurationDTO.class).apply(aiConfiguration));
+ });
+
+ var configuration = aiSecretService.generateConfiguration();
+
+ aiService.sendConfiguration(configuration);
+
+ return Response.status(HttpStatus.OK.value())
+ .entity(configurations)
+ .build();
+ }
+
+ /**
+ * Update a configuration.
+ *
+ * This method handles a PUT request to update a configuration.
+ * It validates the user's session and ensures the user has administrative privileges before proceeding with the
+ * association.
+ *
+ * @param request the HttpServletRequest from which to obtain the HttpSession for user validation.
+ * @param id the ID of the configuration . Must be a valid and non-null UUID value.
+ * @param aiConfigurationRecord the record containing the details of the configuration to be updated,
+ * validated for correctness.
+ * @return a Response object indicating the outcome of the configuration update. A successful operation returns
+ * a status of OK.
+ */
+ @PUT
+ @Path("/{id}")
+ public Response updateConfiguration(final @Context HttpServletRequest request,
+ final @PathParam("id") @Valid @NotNull UUID id,
+ final @Valid AIConfigurationRecord aiConfigurationRecord) {
+ HttpSession session = request.getSession();
+ User user = userService.getFromSession(session);
+ userPermissionService.checkPermission(user, "id", EntityPermission.AI_CONFIGURATION, ActionPermission.UPDATE);
+
+ log.info("[{}] Received PUT request to update configuration {}", user.getLogin(), id.toString());
+
+ var aiConfiguration = aiConfigurationService.update(id, aiConfigurationRecord);
+ var configuration = aiSecretService.generateConfiguration();
+
+ aiService.sendConfiguration(configuration);
+
+ return Response.status(HttpStatus.OK.value())
+ .entity(new BeanMapper<>(AIConfigurationDTO.class).apply(aiConfiguration))
+ .build();
+ }
+
+ /**
+ * Delete a configuration.
+ *
+ *
This method facilitates the handling of a DELETE request to delete a configuration identified by its
+ * respective ID.
+ * The operation is secured, requiring validation of the user's session and administrative privileges.
+ *
+ * @param request the HttpServletRequest used to validate the user's session.
+ * @param id the ID of the configuration . Must be a valid and non-null UUID value.
+ * @return a Response object with a status indicating the outcome of the deletion operation. A successful operation
+ * returns a status of NO_CONTENT.
+ */
+ @DELETE
+ @Path("/{id}")
+ public Response deleteConfiguration(final @Context HttpServletRequest request,
+ final @PathParam("id") @Valid @NotNull UUID id) {
+ HttpSession session = request.getSession();
+ User user = userService.getFromSession(session);
+ userPermissionService.checkPermission(user, "id", EntityPermission.AI_CONFIGURATION, ActionPermission.DELETE);
+
+ log.info("[{}] Received DELETE request to delete configuration {}", user.getLogin(), id);
+ var aiConfiguration = aiConfigurationService.findById(id);
+
+ aiConfiguration.setValue(null);
+
+ var configuration = aiSecretService.generateConfiguration();
+
+ aiService.sendConfiguration(configuration);
+
+ aiConfigurationService.delete(id);
+
+ return Response.noContent().build();
+ }
+}
diff --git a/src/main/java/com/ditrit/letomodelizerapi/controller/AIController.java b/src/main/java/com/ditrit/letomodelizerapi/controller/AIController.java
index 3091d0a2..f3c42794 100644
--- a/src/main/java/com/ditrit/letomodelizerapi/controller/AIController.java
+++ b/src/main/java/com/ditrit/letomodelizerapi/controller/AIController.java
@@ -1,6 +1,7 @@
package com.ditrit.letomodelizerapi.controller;
+import com.ditrit.letomodelizerapi.config.Constants;
import com.ditrit.letomodelizerapi.controller.model.QueryFilter;
import com.ditrit.letomodelizerapi.model.BeanMapper;
import com.ditrit.letomodelizerapi.model.ai.AIConversationDTO;
@@ -12,6 +13,7 @@
import com.ditrit.letomodelizerapi.model.permission.ActionPermission;
import com.ditrit.letomodelizerapi.model.permission.EntityPermission;
import com.ditrit.letomodelizerapi.persistence.model.User;
+import com.ditrit.letomodelizerapi.service.AISecretService;
import com.ditrit.letomodelizerapi.service.AIService;
import com.ditrit.letomodelizerapi.service.UserPermissionService;
import com.ditrit.letomodelizerapi.service.UserService;
@@ -69,6 +71,11 @@ public class AIController implements DefaultController {
*/
private AIService aiService;
+ /**
+ * Service to manage AI request.
+ */
+ private AISecretService aiSecretService;
+
/**
* Handles a POST request to generate files with an Artificial Intelligence (AI) based on the provided
* request details.
@@ -287,4 +294,54 @@ public Response findAllMessages(final @Context HttpServletRequest request,
return Response.status(this.getStatus(resources)).entity(resources).build();
}
+
+ /**
+ * Sends the generated configuration to the AI proxy.
+ *
+ * This method handles GET requests to the endpoint {@code /proxy/configuration}.
+ * It uses the {@code aiSecretService} to generate a configuration that is then sent to the AI proxy.
+ *
+ * @param request the {@link HttpServletRequest} containing the current HTTP request information, used to retrieve
+ * the session details.
+ * @return a {@link Response} with a 204 (No Content) status, indicating the configuration was successfully sent
+ * to the AI proxy.
+ */
+ @GET
+ @Path("/proxy/configuration")
+ public Response sendConfigurationToProxy(final @Context HttpServletRequest request) {
+ HttpSession session = request.getSession();
+ log.info("[{}] Received GET request to send configuration to proxy",
+ session.getAttribute(Constants.DEFAULT_USER_PROPERTY));
+
+ var configuration = aiSecretService.generateConfiguration();
+
+ aiService.sendConfiguration(configuration);
+
+ return Response.noContent().build();
+ }
+
+ /**
+ * Retrieves configuration descriptions from the AI proxy.
+ *
+ * This method handles GET requests to the endpoint {@code /proxy/descriptions}.
+ * If the user has permission, the method retrieves and returns the configuration descriptions from the AI proxy.
+ *
+ * @param request the {@link HttpServletRequest} containing the current HTTP request information, used to retrieve
+ * the session details and user information.
+ * @return a {@link Response} containing the configuration descriptions in the response body with a 200 (OK) status.
+ * If the user lacks permission, an appropriate error response will be returned.
+ */
+ @GET
+ @Path("/proxy/descriptions")
+ public Response retrieveConfigurationDescriptions(final @Context HttpServletRequest request) {
+ HttpSession session = request.getSession();
+ User user = userService.getFromSession(session);
+ userPermissionService.checkPermission(user, "id", EntityPermission.AI_SECRET, ActionPermission.ACCESS);
+
+ log.info("[{}] Received GET request to get configuration descriptions from proxy", user.getLogin());
+
+ var descriptions = aiService.getConfigurationDescriptions();
+
+ return Response.ok(descriptions).build();
+ }
}
diff --git a/src/main/java/com/ditrit/letomodelizerapi/model/ai/AIConfigurationDTO.java b/src/main/java/com/ditrit/letomodelizerapi/model/ai/AIConfigurationDTO.java
new file mode 100644
index 00000000..f8436bba
--- /dev/null
+++ b/src/main/java/com/ditrit/letomodelizerapi/model/ai/AIConfigurationDTO.java
@@ -0,0 +1,40 @@
+package com.ditrit.letomodelizerapi.model.ai;
+
+import lombok.Data;
+
+import java.sql.Timestamp;
+import java.util.UUID;
+
+/**
+ * Data Transfer Object (DTO) for AIConfiguration.
+ * This class is used for transferring AIConfiguration data between different layers of the application,
+ * typically between services and controllers. It is designed to encapsulate the data attributes of a
+ * AIConfiguration entity in a form that is easy to serialize and deserialize when sending responses or requests.
+ */
+@Data
+public class AIConfigurationDTO {
+ /**
+ * The unique identifier of the AIConfiguration entity.
+ * This field represents the primary key in the database.
+ */
+ private UUID id;
+ /**
+ * The handler of the AIConfiguration entity.
+ * This field can be used to display or refer to the AIConfiguration entity in the user interface.
+ */
+ private String handler;
+ /**
+ * The key of the AIConfiguration entity.
+ * This field can be used to display or refer to the AIConfiguration entity in the user interface.
+ */
+ private String key;
+ /**
+ * The value of the AIConfiguration entity.
+ * This field can be used to display or refer to the AIConfiguration entity in the user interface.
+ */
+ private String value;
+ /**
+ * The last update date of the AIConfiguration.
+ */
+ private Timestamp updateDate;
+}
diff --git a/src/main/java/com/ditrit/letomodelizerapi/model/ai/AIConfigurationRecord.java b/src/main/java/com/ditrit/letomodelizerapi/model/ai/AIConfigurationRecord.java
new file mode 100644
index 00000000..5ad51041
--- /dev/null
+++ b/src/main/java/com/ditrit/letomodelizerapi/model/ai/AIConfigurationRecord.java
@@ -0,0 +1,16 @@
+package com.ditrit.letomodelizerapi.model.ai;
+
+import jakarta.validation.constraints.NotBlank;
+
+/**
+ * Represents an immutable AI configuration.
+ * @param handler The configuration handler.
+ * @param key The non-blank configuration key.
+ * @param value The non-blank configuration value.
+ */
+public record AIConfigurationRecord(
+ String handler,
+ @NotBlank String key,
+ @NotBlank String value
+) {
+}
diff --git a/src/main/java/com/ditrit/letomodelizerapi/model/ai/UpdateMultipleAIConfigurationRecord.java b/src/main/java/com/ditrit/letomodelizerapi/model/ai/UpdateMultipleAIConfigurationRecord.java
new file mode 100644
index 00000000..48daf288
--- /dev/null
+++ b/src/main/java/com/ditrit/letomodelizerapi/model/ai/UpdateMultipleAIConfigurationRecord.java
@@ -0,0 +1,21 @@
+package com.ditrit.letomodelizerapi.model.ai;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.UUID;
+
+/**
+ * Represents an immutable AI configuration to update.
+ * @param id The configuration id.
+ * @param handler The configuration handler.
+ * @param key The non-blank configuration key.
+ * @param value The non-blank configuration value.
+ */
+public record UpdateMultipleAIConfigurationRecord(
+ @NotNull UUID id,
+ String handler,
+ @NotBlank String key,
+ @NotBlank String value
+) {
+}
diff --git a/src/main/java/com/ditrit/letomodelizerapi/model/permission/EntityPermission.java b/src/main/java/com/ditrit/letomodelizerapi/model/permission/EntityPermission.java
index 4af72ab5..53002774 100644
--- a/src/main/java/com/ditrit/letomodelizerapi/model/permission/EntityPermission.java
+++ b/src/main/java/com/ditrit/letomodelizerapi/model/permission/EntityPermission.java
@@ -47,5 +47,9 @@ public enum EntityPermission {
/**
* Represents permissions for managing AI secrets.
*/
- AI_SECRET;
+ AI_SECRET,
+ /**
+ * Represents permissions for managing AI configurations.
+ */
+ AI_CONFIGURATION;
}
diff --git a/src/main/java/com/ditrit/letomodelizerapi/persistence/model/AIConfiguration.java b/src/main/java/com/ditrit/letomodelizerapi/persistence/model/AIConfiguration.java
new file mode 100644
index 00000000..7b9025a7
--- /dev/null
+++ b/src/main/java/com/ditrit/letomodelizerapi/persistence/model/AIConfiguration.java
@@ -0,0 +1,64 @@
+package com.ditrit.letomodelizerapi.persistence.model;
+
+import com.ditrit.letomodelizerapi.persistence.specification.filter.FilterType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.PrePersist;
+import jakarta.persistence.Table;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.sql.Timestamp;
+import java.time.LocalDateTime;
+import java.util.UUID;
+
+/**
+ * Represents an AI configuration in the system.
+ */
+@Entity
+@Table(name = "ai_configurations")
+@EqualsAndHashCode(callSuper = true)
+@Data
+public class AIConfiguration extends AbstractEntity {
+
+ /**
+ * Internal id.
+ */
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "acf_id")
+ @FilterType(type = FilterType.Type.UUID)
+ private UUID id;
+
+ /**
+ * The configuration handler.
+ */
+ @Column(name = "handler")
+ @FilterType(type = FilterType.Type.TEXT)
+ private String handler;
+
+ /**
+ * The configuration key.
+ */
+ @Column(name = "key")
+ @FilterType(type = FilterType.Type.TEXT)
+ private String key;
+
+ /**
+ * The value of the configuration.
+ */
+ @Column(name = "value")
+ @FilterType(type = FilterType.Type.TEXT)
+ private String value;
+
+ /**
+ * Set insertDate before persisting in repository.
+ */
+ @PrePersist
+ public void prePersist() {
+ this.setInsertDate(Timestamp.valueOf(LocalDateTime.now()));
+ }
+}
diff --git a/src/main/java/com/ditrit/letomodelizerapi/persistence/repository/AIConfigurationRepository.java b/src/main/java/com/ditrit/letomodelizerapi/persistence/repository/AIConfigurationRepository.java
new file mode 100644
index 00000000..2a236b5a
--- /dev/null
+++ b/src/main/java/com/ditrit/letomodelizerapi/persistence/repository/AIConfigurationRepository.java
@@ -0,0 +1,52 @@
+package com.ditrit.letomodelizerapi.persistence.repository;
+
+import com.ditrit.letomodelizerapi.persistence.model.AIConfiguration;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.domain.Specification;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * Interface for the AI configuration repository that extends JpaRepository to handle data access operations for
+ * {@code AIConfiguration} entities. This repository interface provides CRUD operations and additional methods
+ * to interact with the underlying AI configuration storage mechanism.
+ *
+ * @see JpaRepository
+ */
+public interface AIConfigurationRepository extends JpaRepository {
+
+ /**
+ * Retrieves a page of AIConfiguration entities that match the given specification.
+ * This method allows for complex queries and filtering of AIConfiguration records using the provided
+ * specification.
+ *
+ * @param specification a Specification object that defines the conditions for filtering AIConfiguration
+ * records.
+ * @param pageable a Pageable object that defines the pagination parameters.
+ * @return a Page containing AIConfiguration entities that match the given specification.
+ */
+ Page findAll(Specification specification, Pageable pageable);
+
+ /**
+ * Checks whether a AIConfiguration entity with the given key exists in the repository.
+ * This method allows for verifying the existence of a AIConfiguration by its unique key.
+ *
+ * @param handler a String representing the handler of the AIConfiguration entity.
+ * @param key a String representing the key of the AIConfiguration entity.
+ * @return true if a AIConfiguration with the given key exists, false otherwise.
+ */
+ boolean existsByHandlerAndKey(String handler, String key);
+
+ /**
+ * Retrieves an Optional containing a AIConfiguration entity with the given ID.
+ * This method looks for a AIConfiguration by its unique UUID and returns it if found.
+ *
+ * @param id the UUID of the AIConfiguration entity.
+ * @return an Optional containing the found AIConfiguration entity, or an empty Optional if no entity
+ * with the given ID is found.
+ */
+ Optional findById(UUID id);
+}
diff --git a/src/main/java/com/ditrit/letomodelizerapi/service/AIConfigurationService.java b/src/main/java/com/ditrit/letomodelizerapi/service/AIConfigurationService.java
new file mode 100644
index 00000000..656574b8
--- /dev/null
+++ b/src/main/java/com/ditrit/letomodelizerapi/service/AIConfigurationService.java
@@ -0,0 +1,57 @@
+package com.ditrit.letomodelizerapi.service;
+
+import com.ditrit.letomodelizerapi.model.ai.AIConfigurationRecord;
+import com.ditrit.letomodelizerapi.persistence.model.AIConfiguration;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Service interface for managing AIConfiguration entities.
+ * This interface defines methods for operations like finding, creating, updating, and deleting
+ * AIConfiguration entities.
+ */
+public interface AIConfigurationService {
+ /**
+ * Finds and returns a page of AIConfiguration entities, filtered by provided criteria.
+ *
+ * @param filters a Map of strings representing the filtering criteria.
+ * @param pageable a Pageable object for pagination information.
+ * @return a Page of AIConfiguration entities matching the specified type and filters.
+ */
+ Page findAll(Map filters, Pageable pageable);
+
+ /**
+ * Finds and returns an AIConfiguration entity of a specific type by its ID.
+ *
+ * @param id the ID of the AIConfiguration entity.
+ * @return the found AIConfiguration entity, or null if no entity is found with the given ID.
+ */
+ AIConfiguration findById(UUID id);
+
+ /**
+ * Creates a new AIConfiguration entity of a specified type.
+ *
+ * @param aiConfigurationRecord an AIConfigurationRecord object containing the data for the new entity.
+ * @return the newly created AIConfiguration entity.
+ */
+ AIConfiguration create(AIConfigurationRecord aiConfigurationRecord);
+
+ /**
+ * Updates an existing AIConfiguration entity of a specified type and ID.
+ *
+ * @param id the ID of the AIConfiguration entity to update.
+ * @param aiConfigurationRecord a AIConfigurationRecord object containing the updated data.
+ * @return the updated AIConfiguration entity.
+ */
+ AIConfiguration update(UUID id, AIConfigurationRecord aiConfigurationRecord);
+
+ /**
+ * Deletes an AIConfiguration entity of a specified type by its ID.
+ *
+ * @param id the ID of the AIConfiguration entity to delete.
+ */
+ void delete(UUID id);
+}
diff --git a/src/main/java/com/ditrit/letomodelizerapi/service/AIConfigurationServiceImpl.java b/src/main/java/com/ditrit/letomodelizerapi/service/AIConfigurationServiceImpl.java
new file mode 100644
index 00000000..f84a8f40
--- /dev/null
+++ b/src/main/java/com/ditrit/letomodelizerapi/service/AIConfigurationServiceImpl.java
@@ -0,0 +1,90 @@
+package com.ditrit.letomodelizerapi.service;
+
+import com.ditrit.letomodelizerapi.model.ai.AIConfigurationRecord;
+import com.ditrit.letomodelizerapi.model.error.ApiException;
+import com.ditrit.letomodelizerapi.model.error.ErrorType;
+import com.ditrit.letomodelizerapi.persistence.model.AIConfiguration;
+import com.ditrit.letomodelizerapi.persistence.repository.AIConfigurationRepository;
+import com.ditrit.letomodelizerapi.persistence.specification.SpecificationHelper;
+import jakarta.transaction.Transactional;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Implementation of the AccessControlService interface.
+ *
+ * This class provides concrete implementations for the access control management operations defined in
+ * AccessControlService.
+ * AccessControlServiceImpl interacts with the underlying repository layer to perform these operations,
+ * ensuring that business logic and data access are effectively managed.
+ */
+@Slf4j
+@Service
+@Transactional
+@AllArgsConstructor(onConstructor = @__(@Autowired))
+public class AIConfigurationServiceImpl implements AIConfigurationService {
+
+ /**
+ * The AIConfigurationRepository instance is injected by Spring's dependency injection mechanism.
+ * This repository is used for performing database operations related to AIConfiguration entities,
+ * such as querying, saving, and updating access control data.
+ */
+ private final AIConfigurationRepository aiConfigurationRepository;
+
+ @Override
+ public Page findAll(final Map filters, final Pageable pageable) {
+ return aiConfigurationRepository.findAll(new SpecificationHelper<>(AIConfiguration.class, filters),
+ PageRequest.of(
+ pageable.getPageNumber(),
+ pageable.getPageSize(),
+ pageable.getSortOr(Sort.by(Sort.Direction.ASC, "key"))
+ )
+ );
+ }
+
+ @Override
+ public AIConfiguration findById(final UUID id) {
+ return aiConfigurationRepository.findById(id)
+ .orElseThrow(() -> new ApiException(ErrorType.ENTITY_NOT_FOUND, "id", id.toString()));
+ }
+
+ @Override
+ public AIConfiguration create(final AIConfigurationRecord aiConfigurationRecord) {
+ if (aiConfigurationRepository.existsByHandlerAndKey(aiConfigurationRecord.handler(),
+ aiConfigurationRecord.key())) {
+ throw new ApiException(ErrorType.ENTITY_ALREADY_EXISTS, "key", aiConfigurationRecord.key());
+ }
+
+ var aiConfiguration = new AIConfiguration();
+ aiConfiguration.setHandler(aiConfigurationRecord.handler());
+ aiConfiguration.setKey(aiConfigurationRecord.key());
+ aiConfiguration.setValue(aiConfigurationRecord.value());
+
+ return aiConfigurationRepository.save(aiConfiguration);
+ }
+
+ @Override
+ public AIConfiguration update(final UUID id, final AIConfigurationRecord aiConfigurationRecord) {
+ AIConfiguration aiConfiguration = findById(id);
+
+ aiConfiguration.setValue(aiConfigurationRecord.value());
+
+ return aiConfigurationRepository.save(aiConfiguration);
+ }
+
+ @Override
+ public void delete(final UUID id) {
+ AIConfiguration aiConfiguration = findById(id);
+
+ aiConfigurationRepository.delete(aiConfiguration);
+ }
+}
diff --git a/src/main/java/com/ditrit/letomodelizerapi/service/AISecretService.java b/src/main/java/com/ditrit/letomodelizerapi/service/AISecretService.java
index 704e9993..e09e9e18 100644
--- a/src/main/java/com/ditrit/letomodelizerapi/service/AISecretService.java
+++ b/src/main/java/com/ditrit/letomodelizerapi/service/AISecretService.java
@@ -54,4 +54,11 @@ public interface AISecretService {
* @param id the ID of the AISecret entity to delete.
*/
void delete(UUID id);
+
+ /**
+ * Retrieve all configurations, apply secrets in configuration values and return encrypted configuration for AI
+ * proxy.
+ * @return Encrypted configuration.
+ */
+ byte[] generateConfiguration();
}
diff --git a/src/main/java/com/ditrit/letomodelizerapi/service/AISecretServiceImpl.java b/src/main/java/com/ditrit/letomodelizerapi/service/AISecretServiceImpl.java
index 62240d8d..5c2845f5 100644
--- a/src/main/java/com/ditrit/letomodelizerapi/service/AISecretServiceImpl.java
+++ b/src/main/java/com/ditrit/letomodelizerapi/service/AISecretServiceImpl.java
@@ -5,9 +5,13 @@
import com.ditrit.letomodelizerapi.model.ai.AISecretRecord;
import com.ditrit.letomodelizerapi.model.error.ApiException;
import com.ditrit.letomodelizerapi.model.error.ErrorType;
+import com.ditrit.letomodelizerapi.persistence.model.AIConfiguration;
import com.ditrit.letomodelizerapi.persistence.model.AISecret;
+import com.ditrit.letomodelizerapi.persistence.repository.AIConfigurationRepository;
import com.ditrit.letomodelizerapi.persistence.repository.AISecretRepository;
import com.ditrit.letomodelizerapi.persistence.specification.SpecificationHelper;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.hubspot.jinjava.Jinjava;
import jakarta.transaction.Transactional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@@ -24,6 +28,8 @@
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
+import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -58,11 +64,23 @@ public class AISecretServiceImpl implements AISecretService {
*/
private final AISecretRepository aiSecretRepository;
+ /**
+ * The AIConfigurationRepository instance is injected by Spring's dependency injection mechanism.
+ * This repository is used for performing database operations related to AIConfiguration entities,
+ * such as querying, saving, and updating access control data.
+ */
+ private final AIConfigurationRepository aiConfigurationRepository;
+
/**
* The key to encrypt or decrypt secret value.
*/
private final String secretEncryptionKey;
+ /**
+ * The key to encrypt or decrypt configuration.
+ */
+ private final String configurationEncryptionKey;
+
/**
* Size of IV.
*/
@@ -81,13 +99,19 @@ public class AISecretServiceImpl implements AISecretService {
* Constructor for AISecretServiceImpl.
*
* @param aiSecretRepository Repository to manage AISecret.
+ * @param aiConfigurationRepository Repository to manage AIConfiguration.
* @param secretEncryptionKey the key to encrypt or decrypt secret value.
+ * @param configurationEncryptionKey the key to encrypt or decrypt configuration.
*/
@Autowired
public AISecretServiceImpl(final AISecretRepository aiSecretRepository,
- @Value("${ai.secrets.encryption.key}") final String secretEncryptionKey) {
+ final AIConfigurationRepository aiConfigurationRepository,
+ @Value("${ai.secrets.encryption.key}") final String secretEncryptionKey,
+ @Value("${ai.configuration.encryption.key}") final String configurationEncryptionKey) {
this.aiSecretRepository = aiSecretRepository;
+ this.aiConfigurationRepository = aiConfigurationRepository;
this.secretEncryptionKey = secretEncryptionKey;
+ this.configurationEncryptionKey = configurationEncryptionKey;
}
/**
@@ -96,10 +120,11 @@ public AISecretServiceImpl(final AISecretRepository aiSecretRepository,
* and uses AES encryption to secure the plain text. The resulting byte array contains both the IV and the
* encrypted text.
*
+ * @param key key to encrypt the text.
* @param plainText the plain text to be encrypted.
* @return a byte array containing the IV and the encrypted text.
*/
- private byte[] encrypt(final String plainText) {
+ public byte[] encrypt(final String key, final String plainText) {
try {
byte[] clean = plainText.getBytes();
@@ -111,7 +136,7 @@ private byte[] encrypt(final String plainText) {
// Hashing key
MessageDigest digest = MessageDigest.getInstance("SHA-256");
- digest.update(secretEncryptionKey.getBytes(StandardCharsets.UTF_8));
+ digest.update(key.getBytes(StandardCharsets.UTF_8));
byte[] keyBytes = new byte[KEY_SIZE];
System.arraycopy(digest.digest(), 0, keyBytes, 0, keyBytes.length);
SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, "AES");
@@ -137,10 +162,11 @@ private byte[] encrypt(final String plainText) {
* This method extracts the IV, hashes the provided secret key using SHA-256, and uses AES decryption
* to convert the encrypted bytes back into plain text.
*
+ * @param key key to decrypt the text.
* @param encryptedIvTextBytes a byte array containing the IV and the encrypted text.
* @return the decrypted plain text.
*/
- private String decrypt(final byte[] encryptedIvTextBytes) {
+ public String decrypt(final String key, final byte[] encryptedIvTextBytes) {
try {
// Extract IV
byte[] iv = new byte[IV_SIZE];
@@ -155,7 +181,7 @@ private String decrypt(final byte[] encryptedIvTextBytes) {
// Hash key
byte[] keyBytes = new byte[KEY_SIZE];
MessageDigest md = MessageDigest.getInstance("SHA-256");
- md.update(secretEncryptionKey.getBytes(StandardCharsets.UTF_8));
+ md.update(key.getBytes(StandardCharsets.UTF_8));
System.arraycopy(md.digest(), 0, keyBytes, 0, keyBytes.length);
SecretKeySpec secretKeySpec = new SecretKeySpec(keyBytes, "AES");
@@ -227,7 +253,7 @@ public AISecret create(final AISecretRecord aiSecretRecord) {
var aiSecret = new AISecret();
aiSecret.setKey(aiSecretRecord.key());
- aiSecret.setValue(encrypt(aiSecretRecord.value()));
+ aiSecret.setValue(encrypt(secretEncryptionKey, aiSecretRecord.value()));
aiSecret = aiSecretRepository.save(aiSecret);
@@ -241,7 +267,7 @@ public AISecret create(final AISecretRecord aiSecretRecord) {
public AISecret update(final UUID id, final AISecretRecord aiSecretRecord) {
AISecret aiSecret = findById(id);
- aiSecret.setValue(encrypt(aiSecretRecord.value()));
+ aiSecret.setValue(encrypt(secretEncryptionKey, aiSecretRecord.value()));
aiSecret = aiSecretRepository.save(aiSecret);
@@ -257,4 +283,54 @@ public void delete(final UUID id) {
aiSecretRepository.delete(aiSecret);
}
+
+ @Override
+ public byte[] generateConfiguration() {
+ return generateConfiguration(aiConfigurationRepository.findAll());
+ }
+
+ /**
+ * Generates an encrypted configuration file using a list of AI configurations.
+ *
+ * This method uses the Jinjava templating engine to process configuration values and replace
+ * placeholders with decrypted secret values. It collects all secrets from the {@code aiSecretRepository},
+ * decrypts them, and stores them in a context map. Then, it iterates over the provided configurations,
+ * applies the secret replacements, and constructs a JSON object containing the processed configuration values.
+ * Finally, the generated configuration is encrypted before being returned as a byte array.
+ *
+ * @param configurations a list of {@link AIConfiguration} objects that define the keys, values, and optional
+ * handlers for the configuration. The values may contain placeholders for secrets that will
+ * be replaced using Jinjava.
+ * @return a byte array representing the encrypted configuration, ready to be sent to the AI proxy.
+ * @throws ApiException if encryption fails or any other unexpected error occurs during processing.
+ */
+ public byte[] generateConfiguration(final List configurations) {
+ var jinjava = new Jinjava();
+ var secrets = new HashMap();
+ var context = new HashMap();
+ var json = JsonNodeFactory.instance.objectNode();
+
+ aiSecretRepository.findAll().forEach(secret -> secrets.put(secret.getKey(),
+ decrypt(secretEncryptionKey, secret.getValue())));
+
+ context.put("secrets", secrets);
+
+ configurations.forEach(configuration -> {
+ String key = null;
+
+ if (configuration.getHandler() == null) {
+ key = configuration.getKey();
+ } else {
+ key = String.format("%s.%s", configuration.getHandler(), configuration.getKey());
+ }
+
+ if (configuration.getValue() == null) {
+ json.put(key, "");
+ } else {
+ json.put(key, jinjava.render(configuration.getValue(), context));
+ }
+ });
+
+ return encrypt(configurationEncryptionKey, json.toString());
+ }
}
diff --git a/src/main/java/com/ditrit/letomodelizerapi/service/AIService.java b/src/main/java/com/ditrit/letomodelizerapi/service/AIService.java
index 1067b37f..756efa2a 100644
--- a/src/main/java/com/ditrit/letomodelizerapi/service/AIService.java
+++ b/src/main/java/com/ditrit/letomodelizerapi/service/AIService.java
@@ -118,4 +118,25 @@ AIConversation updateConversationById(User user, UUID id, AIConversationRecord a
Page findAllConversations(Map immutableFilters,
Pageable pageable);
+ /**
+ * Sends the encrypted configuration to the AI proxy.
+ *
+ * This method accepts a byte array representing the encrypted configuration and sends it to the AI proxy for
+ * further processing or application. The configuration is assumed to have been generated and encrypted by the
+ * caller before being passed to this method.
+ *
+ * @param configuration a byte array containing the encrypted configuration data to be sent to the AI proxy.
+ */
+ void sendConfiguration(byte[] configuration);
+
+ /**
+ * Retrieves the descriptions of the configurations from the AI proxy.
+ *
+ * This method communicates with the AI proxy to retrieve a list or summary of configuration descriptions that are
+ * currently available. The descriptions provide insight into the configurations being used or processed by the
+ * proxy. The result is returned as a string, typically in a JSON or plain text format.
+ *
+ * @return a string representing the configuration descriptions retrieved from the AI proxy.
+ */
+ String getConfigurationDescriptions();
}
diff --git a/src/main/java/com/ditrit/letomodelizerapi/service/AIServiceImpl.java b/src/main/java/com/ditrit/letomodelizerapi/service/AIServiceImpl.java
index 70a0f3ee..187cda89 100644
--- a/src/main/java/com/ditrit/letomodelizerapi/service/AIServiceImpl.java
+++ b/src/main/java/com/ditrit/letomodelizerapi/service/AIServiceImpl.java
@@ -19,6 +19,14 @@
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.github.erosb.jsonsKema.FormatValidationPolicy;
+import com.github.erosb.jsonsKema.JsonParser;
+import com.github.erosb.jsonsKema.JsonValue;
+import com.github.erosb.jsonsKema.Schema;
+import com.github.erosb.jsonsKema.SchemaLoader;
+import com.github.erosb.jsonsKema.ValidationFailure;
+import com.github.erosb.jsonsKema.Validator;
+import com.github.erosb.jsonsKema.ValidatorConfig;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.MediaType;
@@ -34,6 +42,7 @@
import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.http.HttpClient;
@@ -75,6 +84,14 @@ public class AIServiceImpl implements AIService {
*/
private final AIMessageRepository aiMessageRepository;
+
+ /**
+ * A Validator instance used for validating description of configuration entities against a JSON schema.
+ * This validator ensures that description of configuration data conforms to a specified schema, providing a way
+ * to enforce data integrity and structure.
+ */
+ private Validator configurationDescriptionSchemaValidator;
+
/**
* Constructor for AIServiceImpl.
* Initializes the service with the host address of the Artificial Intelligence (AI) system. This address is used to
@@ -94,23 +111,50 @@ public AIServiceImpl(final AIConversationRepository aiConversationRepository,
this.aiConversationRepository = aiConversationRepository;
this.aiMessageRepository = aiMessageRepository;
this.aiHost = aiHost;
+
+ loadSchemaValidator();
+ }
+
+ /**
+ * Loads the JSON schema validator for description of configuration entities from a JSON file.
+ * This method reads the description of configuration schema definition from a file, parses it to a JsonValue,
+ * then constructs and configures a Schema instance for validation. It sets the
+ * configurationDescriptionSchemaValidator attribute of the class for future validation operations.
+ *
+ * Throws ApiException with an appropriate error message and type if there is an issue loading or parsing
+ * the schema file, such as an IOException.
+ */
+ public void loadSchemaValidator() {
+ InputStream inputStream = getClass().getResourceAsStream("/ai-configuration-description-schema.json");
+
+ try {
+ JsonValue json = new JsonParser(new String(inputStream.readAllBytes(), StandardCharsets.UTF_8)).parse();
+ Schema schema = new SchemaLoader(json).load();
+ this.configurationDescriptionSchemaValidator = Validator
+ .create(schema, new ValidatorConfig(FormatValidationPolicy.ALWAYS));
+ } catch (IOException e) {
+ log.error("Error when retrieving ai-configuration-description-schema.json", e);
+ throw new ApiException(e, ErrorType.INTERNAL_ERROR, "ai-configuration-description-schema.json",
+ "Error when retrieving.");
+ }
}
/**
* Sends a request to the AI service with the specified endpoint and request body.
*
* @param endpoint the URL of the AI endpoint to which the request is sent.
+ * @param contentType the content type of the body.
* @param body the content to be sent in the body of the request.
* @return the response body returned by the AI service.
*/
- public String sendRequest(final String endpoint, final String body) {
+ public String sendRequest(final String endpoint, final String contentType, final byte[] body) {
try {
URI uri = new URI(aiHost).resolve("api/").resolve(endpoint);
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
- .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
+ .header(HttpHeaders.CONTENT_TYPE, contentType)
.headers(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON)
- .POST(HttpRequest.BodyPublishers.ofString(body))
+ .POST(HttpRequest.BodyPublishers.ofByteArray(body))
.build();
HttpResponse response = HttpClient
@@ -119,7 +163,7 @@ public String sendRequest(final String endpoint, final String body) {
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == ErrorType.AI_GENERATION_ERROR.getCode()) {
- throw new ApiException(ErrorType.AI_GENERATION_ERROR, "body", body);
+ throw new ApiException(ErrorType.AI_GENERATION_ERROR, "body");
}
if (!HttpStatus.valueOf(response.statusCode()).is2xxSuccessful()) {
@@ -161,7 +205,8 @@ public String sendFiles(final AIConversation conversation, final List findAllConversations(final Map immut
pageable.getSortOr(Sort.by(Sort.Direction.DESC, Constants.DEFAULT_UPDATE_DATE_PROPERTY))
));
}
+
+ @Override
+ public void sendConfiguration(final byte[] configuration) {
+ sendRequest("configurations", MediaType.APPLICATION_OCTET_STREAM, configuration);
+ }
+
+ @Override
+ public String getConfigurationDescriptions() {
+ final var endpoint = "api/configurations/descriptions";
+ try {
+ URI uri = new URI(aiHost).resolve(endpoint);
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(uri)
+ .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
+ .headers(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON)
+ .GET()
+ .build();
+
+ HttpResponse response = HttpClient
+ .newBuilder()
+ .build()
+ .send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (!HttpStatus.valueOf(response.statusCode()).is2xxSuccessful()) {
+ throw new ApiException(ErrorType.WRONG_VALUE, "url", uri.toString());
+ }
+ String result = response.body();
+
+ JsonNode json = new ObjectMapper().readTree(result);
+
+ json.forEach(handler -> handler.forEach(handlerDescription -> {
+ ValidationFailure failure = configurationDescriptionSchemaValidator
+ .validate(new JsonParser(handlerDescription.toString()).parse());
+
+ if (failure != null) {
+ throw new ApiException(ErrorType.INTERNAL_ERROR, failure.getInstance().getLocation()
+ .getPointer().toString(), failure.getMessage());
+ }
+ }));
+
+ return response.body();
+ } catch (URISyntaxException | IOException e) {
+ throw new ApiException(ErrorType.WRONG_VALUE, "url", aiHost + endpoint);
+ } catch (InterruptedException e) {
+ log.warn("InterruptedException during requesting ai with {}", aiHost + endpoint, e);
+ Thread.currentThread().interrupt();
+ throw new ApiException(ErrorType.INTERNAL_ERROR, "url", aiHost + endpoint);
+ }
+ }
}
diff --git a/src/main/resources/ai-configuration-description-schema.json b/src/main/resources/ai-configuration-description-schema.json
new file mode 100644
index 00000000..08cbddf7
--- /dev/null
+++ b/src/main/resources/ai-configuration-description-schema.json
@@ -0,0 +1,59 @@
+{
+ "$schema": "http://json-schema.org/draft-06/schema#",
+ "unevaluatedProperties": false,
+ "type": "object",
+ "required": [
+ "handler",
+ "key",
+ "type",
+ "values",
+ "defaultValue",
+ "label",
+ "title",
+ "description",
+ "pluginDependent",
+ "required"
+ ],
+ "properties": {
+ "handler": {
+ "type": "string",
+ "maxLength": 255
+ },
+ "key": {
+ "type": "string",
+ "maxLength": 255
+ },
+ "type": {
+ "type": "string",
+ "enum": ["select", "text", "textarea"]
+ },
+ "values": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "defaultValue": {
+ "type": "string",
+ "maxLength": 255
+ },
+ "label": {
+ "type": "string",
+ "maxLength": 255
+ },
+ "title": {
+ "type": "string",
+ "maxLength": 255
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 255
+ },
+ "pluginDependent": {
+ "type": "boolean"
+ },
+ "required": {
+ "type": "boolean"
+ }
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 9948ccfe..8c36241e 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -44,3 +44,4 @@ csrf.token.timeout=${CSRF_TOKEN_TIMEOUT:3600}
server.servlet.session.timeout=${USER_SESSION_TIMEOUT:3600}
ai.host=${AI_HOST:http://localhost:8585/}
ai.secrets.encryption.key=${AI_SECRETS_ENCRYPTION_KEY}
+ai.configuration.encryption.key=${AI_CONFIGURATION_ENCRYPTION_KEY}
diff --git a/src/main/resources/db/migration/V1_12_0__new_table_ai_configurations_and_permissions.sql b/src/main/resources/db/migration/V1_12_0__new_table_ai_configurations_and_permissions.sql
new file mode 100644
index 00000000..a6f59c7c
--- /dev/null
+++ b/src/main/resources/db/migration/V1_12_0__new_table_ai_configurations_and_permissions.sql
@@ -0,0 +1,37 @@
+-- Add permission for add/update/delete configuration.
+ALTER TYPE entity_type ADD VALUE 'AI_CONFIGURATION';
+
+INSERT INTO permissions(entity, action, lib_id) VALUES
+('AI_CONFIGURATION', 'CREATE', NULL),
+('AI_CONFIGURATION', 'DELETE', NULL),
+('AI_CONFIGURATION', 'UPDATE', NULL),
+('AI_CONFIGURATION', 'ACCESS', NULL);
+
+-- Add permission to Super Administrator.
+INSERT INTO access_controls_permissions(aco_id, per_id)
+SELECT (SELECT aco_id FROM access_controls WHERE name = 'SUPER_ADMINISTRATOR'), per_id FROM permissions WHERE entity = 'AI_CONFIGURATION';
+
+-- Add permission to Administrator.
+INSERT INTO access_controls_permissions(aco_id, per_id)
+SELECT (SELECT aco_id FROM access_controls WHERE name = 'ADMINISTRATOR'), per_id FROM permissions WHERE entity = 'AI_CONFIGURATION';
+
+-- Create configuration table.
+CREATE TABLE IF NOT EXISTS ai_configurations (
+ acf_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ handler VARCHAR(100),
+ key TEXT NOT NULL,
+ value TEXT NOT NULL,
+ insert_date TIMESTAMP NOT NULL DEFAULT now(),
+ update_date TIMESTAMP NOT NULL DEFAULT now(),
+ CONSTRAINT uc_acf_handler_key UNIQUE (handler, key)
+);
+
+COMMENT ON TABLE ai_configurations IS 'Table to store AI configurations.';
+COMMENT ON COLUMN ai_configurations.acf_id IS 'References the primary key in the ai_configurations table.';
+COMMENT ON COLUMN ai_configurations.handler IS 'The configuration handler name, if null refer to system configuration.';
+COMMENT ON COLUMN ai_configurations.key IS 'The configuration key given by the user, must be unique.';
+COMMENT ON COLUMN ai_configurations.value IS 'The encoded value of the configuration.';
+COMMENT ON COLUMN ai_configurations.insert_date IS 'Creation date of this row.';
+COMMENT ON COLUMN ai_configurations.update_date IS 'Last update date of this row.';
+
+COMMENT ON CONSTRAINT uc_acf_handler_key ON ai_configurations IS 'Constraint to make handler and key column unique.';
diff --git a/src/test/java/com/ditrit/letomodelizerapi/controller/AIConfigurationControllerTest.java b/src/test/java/com/ditrit/letomodelizerapi/controller/AIConfigurationControllerTest.java
new file mode 100644
index 00000000..8587d20a
--- /dev/null
+++ b/src/test/java/com/ditrit/letomodelizerapi/controller/AIConfigurationControllerTest.java
@@ -0,0 +1,197 @@
+package com.ditrit.letomodelizerapi.controller;
+
+import com.ditrit.letomodelizerapi.controller.model.QueryFilter;
+import com.ditrit.letomodelizerapi.helper.MockHelper;
+import com.ditrit.letomodelizerapi.model.ai.AIConfigurationRecord;
+import com.ditrit.letomodelizerapi.model.ai.UpdateMultipleAIConfigurationRecord;
+import com.ditrit.letomodelizerapi.persistence.model.AIConfiguration;
+import com.ditrit.letomodelizerapi.persistence.model.User;
+import com.ditrit.letomodelizerapi.service.AIConfigurationService;
+import com.ditrit.letomodelizerapi.service.AISecretService;
+import com.ditrit.letomodelizerapi.service.AIService;
+import com.ditrit.letomodelizerapi.service.UserPermissionService;
+import com.ditrit.letomodelizerapi.service.UserService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpSession;
+import jakarta.ws.rs.core.Response;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.http.HttpStatus;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@Tag("unit")
+@ExtendWith(MockitoExtension.class)
+@DisplayName("Test class: AIController")
+class AIConfigurationControllerTest extends MockHelper {
+
+ @Mock
+ UserService userService;
+
+ @Mock
+ UserPermissionService userPermissionService;
+
+ @Mock
+ AIConfigurationService aiConfigurationService;
+
+ @Mock
+ AISecretService aiSecretService;
+
+ @Mock
+ AIService aiService;
+
+ @InjectMocks
+ AIConfigurationController controller;
+
+ @Test
+ @DisplayName("Test getAllConfigurations: should return valid response to get all configurations.")
+ void testGetAllConfigurations() {
+ User user = new User();
+ user.setLogin("login");
+ HttpSession session = Mockito.mock(HttpSession.class);
+ HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+
+ Mockito.when(userService.getFromSession(Mockito.any())).thenReturn(user);
+ Mockito.when(request.getSession()).thenReturn(session);
+ Mockito.doNothing().when(userPermissionService).checkPermission(Mockito.any(), Mockito.any(), Mockito.any(),
+ Mockito.any());
+ Mockito.when(this.aiConfigurationService.findAll(Mockito.any(), Mockito.any()))
+ .thenReturn(new PageImpl<>(new ArrayList<>()));
+
+ final Response response = this.controller.getAllConfigurations(request, mockUriInfo(), new QueryFilter());
+
+ assertNotNull(response);
+ assertEquals(HttpStatus.OK.value(), response.getStatus());
+ assertNotNull(response.getEntity());
+ }
+
+ @Test
+ @DisplayName("Test getConfigurationById: should return valid response to get configuration by id.")
+ void testGetConfigurationById() {
+ User user = new User();
+ user.setLogin("login");
+ HttpSession session = Mockito.mock(HttpSession.class);
+ HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+
+ Mockito.when(userService.getFromSession(Mockito.any())).thenReturn(user);
+ Mockito.when(request.getSession()).thenReturn(session);
+ Mockito.doNothing().when(userPermissionService).checkPermission(Mockito.any(), Mockito.any(), Mockito.any(),
+ Mockito.any());
+ Mockito.when(this.aiConfigurationService.findById(Mockito.any())).thenReturn(new AIConfiguration());
+
+ final Response response = this.controller.getConfigurationById(request, UUID.randomUUID());
+
+ assertNotNull(response);
+ assertEquals(HttpStatus.OK.value(), response.getStatus());
+ assertNotNull(response.getEntity());
+ }
+
+ @Test
+ @DisplayName("Test createConfiguration: should return valid response on create a configuration.")
+ void testCreateConfiguration() {
+ User user = new User();
+ user.setLogin("login");
+ HttpSession session = Mockito.mock(HttpSession.class);
+ HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+
+ Mockito.when(userService.getFromSession(Mockito.any())).thenReturn(user);
+ Mockito.when(request.getSession()).thenReturn(session);
+ Mockito.doNothing().when(userPermissionService).checkPermission(Mockito.any(), Mockito.any(), Mockito.any(),
+ Mockito.any());
+ Mockito.when(this.aiConfigurationService.create(Mockito.any())).thenReturn(new AIConfiguration());
+ Mockito.when(this.aiSecretService.generateConfiguration()).thenReturn("test".getBytes());
+ Mockito.doNothing().when(this.aiService).sendConfiguration(Mockito.any());
+
+ final Response response = this.controller.createConfiguration(request,
+ new AIConfigurationRecord("handler","key", "value"));
+
+ assertNotNull(response);
+ assertEquals(HttpStatus.CREATED.value(), response.getStatus());
+ assertNotNull(response.getEntity());
+ }
+
+ @Test
+ @DisplayName("Test updateConfiguration: should return valid response on update a configuration.")
+ void testUpdateConfiguration() {
+ User user = new User();
+ user.setLogin("login");
+ HttpSession session = Mockito.mock(HttpSession.class);
+ HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+
+ Mockito.when(userService.getFromSession(Mockito.any())).thenReturn(user);
+ Mockito.when(request.getSession()).thenReturn(session);
+ Mockito.doNothing().when(userPermissionService).checkPermission(Mockito.any(), Mockito.any(), Mockito.any(),
+ Mockito.any());
+ Mockito.when(this.aiConfigurationService.update(Mockito.any(), Mockito.any()))
+ .thenReturn(new AIConfiguration());
+ Mockito.when(this.aiSecretService.generateConfiguration()).thenReturn("test".getBytes());
+ Mockito.doNothing().when(this.aiService).sendConfiguration(Mockito.any());
+
+ final Response response = this.controller.updateConfiguration(request, UUID.randomUUID(),
+ new AIConfigurationRecord("handler", "key", "value"));
+
+ assertNotNull(response);
+ assertEquals(HttpStatus.OK.value(), response.getStatus());
+ assertNotNull(response.getEntity());
+ }
+
+ @Test
+ @DisplayName("Test updateConfiguration: should update multiples times.")
+ void testUpdateConfigurations() {
+ User user = new User();
+ user.setLogin("login");
+ HttpSession session = Mockito.mock(HttpSession.class);
+ HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+
+ Mockito.when(userService.getFromSession(Mockito.any())).thenReturn(user);
+ Mockito.when(request.getSession()).thenReturn(session);
+ Mockito.doNothing().when(userPermissionService).checkPermission(Mockito.any(), Mockito.any(), Mockito.any(),
+ Mockito.any());
+ Mockito.when(this.aiConfigurationService.update(Mockito.any(), Mockito.any()))
+ .thenReturn(new AIConfiguration());
+ Mockito.when(this.aiSecretService.generateConfiguration()).thenReturn("test".getBytes());
+ Mockito.doNothing().when(this.aiService).sendConfiguration(Mockito.any());
+
+ final Response response = this.controller.updateConfiguration(request,
+ List.of(new UpdateMultipleAIConfigurationRecord(UUID.randomUUID(), "handler", "key", "value")));
+
+ assertNotNull(response);
+ assertEquals(HttpStatus.OK.value(), response.getStatus());
+ assertNotNull(response.getEntity());
+ }
+
+ @Test
+ @DisplayName("Test deleteConfiguration: should return valid response on delete a configuration.")
+ void testDeleteConfiguration() {
+ User user = new User();
+ user.setLogin("login");
+ HttpSession session = Mockito.mock(HttpSession.class);
+ HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+
+ Mockito.when(userService.getFromSession(Mockito.any())).thenReturn(user);
+ Mockito.when(request.getSession()).thenReturn(session);
+ Mockito.doNothing().when(userPermissionService).checkPermission(Mockito.any(), Mockito.any(), Mockito.any(),
+ Mockito.any());
+ Mockito.doNothing().when(aiConfigurationService).delete(Mockito.any());
+ Mockito.when(aiConfigurationService.findById(Mockito.any())).thenReturn(new AIConfiguration());
+ Mockito.when(this.aiSecretService.generateConfiguration()).thenReturn("test".getBytes());
+ Mockito.doNothing().when(this.aiService).sendConfiguration(Mockito.any());
+
+ final Response response = this.controller.deleteConfiguration(request, UUID.randomUUID());
+
+ assertNotNull(response);
+ assertEquals(HttpStatus.NO_CONTENT.value(), response.getStatus());
+ assertNull(response.getEntity());
+ }
+}
diff --git a/src/test/java/com/ditrit/letomodelizerapi/controller/AIControllerTest.java b/src/test/java/com/ditrit/letomodelizerapi/controller/AIControllerTest.java
index b73e408d..e3a421c4 100644
--- a/src/test/java/com/ditrit/letomodelizerapi/controller/AIControllerTest.java
+++ b/src/test/java/com/ditrit/letomodelizerapi/controller/AIControllerTest.java
@@ -8,6 +8,7 @@
import com.ditrit.letomodelizerapi.persistence.model.AIConversation;
import com.ditrit.letomodelizerapi.persistence.model.AIMessage;
import com.ditrit.letomodelizerapi.persistence.model.User;
+import com.ditrit.letomodelizerapi.service.AISecretService;
import com.ditrit.letomodelizerapi.service.AIService;
import com.ditrit.letomodelizerapi.service.UserPermissionService;
import com.ditrit.letomodelizerapi.service.UserService;
@@ -30,8 +31,7 @@
import java.util.List;
import java.util.UUID;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.*;
@Tag("unit")
@ExtendWith(MockitoExtension.class)
@@ -44,6 +44,8 @@ class AIControllerTest extends MockHelper {
UserPermissionService userPermissionService;
@Mock
AIService aiService;
+ @Mock
+ AISecretService aiSecretService;
@InjectMocks
AIController controller;
@@ -244,4 +246,43 @@ void testFindAllMessages() {
assertEquals(HttpStatus.OK.value(), response.getStatus());
assertNotNull(response.getEntity());
}
+
+ @Test
+ @DisplayName("Test sendConfigurationToProxy: should send configuration")
+ void testSendConfigurationToProxy() {
+ HttpSession session = Mockito.mock(HttpSession.class);
+ HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+
+ Mockito.when(request.getSession()).thenReturn(session);
+ Mockito.when(session.getAttribute(Mockito.any())).thenReturn("user");
+ Mockito.when(aiSecretService.generateConfiguration()).thenReturn(new byte[0]);
+ Mockito.doNothing().when(aiService).sendConfiguration(Mockito.any());
+
+ Response response = this.controller.sendConfigurationToProxy(request);
+
+ assertNotNull(response);
+ assertEquals(HttpStatus.NO_CONTENT.value(), response.getStatus());
+ assertNull(response.getEntity());
+ }
+
+ @Test
+ @DisplayName("Test retrieveConfigurationDescriptions: should retrieve descriptions")
+ void testRetrieveConfigurationDescriptions() {
+ User user = new User();
+ user.setLogin("login");
+ HttpSession session = Mockito.mock(HttpSession.class);
+ HttpServletRequest request = Mockito.mock(HttpServletRequest.class);
+
+ Mockito.when(request.getSession()).thenReturn(session);
+ Mockito.when(userService.getFromSession(Mockito.any())).thenReturn(user);
+ Mockito.when(aiService.getConfigurationDescriptions()).thenReturn("test");
+ Mockito.doNothing().when(userPermissionService).checkPermission(Mockito.any(), Mockito.any(), Mockito.any(),
+ Mockito.any());
+
+ Response response = this.controller.retrieveConfigurationDescriptions(request);
+
+ assertNotNull(response);
+ assertEquals(HttpStatus.OK.value(), response.getStatus());
+ assertEquals("test", response.getEntity());
+ }
}
diff --git a/src/test/java/com/ditrit/letomodelizerapi/cucumber/StepDefinitions.java b/src/test/java/com/ditrit/letomodelizerapi/cucumber/StepDefinitions.java
index 47d29156..1dbb994d 100644
--- a/src/test/java/com/ditrit/letomodelizerapi/cucumber/StepDefinitions.java
+++ b/src/test/java/com/ditrit/letomodelizerapi/cucumber/StepDefinitions.java
@@ -441,6 +441,11 @@ public void cleanSecret(String key) throws URISyntaxException, IOException, Inte
this.clean("ai/secrets", String.format("key=%s", key));
}
+ @And("I clean AI configuration {string}")
+ public void cleanConfiguration(String key) throws URISyntaxException, IOException, InterruptedException {
+ this.clean("ai/configurations", String.format("key=%s", key));
+ }
+
public void clean(String entity, String query) throws URISyntaxException, IOException, InterruptedException {
this.request(String.format("/%s?%s", entity, query));
if (statusCode == 200 && json.get("totalElements").asInt() > 0) {
diff --git a/src/test/java/com/ditrit/letomodelizerapi/service/AIConfigurationServiceImplTest.java b/src/test/java/com/ditrit/letomodelizerapi/service/AIConfigurationServiceImplTest.java
new file mode 100644
index 00000000..c41a985f
--- /dev/null
+++ b/src/test/java/com/ditrit/letomodelizerapi/service/AIConfigurationServiceImplTest.java
@@ -0,0 +1,164 @@
+package com.ditrit.letomodelizerapi.service;
+
+import com.ditrit.letomodelizerapi.model.ai.AIConfigurationRecord;
+import com.ditrit.letomodelizerapi.model.error.ApiException;
+import com.ditrit.letomodelizerapi.model.error.ErrorType;
+import com.ditrit.letomodelizerapi.persistence.model.AIConfiguration;
+import com.ditrit.letomodelizerapi.persistence.repository.AIConfigurationRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.domain.Specification;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+@Tag("unit")
+@ExtendWith(MockitoExtension.class)
+@DisplayName("Test class: AIConfigurationImpl")
+class AIConfigurationServiceImplTest {
+
+ @Mock
+ AIConfigurationRepository aiConfigurationRepository;
+
+ @InjectMocks
+ AIConfigurationServiceImpl service;
+
+ @Test
+ @DisplayName("Test findAll: should return wanted configuration")
+ void testFindAll() {
+ AIConfiguration configuration = new AIConfiguration();
+ configuration.setId(UUID.randomUUID());
+ configuration.setKey("key");
+ configuration.setValue("value");
+
+ Pageable pageable = PageRequest.of(0, 10);
+ Page page = new PageImpl<>(List.of(configuration), pageable, 1);
+
+ Mockito.when(aiConfigurationRepository.findAll(Mockito.any(Specification.class), Mockito.any()))
+ .thenReturn(page);
+
+ var result = service.findAll(Map.of(), Pageable.ofSize(10));
+ assertEquals(configuration, result.getContent().getFirst());
+ }
+
+ @Test
+ @DisplayName("Test findById: should return wanted configuration")
+ void testFindById() {
+ AIConfiguration configuration = new AIConfiguration();
+ configuration.setId(UUID.randomUUID());
+ configuration.setKey("key");
+ configuration.setValue("value");
+
+ Mockito.when(aiConfigurationRepository.findById(Mockito.any())).thenReturn(Optional.of(configuration));
+
+ assertEquals(configuration, service.findById(UUID.randomUUID()));
+ }
+
+ @Test
+ @DisplayName("Test findById: should throw an exception on invalid id")
+ void testFindByIdError() {
+ AIConfiguration configuration = new AIConfiguration();
+ configuration.setId(UUID.randomUUID());
+ configuration.setKey("key");
+ configuration.setValue("value");
+
+ Mockito.when(aiConfigurationRepository.findById(Mockito.any())).thenReturn(Optional.empty());
+
+ ApiException exception = null;
+ var uuid = UUID.randomUUID();
+ try {
+ service.findById(uuid);
+ } catch (ApiException e) {
+ exception = e;
+ }
+
+ assertNotNull(exception);
+ assertEquals(ErrorType.ENTITY_NOT_FOUND.getStatus(), exception.getStatus());
+ assertEquals(ErrorType.ENTITY_NOT_FOUND.getMessage(), exception.getMessage());
+ assertEquals("id", exception.getError().getField());
+ assertEquals(uuid.toString(), exception.getError().getValue());
+ }
+
+ @Test
+ @DisplayName("Test create: should create configuration")
+ void testCreate() {
+ AIConfiguration configuration = new AIConfiguration();
+ configuration.setId(UUID.randomUUID());
+ configuration.setKey("key");
+ configuration.setValue("value");
+
+ Mockito.when(aiConfigurationRepository.existsByHandlerAndKey(Mockito.any(), Mockito.any()))
+ .thenReturn(false);
+ Mockito.when(aiConfigurationRepository.save(Mockito.any())).thenReturn(configuration);
+
+ var result = service.create(new AIConfigurationRecord("handler", "key", "value"));
+ assertEquals(configuration, result);
+ }
+
+ @Test
+ @DisplayName("Test create: should throw an exception on already exists entity")
+ void testCreateError() {
+ Mockito.when(aiConfigurationRepository.existsByHandlerAndKey(Mockito.any(), Mockito.any()))
+ .thenReturn(true);
+ ApiException exception = null;
+
+ try {
+ service.create(new AIConfigurationRecord("handler", "key", "value"));
+ } catch (ApiException e) {
+ exception = e;
+ }
+
+ assertNotNull(exception);
+ assertEquals(ErrorType.ENTITY_ALREADY_EXISTS.getStatus(), exception.getStatus());
+ assertEquals(ErrorType.ENTITY_ALREADY_EXISTS.getMessage(), exception.getMessage());
+ assertEquals("key", exception.getError().getField());
+ assertEquals("key", exception.getError().getValue());
+ }
+
+ @Test
+ @DisplayName("Test update: should update configuration")
+ void testUpdate() {
+ AIConfiguration configuration = new AIConfiguration();
+ configuration.setId(UUID.randomUUID());
+ configuration.setKey("key");
+ configuration.setValue("value");
+
+ Mockito.when(aiConfigurationRepository.findById(Mockito.any())).thenReturn(Optional.of(configuration));
+ Mockito.when(aiConfigurationRepository.save(Mockito.any())).thenReturn(configuration);
+
+ var result = service.update(UUID.randomUUID(), new AIConfigurationRecord("handler", "key",
+ "value"));
+ assertEquals(configuration, result);
+ }
+
+ @Test
+ @DisplayName("Test delete: should delete configuration")
+ void testDelete() {
+ AIConfiguration configuration = new AIConfiguration();
+ configuration.setId(UUID.randomUUID());
+ configuration.setKey("key");
+ configuration.setValue("value");
+
+ Mockito.when(aiConfigurationRepository.findById(Mockito.any())).thenReturn(Optional.of(configuration));
+ Mockito.doNothing().when(aiConfigurationRepository).delete(Mockito.any());
+
+ service.delete(UUID.randomUUID());
+
+ Mockito.verify(aiConfigurationRepository, Mockito.times(1)).delete(Mockito.any());
+ }
+}
diff --git a/src/test/java/com/ditrit/letomodelizerapi/service/AISecretImplTest.java b/src/test/java/com/ditrit/letomodelizerapi/service/AISecretServiceImplTest.java
similarity index 78%
rename from src/test/java/com/ditrit/letomodelizerapi/service/AISecretImplTest.java
rename to src/test/java/com/ditrit/letomodelizerapi/service/AISecretServiceImplTest.java
index 7086c30e..44d3880a 100644
--- a/src/test/java/com/ditrit/letomodelizerapi/service/AISecretImplTest.java
+++ b/src/test/java/com/ditrit/letomodelizerapi/service/AISecretServiceImplTest.java
@@ -3,7 +3,9 @@
import com.ditrit.letomodelizerapi.model.ai.AISecretRecord;
import com.ditrit.letomodelizerapi.model.error.ApiException;
import com.ditrit.letomodelizerapi.model.error.ErrorType;
+import com.ditrit.letomodelizerapi.persistence.model.AIConfiguration;
import com.ditrit.letomodelizerapi.persistence.model.AISecret;
+import com.ditrit.letomodelizerapi.persistence.repository.AIConfigurationRepository;
import com.ditrit.letomodelizerapi.persistence.repository.AISecretRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -32,18 +34,22 @@
@Tag("unit")
@ExtendWith(MockitoExtension.class)
@DisplayName("Test class: AISecretImpl")
-class AISecretImplTest {
+class AISecretServiceImplTest {
@Mock
AISecretRepository aiSecretRepository;
+ @Mock
+ AIConfigurationRepository aiConfigurationRepository;
+
@InjectMocks
AISecretServiceImpl service;
@BeforeEach
public void setUp() {
MockitoAnnotations.openMocks(this);
- service = new AISecretServiceImpl(aiSecretRepository, "secret"); // Initialisation avec la clé
+ service = new AISecretServiceImpl(aiSecretRepository, aiConfigurationRepository, "password1",
+ "password2");
}
@Test
@@ -156,4 +162,29 @@ void testDelete() {
Mockito.verify(aiSecretRepository, Mockito.times(1)).delete(Mockito.any());
}
+
+ @Test
+ @DisplayName("Test generateConfiguration: should generate configuration")
+ void testGenerateConfiguration() {
+ var configuration1 = new AIConfiguration();
+ configuration1.setKey("key1");
+ configuration1.setValue("value1");
+ var configuration2 = new AIConfiguration();
+ configuration2.setHandler("test");
+ configuration2.setKey("key2");
+ configuration2.setValue("{{secrets.secret1}}");
+ var configuration3 = new AIConfiguration();
+ configuration3.setKey("key3");
+ configuration3.setValue(null);
+ var secret1 = new AISecret();
+ secret1.setKey("secret1");
+ secret1.setValue(service.encrypt("password1", "value2"));
+
+ Mockito.when(aiConfigurationRepository.findAll()).thenReturn(List.of(configuration1, configuration2, configuration3));
+ Mockito.when(aiSecretRepository.findAll()).thenReturn(List.of(secret1));
+
+ var result = service.generateConfiguration();
+
+ assertEquals("{\"key1\":\"value1\",\"test.key2\":\"value2\",\"key3\":\"\"}", service.decrypt("password2", result));
+ }
}
diff --git a/src/test/java/com/ditrit/letomodelizerapi/service/AIServiceImplTest.java b/src/test/java/com/ditrit/letomodelizerapi/service/AIServiceImplTest.java
index 1263e69c..f95aa945 100644
--- a/src/test/java/com/ditrit/letomodelizerapi/service/AIServiceImplTest.java
+++ b/src/test/java/com/ditrit/letomodelizerapi/service/AIServiceImplTest.java
@@ -11,6 +11,9 @@
import com.ditrit.letomodelizerapi.persistence.repository.AIConversationRepository;
import com.ditrit.letomodelizerapi.persistence.repository.AIMessageRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
@@ -461,4 +464,105 @@ void testFindAllConversations() {
assertEquals(Page.empty(), service.findAllConversations(Map.of(), Pageable.ofSize(10)));
}
+
+ @Test
+ @DisplayName("Test sendConfiguration: should send configuration")
+ void testSendConfiguration() throws IOException, InterruptedException {
+ MockedStatic clientStatic = Mockito.mockStatic(HttpClient.class);
+ mockHttpCall(clientStatic,200, "{\"context\": \"[\\\"newContext\\\"]\", \"message\": \"test\"}");
+
+ AIServiceImpl service = newInstance();
+ ApiException exception = null;
+ try {
+ service.sendConfiguration(new byte[0]);
+ } catch (ApiException e) {
+ exception = e;
+ }
+ assertNull(exception);
+
+ Mockito.reset();
+ clientStatic.close();
+ }
+
+ @Test
+ @DisplayName("Test getConfigurationDescriptions: should return descriptions")
+ void testGetConfigurationDescriptions() throws IOException, InterruptedException {
+ ObjectNode ollama = JsonNodeFactory.instance.objectNode();
+ ollama.put("handler", "ollama");
+ ollama.put("key", "base.url");
+ ollama.put("type", "text");
+ ollama.set("values", JsonNodeFactory.instance.arrayNode());
+ ollama.put("defaultValue", "test");
+ ollama.put("label", "label");
+ ollama.put("title", "title");
+ ollama.put("description", "description");
+ ollama.put("pluginDependent", false);
+ ollama.put("required", true);
+
+ ArrayNode descriptions = JsonNodeFactory.instance.arrayNode();
+ descriptions.add(ollama);
+ ObjectNode json = JsonNodeFactory.instance.objectNode();
+ json.set("ollama", descriptions);
+
+ MockedStatic clientStatic = Mockito.mockStatic(HttpClient.class);
+ mockHttpCall(clientStatic,200, json.toString());
+
+ AIServiceImpl service = newInstance();
+ ApiException exception = null;
+ String result = null;
+
+ try {
+ result = service.getConfigurationDescriptions();
+ } catch (ApiException e) {
+ exception = e;
+ }
+
+ assertNull(exception);
+ assertEquals(json.toString(), result);
+
+ Mockito.reset();
+ clientStatic.close();
+ }
+
+ @Test
+ @DisplayName("Test getConfigurationDescriptions: should throw exception on invalid description")
+ void testGetConfigurationDescriptionsError() throws IOException, InterruptedException {
+ ObjectNode ollama = JsonNodeFactory.instance.objectNode();
+ ollama.put("handler", "ollama");
+ ollama.put("key", "base.url");
+ ollama.put("type", "bad");
+ ollama.set("values", JsonNodeFactory.instance.arrayNode());
+ ollama.put("defaultValue", "test");
+ ollama.put("label", "label");
+ ollama.put("title", "title");
+ ollama.put("description", "description");
+ ollama.put("pluginDependent", false);
+ ollama.put("required", true);
+
+ ArrayNode descriptions = JsonNodeFactory.instance.arrayNode();
+ descriptions.add(ollama);
+ ObjectNode json = JsonNodeFactory.instance.objectNode();
+ json.set("ollama", descriptions);
+
+ MockedStatic clientStatic = Mockito.mockStatic(HttpClient.class);
+ mockHttpCall(clientStatic,200, json.toString());
+
+ AIServiceImpl service = newInstance();
+ ApiException exception = null;
+
+ try {
+ service.getConfigurationDescriptions();
+ } catch (ApiException e) {
+ exception = e;
+ }
+
+ assertNotNull(exception);
+ assertEquals(ErrorType.INTERNAL_ERROR.getStatus(), exception.getStatus());
+ assertEquals(ErrorType.INTERNAL_ERROR.getMessage(), exception.getMessage());
+ assertEquals("#/type", exception.getError().getField());
+ assertEquals("the instance is not equal to any enum values", exception.getError().getValue());
+
+ Mockito.reset();
+ clientStatic.close();
+ }
}
diff --git a/src/test/resources/ai/ai_descriptions.json b/src/test/resources/ai/ai_descriptions.json
new file mode 100644
index 00000000..efe37eb1
--- /dev/null
+++ b/src/test/resources/ai/ai_descriptions.json
@@ -0,0 +1,147 @@
+{
+ "ollama": [{
+ "handler": "ollama",
+ "key": "base.url",
+ "type": "text",
+ "values": [],
+ "defaultValue": "http://localhost:11434/api",
+ "label": "Ollama server url",
+ "title": "Define the url of ollama server",
+ "description": "",
+ "pluginDependent": false,
+ "required": true
+ }, {
+ "handler": "ollama",
+ "key": "default.model",
+ "type": "select",
+ "values": ["mistral", "lama2"],
+ "defaultValue": "mistral",
+ "label": "Default model's name",
+ "title": "Define the name of ollama model by default.",
+ "description": "",
+ "pluginDependent": false,
+ "required": true
+ }, {
+ "handler": "ollama",
+ "key": "{{ plugin }}.model",
+ "type": "text",
+ "values": [],
+ "defaultValue": "mistral",
+ "label": "{{plugin}} model's name",
+ "title": "Define the name of model to use for {{plugin}}.",
+ "description": "",
+ "pluginDependent": true,
+ "required": true
+ }, {
+ "handler": "ollama",
+ "key": "model.files.generate.default",
+ "type": "textarea",
+ "values": [],
+ "defaultValue": "",
+ "label": "Default generation instructions",
+ "title": "Define the default instructions for ollama to generate plugin files.",
+ "description": "Default Model instructions to generate files for plugins.",
+ "pluginDependent": false,
+ "required": true
+ }, {
+ "handler": "ollama",
+ "key": "model.files.generate.{{ plugin }}",
+ "type": "textarea",
+ "values": [],
+ "defaultValue": "",
+ "label": "{{ plugin }} generate model file.",
+ "title": "Define the model instructions for ollama to generate {{ plugin }} file.",
+ "description": "Model file to generate file for {{ plugin }} plugin.",
+ "pluginDependent": true,
+ "required": false
+ }, {
+ "handler": "ollama",
+ "key": "model.files.message.default",
+ "type": "textarea",
+ "values": [],
+ "defaultValue": "",
+ "label": "Default discuss instructions.",
+ "title": "Define the model instructions for ollama to discuss about plugin.",
+ "description": "Default instructions to discuss about plugin.",
+ "pluginDependent": false,
+ "required": true
+ }, {
+ "handler": "ollama",
+ "key": "model.files.message.{{ plugin }}",
+ "type": "textarea",
+ "values": [],
+ "defaultValue": "",
+ "label": "{{ plugin }} discuss instructions.",
+ "title": "Define the model instructions for ollama to discuss about {{ plugin }} plugin.",
+ "description": "Default instructions to discuss about {{ plugin }} plugin.",
+ "pluginDependent": true,
+ "required": false
+ }],
+ "gemini": [{
+ "handler": "gemini",
+ "key": "base.url",
+ "type": "text",
+ "values": [],
+ "defaultValue": "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent",
+ "label": "Gemini server url",
+ "title": "Define the url of gemini server",
+ "description": "",
+ "pluginDependent": false,
+ "required": true
+ }, {
+ "handler": "gemini",
+ "key": "key",
+ "type": "text",
+ "values": [],
+ "defaultValue": "",
+ "label": "Secret API key of gemini",
+ "title": "Define the secret API key of gemini.",
+ "description": "Please use secret to store secret key.",
+ "pluginDependent": false,
+ "required": true
+ }, {
+ "handler": "gemini",
+ "key": "system.instruction.generate.default",
+ "type": "textarea",
+ "values": [],
+ "defaultValue": "",
+ "label": "Default Model files to generate files for plugins.",
+ "title": "Define the model instructions for gemini to generate plugin files.",
+ "description": "",
+ "pluginDependent": false,
+ "required": true
+ }, {
+ "handler": "gemini",
+ "key": "system.instruction.generate.{{ plugin }}",
+ "type": "textarea",
+ "values": [],
+ "defaultValue": "",
+ "label": "Model files to generate files for {{ plugin }} plugin.",
+ "title": "Define the model instructions for gemini to generate {{ plugin }} files.",
+ "description": "",
+ "pluginDependent": true,
+ "required": false
+ }, {
+ "handler": "gemini",
+ "key": "system.instruction.message.default",
+ "type": "textarea",
+ "values": [],
+ "defaultValue": "",
+ "label": "Default Model files to discuss about plugin.",
+ "title": "Define the model instructions for gemini to discuss about plugin.",
+ "description": "",
+ "pluginDependent": false,
+ "required": true
+ }, {
+ "handler": "gemini",
+ "key": "system.instruction.message.{{ plugin }}",
+ "type": "textarea",
+ "values": [],
+ "defaultValue": "",
+ "label": "Default Model files to discuss about {{ plugin }} plugin.",
+ "title": "Define the model instructions for gemini to discuss about {{ plugin }} plugin.",
+ "description": "",
+ "pluginDependent": true,
+ "required": false
+ }]
+}
\ No newline at end of file
diff --git a/src/test/resources/ai/index.php b/src/test/resources/ai/index.php
index 47fdd2fd..90c30700 100644
--- a/src/test/resources/ai/index.php
+++ b/src/test/resources/ai/index.php
@@ -1,9 +1,7 @@
1 + $contextValue,
@@ -21,7 +19,7 @@
$json = json_encode($data);
echo $json;
exit;
-} else if ($type && isset($requestBody['pluginName'])) {
+} else if ($_SERVER['REQUEST_METHOD'] == 'POST' && $type && isset($requestBody['pluginName'])) {
$fileName = $type . "_" . str_replace("@ditrit/", "", $requestBody['pluginName']) . ".json";
error_log($fileName);
@@ -31,6 +29,15 @@
readfile($fileName);
exit;
}
+} else if ($_SERVER['REQUEST_METHOD'] == 'POST' && $URI == "/api/configurations") {
+ http_response_code(204);
+ exit;
+} else if ($_SERVER['REQUEST_METHOD'] == 'GET' && $URI == "/api/configurations/descriptions") {
+ http_response_code(200);
+ readfile("ai_descriptions.json");
+ exit;
}
-http_response_code(400);
+error_log($URI);
+
+http_response_code(404);
diff --git a/src/test/resources/features/AIConfiguration.feature b/src/test/resources/features/AIConfiguration.feature
new file mode 100644
index 00000000..a9ca387b
--- /dev/null
+++ b/src/test/resources/features/AIConfiguration.feature
@@ -0,0 +1,208 @@
+Feature: AI configuration feature
+
+ Scenario: Should return 400 on invalid key
+ Given I initialize the admin user
+ And I clean AI configuration "config"
+
+ When I request "/ai/configurations" with method "POST" with json
+ | key | value |
+ | key | |
+ | value | value |
+ Then I expect "400" as status code
+ And I expect response fields length is "5"
+ And I expect response field "message" is "Field value is empty."
+ And I expect response field "code" is "201"
+ And I expect response field "field" is "key"
+ And I expect response field "value" is "NULL"
+ And I expect response field "cause" is "NULL"
+
+ Scenario: Should return 400 on invalid value
+ Given I initialize the admin user
+ And I clean AI configuration "config"
+
+ When I request "/ai/configurations" with method "POST" with json
+ | key | value |
+ | key | config |
+ | value | |
+ Then I expect "400" as status code
+ And I expect response fields length is "5"
+ And I expect response field "message" is "Field value is empty."
+ And I expect response field "code" is "201"
+ And I expect response field "field" is "value"
+ And I expect response field "value" is "NULL"
+ And I expect response field "cause" is "NULL"
+
+ Scenario: Should return 400 on duplicated key
+ Given I initialize the admin user
+ And I clean AI configuration "config"
+
+ When I request "/ai/configurations" with method "POST" with json
+ | key | value |
+ | key | config |
+ | value | value |
+ Then I expect "201" as status code
+
+ When I request "/ai/configurations" with method "POST" with json
+ | key | value |
+ | key | config |
+ | value | value |
+ Then I expect "400" as status code
+ And I expect response fields length is "5"
+ And I expect response field "message" is "Entity already exists."
+ And I expect response field "code" is "208"
+ And I expect response field "field" is "key"
+ And I expect response field "value" is "config"
+ And I expect response field "cause" is "NULL"
+
+ Scenario: Should return 400 on duplicated key with same handler
+ Given I initialize the admin user
+ And I clean AI configuration "config"
+
+ When I request "/ai/configurations" with method "POST" with json
+ | key | value |
+ | handler | test |
+ | key | config |
+ | value | value |
+ Then I expect "201" as status code
+
+ When I request "/ai/configurations" with method "POST" with json
+ | key | value |
+ | handler | test |
+ | key | config |
+ | value | value |
+ Then I expect "400" as status code
+ And I expect response fields length is "5"
+ And I expect response field "message" is "Entity already exists."
+ And I expect response field "code" is "208"
+ And I expect response field "field" is "key"
+ And I expect response field "value" is "config"
+ And I expect response field "cause" is "NULL"
+
+ Scenario: Should return 200 on valid creation
+ Given I initialize the admin user
+ And I clean AI configuration "config"
+
+ When I request "/ai/configurations" with method "POST" with json
+ | key | value |
+ | handler | test |
+ | key | config |
+ | value | value |
+ Then I expect "201" as status code
+ And I set response field "id" to context "configuration_id"
+
+ When I request "/ai/configurations/[configuration_id]" with method "GET"
+ Then I expect "200" as status code
+ And I expect response field "id" is "[configuration_id]"
+ And I expect response field "handler" is "test"
+ And I expect response field "key" is "config"
+ And I expect response field "value" is "value"
+ And I expect response field "updateDate" is "NOT_NULL"
+
+ Scenario: Should return 200 with same key but different config
+ Given I initialize the admin user
+ And I clean AI configuration "config"
+
+ When I request "/ai/configurations" with method "POST" with json
+ | key | value |
+ | handler | test |
+ | key | config |
+ | value | value |
+ Then I expect "201" as status code
+ And I set response field "id" to context "configuration1_id"
+
+ When I request "/ai/configurations" with method "POST" with json
+ | key | value |
+ | handler | |
+ | key | config |
+ | value | value |
+ Then I expect "201" as status code
+ And I set response field "id" to context "configuration2_id"
+
+ When I request "/ai/configurations" with method "POST" with json
+ | key | value |
+ | handler | other |
+ | key | config |
+ | value | value |
+ Then I expect "201" as status code
+ And I set response field "id" to context "configuration3_id"
+
+ When I request "/ai/configurations/[configuration1_id]" with method "DELETE"
+ And I expect "204" as status code
+
+ When I request "/ai/configurations/[configuration2_id]" with method "DELETE"
+ And I expect "204" as status code
+
+ When I request "/ai/configurations/[configuration3_id]" with method "DELETE"
+ And I expect "204" as status code
+
+ Scenario: should return 200 on configuration update
+ Given I initialize the admin user
+ And I clean AI configuration "config"
+
+ When I request "/ai/configurations" with method "POST" with json
+ | key | value |
+ | handler | test |
+ | key | config |
+ | value | value |
+ Then I expect "201" as status code
+ And I set response field "id" to context "configuration_id"
+
+ When I request "/ai/configurations/[configuration_id]" with method "GET"
+ Then I expect "200" as status code
+ And I expect response field "id" is "[configuration_id]"
+ And I expect response field "handler" is "test"
+ And I expect response field "key" is "config"
+ And I expect response field "value" is "value"
+ And I expect response field "updateDate" is "NOT_NULL"
+
+ When I request "/ai/configurations/[configuration_id]" with method "PUT" with json
+ | key | value |
+ | handler | test |
+ | key | config |
+ | value | value2 |
+ Then I expect "200" as status code
+
+ When I request "/ai/configurations/[configuration_id]" with method "GET"
+ Then I expect "200" as status code
+ And I expect response field "id" is "[configuration_id]"
+ And I expect response field "handler" is "test"
+ And I expect response field "key" is "config"
+ And I expect response field "value" is "value2"
+ And I expect response field "updateDate" is "NOT_NULL"
+
+ Scenario: should return 200 on multiple configurations update
+ Given I initialize the admin user
+ And I clean AI configuration "config1"
+ And I clean AI configuration "config2"
+
+ When I request "/ai/configurations" with method "POST" with json
+ | key | value |
+ | handler | test |
+ | key | config1 |
+ | value | value |
+ Then I expect "201" as status code
+ And I set response field "id" to context "configuration1_id"
+
+ When I request "/ai/configurations/[configuration1_id]" with method "GET"
+ Then I expect "200" as status code
+ And I expect response field "id" is "[configuration1_id]"
+ And I expect response field "handler" is "test"
+ And I expect response field "key" is "config1"
+ And I expect response field "value" is "value"
+ And I expect response field "updateDate" is "NOT_NULL"
+
+ When I request "/ai/configurations" with method "POST" with json
+ | key | value |
+ | handler | test |
+ | key | config2 |
+ | value | value |
+ Then I expect "201" as status code
+ And I set response field "id" to context "configuration2_id"
+
+ When I request "/ai/configurations/[configuration2_id]" with method "GET"
+ Then I expect "200" as status code
+ And I expect response field "id" is "[configuration2_id]"
+ And I expect response field "handler" is "test"
+ And I expect response field "key" is "config2"
+ And I expect response field "value" is "value"
+ And I expect response field "updateDate" is "NOT_NULL"
\ No newline at end of file