diff --git a/back/src/Kyoo.Abstractions/Kyoo.Abstractions.csproj b/back/src/Kyoo.Abstractions/Kyoo.Abstractions.csproj index 7d92d9c8..283f5509 100644 --- a/back/src/Kyoo.Abstractions/Kyoo.Abstractions.csproj +++ b/back/src/Kyoo.Abstractions/Kyoo.Abstractions.csproj @@ -14,6 +14,7 @@ + diff --git a/back/src/Kyoo.Abstractions/Models/Utils/Filter.cs b/back/src/Kyoo.Abstractions/Models/Utils/Filter.cs index dee6cbed..79ba0c41 100644 --- a/back/src/Kyoo.Abstractions/Models/Utils/Filter.cs +++ b/back/src/Kyoo.Abstractions/Models/Utils/Filter.cs @@ -17,8 +17,13 @@ // along with Kyoo. If not, see . using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Linq.Expressions; +using System.Reflection; +using Sprache; + namespace Kyoo.Abstractions.Models.Utils; public abstract record Filter @@ -46,24 +51,154 @@ public abstract record Filter : Filter public record Eq(string property, object value) : Filter; - public record Ne(string property, T2 value) : Filter; + public record Ne(string property, object value) : Filter; - public record Gt(string property, T2 value) : Filter; + public record Gt(string property, object value) : Filter; - public record Ge(string property, T2 value) : Filter; + public record Ge(string property, object value) : Filter; - public record Lt(string property, T2 value) : Filter; + public record Lt(string property, object value) : Filter; - public record Le(string property, T2 value) : Filter; + public record Le(string property, object value) : Filter; - public record Has(string property, T2 value) : Filter; + public record Has(string property, object value) : Filter; public record In(string property, object[] value) : Filter; public record Lambda(Expression> lambda) : Filter; - public static Filter From(string filter) + public static class FilterParsers { + public static readonly Parser> Filter = + Parse.Ref(() => Bracket) + .Or(Parse.Ref(() => Not)) + .Or(Parse.Ref(() => Eq)) + .Or(Parse.Ref(() => Ne)) + .Or(Parse.Ref(() => Gt)) + .Or(Parse.Ref(() => Ge)) + .Or(Parse.Ref(() => Lt)) + .Or(Parse.Ref(() => Le)); + public static readonly Parser> CompleteFilter = + Parse.Ref(() => Or) + .Or(Parse.Ref(() => And)) + .Or(Filter); + + public static readonly Parser> Bracket = + from open in Parse.Char('(').Token() + from filter in CompleteFilter + from close in Parse.Char(')').Token() + select filter; + + public static readonly Parser> AndOperator = Parse.IgnoreCase("and") + .Or(Parse.String("&&")) + .Token(); + + public static readonly Parser> OrOperator = Parse.IgnoreCase("or") + .Or(Parse.String("||")) + .Token(); + + public static readonly Parser> And = Parse.ChainOperator(AndOperator, Filter, (_, a, b) => new Filter.And(a, b)); + + public static readonly Parser> Or = Parse.ChainOperator(OrOperator, And.Or(Filter), (_, a, b) => new Filter.Or(a, b)); + + public static readonly Parser> Not = + from not in Parse.IgnoreCase("not") + .Or(Parse.String("!")) + .Token() + from filter in CompleteFilter + select new Filter.Not(filter); + + private static Parser> _GetOperationParser(Parser op, Func> apply) + { + Parser property = Parse.LetterOrDigit.AtLeastOnce().Text(); + + return property.Then(prop => + { + PropertyInfo? propInfo = typeof(T).GetProperty(prop, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance); + if (propInfo == null) + throw new ValidationException($"The given filter {property} is invalid."); + + Parser value; + + if (propInfo.PropertyType == typeof(int)) + value = Parse.Number.Select(x => int.Parse(x) as object); + else if (propInfo.PropertyType == typeof(float)) + { + value = + from a in Parse.Number + from dot in Parse.Char('.') + from b in Parse.Number + select float.Parse($"{a}.{b}") as object; + } + else if (propInfo.PropertyType == typeof(string)) + { + value = ( + from lq in Parse.Char('"').Or(Parse.Char('\'')) + from str in Parse.AnyChar.Where(x => x is not '"' and not '\'').Many().Text() + from rq in Parse.Char('"').Or(Parse.Char('\'')) + select str + ).Or(Parse.LetterOrDigit.Many().Text()); + } + else if (propInfo.PropertyType.IsEnum) + { + value = Parse.LetterOrDigit.Many().Text().Select(x => + { + if (Enum.TryParse(propInfo.PropertyType, x, true, out object? value)) + return value!; + throw new ValidationException($"Invalid enum value. Unexpected {x}"); + }); + } + // TODO: Support arrays + else + throw new ValidationException("Unfilterable field found"); + + return + from eq in op + from val in value + select apply(prop, val); + }); + } + + public static readonly Parser> Eq = _GetOperationParser( + Parse.IgnoreCase("eq").Or(Parse.String("=")).Token(), + (property, value) => new Eq(property, value) + ); + + public static readonly Parser> Ne = _GetOperationParser( + Parse.IgnoreCase("ne").Or(Parse.String("!=")).Token(), + (property, value) => new Ne(property, value) + ); + + public static readonly Parser> Gt = _GetOperationParser( + Parse.IgnoreCase("gt").Or(Parse.String(">")).Token(), + (property, value) => new Gt(property, value) + ); + + public static readonly Parser> Ge = _GetOperationParser( + Parse.IgnoreCase("ge").Or(Parse.String(">=")).Token(), + (property, value) => new Ge(property, value) + ); + + public static readonly Parser> Lt = _GetOperationParser( + Parse.IgnoreCase("lt").Or(Parse.String("<")).Token(), + (property, value) => new Lt(property, value) + ); + + public static readonly Parser> Le = _GetOperationParser( + Parse.IgnoreCase("le").Or(Parse.String("<=")).Token(), + (property, value) => new Le(property, value) + ); + } + + public static Filter? From(string? filter) + { + if (filter == null) + return null; + + IResult> ret = FilterParsers.CompleteFilter.End().TryParse(filter); + if (ret.WasSuccessful) + return ret.Value; + throw new ValidationException($"Could not parse filter argument: {ret.Message}. Not parsed: {filter[ret.Remainder.Position..]}"); } } diff --git a/back/src/Kyoo.Abstractions/Models/Utils/Pagination.cs b/back/src/Kyoo.Abstractions/Models/Utils/Pagination.cs index e7c1478e..a76444ad 100644 --- a/back/src/Kyoo.Abstractions/Models/Utils/Pagination.cs +++ b/back/src/Kyoo.Abstractions/Models/Utils/Pagination.cs @@ -21,7 +21,7 @@ namespace Kyoo.Abstractions.Controllers /// /// Information about the pagination. How many items should be displayed and where to start. /// - public struct Pagination + public class Pagination { /// /// The count of items to return. diff --git a/back/src/Kyoo.Core/CoreModule.cs b/back/src/Kyoo.Core/CoreModule.cs index 1c938909..df8cf59c 100644 --- a/back/src/Kyoo.Core/CoreModule.cs +++ b/back/src/Kyoo.Core/CoreModule.cs @@ -80,6 +80,7 @@ namespace Kyoo.Core options.Filters.Add(); options.ModelBinderProviders.Insert(0, new SortBinder.Provider()); options.ModelBinderProviders.Insert(0, new IncludeBinder.Provider()); + options.ModelBinderProviders.Insert(0, new FilterBinder.Provider()); }) .AddNewtonsoftJson(x => { diff --git a/back/src/Kyoo.Core/ExceptionFilter.cs b/back/src/Kyoo.Core/ExceptionFilter.cs index c2e5af5a..372f9564 100644 --- a/back/src/Kyoo.Core/ExceptionFilter.cs +++ b/back/src/Kyoo.Core/ExceptionFilter.cs @@ -48,9 +48,6 @@ namespace Kyoo.Core { switch (context.Exception) { - case ArgumentException ex: - context.Result = new BadRequestObjectResult(new RequestError(ex.Message)); - break; case ValidationException ex: context.Result = new BadRequestObjectResult(new RequestError(ex.Message)); break; diff --git a/back/src/Kyoo.Core/Views/Helper/ApiHelper.cs b/back/src/Kyoo.Core/Views/Helper/ApiHelper.cs deleted file mode 100644 index 0dec46a3..00000000 --- a/back/src/Kyoo.Core/Views/Helper/ApiHelper.cs +++ /dev/null @@ -1,204 +0,0 @@ -// Kyoo - A portable and vast media library solution. -// Copyright (c) Kyoo. -// -// See AUTHORS.md and LICENSE file in the project root for full license information. -// -// Kyoo is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// any later version. -// -// Kyoo is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Kyoo. If not, see . - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using JetBrains.Annotations; -using Kyoo.Abstractions.Models; - -namespace Kyoo.Core.Api -{ - /// - /// A static class containing methods to parse the where query string. - /// - public static class ApiHelper - { - /// - /// Make an expression (like - /// - /// compatible with strings). If the expressions are not strings, the given is - /// constructed but if the expressions are strings, this method make the compatible with - /// strings. - /// - /// - /// The expression to make compatible. It should be something like - /// or - /// . - /// - /// The first parameter to compare. - /// The second parameter to compare. - /// A comparison expression compatible with strings - public static BinaryExpression StringCompatibleExpression( - Func operand, - Expression left, - Expression right) - { - if (left.Type != typeof(string)) - return operand(left, right); - MethodCallExpression call = Expression.Call(typeof(string), "Compare", null, left, right); - return operand(call, Expression.Constant(0)); - } - - /// - /// Parse a where query for the given . Items can be filtered by any property - /// of the given type. - /// - /// The list of filters. - /// - /// A custom expression to initially filter a collection. It will be combined with the parsed expression. - /// - /// The type to create filters for. - /// A filter is invalid. - /// An expression representing the filters that can be used anywhere or compiled - public static Expression>? ParseWhere(Dictionary? where, - Expression>? defaultWhere = null) - { - if (where == null || where.Count == 0) - return defaultWhere; - - ParameterExpression param = defaultWhere?.Parameters.First() ?? Expression.Parameter(typeof(T)); - Expression? expression = defaultWhere?.Body; - - foreach ((string key, string desired) in where) - { - if (key == null || desired == null) - throw new ArgumentException("Invalid key/value pair. Can't be null."); - - string value = desired; - string operand = "eq"; - if (desired.Contains(':')) - { - operand = desired.Substring(0, desired.IndexOf(':')); - value = desired.Substring(desired.IndexOf(':') + 1); - } - - PropertyInfo? property = typeof(T).GetProperty(key, BindingFlags.Instance | BindingFlags.Public | BindingFlags.IgnoreCase); - if (property == null) - throw new ArgumentException($"No filterable parameter with the name {key}."); - MemberExpression propertyExpr = Expression.Property(param, property); - - ConstantExpression? valueExpr = null; - bool isList = typeof(IEnumerable).IsAssignableFrom(propertyExpr.Type) && propertyExpr.Type != typeof(string); - if (operand != "ctn" && !typeof(IResource).IsAssignableFrom(propertyExpr.Type) && !isList) - { - Type propertyType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; - object? val; - try - { - val = string.IsNullOrEmpty(value) || value.Equals("null", StringComparison.OrdinalIgnoreCase) - ? null - : Convert.ChangeType(value, propertyType); - } - catch (InvalidCastException) - { - throw new ArgumentException("Comparing two different value's type."); - } - - valueExpr = Expression.Constant(val, property.PropertyType); - } - - Expression condition = operand switch - { - "eq" when isList => _ContainsResourceExpression(propertyExpr, value), - "ctn" => _ContainsResourceExpression(propertyExpr, value), - - "eq" when valueExpr == null => _ResourceEqual(propertyExpr, value), - "not" when valueExpr == null => _ResourceEqual(propertyExpr, value, true), - - "eq" => Expression.Equal(propertyExpr, valueExpr), - "not" => Expression.NotEqual(propertyExpr, valueExpr), - "lt" => StringCompatibleExpression(Expression.LessThan, propertyExpr, valueExpr!), - "lte" => StringCompatibleExpression(Expression.LessThanOrEqual, propertyExpr, valueExpr!), - "gt" => StringCompatibleExpression(Expression.GreaterThan, propertyExpr, valueExpr!), - "gte" => StringCompatibleExpression(Expression.GreaterThanOrEqual, propertyExpr, valueExpr!), - _ => throw new ArgumentException($"Invalid operand: {operand}") - }; - - expression = expression != null - ? Expression.AndAlso(expression, condition) - : condition; - } - - return Expression.Lambda>(expression!, param); - } - - private static Expression _ResourceEqual(Expression parameter, string value, bool notEqual = false) - { - MemberExpression field; - ConstantExpression valueConst; - if (int.TryParse(value, out int id)) - { - field = Expression.Property(parameter, "ID"); - valueConst = Expression.Constant(id); - } - else - { - field = Expression.Property(parameter, "Slug"); - valueConst = Expression.Constant(value); - } - - return notEqual - ? Expression.NotEqual(field, valueConst) - : Expression.Equal(field, valueConst); - } - - private static Expression _ContainsResourceExpression(MemberExpression xProperty, string value) - { - if (xProperty.Type == typeof(string)) - { - // x.PROPRETY.Contains(value); - return Expression.Call(xProperty, typeof(string).GetMethod("Contains", new[] { typeof(string) })!, Expression.Constant(value)); - } - - // x.PROPERTY is either a List<> or a [] - Type inner = xProperty.Type.GetElementType() ?? xProperty.Type.GenericTypeArguments.First(); - - if (inner.IsAssignableTo(typeof(string))) - { - return Expression.Call(typeof(Enumerable), "Contains", new[] { inner }, xProperty, Expression.Constant(value)); - } - - if (inner.IsEnum && Enum.TryParse(inner, value, true, out object? enumValue)) - { - return Expression.Call(typeof(Enumerable), "Contains", new[] { inner }, xProperty, Expression.Constant(enumValue)); - } - - if (!inner.IsAssignableTo(typeof(IResource))) - throw new ArgumentException("Contain (ctn) not appliable for this property."); - - // x => x.PROPERTY.Any(y => y.Slug == value) - Expression? ret = null; - ParameterExpression y = Expression.Parameter(inner, "y"); - foreach (string val in value.Split(',')) - { - LambdaExpression lambda = Expression.Lambda(_ResourceEqual(y, val), y); - Expression iteration = Expression.Call(typeof(Enumerable), "Any", new[] { inner }, xProperty, lambda); - - if (ret == null) - ret = iteration; - else - ret = Expression.AndAlso(ret, iteration); - } - return ret!; - } - } -} diff --git a/back/src/Kyoo.Core/Views/Helper/FilterBinder.cs b/back/src/Kyoo.Core/Views/Helper/FilterBinder.cs new file mode 100644 index 00000000..f740ec01 --- /dev/null +++ b/back/src/Kyoo.Core/Views/Helper/FilterBinder.cs @@ -0,0 +1,57 @@ +// Kyoo - A portable and vast media library solution. +// Copyright (c) Kyoo. +// +// See AUTHORS.md and LICENSE file in the project root for full license information. +// +// Kyoo is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// Kyoo is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Kyoo. If not, see . + +using System.Reflection; +using System.Threading.Tasks; +using Kyoo.Abstractions.Models.Utils; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; + +namespace Kyoo.Core.Api; + +public class FilterBinder : IModelBinder +{ + public Task BindModelAsync(ModelBindingContext bindingContext) + { + ValueProviderResult fields = bindingContext.ValueProvider.GetValue(bindingContext.FieldName); + try + { + object? filter = bindingContext.ModelType.GetMethod(nameof(Filter.From))! + .Invoke(null, new object?[] { fields.FirstValue }); + bindingContext.Result = ModelBindingResult.Success(filter); + return Task.CompletedTask; + } + catch (TargetInvocationException ex) + { + throw ex.InnerException!; + } + } + + public class Provider : IModelBinderProvider + { + public IModelBinder GetBinder(ModelBinderProviderContext context) + { + if (context.Metadata.ModelType.Name == "Filter`1") + { + return new BinderTypeModelBinder(typeof(FilterBinder)); + } + + return null!; + } + } +} diff --git a/back/tests/Kyoo.Tests/Utility/TaskTests.cs b/back/tests/Kyoo.Tests/Utility/TaskTests.cs deleted file mode 100644 index 7151871a..00000000 --- a/back/tests/Kyoo.Tests/Utility/TaskTests.cs +++ /dev/null @@ -1,56 +0,0 @@ -// Kyoo - A portable and vast media library solution. -// Copyright (c) Kyoo. -// -// See AUTHORS.md and LICENSE file in the project root for full license information. -// -// Kyoo is free software: you can redistribute it and/or modify -// it under the terms of the GNU General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// any later version. -// -// Kyoo is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with Kyoo. If not, see . - -using System; -using System.Threading; -using System.Threading.Tasks; -using Kyoo.Utils; -using Xunit; - -namespace Kyoo.Tests.Utility -{ - public class TaskTests - { - [Fact] - public async Task ThenTest() - { - await Assert.ThrowsAsync(() => Task.FromResult(1) - .Then(_ => throw new ArgumentException())); - Assert.Equal(1, await Task.FromResult(1) - .Then(_ => { })); - - static async Task Faulted() - { - await Task.Delay(1); - throw new ArgumentException(); - } - await Assert.ThrowsAsync(() => Faulted().Then(_ => KAssert.Fail())); - - static async Task Infinite() - { - await Task.Delay(100000); - return 1; - } - - CancellationTokenSource token = new(); - token.Cancel(); - await Assert.ThrowsAsync(() => Task.Run(Infinite, token.Token) - .Then(_ => { })); - } - } -}