diff options
10 files changed, 163 insertions, 261 deletions
diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/TableInfoTest.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/TableInfoTest.cs index c6791c6..62fb9e5 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/TableInfoTest.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons.Tests/Crud/TableInfoTest.cs @@ -24,7 +24,7 @@ public class TableInfoTest      [Fact]      public void GenerateSelectSqlTest()      { -        var (sql, parameters) = _tableInfo.GenerateSelectSql(WhereClause.Create().Eq("Name", "Hello")); +        var (sql, parameters) = _tableInfo.GenerateSelectSql(null, WhereClause.Create().Eq("Name", "Hello"));          var parameterName = parameters.ParameterNames.First();          // TODO: Is there a way to auto detect parameters? diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnInfo.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnInfo.cs index 88b5ced..f1fb99b 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnInfo.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnInfo.cs @@ -86,16 +86,39 @@ public class ColumnInfo      protected void OnBeforeInsert(ColumnInfo column, ref object? value)      { -        TryCoerceStringFromNullToEmpty(ref value); -        if (column.IsNotNull && !column.IsAutoIncrement) +        if (column.IsClientGenerate && value is not null) +        { +            throw new Exception($"Column {column.ColumnName} can't be set manually."); +        } + +        var defaultValueGeneratorMethod = DefaultValueGeneratorMethod; +        if (defaultValueGeneratorMethod is not null)          { -            throw new Exception($"Column {column.ColumnName} can't be empty."); +            value = defaultValueGeneratorMethod.Invoke(null, new object[] { });          } + + +        OnBeforeSet(column, ref value);      }      protected void OnBeforeUpdate(ColumnInfo column, ref object? value)      { +        OnBeforeSet(column, ref value); + +        if (column.IsNoUpdate) +        { +            throw new Exception($"Column {column.ColumnName} not updatable."); +        } +    } + +    protected void OnBeforeSet(ColumnInfo column, ref object? value) +    {          TryCoerceStringFromNullToEmpty(ref value); + +        if (value is null && column.IsNotNull) +        { +            throw new Exception($"Column {column.ColumnName} can't be null."); +        }      }      public string ColumnName @@ -108,9 +131,33 @@ public class ColumnInfo          }      } +    public MethodInfo? DefaultValueGeneratorMethod +    { +        get +        { +            object? value = Metadata.GetValueOrDefault(ColumnMetadataKeys.DefaultValueGenerator); +            Debug.Assert(value is null || value is string); +            MethodInfo? result; +            if (value is null) +            { +                string methodName = ColumnName + "DefaultValueGenerator"; +                result = Table.EntityType.GetMethod(methodName, BindingFlags.Static); +            } +            else +            { +                string methodName = (string)value; +                result = Table.EntityType.GetMethod(methodName, BindingFlags.Static) ?? throw new Exception("The default value generator does not exist."); +            } + +            return result; +        } +    } +      public bool IsPrimaryKey => Metadata.GetValueOrDefault(ColumnMetadataKeys.IsPrimaryKey) is true;      public bool IsAutoIncrement => Metadata.GetValueOrDefault(ColumnMetadataKeys.IsAutoIncrement) is true;      public bool IsNotNull => IsPrimaryKey || Metadata.GetValueOrDefault(ColumnMetadataKeys.NotNull) is true; +    public bool IsClientGenerate => Metadata.GetValueOrDefault(ColumnMetadataKeys.ClientGenerate) is true; +    public bool IsNoUpdate => Metadata.GetValueOrDefault(ColumnMetadataKeys.NoUpdate) is true;      public ColumnIndexType Index => Metadata.GetValueOrDefault<ColumnIndexType?>(ColumnMetadataKeys.Index) ?? ColumnIndexType.None; diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnMetadata.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnMetadata.cs index 91e49f8..1ca2ce8 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnMetadata.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/ColumnMetadata.cs @@ -17,6 +17,18 @@ public static class ColumnMetadataKeys      /// This indicates that you take care of generate this column value when create entity. User calling the api can not specify the value.      /// </summary>      public const string ClientGenerate = nameof(ColumnAttribute.DefaultEmptyForString); + +    /// <summary> +    /// The default value generator method name in entity type. Default to null, aka, search for ColumnNameDefaultValueGenerator.  +    /// </summary> +    /// <returns></returns> +    public const string DefaultValueGenerator = nameof(ColumnAttribute.DefaultValueGenerator); + +    /// <summary> +    /// The column can only be set when inserted, can't be changed in update. +    /// </summary> +    /// <returns></returns> +    public const string NoUpdate = nameof(ColumnAttribute.NoUpdate);  }  public interface IColumnMetadata @@ -87,6 +99,12 @@ public class ColumnAttribute : Attribute, IColumnMetadata      /// <see cref="ColumnMetadataKeys.ClientGenerate"/>      public bool ClientGenerate { get; init; } +    /// <see cref="ColumnMetadataKeys.DefaultValueGenerator"/> +    public string? DefaultValueGenerator { get; init; } + +    /// <see cref="ColumnMetadataKeys.NoUpdate"/> +    public bool NoUpdate { get; init; } +      public bool TryGetValue(string key, out object? value)      {          var property = GetType().GetProperty(key); diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudService.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudService.cs index e098aca..811b2e6 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudService.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudService.cs @@ -40,6 +40,16 @@ public class CrudService<TEntity> : IDisposable where TEntity : class          return _table.Select(_dbConnection, filter).Cast<TEntity>().ToList();      } +    public bool Exists(IWhereClause? filter) +    { +        return _table.SelectCount(_dbConnection, filter) > 0; +    } + +    public int Count(IWhereClause? filter) +    { +        return _table.SelectCount(_dbConnection, filter); +    } +      public int Insert(IInsertClause insertClause)      {          return _table.Insert(_dbConnection, insertClause); diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudWebApplicationExtensions.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudWebApplicationExtensions.cs index aa25a23..3467625 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudWebApplicationExtensions.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/CrudWebApplicationExtensions.cs @@ -2,8 +2,13 @@ namespace CrupestApi.Commons.Crud;  public static class CrudWebApplicationExtensions  { -    public static IApplicationBuilder UseCrud(this IApplicationBuilder app, string path) +    public static WebApplication UseCrud(this WebApplication app, string path)      { +        app.MapGet(path, async (context) => +        { + +        }); +          return app;      }  } diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/DbConnectionFactory.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/DbConnectionFactory.cs index 2ee01ca..e8f8abf 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/DbConnectionFactory.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/DbConnectionFactory.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.Options;  namespace CrupestApi.Commons.Crud; -// TODO: Implement and register this service.  public interface IDbConnectionFactory  {      IDbConnection Get(string? name = null); diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/TableInfo.cs b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/TableInfo.cs index 28dc1ad..5bb19ad 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/TableInfo.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Commons/Crud/TableInfo.cs @@ -207,7 +207,7 @@ CREATE TABLE {tableName}(          }      } -    public (string sql, DynamicParameters parameters) GenerateSelectSql(IWhereClause? whereClause, IOrderByClause? orderByClause = null, int? skip = null, int? limit = null, string? dbProviderId = null) +    public (string sql, DynamicParameters parameters) GenerateSelectSql(string? what, IWhereClause? whereClause, IOrderByClause? orderByClause = null, int? skip = null, int? limit = null, string? dbProviderId = null)      {          CheckRelatedColumns(whereClause);          CheckRelatedColumns(orderByClause); @@ -215,7 +215,7 @@ CREATE TABLE {tableName}(          var parameters = new DynamicParameters();          StringBuilder result = new StringBuilder() -            .Append("SELECT * FROM ") +            .Append($"SELECT {what ?? "*"} FROM ")              .Append(TableName);          if (whereClause is not null) @@ -372,49 +372,43 @@ CREATE TABLE {tableName}(          return result;      } -    private object? ConvertFromDynamicToEntity(dynamic d) +    public virtual int SelectCount(IDbConnection dbConnection, IWhereClause? where = null, IOrderByClause? orderBy = null, int? skip = null, int? limit = null)      { -        if (d is null) return null; +        var (sql, parameters) = GenerateSelectSql("COUNT(*)", where, orderBy, skip, limit); +        return dbConnection.QuerySingle<int>(sql, parameters); -        Type dynamicType = d.GetType(); - -        var result = Activator.CreateInstance(EntityType); - -        foreach (var column in ColumnInfos) -        { -            var propertyInfo = column.PropertyInfo; -            if (propertyInfo is not null) -            { -                var dynamicProperty = dynamicType.GetProperty(column.ColumnName); -                if (dynamicProperty is null) continue; -                object? value = dynamicProperty.GetValue(d); -                value = column.ColumnType.ConvertFromDatabase(value); -                propertyInfo.SetValue(result, value); -            } -        } - -        return result;      }      public virtual IEnumerable<object?> Select(IDbConnection dbConnection, IWhereClause? where = null, IOrderByClause? orderBy = null, int? skip = null, int? limit = null)      { -        var (sql, parameters) = GenerateSelectSql(where, orderBy, skip, limit); +        return Select<IEnumerable<object?>>(dbConnection, null, where, orderBy, skip, limit); +    } + +    public virtual IEnumerable<TResult> Select<TResult>(IDbConnection dbConnection, string? what, IWhereClause? where = null, IOrderByClause? orderBy = null, int? skip = null, int? limit = null) +    { +        var (sql, parameters) = GenerateSelectSql(what, where, orderBy, skip, limit);          return dbConnection.Query<dynamic>(sql, parameters).Select(d =>          { -            var e = ConvertFromDynamicToEntity(d); +            Type dynamicType = d.GetType(); + +            var result = Activator.CreateInstance<TResult>();              foreach (var column in ColumnInfos)              { +                object? value = null; +                var dynamicProperty = dynamicType.GetProperty(column.ColumnName); +                if (dynamicProperty is not null) value = dynamicProperty.GetValue(d); +                column.Hooks.AfterSelect(column, ref value); +                if (value is not null) +                    value = column.ColumnType.ConvertFromDatabase(value);                  var propertyInfo = column.PropertyInfo;                  if (propertyInfo is not null)                  { -                    var value = propertyInfo.GetValue(e); -                    column.Hooks.AfterSelect(column, ref value); -                    propertyInfo.SetValue(e, value); +                    propertyInfo.SetValue(result, value);                  }              } -            return e; +            return result;          });      } @@ -422,12 +416,20 @@ CREATE TABLE {tableName}(      {          var (sql, parameters) = GenerateInsertSql(insert); -        foreach (var item in insert.Items) +        foreach (var column in ColumnInfos)          { -            var column = GetColumn(item.ColumnName); -            var value = item.Value; +            InsertItem? item = insert.Items.FirstOrDefault(i => i.ColumnName == column.ColumnName); +            var value = item?.Value;              column.Hooks.BeforeInsert(column, ref value); -            item.Value = value; +            if (item is null) +            { +                if (value is not null) +                    insert.Items.Add(new InsertItem(column.ColumnName, value)); +            } +            else +            { +                item.Value = value; +            }          }          return dbConnection.Execute(sql, ConvertParameters(parameters)); @@ -437,13 +439,22 @@ CREATE TABLE {tableName}(      {          var (sql, parameters) = GenerateUpdateSql(where, update); -        foreach (var item in update.Items) +        foreach (var column in ColumnInfos)          { -            var column = GetColumn(item.ColumnName); -            var value = item.Value; +            UpdateItem? item = update.Items.FirstOrDefault(i => i.ColumnName == column.ColumnName); +            var value = item?.Value;              column.Hooks.BeforeUpdate(column, ref value); -            item.Value = value; +            if (item is null) +            { +                if (value is not null) +                    update.Items.Add(new UpdateItem(column.ColumnName, value)); +            } +            else +            { +                item.Value = value; +            }          } +          return dbConnection.Execute(sql, ConvertParameters(parameters));      } diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/ISecretsService.cs b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/ISecretsService.cs deleted file mode 100644 index b5de436..0000000 --- a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/ISecretsService.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace CrupestApi.Secrets; - -public interface ISecretsService -{ -    Task<SecretInfo?> GetSecretAsync(string secret); - -    Task<List<SecretInfo>> GetSecretListAsync(bool includeExpired = false, bool includeRevoked = false); - -    Task<List<SecretInfo>> GetSecretListByKeyAsync(string key, bool includeExpired = false, bool includeRevoked = false); - -    Task VerifySecretAsync(string? key, string? secret); - -    // Check if "secret" query param exists and is only one. Then check the secret is valid for given key. -    // If check fails, will throw a VerifySecretException with proper message that can be send to client. -    Task VerifySecretForHttpRequestAsync(HttpRequest request, string? key = null, string queryKey = "secret"); - -    Task<SecretInfo> CreateSecretAsync(string key, string description, DateTime? expireTime = null); - -    Task RevokeSecretAsync(string secret); - -    // Throw SecretNotExistException if request secret does not exist. -    Task<SecretInfo> ModifySecretAsync(string secret, SecretModifyRequest modifyRequest); -} diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretInfo.cs b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretInfo.cs index 009bde9..e6af39b 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretInfo.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretInfo.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text;  using CrupestApi.Commons.Crud;  namespace CrupestApi.Secrets; @@ -6,7 +8,7 @@ public class SecretInfo  {      [Column(NotNull = true)]      public string Key { get; set; } = default!; -    [Column(NotNull = true, ClientGenerate = true)] +    [Column(NotNull = true, ClientGenerate = true, NoUpdate = true)]      public string Secret { get; set; } = default!;      [Column(DefaultEmptyForString = true)]      public string Description { get; set; } = default!; @@ -16,4 +18,31 @@ public class SecretInfo      public bool Revoked { get; set; }      [Column(NotNull = true)]      public DateTime CreateTime { get; set; } + +    private static RandomNumberGenerator RandomNumberGenerator = RandomNumberGenerator.Create(); + +    private static string GenerateRandomKey(int length) +    { +        const string alphanum = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +        var result = new StringBuilder(length); +        lock (RandomNumberGenerator) +        { +            for (int i = 0; i < length; i++) +            { +                result.Append(alphanum[RandomNumberGenerator.GetInt32(alphanum.Length)]); +            } +        } +        return result.ToString(); +    } + + +    public static string SecretDefaultValueGenerator() +    { +        return GenerateRandomKey(16); +    } + +    public static DateTime CreateTimeDefaultValueGenerator() +    { +        return DateTime.UtcNow; +    }  } diff --git a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsService.cs b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsService.cs index 5a49121..b8912cb 100644 --- a/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsService.cs +++ b/docker/crupest-api/CrupestApi/CrupestApi.Secrets/SecretsService.cs @@ -5,7 +5,7 @@ using Dapper;  namespace CrupestApi.Secrets; -public class SecretsService : CrudService<SecretInfo>, ISecretsService +public class SecretsService : CrudService<SecretInfo>  {      private readonly ILogger<SecretsService> _logger; @@ -14,198 +14,4 @@ public class SecretsService : CrudService<SecretInfo>, ISecretsService      {          _logger = loggerFactory.CreateLogger<SecretsService>();      } - -    public async Task<SecretInfo> CreateSecretAsync(SecretInfo secretInfo) -    { -        if (secretInfo.Secret is not null) -        { -            throw new ArgumentException("Secret is auto generated. Don't specify it explicit."); -        } - -        secretInfo.Secret = GenerateRandomKey(16); -        secretInfo.CreateTime = DateTime.Now; - -        await InsertAsync(_table.GenerateInsertClauseFromEntity(secretInfo)); - -        return secretInfo; -    } - -    public async Task<List<SecretInfo>> GetSecretListAsync(bool includeExpired = false, bool includeRevoked = false) -    { -        return (await QueryAsync()).ToList(); -    } - -    public async Task<List<SecretInfo>> GetSecretListByKeyAsync(string key, bool includeExpired = false, bool includeRevoked = false) -    { -        WhereClause where = WhereClause.Create(); - -        where.Eq(nameof(SecretInfo.Key), key); - -        if (!includeExpired) -        { -            where.Add(nameof(SecretInfo.ExpireTime), "<=", ) -        } - -        if (!includeRevoked) -        { -            where.Eq(nameof(SecretInfo.Revoked), false); -        } - -        return (await QueryAsync(where)).ToList(); -    } - -    public async Task<SecretInfo> ModifySecretAsync(string secret, SecretModifyRequest modifyRequest) -    { -        var dbConnection = await EnsureDatabase(); - -        var secretInfo = await GetSecretAsync(dbConnection, secret); - -        if (secretInfo is null) -        { -            throw new EntityNotExistException("Secret not found."); -        } - -        var queryParams = new DynamicParameters(); -        var updateColumnList = new List<string>(); - -        if (modifyRequest.Key is not null) -        { -            queryParams.Add("Key", modifyRequest.Key); -            updateColumnList.Add("Key"); -        } - -        if (modifyRequest.Description is not null) -        { -            queryParams.Add("Description", modifyRequest.Description); -            updateColumnList.Add("Description"); -        } - -        if (modifyRequest.SetExpireTime is true) -        { -            queryParams.Add("ExpireTime", modifyRequest.ExpireTime?.ToString("O")); -            updateColumnList.Add("ExpireTime"); -        } - -        if (modifyRequest.Revoked is true && secretInfo.Revoked is not true) -        { -            queryParams.Add("Revoked", true); -            updateColumnList.Add("Revoked"); -        } - -        if (updateColumnList.Count == 0) -        { -            return secretInfo; -        } - -        queryParams.Add("Secret", secret); - -        var updateColumnString = updateColumnList.GenerateUpdateColumnString(); - -        var changeCount = await dbConnection.ExecuteAsync($@" -UPDATE secrets SET {updateColumnString} WHERE Secret = @Secret; -        ", queryParams); - -        Debug.Assert(changeCount == 1); - -        return secretInfo; -    } - -    public async Task RevokeSecretAsync(string secret) -    { -        await ModifySecretAsync(secret, new SecretModifyRequest -        { -            Revoked = true, -        }); -    } - -    public async Task VerifySecretAsync(string? key, string? secret) -    { -        var dbConnection = await EnsureDatabase(); - -        if (secret is null) -        { -            if (key is not null) -            { -                throw new VerifySecretException(key, "A secret with given key is needed."); -            } -        } - -        var entity = await dbConnection.QueryFirstOrDefaultAsync<SecretInfo>(@" -SELECT Id, Key, Secret, Description, ExpireTime, Revoked, CreateTime FROM secrets WHERE Key = @Key AND Secret = @Secret -        ", new -        { -            Key = key, -            Secret = secret, -        }); - -        if (entity is null) -        { -            throw new VerifySecretException(key, "Secret token is invalid."); -        } - -        if (entity.Revoked is true) -        { -            throw new VerifySecretException(key, "Secret token is revoked."); -        } - -        if (entity.ExpireTime is not null && DateTime.ParseExact(entity.ExpireTime, "O", null) > DateTime.Now) -        { -            throw new VerifySecretException(key, "Secret token is expired."); -        } - -        if (key is not null) -        { -            if (entity.Key != key) -            { -                throw new VerifySecretException(key, "Secret is not for this key", VerifySecretException.ErrorKind.Forbidden); -            } -        } -    } - -    public async Task VerifySecretForHttpRequestAsync(HttpRequest request, string? key, string queryKey = "secret") -    { -        string? secret = null; - -        var authorizationHeaders = request.Headers.Authorization.ToList(); -        if (authorizationHeaders.Count > 1) -        { -            _logger.LogWarning("There are multiple Authorization headers in the request. Will use the last one."); -        } -        if (authorizationHeaders.Count > 0) -        { -            var authorizationHeader = authorizationHeaders[^1] ?? ""; -            if (!authorizationHeader.StartsWith("Bearer ")) -            { -                throw new VerifySecretException(key, "Authorization header must start with 'Bearer '."); -            } - -            secret = authorizationHeader.Substring("Bearer ".Length).Trim(); -        } - -        var secretQueryParam = request.Query[queryKey].ToList(); -        if (secretQueryParam.Count > 1) -        { -            _logger.LogWarning($"There are multiple '{queryKey}' query parameters in the request. Will use the last one."); -        } -        if (secretQueryParam.Count > 0) -        { -            if (secret is not null) -            { -                _logger.LogWarning("Secret found both in Authorization header and query parameter. Will use the one in query parameter."); -            } -            secret = secretQueryParam[^1] ?? ""; -        } - -        await VerifySecretAsync(key, secret); -    } - -    public Task<SecretInfo?> GetSecretAsync(string secret) -    { -        throw new NotImplementedException(); -    } - -    public Task<SecretInfo> CreateSecretAsync(string key, string description, DateTime? expireTime = null) -    { -        throw new NotImplementedException(); -    }  }  | 
