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 index 7088e15ef01..6d5853cdd16 100644 --- 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 @@ -20,7 +20,10 @@ 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.SENDER_ID; import static com.hedera.mirror.restjava.common.Constants.TOKEN_ID; +import static com.hedera.mirror.restjava.dto.TokenAirdropRequest.AirdropRequestType.OUTSTANDING; +import static com.hedera.mirror.restjava.dto.TokenAirdropRequest.AirdropRequestType.PENDING; import com.google.common.collect.ImmutableSortedMap; import com.hedera.mirror.rest.model.TokenAirdrop; @@ -29,6 +32,7 @@ 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.dto.TokenAirdropRequest.AirdropRequestType; import com.hedera.mirror.restjava.mapper.TokenAirdropMapper; import com.hedera.mirror.restjava.service.Bound; import com.hedera.mirror.restjava.service.TokenAirdropService; @@ -55,6 +59,7 @@ public class TokenAirdropsController { private static final Function> EXTRACTOR = tokenAirdrop -> ImmutableSortedMap.of( RECEIVER_ID, tokenAirdrop.getReceiverId(), + SENDER_ID, tokenAirdrop.getSenderId(), TOKEN_ID, tokenAirdrop.getTokenId()); private final LinkFactory linkFactory; @@ -68,16 +73,41 @@ TokenAirdropsResponse getOutstandingAirdrops( @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 entityIdsBound = new Bound(receiverIds, true, ACCOUNT_ID); + return processRequest(id, entityIdsBound, limit, order, tokenIds, OUTSTANDING, RECEIVER_ID); + } + + @GetMapping(value = "/pending") + TokenAirdropsResponse getPendingAirdrops( + @PathVariable EntityIdParameter id, + @RequestParam(defaultValue = DEFAULT_LIMIT) @Positive @Max(MAX_LIMIT) int limit, + @RequestParam(defaultValue = "asc") Sort.Direction order, + @RequestParam(name = SENDER_ID, required = false) @Size(max = 2) List senderIds, + @RequestParam(name = TOKEN_ID, required = false) @Size(max = 2) List tokenIds) { + var entityIdsBound = new Bound(senderIds, true, ACCOUNT_ID); + return processRequest(id, entityIdsBound, limit, order, tokenIds, PENDING, SENDER_ID); + } + + private TokenAirdropsResponse processRequest( + EntityIdParameter id, + Bound entityIdsBound, + int limit, + Sort.Direction order, + List tokenIds, + AirdropRequestType type, + String primarySortField) { var request = TokenAirdropRequest.builder() .accountId(id) - .entityIds(new Bound(receiverIds, true, ACCOUNT_ID)) + .entityIds(entityIdsBound) .limit(limit) .order(order) .tokenIds(new Bound(tokenIds, false, TOKEN_ID)) + .type(type) .build(); - var response = service.getOutstandingAirdrops(request); + + var response = service.getAirdrops(request); var airdrops = tokenAirdropMapper.map(response); - var sort = Sort.by(order, RECEIVER_ID, TOKEN_ID); + var sort = Sort.by(order, primarySortField, 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 index 494d83d744e..6375cf07ee5 100644 --- 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 @@ -16,10 +16,15 @@ package com.hedera.mirror.restjava.dto; +import static com.hedera.mirror.restjava.jooq.domain.Tables.TOKEN_AIRDROP; + import com.hedera.mirror.restjava.common.EntityIdParameter; import com.hedera.mirror.restjava.service.Bound; import lombok.Builder; import lombok.Data; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.jooq.Field; import org.springframework.data.domain.Sort; @Data @@ -39,4 +44,24 @@ public class TokenAirdropRequest { private Bound entityIds; private Bound tokenIds; + + @Builder.Default + private AirdropRequestType type = AirdropRequestType.OUTSTANDING; + + @Getter + @RequiredArgsConstructor + public enum AirdropRequestType { + OUTSTANDING(TOKEN_AIRDROP.SENDER_ACCOUNT_ID, TOKEN_AIRDROP.RECEIVER_ACCOUNT_ID), + PENDING(TOKEN_AIRDROP.RECEIVER_ACCOUNT_ID, TOKEN_AIRDROP.SENDER_ACCOUNT_ID); + + // The base field is the conditional clause for the base DB query. + // The base field is the path parameter accountId, which is Sender Id for Outstanding Airdrops and Receiver Id + // for Pending Airdrops + private final Field baseField; + + // The primary field is the primary sort field for the DB query. + // The primary field is the optional query parameter 'entityIds', which is Receiver Id for Outstanding Airdrops + // and Sender Id for Pending Airdrops + private final Field primaryField; + } } 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 index 50b7a1868c4..161ef00b40a 100644 --- 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 @@ -25,5 +25,5 @@ public interface TokenAirdropRepositoryCustom extends JooqRepository { @NotNull - Collection findAllOutstanding(TokenAirdropRequest request, EntityId accountId); + Collection findAll(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 index 13c308631f3..1ab12ab203c 100644 --- 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 @@ -17,11 +17,14 @@ package com.hedera.mirror.restjava.repository; import static com.hedera.mirror.restjava.common.RangeOperator.EQ; +import static com.hedera.mirror.restjava.dto.TokenAirdropRequest.AirdropRequestType.OUTSTANDING; +import static com.hedera.mirror.restjava.dto.TokenAirdropRequest.AirdropRequestType.PENDING; 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.dto.TokenAirdropRequest.AirdropRequestType; import com.hedera.mirror.restjava.jooq.domain.enums.AirdropState; import jakarta.inject.Named; import java.util.Collection; @@ -30,6 +33,7 @@ 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; @@ -38,20 +42,28 @@ 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())); + private static final Map>>> SORT_ORDERS = Map.of( + OUTSTANDING, + Map.of( + Direction.ASC, List.of(OUTSTANDING.getPrimaryField().asc(), TOKEN_AIRDROP.TOKEN_ID.asc()), + Direction.DESC, + List.of(OUTSTANDING.getPrimaryField().desc(), TOKEN_AIRDROP.TOKEN_ID.desc())), + PENDING, + Map.of( + Direction.ASC, List.of(PENDING.getPrimaryField().asc(), TOKEN_AIRDROP.TOKEN_ID.asc()), + Direction.DESC, List.of(PENDING.getPrimaryField().desc(), TOKEN_AIRDROP.TOKEN_ID.desc()))); @Override - public Collection findAllOutstanding(TokenAirdropRequest request, EntityId accountId) { - var fieldBounds = getFieldBound(request, true); - var condition = getBaseCondition(accountId, true) + public Collection findAll(TokenAirdropRequest request, EntityId accountId) { + var type = request.getType(); + var fieldBounds = getFieldBound(request); + var condition = getBaseCondition(accountId, type.getBaseField()) .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()); + var order = SORT_ORDERS.get(type).get(request.getOrder()); return dslContext .selectFrom(TOKEN_AIRDROP) .where(condition) @@ -60,17 +72,14 @@ public Collection findAllOutstanding(TokenAirdropRequest request, .fetchInto(TokenAirdrop.class); } - private ConditionalFieldBounds getFieldBound(TokenAirdropRequest request, boolean outstanding) { - var primaryField = outstanding ? TOKEN_AIRDROP.RECEIVER_ACCOUNT_ID : TOKEN_AIRDROP.SENDER_ACCOUNT_ID; + private ConditionalFieldBounds getFieldBound(TokenAirdropRequest request) { + var primaryField = request.getType().getPrimaryField(); 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()); + private Condition getBaseCondition(EntityId accountId, Field baseField) { + return getCondition(baseField, EQ, accountId.getId()); } } 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 index 7eff0d62585..e7475b259be 100644 --- 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 @@ -22,5 +22,5 @@ public interface TokenAirdropService { - Collection getOutstandingAirdrops(TokenAirdropRequest request); + Collection getAirdrops(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 index 8e56a4da857..755a6e7a4b2 100644 --- 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 @@ -30,8 +30,8 @@ public class TokenAirdropServiceImpl implements TokenAirdropService { private final EntityService entityService; private final TokenAirdropRepository repository; - public Collection getOutstandingAirdrops(TokenAirdropRequest request) { + public Collection getAirdrops(TokenAirdropRequest request) { var id = entityService.lookup(request.getAccountId()); - return repository.findAllOutstanding(request, id); + return repository.findAll(request, id); } } 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 index 2ad1917772d..b38f7ac9464 100644 --- 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 @@ -428,6 +428,387 @@ void invalidTokenId(String tokenId) { } } + @DisplayName("/api/v1/accounts/{id}/airdrops/pending") + @Nested + class PendingTokenAirdropsEndpointTest extends EndpointTest { + + @Override + protected String getUrl() { + return "accounts/{id}/airdrops/pending"; + } + + @Override + protected RequestHeadersSpec defaultRequest(RequestHeadersUriSpec uriSpec) { + var tokenAirdrop = domainBuilder.tokenAirdrop(FUNGIBLE_COMMON).persist(); + return uriSpec.uri("", tokenAirdrop.getReceiverAccountId()); + } + + @ValueSource(strings = {"1000", "0.1000", "0.0.1000"}) + @ParameterizedTest + void entityId(String id) { + // Given + var tokenAirdrop = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.receiverAccountId(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.receiverAccountId(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.receiverAccountId(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.senderAccountId(sender)) + .persist(); + domainBuilder + .tokenAirdrop(NON_FUNGIBLE_UNIQUE) + .customize(a -> a.senderAccountId(sender)) + .persist(); + + var uriParams = "?limit=1&sender.id=gte:%s&order=desc".formatted(sender); + var baseLink = "/api/v1/accounts/%d/airdrops/pending".formatted(receiver); + + // When + var result = restClient.get().uri(uriParams, receiver).retrieve().body(TokenAirdropsResponse.class); + var nextParams = "?limit=1&sender.id=gte:1000&sender.id=lte:0.0.1000&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, receiver).retrieve().body(TokenAirdropsResponse.class); + + // Then + nextParams = "?limit=1&sender.id=gte:1000&sender.id=lte:0.0.1000&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, receiver).retrieve().body(TokenAirdropsResponse.class); + + // Then + nextParams = "?limit=1&sender.id=gte:1000&sender.id=lte:0.0.1000&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, receiver).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(3L) + .receiverAccountId(entity.getId()) + .tokenId(5L)) + .persist(); + var airdrop2 = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(3L) + .receiverAccountId(entity.getId()) + .tokenId(6L)) + .persist(); + var airdrop3 = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(4L) + .receiverAccountId(entity.getId()) + .tokenId(5L)) + .persist(); + + var baseLink = "/api/v1/accounts/%d/airdrops/pending".formatted(entity.getId()); + var nextParams = "?limit=1&sender.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&sender.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&sender.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&sender.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(2L) + .receiverAccountId(entity.getId()) + .tokenId(5L)) + .persist(); + var airdrop2 = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(3L) + .receiverAccountId(entity.getId()) + .tokenId(5L)) + .persist(); + domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(3L) + .receiverAccountId(entity.getId()) + .tokenId(6L)) + .persist(); + domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> a.senderAccountId(4L) + .receiverAccountId(entity.getId()) + .tokenId(5L)) + .persist(); + + var baseLink = "/api/v1/accounts/%d/airdrops/pending".formatted(entity.getId()); + var uriParams = "?limit=1&sender.id=gte:0.0.1&sender.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&sender.id=lt:0.0.4&sender.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&sender.id=lt:0.0.4&sender.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("?sender.id={accountId}", "0.0.1001", accountId) + .retrieve() + .body(TokenAirdropsResponse.class); + + // Then + validateError( + callable, + HttpClientErrorException.BadRequest.class, + "Failed to convert 'sender.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/repository/TokenAirdropRepositoryTest.java b/hedera-mirror-rest-java/src/test/java/com/hedera/mirror/restjava/repository/TokenAirdropRepositoryTest.java index 7a0a587fb5e..7280ed9a260 100644 --- 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 @@ -18,6 +18,8 @@ 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 com.hedera.mirror.restjava.dto.TokenAirdropRequest.AirdropRequestType.OUTSTANDING; +import static com.hedera.mirror.restjava.dto.TokenAirdropRequest.AirdropRequestType.PENDING; import static org.assertj.core.api.Assertions.assertThat; import com.hedera.mirror.common.domain.entity.EntityId; @@ -27,12 +29,15 @@ 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.dto.TokenAirdropRequest.AirdropRequestType; import com.hedera.mirror.restjava.service.Bound; import java.util.List; +import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.data.domain.Sort.Direction; @RequiredArgsConstructor @@ -53,7 +58,18 @@ void findBySenderId() { var request = TokenAirdropRequest.builder() .accountId(new EntityIdNumParameter(entityId)) .build(); - assertThat(repository.findAllOutstanding(request, entityId)).contains(tokenAirdrop); + assertThat(repository.findAll(request, entityId)).contains(tokenAirdrop); + } + + @Test + void findByReceiverId() { + var tokenAirdrop = domainBuilder.tokenAirdrop(FUNGIBLE_COMMON).persist(); + var entityId = EntityId.of(tokenAirdrop.getReceiverAccountId()); + var request = TokenAirdropRequest.builder() + .accountId(new EntityIdNumParameter(entityId)) + .type(PENDING) + .build(); + assertThat(repository.findAll(request, entityId)).contains(tokenAirdrop); } @Test @@ -68,12 +84,13 @@ void noMatch() { true, Constants.ACCOUNT_ID)) .build(); - assertThat(repository.findAllOutstanding(request, entityId)).isEmpty(); + assertThat(repository.findAll(request, entityId)).isEmpty(); } @ParameterizedTest - @EnumSource(Direction.class) - void conditionalClausesByDirection(Direction order) { + @MethodSource("provideArguments") + void conditionalClausesByDirection(Direction order, AirdropRequestType type) { + // Setup var sender = domainBuilder.entity().get(); var receiver = domainBuilder.entity().get(); var tokenId = 5000L; @@ -98,11 +115,16 @@ void conditionalClausesByDirection(Direction order) { .receiverAccountId(receiver.getId()) .tokenId(tokenId + 1)) .persist(); - var tokenSpecifiedAirdrop = domainBuilder + var senderTokenSpecifiedAirdrop = domainBuilder .tokenAirdrop(FUNGIBLE_COMMON) .customize(a -> a.senderAccountId(sender.getId()).receiverAccountId(1).tokenId(tokenId)) .persist(); + var receiverTokenSpecifiedAirdrop = domainBuilder + .tokenAirdrop(FUNGIBLE_COMMON) + .customize(a -> + a.senderAccountId(1).receiverAccountId(receiver.getId()).tokenId(tokenId)) + .persist(); domainBuilder .tokenAirdrop(NON_FUNGIBLE_UNIQUE) .customize(a -> a.senderAccountId(sender.getId()) @@ -111,87 +133,114 @@ void conditionalClausesByDirection(Direction order) { .tokenId(tokenId)) .persist(); - // Default asc ordering by receiver, tokenId - var allAirdrops = List.of( - tokenSpecifiedAirdrop, + // Default asc ordering by receiver, tokenId for outstanding airdrops + var allOutstandingAirdrops = List.of( + senderTokenSpecifiedAirdrop, receiverSpecifiedAirdrop, receiverSpecifiedAirdrop2, tokenReceiverSpecifiedAirdrop, tokenReceiverSpecifiedAirdrop2); - var receiverSpecifiedAirdrops = List.of( + // Default asc ordering by sender, tokenId for pending airdrops + var allPendingAirdrops = List.of( + receiverTokenSpecifiedAirdrop, + receiverSpecifiedAirdrop, + receiverSpecifiedAirdrop2, + tokenReceiverSpecifiedAirdrop, + tokenReceiverSpecifiedAirdrop2); + var outstandingReceiverSpecifiedAirdrops = List.of( + receiverSpecifiedAirdrop, + receiverSpecifiedAirdrop2, + tokenReceiverSpecifiedAirdrop, + tokenReceiverSpecifiedAirdrop2); + var pendingSenderSpecifiedAirdrops = 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 tokenReceiverSpecifiedAirdrops = List.of(tokenReceiverSpecifiedAirdrop, tokenReceiverSpecifiedAirdrop2); + var outstandingTokenSpecifiedAirdrops = + List.of(senderTokenSpecifiedAirdrop, tokenReceiverSpecifiedAirdrop, tokenReceiverSpecifiedAirdrop2); + var pendingTokenSpecifiedAirdrops = + List.of(receiverTokenSpecifiedAirdrop, tokenReceiverSpecifiedAirdrop, tokenReceiverSpecifiedAirdrop2); + + var accountEntityId = type == OUTSTANDING ? sender.toEntityId() : receiver.toEntityId(); + var accountId = new EntityIdNumParameter(accountEntityId); + var entity = type == OUTSTANDING ? receiver : sender; + var entityIds = new Bound( + List.of(new EntityIdRangeParameter(RangeOperator.EQ, EntityId.of(entity.getId()))), + true, + Constants.ACCOUNT_ID); + var tokenIds = new Bound( + List.of(new EntityIdRangeParameter(RangeOperator.GTE, EntityId.of(tokenId))), true, Constants.TOKEN_ID); + + var expectedResult = type == OUTSTANDING ? allOutstandingAirdrops : allPendingAirdrops; + expectedResult = order.isAscending() ? expectedResult : expectedResult.reversed(); + + // When var request = TokenAirdropRequest.builder() - .accountId(new EntityIdNumParameter(sender.toEntityId())) + .accountId(accountId) .order(order) + .type(type) .build(); - assertThat(repository.findAllOutstanding(request, sender.toEntityId())) - .containsExactlyElementsOf(orderedAirdrops); + // Then + assertThat(repository.findAll(request, accountEntityId)).containsExactlyElementsOf(expectedResult); - // With receiver id condition - var receiverAirdrops = order.isAscending() ? receiverSpecifiedAirdrops : receiverSpecifiedAirdrops.reversed(); + // When receiver id condition for Outstanding Airdrops + // or sender id condition for Pending Airdrops + expectedResult = type == OUTSTANDING ? outstandingReceiverSpecifiedAirdrops : pendingSenderSpecifiedAirdrops; + expectedResult = order.isAscending() ? expectedResult : expectedResult.reversed(); request = TokenAirdropRequest.builder() - .accountId(new EntityIdNumParameter(sender.toEntityId())) + .accountId(accountId) .order(order) - .entityIds(new Bound( - List.of(new EntityIdRangeParameter(RangeOperator.EQ, EntityId.of(receiver.getId()))), - true, - Constants.ACCOUNT_ID)) + .entityIds(entityIds) + .type(type) .build(); - assertThat(repository.findAllOutstanding(request, sender.toEntityId())) - .containsExactlyElementsOf(receiverAirdrops); + // Then + assertThat(repository.findAll(request, accountEntityId)).containsExactlyElementsOf(expectedResult); - // With token id and receiver condition - var tokenAirdrops = order.isAscending() ? tokenReceiverAirdrops : tokenReceiverAirdrops.reversed(); + // When token id and receiver or sender condition + expectedResult = + order.isAscending() ? tokenReceiverSpecifiedAirdrops : tokenReceiverSpecifiedAirdrops.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)) + .accountId(accountId) + .entityIds(entityIds) .order(order) - .tokenIds(new Bound( - List.of(new EntityIdRangeParameter(RangeOperator.GTE, EntityId.of(tokenId))), - false, - Constants.TOKEN_ID)) + .tokenIds(tokenIds) + .type(type) .build(); - assertThat(repository.findAllOutstanding(request, sender.toEntityId())) - .containsExactlyElementsOf(tokenAirdrops); + // Then + assertThat(repository.findAll(request, accountEntityId)).containsExactlyElementsOf(expectedResult); - // With token id condition as primary sort field and with receiver id + // When token id condition as primary sort field and with receiver id request = TokenAirdropRequest.builder() - .accountId(new EntityIdNumParameter(sender.toEntityId())) + .accountId(accountId) .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)) + .entityIds(entityIds) + .tokenIds(tokenIds) + .type(type) .build(); - assertThat(repository.findAllOutstanding(request, sender.toEntityId())) - .containsExactlyElementsOf(tokenAirdrops); + // Then + assertThat(repository.findAll(request, accountEntityId)).containsExactlyElementsOf(expectedResult); - // With token id condition but no receiver id - var tokenIdAirdrops = order.isAscending() ? tokenSpecifiedAirdrops : tokenSpecifiedAirdrops.reversed(); + // When token id condition but no receiver id for outstanding airdrops nor receiver id for pending airdrops + expectedResult = type == OUTSTANDING ? outstandingTokenSpecifiedAirdrops : pendingTokenSpecifiedAirdrops; + expectedResult = order.isAscending() ? expectedResult : expectedResult.reversed(); request = TokenAirdropRequest.builder() - .accountId(new EntityIdNumParameter(sender.toEntityId())) + .accountId(accountId) .order(order) - .tokenIds(new Bound( - List.of(new EntityIdRangeParameter(RangeOperator.GTE, EntityId.of(tokenId))), - false, - Constants.TOKEN_ID)) + .tokenIds(tokenIds) + .type(type) .build(); - assertThat(repository.findAllOutstanding(request, sender.toEntityId())) - .containsExactlyElementsOf(tokenIdAirdrops); + // Then + assertThat(repository.findAll(request, accountEntityId)).containsExactlyElementsOf(expectedResult); + } + + private static Stream provideArguments() { + return Stream.of( + Arguments.of(Direction.ASC, OUTSTANDING), + Arguments.of(Direction.DESC, OUTSTANDING), + Arguments.of(Direction.ASC, PENDING), + Arguments.of(Direction.DESC, PENDING)); } } 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 index dde37d63140..ba5557b518e 100644 --- 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 @@ -17,6 +17,7 @@ package com.hedera.mirror.restjava.service; import static com.hedera.mirror.common.domain.token.TokenTypeEnum.FUNGIBLE_COMMON; +import static com.hedera.mirror.restjava.dto.TokenAirdropRequest.AirdropRequestType.OUTSTANDING; import static org.assertj.core.api.Assertions.assertThat; import com.hedera.mirror.common.domain.entity.EntityId; @@ -25,8 +26,10 @@ import com.hedera.mirror.restjava.common.EntityIdEvmAddressParameter; import com.hedera.mirror.restjava.common.EntityIdNumParameter; import com.hedera.mirror.restjava.dto.TokenAirdropRequest; +import com.hedera.mirror.restjava.dto.TokenAirdropRequest.AirdropRequestType; import lombok.RequiredArgsConstructor; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; @RequiredArgsConstructor class TokenAirdropServiceTest extends RestJavaIntegrationTest { @@ -36,8 +39,9 @@ class TokenAirdropServiceTest extends RestJavaIntegrationTest { private static final EntityId SENDER = EntityId.of(1001L); private static final EntityId TOKEN_ID = EntityId.of(5000L); - @Test - void getOutstanding() { + @ParameterizedTest + @EnumSource(AirdropRequestType.class) + void getAirdrops(AirdropRequestType type) { var fungibleAirdrop = domainBuilder .tokenAirdrop(FUNGIBLE_COMMON) .customize(a -> a.amount(100L) @@ -46,48 +50,65 @@ void getOutstanding() { .tokenId(TOKEN_ID.getId())) .persist(); + var accountId = type == OUTSTANDING ? SENDER : RECEIVER; var request = TokenAirdropRequest.builder() - .accountId(new EntityIdNumParameter(SENDER)) + .accountId(new EntityIdNumParameter(accountId)) + .type(type) .build(); - var response = service.getOutstandingAirdrops(request); + var response = service.getAirdrops(request); assertThat(response).containsExactly(fungibleAirdrop); } - @Test - void getOutstandingByAlias() { + @ParameterizedTest + @EnumSource(AirdropRequestType.class) + void getByAlias(AirdropRequestType type) { var entity = domainBuilder.entity().persist(); - var tokenAirdrop = domainBuilder - .tokenAirdrop(FUNGIBLE_COMMON) - .customize(a -> a.senderAccountId(entity.getId())) - .persist(); + var tokenAirdropBuilder = domainBuilder.tokenAirdrop(FUNGIBLE_COMMON); + var tokenAirdrop = type == OUTSTANDING + ? tokenAirdropBuilder + .customize(a -> a.senderAccountId(entity.getId())) + .persist() + : tokenAirdropBuilder + .customize(a -> a.receiverAccountId(entity.getId())) + .persist(); + var request = TokenAirdropRequest.builder() .accountId(new EntityIdAliasParameter(entity.getShard(), entity.getRealm(), entity.getAlias())) + .type(type) .build(); - var response = service.getOutstandingAirdrops(request); + var response = service.getAirdrops(request); assertThat(response).containsExactly(tokenAirdrop); } - @Test - void getOutstandingByEvmAddress() { + @ParameterizedTest + @EnumSource(AirdropRequestType.class) + void getByEvmAddress(AirdropRequestType type) { var entity = domainBuilder.entity().persist(); - var tokenAirdrop = domainBuilder - .tokenAirdrop(FUNGIBLE_COMMON) - .customize(a -> a.senderAccountId(entity.getId())) - .persist(); + var tokenAirdropBuilder = domainBuilder.tokenAirdrop(FUNGIBLE_COMMON); + var tokenAirdrop = type == OUTSTANDING + ? tokenAirdropBuilder + .customize(a -> a.senderAccountId(entity.getId())) + .persist() + : tokenAirdropBuilder + .customize(a -> a.receiverAccountId(entity.getId())) + .persist(); var request = TokenAirdropRequest.builder() .accountId( new EntityIdEvmAddressParameter(entity.getShard(), entity.getRealm(), entity.getEvmAddress())) + .type(type) .build(); - var response = service.getOutstandingAirdrops(request); + var response = service.getAirdrops(request); assertThat(response).containsExactly(tokenAirdrop); } - @Test - void getOutstandingNotFound() { + @ParameterizedTest + @EnumSource(AirdropRequestType.class) + void getNotFound(AirdropRequestType type) { var request = TokenAirdropRequest.builder() - .accountId(new EntityIdNumParameter(SENDER)) + .accountId(new EntityIdNumParameter(EntityId.of(3000L))) + .type(type) .build(); - var response = service.getOutstandingAirdrops(request); + var response = service.getAirdrops(request); assertThat(response).isEmpty(); } } diff --git a/hedera-mirror-rest/api/v1/openapi.yml b/hedera-mirror-rest/api/v1/openapi.yml index 94e8a8969bb..fb436be7ff3 100644 --- a/hedera-mirror-rest/api/v1/openapi.yml +++ b/hedera-mirror-rest/api/v1/openapi.yml @@ -164,7 +164,7 @@ paths: - accounts /api/v1/accounts/{idOrAliasOrEvmAddress}/airdrops/outstanding: get: - summary: Get get outstanding fungible token airdrops sent by an account + summary: 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 @@ -187,6 +187,31 @@ paths: $ref: "#/components/responses/NotFoundError" tags: - airdrops + /api/v1/accounts/{idOrAliasOrEvmAddress}/airdrops/pending: + get: + summary: Get pending fungible token airdrops received by an account + description: | + Returns pending fungible token airdrops that have been received by an account. This API is currently under development. Support for NFT airdrops will be added in a future release. + operationId: getPendingTokenAirdrops + parameters: + - $ref: "#/components/parameters/accountIdOrAliasOrEvmAddressPathParam" + - $ref: "#/components/parameters/limitQueryParam" + - $ref: "#/components/parameters/orderQueryParam" + - $ref: "#/components/parameters/senderIdQueryParam" + - $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 @@ -4558,6 +4583,40 @@ components: value: lte:0.0.700 schema: $ref: "#/components/schemas/EntityIdQuery" + senderIdQueryParam: + name: sender.id + description: The ID of the sender 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" serialNumberPathParam: name: serialNumber in: path