committed by
GitHub
36 changed files with 923 additions and 87 deletions
@ -0,0 +1,30 @@ |
|||
/** |
|||
* 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.limit; |
|||
|
|||
public record RateLimitEntry(long capacity, long durationSeconds) { |
|||
|
|||
public static RateLimitEntry parse(String s) { |
|||
String[] parts = s.split(":"); |
|||
return new RateLimitEntry(Long.parseLong(parts[0]), Long.parseLong(parts[1])); |
|||
} |
|||
|
|||
@Override |
|||
public String toString() { |
|||
return capacity + ":" + durationSeconds; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,92 @@ |
|||
/** |
|||
* 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.limit; |
|||
|
|||
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.Arrays; |
|||
import java.util.Collections; |
|||
import java.util.HashMap; |
|||
import java.util.HashSet; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.function.Function; |
|||
import java.util.stream.Collectors; |
|||
|
|||
public class RateLimitUtil { |
|||
|
|||
public static List<RateLimitEntry> parseConfig(String config) { |
|||
if (config == null || config.isEmpty()) { |
|||
return Collections.emptyList(); |
|||
} |
|||
return Arrays.stream(config.split(",")) |
|||
.map(RateLimitEntry::parse) |
|||
.toList(); |
|||
} |
|||
|
|||
public static Function<DefaultTenantProfileConfiguration, String> merge( |
|||
Function<DefaultTenantProfileConfiguration, String> configExtractor1, |
|||
Function<DefaultTenantProfileConfiguration, String> configExtractor2) { |
|||
return config -> { |
|||
String config1 = configExtractor1.apply(config); |
|||
String config2 = configExtractor2.apply(config); |
|||
return RateLimitUtil.mergeStrConfigs(config1, config2); // merges the configs
|
|||
}; |
|||
} |
|||
|
|||
private static String mergeStrConfigs(String firstConfig, String secondConfig) { |
|||
List<RateLimitEntry> all = new ArrayList<>(); |
|||
all.addAll(parseConfig(firstConfig)); |
|||
all.addAll(parseConfig(secondConfig)); |
|||
|
|||
Map<Long, Long> merged = new HashMap<>(); |
|||
|
|||
for (RateLimitEntry entry : all) { |
|||
merged.merge(entry.durationSeconds(), entry.capacity(), Long::sum); |
|||
} |
|||
|
|||
return merged.entrySet().stream() |
|||
.sorted(Map.Entry.comparingByKey()) // optional: sort by duration
|
|||
.map(e -> e.getValue() + ":" + e.getKey()) |
|||
.collect(Collectors.joining(",")); |
|||
} |
|||
|
|||
public static boolean isValid(String configStr) { |
|||
List<RateLimitEntry> limitedApiEntries = parseConfig(configStr); |
|||
Set<Long> distinctDurations = new HashSet<>(); |
|||
for (RateLimitEntry entry : limitedApiEntries) { |
|||
if (!distinctDurations.add(entry.durationSeconds())) { |
|||
return false; |
|||
} |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
@Deprecated(forRemoval = true, since = "4.1") |
|||
public static String deduplicateByDuration(String configStr) { |
|||
if (configStr == null) { |
|||
return null; |
|||
} |
|||
Set<Long> distinctDurations = new HashSet<>(); |
|||
return parseConfig(configStr).stream() |
|||
.filter(entry -> distinctDurations.add(entry.durationSeconds())) |
|||
.map(RateLimitEntry::toString) |
|||
.collect(Collectors.joining(",")); |
|||
} |
|||
|
|||
} |
|||
@ -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.ElementType; |
|||
import java.lang.annotation.Retention; |
|||
import java.lang.annotation.RetentionPolicy; |
|||
import java.lang.annotation.Target; |
|||
|
|||
@Retention(RetentionPolicy.RUNTIME) |
|||
@Target({ElementType.FIELD, ElementType.METHOD}) |
|||
@Constraint(validatedBy = {}) |
|||
public @interface RateLimit { |
|||
|
|||
String message() default "rate limit has duplicate 'Per seconds' configuration."; |
|||
|
|||
String fieldName() default ""; |
|||
|
|||
Class<?>[] groups() default {}; |
|||
|
|||
Class<? extends Payload>[] payload() default {}; |
|||
|
|||
} |
|||
@ -0,0 +1,101 @@ |
|||
/** |
|||
* 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.limit; |
|||
|
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; |
|||
|
|||
import java.util.Arrays; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.stream.Collectors; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.Mockito.clearInvocations; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.verify; |
|||
|
|||
class LimitedApiTest { |
|||
|
|||
private DefaultTenantProfileConfiguration config; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
config = mock(DefaultTenantProfileConfiguration.class); |
|||
} |
|||
|
|||
@Test |
|||
void testCorrectConfigExtractorsUsed() { |
|||
Map<LimitedApi, Runnable> verifierMap = Map.ofEntries( |
|||
Map.entry(LimitedApi.ENTITY_EXPORT, () -> |
|||
verify(config).getTenantEntityExportRateLimit()), |
|||
Map.entry(LimitedApi.ENTITY_IMPORT, () -> |
|||
verify(config).getTenantEntityImportRateLimit()), |
|||
Map.entry(LimitedApi.NOTIFICATION_REQUESTS, () -> |
|||
verify(config).getTenantNotificationRequestsRateLimit()), |
|||
Map.entry(LimitedApi.NOTIFICATION_REQUESTS_PER_RULE, () -> |
|||
verify(config).getTenantNotificationRequestsPerRuleRateLimit()), |
|||
Map.entry(LimitedApi.REST_REQUESTS_PER_TENANT, () -> |
|||
verify(config).getTenantServerRestLimitsConfiguration()), |
|||
Map.entry(LimitedApi.REST_REQUESTS_PER_CUSTOMER, () -> |
|||
verify(config).getCustomerServerRestLimitsConfiguration()), |
|||
Map.entry(LimitedApi.WS_UPDATES_PER_SESSION, () -> |
|||
verify(config).getWsUpdatesPerSessionRateLimit()), |
|||
Map.entry(LimitedApi.CASSANDRA_WRITE_QUERIES_CORE, () -> |
|||
verify(config).getCassandraWriteQueryTenantCoreRateLimits()), |
|||
Map.entry(LimitedApi.CASSANDRA_READ_QUERIES_CORE, () -> |
|||
verify(config).getCassandraReadQueryTenantCoreRateLimits()), |
|||
Map.entry(LimitedApi.CASSANDRA_WRITE_QUERIES_RULE_ENGINE, () -> |
|||
verify(config).getCassandraWriteQueryTenantRuleEngineRateLimits()), |
|||
Map.entry(LimitedApi.CASSANDRA_READ_QUERIES_RULE_ENGINE, () -> |
|||
verify(config).getCassandraReadQueryTenantRuleEngineRateLimits()), |
|||
Map.entry(LimitedApi.CASSANDRA_READ_QUERIES_MONOLITH, () -> { |
|||
verify(config).getCassandraReadQueryTenantCoreRateLimits(); |
|||
verify(config).getCassandraReadQueryTenantRuleEngineRateLimits(); |
|||
}), |
|||
Map.entry(LimitedApi.CASSANDRA_WRITE_QUERIES_MONOLITH, () -> { |
|||
verify(config).getCassandraWriteQueryTenantCoreRateLimits(); |
|||
verify(config).getCassandraWriteQueryTenantRuleEngineRateLimits(); |
|||
}), |
|||
Map.entry(LimitedApi.EDGE_EVENTS, () -> |
|||
verify(config).getEdgeEventRateLimits()), |
|||
Map.entry(LimitedApi.EDGE_EVENTS_PER_EDGE, () -> |
|||
verify(config).getEdgeEventRateLimitsPerEdge()), |
|||
Map.entry(LimitedApi.EDGE_UPLINK_MESSAGES, () -> |
|||
verify(config).getEdgeUplinkMessagesRateLimits()), |
|||
Map.entry(LimitedApi.EDGE_UPLINK_MESSAGES_PER_EDGE, () -> |
|||
verify(config).getEdgeUplinkMessagesRateLimitsPerEdge()) |
|||
); |
|||
|
|||
Set<LimitedApi> expected = verifierMap.keySet(); |
|||
Set<LimitedApi> actual = Arrays.stream(LimitedApi.values()) |
|||
.filter(api -> api.getConfigExtractor() != null) |
|||
.collect(Collectors.toSet()); |
|||
|
|||
assertThat(expected) |
|||
.as("Verifier map should cover all LimitedApis with extractors") |
|||
.containsExactlyInAnyOrderElementsOf(actual); |
|||
|
|||
for (Map.Entry<LimitedApi, Runnable> entry : verifierMap.entrySet()) { |
|||
LimitedApi api = entry.getKey(); |
|||
api.getLimitConfig(config); |
|||
entry.getValue().run(); |
|||
clearInvocations(config); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,99 @@ |
|||
/** |
|||
* 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.limit; |
|||
|
|||
import org.junit.jupiter.api.DisplayName; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; |
|||
|
|||
import java.util.List; |
|||
import java.util.function.Function; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
class RateLimitUtilTest { |
|||
|
|||
@Test |
|||
@DisplayName("LimitedApiUtil should parse single entry correctly") |
|||
void testParseSingleEntry() { |
|||
List<RateLimitEntry> entries = RateLimitUtil.parseConfig("100:60"); |
|||
|
|||
assertThat(entries).hasSize(1); |
|||
assertThat(entries.get(0).capacity()).isEqualTo(100); |
|||
assertThat(entries.get(0).durationSeconds()).isEqualTo(60); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("LimitedApiUtil should parse multiple entries correctly") |
|||
void testParseMultipleEntries() { |
|||
List<RateLimitEntry> entries = RateLimitUtil.parseConfig("100:60,200:30"); |
|||
|
|||
assertThat(entries).hasSize(2); |
|||
assertThat(entries.get(0).capacity()).isEqualTo(100); |
|||
assertThat(entries.get(0).durationSeconds()).isEqualTo(60); |
|||
assertThat(entries.get(1).capacity()).isEqualTo(200); |
|||
assertThat(entries.get(1).durationSeconds()).isEqualTo(30); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("LimitedApiUtil should return empty list for null or empty config") |
|||
void testParseEmptyConfig() { |
|||
assertThat(RateLimitUtil.parseConfig(null)).isEmpty(); |
|||
assertThat(RateLimitUtil.parseConfig("")).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("LimitedApiUtil should merge two configs by summing capacities with same durations") |
|||
void testMergeStrConfigs() { |
|||
Function<DefaultTenantProfileConfiguration, String> extractor1 = cfg -> "100:60,50:30"; |
|||
Function<DefaultTenantProfileConfiguration, String> extractor2 = cfg -> "200:60,25:10"; |
|||
|
|||
// Fake config instance (not used directly in lambda logic)
|
|||
DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); |
|||
|
|||
String result = RateLimitUtil.merge(extractor1, extractor2).apply(config); |
|||
|
|||
// Should be: 300:60 (100+200), 50:30, 25:10
|
|||
assertThat(result).isEqualTo("25:10,50:30,300:60"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("LimitedApiUtil should merge configs when one is empty") |
|||
void testMergeWithEmptyOne() { |
|||
Function<DefaultTenantProfileConfiguration, String> extractor1 = cfg -> "100:60"; |
|||
Function<DefaultTenantProfileConfiguration, String> extractor2 = cfg -> ""; |
|||
|
|||
// Fake config instance (not used directly in lambda logic)
|
|||
DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); |
|||
String result = RateLimitUtil.merge(extractor1, extractor2).apply(config); |
|||
|
|||
assertThat(result).isEqualTo("100:60"); |
|||
} |
|||
|
|||
@Test |
|||
@DisplayName("LimitedApiUtil should merge configs when both have distinct durations") |
|||
void testMergeWithDistinctDurations() { |
|||
Function<DefaultTenantProfileConfiguration, String> extractor1 = cfg -> "100:60"; |
|||
Function<DefaultTenantProfileConfiguration, String> extractor2 = cfg -> "200:10"; |
|||
|
|||
// Fake config instance (not used directly in lambda logic)
|
|||
DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); |
|||
String result = RateLimitUtil.merge(extractor1, extractor2).apply(config); |
|||
|
|||
assertThat(result).isEqualTo("200:10,100:60"); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,73 @@ |
|||
<!-- |
|||
|
|||
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. |
|||
|
|||
--> |
|||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
|||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> |
|||
<modelVersion>4.0.0</modelVersion> |
|||
<parent> |
|||
<groupId>org.thingsboard</groupId> |
|||
<version>4.1.0-SNAPSHOT</version> |
|||
<artifactId>common</artifactId> |
|||
</parent> |
|||
<groupId>org.thingsboard.common</groupId> |
|||
<artifactId>discovery-api</artifactId> |
|||
<packaging>jar</packaging> |
|||
|
|||
<name>Thingsboard Server Service Info Provider API</name> |
|||
<url>https://thingsboard.io</url> |
|||
|
|||
<properties> |
|||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> |
|||
<main.dir>${basedir}/../..</main.dir> |
|||
</properties> |
|||
|
|||
<dependencies> |
|||
<dependency> |
|||
<groupId>org.thingsboard.common</groupId> |
|||
<artifactId>proto</artifactId> |
|||
</dependency> |
|||
<dependency> |
|||
<groupId>org.thingsboard.common</groupId> |
|||
<artifactId>message</artifactId> |
|||
</dependency> |
|||
</dependencies> |
|||
|
|||
<build> |
|||
<plugins> |
|||
<plugin> |
|||
<groupId>org.apache.maven.plugins</groupId> |
|||
<artifactId>maven-source-plugin</artifactId> |
|||
<executions> |
|||
<execution> |
|||
<id>attach-sources</id> |
|||
<goals> |
|||
<goal>jar</goal> |
|||
</goals> |
|||
</execution> |
|||
</executions> |
|||
</plugin> |
|||
<plugin> |
|||
<groupId>org.apache.maven.plugins</groupId> |
|||
<artifactId>maven-deploy-plugin</artifactId> |
|||
<configuration> |
|||
<skip>false</skip> |
|||
</configuration> |
|||
</plugin> |
|||
</plugins> |
|||
</build> |
|||
|
|||
</project> |
|||
@ -0,0 +1,32 @@ |
|||
/** |
|||
* 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 lombok.extern.slf4j.Slf4j; |
|||
import org.thingsboard.server.common.data.limit.RateLimitUtil; |
|||
import org.thingsboard.server.common.data.validation.RateLimit; |
|||
|
|||
@Slf4j |
|||
public class RateLimitValidator implements ConstraintValidator<RateLimit, String> { |
|||
|
|||
@Override |
|||
public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) { |
|||
return value == null || RateLimitUtil.isValid(value); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
/** |
|||
* 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.util; |
|||
|
|||
import lombok.Getter; |
|||
import org.apache.commons.lang3.StringUtils; |
|||
import org.thingsboard.server.common.data.limit.LimitedApi; |
|||
|
|||
@Getter |
|||
public enum BufferedRateExecutorType { |
|||
|
|||
READ(LimitedApi.CASSANDRA_READ_QUERIES_CORE, LimitedApi.CASSANDRA_READ_QUERIES_RULE_ENGINE, LimitedApi.CASSANDRA_READ_QUERIES_MONOLITH), |
|||
WRITE(LimitedApi.CASSANDRA_WRITE_QUERIES_CORE, LimitedApi.CASSANDRA_WRITE_QUERIES_RULE_ENGINE, LimitedApi.CASSANDRA_WRITE_QUERIES_MONOLITH); |
|||
|
|||
private final LimitedApi coreLimitedApi; |
|||
private final LimitedApi ruleEngineLimitedApi; |
|||
private final LimitedApi monolithLimitedApi; |
|||
|
|||
private final String displayName = StringUtils.capitalize(name().toLowerCase()); |
|||
|
|||
BufferedRateExecutorType(LimitedApi coreLimitedApi, LimitedApi ruleEngineLimitedApi, LimitedApi monolithLimitedApi) { |
|||
this.coreLimitedApi = coreLimitedApi; |
|||
this.ruleEngineLimitedApi = ruleEngineLimitedApi; |
|||
this.monolithLimitedApi = monolithLimitedApi; |
|||
} |
|||
|
|||
} |
|||
Loading…
Reference in new issue