From a57291f28c2d111bc6c4628edda76859b6342661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Moitti=C3=A9?= Date: Wed, 30 Oct 2024 10:38:36 +0100 Subject: [PATCH 01/13] Update sql with new permissions and configurations table --- ...able_ai_configurations_and_permissions.sql | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/main/resources/db/migration/V1_12_0__new_table_ai_configurations_and_permissions.sql 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.'; From 536c8f9522409a2e3ac0e47ac5f0e4c10790a54e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Moitti=C3=A9?= Date: Wed, 30 Oct 2024 10:39:30 +0100 Subject: [PATCH 02/13] Add new permission entity in enum --- README.md | 4 ++++ .../letomodelizerapi/model/permission/EntityPermission.java | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 46740835..0874ee98 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 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; } From b38fd70c6f97ec47f0817caab5c5bc755ea7d939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Moitti=C3=A9?= Date: Wed, 30 Oct 2024 10:40:23 +0100 Subject: [PATCH 03/13] Add entity and repository --- .../persistence/model/AIConfiguration.java | 64 +++++++++++++++++++ .../repository/AIConfigurationRepository.java | 52 +++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/main/java/com/ditrit/letomodelizerapi/persistence/model/AIConfiguration.java create mode 100644 src/main/java/com/ditrit/letomodelizerapi/persistence/repository/AIConfigurationRepository.java 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); +} From 42dce400ea9a72bd965f4e1d02cca71895ac4e39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Moitti=C3=A9?= Date: Wed, 30 Oct 2024 10:41:12 +0100 Subject: [PATCH 04/13] Add models for AI configurations --- .../model/ai/AIConfigurationDTO.java | 40 +++++++++++++++++++ .../model/ai/AIConfigurationRecord.java | 16 ++++++++ .../UpdateMultipleAIConfigurationRecord.java | 21 ++++++++++ 3 files changed, 77 insertions(+) create mode 100644 src/main/java/com/ditrit/letomodelizerapi/model/ai/AIConfigurationDTO.java create mode 100644 src/main/java/com/ditrit/letomodelizerapi/model/ai/AIConfigurationRecord.java create mode 100644 src/main/java/com/ditrit/letomodelizerapi/model/ai/UpdateMultipleAIConfigurationRecord.java 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 +) { +} From f268317f23fc7a472ac9291fc853210c616993be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Moitti=C3=A9?= Date: Wed, 30 Oct 2024 10:42:11 +0100 Subject: [PATCH 05/13] Add new configuration key --- README.md | 4 +- docker-compose-e2e.yml | 1 + docker-compose.yml | 1 + .../ai-configuration-description-schema.json | 59 +++++++++++++++++++ src/main/resources/application.properties | 1 + 5 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/ai-configuration-description-schema.json diff --git a/README.md b/README.md index 0874ee98..29880874 100644 --- a/README.md +++ b/README.md @@ -256,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. @@ -286,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/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/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} From 99b5478d5cd8ad7133cc5bc847925693101215bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Moitti=C3=A9?= Date: Wed, 30 Oct 2024 10:50:40 +0100 Subject: [PATCH 06/13] Install jinja dependency --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index 22266a97..0fad4298 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,7 @@ dependencies { 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' From 8f2c3af63a72da106a47cb364efaac5131587120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Moitti=C3=A9?= Date: Wed, 30 Oct 2024 10:43:04 +0100 Subject: [PATCH 07/13] Add service to manage AI configurations --- .../service/AIConfigurationService.java | 57 ++++++ .../service/AIConfigurationServiceImpl.java | 90 ++++++++++ .../AIConfigurationServiceImplTest.java | 164 ++++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 src/main/java/com/ditrit/letomodelizerapi/service/AIConfigurationService.java create mode 100644 src/main/java/com/ditrit/letomodelizerapi/service/AIConfigurationServiceImpl.java create mode 100644 src/test/java/com/ditrit/letomodelizerapi/service/AIConfigurationServiceImplTest.java 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/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()); + } +} From b93ff5dd2770b004abcda4ab034115bb21cb5e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Moitti=C3=A9?= Date: Wed, 30 Oct 2024 10:43:36 +0100 Subject: [PATCH 08/13] Update services to encrypt and send configurations --- .../service/AISecretService.java | 7 ++ .../service/AISecretServiceImpl.java | 90 +++++++++++++-- .../letomodelizerapi/service/AIService.java | 21 ++++ .../service/AIServiceImpl.java | 109 ++++++++++++++++-- ...Test.java => AISecretServiceImplTest.java} | 35 +++++- .../service/AIServiceImplTest.java | 104 +++++++++++++++++ 6 files changed, 350 insertions(+), 16 deletions(-) rename src/test/java/com/ditrit/letomodelizerapi/service/{AISecretImplTest.java => AISecretServiceImplTest.java} (78%) 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/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(); + } } From 51b4d686e6fce7e6b3b530cb3c8763030d49364d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Moitti=C3=A9?= Date: Wed, 30 Oct 2024 10:44:36 +0100 Subject: [PATCH 09/13] Add new controller to manage configuration --- .../letomodelizerapi/config/JerseyConfig.java | 4 +- .../controller/AIConfigurationController.java | 288 ++++++++++++++++++ .../AIConfigurationControllerTest.java | 197 ++++++++++++ 3 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/ditrit/letomodelizerapi/controller/AIConfigurationController.java create mode 100644 src/test/java/com/ditrit/letomodelizerapi/controller/AIConfigurationControllerTest.java 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/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()); + } +} From 56ddcc5120509c298f4b3b2cea028f02acb494ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Moitti=C3=A9?= Date: Wed, 30 Oct 2024 10:45:19 +0100 Subject: [PATCH 10/13] Add endpoint to send configuration --- .../controller/AIController.java | 57 +++++++++++++++++++ .../controller/AIControllerTest.java | 45 ++++++++++++++- 2 files changed, 100 insertions(+), 2 deletions(-) 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/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()); + } } From a44094611e8f44a935d45896f6c1d050c4945681 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Moitti=C3=A9?= Date: Wed, 30 Oct 2024 10:47:42 +0100 Subject: [PATCH 11/13] Update e2e --- .../cucumber/StepDefinitions.java | 5 + src/test/resources/ai/ai_descriptions.json | 147 +++++++++++++ src/test/resources/ai/index.php | 21 +- .../features/AIConfiguration.feature | 208 ++++++++++++++++++ 4 files changed, 374 insertions(+), 7 deletions(-) create mode 100644 src/test/resources/ai/ai_descriptions.json create mode 100644 src/test/resources/features/AIConfiguration.feature 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/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 From b4c9067b8fabf1ab00831af747b0e36b72021e32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Moitti=C3=A9?= Date: Wed, 30 Oct 2024 15:30:06 +0100 Subject: [PATCH 12/13] Update dependencies --- build.gradle | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.gradle b/build.gradle index 0fad4298..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,9 +44,9 @@ 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' @@ -57,7 +57,7 @@ dependencies { 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') { From 270ced526187fba2d3bd34a1a9f35183bed95327 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Moitti=C3=A9?= Date: Wed, 30 Oct 2024 10:50:52 +0100 Subject: [PATCH 13/13] Update changelog --- changelog.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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.