From b2349db9788323ae037355c49169ac9fc4a9420c Mon Sep 17 00:00:00 2001 From: Edwin Greene Date: Mon, 16 Sep 2024 18:32:23 -0400 Subject: [PATCH] Add outstanding token airdrops to REST API (#9286) * Add outstanding token airdrops to rest api Signed-off-by: Edwin Greene --- .../{ParameterNames.java => Constants.java} | 8 +- .../common/EntityIdRangeParameter.java | 9 - .../restjava/common/NumberRangeParameter.java | 47 ++ .../restjava/common/RangeParameter.java | 9 + .../controller/AllowancesController.java | 8 +- .../controller/TokenAirdropsController.java | 85 ++++ .../restjava/dto/TokenAirdropRequest.java | 42 ++ .../restjava/mapper/CollectionMapper.java | 40 ++ .../restjava/mapper/NftAllowanceMapper.java | 19 +- .../restjava/mapper/TokenAirdropMapper.java | 40 ++ .../restjava/repository/JooqRepository.java | 133 ++++++ .../NftAllowanceRepositoryCustom.java | 2 +- .../NftAllowanceRepositoryCustomImpl.java | 92 +--- .../repository/TokenAirdropRepository.java | 23 + .../TokenAirdropRepositoryCustom.java | 29 ++ .../TokenAirdropRepositoryCustomImpl.java | 76 +++ .../hedera/mirror/restjava/service/Bound.java | 3 + .../service/NftAllowanceServiceImpl.java | 4 +- .../restjava/service/TokenAirdropService.java | 26 ++ .../service/TokenAirdropServiceImpl.java | 37 ++ .../restjava/common/LinkFactoryTest.java | 4 +- .../common/NumberRangeParameterTest.java | 81 ++++ .../TokenAirdropsControllerTest.java | 434 ++++++++++++++++++ .../mapper/TokenAirdropMapperTest.java | 78 ++++ .../restjava/model/TokenAirdropModelTest.java | 78 ++++ .../NftAllowanceRepositoryTest.java | 121 +++-- .../TokenAirdropRepositoryTest.java | 197 ++++++++ .../service/NftAllowanceServiceTest.java | 74 ++- .../service/TokenAirdropServiceTest.java | 93 ++++ hedera-mirror-rest/api/v1/openapi.yml | 104 +++++ 30 files changed, 1764 insertions(+), 232 deletions(-) rename hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/{ParameterNames.java => Constants.java} (73%) create mode 100644 hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/NumberRangeParameter.java create mode 100644 hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/controller/TokenAirdropsController.java create mode 100644 hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/dto/TokenAirdropRequest.java create mode 100644 hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/CollectionMapper.java create mode 100644 hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/TokenAirdropMapper.java create mode 100644 hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/JooqRepository.java create mode 100644 hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/TokenAirdropRepository.java create mode 100644 hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/TokenAirdropRepositoryCustom.java create mode 100644 hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/TokenAirdropRepositoryCustomImpl.java create mode 100644 hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/TokenAirdropService.java create mode 100644 hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/TokenAirdropServiceImpl.java create mode 100644 hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/common/NumberRangeParameterTest.java create mode 100644 hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/controller/TokenAirdropsControllerTest.java create mode 100644 hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/TokenAirdropMapperTest.java create mode 100644 hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/model/TokenAirdropModelTest.java create mode 100644 hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/repository/TokenAirdropRepositoryTest.java create mode 100644 hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/service/TokenAirdropServiceTest.java diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/ParameterNames.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/Constants.java similarity index 73% rename from hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/ParameterNames.java rename to hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/Constants.java index bf51cf484ed..54cadc86377 100644 --- a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/ParameterNames.java +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/Constants.java @@ -19,8 +19,14 @@ import lombok.experimental.UtilityClass; @UtilityClass -public class ParameterNames { +public class Constants { public static final String ACCOUNT_ID = "account.id"; + public static final String RECEIVER_ID = "receiver.id"; + public static final String SENDER_ID = "sender.id"; + public static final String SERIAL_NUMBER = "serialnumber"; public static final String TOKEN_ID = "token.id"; + + public static final int MAX_LIMIT = 100; + public static final String DEFAULT_LIMIT = "25"; } diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/EntityIdRangeParameter.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/EntityIdRangeParameter.java index f46ee93e558..b263f74c674 100644 --- a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/EntityIdRangeParameter.java +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/EntityIdRangeParameter.java @@ -61,13 +61,4 @@ private static EntityId getEntityId(String entityId) { default -> throw new IllegalArgumentException("Invalid entity ID: " + entityId); }; } - - // Considering EQ in the same category as GT,GTE as an assumption - public boolean hasLowerBound() { - return operator == RangeOperator.GT || operator == RangeOperator.GTE || operator == RangeOperator.EQ; - } - - public boolean hasUpperBound() { - return operator == RangeOperator.LT || operator == RangeOperator.LTE; - } } diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/NumberRangeParameter.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/NumberRangeParameter.java new file mode 100644 index 00000000000..ce81947d904 --- /dev/null +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/NumberRangeParameter.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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 com.hedera.mirror.restjava.common; + +import org.apache.commons.lang3.StringUtils; + +public record NumberRangeParameter(RangeOperator operator, Long value) implements RangeParameter { + + public static final NumberRangeParameter EMPTY = new NumberRangeParameter(null, null); + + public static NumberRangeParameter valueOf(String valueRangeParam) { + if (StringUtils.isBlank(valueRangeParam)) { + return EMPTY; + } + + var splitVal = valueRangeParam.split(":"); + return switch (splitVal.length) { + case 1 -> new NumberRangeParameter(RangeOperator.EQ, getNumberValue(splitVal[0])); + case 2 -> new NumberRangeParameter(RangeOperator.of(splitVal[0]), getNumberValue(splitVal[1])); + default -> throw new IllegalArgumentException( + "Invalid range operator %s. Should have format rangeOperator:Number".formatted(valueRangeParam)); + }; + } + + private static long getNumberValue(String number) { + var value = Long.parseLong(number); + if (value < 0) { + throw new IllegalArgumentException("Invalid range value: " + number); + } + + return value; + } +} diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/RangeParameter.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/RangeParameter.java index 24b0466fa96..d2d2e971ea8 100644 --- a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/RangeParameter.java +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/common/RangeParameter.java @@ -21,4 +21,13 @@ public interface RangeParameter { RangeOperator operator(); T value(); + + // Considering EQ in the same category as GT,GTE as an assumption + default boolean hasLowerBound() { + return operator() == RangeOperator.GT || operator() == RangeOperator.GTE || operator() == RangeOperator.EQ; + } + + default boolean hasUpperBound() { + return operator() == RangeOperator.LT || operator() == RangeOperator.LTE; + } } diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/controller/AllowancesController.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/controller/AllowancesController.java index 47e29e93346..70f4b8950c1 100644 --- a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/controller/AllowancesController.java +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/controller/AllowancesController.java @@ -16,8 +16,10 @@ package com.hedera.mirror.restjava.controller; -import static com.hedera.mirror.restjava.common.ParameterNames.ACCOUNT_ID; -import static com.hedera.mirror.restjava.common.ParameterNames.TOKEN_ID; +import static com.hedera.mirror.restjava.common.Constants.ACCOUNT_ID; +import static com.hedera.mirror.restjava.common.Constants.DEFAULT_LIMIT; +import static com.hedera.mirror.restjava.common.Constants.MAX_LIMIT; +import static com.hedera.mirror.restjava.common.Constants.TOKEN_ID; import com.google.common.collect.ImmutableSortedMap; import com.hedera.mirror.rest.model.NftAllowance; @@ -51,7 +53,6 @@ @RestController public class AllowancesController { - private static final String DEFAULT_LIMIT = "25"; private static final Map>> EXTRACTORS = Map.of( true, nftAllowance -> ImmutableSortedMap.of( @@ -61,7 +62,6 @@ public class AllowancesController { nftAllowance -> ImmutableSortedMap.of( ACCOUNT_ID, nftAllowance.getOwner(), TOKEN_ID, nftAllowance.getTokenId())); - private static final int MAX_LIMIT = 100; private final LinkFactory linkFactory; private final NftAllowanceService service; diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/controller/TokenAirdropsController.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/controller/TokenAirdropsController.java new file mode 100644 index 00000000000..7088e15ef01 --- /dev/null +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/controller/TokenAirdropsController.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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 com.hedera.mirror.restjava.controller; + +import static com.hedera.mirror.restjava.common.Constants.ACCOUNT_ID; +import static com.hedera.mirror.restjava.common.Constants.DEFAULT_LIMIT; +import static com.hedera.mirror.restjava.common.Constants.MAX_LIMIT; +import static com.hedera.mirror.restjava.common.Constants.RECEIVER_ID; +import static com.hedera.mirror.restjava.common.Constants.TOKEN_ID; + +import com.google.common.collect.ImmutableSortedMap; +import com.hedera.mirror.rest.model.TokenAirdrop; +import com.hedera.mirror.rest.model.TokenAirdropsResponse; +import com.hedera.mirror.restjava.common.EntityIdParameter; +import com.hedera.mirror.restjava.common.EntityIdRangeParameter; +import com.hedera.mirror.restjava.common.LinkFactory; +import com.hedera.mirror.restjava.dto.TokenAirdropRequest; +import com.hedera.mirror.restjava.mapper.TokenAirdropMapper; +import com.hedera.mirror.restjava.service.Bound; +import com.hedera.mirror.restjava.service.TokenAirdropService; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import lombok.CustomLog; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@CustomLog +@RequestMapping("/api/v1/accounts/{id}/airdrops") +@RequiredArgsConstructor +@RestController +public class TokenAirdropsController { + private static final Function> EXTRACTOR = tokenAirdrop -> ImmutableSortedMap.of( + RECEIVER_ID, tokenAirdrop.getReceiverId(), + TOKEN_ID, tokenAirdrop.getTokenId()); + + private final LinkFactory linkFactory; + private final TokenAirdropMapper tokenAirdropMapper; + private final TokenAirdropService service; + + @GetMapping(value = "/outstanding") + TokenAirdropsResponse getOutstandingAirdrops( + @PathVariable EntityIdParameter id, + @RequestParam(defaultValue = DEFAULT_LIMIT) @Positive @Max(MAX_LIMIT) int limit, + @RequestParam(defaultValue = "asc") Sort.Direction order, + @RequestParam(name = RECEIVER_ID, required = false) @Size(max = 2) List receiverIds, + @RequestParam(name = TOKEN_ID, required = false) @Size(max = 2) List tokenIds) { + var request = TokenAirdropRequest.builder() + .accountId(id) + .entityIds(new Bound(receiverIds, true, ACCOUNT_ID)) + .limit(limit) + .order(order) + .tokenIds(new Bound(tokenIds, false, TOKEN_ID)) + .build(); + var response = service.getOutstandingAirdrops(request); + var airdrops = tokenAirdropMapper.map(response); + var sort = Sort.by(order, RECEIVER_ID, TOKEN_ID); + var pageable = PageRequest.of(0, limit, sort); + var links = linkFactory.create(airdrops, pageable, EXTRACTOR); + return new TokenAirdropsResponse().airdrops(airdrops).links(links); + } +} diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/dto/TokenAirdropRequest.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/dto/TokenAirdropRequest.java new file mode 100644 index 00000000000..494d83d744e --- /dev/null +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/dto/TokenAirdropRequest.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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 com.hedera.mirror.restjava.dto; + +import com.hedera.mirror.restjava.common.EntityIdParameter; +import com.hedera.mirror.restjava.service.Bound; +import lombok.Builder; +import lombok.Data; +import org.springframework.data.domain.Sort; + +@Data +@Builder +public class TokenAirdropRequest { + + // Sender Id for Outstanding Airdrops, Receiver Id for Pending Airdrops + private EntityIdParameter accountId; + + @Builder.Default + private int limit = 25; + + @Builder.Default + private Sort.Direction order = Sort.Direction.ASC; + + // Receiver Id for Outstanding Airdrops, Sender Id for Pending Airdrops + private Bound entityIds; + + private Bound tokenIds; +} diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/CollectionMapper.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/CollectionMapper.java new file mode 100644 index 00000000000..af680f1cf98 --- /dev/null +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/CollectionMapper.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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 com.hedera.mirror.restjava.mapper; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +public interface CollectionMapper { + + T map(S source); + + default List map(Collection sources) { + if (sources == null) { + return Collections.emptyList(); + } + + List list = new ArrayList<>(sources.size()); + for (S source : sources) { + list.add(map(source)); + } + + return list; + } +} diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/NftAllowanceMapper.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/NftAllowanceMapper.java index 097bbd7c102..bc052bacbb1 100644 --- a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/NftAllowanceMapper.java +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/NftAllowanceMapper.java @@ -17,29 +17,12 @@ package com.hedera.mirror.restjava.mapper; import com.hedera.mirror.common.domain.entity.NftAllowance; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; import org.mapstruct.Mapper; import org.mapstruct.Mapping; @Mapper(config = MapperConfiguration.class) -public interface NftAllowanceMapper { +public interface NftAllowanceMapper extends CollectionMapper { @Mapping(source = "timestampRange", target = "timestamp") com.hedera.mirror.rest.model.NftAllowance map(NftAllowance source); - - default List map(Collection source) { - if (source == null) { - return Collections.emptyList(); - } - - List list = new ArrayList<>(source.size()); - for (NftAllowance allowance : source) { - list.add(map(allowance)); - } - - return list; - } } diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/TokenAirdropMapper.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/TokenAirdropMapper.java new file mode 100644 index 00000000000..21bd71ad4d2 --- /dev/null +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/mapper/TokenAirdropMapper.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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 com.hedera.mirror.restjava.mapper; + +import com.hedera.mirror.common.domain.token.TokenAirdrop; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; + +@Mapper(config = MapperConfiguration.class) +public interface TokenAirdropMapper extends CollectionMapper { + + @Mapping(source = "receiverAccountId", target = "receiverId") + @Mapping(source = "senderAccountId", target = "senderId") + @Mapping(source = "serialNumber", target = "serialNumber", qualifiedByName = "mapToNullIfZero") + @Mapping(source = "timestampRange", target = "timestamp") + com.hedera.mirror.rest.model.TokenAirdrop map(TokenAirdrop source); + + @Named("mapToNullIfZero") + default Long mapToNullIfZero(long serialNumber) { + if (serialNumber == 0L) { + return null; + } + return serialNumber; + } +} diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/JooqRepository.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/JooqRepository.java new file mode 100644 index 00000000000..f65443b88f4 --- /dev/null +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/JooqRepository.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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 com.hedera.mirror.restjava.repository; + +import static com.hedera.mirror.restjava.common.RangeOperator.EQ; +import static com.hedera.mirror.restjava.common.RangeOperator.GT; +import static com.hedera.mirror.restjava.common.RangeOperator.LT; +import static org.jooq.impl.DSL.noCondition; + +import com.hedera.mirror.common.domain.entity.EntityId; +import com.hedera.mirror.restjava.common.EntityIdRangeParameter; +import com.hedera.mirror.restjava.common.RangeOperator; +import com.hedera.mirror.restjava.service.Bound; +import org.jooq.Condition; +import org.jooq.Field; + +interface JooqRepository { + + default Condition getCondition(Field field, EntityIdRangeParameter param) { + if (param == null || param == EntityIdRangeParameter.EMPTY) { + return noCondition(); + } + + return getCondition(field, param.operator(), param.value().getId()); + } + + default Condition getCondition(Field field, RangeOperator operator, Long value) { + return operator.getFunction().apply(field, value); + } + + default Condition getBoundCondition(ConditionalFieldBounds fieldBounds) { + var primaryBound = fieldBounds.primary.bound(); + var primaryLower = primaryBound.getLower(); + var primaryUpper = primaryBound.getUpper(); + if (!primaryBound.isEmpty() && primaryBound.hasEqualBounds()) { + // If the primary param has a range with a single value, rewrite it to EQ + primaryLower = new EntityIdRangeParameter(EQ, EntityId.of(primaryBound.adjustLowerBound())); + primaryUpper = null; + } + + var secondaryBound = fieldBounds.secondary().bound(); + var secondaryLower = secondaryBound.getLower(); + var secondaryUpper = secondaryBound.getUpper(); + if (!secondaryBound.isEmpty() && (secondaryLower != null && secondaryLower.operator() == EQ)) { + // If the secondary param operator is EQ, set the secondary upper bound to the same + secondaryUpper = secondaryLower; + } + + var primaryField = fieldBounds.primary().field(); + var secondaryField = fieldBounds.secondary().field(); + var lowerCondition = getOuterBoundCondition(primaryLower, secondaryLower, primaryField, secondaryField); + var middleCondition = getMiddleCondition(primaryLower, secondaryLower, primaryField, secondaryField) + .and(getMiddleCondition(primaryUpper, secondaryUpper, primaryField, secondaryField)); + var upperCondition = getOuterBoundCondition(primaryUpper, secondaryUpper, primaryField, secondaryField); + + return lowerCondition.or(middleCondition).or(upperCondition); + } + + private Condition getOuterBoundCondition( + EntityIdRangeParameter primaryParam, + EntityIdRangeParameter secondaryParam, + Field primaryField, + Field secondaryField) { + // No outer bound condition if there is no primary parameter, or the operator is EQ. For EQ, everything should + // go into the middle condition + if (primaryParam == null + || primaryParam.equals(EntityIdRangeParameter.EMPTY) + || primaryParam.operator() == EQ) { + return noCondition(); + } + + // If the secondary param operator is EQ, there should only have the middle condition + if (secondaryParam != null && secondaryParam.operator() == EQ) { + return noCondition(); + } + + long value = primaryParam.value().getId(); + if (primaryParam.operator() == GT) { + value += 1L; + } else if (primaryParam.operator() == LT) { + value -= 1L; + } + + return getCondition(primaryField, EQ, value).and(getCondition(secondaryField, secondaryParam)); + } + + private Condition getMiddleCondition( + EntityIdRangeParameter primaryParam, + EntityIdRangeParameter secondaryParam, + Field primaryField, + Field secondaryField) { + if (primaryParam == null) { + return getCondition(secondaryField, secondaryParam); + } + + // When the primary param operator is EQ, or the secondary param operator is EQ, don't adjust the value for the + // primary param. + if (primaryParam.operator() == EQ || (secondaryParam != null && secondaryParam.operator() == EQ)) { + return getCondition(primaryField, primaryParam).and(getCondition(secondaryField, secondaryParam)); + } + + long value = primaryParam.value().getId(); + value += primaryParam.hasLowerBound() ? 1L : -1L; + return getCondition(primaryField, primaryParam.operator(), value); + } + + record ConditionalFieldBounds(FieldBound primary, FieldBound secondary) {} + + record FieldBound(Field field, Bound bound) { + public FieldBound { + if (field == null) { + throw new IllegalArgumentException("Conditional field cannot be null"); + } + if (bound == null) { + bound = Bound.EMPTY; + } + } + } +} diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/NftAllowanceRepositoryCustom.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/NftAllowanceRepositoryCustom.java index e9a09fcfaac..e2897ddef36 100644 --- a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/NftAllowanceRepositoryCustom.java +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/NftAllowanceRepositoryCustom.java @@ -22,7 +22,7 @@ import jakarta.validation.constraints.NotNull; import java.util.Collection; -public interface NftAllowanceRepositoryCustom { +public interface NftAllowanceRepositoryCustom extends JooqRepository { /** * Find all NftAllowance matching the request parameters with the given limit, sort order, and byOwner flag diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/NftAllowanceRepositoryCustomImpl.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/NftAllowanceRepositoryCustomImpl.java index 92f24911b84..2dff5333c09 100644 --- a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/NftAllowanceRepositoryCustomImpl.java +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/NftAllowanceRepositoryCustomImpl.java @@ -17,17 +17,11 @@ package com.hedera.mirror.restjava.repository; import static com.hedera.mirror.restjava.common.RangeOperator.EQ; -import static com.hedera.mirror.restjava.common.RangeOperator.GT; -import static com.hedera.mirror.restjava.common.RangeOperator.LT; import static com.hedera.mirror.restjava.jooq.domain.Tables.NFT_ALLOWANCE; -import static org.jooq.impl.DSL.noCondition; import com.hedera.mirror.common.domain.entity.EntityId; import com.hedera.mirror.common.domain.entity.NftAllowance; -import com.hedera.mirror.restjava.common.EntityIdRangeParameter; -import com.hedera.mirror.restjava.common.RangeOperator; import com.hedera.mirror.restjava.dto.NftAllowanceRequest; -import com.hedera.mirror.restjava.service.Bound; import jakarta.inject.Named; import jakarta.validation.constraints.NotNull; import java.util.Collection; @@ -36,7 +30,6 @@ import lombok.RequiredArgsConstructor; import org.jooq.Condition; import org.jooq.DSLContext; -import org.jooq.Field; import org.jooq.SortField; import org.springframework.data.domain.Sort.Direction; @@ -57,8 +50,8 @@ class NftAllowanceRepositoryCustomImpl implements NftAllowanceRepositoryCustom { @Override public Collection findAll(NftAllowanceRequest request, EntityId accountId) { boolean byOwner = request.isOwner(); - var condition = getBaseCondition(accountId, byOwner) - .and(getBoundCondition(byOwner, request.getOwnerOrSpenderIds(), request.getTokenIds())); + var fieldBounds = getFieldBounds(request, byOwner); + var condition = getBaseCondition(accountId, byOwner).and(getBoundCondition(fieldBounds)); return dslContext .selectFrom(NFT_ALLOWANCE) .where(condition) @@ -72,82 +65,11 @@ private Condition getBaseCondition(EntityId accountId, boolean byOwner) { .and(APPROVAL_CONDITION); } - private Condition getBoundCondition(boolean byOwner, Bound primaryBound, Bound tokenBound) { - var primaryField = byOwner ? NFT_ALLOWANCE.SPENDER : NFT_ALLOWANCE.OWNER; - var primaryLower = primaryBound.getLower(); - var primaryUpper = primaryBound.getUpper(); - var tokenLower = tokenBound.getLower(); - var tokenUpper = tokenBound.getUpper(); - - // If the primary param has a range with a single value, rewrite it to EQ - if (primaryBound.hasEqualBounds()) { - primaryLower = new EntityIdRangeParameter(EQ, EntityId.of(primaryBound.adjustLowerBound())); - primaryUpper = null; - } - - // If the token param operator is EQ, set the token upper bound to the same - if (tokenLower != null && tokenLower.operator() == EQ) { - tokenUpper = tokenLower; - } - - var lowerCondition = getOuterBoundCondition(primaryLower, tokenLower, primaryField); - var middleCondition = getMiddleCondition(primaryLower, tokenLower, primaryField) - .and(getMiddleCondition(primaryUpper, tokenUpper, primaryField)); - var upperCondition = getOuterBoundCondition(primaryUpper, tokenUpper, primaryField); - - return lowerCondition.or(middleCondition).or(upperCondition); - } - - private Condition getOuterBoundCondition( - EntityIdRangeParameter primaryParam, EntityIdRangeParameter tokenParam, Field primaryField) { - // No outer bound condition if there is no primary parameter, or the operator is EQ. For EQ, everything should - // go into the middle condition - if (primaryParam == null || primaryParam.operator() == EQ) { - return noCondition(); - } - - // If the token param operator is EQ, there should only have the middle condition - if (tokenParam != null && tokenParam.operator() == EQ) { - return noCondition(); - } - - long value = primaryParam.value().getId(); - if (primaryParam.operator() == GT) { - value += 1L; - } else if (primaryParam.operator() == LT) { - value -= 1L; - } - - return getCondition(primaryField, EQ, value).and(getCondition(NFT_ALLOWANCE.TOKEN_ID, tokenParam)); - } - - private Condition getMiddleCondition( - EntityIdRangeParameter primaryParam, EntityIdRangeParameter tokenParam, Field primaryField) { - if (primaryParam == null) { - return noCondition(); - } - - // When the primary param operator is EQ, or the token param operator is EQ, don't adjust the value for the - // primary param. - if (primaryParam.operator() == EQ || (tokenParam != null && tokenParam.operator() == EQ)) { - return getCondition(primaryField, primaryParam).and(getCondition(NFT_ALLOWANCE.TOKEN_ID, tokenParam)); - } - - long value = primaryParam.value().getId(); - value += primaryParam.hasLowerBound() ? 1L : -1L; - return getCondition(primaryField, primaryParam.operator(), value); - } - - private static Condition getCondition(Field field, RangeOperator operator, Long value) { - return operator.getFunction().apply(field, value); - } - - private static Condition getCondition(Field field, EntityIdRangeParameter param) { - if (param == null) { - return noCondition(); - } - - return getCondition(field, param.operator(), param.value().getId()); + private ConditionalFieldBounds getFieldBounds(NftAllowanceRequest request, boolean byOwner) { + var field = byOwner ? NFT_ALLOWANCE.SPENDER : NFT_ALLOWANCE.OWNER; + return new ConditionalFieldBounds( + new FieldBound(field, request.getOwnerOrSpenderIds()), + new FieldBound(NFT_ALLOWANCE.TOKEN_ID, request.getTokenIds())); } private record OrderSpec(boolean byOwner, Direction direction) {} diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/TokenAirdropRepository.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/TokenAirdropRepository.java new file mode 100644 index 00000000000..810a626864c --- /dev/null +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/TokenAirdropRepository.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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 com.hedera.mirror.restjava.repository; + +import com.hedera.mirror.common.domain.token.AbstractTokenAirdrop.Id; +import com.hedera.mirror.common.domain.token.TokenAirdrop; +import org.springframework.data.repository.CrudRepository; + +public interface TokenAirdropRepository extends CrudRepository, TokenAirdropRepositoryCustom {} diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/TokenAirdropRepositoryCustom.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/TokenAirdropRepositoryCustom.java new file mode 100644 index 00000000000..50b7a1868c4 --- /dev/null +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/TokenAirdropRepositoryCustom.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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 com.hedera.mirror.restjava.repository; + +import com.hedera.mirror.common.domain.entity.EntityId; +import com.hedera.mirror.common.domain.token.TokenAirdrop; +import com.hedera.mirror.restjava.dto.TokenAirdropRequest; +import jakarta.validation.constraints.NotNull; +import java.util.Collection; + +public interface TokenAirdropRepositoryCustom extends JooqRepository { + + @NotNull + Collection findAllOutstanding(TokenAirdropRequest request, EntityId accountId); +} diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/TokenAirdropRepositoryCustomImpl.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/TokenAirdropRepositoryCustomImpl.java new file mode 100644 index 00000000000..13c308631f3 --- /dev/null +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/repository/TokenAirdropRepositoryCustomImpl.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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 com.hedera.mirror.restjava.repository; + +import static com.hedera.mirror.restjava.common.RangeOperator.EQ; +import static com.hedera.mirror.restjava.jooq.domain.Tables.TOKEN_AIRDROP; + +import com.hedera.mirror.common.domain.entity.EntityId; +import com.hedera.mirror.common.domain.token.TokenAirdrop; +import com.hedera.mirror.restjava.dto.TokenAirdropRequest; +import com.hedera.mirror.restjava.jooq.domain.enums.AirdropState; +import jakarta.inject.Named; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.jooq.Condition; +import org.jooq.DSLContext; +import org.jooq.SortField; +import org.springframework.data.domain.Sort.Direction; + +@Named +@RequiredArgsConstructor +class TokenAirdropRepositoryCustomImpl implements TokenAirdropRepositoryCustom { + + private final DSLContext dslContext; + private static final Map>> OUTSTANDING_SORT_ORDERS = Map.of( + Direction.ASC, List.of(TOKEN_AIRDROP.RECEIVER_ACCOUNT_ID.asc(), TOKEN_AIRDROP.TOKEN_ID.asc()), + Direction.DESC, List.of(TOKEN_AIRDROP.RECEIVER_ACCOUNT_ID.desc(), TOKEN_AIRDROP.TOKEN_ID.desc())); + + @Override + public Collection findAllOutstanding(TokenAirdropRequest request, EntityId accountId) { + var fieldBounds = getFieldBound(request, true); + var condition = getBaseCondition(accountId, true) + .and(getBoundCondition(fieldBounds)) + .and(TOKEN_AIRDROP.STATE.eq(AirdropState.PENDING)) + // Exclude NFTs + .and(TOKEN_AIRDROP.SERIAL_NUMBER.eq(0L)); + + var order = OUTSTANDING_SORT_ORDERS.get(request.getOrder()); + return dslContext + .selectFrom(TOKEN_AIRDROP) + .where(condition) + .orderBy(order) + .limit(request.getLimit()) + .fetchInto(TokenAirdrop.class); + } + + private ConditionalFieldBounds getFieldBound(TokenAirdropRequest request, boolean outstanding) { + var primaryField = outstanding ? TOKEN_AIRDROP.RECEIVER_ACCOUNT_ID : TOKEN_AIRDROP.SENDER_ACCOUNT_ID; + var primary = new FieldBound(primaryField, request.getEntityIds()); + var secondary = new FieldBound(TOKEN_AIRDROP.TOKEN_ID, request.getTokenIds()); + return new ConditionalFieldBounds(primary, secondary); + } + + private Condition getBaseCondition(EntityId accountId, boolean outstanding) { + return getCondition( + outstanding ? TOKEN_AIRDROP.SENDER_ACCOUNT_ID : TOKEN_AIRDROP.RECEIVER_ACCOUNT_ID, + EQ, + accountId.getId()); + } +} diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/Bound.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/Bound.java index 910c3c4c665..2172760ae6f 100644 --- a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/Bound.java +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/Bound.java @@ -22,10 +22,13 @@ import java.util.EnumMap; import java.util.List; import lombok.Getter; +import org.apache.commons.lang3.StringUtils; import org.springframework.util.CollectionUtils; public class Bound { + public static final Bound EMPTY = new Bound(null, false, StringUtils.EMPTY); + @Getter private EntityIdRangeParameter lower; diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/NftAllowanceServiceImpl.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/NftAllowanceServiceImpl.java index 60536bce1cc..751493d33cf 100644 --- a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/NftAllowanceServiceImpl.java +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/NftAllowanceServiceImpl.java @@ -17,7 +17,7 @@ package com.hedera.mirror.restjava.service; import com.hedera.mirror.common.domain.entity.NftAllowance; -import com.hedera.mirror.restjava.common.ParameterNames; +import com.hedera.mirror.restjava.common.Constants; import com.hedera.mirror.restjava.common.RangeOperator; import com.hedera.mirror.restjava.dto.NftAllowanceRequest; import com.hedera.mirror.restjava.repository.NftAllowanceRepository; @@ -55,7 +55,7 @@ private static void checkOwnerSpenderParamValidity(Bound ownerOrSpenderParams, B if (!ownerOrSpenderParams.hasLowerAndUpper() && tokenParams.adjustLowerBound() > tokenParams.adjustUpperBound()) { - throw new IllegalArgumentException("Invalid range provided for %s".formatted(ParameterNames.TOKEN_ID)); + throw new IllegalArgumentException("Invalid range provided for %s".formatted(Constants.TOKEN_ID)); } if (tokenParams.getCardinality(RangeOperator.LT, RangeOperator.LTE) > 0 diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/TokenAirdropService.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/TokenAirdropService.java new file mode 100644 index 00000000000..7eff0d62585 --- /dev/null +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/TokenAirdropService.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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 com.hedera.mirror.restjava.service; + +import com.hedera.mirror.common.domain.token.TokenAirdrop; +import com.hedera.mirror.restjava.dto.TokenAirdropRequest; +import java.util.Collection; + +public interface TokenAirdropService { + + Collection getOutstandingAirdrops(TokenAirdropRequest request); +} diff --git a/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/TokenAirdropServiceImpl.java b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/TokenAirdropServiceImpl.java new file mode 100644 index 00000000000..8e56a4da857 --- /dev/null +++ b/hedera-mirror-rest-java/src/main/java/com/hedera/mirror/restjava/service/TokenAirdropServiceImpl.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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 com.hedera.mirror.restjava.service; + +import com.hedera.mirror.common.domain.token.TokenAirdrop; +import com.hedera.mirror.restjava.dto.TokenAirdropRequest; +import com.hedera.mirror.restjava.repository.TokenAirdropRepository; +import jakarta.inject.Named; +import java.util.Collection; +import lombok.RequiredArgsConstructor; + +@Named +@RequiredArgsConstructor +public class TokenAirdropServiceImpl implements TokenAirdropService { + + private final EntityService entityService; + private final TokenAirdropRepository repository; + + public Collection getOutstandingAirdrops(TokenAirdropRequest request) { + var id = entityService.lookup(request.getAccountId()); + return repository.findAllOutstanding(request, id); + } +} diff --git a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/common/LinkFactoryTest.java b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/common/LinkFactoryTest.java index 61d1ed43a38..8c983ea38ea 100644 --- a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/common/LinkFactoryTest.java +++ b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/common/LinkFactoryTest.java @@ -16,8 +16,8 @@ package com.hedera.mirror.restjava.common; -import static com.hedera.mirror.restjava.common.ParameterNames.ACCOUNT_ID; -import static com.hedera.mirror.restjava.common.ParameterNames.TOKEN_ID; +import static com.hedera.mirror.restjava.common.Constants.ACCOUNT_ID; +import static com.hedera.mirror.restjava.common.Constants.TOKEN_ID; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.mockito.Mockito.when; diff --git a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/common/NumberRangeParameterTest.java b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/common/NumberRangeParameterTest.java new file mode 100644 index 00000000000..9b732944d7f --- /dev/null +++ b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/common/NumberRangeParameterTest.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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 com.hedera.mirror.restjava.common; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import com.hedera.mirror.restjava.RestJavaProperties; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class NumberRangeParameterTest { + + @Mock + private RestJavaProperties properties; + + private MockedStatic context; + + @BeforeEach + void setUp() { + context = Mockito.mockStatic(SpringApplicationContext.class); + when(SpringApplicationContext.getBean(RestJavaProperties.class)).thenReturn(properties); + } + + @AfterEach + void closeMocks() { + context.close(); + } + + @Test + void testNoOperatorPresent() { + assertThat(new NumberRangeParameter(RangeOperator.EQ, 2000L)).isEqualTo(NumberRangeParameter.valueOf("2000")); + } + + @ParameterizedTest + @EnumSource(RangeOperator.class) + void testRangeOperator(RangeOperator operator) { + assertThat(new NumberRangeParameter(operator, 2000L)) + .isEqualTo(NumberRangeParameter.valueOf(operator + ":2000")); + } + + @ParameterizedTest + @NullAndEmptySource + void testEmpty(String input) { + assertThat(NumberRangeParameter.valueOf(input)).isEqualTo(NumberRangeParameter.EMPTY); + } + + @ParameterizedTest + @ValueSource(strings = {"a", ".1", "someinvalidstring", "-1", "9223372036854775808", ":2000", ":", "eq:", ":1"}) + @DisplayName("IntegerRangeParameter parse from string tests, negative cases") + void testInvalidParam(String input) { + assertThrows(IllegalArgumentException.class, () -> NumberRangeParameter.valueOf(input)); + } +} diff --git a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/controller/TokenAirdropsControllerTest.java b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/controller/TokenAirdropsControllerTest.java new file mode 100644 index 00000000000..2ad1917772d --- /dev/null +++ b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/controller/TokenAirdropsControllerTest.java @@ -0,0 +1,434 @@ +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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 com.hedera.mirror.restjava.controller; + +import static com.hedera.mirror.common.domain.token.TokenTypeEnum.FUNGIBLE_COMMON; +import static com.hedera.mirror.common.domain.token.TokenTypeEnum.NON_FUNGIBLE_UNIQUE; +import static org.assertj.core.api.Assertions.assertThat; + +import com.google.common.io.BaseEncoding; +import com.hedera.mirror.common.domain.token.TokenAirdrop; +import com.hedera.mirror.common.util.DomainUtils; +import com.hedera.mirror.rest.model.Links; +import com.hedera.mirror.rest.model.TokenAirdropsResponse; +import com.hedera.mirror.restjava.mapper.TokenAirdropMapper; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClient.RequestHeadersSpec; +import org.springframework.web.client.RestClient.RequestHeadersUriSpec; + +@RequiredArgsConstructor +class TokenAirdropsControllerTest extends ControllerTest { + + private final TokenAirdropMapper mapper; + + @DisplayName("/api/v1/accounts/{id}/airdrops/outstanding") + @Nested + class OutstandingTokenAirdropsEndpointTest extends EndpointTest { + + @Override + protected String getUrl() { + return "accounts/{id}/airdrops/outstanding"; + } + + @Override + protected RequestHeadersSpec defaultRequest(RequestHeadersUriSpec uriSpec) { + var tokenAirdrop = domainBuilder.tokenAirdrop(FUNGIBLE_COMMON).persist(); + return uriSpec.uri("", tokenAirdrop.getSenderAccountId()); + } + + @ValueSource(strings = {"1000", "0.1000", "0.0.1000"}) + @ParameterizedTest + void entityId(String id) { + // Given + var tokenAirdrop = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(1000L)) + .persist(); + + // When + var response = restClient.get().uri("", id).retrieve().toEntity(TokenAirdropsResponse.class); + + // Then + assertThat(response.getBody().getAirdrops().getFirst()).isEqualTo(mapper.map(tokenAirdrop)); + // Based on application.yml response headers configuration + assertThat(response.getHeaders().getAccessControlAllowOrigin()).isEqualTo("*"); + assertThat(response.getHeaders().getCacheControl()).isEqualTo("public, max-age=1"); + } + + @Test + void evmAddress() { + // Given + var entity = domainBuilder.entity().persist(); + var tokenAirdrop = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(entity.getId())) + .persist(); + + // When + var response = restClient + .get() + .uri("", DomainUtils.bytesToHex(entity.getEvmAddress())) + .retrieve() + .toEntity(TokenAirdropsResponse.class); + + // Then + assertThat(response.getBody().getAirdrops().getFirst()).isEqualTo(mapper.map(tokenAirdrop)); + } + + @Test + void alias() { + // Given + var entity = domainBuilder.entity().persist(); + var tokenAirdrop = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(entity.getId())) + .persist(); + + // When + var response = restClient + .get() + .uri("", BaseEncoding.base32().omitPadding().encode(entity.getAlias())) + .retrieve() + .toEntity(TokenAirdropsResponse.class); + + // Then + assertThat(response.getBody().getAirdrops().getFirst()).isEqualTo(mapper.map(tokenAirdrop)); + } + + @Test + void followDescendingOrderLink() { + // Given + long sender = 1000; + long receiver = 2000; + long fungibleTokenId = 100; + long token1 = 300; + long token2 = 301; + + var airdrop1 = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(sender) + .receiverAccountId(receiver) + .tokenId(fungibleTokenId)) + .persist(); + var airdrop2 = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(sender) + .receiverAccountId(receiver) + .tokenId(token1)) + .persist(); + var airdrop3 = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(sender) + .receiverAccountId(receiver) + .tokenId(token2)) + .persist(); + domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.receiverAccountId(receiver)) + .persist(); + domainBuilder + .tokenAirdrop(NON_FUNGIBLE_UNIQUE) + .customize(a -> a.receiverAccountId(receiver)) + .persist(); + + var uriParams = "?limit=1&receiver.id=gte:%s&order=desc".formatted(receiver); + var baseLink = "/api/v1/accounts/%d/airdrops/outstanding".formatted(sender); + + // When + var result = restClient.get().uri(uriParams, sender).retrieve().body(TokenAirdropsResponse.class); + // The first receiver id is '2000' instead of 0.0.2000 because the link creation does not alter the original + // value sent in the request + // The second receiver id is added by the link generator and has shard.realm.num format + var nextParams = "?limit=1&receiver.id=gte:2000&receiver.id=lte:0.0.2000&order=desc&token.id=lt:0.0.301"; + + // Then + assertThat(result).isEqualTo(getExpectedResponse(List.of(airdrop3), baseLink + nextParams)); + + // When follow link + result = restClient.get().uri(nextParams, sender).retrieve().body(TokenAirdropsResponse.class); + + // Then + nextParams = "?limit=1&receiver.id=gte:2000&receiver.id=lte:0.0.2000&order=desc&token.id=lt:0.0.300"; + assertThat(result).isEqualTo(getExpectedResponse(List.of(airdrop2), baseLink + nextParams)); + + // When follow link + result = restClient.get().uri(nextParams, sender).retrieve().body(TokenAirdropsResponse.class); + + // Then + nextParams = "?limit=1&receiver.id=gte:2000&receiver.id=lte:0.0.2000&order=desc&token.id=lt:0.0.100"; + assertThat(result).isEqualTo(getExpectedResponse(List.of(airdrop1), baseLink + nextParams)); + + // When follow link + result = restClient.get().uri(nextParams, sender).retrieve().body(TokenAirdropsResponse.class); + + // Then + assertThat(result).isEqualTo(getExpectedResponse(List.of(), null)); + } + + @Test + void followAscendingOrderLink() { + // Given + var entity = domainBuilder.entity().persist(); + var airdrop = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(entity.getId()) + .receiverAccountId(3L) + .tokenId(5L)) + .persist(); + var airdrop2 = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(entity.getId()) + .receiverAccountId(3L) + .tokenId(6L)) + .persist(); + var airdrop3 = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(entity.getId()) + .receiverAccountId(4L) + .tokenId(5L)) + .persist(); + + var baseLink = "/api/v1/accounts/%d/airdrops/outstanding".formatted(entity.getId()); + var nextParams = "?limit=1&receiver.id=gte:0.0.3&token.id=gt:0.0.5"; + + // When no primary or secondary parameters are specified + var uriParams = "?limit=1"; + var result = + restClient.get().uri(uriParams, entity.getId()).retrieve().body(TokenAirdropsResponse.class); + // Then + assertThat(result).isEqualTo(getExpectedResponse(List.of(airdrop), baseLink + nextParams)); + + // When primary and secondary fields are specified + uriParams = "?limit=1&receiver.id=gt:2&token.id=gt:4"; + result = restClient.get().uri(uriParams, entity.getId()).retrieve().body(TokenAirdropsResponse.class); + // Then + assertThat(result).isEqualTo(getExpectedResponse(List.of(airdrop), baseLink + nextParams)); + + // When only the secondary field is specified + uriParams = "?limit=1&token.id=gt:4"; + result = restClient.get().uri(uriParams, entity.getId()).retrieve().body(TokenAirdropsResponse.class); + // Then + assertThat(result).isEqualTo(getExpectedResponse(List.of(airdrop), baseLink + nextParams)); + + // When follow link + result = restClient.get().uri(nextParams, entity.getId()).retrieve().body(TokenAirdropsResponse.class); + nextParams = "?limit=1&receiver.id=gte:0.0.3&token.id=gt:0.0.6"; + // Then + assertThat(result).isEqualTo(getExpectedResponse(List.of(airdrop2), baseLink + nextParams)); + + // When follow link + result = restClient.get().uri(nextParams, entity.getId()).retrieve().body(TokenAirdropsResponse.class); + nextParams = "?limit=1&receiver.id=gte:0.0.4&token.id=gt:0.0.5"; + // Then + assertThat(result).isEqualTo(getExpectedResponse(List.of(airdrop3), baseLink + nextParams)); + + // When follow link + result = restClient.get().uri(nextParams, entity.getId()).retrieve().body(TokenAirdropsResponse.class); + // Then + assertThat(result).isEqualTo(getExpectedResponse(List.of(), null)); + } + + @Test + void allParameters() { + // Given + var entity = domainBuilder.entity().persist(); + var airdrop = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(entity.getId()) + .receiverAccountId(2L) + .tokenId(5L)) + .persist(); + var airdrop2 = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(entity.getId()) + .receiverAccountId(3L) + .tokenId(5L)) + .persist(); + domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(entity.getId()) + .receiverAccountId(3L) + .tokenId(6L)) + .persist(); + domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(entity.getId()) + .receiverAccountId(4L) + .tokenId(5L)) + .persist(); + + var baseLink = "/api/v1/accounts/%d/airdrops/outstanding".formatted(entity.getId()); + var uriParams = "?limit=1&receiver.id=gte:0.0.1&receiver.id=lt:0.0.4&token.id=lte:0.0.5&token.id=gt:0.0.3"; + + // When + var result = + restClient.get().uri(uriParams, entity.getId()).retrieve().body(TokenAirdropsResponse.class); + var nextParams = "?limit=1&receiver.id=lt:0.0.4&receiver.id=gte:0.0.2&token.id=lte:0.0.5&token.id=gt:0.0.5"; + // Then + assertThat(result).isEqualTo(getExpectedResponse(List.of(airdrop), baseLink + nextParams)); + + // When + result = restClient.get().uri(nextParams, entity.getId()).retrieve().body(TokenAirdropsResponse.class); + nextParams = "?limit=1&receiver.id=lt:0.0.4&receiver.id=gte:0.0.3&token.id=lte:0.0.5&token.id=gt:0.0.5"; + // Then + assertThat(result).isEqualTo(getExpectedResponse(List.of(airdrop2), baseLink + nextParams)); + } + + @ParameterizedTest + @ValueSource( + strings = { + "0.0x000000000000000000000000000000000186Fb1b", + "0.0.0x000000000000000000000000000000000186Fb1b", + "0x000000000000000000000000000000000186Fb1b", + "0.0.AABBCC22", + "0.AABBCC22", + "AABBCC22" + }) + void notFound(String accountId) { + // When + ThrowingCallable callable = + () -> restClient.get().uri("", accountId).retrieve().body(TokenAirdropsResponse.class); + + // Then + validateError(callable, HttpClientErrorException.NotFound.class, "No account found for the given ID"); + } + + @ParameterizedTest + @ValueSource( + strings = { + "abc", + "a.b.c", + "0.0.", + "0.65537.1001", + "0.0.-1001", + "9223372036854775807", + "0x00000001000000000000000200000000000000034" + }) + void invalidId(String id) { + // When + ThrowingCallable callable = + () -> restClient.get().uri("", id).retrieve().body(TokenAirdropsResponse.class); + + // Then + validateError( + callable, + HttpClientErrorException.BadRequest.class, + "Failed to convert 'id' with value: '" + id + "'"); + } + + @ParameterizedTest + @ValueSource( + strings = { + "abc", + "a.b.c", + "0.0.", + "0.65537.1001", + "0.0.-1001", + "9223372036854775807", + "0x00000001000000000000000200000000000000034" + }) + void invalidAccountId(String accountId) { + // When + ThrowingCallable callable = () -> restClient + .get() + .uri("?receiver.id={accountId}", "0.0.1001", accountId) + .retrieve() + .body(TokenAirdropsResponse.class); + + // Then + validateError( + callable, + HttpClientErrorException.BadRequest.class, + "Failed to convert 'receiver.id' with value: '" + accountId + "'"); + } + + @ParameterizedTest + @CsvSource({ + "101, limit must be less than or equal to 100", + "-1, limit must be greater than 0", + "a, Failed to convert 'limit' with value: 'a'" + }) + void invalidLimit(String limit, String expected) { + // When + ThrowingCallable callable = () -> restClient + .get() + .uri("?limit={limit}", "0.0.1001", limit) + .retrieve() + .body(TokenAirdropsResponse.class); + + // Then + validateError(callable, HttpClientErrorException.BadRequest.class, expected); + } + + @ParameterizedTest + @CsvSource({ + "ascending, Failed to convert 'order' with value: 'ascending'", + "dsc, Failed to convert 'order' with value: 'dsc'", + "invalid, Failed to convert 'order' with value: 'invalid'" + }) + void invalidOrder(String order, String expected) { + // When + ThrowingCallable callable = () -> restClient + .get() + .uri("?order={order}", "0.0.1001", order) + .retrieve() + .body(TokenAirdropsResponse.class); + + // Then + validateError(callable, HttpClientErrorException.BadRequest.class, expected); + } + + @ParameterizedTest + @ValueSource( + strings = { + "abc", + "a.b.c", + "0.0.", + "0.65537.1001", + "0.0.-1001", + "9223372036854775807", + "0x00000001000000000000000200000000000000034" + }) + void invalidTokenId(String tokenId) { + // When + ThrowingCallable callable = () -> restClient + .get() + .uri("?token.id={tokenId}", "0.0.1001", tokenId) + .retrieve() + .body(TokenAirdropsResponse.class); + + // Then + validateError( + callable, + HttpClientErrorException.BadRequest.class, + "Failed to convert 'token.id' with value: '" + tokenId + "'"); + } + } + + private TokenAirdropsResponse getExpectedResponse(List tokenAirdrops, String next) { + return new TokenAirdropsResponse().airdrops(mapper.map(tokenAirdrops)).links(new Links().next(next)); + } +} diff --git a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/TokenAirdropMapperTest.java b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/TokenAirdropMapperTest.java new file mode 100644 index 00000000000..79d62b6493c --- /dev/null +++ b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/mapper/TokenAirdropMapperTest.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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 com.hedera.mirror.restjava.mapper; + +import static com.hedera.mirror.common.domain.token.TokenTypeEnum.FUNGIBLE_COMMON; +import static com.hedera.mirror.common.domain.token.TokenTypeEnum.NON_FUNGIBLE_UNIQUE; +import static org.assertj.core.api.Assertions.assertThat; + +import com.hedera.mirror.common.domain.DomainBuilder; +import com.hedera.mirror.common.domain.entity.EntityId; +import com.hedera.mirror.common.domain.token.TokenTypeEnum; +import com.hedera.mirror.rest.model.TimestampRange; +import com.hedera.mirror.rest.model.TokenAirdrop; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class TokenAirdropMapperTest { + + private CommonMapper commonMapper; + private DomainBuilder domainBuilder; + private TokenAirdropMapper mapper; + + @BeforeEach + void setup() { + commonMapper = new CommonMapperImpl(); + mapper = new TokenAirdropMapperImpl(commonMapper); + domainBuilder = new DomainBuilder(); + } + + @ParameterizedTest + @EnumSource(TokenTypeEnum.class) + void map(TokenTypeEnum tokenType) { + var tokenAirdrop = domainBuilder.tokenAirdrop(tokenType).get(); + var to = commonMapper.mapTimestamp(tokenAirdrop.getTimestampLower()); + + assertThat(mapper.map(List.of(tokenAirdrop))) + .first() + .returns(tokenType == NON_FUNGIBLE_UNIQUE ? null : tokenAirdrop.getAmount(), TokenAirdrop::getAmount) + .returns(EntityId.of(tokenAirdrop.getReceiverAccountId()).toString(), TokenAirdrop::getReceiverId) + .returns(EntityId.of(tokenAirdrop.getSenderAccountId()).toString(), TokenAirdrop::getSenderId) + .returns( + tokenType == FUNGIBLE_COMMON ? null : tokenAirdrop.getSerialNumber(), + TokenAirdrop::getSerialNumber) + .returns(EntityId.of(tokenAirdrop.getTokenId()).toString(), TokenAirdrop::getTokenId) + .satisfies(a -> assertThat(a.getTimestamp()) + .returns(to, TimestampRange::getFrom) + .returns(null, TimestampRange::getTo)); + } + + @Test + void mapNulls() { + var tokenAirdrop = new com.hedera.mirror.common.domain.token.TokenAirdrop(); + assertThat(mapper.map(tokenAirdrop)) + .returns(null, TokenAirdrop::getAmount) + .returns(null, TokenAirdrop::getReceiverId) + .returns(null, TokenAirdrop::getSenderId) + .returns(null, TokenAirdrop::getSerialNumber) + .returns(null, TokenAirdrop::getTokenId) + .returns(null, TokenAirdrop::getTimestamp); + } +} diff --git a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/model/TokenAirdropModelTest.java b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/model/TokenAirdropModelTest.java new file mode 100644 index 00000000000..a9e4213ec51 --- /dev/null +++ b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/model/TokenAirdropModelTest.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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 com.hedera.mirror.restjava.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hedera.mirror.rest.model.TokenAirdropsResponse; +import org.junit.jupiter.api.Test; + +class TokenAirdropModelTest { + String airdropsResponse = + """ + { + "airdrops": [ + { + "amount": 333, + "receiver_id": "0.0.999", + "sender_id": "0.0.222", + "serial_number": null, + "timestamp": { + "from": "1111111111.111111111", + "to": null + }, + "token_id": "0.0.111" + }, + { + "amount": 555, + "receiver_id": "0.0.999", + "sender_id": "0.0.222", + "serial_number": null, + "timestamp": { + "from": "1111111111.111111112", + "to": null + }, + "token_id": "0.0.444" + }, + { + "amount": null, + "receiver_id": "0.0.999", + "sender_id": "0.0.222", + "serial_number": 888, + "timestamp": { + "from": "1111111111.111111113", + "to": null + }, + "token_id": "0.0.666" + } + ], + "links": { + "next": "/api/v1/accounts/0.0.1000/airdrops/outstanding?limit=3&order=asc&token.id=gt:0.0.667" + } + } + """; + + @Test + void verifyModelGeneration() throws JsonProcessingException { + var mapper = new ObjectMapper(); + var response = mapper.readValue(airdropsResponse, TokenAirdropsResponse.class); + var tokenAirdrop = mapper.writeValueAsString(response); + assertThat(tokenAirdrop).isEqualToIgnoringWhitespace(airdropsResponse); + } +} diff --git a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/repository/NftAllowanceRepositoryTest.java b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/repository/NftAllowanceRepositoryTest.java index 6d3ef612317..08df0fea5cb 100644 --- a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/repository/NftAllowanceRepositoryTest.java +++ b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/repository/NftAllowanceRepositoryTest.java @@ -26,9 +26,9 @@ import com.hedera.mirror.common.domain.entity.EntityId; import com.hedera.mirror.common.domain.entity.NftAllowance; import com.hedera.mirror.restjava.RestJavaIntegrationTest; +import com.hedera.mirror.restjava.common.Constants; import com.hedera.mirror.restjava.common.EntityIdNumParameter; import com.hedera.mirror.restjava.common.EntityIdRangeParameter; -import com.hedera.mirror.restjava.common.ParameterNames; import com.hedera.mirror.restjava.common.RangeOperator; import com.hedera.mirror.restjava.dto.NftAllowanceRequest; import com.hedera.mirror.restjava.service.Bound; @@ -74,8 +74,8 @@ void findAllNoMatch() { NftAllowanceRequest.builder() .isOwner(true) .accountId(new EntityIdNumParameter(EntityId.of(owners.get(2) + 1))) - .ownerOrSpenderIds(new Bound(List.of(), false, ParameterNames.ACCOUNT_ID)) - .tokenIds(new Bound(List.of(), false, ParameterNames.TOKEN_ID)) + .ownerOrSpenderIds(new Bound(List.of(), false, Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(), false, Constants.TOKEN_ID)) .limit(10) .order(Direction.ASC) .build(), @@ -90,8 +90,8 @@ void findAllNoMatch() { .ownerOrSpenderIds(new Bound( List.of(new EntityIdRangeParameter(EQ, EntityId.of(spenders.get(2) + 1))), false, - ParameterNames.ACCOUNT_ID)) - .tokenIds(new Bound(List.of(), false, ParameterNames.TOKEN_ID)) + Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(), false, Constants.TOKEN_ID)) .limit(10) .order(Direction.ASC) .build(), @@ -103,12 +103,12 @@ void findAllNoMatch() { NftAllowanceRequest.builder() .isOwner(true) .accountId(new EntityIdNumParameter(EntityId.of(owners.get(0)))) - .ownerOrSpenderIds(new Bound( - List.of(fromIndex(EQ, spenders, 0)), false, ParameterNames.ACCOUNT_ID)) + .ownerOrSpenderIds( + new Bound(List.of(fromIndex(EQ, spenders, 0)), false, Constants.ACCOUNT_ID)) .tokenIds(new Bound( List.of(new EntityIdRangeParameter(EQ, EntityId.of(tokenIds.get(2) + 1))), false, - ParameterNames.TOKEN_ID)) + Constants.TOKEN_ID)) .limit(10) .order(Direction.ASC) .build(), @@ -123,11 +123,11 @@ void findAllNoMatch() { .ownerOrSpenderIds(new Bound( List.of(new EntityIdRangeParameter(GT, EntityId.of(spenders.get(2)))), false, - ParameterNames.ACCOUNT_ID)) + Constants.ACCOUNT_ID)) .tokenIds(new Bound( List.of(new EntityIdRangeParameter(GT, EntityId.of(tokenIds.get(0)))), false, - ParameterNames.TOKEN_ID)) + Constants.TOKEN_ID)) .limit(10) .order(Direction.ASC) .build(), @@ -142,11 +142,11 @@ void findAllNoMatch() { .ownerOrSpenderIds(new Bound( List.of(new EntityIdRangeParameter(LT, EntityId.of(spenders.get(0)))), false, - ParameterNames.ACCOUNT_ID)) + Constants.ACCOUNT_ID)) .tokenIds(new Bound( List.of(new EntityIdRangeParameter(LT, EntityId.of(tokenIds.get(2)))), false, - ParameterNames.TOKEN_ID)) + Constants.TOKEN_ID)) .limit(10) .order(Direction.ASC) .build(), @@ -190,8 +190,8 @@ private void populateTestSpecs() { NftAllowanceRequest.builder() .isOwner(true) .accountId(new EntityIdNumParameter(EntityId.of(owners.get(0)))) - .ownerOrSpenderIds(new Bound(List.of(), false, ParameterNames.ACCOUNT_ID)) - .tokenIds(new Bound(List.of(), false, ParameterNames.TOKEN_ID)) + .ownerOrSpenderIds(new Bound(List.of(), false, Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(), false, Constants.TOKEN_ID)) .limit(4) .order(Direction.ASC) .build(), @@ -201,8 +201,8 @@ private void populateTestSpecs() { NftAllowanceRequest.builder() .isOwner(true) .accountId(new EntityIdNumParameter(EntityId.of(owners.get(0)))) - .ownerOrSpenderIds(new Bound(List.of(), false, ParameterNames.ACCOUNT_ID)) - .tokenIds(new Bound(List.of(), false, ParameterNames.TOKEN_ID)) + .ownerOrSpenderIds(new Bound(List.of(), false, Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(), false, Constants.TOKEN_ID)) .limit(4) .order(Direction.DESC) .build(), @@ -212,8 +212,8 @@ private void populateTestSpecs() { NftAllowanceRequest.builder() .isOwner(false) .accountId(new EntityIdNumParameter(EntityId.of(spenders.get(1)))) - .ownerOrSpenderIds(new Bound(List.of(), false, ParameterNames.ACCOUNT_ID)) - .tokenIds(new Bound(List.of(), false, ParameterNames.TOKEN_ID)) + .ownerOrSpenderIds(new Bound(List.of(), false, Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(), false, Constants.TOKEN_ID)) .limit(4) .order(Direction.ASC) .build(), @@ -223,8 +223,8 @@ private void populateTestSpecs() { NftAllowanceRequest.builder() .isOwner(false) .accountId(new EntityIdNumParameter(EntityId.of(spenders.get(1)))) - .ownerOrSpenderIds(new Bound(List.of(), false, ParameterNames.ACCOUNT_ID)) - .tokenIds(new Bound(List.of(), false, ParameterNames.TOKEN_ID)) + .ownerOrSpenderIds(new Bound(List.of(), false, Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(), false, Constants.TOKEN_ID)) .limit(4) .order(Direction.DESC) .build(), @@ -234,9 +234,9 @@ private void populateTestSpecs() { NftAllowanceRequest.builder() .isOwner(true) .accountId(new EntityIdNumParameter(EntityId.of(owners.get(1)))) - .ownerOrSpenderIds(new Bound( - List.of(fromIndex(EQ, spenders, 0)), false, ParameterNames.ACCOUNT_ID)) - .tokenIds(new Bound(List.of(), false, ParameterNames.TOKEN_ID)) + .ownerOrSpenderIds( + new Bound(List.of(fromIndex(EQ, spenders, 0)), false, Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(), false, Constants.TOKEN_ID)) .limit(4) .order(Direction.ASC) .build(), @@ -246,9 +246,9 @@ private void populateTestSpecs() { NftAllowanceRequest.builder() .isOwner(true) .accountId(new EntityIdNumParameter(EntityId.of(owners.get(1)))) - .ownerOrSpenderIds(new Bound( - List.of(fromIndex(GTE, spenders, 0)), false, ParameterNames.ACCOUNT_ID)) - .tokenIds(new Bound(List.of(), false, ParameterNames.TOKEN_ID)) + .ownerOrSpenderIds( + new Bound(List.of(fromIndex(GTE, spenders, 0)), false, Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(), false, Constants.TOKEN_ID)) .limit(4) .order(Direction.ASC) .build(), @@ -258,10 +258,9 @@ private void populateTestSpecs() { NftAllowanceRequest.builder() .isOwner(true) .accountId(new EntityIdNumParameter(EntityId.of(owners.get(1)))) - .ownerOrSpenderIds(new Bound( - List.of(fromIndex(GTE, spenders, 0)), false, ParameterNames.ACCOUNT_ID)) - .tokenIds( - new Bound(List.of(fromIndex(GTE, tokenIds, 0)), false, ParameterNames.TOKEN_ID)) + .ownerOrSpenderIds( + new Bound(List.of(fromIndex(GTE, spenders, 0)), false, Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(fromIndex(GTE, tokenIds, 0)), false, Constants.TOKEN_ID)) .limit(4) .order(Direction.ASC) .build(), @@ -271,10 +270,9 @@ private void populateTestSpecs() { NftAllowanceRequest.builder() .isOwner(true) .accountId(new EntityIdNumParameter(EntityId.of(owners.get(1)))) - .ownerOrSpenderIds(new Bound( - List.of(fromIndex(GTE, spenders, 0)), false, ParameterNames.ACCOUNT_ID)) - .tokenIds( - new Bound(List.of(fromIndex(GT, tokenIds, 1)), false, ParameterNames.TOKEN_ID)) + .ownerOrSpenderIds( + new Bound(List.of(fromIndex(GTE, spenders, 0)), false, Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(fromIndex(GT, tokenIds, 1)), false, Constants.TOKEN_ID)) .limit(4) .order(Direction.ASC) .build(), @@ -284,10 +282,9 @@ private void populateTestSpecs() { NftAllowanceRequest.builder() .isOwner(true) .accountId(new EntityIdNumParameter(EntityId.of(owners.get(1)))) - .ownerOrSpenderIds(new Bound( - List.of(fromIndex(LTE, spenders, 2)), false, ParameterNames.ACCOUNT_ID)) - .tokenIds( - new Bound(List.of(fromIndex(LT, tokenIds, 2)), false, ParameterNames.TOKEN_ID)) + .ownerOrSpenderIds( + new Bound(List.of(fromIndex(LTE, spenders, 2)), false, Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(fromIndex(LT, tokenIds, 2)), false, Constants.TOKEN_ID)) .limit(4) .order(Direction.DESC) .build(), @@ -300,11 +297,11 @@ private void populateTestSpecs() { .ownerOrSpenderIds(new Bound( List.of(fromIndex(LTE, spenders, 2), fromIndex(GTE, spenders, 0)), false, - ParameterNames.ACCOUNT_ID)) + Constants.ACCOUNT_ID)) .tokenIds(new Bound( List.of(fromIndex(LT, tokenIds, 2), fromIndex(GT, tokenIds, 0)), false, - ParameterNames.TOKEN_ID)) + Constants.TOKEN_ID)) .limit(4) .order(Direction.ASC) .build(), @@ -317,9 +314,8 @@ private void populateTestSpecs() { .ownerOrSpenderIds(new Bound( List.of(fromIndex(LTE, spenders, 2), fromIndex(GT, spenders, 0)), false, - ParameterNames.ACCOUNT_ID)) - .tokenIds( - new Bound(List.of(fromIndex(LTE, tokenIds, 0)), false, ParameterNames.TOKEN_ID)) + Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(fromIndex(LTE, tokenIds, 0)), false, Constants.TOKEN_ID)) .limit(6) .order(Direction.DESC) .build(), @@ -329,10 +325,9 @@ private void populateTestSpecs() { NftAllowanceRequest.builder() .isOwner(true) .accountId(new EntityIdNumParameter(EntityId.of(owners.get(1)))) - .ownerOrSpenderIds(new Bound( - List.of(fromIndex(EQ, spenders, 0)), false, ParameterNames.ACCOUNT_ID)) - .tokenIds( - new Bound(List.of(fromIndex(EQ, tokenIds, 1)), false, ParameterNames.TOKEN_ID)) + .ownerOrSpenderIds( + new Bound(List.of(fromIndex(EQ, spenders, 0)), false, Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(fromIndex(EQ, tokenIds, 1)), false, Constants.TOKEN_ID)) .limit(4) .order(Direction.ASC) .build(), @@ -345,9 +340,8 @@ private void populateTestSpecs() { .ownerOrSpenderIds(new Bound( List.of(fromIndex(GTE, spenders, 1), fromIndex(LTE, spenders, 1)), false, - ParameterNames.ACCOUNT_ID)) - .tokenIds( - new Bound(List.of(fromIndex(EQ, tokenIds, 0)), false, ParameterNames.TOKEN_ID)) + Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(fromIndex(EQ, tokenIds, 0)), false, Constants.TOKEN_ID)) .limit(4) .order(Direction.ASC) .build(), @@ -360,9 +354,8 @@ private void populateTestSpecs() { .ownerOrSpenderIds(new Bound( List.of(fromIndex(GTE, spenders, 1), fromIndex(LTE, spenders, 1)), false, - ParameterNames.ACCOUNT_ID)) - .tokenIds( - new Bound(List.of(fromIndex(GTE, tokenIds, 0)), false, ParameterNames.TOKEN_ID)) + Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(fromIndex(GTE, tokenIds, 0)), false, Constants.TOKEN_ID)) .limit(4) .order(Direction.ASC) .build(), @@ -375,9 +368,8 @@ private void populateTestSpecs() { .ownerOrSpenderIds(new Bound( List.of(fromIndex(GT, spenders, 0), fromIndex(LT, spenders, 2)), false, - ParameterNames.ACCOUNT_ID)) - .tokenIds( - new Bound(List.of(fromIndex(GTE, tokenIds, 0)), false, ParameterNames.TOKEN_ID)) + Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(fromIndex(GTE, tokenIds, 0)), false, Constants.TOKEN_ID)) .limit(4) .order(Direction.ASC) .build(), @@ -392,8 +384,8 @@ private void populateTestSpecs() { new EntityIdRangeParameter(GT, EntityId.of(spenders.get(0) - 1)), new EntityIdRangeParameter(LT, EntityId.of(spenders.get(2) + 1))), false, - ParameterNames.ACCOUNT_ID)) - .tokenIds(new Bound(List.of(), false, ParameterNames.TOKEN_ID)) + Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(), false, Constants.TOKEN_ID)) .limit(4) .order(Direction.ASC) .build(), @@ -406,9 +398,8 @@ private void populateTestSpecs() { .ownerOrSpenderIds(new Bound( List.of(fromIndex(GTE, spenders, 0), fromIndex(LTE, spenders, 2)), false, - ParameterNames.ACCOUNT_ID)) - .tokenIds( - new Bound(List.of(fromIndex(EQ, tokenIds, 0)), false, ParameterNames.TOKEN_ID)) + Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(fromIndex(EQ, tokenIds, 0)), false, Constants.TOKEN_ID)) .limit(4) .order(Direction.ASC) .build(), @@ -421,11 +412,11 @@ private void populateTestSpecs() { .ownerOrSpenderIds(new Bound( List.of(fromIndex(GTE, spenders, 0), fromIndex(LTE, spenders, 2)), false, - ParameterNames.ACCOUNT_ID)) + Constants.ACCOUNT_ID)) .tokenIds(new Bound( List.of(fromIndex(GTE, tokenIds, 1), fromIndex(LTE, tokenIds, 1)), false, - ParameterNames.TOKEN_ID)) + Constants.TOKEN_ID)) .limit(4) .order(Direction.ASC) .build(), @@ -438,11 +429,11 @@ private void populateTestSpecs() { .ownerOrSpenderIds(new Bound( List.of(fromIndex(GTE, spenders, 0), fromIndex(LTE, spenders, 2)), false, - ParameterNames.ACCOUNT_ID)) + Constants.ACCOUNT_ID)) .tokenIds(new Bound( List.of(fromIndex(GT, tokenIds, 0), fromIndex(LT, tokenIds, 2)), false, - ParameterNames.TOKEN_ID)) + Constants.TOKEN_ID)) .limit(4) .order(Direction.ASC) .build(), diff --git a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/repository/TokenAirdropRepositoryTest.java b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/repository/TokenAirdropRepositoryTest.java new file mode 100644 index 00000000000..7a0a587fb5e --- /dev/null +++ b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/repository/TokenAirdropRepositoryTest.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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 com.hedera.mirror.restjava.repository; + +import static com.hedera.mirror.common.domain.token.TokenTypeEnum.FUNGIBLE_COMMON; +import static com.hedera.mirror.common.domain.token.TokenTypeEnum.NON_FUNGIBLE_UNIQUE; +import static org.assertj.core.api.Assertions.assertThat; + +import com.hedera.mirror.common.domain.entity.EntityId; +import com.hedera.mirror.restjava.RestJavaIntegrationTest; +import com.hedera.mirror.restjava.common.Constants; +import com.hedera.mirror.restjava.common.EntityIdNumParameter; +import com.hedera.mirror.restjava.common.EntityIdRangeParameter; +import com.hedera.mirror.restjava.common.RangeOperator; +import com.hedera.mirror.restjava.dto.TokenAirdropRequest; +import com.hedera.mirror.restjava.service.Bound; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.data.domain.Sort.Direction; + +@RequiredArgsConstructor +class TokenAirdropRepositoryTest extends RestJavaIntegrationTest { + + private final TokenAirdropRepository repository; + + @Test + void findById() { + var tokenAirdrop = domainBuilder.tokenAirdrop(FUNGIBLE_COMMON).persist(); + assertThat(repository.findById(tokenAirdrop.getId())).get().isEqualTo(tokenAirdrop); + } + + @Test + void findBySenderId() { + var tokenAirdrop = domainBuilder.tokenAirdrop(FUNGIBLE_COMMON).persist(); + var entityId = EntityId.of(tokenAirdrop.getSenderAccountId()); + var request = TokenAirdropRequest.builder() + .accountId(new EntityIdNumParameter(entityId)) + .build(); + assertThat(repository.findAllOutstanding(request, entityId)).contains(tokenAirdrop); + } + + @Test + void noMatch() { + var tokenAirdrop = domainBuilder.tokenAirdrop(FUNGIBLE_COMMON).persist(); + var entityId = EntityId.of(tokenAirdrop.getSenderAccountId()); + var request = TokenAirdropRequest.builder() + .accountId(new EntityIdNumParameter(entityId)) + .entityIds(new Bound( + List.of(new EntityIdRangeParameter( + RangeOperator.GT, EntityId.of(tokenAirdrop.getReceiverAccountId()))), + true, + Constants.ACCOUNT_ID)) + .build(); + assertThat(repository.findAllOutstanding(request, entityId)).isEmpty(); + } + + @ParameterizedTest + @EnumSource(Direction.class) + void conditionalClausesByDirection(Direction order) { + var sender = domainBuilder.entity().get(); + var receiver = domainBuilder.entity().get(); + var tokenId = 5000L; + + var receiverSpecifiedAirdrop = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(sender.getId()).receiverAccountId(receiver.getId())) + .persist(); + var receiverSpecifiedAirdrop2 = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(sender.getId()).receiverAccountId(receiver.getId())) + .persist(); + var tokenReceiverSpecifiedAirdrop = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(sender.getId()) + .receiverAccountId(receiver.getId()) + .tokenId(tokenId)) + .persist(); + var tokenReceiverSpecifiedAirdrop2 = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(sender.getId()) + .receiverAccountId(receiver.getId()) + .tokenId(tokenId + 1)) + .persist(); + var tokenSpecifiedAirdrop = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> + a.senderAccountId(sender.getId()).receiverAccountId(1).tokenId(tokenId)) + .persist(); + domainBuilder + .tokenAirdrop(NON_FUNGIBLE_UNIQUE) + .customize(a -> a.senderAccountId(sender.getId()) + .receiverAccountId(receiver.getId()) + .serialNumber(5) + .tokenId(tokenId)) + .persist(); + + // Default asc ordering by receiver, tokenId + var allAirdrops = List.of( + tokenSpecifiedAirdrop, + receiverSpecifiedAirdrop, + receiverSpecifiedAirdrop2, + tokenReceiverSpecifiedAirdrop, + tokenReceiverSpecifiedAirdrop2); + var receiverSpecifiedAirdrops = List.of( + receiverSpecifiedAirdrop, + receiverSpecifiedAirdrop2, + tokenReceiverSpecifiedAirdrop, + tokenReceiverSpecifiedAirdrop2); + var tokenReceiverAirdrops = List.of(tokenReceiverSpecifiedAirdrop, tokenReceiverSpecifiedAirdrop2); + var tokenSpecifiedAirdrops = + List.of(tokenSpecifiedAirdrop, tokenReceiverSpecifiedAirdrop, tokenReceiverSpecifiedAirdrop2); + + var orderedAirdrops = order.isAscending() ? allAirdrops : allAirdrops.reversed(); + var request = TokenAirdropRequest.builder() + .accountId(new EntityIdNumParameter(sender.toEntityId())) + .order(order) + .build(); + assertThat(repository.findAllOutstanding(request, sender.toEntityId())) + .containsExactlyElementsOf(orderedAirdrops); + + // With receiver id condition + var receiverAirdrops = order.isAscending() ? receiverSpecifiedAirdrops : receiverSpecifiedAirdrops.reversed(); + request = TokenAirdropRequest.builder() + .accountId(new EntityIdNumParameter(sender.toEntityId())) + .order(order) + .entityIds(new Bound( + List.of(new EntityIdRangeParameter(RangeOperator.EQ, EntityId.of(receiver.getId()))), + true, + Constants.ACCOUNT_ID)) + .build(); + assertThat(repository.findAllOutstanding(request, sender.toEntityId())) + .containsExactlyElementsOf(receiverAirdrops); + + // With token id and receiver condition + var tokenAirdrops = order.isAscending() ? tokenReceiverAirdrops : tokenReceiverAirdrops.reversed(); + request = TokenAirdropRequest.builder() + .accountId(new EntityIdNumParameter(sender.toEntityId())) + .entityIds(new Bound( + List.of(new EntityIdRangeParameter(RangeOperator.EQ, EntityId.of(receiver.getId()))), + true, + Constants.ACCOUNT_ID)) + .order(order) + .tokenIds(new Bound( + List.of(new EntityIdRangeParameter(RangeOperator.GTE, EntityId.of(tokenId))), + false, + Constants.TOKEN_ID)) + .build(); + assertThat(repository.findAllOutstanding(request, sender.toEntityId())) + .containsExactlyElementsOf(tokenAirdrops); + + // With token id condition as primary sort field and with receiver id + request = TokenAirdropRequest.builder() + .accountId(new EntityIdNumParameter(sender.toEntityId())) + .order(order) + .entityIds(new Bound( + List.of(new EntityIdRangeParameter(RangeOperator.GTE, EntityId.of(receiver.getId()))), + false, + Constants.ACCOUNT_ID)) + .tokenIds(new Bound( + List.of(new EntityIdRangeParameter(RangeOperator.GTE, EntityId.of(tokenId))), + true, + Constants.TOKEN_ID)) + .build(); + assertThat(repository.findAllOutstanding(request, sender.toEntityId())) + .containsExactlyElementsOf(tokenAirdrops); + + // With token id condition but no receiver id + var tokenIdAirdrops = order.isAscending() ? tokenSpecifiedAirdrops : tokenSpecifiedAirdrops.reversed(); + request = TokenAirdropRequest.builder() + .accountId(new EntityIdNumParameter(sender.toEntityId())) + .order(order) + .tokenIds(new Bound( + List.of(new EntityIdRangeParameter(RangeOperator.GTE, EntityId.of(tokenId))), + false, + Constants.TOKEN_ID)) + .build(); + assertThat(repository.findAllOutstanding(request, sender.toEntityId())) + .containsExactlyElementsOf(tokenIdAirdrops); + } +} diff --git a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/service/NftAllowanceServiceTest.java b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/service/NftAllowanceServiceTest.java index da96a781e7f..a8ff4fca16d 100644 --- a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/service/NftAllowanceServiceTest.java +++ b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/service/NftAllowanceServiceTest.java @@ -22,11 +22,11 @@ import com.hedera.mirror.common.domain.entity.EntityId; import com.hedera.mirror.common.domain.entity.NftAllowance; import com.hedera.mirror.restjava.RestJavaIntegrationTest; +import com.hedera.mirror.restjava.common.Constants; import com.hedera.mirror.restjava.common.EntityIdAliasParameter; import com.hedera.mirror.restjava.common.EntityIdEvmAddressParameter; import com.hedera.mirror.restjava.common.EntityIdNumParameter; import com.hedera.mirror.restjava.common.EntityIdRangeParameter; -import com.hedera.mirror.restjava.common.ParameterNames; import com.hedera.mirror.restjava.common.RangeOperator; import com.hedera.mirror.restjava.dto.NftAllowanceRequest; import java.util.List; @@ -54,8 +54,8 @@ void getNftAllowancesForOrderAsc(boolean owner) { .isOwner(owner) .limit(2) .accountId(new EntityIdNumParameter(ACCOUNT_ID)) - .ownerOrSpenderIds(new Bound(List.of(), false, ParameterNames.ACCOUNT_ID)) - .tokenIds(new Bound(List.of(), false, ParameterNames.TOKEN_ID)) + .ownerOrSpenderIds(new Bound(List.of(), false, Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(), false, Constants.TOKEN_ID)) .order(Sort.Direction.ASC) .build(); var response = service.getNftAllowances(request); @@ -75,13 +75,9 @@ void getNftAllowancesWithAlias() { .limit(2) .accountId(new EntityIdAliasParameter(0, 0, entity.getAlias())) .ownerOrSpenderIds(new Bound( - List.of(new EntityIdRangeParameter(RangeOperator.GTE, accountId)), - true, - ParameterNames.ACCOUNT_ID)) + List.of(new EntityIdRangeParameter(RangeOperator.GTE, accountId)), true, Constants.ACCOUNT_ID)) .tokenIds(new Bound( - List.of(new EntityIdRangeParameter(RangeOperator.GT, accountId)), - false, - ParameterNames.TOKEN_ID)) + List.of(new EntityIdRangeParameter(RangeOperator.GT, accountId)), false, Constants.TOKEN_ID)) .order(Sort.Direction.ASC) .build(); var response = service.getNftAllowances(request); @@ -101,13 +97,9 @@ void getNftAllowancesWithEvmAddress() { .limit(2) .accountId(new EntityIdEvmAddressParameter(0, 0, entity.getEvmAddress())) .ownerOrSpenderIds(new Bound( - List.of(new EntityIdRangeParameter(RangeOperator.GTE, accountId)), - true, - ParameterNames.ACCOUNT_ID)) + List.of(new EntityIdRangeParameter(RangeOperator.GTE, accountId)), true, Constants.ACCOUNT_ID)) .tokenIds(new Bound( - List.of(new EntityIdRangeParameter(RangeOperator.GT, accountId)), - false, - ParameterNames.TOKEN_ID)) + List.of(new EntityIdRangeParameter(RangeOperator.GT, accountId)), false, Constants.TOKEN_ID)) .order(Sort.Direction.ASC) .build(); var response = service.getNftAllowances(request); @@ -133,13 +125,9 @@ void getNftAllowancesForOrderDescOwner() { .limit(2) .accountId(new EntityIdNumParameter(ACCOUNT_ID)) .ownerOrSpenderIds(new Bound( - List.of(new EntityIdRangeParameter(RangeOperator.GTE, ACCOUNT_ID)), - true, - ParameterNames.ACCOUNT_ID)) + List.of(new EntityIdRangeParameter(RangeOperator.GTE, ACCOUNT_ID)), true, Constants.ACCOUNT_ID)) .tokenIds(new Bound( - List.of(new EntityIdRangeParameter(RangeOperator.GT, ACCOUNT_ID)), - false, - ParameterNames.TOKEN_ID)) + List.of(new EntityIdRangeParameter(RangeOperator.GT, ACCOUNT_ID)), false, Constants.TOKEN_ID)) .order(Sort.Direction.DESC) .build(); @@ -167,13 +155,9 @@ void getNftAllowancesForOrderDescSpender() { .limit(2) .accountId(new EntityIdNumParameter(ACCOUNT_ID)) .ownerOrSpenderIds(new Bound( - List.of(new EntityIdRangeParameter(RangeOperator.GTE, ACCOUNT_ID)), - true, - ParameterNames.ACCOUNT_ID)) + List.of(new EntityIdRangeParameter(RangeOperator.GTE, ACCOUNT_ID)), true, Constants.ACCOUNT_ID)) .tokenIds(new Bound( - List.of(new EntityIdRangeParameter(RangeOperator.GTE, ACCOUNT_ID)), - false, - ParameterNames.TOKEN_ID)) + List.of(new EntityIdRangeParameter(RangeOperator.GTE, ACCOUNT_ID)), false, Constants.TOKEN_ID)) .order(Sort.Direction.DESC) .build(); @@ -204,11 +188,11 @@ void getNftAllowancesForGteOwner() { .ownerOrSpenderIds(new Bound( List.of(new EntityIdRangeParameter(RangeOperator.GTE, EntityId.of(nftAllowance1.getSpender()))), true, - ParameterNames.ACCOUNT_ID)) + Constants.ACCOUNT_ID)) .tokenIds(new Bound( List.of(new EntityIdRangeParameter(RangeOperator.GTE, EntityId.of(nftAllowance1.getTokenId()))), false, - ParameterNames.TOKEN_ID)) + Constants.TOKEN_ID)) .order(Sort.Direction.ASC) .build(); var response = service.getNftAllowances(request); @@ -235,11 +219,11 @@ void getNftAllowancesForGteSpender() { .ownerOrSpenderIds(new Bound( List.of(new EntityIdRangeParameter(RangeOperator.GTE, EntityId.of(nftAllowance1.getOwner()))), true, - ParameterNames.ACCOUNT_ID)) + Constants.ACCOUNT_ID)) .tokenIds(new Bound( List.of(new EntityIdRangeParameter(RangeOperator.GTE, EntityId.of(nftAllowance1.getTokenId()))), false, - ParameterNames.TOKEN_ID)) + Constants.TOKEN_ID)) .order(Sort.Direction.ASC) .build(); var response = service.getNftAllowances(request); @@ -268,11 +252,11 @@ void getNftAllowancesForOptimizedRange() { new EntityIdRangeParameter(RangeOperator.GTE, EntityId.of(nftAllowance1.getOwner())), new EntityIdRangeParameter(RangeOperator.LTE, EntityId.of(nftAllowance1.getOwner()))), true, - ParameterNames.ACCOUNT_ID)) + Constants.ACCOUNT_ID)) .tokenIds(new Bound( List.of(new EntityIdRangeParameter(RangeOperator.GTE, EntityId.of(nftAllowance1.getTokenId()))), false, - ParameterNames.TOKEN_ID)) + Constants.TOKEN_ID)) .order(Sort.Direction.ASC) .build(); var response = service.getNftAllowances(request); @@ -297,11 +281,11 @@ void getNftAllowancesForTokenNeedsOwnerOrSpenderIdEq() { .ownerOrSpenderIds(new Bound( List.of(new EntityIdRangeParameter(RangeOperator.LT, EntityId.of(nftAllowance1.getOwner()))), true, - ParameterNames.ACCOUNT_ID)) + Constants.ACCOUNT_ID)) .tokenIds(new Bound( List.of(new EntityIdRangeParameter(RangeOperator.LT, EntityId.of(nftAllowance1.getTokenId()))), false, - ParameterNames.TOKEN_ID)) + Constants.TOKEN_ID)) .order(Sort.Direction.ASC) .build(); assertThatThrownBy(() -> service.getNftAllowances(request)) @@ -315,11 +299,11 @@ void getNftAllowancesForTokenNeedsOwnerOrSpenderIdEq() { .ownerOrSpenderIds(new Bound( List.of(new EntityIdRangeParameter(RangeOperator.GT, EntityId.of(nftAllowance1.getOwner()))), true, - ParameterNames.ACCOUNT_ID)) + Constants.ACCOUNT_ID)) .tokenIds(new Bound( List.of(new EntityIdRangeParameter(RangeOperator.GT, EntityId.of(nftAllowance1.getTokenId()))), false, - ParameterNames.TOKEN_ID)) + Constants.TOKEN_ID)) .order(Sort.Direction.ASC) .build(); assertThatThrownBy(() -> service.getNftAllowances(request1)) @@ -348,11 +332,11 @@ void getNftAllowancesForRepeatedParameter() { new EntityIdRangeParameter( RangeOperator.LT, EntityId.of(nftAllowance1.getOwner() + 10))), true, - ParameterNames.ACCOUNT_ID)) + Constants.ACCOUNT_ID)) .tokenIds(new Bound( List.of(new EntityIdRangeParameter(RangeOperator.LT, EntityId.of(nftAllowance1.getTokenId()))), false, - ParameterNames.TOKEN_ID)) + Constants.TOKEN_ID)) .order(Sort.Direction.ASC) .build(); assertThatThrownBy(() -> service.getNftAllowances(request)) @@ -381,11 +365,11 @@ void getNftAllowancesEqAndRangeParameter() { new EntityIdRangeParameter( RangeOperator.EQ, EntityId.of(nftAllowance1.getOwner() - 10))), true, - ParameterNames.ACCOUNT_ID)) + Constants.ACCOUNT_ID)) .tokenIds(new Bound( List.of(new EntityIdRangeParameter(RangeOperator.LT, EntityId.of(nftAllowance1.getTokenId()))), false, - ParameterNames.TOKEN_ID)) + Constants.TOKEN_ID)) .order(Sort.Direction.ASC) .build(); assertThatThrownBy(() -> service.getNftAllowances(request)) @@ -408,11 +392,11 @@ void getNftAllowancesForOwnerOrSpenderIdNotPresent() { .isOwner(false) .limit(2) .accountId(new EntityIdNumParameter(ACCOUNT_ID)) - .ownerOrSpenderIds(new Bound(List.of(), true, ParameterNames.ACCOUNT_ID)) + .ownerOrSpenderIds(new Bound(List.of(), true, Constants.ACCOUNT_ID)) .tokenIds(new Bound( List.of(new EntityIdRangeParameter(RangeOperator.GTE, EntityId.of(nftAllowance1.getTokenId()))), false, - ParameterNames.TOKEN_ID)) + Constants.TOKEN_ID)) .order(Sort.Direction.ASC) .build(); assertThatThrownBy(() -> service.getNftAllowances(request)) @@ -439,8 +423,8 @@ void getNftAllowancesForInvalidOperatorPresent() { .ownerOrSpenderIds(new Bound( List.of(new EntityIdRangeParameter(RangeOperator.NE, EntityId.of(nftAllowance1.getSpender()))), true, - ParameterNames.ACCOUNT_ID)) - .tokenIds(new Bound(List.of(), false, ParameterNames.TOKEN_ID)) + Constants.ACCOUNT_ID)) + .tokenIds(new Bound(List.of(), false, Constants.TOKEN_ID)) .order(Sort.Direction.ASC) .build(); assertThatThrownBy(() -> service.getNftAllowances(request)) diff --git a/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/service/TokenAirdropServiceTest.java b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/service/TokenAirdropServiceTest.java new file mode 100644 index 00000000000..dde37d63140 --- /dev/null +++ b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/service/TokenAirdropServiceTest.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * 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 com.hedera.mirror.restjava.service; + +import static com.hedera.mirror.common.domain.token.TokenTypeEnum.FUNGIBLE_COMMON; +import static org.assertj.core.api.Assertions.assertThat; + +import com.hedera.mirror.common.domain.entity.EntityId; +import com.hedera.mirror.restjava.RestJavaIntegrationTest; +import com.hedera.mirror.restjava.common.EntityIdAliasParameter; +import com.hedera.mirror.restjava.common.EntityIdEvmAddressParameter; +import com.hedera.mirror.restjava.common.EntityIdNumParameter; +import com.hedera.mirror.restjava.dto.TokenAirdropRequest; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.Test; + +@RequiredArgsConstructor +class TokenAirdropServiceTest extends RestJavaIntegrationTest { + + private final TokenAirdropService service; + private static final EntityId RECEIVER = EntityId.of(1000L); + private static final EntityId SENDER = EntityId.of(1001L); + private static final EntityId TOKEN_ID = EntityId.of(5000L); + + @Test + void getOutstanding() { + var fungibleAirdrop = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.amount(100L) + .receiverAccountId(RECEIVER.getId()) + .senderAccountId(SENDER.getId()) + .tokenId(TOKEN_ID.getId())) + .persist(); + + var request = TokenAirdropRequest.builder() + .accountId(new EntityIdNumParameter(SENDER)) + .build(); + var response = service.getOutstandingAirdrops(request); + assertThat(response).containsExactly(fungibleAirdrop); + } + + @Test + void getOutstandingByAlias() { + var entity = domainBuilder.entity().persist(); + var tokenAirdrop = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(entity.getId())) + .persist(); + var request = TokenAirdropRequest.builder() + .accountId(new EntityIdAliasParameter(entity.getShard(), entity.getRealm(), entity.getAlias())) + .build(); + var response = service.getOutstandingAirdrops(request); + assertThat(response).containsExactly(tokenAirdrop); + } + + @Test + void getOutstandingByEvmAddress() { + var entity = domainBuilder.entity().persist(); + var tokenAirdrop = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(entity.getId())) + .persist(); + var request = TokenAirdropRequest.builder() + .accountId( + new EntityIdEvmAddressParameter(entity.getShard(), entity.getRealm(), entity.getEvmAddress())) + .build(); + var response = service.getOutstandingAirdrops(request); + assertThat(response).containsExactly(tokenAirdrop); + } + + @Test + void getOutstandingNotFound() { + var request = TokenAirdropRequest.builder() + .accountId(new EntityIdNumParameter(SENDER)) + .build(); + var response = service.getOutstandingAirdrops(request); + assertThat(response).isEmpty(); + } +} diff --git a/hedera-mirror-rest/api/v1/openapi.yml b/hedera-mirror-rest/api/v1/openapi.yml index 7ff66ad7bba..94e8a8969bb 100644 --- a/hedera-mirror-rest/api/v1/openapi.yml +++ b/hedera-mirror-rest/api/v1/openapi.yml @@ -162,6 +162,31 @@ paths: $ref: "#/components/responses/NotFoundError" tags: - accounts + /api/v1/accounts/{idOrAliasOrEvmAddress}/airdrops/outstanding: + get: + summary: Get get outstanding fungible token airdrops sent by an account + description: | + Returns outstanding fungible token airdrops that have been sent by an account. This API is currently under development. Support for NFT airdrops will be added in a future release. + operationId: getOutstandingTokenAirdrops + parameters: + - $ref: "#/components/parameters/accountIdOrAliasOrEvmAddressPathParam" + - $ref: "#/components/parameters/limitQueryParam" + - $ref: "#/components/parameters/orderQueryParam" + - $ref: "#/components/parameters/receiverIdQueryParam" + - $ref: "#/components/parameters/tokenIdQueryParam" + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/TokenAirdropsResponse" + 400: + $ref: "#/components/responses/InvalidParameterError" + 404: + $ref: "#/components/responses/NotFoundError" + tags: + - airdrops /api/v1/accounts/{idOrAliasOrEvmAddress}/allowances/crypto: get: summary: Get crypto allowances for an account info @@ -1654,6 +1679,13 @@ components: $ref: "#/components/schemas/StakingReward" links: $ref: "#/components/schemas/Links" + TokenAirdropsResponse: + type: object + properties: + airdrops: + $ref: "#/components/schemas/TokenAirdrops" + links: + $ref: "#/components/schemas/Links" TokenAllowancesResponse: type: object properties: @@ -3306,6 +3338,44 @@ components: symbol: FIRSTMOVERLPDJH token_id: 0.0.1 type: FUNGIBLE_COMMON + TokenAirdrop: + type: object + properties: + amount: + format: int64 + type: integer + receiver_id: + $ref: "#/components/schemas/EntityId" + sender_id: + $ref: "#/components/schemas/EntityId" + serial_number: + example: 1 + format: int64 + nullable: true + type: integer + timestamp: + $ref: "#/components/schemas/TimestampRange" + token_id: + $ref: "#/components/schemas/EntityId" + example: + amount: 10 + receiver_id: "0.0.15" + sender_id: "0.0.10" + serial_number: null + timestamp: + from: "1651560386.060890949" + to: "1651560386.661997287" + token_id: "0.0.99" + required: + - amount + - receiver_id + - sender_id + - timestamp + - token_id + TokenAirdrops: + type: array + items: + $ref: "#/components/schemas/TokenAirdrop" TokenAllowance: allOf: - $ref: "#/components/schemas/Allowance" @@ -4384,6 +4454,40 @@ components: example: 3c3d546321ff6f63d701d2ec5c277095874e19f4a235bee1e6bb19258bf362be schema: type: string + receiverIdQueryParam: + name: receiver.id + description: The ID of the receiver to return information for + in: query + examples: + noValue: + summary: -- + value: "" + entityNumNoOperator: + summary: Example of entityNum equals with no operator + value: 100 + idNoOperator: + summary: Example of id equals with no operator + value: 0.0.100 + entityNumEqOperator: + summary: Example of entityNum equals operator + value: eq:200 + idEqOperator: + summary: Example of id equals operator + value: eq:0.0.200 + idGtOperator: + summary: Example of id greather than operator + value: gt:0.0.200 + idGteOperator: + summary: Example of id greather than or equal to operator + value: gte:0.0.200 + idLtOperator: + summary: Example of id less than operator + value: lt:0.0.200 + idLteOperator: + summary: Example of id less than or equal to operator + value: lte:0.0.200 + schema: + $ref: "#/components/schemas/EntityIdQuery" scheduledQueryParam: name: scheduled in: query