diff --git a/UniversalNFT.dev.API/Controllers/NFTController.cs b/UniversalNFT.dev.API/Controllers/NFTController.cs index dd6481d..209483d 100644 --- a/UniversalNFT.dev.API/Controllers/NFTController.cs +++ b/UniversalNFT.dev.API/Controllers/NFTController.cs @@ -7,7 +7,7 @@ namespace UniversalNFT.dev.API.Controllers { /// - /// Load and translate metadata for an NFToken on the blockchain and return it in the specified format. + /// Load and translate metadata for a specfic NFToken on the blockchain and return it in the specified format. /// [ApiController] [Route("v1.0/NFT")] diff --git a/UniversalNFT.dev.API/Controllers/WalletController.cs b/UniversalNFT.dev.API/Controllers/WalletController.cs new file mode 100644 index 0000000..0b1696a --- /dev/null +++ b/UniversalNFT.dev.API/Controllers/WalletController.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using UniversalNFT.dev.API.Models.API; +using UniversalNFT.dev.API.Services.NFT; +using UniversalNFT.dev.API.SwaggerConfig; + +namespace UniversalNFT.dev.API.Controllers +{ + /// + /// Return all NFTs in a specific wallet in our UniversalNFTResponseV1 format. + /// + [ApiController] + [Route("v1.0/Wallet")] + public class WalletController : ControllerBase + { + private readonly INFTService _nftService; + + public WalletController(INFTService nftService) + { + _nftService = nftService; + } + + /// + /// Return all NFTs in a wallet in our UniversalNFTResponseV1 format. + /// + [HttpGet] + [SwaggerResponse(200, "The wallet NFTs are loaded and thumbnail cached sucessfully", typeof(IEnumerable))] + [SwaggerResponse(404, "The wallet could not be found")] + public async Task Get( + [SwaggerParameter("The XRPL wallet to load NFTs from", Required = true)] + [SwaggerTryItOutDefaultValue("rPpMSFxzjqJ6AGgEZ8kgbQeeo6UJvUkVmb")] + string OwnerWalletAddress) + { + var response = await _nftService.GetAllNFTs(OwnerWalletAddress); + + if (response == null) + return NotFound(); + + return new JsonResult(response); + } + } +} diff --git a/UniversalNFT.dev.API/Facades/HttpFacade.cs b/UniversalNFT.dev.API/Facades/HttpFacade.cs index 7f0b4d0..c9938f1 100644 --- a/UniversalNFT.dev.API/Facades/HttpFacade.cs +++ b/UniversalNFT.dev.API/Facades/HttpFacade.cs @@ -18,6 +18,5 @@ public class HttpFacade : IHttpFacade return null; } - } } diff --git a/UniversalNFT.dev.API/Facades/IHttpFacade.cs b/UniversalNFT.dev.API/Facades/IHttpFacade.cs index 9efc92f..f142f3d 100644 --- a/UniversalNFT.dev.API/Facades/IHttpFacade.cs +++ b/UniversalNFT.dev.API/Facades/IHttpFacade.cs @@ -1,5 +1,4 @@ - -namespace UniversalNFT.dev.API.Facades +namespace UniversalNFT.dev.API.Facades { public interface IHttpFacade { diff --git a/UniversalNFT.dev.API/Models/API/Artv0Response.cs b/UniversalNFT.dev.API/Models/API/Artv0Response.cs index 7f3345f..cfbf055 100644 --- a/UniversalNFT.dev.API/Models/API/Artv0Response.cs +++ b/UniversalNFT.dev.API/Models/API/Artv0Response.cs @@ -11,7 +11,7 @@ public class Artv0Response public string nftType { get => "art.v0"; } [SwaggerSchema("The NFTokenID", Nullable = false)] - public string name { get; set; } + public string name { get; set; } [SwaggerSchema("The date and time in ISO format this request was last generated on the server e.g. 2023-05-20T15:30:00.0000000+00:00", Nullable = false)] public string description { get; set; } diff --git a/UniversalNFT.dev.API/Program.cs b/UniversalNFT.dev.API/Program.cs index 40a5614..7788214 100644 --- a/UniversalNFT.dev.API/Program.cs +++ b/UniversalNFT.dev.API/Program.cs @@ -16,7 +16,8 @@ // Swagger builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(options => { +builder.Services.AddSwaggerGen(options => +{ var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename), includeControllerXmlComments: true); diff --git a/UniversalNFT.dev.API/Services/AppSettings/XRPLSettings.cs b/UniversalNFT.dev.API/Services/AppSettings/XRPLSettings.cs index d844a91..1b043b4 100644 --- a/UniversalNFT.dev.API/Services/AppSettings/XRPLSettings.cs +++ b/UniversalNFT.dev.API/Services/AppSettings/XRPLSettings.cs @@ -3,5 +3,7 @@ public class XRPLSettings { public string? XRPLServerAddress { get; set; } + + public bool EnableDelay { get; set; } = true; } } diff --git a/UniversalNFT.dev.API/Services/NFT/INFTService.cs b/UniversalNFT.dev.API/Services/NFT/INFTService.cs index e8fa89b..12848ba 100644 --- a/UniversalNFT.dev.API/Services/NFT/INFTService.cs +++ b/UniversalNFT.dev.API/Services/NFT/INFTService.cs @@ -7,5 +7,7 @@ public interface INFTService Task GetNFT(string NFTokenID, string OwnerWalletAddress); Task GetArtv0(string NFTokenID, string OwnerWalletAddress); + + Task> GetAllNFTs(string OwnerWalletAddress); } } \ No newline at end of file diff --git a/UniversalNFT.dev.API/Services/NFT/NFTService.cs b/UniversalNFT.dev.API/Services/NFT/NFTService.cs index 4167b29..c450c9d 100644 --- a/UniversalNFT.dev.API/Services/NFT/NFTService.cs +++ b/UniversalNFT.dev.API/Services/NFT/NFTService.cs @@ -27,6 +27,9 @@ public NFTService( _serverSettings = serverSettings.Value; } + /// + /// Load a specific NFT from a given wallet and return it in our format + /// public async Task GetNFT( string NFTokenID, string OwnerWalletAddress) @@ -68,6 +71,53 @@ public async Task GetNFT( return null; } + /// + /// Load all NFTs in a wallet and process them into our format + /// + public async Task> GetAllNFTs(string OwnerWalletAddress) + { + var accountNftsResult = new List(); + try + { + // Load the NFT from XRPL + var accountNfts = await _xrplService.GetAllNFTs(OwnerWalletAddress); + if (accountNfts?.Any() != true) + return Enumerable.Empty(); + + foreach (var accountNft in accountNfts) + { + var imageUrl = await _rulesEngine.ProcessNFToken(accountNft) ?? string.Empty; + imageUrl = IPFSService.NormaliseUrl(imageUrl); + + // We have an imageUrl extracted! Create the thumbnail if it doesn't exist + // in our cache. + var thumbnailFilename = await _imageService.CreateThumbnail(imageUrl, accountNft.NFTokenID); + + accountNftsResult.Add(new UniversalNFTResponseV1 + { + NFTokenID = accountNft.NFTokenID, + OwnerAccount = OwnerWalletAddress, + ImageThumbnailCacheUrl = !string.IsNullOrWhiteSpace(thumbnailFilename) + ? $"{_serverSettings.ServerExternalDomain}/v1.0/Image?file={thumbnailFilename}" + : string.Empty, + ImageUrl = imageUrl, + Timestamp = DateTime.UtcNow.ToString("O") + }); + } + + return accountNftsResult; + } + catch (Exception ex) + { + // Log it if you care + } + + return Enumerable.Empty(); + } + + /// + /// Load a specific NFT from a given wallet and return it in art.v0 format + /// public async Task GetArtv0( string NFTokenID, string OwnerWalletAddress) diff --git a/UniversalNFT.dev.API/Services/XRPL/IXRPLService.cs b/UniversalNFT.dev.API/Services/XRPL/IXRPLService.cs index 958e95e..0b2a1c5 100644 --- a/UniversalNFT.dev.API/Services/XRPL/IXRPLService.cs +++ b/UniversalNFT.dev.API/Services/XRPL/IXRPLService.cs @@ -4,6 +4,8 @@ namespace UniversalNFT.dev.API.Services.XRPL { public interface IXRPLService { + Task> GetAllNFTs(string ownerAccount); + Task GetNFT(string tokenID, string ownerAccount); } } \ No newline at end of file diff --git a/UniversalNFT.dev.API/Services/XRPL/XRPLService.cs b/UniversalNFT.dev.API/Services/XRPL/XRPLService.cs index 6cd19d0..048a06b 100644 --- a/UniversalNFT.dev.API/Services/XRPL/XRPLService.cs +++ b/UniversalNFT.dev.API/Services/XRPL/XRPLService.cs @@ -83,10 +83,12 @@ public XRPLService(IOptions xrplSettings, ILogger log ]}"; // Lets be kind to the free cluster servers, for better performance - // host your own Rippled node and remove the next line! :) - Thread.Sleep(200); + // host your own Rippled node and disable with appsettings.json + // "XRPLSettings": { "EnableDelay" : false } + if (_xrplSettings.EnableDelay) + Thread.Sleep(200); - var seekResponse = await _httpClient.PostAsync(_xrplSettings.XRPLServerAddress, + var seekResponse = await _httpClient.PostAsync(_xrplSettings.XRPLServerAddress, new StringContent(body)); if (seekResponse.IsSuccessStatusCode) { @@ -132,4 +134,94 @@ public XRPLService(IOptions xrplSettings, ILogger log return null; } + + public async Task> GetAllNFTs(string ownerAccount) + { + var accountNfts = new List(); + + try + { + // Setup the request body to load account NFTs + var body = @"{ ""method"": ""account_nfts"", + ""params"": [ + { + ""account"": """ + ownerAccount + @""", + ""ledger_index"": ""validated"", + ""limit"": 1000 + } + ]}"; + + // Load account NFTs from Rippled + var response = await _httpClient.PostAsync(_xrplSettings.XRPLServerAddress, new StringContent(body)); + if (response.IsSuccessStatusCode) + { + // Parse success response + var data = await response.Content.ReadAsStringAsync(); + var resultObj = JsonSerializer.Deserialize(data); + + foreach (var accountNft in resultObj?.Result?.NFTs ?? Enumerable.Empty()) + { + if (!string.IsNullOrWhiteSpace(accountNft.URI)) + { + var convertedUri = Encoding.UTF8.GetString(HexHelper.StringToByteArray(accountNft.URI)); + accountNft.URI = IPFSService.NormaliseUrl(convertedUri); + } + accountNfts.Add(accountNft); + } + + // Page if we have to keep going + var seek = resultObj?.Result?.Marker; + while (!string.IsNullOrWhiteSpace(seek)) + { + body = @"{ ""method"": ""account_nfts"", + ""params"": [ + { + ""account"": """ + ownerAccount + @""", + ""ledger_index"": ""validated"", + ""limit"": 1000, + ""marker"": """ + seek + @""" + } + ]}"; + + // Lets be kind to the free cluster servers, for better performance + // host your own Rippled node and disable with appsettings.json + // "XRPLSettings": { "EnableDelay" : false } + if (_xrplSettings.EnableDelay) + Thread.Sleep(200); + + var seekResponse = await _httpClient.PostAsync(_xrplSettings.XRPLServerAddress, + new StringContent(body)); + if (seekResponse.IsSuccessStatusCode) + { + // Parse success response + var seekData = await seekResponse.Content.ReadAsStringAsync(); + var seekResultObj = JsonSerializer.Deserialize(seekData); + + foreach (var seekAccountNft in seekResultObj?.Result?.NFTs ?? Enumerable.Empty()) + { + if (!string.IsNullOrWhiteSpace(seekAccountNft.URI)) + { + var convertedUri = Encoding.UTF8.GetString(HexHelper.StringToByteArray(seekAccountNft.URI)); + seekAccountNft.URI = IPFSService.NormaliseUrl(convertedUri); + } + accountNfts.Add(seekAccountNft); + } + + seek = seekResultObj?.Result?.Marker; + } + else + { + // Don't carry on seeking on error + seek = null; + } + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting NFT in XRPLService"); + } + + return accountNfts; + } }