diff --git a/Tzkt.Api/Controllers/ConstantsController.cs b/Tzkt.Api/Controllers/ConstantsController.cs new file mode 100644 index 000000000..05348b252 --- /dev/null +++ b/Tzkt.Api/Controllers/ConstantsController.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +using Tzkt.Api.Models; +using Tzkt.Api.Repositories; +using Tzkt.Api.Services.Cache; + +namespace Tzkt.Api.Controllers +{ + [ApiController] + [Route("v1/constants")] + public class ConstantsController : ControllerBase + { + private readonly ConstantsRepository Constants; + private readonly StateCache State; + + public ConstantsController(ConstantsRepository constants, StateCache state) + { + Constants = constants; + State = state; + } + + /// + /// Get global constants + /// + /// + /// Returns a list of global constants. + /// + /// Filters constants by global address (starts with `expr..`). + /// Filters constants by creation level. + /// Filters constants by creation time. + /// Filters constants by creator. + /// Filters constants by number of refs. + /// Filters constants by size in bytes. + /// Specify comma-separated list of fields to include into response or leave it undefined to return full object. If you select single field, response will be an array of values in both `.fields` and `.values` modes. + /// Sorts delegators by specified field. Supported fields: `id` (default), `creationLevel`, `size`, `refs`. + /// Specifies which or how many items should be skipped + /// Maximum number of items to return + /// + [HttpGet] + public async Task>> Get( + ExpressionParameter address, + Int32Parameter creationLevel, + TimestampParameter creationTime, + AccountParameter creator, + Int32Parameter refs, + Int32Parameter size, + SelectParameter select, + SortParameter sort, + OffsetParameter offset, + [Range(0, 10000)] int limit = 100) + { + #region validate + if (sort != null && !sort.Validate("creationLevel", "size", "refs")) + return new BadRequest($"{nameof(sort)}", "Sorting by the specified field is not allowed."); + #endregion + + if (select == null) + return Ok(await Constants.Get(address, creationLevel, creationTime, creator, refs, size, sort, offset, limit)); + + if (select.Values != null) + { + if (select.Values.Length == 1) + return Ok(await Constants.Get(address, creationLevel, creationTime, creator, refs, size, sort, offset, limit, select.Values[0])); + else + return Ok(await Constants.Get(address, creationLevel, creationTime, creator, refs, size, sort, offset, limit, select.Values)); + } + else + { + if (select.Fields.Length == 1) + return Ok(await Constants.Get(address, creationLevel, creationTime, creator, refs, size, sort, offset, limit, select.Fields[0])); + else + { + return Ok(new SelectionResponse + { + Cols = select.Fields, + Rows = await Constants.Get(address, creationLevel, creationTime, creator, refs, size, sort, offset, limit, select.Fields) + }); + } + } + } + + /// + /// Get global constant by address + /// + /// + /// Returns global constant with specified address (expression hash). + /// + /// Global address (starts with `expr..`) + /// + [HttpGet("{address}")] + public async Task GetByAddress( + [Required][ExpressionHash] string address) + { + var res = await Constants.Get(address, null, null, null, null, null, null, null, 1); + return res.FirstOrDefault(); + } + + /// + /// Get global constants count + /// + /// + /// Returns a number of global constants. + /// + /// Filters constants by number of refs. + /// + [HttpGet("count")] + public Task GetCount(Int32Parameter refs) + { + if (refs == null) + return Task.FromResult(State.Current.ConstantsCount); + + return Constants.GetCount(); + } + } +} diff --git a/Tzkt.Api/Models/Constant.cs b/Tzkt.Api/Models/Constant.cs new file mode 100644 index 000000000..9a0be5f69 --- /dev/null +++ b/Tzkt.Api/Models/Constant.cs @@ -0,0 +1,48 @@ +using Netezos.Encoding; +using System; + +namespace Tzkt.Api.Models +{ + public class Constant + { + /// + /// Global address (expression hash) + /// + public string Address { get; set; } + + /// + /// Constant micheline expression + /// + public IMicheline Value { get; set; } + + /// + /// Constant size in bytes + /// + public int Size { get; set; } + + /// + /// Number of contracts referencing this constant + /// + public int Refs { get; set; } + + /// + /// Account registered this constant + /// + public Alias Creator { get; set; } + + /// + /// Level of the first block baked with this software + /// + public int CreationLevel { get; set; } + + /// + /// Datetime of the first block baked with this software + /// + public DateTime CreationTime { get; set; } + + /// + /// Offchain metadata + /// + public RawJson Metadata { get; set; } + } +} diff --git a/Tzkt.Api/Parameters/ExpressionParameter.cs b/Tzkt.Api/Parameters/ExpressionParameter.cs index f79946fcb..39beda225 100644 --- a/Tzkt.Api/Parameters/ExpressionParameter.cs +++ b/Tzkt.Api/Parameters/ExpressionParameter.cs @@ -37,5 +37,9 @@ public class ExpressionParameter /// Example: `?address.ni=expr1,expr2`. /// public List Ni { get; set; } + + #region operators + public static implicit operator ExpressionParameter(string value) => new() { Eq = value }; + #endregion } } diff --git a/Tzkt.Api/Repositories/ConstantsRepository.cs b/Tzkt.Api/Repositories/ConstantsRepository.cs new file mode 100644 index 000000000..79aa3fe8a --- /dev/null +++ b/Tzkt.Api/Repositories/ConstantsRepository.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Dapper; +using Netezos.Encoding; + +using Tzkt.Api.Models; +using Tzkt.Api.Services.Cache; + +namespace Tzkt.Api.Repositories +{ + public class ConstantsRepository : DbConnection + { + readonly AccountsCache Accounts; + readonly TimeCache Time; + + public ConstantsRepository(AccountsCache accounts, TimeCache time, IConfiguration config) : base(config) + { + Accounts = accounts; + Time = time; + } + + public async Task GetCount() + { + using var db = GetConnection(); + return await db.QueryFirstAsync(@"SELECT COUNT(*) FROM ""RegisterConstantOps"" WHERE ""Address"" IS NOT NULL"); + } + + public async Task> Get( + ExpressionParameter address, + Int32Parameter creationLevel, + TimestampParameter creationTime, + AccountParameter creator, + Int32Parameter refs, + Int32Parameter size, + SortParameter sort, + OffsetParameter offset, + int limit) + { + var sql = new SqlBuilder(@"SELECT * FROM ""RegisterConstantOps""") + .Filter(@"""Address"" IS NOT NULL") + .Filter("Address", address) + .Filter("Level", creationLevel) + .Filter("Level", creationTime) + .Filter("SenderId", creator) + .Filter("Refs", refs) + .Filter("StorageUsed", size) + .Take(sort, offset, limit, x => x switch + { + "creationLevel" => ("Level", "Level"), + "size" => ("StorageUsed", "StorageUsed"), + "refs" => ("Refs", "Refs"), + _ => ("Id", "Id") + }); + + using var db = GetConnection(); + var rows = await db.QueryAsync(sql.Query, sql.Params); + + return rows.Select(row => new Constant + { + Address = row.Address, + CreationLevel = row.Level, + CreationTime = row.Timestamp, + Creator = Accounts.GetAlias(row.SenderId), + Refs = row.Refs, + Size = row.StorageUsed, + Value = Micheline.FromBytes(row.Value), + Metadata = row.Metadata + }); + } + + public async Task Get( + ExpressionParameter address, + Int32Parameter creationLevel, + TimestampParameter creationTime, + AccountParameter creator, + Int32Parameter refs, + Int32Parameter size, + SortParameter sort, + OffsetParameter offset, + int limit, + string[] fields) + { + var columns = new HashSet(fields.Length); + foreach (var field in fields) + { + switch (field) + { + case "address": columns.Add(@"""Address"""); break; + case "creationLevel": columns.Add(@"""Level"""); break; + case "creationTime": columns.Add(@"""Timestamp"""); break; + case "creator": columns.Add(@"""SenderId"""); break; + case "refs": columns.Add(@"""Refs"""); break; + case "size": columns.Add(@"""StorageUsed"""); break; + case "value": columns.Add(@"""Value"""); break; + case "metadata": columns.Add(@"""Metadata"""); break; + } + } + + if (columns.Count == 0) + return Array.Empty(); + + var sql = new SqlBuilder($@"SELECT {string.Join(',', columns)} FROM ""RegisterConstantOps""") + .Filter(@"""Address"" IS NOT NULL") + .Filter("Address", address) + .Filter("Level", creationLevel) + .Filter("Level", creationTime) + .Filter("SenderId", creator) + .Filter("Refs", refs) + .Filter("StorageUsed", size) + .Take(sort, offset, limit, x => x switch + { + "creationLevel" => ("Level", "Level"), + "size" => ("StorageUsed", "StorageUsed"), + "refs" => ("Refs", "Refs"), + _ => ("Id", "Id") + }); + + using var db = GetConnection(); + var rows = await db.QueryAsync(sql.Query, sql.Params); + + var result = new object[rows.Count()][]; + for (int i = 0; i < result.Length; i++) + result[i] = new object[fields.Length]; + + for (int i = 0, j = 0; i < fields.Length; j = 0, i++) + { + switch (fields[i]) + { + case "address": + foreach (var row in rows) + result[j++][i] = row.Address; + break; + case "creationLevel": + foreach (var row in rows) + result[j++][i] = row.Level; + break; + case "creationTime": + foreach (var row in rows) + result[j++][i] = row.Timestamp; + break; + case "creator": + foreach (var row in rows) + result[j++][i] = Accounts.GetAlias(row.SenderId); + break; + case "refs": + foreach (var row in rows) + result[j++][i] = row.Refs; + break; + case "size": + foreach (var row in rows) + result[j++][i] = row.StorageUsed; + break; + case "value": + foreach (var row in rows) + result[j++][i] = Micheline.FromBytes(row.Value); + break; + case "metadata": + foreach (var row in rows) + result[j++][i] = (RawJson)row.Metadata; + break; + } + } + + return result; + } + + public async Task Get( + ExpressionParameter address, + Int32Parameter creationLevel, + TimestampParameter creationTime, + AccountParameter creator, + Int32Parameter refs, + Int32Parameter size, + SortParameter sort, + OffsetParameter offset, + int limit, + string field) + { + var columns = new HashSet(1); + switch (field) + { + case "address": columns.Add(@"""Address"""); break; + case "creationLevel": columns.Add(@"""Level"""); break; + case "creationTime": columns.Add(@"""Timestamp"""); break; + case "creator": columns.Add(@"""SenderId"""); break; + case "refs": columns.Add(@"""Refs"""); break; + case "size": columns.Add(@"""StorageUsed"""); break; + case "value": columns.Add(@"""Value"""); break; + case "metadata": columns.Add(@"""Metadata"""); break; + } + + if (columns.Count == 0) + return Array.Empty(); + + var sql = new SqlBuilder($@"SELECT {string.Join(',', columns)} FROM ""RegisterConstantOps""") + .Filter(@"""Address"" IS NOT NULL") + .Filter("Address", address) + .Filter("Level", creationLevel) + .Filter("Level", creationTime) + .Filter("SenderId", creator) + .Filter("Refs", refs) + .Filter("StorageUsed", size) + .Take(sort, offset, limit, x => x switch + { + "creationLevel" => ("Level", "Level"), + "size" => ("StorageUsed", "StorageUsed"), + "refs" => ("Refs", "Refs"), + _ => ("Id", "Id") + }); + + using var db = GetConnection(); + var rows = await db.QueryAsync(sql.Query, sql.Params); + + var result = new object[rows.Count()]; + var j = 0; + + switch (field) + { + case "address": + foreach (var row in rows) + result[j++] = row.Address; + break; + case "creationLevel": + foreach (var row in rows) + result[j++] = row.Level; + break; + case "creationTime": + foreach (var row in rows) + result[j++] = row.Timestamp; + break; + case "creator": + foreach (var row in rows) + result[j++] = Accounts.GetAlias(row.SenderId); + break; + case "refs": + foreach (var row in rows) + result[j++] = row.Refs; + break; + case "size": + foreach (var row in rows) + result[j++] = row.StorageUsed; + break; + case "value": + foreach (var row in rows) + result[j++] = Micheline.FromBytes(row.Value); + break; + case "metadata": + foreach (var row in rows) + result[j++] = (RawJson)row.Metadata; + break; + } + + return result; + } + } +} diff --git a/Tzkt.Api/Startup.cs b/Tzkt.Api/Startup.cs index 753528809..e559e7838 100644 --- a/Tzkt.Api/Startup.cs +++ b/Tzkt.Api/Startup.cs @@ -60,6 +60,7 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddAuthService(Configuration); diff --git a/Tzkt.Api/Validation/ExpressionHashAttribute.cs b/Tzkt.Api/Validation/ExpressionHashAttribute.cs new file mode 100644 index 000000000..f242e5184 --- /dev/null +++ b/Tzkt.Api/Validation/ExpressionHashAttribute.cs @@ -0,0 +1,14 @@ +using System.Text.RegularExpressions; + +namespace System.ComponentModel.DataAnnotations +{ + public sealed class ExpressionHashAttribute : ValidationAttribute + { + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + return value != null && !Regex.IsMatch((string)value, "^expr[0-9A-Za-z]{50}$") + ? new ValidationResult("Invalid expression hash.") + : ValidationResult.Success; + } + } +}