From 23beef2904f3001e08ba79ac4749a538529c34f3 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Mon, 7 Feb 2022 12:34:13 +0100 Subject: [PATCH] Tus support. (#839) * Tus support. * Save file. * Fix registration. * Fix local cache usage. * Make assert more flexible. * Cleanup * Another cleanup. * better tests. * Fix tests? * Update packages. * Easier tests? * Upload packages. * Another attempt. * Check for exception. * Fix test --- .../Squidex.Extensions.csproj | 2 +- backend/src/Migrations/Migrations.csproj | 2 +- .../Squidex.Domain.Apps.Core.Model.csproj | 2 +- ...Squidex.Domain.Apps.Core.Operations.csproj | 2 +- ...quidex.Domain.Apps.Entities.MongoDb.csproj | 2 +- .../AppProvider.cs | 85 ++--- .../Assets/AssetCleanupGrain.cs | 40 +++ .../Assets/IAssetCleanupGrain.cs | 15 + .../GraphQL/CachingGraphQLResolver.cs | 9 +- .../GraphQL/GraphQLExecutionContext.cs | 53 +-- .../GraphQL/Types/Assets/AssetGraphType.cs | 12 +- .../GraphQL/Types/Contents/ContentActions.cs | 3 +- .../Types/Contents/ContentResolvers.cs | 5 +- .../Contents/GraphQL/Types/Resolvers.cs | 4 +- .../Contents/Queries/QueryExecutionContext.cs | 43 ++- .../Squidex.Domain.Apps.Entities.csproj | 3 +- .../Squidex.Domain.Apps.Events.csproj | 2 +- .../Squidex.Domain.Users.MongoDb.csproj | 2 +- .../Squidex.Domain.Users.csproj | 2 +- ...quidex.Infrastructure.GetEventStore.csproj | 2 +- ...goDBHealthCheck.cs => MongoHealthCheck.cs} | 4 +- .../Squidex.Infrastructure.MongoDb.csproj | 2 +- .../Squidex.Infrastructure.RabbitMq.csproj | 2 +- .../Squidex.Infrastructure.csproj | 6 +- .../src/Squidex.Shared/Squidex.Shared.csproj | 2 +- backend/src/Squidex.Web/Squidex.Web.csproj | 2 +- .../Controllers/Assets/AssetsController.cs | 80 ++++- .../Assets/Models/CreateAssetDto.cs | 22 ++ .../Squidex/Config/Domain/AssetServices.cs | 27 +- .../Squidex/Config/Domain/ResizeServices.cs | 2 - .../Squidex/Config/Domain/StoreServices.cs | 18 +- backend/src/Squidex/Squidex.csproj | 19 +- .../Squidex.Domain.Apps.Core.Tests.csproj | 2 +- .../Assets/AssetCleanupGrainTests.cs | 39 +++ .../Squidex.Domain.Apps.Entities.Tests.csproj | 4 +- .../Squidex.Domain.Users.Tests.csproj | 2 +- .../Squidex.Infrastructure.Tests.csproj | 2 +- .../Squidex.Web.Tests.csproj | 2 +- .../TestSuite.ApiTests/AppWorkflowsTest.cs | 1 + .../TestSuite.ApiTests/AssetFormatTests.cs | 1 - .../TestSuite.ApiTests/AssetTests.cs | 305 ++++++++++++++++++ .../TestSuite.ApiTests.csproj | 5 +- .../TestSuite.LoadTests.csproj | 2 +- .../TestSuite.Shared/Model/TestEntity.cs | 2 + .../Model/TestEntityWithReferences.cs | 2 + .../TestSuite.Shared/TestSuite.Shared.csproj | 4 +- backend/tools/TestSuite/TestSuite.sln | 4 +- 47 files changed, 698 insertions(+), 155 deletions(-) create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCleanupGrain.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetCleanupGrain.cs rename backend/src/Squidex.Infrastructure.MongoDb/Diagnostics/{MongoDBHealthCheck.cs => MongoHealthCheck.cs} (90%) create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCleanupGrainTests.cs diff --git a/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj b/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj index 190df1f11..1631c233a 100644 --- a/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj +++ b/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj @@ -18,7 +18,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Migrations/Migrations.csproj b/backend/src/Migrations/Migrations.csproj index 5f7d52abb..e7aaf543b 100644 --- a/backend/src/Migrations/Migrations.csproj +++ b/backend/src/Migrations/Migrations.csproj @@ -6,7 +6,7 @@ enable - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj b/backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj index 950043ca2..d23bbca87 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj @@ -12,7 +12,7 @@ True - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj index 47d2fd8be..20e9a7e11 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj @@ -21,7 +21,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj index d0933051a..eb77d918a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj @@ -19,7 +19,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs b/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs index 767a80845..c1fd1ebaa 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs @@ -58,16 +58,13 @@ namespace Squidex.Domain.Apps.Entities { var cacheKey = AppCacheKey(appId); - if (localCache.TryGetValue(cacheKey, out var cached) && cached is IAppEntity found) + var app = await GetOrCreate(cacheKey, () => { - return found; - } - - var app = await indexForApps.GetAppAsync(appId, canCache, ct); + return indexForApps.GetAppAsync(appId, canCache, ct); + }); if (app != null) { - localCache.Add(cacheKey, app); localCache.Add(AppCacheKey(app.Name), app); } @@ -79,16 +76,13 @@ namespace Squidex.Domain.Apps.Entities { var cacheKey = AppCacheKey(appName); - if (localCache.TryGetValue(cacheKey, out var cached) && cached is IAppEntity found) + var app = await GetOrCreate(cacheKey, () => { - return found; - } - - var app = await indexForApps.GetAppAsync(appName, canCache, ct); + return indexForApps.GetAppAsync(appName, canCache, ct); + }); if (app != null) { - localCache.Add(cacheKey, app); localCache.Add(AppCacheKey(app.Id), app); } @@ -100,16 +94,13 @@ namespace Squidex.Domain.Apps.Entities { var cacheKey = SchemaCacheKey(appId, name); - if (localCache.TryGetValue(cacheKey, out var cached) && cached is ISchemaEntity found) + var schema = await GetOrCreate(cacheKey, () => { - return found; - } - - var schema = await indexForSchemas.GetSchemaAsync(appId, name, canCache, ct); + return indexForSchemas.GetSchemaAsync(appId, name, canCache, ct); + }); if (schema != null) { - localCache.Add(cacheKey, schema); localCache.Add(SchemaCacheKey(appId, schema.Id), schema); } @@ -121,16 +112,13 @@ namespace Squidex.Domain.Apps.Entities { var cacheKey = SchemaCacheKey(appId, id); - if (localCache.TryGetValue(cacheKey, out var cached) && cached is ISchemaEntity found) + var schema = await GetOrCreate(cacheKey, () => { - return found; - } - - var schema = await indexForSchemas.GetSchemaAsync(appId, id, canCache, ct); + return indexForSchemas.GetSchemaAsync(appId, id, canCache, ct); + }); if (schema != null) { - localCache.Add(cacheKey, schema); localCache.Add(SchemaCacheKey(appId, schema.SchemaDef.Name), schema); } @@ -140,40 +128,43 @@ namespace Squidex.Domain.Apps.Entities public async Task> GetUserAppsAsync(string userId, PermissionSet permissions, CancellationToken ct = default) { - var apps = await localCache.GetOrCreateAsync($"GetUserApps({userId})", () => + var apps = await GetOrCreate($"GetUserApps({userId})", () => { - return indexForApps.GetAppsForUserAsync(userId, permissions, ct); + return indexForApps.GetAppsForUserAsync(userId, permissions, ct)!; }); - return apps; + return apps?.ToList() ?? new List(); } public async Task> GetSchemasAsync(DomainId appId, CancellationToken ct = default) { - var schemas = await localCache.GetOrCreateAsync($"GetSchemasAsync({appId})", () => + var schemas = await GetOrCreate($"GetSchemasAsync({appId})", () => { - return indexForSchemas.GetSchemasAsync(appId, ct); + return indexForSchemas.GetSchemasAsync(appId, ct)!; }); - foreach (var schema in schemas) + if (schemas != null) { - localCache.Add(SchemaCacheKey(appId, schema.Id), schema); - localCache.Add(SchemaCacheKey(appId, schema.SchemaDef.Name), schema); + foreach (var schema in schemas) + { + localCache.Add(SchemaCacheKey(appId, schema.Id), schema); + localCache.Add(SchemaCacheKey(appId, schema.SchemaDef.Name), schema); + } } - return schemas; + return schemas?.ToList() ?? new List(); } public async Task> GetRulesAsync(DomainId appId, CancellationToken ct = default) { - var rules = await localCache.GetOrCreateAsync($"GetRulesAsync({appId})", () => + var rules = await GetOrCreate($"GetRulesAsync({appId})", () => { - return indexForRules.GetRulesAsync(appId, ct); + return indexForRules.GetRulesAsync(appId, ct)!; }); - return rules.ToList(); + return rules?.ToList() ?? new List(); } public async Task GetRuleAsync(DomainId appId, DomainId id, @@ -184,6 +175,28 @@ namespace Squidex.Domain.Apps.Entities return rules.Find(x => x.Id == id); } + public async Task GetOrCreate(object key, Func> creator) where T : class + { + if (localCache.TryGetValue(key, out var value)) + { + switch (value) + { + case T typed: + return typed; + case Task typedTask: + return await typedTask; + default: + return null; + } + } + + var result = creator(); + + localCache.Add(key, result); + + return await result; + } + private static string AppCacheKey(DomainId appId) { return $"APPS_ID_{appId}"; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCleanupGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCleanupGrain.cs new file mode 100644 index 000000000..bb7d0dcf9 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetCleanupGrain.cs @@ -0,0 +1,40 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Orleans; +using Orleans.Runtime; +using tusdotnet.Interfaces; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public sealed class AssetCleanupGrain : Grain, IRemindable, IAssetCleanupGrain + { + private readonly ITusExpirationStore expirationStore; + + public AssetCleanupGrain(ITusExpirationStore expirationStore) + { + this.expirationStore = expirationStore; + } + + public override Task OnActivateAsync() + { + RegisterOrUpdateReminder("Cleanup", TimeSpan.Zero, TimeSpan.FromMinutes(10)); + + return base.OnActivateAsync(); + } + + public Task ActivateAsync() + { + return Task.CompletedTask; + } + + public Task ReceiveReminder(string reminderName, TickStatus status) + { + return expirationStore.RemoveExpiredFilesAsync(default); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetCleanupGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetCleanupGrain.cs new file mode 100644 index 000000000..e525b8890 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetCleanupGrain.cs @@ -0,0 +1,15 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public interface IAssetCleanupGrain : IBackgroundGrain + { + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs index 38d0cd365..591e7a547 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs @@ -30,6 +30,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL private readonly ISchemasHash schemasHash; private readonly IServiceProvider serviceProvider; private readonly GraphQLOptions options; + private readonly SharedTypes sharedTypes; private sealed record CacheEntry(GraphQLSchema Model, string Hash, Instant Created); @@ -45,6 +46,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL this.schemasHash = schemasHash; this.serviceProvider = serviceProvider; this.options = options.Value; + + sharedTypes = serviceProvider.GetRequiredService(); } public async Task ConfigureAsync(ExecutionOptions executionOptions) @@ -88,10 +91,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var hash = await schemasHash.ComputeHashAsync(app, schemas); - return new CacheEntry( - new Builder(app, serviceProvider.GetRequiredService()).BuildSchema(schemas), - hash, - SystemClock.Instance.GetCurrentInstant()); + return new CacheEntry(new Builder(app, sharedTypes).BuildSchema(schemas), + hash, SystemClock.Instance.GetCurrentInstant()); } private static object CreateCacheKey(DomainId appId, string etag) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs index 5b776dbfd..2a8c468f3 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs @@ -6,13 +6,10 @@ // ========================================================================== using GraphQL.DataLoader; -using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Contents.Queries; using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Json.Objects; -using Squidex.Log; using Squidex.Shared.Users; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL @@ -22,37 +19,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL private static readonly List EmptyAssets = new List(); private static readonly List EmptyContents = new List(); private readonly IDataLoaderContextAccessor dataLoaders; - private readonly IUserResolver userResolver; - - public IUrlGenerator UrlGenerator { get; } - - public ICommandBus CommandBus { get; } - - public ISemanticLog Log { get; } public override Context Context { get; } - public GraphQLExecutionContext(IAssetQueryService assetQuery, IContentQueryService contentQuery, - Context context, - IDataLoaderContextAccessor dataLoaders, - ICommandBus commandBus, - IUrlGenerator urlGenerator, - IUserResolver userResolver, - ISemanticLog log) - : base(assetQuery, contentQuery) + public GraphQLExecutionContext(IServiceProvider serviceProvider, IDataLoaderContextAccessor dataLoaders, Context context) + : base(serviceProvider) { this.dataLoaders = dataLoaders; - this.userResolver = userResolver; - - CommandBus = commandBus; - - UrlGenerator = urlGenerator; Context = context.Clone(b => b .WithoutCleanup() .WithoutContentEnrichment()); - - Log = log; } public async Task FindUserAsync(RefToken refToken, @@ -93,34 +70,38 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return content; } - public Task> GetReferencedAssetsAsync(IJsonValue value, + public async Task> GetReferencedAssetsAsync(IJsonValue value, CancellationToken ct) { var ids = ParseIds(value); if (ids == null) { - return Task.FromResult>(EmptyAssets); + return EmptyAssets; } var dataLoader = GetAssetsLoader(); - return LoadManyAsync(dataLoader, ids, ct); + var result = await dataLoader.LoadAsync(ids).GetResultAsync(ct); + + return result?.NotNull().ToList() ?? EmptyAssets; } - public Task> GetReferencedContentsAsync(IJsonValue value, + public async Task> GetReferencedContentsAsync(IJsonValue value, CancellationToken ct) { var ids = ParseIds(value); if (ids == null) { - return Task.FromResult>(EmptyContents); + return EmptyContents; } var dataLoader = GetContentsLoader(); - return LoadManyAsync(dataLoader, ids, ct); + var result = await dataLoader.LoadAsync(ids).GetResultAsync(ct); + + return result?.NotNull().ToList() ?? EmptyContents; } private IDataLoader GetAssetsLoader() @@ -150,20 +131,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL return dataLoaders.Context.GetOrAddBatchLoader(nameof(GetUserLoader), async (batch, ct) => { - var result = await userResolver.QueryManyAsync(batch.ToArray(), ct); + var result = await Resolve().QueryManyAsync(batch.ToArray(), ct); return result; }); } - private static async Task> LoadManyAsync(IDataLoader dataLoader, ICollection keys, - CancellationToken ct) where T : class - { - var contents = await Task.WhenAll(keys.Select(x => dataLoader.LoadAsync(x).GetResultAsync(ct))); - - return contents.NotNull().ToList(); - } - private static ICollection? ParseIds(IJsonValue value) { try diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetGraphType.cs index 04e8703a5..88ce45063 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetGraphType.cs @@ -251,17 +251,23 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets private static readonly IFieldResolver Url = Resolve((asset, _, context) => { - return context.UrlGenerator.AssetContent(asset.AppId, asset.Id.ToString()); + var urlGenerator = context.Resolve(); + + return urlGenerator.AssetContent(asset.AppId, asset.Id.ToString()); }); private static readonly IFieldResolver SourceUrl = Resolve((asset, _, context) => { - return context.UrlGenerator.AssetSource(asset.AppId, asset.Id, asset.FileVersion); + var urlGenerator = context.Resolve(); + + return urlGenerator.AssetSource(asset.AppId, asset.Id, asset.FileVersion); }); private static readonly IFieldResolver ThumbnailUrl = Resolve((asset, _, context) => { - return context.UrlGenerator.AssetThumbnail(asset.AppId, asset.Id.ToString(), asset.Type); + var urlGenerator = context.Resolve(); + + return urlGenerator.AssetThumbnail(asset.AppId, asset.Id.ToString(), asset.Type); }); private static IFieldResolver Resolve(Func resolver) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs index f35334c94..7bc42043d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs @@ -13,6 +13,7 @@ using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Translations; using Squidex.Shared; @@ -443,7 +444,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents contentCommand.SchemaId = schemaId; contentCommand.ExpectedVersion = fieldContext.GetArgument("expectedVersion", EtagVersion.Any); - var commandContext = await context.CommandBus.PublishAsync(contentCommand); + var commandContext = await context.Resolve().PublishAsync(contentCommand); return commandContext.PlainResult!; }); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentResolvers.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentResolvers.cs index 75b77a60e..01c005ccc 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentResolvers.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentResolvers.cs @@ -7,6 +7,7 @@ using GraphQL; using GraphQL.Resolvers; +using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.ConvertContent; using Squidex.Infrastructure; @@ -24,9 +25,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents public static readonly IFieldResolver Url = Resolve((content, _, context) => { - var appId = content.AppId; + var urlGenerator = context.Resolve(); - return context.UrlGenerator.ContentUI(appId, content.SchemaId, content.Id); + return urlGenerator.ContentUI(content.AppId, content.SchemaId, content.Id); }); public static readonly IFieldResolver FlatData = Resolve((content, c, context) => diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs index f378c9e60..c80f48565 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs @@ -62,7 +62,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types } catch (Exception ex) { - executionContext.Log.LogWarning(ex, w => w + executionContext.Resolve().LogWarning(ex, w => w .WriteProperty("action", "resolveField") .WriteProperty("status", "failed") .WriteProperty("field", context.FieldDefinition.Name)); @@ -104,7 +104,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types } catch (Exception ex) { - executionContext.Log.LogWarning(ex, w => w + executionContext.Resolve().LogWarning(ex, w => w .WriteProperty("action", "resolveField") .WriteProperty("status", "failed") .WriteProperty("field", context.FieldDefinition.Name)); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs index e244f0e6a..1959e9695 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Collections.Concurrent; +using Microsoft.Extensions.DependencyInjection; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Infrastructure; @@ -16,24 +17,22 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries private readonly SemaphoreSlim maxRequests = new SemaphoreSlim(10); private readonly ConcurrentDictionary cachedContents = new ConcurrentDictionary(); private readonly ConcurrentDictionary cachedAssets = new ConcurrentDictionary(); - private readonly IContentQueryService contentQuery; - private readonly IAssetQueryService assetQuery; public abstract Context Context { get; } - protected QueryExecutionContext(IAssetQueryService assetQuery, IContentQueryService contentQuery) + public IServiceProvider Services { get; } + + protected QueryExecutionContext(IServiceProvider serviceProvider) { - Guard.NotNull(assetQuery); - Guard.NotNull(contentQuery); + Guard.NotNull(serviceProvider); - this.assetQuery = assetQuery; - this.contentQuery = contentQuery; + Services = serviceProvider; } public virtual Task FindContentAsync(string schemaIdOrName, DomainId id, long version, CancellationToken ct) { - return contentQuery.FindAsync(Context, schemaIdOrName, id, version, ct); + return Resolve().FindAsync(Context, schemaIdOrName, id, version, ct); } public virtual async Task> QueryAssetsAsync(Q q, @@ -44,7 +43,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries await maxRequests.WaitAsync(ct); try { - assets = await assetQuery.QueryAsync(Context, null, q, ct); + assets = await Resolve().QueryAsync(Context, null, q, ct); } finally { @@ -67,7 +66,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries await maxRequests.WaitAsync(ct); try { - contents = await contentQuery.QueryAsync(Context, schemaIdOrName, q, ct); + contents = await Resolve().QueryAsync(Context, schemaIdOrName, q, ct); } finally { @@ -96,7 +95,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries await maxRequests.WaitAsync(ct); try { - assets = await assetQuery.QueryAsync(Context, null, Q.Empty.WithIds(notLoadedAssets).WithoutTotal(), ct); + var q = Q.Empty.WithIds(notLoadedAssets).WithoutTotal(); + + assets = await Resolve().QueryAsync(Context, null, q, ct); } finally { @@ -126,7 +127,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries await maxRequests.WaitAsync(ct); try { - contents = await contentQuery.QueryAsync(Context, Q.Empty.WithIds(notLoadedContents).WithoutTotal(), ct); + var q = Q.Empty.WithIds(notLoadedContents).WithoutTotal(); + + contents = await Resolve().QueryAsync(Context, q, ct); } finally { @@ -141,5 +144,21 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries return ids.Select(cachedContents.GetOrDefault).NotNull().ToList(); } + + public T Resolve() where T : class + { + var key = typeof(T).Name; + + if (TryGetValue(key, out var stored) && stored is T typed) + { + return typed; + } + + typed = Services.GetRequiredService(); + + this[key] = typed; + + return typed; + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj b/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj index 5d95d7de1..878f2b733 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj +++ b/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj @@ -22,7 +22,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -38,6 +38,7 @@ + diff --git a/backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj b/backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj index 2635111ac..f00d8eaa6 100644 --- a/backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj +++ b/backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj @@ -14,7 +14,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj b/backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj index a535a50c8..2ba42f114 100644 --- a/backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj +++ b/backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj @@ -19,7 +19,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj b/backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj index 85d79ac66..67e945cda 100644 --- a/backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj +++ b/backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj @@ -18,7 +18,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj b/backend/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj index d111ed3d7..3f0297a7a 100644 --- a/backend/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj +++ b/backend/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj @@ -15,7 +15,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Infrastructure.MongoDb/Diagnostics/MongoDBHealthCheck.cs b/backend/src/Squidex.Infrastructure.MongoDb/Diagnostics/MongoHealthCheck.cs similarity index 90% rename from backend/src/Squidex.Infrastructure.MongoDb/Diagnostics/MongoDBHealthCheck.cs rename to backend/src/Squidex.Infrastructure.MongoDb/Diagnostics/MongoHealthCheck.cs index 1fa864178..43c9485f3 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/Diagnostics/MongoDBHealthCheck.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/Diagnostics/MongoHealthCheck.cs @@ -10,11 +10,11 @@ using MongoDB.Driver; namespace Squidex.Infrastructure.Diagnostics { - public sealed class MongoDBHealthCheck : IHealthCheck + public sealed class MongoHealthCheck : IHealthCheck { private readonly IMongoDatabase mongoDatabase; - public MongoDBHealthCheck(IMongoDatabase mongoDatabase) + public MongoHealthCheck(IMongoDatabase mongoDatabase) { this.mongoDatabase = mongoDatabase; } diff --git a/backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj b/backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj index 1754a5e93..760ec1614 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj +++ b/backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj @@ -14,7 +14,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Infrastructure.RabbitMq/Squidex.Infrastructure.RabbitMq.csproj b/backend/src/Squidex.Infrastructure.RabbitMq/Squidex.Infrastructure.RabbitMq.csproj index 311343fab..24aeaaccb 100644 --- a/backend/src/Squidex.Infrastructure.RabbitMq/Squidex.Infrastructure.RabbitMq.csproj +++ b/backend/src/Squidex.Infrastructure.RabbitMq/Squidex.Infrastructure.RabbitMq.csproj @@ -11,7 +11,7 @@ True - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj index 294017a71..e6fb249fe 100644 --- a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj +++ b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj @@ -14,7 +14,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -31,8 +31,8 @@ - - + + diff --git a/backend/src/Squidex.Shared/Squidex.Shared.csproj b/backend/src/Squidex.Shared/Squidex.Shared.csproj index ae360bdce..7f75888e8 100644 --- a/backend/src/Squidex.Shared/Squidex.Shared.csproj +++ b/backend/src/Squidex.Shared/Squidex.Shared.csproj @@ -9,7 +9,7 @@ True - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Web/Squidex.Web.csproj b/backend/src/Squidex.Web/Squidex.Web.csproj index ade9a860b..1f4a8fff5 100644 --- a/backend/src/Squidex.Web/Squidex.Web.csproj +++ b/backend/src/Squidex.Web/Squidex.Web.csproj @@ -19,7 +19,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index d3f374798..473b27318 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -36,18 +36,21 @@ namespace Squidex.Areas.Api.Controllers.Assets private readonly IAssetUsageTracker assetStatsRepository; private readonly IAppPlansProvider appPlansProvider; private readonly ITagService tagService; + private readonly AssetTusRunner assetTusRunner; public AssetsController( ICommandBus commandBus, IAssetQueryService assetQuery, IAssetUsageTracker assetStatsRepository, IAppPlansProvider appPlansProvider, - ITagService tagService) + ITagService tagService, + AssetTusRunner assetTusRunner) : base(commandBus) { + this.appPlansProvider = appPlansProvider; this.assetQuery = assetQuery; this.assetStatsRepository = assetStatsRepository; - this.appPlansProvider = appPlansProvider; + this.assetTusRunner = assetTusRunner; this.tagService = tagService; } @@ -218,6 +221,43 @@ namespace Squidex.Areas.Api.Controllers.Assets return CreatedAtAction(nameof(GetAsset), new { app, id = response.Id }, response); } + /// + /// Upload a new asset using tus.io. + /// + /// The name of the app. + /// + /// 201 => Asset created. + /// 400 => Asset request not valid. + /// 413 => Asset exceeds the maximum upload size. + /// 404 => App not found. + /// + /// + /// Use the tus protocol to upload an asset. + /// + [OpenApiIgnore] + [Route("apps/{app}/assets/tus/{**fileId}")] + [ProducesResponseType(typeof(AssetDto), 201)] + [AssetRequestSizeLimit] + [ApiPermissionOrAnonymous(Permissions.AppAssetsCreate)] + [ApiCosts(1)] + public async Task PostAssetTus(string app) + { + var url = Url.Action(null, new { app, fileId = (object?)null })!; + + var (result, file) = await assetTusRunner.InvokeAsync(HttpContext, url); + + if (file != null) + { + var command = CreateAssetDto.ToCommand(file); + + var response = await InvokeCommandAsync(command); + + return CreatedAtAction(nameof(GetAsset), new { app, id = response.Id }, response); + } + + return result; + } + /// /// Bulk update assets. /// @@ -305,6 +345,42 @@ namespace Squidex.Areas.Api.Controllers.Assets return Ok(response); } + /// + /// Replace asset content using tus. + /// + /// The name of the app. + /// The id of the asset. + /// + /// 200 => Asset updated. + /// 400 => Asset request not valid. + /// 413 => Asset exceeds the maximum upload size. + /// 404 => Asset or app not found. + /// + /// + /// Use the tus protocol to upload an asset. + /// + [OpenApiIgnore] + [Route("apps/{app}/assets/{id}/content/tus/{**fileId}")] + [ProducesResponseType(typeof(AssetDto), StatusCodes.Status200OK)] + [AssetRequestSizeLimit] + [ApiPermissionOrAnonymous(Permissions.AppAssetsUpload)] + [ApiCosts(1)] + public async Task PutAssetContentTus(string app, DomainId id) + { + var (result, file) = await assetTusRunner.InvokeAsync(HttpContext, Url.Action(null, new { app, id })!); + + if (file != null) + { + var command = new UpdateAsset { File = file, AssetId = id }; + + var response = await InvokeCommandAsync(command); + + return Ok(response); + } + + return result; + } + /// /// Update an asset. /// diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetDto.cs index 5bd199380..70b88c35f 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetDto.cs @@ -38,6 +38,28 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models [FromQuery] public bool Duplicate { get; set; } + public static CreateAsset ToCommand(AssetTusFile file) + { + var command = new CreateAsset { File = file }; + + if (file.Metadata.TryGetValue("id", out var id) && !string.IsNullOrWhiteSpace(id)) + { + command.AssetId = DomainId.Create(id); + } + + if (file.Metadata.TryGetValue("parentId", out var parentId) && !string.IsNullOrWhiteSpace(parentId)) + { + command.ParentId = DomainId.Create(parentId); + } + + if (file.Metadata.TryGetValue("duplicate", out var duplicate) && bool.TryParse(duplicate, out var parsed)) + { + command.Duplicate = parsed; + } + + return command; + } + public CreateAsset ToCommand(AssetFile file) { var command = SimpleMapper.Map(this, new CreateAsset { File = file }); diff --git a/backend/src/Squidex/Config/Domain/AssetServices.cs b/backend/src/Squidex/Config/Domain/AssetServices.cs index 5d0789ecb..4727705ea 100644 --- a/backend/src/Squidex/Config/Domain/AssetServices.cs +++ b/backend/src/Squidex/Config/Domain/AssetServices.cs @@ -18,6 +18,8 @@ using Squidex.Domain.Apps.Entities.Search; using Squidex.Hosting; using Squidex.Infrastructure; using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Orleans; +using tusdotnet.Interfaces; namespace Squidex.Config.Domain { @@ -49,12 +51,18 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .AsSelf(); - services.AddSingletonAs() - .AsSelf(); - services.AddTransientAs() .As(); + services.AddSingletonAs() + .AsSelf(); + + services.AddSingletonAs() + .As().As(); + + services.AddSingletonAs() + .AsSelf(); + services.AddTransientAs() .As(); @@ -87,6 +95,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + + services.AddSingletonAs>() + .AsSelf(); } public static void AddSquidexAssetInfrastructure(this IServiceCollection services, IConfiguration config) @@ -176,10 +187,12 @@ namespace Squidex.Config.Domain } }); - services.AddSingletonAs(c => new DelegateInitializer( - c.GetRequiredService().GetType().Name, - c.GetRequiredService().InitializeAsync)) - .As(); + services.AddSingletonAs(c => + { + var service = c.GetRequiredService(); + + return new DelegateInitializer(service.GetType().Name, service.InitializeAsync); + }).As(); } } } diff --git a/backend/src/Squidex/Config/Domain/ResizeServices.cs b/backend/src/Squidex/Config/Domain/ResizeServices.cs index dd456814e..55b81ad19 100644 --- a/backend/src/Squidex/Config/Domain/ResizeServices.cs +++ b/backend/src/Squidex/Config/Domain/ResizeServices.cs @@ -6,8 +6,6 @@ // ========================================================================== using Squidex.Assets; -using Squidex.Assets.ImageMagick; -using Squidex.Assets.ImageSharp; using Squidex.Assets.Remote; namespace Squidex.Config.Domain diff --git a/backend/src/Squidex/Config/Domain/StoreServices.cs b/backend/src/Squidex/Config/Domain/StoreServices.cs index 831a71d32..6ce280749 100644 --- a/backend/src/Squidex/Config/Domain/StoreServices.cs +++ b/backend/src/Squidex/Config/Domain/StoreServices.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Identity; using Migrations.Migrations.MongoDb; using MongoDB.Driver; using Newtonsoft.Json; +using Squidex.Assets; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Apps.DomainObject; using Squidex.Domain.Apps.Entities.Apps.Repositories; @@ -34,6 +35,7 @@ using Squidex.Domain.Apps.Entities.Schemas.Repositories; using Squidex.Domain.Users; using Squidex.Domain.Users.InMemory; using Squidex.Domain.Users.MongoDb; +using Squidex.Hosting; using Squidex.Infrastructure; using Squidex.Infrastructure.Diagnostics; using Squidex.Infrastructure.EventSourcing; @@ -96,7 +98,10 @@ namespace Squidex.Config.Domain .As(); services.AddHealthChecks() - .AddCheck("MongoDB", tags: new[] { "node" }); + .AddCheck("MongoDB", tags: new[] { "node" }); + + services.AddSingletonAs>() + .As>(); services.AddSingletonAs() .As(); @@ -175,6 +180,13 @@ namespace Squidex.Config.Domain services.AddSingleton(typeof(IPersistenceFactory<>), typeof(Store<>)); + + services.AddSingletonAs(c => + { + var service = c.GetRequiredService>(); + + return new DelegateInitializer(service.GetType().Name, service.InitializeAsync); + }).As(); } private static IMongoClient GetClient(string configuration) @@ -182,9 +194,9 @@ namespace Squidex.Config.Domain return Singletons.GetOrAdd(configuration, s => new MongoClient(s)); } - private static IMongoDatabase GetDatabase(IServiceProvider service, string name) + private static IMongoDatabase GetDatabase(IServiceProvider serviceProvider, string name) { - return service.GetRequiredService().GetDatabase(name); + return serviceProvider.GetRequiredService().GetDatabase(name); } } } diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index 323d7d3fa..dea59fb86 100644 --- a/backend/src/Squidex/Squidex.csproj +++ b/backend/src/Squidex/Squidex.csproj @@ -40,7 +40,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -74,13 +74,16 @@ - - - - - - - + + + + + + + + + + diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj b/backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj index d5d7436fd..f8b6b895f 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj @@ -15,7 +15,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCleanupGrainTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCleanupGrainTests.cs new file mode 100644 index 000000000..2820db8e9 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCleanupGrainTests.cs @@ -0,0 +1,39 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using FakeItEasy; +using tusdotnet.Interfaces; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public class AssetCleanupGrainTests + { + private readonly ITusExpirationStore expirationStore = A.Fake(); + private readonly AssetCleanupGrain sut; + + public AssetCleanupGrainTests() + { + sut = new AssetCleanupGrain(expirationStore); + } + + [Fact] + public async Task Should_do_nothing_on_activate() + { + await sut.ActivateAsync(); + } + + [Fact] + public async Task Should_call_expiration_store_when_reminder_invoked() + { + await sut.ReceiveReminder("Reminder", default); + + A.CallTo(() => expirationStore.RemoveExpiredFilesAsync(default)) + .MustHaveHappened(); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj index 0acffa654..1a84d2c12 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj @@ -25,7 +25,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -34,7 +34,7 @@ - + diff --git a/backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj b/backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj index 77209db91..e3990a881 100644 --- a/backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj +++ b/backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj @@ -16,7 +16,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj b/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj index 572f42b79..8e33dc038 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj +++ b/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj @@ -17,7 +17,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj b/backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj index 78c9e66a1..36f5942a3 100644 --- a/backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj +++ b/backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj @@ -13,7 +13,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/AppWorkflowsTest.cs b/backend/tools/TestSuite/TestSuite.ApiTests/AppWorkflowsTest.cs index 6db28bd92..fb98886f1 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/AppWorkflowsTest.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/AppWorkflowsTest.cs @@ -9,6 +9,7 @@ using Squidex.ClientLibrary.Management; using TestSuite.Fixtures; using Xunit; +#pragma warning disable MA0048 // File name must match type name #pragma warning disable SA1300 // Element should begin with upper-case letter #pragma warning disable SA1507 // Code should not contain multiple blank lines in a row diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs index 7b9d4f096..c827be9f6 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs @@ -9,7 +9,6 @@ using Squidex.ClientLibrary.Management; using TestSuite.Fixtures; using Xunit; -#pragma warning disable xUnit1004 // Test methods should not be skipped #pragma warning disable SA1300 // Element should begin with upper-case letter #pragma warning disable CS0618 // Type or member is obsolete diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs index c549e11ad..52963844e 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Assets; using Squidex.ClientLibrary.Management; using TestSuite.Fixtures; using Xunit; @@ -39,6 +40,136 @@ namespace TestSuite.ApiTests } } + [Fact] + public async Task Should_upload_asset_using_tus() + { + // STEP 1: Create asset + var fileParameter = FileParameter.FromPath("Assets/SampleVideo_1280x720_1mb.mp4"); + + var reportedException = (Exception)null; + var reportedProgress = new List(); + var reportedAsset = (AssetDto)null; + + await using (fileParameter.Data) + { + await _.Assets.UploadNewAssetAsync(_.AppName, fileParameter, new AssetUploadOptions + { + ProgressHandler = new AssetDelegatingProgressHandler + { + OnProgressAsync = (@event, _) => + { + reportedProgress.Add(@event.Progress); + return Task.CompletedTask; + }, + OnCompletedAsync = (@event, _) => + { + reportedAsset = @event.Asset; + return Task.CompletedTask; + }, + OnFailedAsync = (@event, _) => + { + reportedException = @event.Exception; + return Task.CompletedTask; + } + } + }); + } + + Assert.NotEmpty(reportedProgress); + Assert.NotNull(reportedAsset); + Assert.Null(reportedException); + + await using (var stream = new FileStream("Assets/SampleVideo_1280x720_1mb.mp4", FileMode.Open)) + { + var downloaded = await _.DownloadAsync(reportedAsset); + + // Should dowload with correct size. + Assert.Equal(stream.Length, downloaded.Length); + } + } + + [Fact] + public async Task Should_upload_asset_using_tus_in_chunks() + { + // STEP 1: Create asset + var fileParameter = FileParameter.FromPath("Assets/SampleVideo_1280x720_1mb.mp4"); + + var pausingStream = new PauseStream(fileParameter.Data, 0.25); + var pausingFile = new FileParameter(pausingStream, fileParameter.FileName, fileParameter.ContentType); + + var numUploads = 0; + var reportedException = (Exception)null; + var reportedProgress = new List(); + var reportedAsset = (AssetDto)null; + var fileId = (string)null; + + await using (pausingFile.Data) + { + using var cts = new CancellationTokenSource(5000); + + while (reportedAsset == null) + { + pausingStream.Reset(); + + if (pausingStream.Position == pausingStream.Length) + { + throw new InvalidOperationException("Stream end reached."); + } + + await _.Assets.UploadNewAssetAsync(_.AppName, pausingFile, new AssetUploadOptions + { + ProgressHandler = new AssetDelegatingProgressHandler + { + OnCreatedAsync = (@event, _) => + { + fileId = @event.FileId; + return Task.CompletedTask; + }, + OnProgressAsync = (@event, _) => + { + reportedProgress.Add(@event.Progress); + return Task.CompletedTask; + }, + OnCompletedAsync = (@event, _) => + { + reportedAsset = @event.Asset; + return Task.CompletedTask; + }, + OnFailedAsync = (@event, _) => + { + if (!@event.Exception.ToString().Contains("PAUSED", StringComparison.OrdinalIgnoreCase)) + { + reportedException = @event.Exception; + } + + return Task.CompletedTask; + } + }, + FileId = fileId + }, cts.Token); + + Assert.Null(reportedException); + + await Task.Delay(50, cts.Token); + + numUploads++; + } + } + + Assert.NotEmpty(reportedProgress); + Assert.NotNull(reportedAsset); + Assert.Null(reportedException); + Assert.True(numUploads > 1); + + await using (var stream = new FileStream("Assets/SampleVideo_1280x720_1mb.mp4", FileMode.Open)) + { + var downloaded = await _.DownloadAsync(reportedAsset); + + // Should dowload with correct size. + Assert.Equal(stream.Length, downloaded.Length); + } + } + [Fact] public async Task Should_upload_asset_with_custom_id() { @@ -98,6 +229,143 @@ namespace TestSuite.ApiTests } } + [Fact] + public async Task Should_replace_asset_using_tus() + { + // STEP 1: Create asset + var asset_1 = await _.UploadFileAsync("Assets/logo-squared.png", "image/png"); + + + // STEP 2: Reupload asset + var fileParameter = FileParameter.FromPath("Assets/SampleVideo_1280x720_1mb.mp4"); + + var reportedException = (Exception)null; + var reportedProgress = new List(); + var reportedAsset = (AssetDto)null; + + await using (fileParameter.Data) + { + await _.Assets.UploadExistingAssetAsync(_.AppName, asset_1.Id, fileParameter, new AssetUploadOptions + { + ProgressHandler = new AssetDelegatingProgressHandler + { + OnProgressAsync = (@event, _) => + { + reportedProgress.Add(@event.Progress); + return Task.CompletedTask; + }, + OnCompletedAsync = (@event, _) => + { + reportedAsset = @event.Asset; + return Task.CompletedTask; + }, + OnFailedAsync = (@event, _) => + { + reportedException = @event.Exception; + return Task.CompletedTask; + } + } + }); + } + + Assert.NotNull(reportedAsset); + Assert.Equal(Enumerable.Range(1, 100).ToArray(), reportedProgress.ToArray()); + + await using (var stream = new FileStream("Assets/SampleVideo_1280x720_1mb.mp4", FileMode.Open)) + { + var downloaded = await _.DownloadAsync(reportedAsset); + + // Should dowload with correct size. + Assert.Equal(stream.Length, downloaded.Length); + } + } + + [Fact] + public async Task Should_replace_asset_using_tus_in_chunks() + { + // STEP 1: Create asset + var asset_1 = await _.UploadFileAsync("Assets/logo-squared.png", "image/png"); + + + // STEP 2: Reupload asset + var fileParameter = FileParameter.FromPath("Assets/SampleVideo_1280x720_1mb.mp4"); + + var pausingStream = new PauseStream(fileParameter.Data, 0.25); + var pausingFile = new FileParameter(pausingStream, fileParameter.FileName, fileParameter.ContentType); + + var numUploads = 0; + var reportedException = (Exception)null; + var reportedProgress = new List(); + var reportedAsset = (AssetDto)null; + var fileId = (string)null; + + await using (pausingFile.Data) + { + using var cts = new CancellationTokenSource(5000); + + while (reportedAsset == null) + { + pausingStream.Reset(); + + if (pausingStream.Position == pausingStream.Length) + { + throw new InvalidOperationException("Stream end reached."); + } + + await _.Assets.UploadExistingAssetAsync(_.AppName, asset_1.Id, pausingFile, new AssetUploadOptions + { + ProgressHandler = new AssetDelegatingProgressHandler + { + OnCreatedAsync = (@event, _) => + { + fileId = @event.FileId; + return Task.CompletedTask; + }, + OnProgressAsync = (@event, _) => + { + reportedProgress.Add(@event.Progress); + return Task.CompletedTask; + }, + OnCompletedAsync = (@event, _) => + { + reportedAsset = @event.Asset; + return Task.CompletedTask; + }, + OnFailedAsync = (@event, _) => + { + if (!@event.Exception.ToString().Contains("PAUSED", StringComparison.OrdinalIgnoreCase)) + { + reportedException = @event.Exception; + } + + return Task.CompletedTask; + } + }, + FileId = fileId + }, cts.Token); + + Assert.Null(reportedException); + + await Task.Delay(50, cts.Token); + + numUploads++; + } + } + + Assert.NotEmpty(reportedProgress); + Assert.NotNull(reportedAsset); + Assert.Null(reportedException); + Assert.True(numUploads > 1); + + await using (var stream = new FileStream("Assets/SampleVideo_1280x720_1mb.mp4", FileMode.Open)) + { + var downloaded = await _.DownloadAsync(reportedAsset); + + // Should dowload with correct size. + Assert.Equal(stream.Length, downloaded.Length); + } + } + [Fact] public async Task Should_annote_asset() { @@ -356,5 +624,42 @@ namespace TestSuite.ApiTests Assert.NotEqual(asset_1.FileSize, asset_2.FileSize); } + + public class PauseStream : DelegateStream + { + private readonly double pauseAfter = 1; + private int totalRead; + + public PauseStream(Stream innerStream, double pauseAfter) + : base(innerStream) + { + this.pauseAfter = pauseAfter; + } + + public void Reset() + { + totalRead = 0; + } + + public override async ValueTask ReadAsync(Memory buffer, + CancellationToken cancellationToken = default) + { + if (Position >= Length) + { + return 0; + } + + if (totalRead >= Length * pauseAfter) + { + throw new InvalidOperationException("PAUSED"); + } + + var bytesRead = await base.ReadAsync(buffer, cancellationToken); + + totalRead += bytesRead; + + return bytesRead; + } + } } } diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj b/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj index 635981deb..bc32720dd 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj +++ b/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj @@ -14,12 +14,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + diff --git a/backend/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj b/backend/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj index 155055ba4..0061253e3 100644 --- a/backend/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj +++ b/backend/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj @@ -6,7 +6,7 @@ enable - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/tools/TestSuite/TestSuite.Shared/Model/TestEntity.cs b/backend/tools/TestSuite/TestSuite.Shared/Model/TestEntity.cs index 87c29c7c8..1054dce86 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/Model/TestEntity.cs +++ b/backend/tools/TestSuite/TestSuite.Shared/Model/TestEntity.cs @@ -10,6 +10,8 @@ using Newtonsoft.Json.Linq; using Squidex.ClientLibrary; using Squidex.ClientLibrary.Management; +#pragma warning disable MA0048 // File name must match type name + namespace TestSuite.Model { public sealed class TestEntity : Content diff --git a/backend/tools/TestSuite/TestSuite.Shared/Model/TestEntityWithReferences.cs b/backend/tools/TestSuite/TestSuite.Shared/Model/TestEntityWithReferences.cs index 5649986c9..41d0028c8 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/Model/TestEntityWithReferences.cs +++ b/backend/tools/TestSuite/TestSuite.Shared/Model/TestEntityWithReferences.cs @@ -9,6 +9,8 @@ using Newtonsoft.Json; using Squidex.ClientLibrary; using Squidex.ClientLibrary.Management; +#pragma warning disable MA0048 // File name must match type name + namespace TestSuite.Model { public sealed class TestEntityWithReferences : Content diff --git a/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj b/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj index c0933f8ab..d84d660af 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj +++ b/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj @@ -11,7 +11,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -21,7 +21,7 @@ - + diff --git a/backend/tools/TestSuite/TestSuite.sln b/backend/tools/TestSuite/TestSuite.sln index 8bc1f5817..5fcbe0f8f 100644 --- a/backend/tools/TestSuite/TestSuite.sln +++ b/backend/tools/TestSuite/TestSuite.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29613.14 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestSuite.Shared", "TestSuite.Shared\TestSuite.Shared.csproj", "{37484845-5542-4E52-AB00-C4576B84FE75}" EndProject