AI rule node: add validation using annotations

This commit is contained in:
Dmytro Skarzhynets 2025-07-03 14:57:32 +03:00
parent 1e82a332e4
commit f2278f86c9
No known key found for this signature in database
GPG Key ID: 2B51652F224037DF
22 changed files with 247 additions and 93 deletions

View File

@ -17,7 +17,9 @@ package org.thingsboard.server.controller;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@ -45,6 +47,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D
import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH;
@Validated
@RestController
@RequestMapping("/api/ai/model/settings")
class AiModelSettingsController extends BaseController {
@ -59,7 +62,7 @@ class AiModelSettingsController extends BaseController {
)
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@PostMapping
public AiModelSettings saveAiModelSettings(@RequestBody AiModelSettings settings) throws ThingsboardException {
public AiModelSettings saveAiModelSettings(@RequestBody @Valid AiModelSettings settings) throws ThingsboardException {
var user = getCurrentUser();
settings.setTenantId(user.getTenantId());
checkEntity(settings.getId(), settings, Resource.AI_MODEL_SETTINGS);

View File

@ -16,6 +16,9 @@
package org.thingsboard.server.common.data.ai;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@ -27,6 +30,8 @@ import org.thingsboard.server.common.data.HasVersion;
import org.thingsboard.server.common.data.ai.model.AiModel;
import org.thingsboard.server.common.data.id.AiModelSettingsId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.validation.Length;
import org.thingsboard.server.common.data.validation.NoNullChar;
import java.io.Serial;
@ -56,6 +61,9 @@ public final class AiModelSettings extends BaseData<AiModelSettingsId> implement
)
private Long version;
@NotBlank
@NoNullChar
@Length(min = 1, max = 255)
@Schema(
requiredMode = Schema.RequiredMode.REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,
@ -64,6 +72,8 @@ public final class AiModelSettings extends BaseData<AiModelSettingsId> implement
)
private String name;
@NotNull
@Valid
@Schema(
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
accessMode = Schema.AccessMode.READ_WRITE,

View File

@ -16,6 +16,12 @@
package org.thingsboard.server.common.data.ai.model.chat;
import dev.langchain4j.model.chat.ChatModel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.With;
import org.thingsboard.server.common.data.ai.model.AiModelType;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
@ -25,8 +31,8 @@ import java.util.List;
public record AmazonBedrockChatModel(
AiModelType modelType,
AmazonBedrockProviderConfig providerConfig,
@With Config modelConfig
@NotNull @Valid AmazonBedrockProviderConfig providerConfig,
@With @NotNull @Valid Config modelConfig
) implements AiChatModel<AmazonBedrockChatModel.Config> {
@Override
@ -36,13 +42,13 @@ public record AmazonBedrockChatModel(
@With
public record Config(
String modelId,
Double temperature,
Double topP,
Integer maxOutputTokens,
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
@Positive Integer maxOutputTokens,
List<String> stopSequences,
Integer timeoutSeconds,
Integer maxRetries
@Positive Integer timeoutSeconds,
@PositiveOrZero Integer maxRetries
) implements AiChatModelConfig<AmazonBedrockChatModel.Config> {}
@Override

View File

@ -16,6 +16,12 @@
package org.thingsboard.server.common.data.ai.model.chat;
import dev.langchain4j.model.chat.ChatModel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.With;
import org.thingsboard.server.common.data.ai.model.AiModelType;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
@ -25,8 +31,8 @@ import java.util.List;
public record AnthropicChatModel(
AiModelType modelType,
AnthropicProviderConfig providerConfig,
@With Config modelConfig
@NotNull @Valid AnthropicProviderConfig providerConfig,
@With @NotNull @Valid Config modelConfig
) implements AiChatModel<AnthropicChatModel.Config> {
@Override
@ -36,14 +42,14 @@ public record AnthropicChatModel(
@With
public record Config(
String modelId,
Double temperature,
Double topP,
Integer topK,
Integer maxOutputTokens,
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
@Positive Integer topK,
@Positive Integer maxOutputTokens,
List<String> stopSequences,
Integer timeoutSeconds,
Integer maxRetries
@Positive Integer timeoutSeconds,
@PositiveOrZero Integer maxRetries
) implements AiChatModelConfig<AnthropicChatModel.Config> {}
@Override

View File

@ -16,6 +16,12 @@
package org.thingsboard.server.common.data.ai.model.chat;
import dev.langchain4j.model.chat.ChatModel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.With;
import org.thingsboard.server.common.data.ai.model.AiModelType;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
@ -25,8 +31,8 @@ import java.util.List;
public record AzureOpenAiChatModel(
AiModelType modelType,
AzureOpenAiProviderConfig providerConfig,
@With Config modelConfig
@NotNull @Valid AzureOpenAiProviderConfig providerConfig,
@With @NotNull @Valid Config modelConfig
) implements AiChatModel<AzureOpenAiChatModel.Config> {
@Override
@ -36,15 +42,15 @@ public record AzureOpenAiChatModel(
@With
public record Config(
String modelId,
Double temperature,
Double topP,
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
Double frequencyPenalty,
Double presencePenalty,
Integer maxOutputTokens,
@Positive Integer maxOutputTokens,
List<String> stopSequences,
Integer timeoutSeconds,
Integer maxRetries
@Positive Integer timeoutSeconds,
@PositiveOrZero Integer maxRetries
) implements AiChatModelConfig<AzureOpenAiChatModel.Config> {}
@Override

View File

@ -16,6 +16,12 @@
package org.thingsboard.server.common.data.ai.model.chat;
import dev.langchain4j.model.chat.ChatModel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.With;
import org.thingsboard.server.common.data.ai.model.AiModelType;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
@ -25,8 +31,8 @@ import java.util.List;
public record GitHubModelsChatModel(
AiModelType modelType,
GitHubModelsProviderConfig providerConfig,
@With Config modelConfig
@NotNull @Valid GitHubModelsProviderConfig providerConfig,
@With @NotNull @Valid Config modelConfig
) implements AiChatModel<GitHubModelsChatModel.Config> {
@Override
@ -36,15 +42,15 @@ public record GitHubModelsChatModel(
@With
public record Config(
String modelId,
Double temperature,
Double topP,
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
Double frequencyPenalty,
Double presencePenalty,
Integer maxOutputTokens,
@Positive Integer maxOutputTokens,
List<String> stopSequences,
Integer timeoutSeconds,
Integer maxRetries
@Positive Integer timeoutSeconds,
@PositiveOrZero Integer maxRetries
) implements AiChatModelConfig<GitHubModelsChatModel.Config> {}
@Override

View File

@ -16,6 +16,12 @@
package org.thingsboard.server.common.data.ai.model.chat;
import dev.langchain4j.model.chat.ChatModel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.With;
import org.thingsboard.server.common.data.ai.model.AiModelType;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
@ -25,8 +31,8 @@ import java.util.List;
public record GoogleAiGeminiChatModel(
AiModelType modelType,
GoogleAiGeminiProviderConfig providerConfig,
@With Config modelConfig
@NotNull @Valid GoogleAiGeminiProviderConfig providerConfig,
@With @NotNull @Valid Config modelConfig
) implements AiChatModel<GoogleAiGeminiChatModel.Config> {
@Override
@ -36,16 +42,16 @@ public record GoogleAiGeminiChatModel(
@With
public record Config(
String modelId,
Double temperature,
Double topP,
Integer topK,
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
@Positive Integer topK,
Double frequencyPenalty,
Double presencePenalty,
Integer maxOutputTokens,
@Positive Integer maxOutputTokens,
List<String> stopSequences,
Integer timeoutSeconds,
Integer maxRetries
@Positive Integer timeoutSeconds,
@PositiveOrZero Integer maxRetries
) implements AiChatModelConfig<GoogleAiGeminiChatModel.Config> {}
@Override

View File

@ -16,6 +16,12 @@
package org.thingsboard.server.common.data.ai.model.chat;
import dev.langchain4j.model.chat.ChatModel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.With;
import org.thingsboard.server.common.data.ai.model.AiModelType;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
@ -25,8 +31,8 @@ import java.util.List;
public record GoogleVertexAiGeminiChatModel(
AiModelType modelType,
GoogleVertexAiGeminiProviderConfig providerConfig,
@With Config modelConfig
@NotNull @Valid GoogleVertexAiGeminiProviderConfig providerConfig,
@With @NotNull @Valid Config modelConfig
) implements AiChatModel<GoogleVertexAiGeminiChatModel.Config> {
@Override
@ -36,16 +42,16 @@ public record GoogleVertexAiGeminiChatModel(
@With
public record Config(
String modelId,
Double temperature,
Double topP,
Integer topK,
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
@Positive Integer topK,
Double frequencyPenalty,
Double presencePenalty,
Integer maxOutputTokens,
@Positive Integer maxOutputTokens,
List<String> stopSequences,
Integer timeoutSeconds,
Integer maxRetries
@Positive Integer timeoutSeconds,
@PositiveOrZero Integer maxRetries
) implements AiChatModelConfig<GoogleVertexAiGeminiChatModel.Config> {}
@Override

View File

@ -16,6 +16,12 @@
package org.thingsboard.server.common.data.ai.model.chat;
import dev.langchain4j.model.chat.ChatModel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.With;
import org.thingsboard.server.common.data.ai.model.AiModelType;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
@ -25,8 +31,8 @@ import java.util.List;
public record MistralAiChatModel(
AiModelType modelType,
MistralAiProviderConfig providerConfig,
@With Config modelConfig
@NotNull @Valid MistralAiProviderConfig providerConfig,
@With @NotNull @Valid Config modelConfig
) implements AiChatModel<MistralAiChatModel.Config> {
@Override
@ -36,15 +42,15 @@ public record MistralAiChatModel(
@With
public record Config(
String modelId,
Double temperature,
Double topP,
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
Double frequencyPenalty,
Double presencePenalty,
Integer maxOutputTokens,
@Positive Integer maxOutputTokens,
List<String> stopSequences,
Integer timeoutSeconds,
Integer maxRetries
@Positive Integer timeoutSeconds,
@PositiveOrZero Integer maxRetries
) implements AiChatModelConfig<MistralAiChatModel.Config> {}
@Override

View File

@ -16,6 +16,12 @@
package org.thingsboard.server.common.data.ai.model.chat;
import dev.langchain4j.model.chat.ChatModel;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.With;
import org.thingsboard.server.common.data.ai.model.AiModelType;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
@ -25,8 +31,8 @@ import java.util.List;
public record OpenAiChatModel(
AiModelType modelType,
OpenAiProviderConfig providerConfig,
@With Config modelConfig
@NotNull @Valid OpenAiProviderConfig providerConfig,
@With @NotNull @Valid Config modelConfig
) implements AiChatModel<OpenAiChatModel.Config> {
@Override
@ -36,15 +42,15 @@ public record OpenAiChatModel(
@With
public record Config(
String modelId,
Double temperature,
Double topP,
@NotBlank String modelId,
@PositiveOrZero Double temperature,
@Positive @Max(1) Double topP,
Double frequencyPenalty,
Double presencePenalty,
Integer maxOutputTokens,
@Positive Integer maxOutputTokens,
List<String> stopSequences,
Integer timeoutSeconds,
Integer maxRetries
@Positive Integer timeoutSeconds,
@PositiveOrZero Integer maxRetries
) implements AiChatModelConfig<OpenAiChatModel.Config> {}
@Override

View File

@ -15,4 +15,10 @@
*/
package org.thingsboard.server.common.data.ai.provider;
public record AmazonBedrockProviderConfig(String region, String accessKeyId, String secretAccessKey) implements AiProviderConfig {}
import jakarta.validation.constraints.NotBlank;
public record AmazonBedrockProviderConfig(
@NotBlank String region,
@NotBlank String accessKeyId,
@NotBlank String secretAccessKey
) implements AiProviderConfig {}

View File

@ -15,4 +15,8 @@
*/
package org.thingsboard.server.common.data.ai.provider;
public record AnthropicProviderConfig(String apiKey) implements AiProviderConfig {}
import jakarta.validation.constraints.NotBlank;
public record AnthropicProviderConfig(
@NotBlank String apiKey
) implements AiProviderConfig {}

View File

@ -15,4 +15,10 @@
*/
package org.thingsboard.server.common.data.ai.provider;
public record AzureOpenAiProviderConfig(String endpoint, String serviceVersion, String apiKey) implements AiProviderConfig {}
import jakarta.validation.constraints.NotBlank;
public record AzureOpenAiProviderConfig(
@NotBlank String endpoint,
String serviceVersion,
@NotBlank String apiKey
) implements AiProviderConfig {}

View File

@ -15,4 +15,8 @@
*/
package org.thingsboard.server.common.data.ai.provider;
public record GitHubModelsProviderConfig(String personalAccessToken) implements AiProviderConfig {}
import jakarta.validation.constraints.NotBlank;
public record GitHubModelsProviderConfig(
@NotBlank String personalAccessToken
) implements AiProviderConfig {}

View File

@ -15,4 +15,8 @@
*/
package org.thingsboard.server.common.data.ai.provider;
public record GoogleAiGeminiProviderConfig(String apiKey) implements AiProviderConfig {}
import jakarta.validation.constraints.NotBlank;
public record GoogleAiGeminiProviderConfig(
@NotBlank String apiKey
) implements AiProviderConfig {}

View File

@ -15,9 +15,11 @@
*/
package org.thingsboard.server.common.data.ai.provider;
import jakarta.validation.constraints.NotBlank;
public record GoogleVertexAiGeminiProviderConfig(
String fileName, // not used on BE, but needed for UI
String projectId,
String location,
String serviceAccountKey
@NotBlank String fileName, // not used on BE, but needed for UI
@NotBlank String projectId,
@NotBlank String location,
@NotBlank String serviceAccountKey
) implements AiProviderConfig {}

View File

@ -15,4 +15,8 @@
*/
package org.thingsboard.server.common.data.ai.provider;
public record MistralAiProviderConfig(String apiKey) implements AiProviderConfig {}
import jakarta.validation.constraints.NotBlank;
public record MistralAiProviderConfig(
@NotBlank String apiKey
) implements AiProviderConfig {}

View File

@ -15,4 +15,8 @@
*/
package org.thingsboard.server.common.data.ai.provider;
public record OpenAiProviderConfig(String apiKey) implements AiProviderConfig {}
import jakarta.validation.constraints.NotBlank;
public record OpenAiProviderConfig(
@NotBlank String apiKey
) implements AiProviderConfig {}

View File

@ -0,0 +1,39 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Documented
@Constraint(validatedBy = {})
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface NoNullChar {
String message() default "should not contain 0x00 symbol";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}

View File

@ -21,7 +21,6 @@ import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.metadata.ConstraintDescriptor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.hibernate.validator.HibernateValidator;
import org.hibernate.validator.HibernateValidatorConfiguration;
@ -32,6 +31,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.thingsboard.server.common.data.validation.Length;
import org.thingsboard.server.common.data.validation.NoNullChar;
import org.thingsboard.server.common.data.validation.NoXss;
import org.thingsboard.server.common.data.validation.RateLimit;
import org.thingsboard.server.dao.exception.DataValidationException;
@ -40,7 +40,6 @@ import java.util.Collection;
import java.util.Set;
import java.util.stream.Collectors;
@Slf4j
@Configuration
public class ConstraintValidator {
@ -88,7 +87,9 @@ public class ConstraintValidator {
ConstraintMapping constraintMapping = getCustomConstraintMapping();
validatorConfiguration.addMapping(constraintMapping);
fieldsValidator = validatorConfiguration.buildValidatorFactory().getValidator();
try (var validatorFactory = validatorConfiguration.buildValidatorFactory()) {
fieldsValidator = validatorFactory.getValidator();
}
}
@Bean
@ -105,6 +106,7 @@ public class ConstraintValidator {
constraintMapping.constraintDefinition(NoXss.class).validatedBy(NoXssValidator.class);
constraintMapping.constraintDefinition(Length.class).validatedBy(StringLengthValidator.class);
constraintMapping.constraintDefinition(RateLimit.class).validatedBy(RateLimitValidator.class);
constraintMapping.constraintDefinition(NoNullChar.class).validatedBy(NoNullCharValidator.class);
return constraintMapping;
}

View File

@ -0,0 +1,29 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.dao.service;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.thingsboard.server.common.data.validation.NoNullChar;
public final class NoNullCharValidator implements ConstraintValidator<NoNullChar, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return value == null || !value.contains("\u0000");
}
}

View File

@ -64,17 +64,6 @@ class AiModelSettingsDataValidator extends DataValidator<AiModelSettings> {
if (!tenantService.tenantExists(tenantId)) {
throw new DataValidationException("AI model settings reference a non-existent tenant!");
}
// name validation
validateString("AI model settings name", settings.getName());
if (settings.getName().length() > 255) {
throw new DataValidationException("AI model settings name should be between 1 and 255 symbols!");
}
// model config validation
if (settings.getConfiguration() == null) {
throw new DataValidationException("AI model settings configuration should be specified!");
}
}
}