From 083890487b399babdc6670c3b159b58b1e9daaa1 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sun, 10 Nov 2024 17:09:00 +0100 Subject: [PATCH 1/4] Update GitHub Action Versions (#1135) Co-authored-by: github-actions[bot] --- .github/workflows/dev.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index f15d3d374..a321794db 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Prepare - Inject short Variables - uses: rlespinasse/github-slug-action@v4.5.0 + uses: rlespinasse/github-slug-action@v5.0.0 - name: Prepare - Setup QEMU uses: docker/setup-qemu-action@v3.2.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9fb748a9..dffb24f04 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Prepare - Inject short Variables - uses: rlespinasse/github-slug-action@v4.5.0 + uses: rlespinasse/github-slug-action@v5.0.0 - name: Prepare - Setup QEMU uses: docker/setup-qemu-action@v3.2.0 From 09f7b21c52f3d805d8d912106257fffb834cc3f4 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sun, 10 Nov 2024 17:09:10 +0100 Subject: [PATCH 2/4] Custom indexes. (#1134) * Custom indexes. * Adjust test. --- .../Contents/IndexParser.cs | 62 +++++++ .../Contents/MongoContentCollection.cs | 30 +++ .../Contents/MongoContentRepository.cs | 19 ++ .../Contents/MongoShardedContentRepository.cs | 19 ++ .../Contents/Operations/Adapt.cs | 47 +++++ .../Operations/QueryInDedicatedCollection.cs | 58 ++++++ .../Backup/BackupJob.cs | 3 + .../Backup/RestoreJob.cs | 12 +- .../Contents/Indexes/CreateIndexJob.cs | 107 +++++++++++ .../Contents/Indexes/DropIndexJob.cs | 80 ++++++++ .../Repositories/IContentRepository.cs | 10 + .../Rules/Runner/RuleRunnerJob.cs | 19 +- .../States/IndexDefinition.cs | 46 +++++ .../StringExtensions.cs | 7 + backend/src/Squidex.Shared/PermissionIds.cs | 1 + backend/src/Squidex.Web/Resources.cs | 2 + .../Controllers/Apps/AppClientsController.cs | 2 +- .../Apps/AppLanguagesController.cs | 2 +- .../Controllers/Apps/AppRolesController.cs | 2 +- .../Api/Controllers/Apps/AppsController.cs | 2 +- .../Api/Controllers/Rules/RulesController.cs | 2 +- .../Schemas/Models/CreateIndexDto.cs | 34 ++++ .../Controllers/Schemas/Models/IndexDto.cs | 51 ++++++ .../Schemas/Models/IndexFieldDto.cs | 32 ++++ .../Controllers/Schemas/Models/IndexesDto.cs | 44 +++++ .../Controllers/Schemas/Models/SchemaDto.cs | 6 + .../Schemas/SchemaFieldsController.cs | 4 +- .../Schemas/SchemaIndexesController.cs | 107 +++++++++++ .../Controllers/Schemas/SchemasController.cs | 2 +- .../Api/Controllers/Teams/TeamsController.cs | 2 +- .../Config/Messaging/MessagingServices.cs | 7 + .../Contents/Indexes/CreateIndexJobTests.cs | 172 ++++++++++++++++++ .../Contents/Indexes/DropIndexJobTests.cs | 132 ++++++++++++++ .../Contents/MongoDb/AdaptionTests.cs | 59 ++++++ .../Contents/MongoDb/IndexParserTests.cs | 124 +++++++++++++ .../States/IndexDefinitionTests.cs | 55 ++++++ .../StringExtensionsTests.cs | 8 + .../forms/editors/toggle.component.html | 2 + .../forms/editors/toggle.component.scss | 28 +++ .../angular/forms/editors/toggle.stories.ts | 56 ++++++ ...uld_create_app_from_templates.verified.txt | 6 + 41 files changed, 1446 insertions(+), 17 deletions(-) create mode 100644 backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/IndexParser.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/CreateIndexJob.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/DropIndexJob.cs create mode 100644 backend/src/Squidex.Infrastructure/States/IndexDefinition.cs create mode 100644 backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateIndexDto.cs create mode 100644 backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexDto.cs create mode 100644 backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexFieldDto.cs create mode 100644 backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexesDto.cs create mode 100644 backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaIndexesController.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/CreateIndexJobTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/DropIndexJobTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/AdaptionTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/IndexParserTests.cs create mode 100644 backend/tests/Squidex.Infrastructure.Tests/States/IndexDefinitionTests.cs create mode 100644 frontend/src/app/framework/angular/forms/editors/toggle.stories.ts diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/IndexParser.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/IndexParser.cs new file mode 100644 index 000000000..fc7677dcd --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/IndexParser.cs @@ -0,0 +1,62 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Diagnostics.CodeAnalysis; +using MongoDB.Bson; +using Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations; +using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.MongoDb.Contents; + +public static class IndexParser +{ + public static bool TryParse(BsonDocument source, string prefix, [MaybeNullWhen(false)] out IndexDefinition index) + { + index = null!; + + if (!source.TryGetValue("name", out var name) || name.BsonType != BsonType.String) + { + return false; + } + + if (!name.AsString.StartsWith(prefix, StringComparison.Ordinal)) + { + return false; + } + + if (!source.TryGetValue("key", out var keys) || keys.BsonType != BsonType.Document) + { + return false; + } + + var definition = new IndexDefinition(); + foreach (var property in keys.AsBsonDocument) + { + if (property.Value.BsonType != BsonType.Int32) + { + return false; + } + + var fieldName = Adapt.MapPathReverse(property.Name).ToString(); + + var order = property.Value.AsInt32 < 0 ? + SortOrder.Descending : + SortOrder.Ascending; + + definition.Add(new IndexField(fieldName, order)); + } + + if (definition.Count == 0) + { + return false; + } + + index = definition; + return true; + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs index 59bcf7abb..e25ddc897 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs @@ -11,6 +11,7 @@ using NodaTime; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations; using Squidex.Infrastructure; using Squidex.Infrastructure.MongoDb; @@ -318,4 +319,33 @@ public sealed class MongoContentCollection : MongoRepositoryBase> GetIndexesAsync(DomainId appId, DomainId schemaId, + CancellationToken ct = default) + { + if (queryInDedicatedCollection != null) + { + return await queryInDedicatedCollection.GetIndexesAsync(appId, schemaId, ct); + } + + return []; + } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs index e171ef434..3006e4ab9 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs @@ -20,6 +20,7 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.MongoDb.Contents; @@ -130,6 +131,24 @@ public partial class MongoContentRepository : MongoBase, ICo return GetCollection(SearchScope.All).ResetScheduledAsync(appId, contentId, ct); } + public Task CreateIndexAsync(DomainId appId, DomainId schemaId, IndexDefinition index, + CancellationToken ct = default) + { + return GetCollection(SearchScope.All).CreateIndexAsync(appId, schemaId, index, ct); + } + + public Task> GetIndexesAsync(DomainId appId, DomainId schemaId, + CancellationToken ct = default) + { + return GetCollection(SearchScope.All).GetIndexesAsync(appId, schemaId, ct); + } + + public Task DropIndexAsync(DomainId appId, DomainId schemaId, string name, + CancellationToken ct = default) + { + return GetCollection(SearchScope.All).DropIndexAsync(appId, schemaId, name, ct); + } + private MongoContentCollection GetCollection(SearchScope scope) { return scope == SearchScope.All ? collectionComplete : collectionPublished; diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoShardedContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoShardedContentRepository.cs index a3a48e1cf..542256041 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoShardedContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoShardedContentRepository.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Runtime.CompilerServices; +using System.Xml.Linq; using NodaTime; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Contents; @@ -79,6 +80,24 @@ public sealed class MongoShardedContentRepository : ShardedSnapshotStore> GetIndexesAsync(DomainId appId, DomainId schemaId, + CancellationToken ct = default) + { + return Shard(appId).GetIndexesAsync(appId, schemaId, ct); + } + public async IAsyncEnumerable StreamScheduledWithoutDataAsync(Instant now, SearchScope scope, [EnumeratorCancellation] CancellationToken ct = default) { diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Adapt.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Adapt.cs index ce4c3a940..ff4b2984e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Adapt.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Adapt.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using GraphQL; using MongoDB.Bson.Serialization; using Squidex.Infrastructure; using Squidex.Infrastructure.MongoDb; @@ -16,7 +17,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations; public static class Adapt { private static Dictionary pathMap; + private static Dictionary pathReverseMap; private static Dictionary propertyMap; + private static Dictionary propertyReverseMap; public static IReadOnlyDictionary PropertyMap { @@ -28,13 +31,29 @@ public static class Adapt StringComparer.OrdinalIgnoreCase); } + public static IReadOnlyDictionary PropertyReverseMap + { + get => propertyReverseMap ??= + BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).AllMemberMaps + .ToDictionary( + x => x.ElementName, + x => x.MemberName.ToCamelCase(), + StringComparer.OrdinalIgnoreCase); + } + public static IReadOnlyDictionary PathMap { get => pathMap ??= PropertyMap.ToDictionary(x => x.Key, x => (PropertyPath)x.Value); } + public static IReadOnlyDictionary PathReverseMap + { + get => pathReverseMap ??= PropertyReverseMap.ToDictionary(x => x.Key, x => (PropertyPath)x.Value); + } + public static PropertyPath MapPath(PropertyPath path) { + // Shortcut to prevent allocations for most used field names. if (path.Count == 1 && PathMap.TryGetValue(path[0], out var mappedPath)) { return mappedPath; @@ -52,12 +71,40 @@ public static class Adapt for (var i = 1; i < path.Count; i++) { + // MongoDB does not accept all field names. result[i] = result[i].UnescapeEdmField().JsonToBsonName().JsonEscape(); } return result; } + public static PropertyPath MapPathReverse(PropertyPath path) + { + // Shortcut to prevent allocations for most used field names. + if (path.Count == 1 && PathReverseMap.TryGetValue(path[0], out var mappedPath)) + { + return mappedPath; + } + + var result = new List(path); + + if (result.Count > 0) + { + if (PropertyReverseMap.TryGetValue(path[0], out var mapped)) + { + result[0] = mapped; + } + } + + for (var i = 1; i < path.Count; i++) + { + // MongoDB does not accept all field names. + result[i] = result[i].EscapeEdmField().BsonToJsonName().JsonUnescape().ToCamelCase(); + } + + return result; + } + public static ClrQuery AdjustToModel(this ClrQuery query, DomainId appId) { if (query.Filter != null) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs index 6f1886bfb..72d5de7b5 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Collections.Concurrent; +using MongoDB.Bson; using MongoDB.Driver; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Contents; @@ -141,6 +142,63 @@ internal sealed class QueryInDedicatedCollection : MongoBase await collection.DeleteOneAsync(session, x => x.DocumentId == value.DocumentId, null, ct); } + public async Task DropIndexAsync(DomainId appId, DomainId schemaId, string name, + CancellationToken ct) + { + var collection = await GetCollectionAsync(appId, schemaId); + + await collection.Indexes.DropOneAsync(name, ct); + } + + public async Task> GetIndexesAsync(DomainId appId, DomainId schemaId, + CancellationToken ct = default) + { + var result = new List(); + + var collection = await GetCollectionAsync(appId, schemaId); + var colIndexes = await collection.Indexes.ListAsync(ct); + + foreach (var index in await colIndexes.ToListAsync(ct)) + { + if (IndexParser.TryParse(index, "custom_", out var definition)) + { + result.Add(definition); + } + } + + return result; + } + + public async Task CreateIndexAsync(DomainId appId, DomainId schemaId, IndexDefinition index, + CancellationToken ct) + { + var collection = await GetCollectionAsync(appId, schemaId); + + var definition = Index.Combine( + index.Select(field => + { + var path = Adapt.MapPath(field.Name).ToString(); + + if (field.Order == SortOrder.Ascending) + { + return Index.Ascending(path); + } + + return Index.Descending(path); + })); + + var name = $"custom_{index.ToName()}"; + + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + definition, + new CreateIndexOptions + { + Name = name, + }), + cancellationToken: ct); + } + private static FilterDefinition BuildFilter(FilterNode? filter) { var filters = new List> diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupJob.cs index 6c6930863..83c07bfd2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupJob.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/BackupJob.cs @@ -50,6 +50,9 @@ public sealed class BackupJob : IJobRunner public static JobRequest BuildRequest(RefToken actor, App app) { + Guard.NotNull(actor); + Guard.NotNull(app); + return JobRequest.Create( actor, TaskName, diff --git a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs index 96298ab5c..889544b74 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs @@ -79,6 +79,9 @@ public sealed class RestoreJob : IJobRunner public static JobRequest BuildRequest(RefToken actor, Uri url, string? appName) { + Guard.NotNull(actor); + Guard.NotNull(url); + return JobRequest.Create( actor, TaskName, @@ -92,9 +95,14 @@ public sealed class RestoreJob : IJobRunner public async Task RunAsync(JobRunContext context, CancellationToken ct) { - if (!context.Job.Arguments.TryGetValue(ArgUrl, out var urlValue) || !Uri.TryCreate(urlValue, UriKind.Absolute, out var url)) + if (!context.Job.Arguments.TryGetValue(ArgUrl, out var urlValue)) + { + throw new DomainException($"Argument '{ArgUrl}' missing."); + } + + if (!Uri.TryCreate(urlValue, UriKind.Absolute, out var url)) { - throw new DomainException("Argument missing."); + throw new DomainException($"Argument '{ArgUrl}' is not a valid URL."); } var state = new State diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/CreateIndexJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/CreateIndexJob.cs new file mode 100644 index 000000000..129412460 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/CreateIndexJob.cs @@ -0,0 +1,107 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Contents.Repositories; +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Contents.Indexes; + +public sealed class CreateIndexJob : IJobRunner +{ + public const string TaskName = "createIndex"; + public const string ArgAppId = "appId"; + public const string ArgAppName = "appName"; + public const string ArgSchemaId = "schemaId"; + public const string ArgSchemaName = "schemaName"; + public const string ArgFieldName = "field_"; + private readonly IContentRepository contentRepository; + + public string Name => TaskName; + + public CreateIndexJob(IContentRepository contentRepository) + { + this.contentRepository = contentRepository; + } + + public static JobRequest BuildRequest(RefToken actor, App app, Schema schema, IndexDefinition index) + { + Guard.NotNull(actor); + Guard.NotNull(app); + Guard.NotNull(schema); + Guard.NotNull(index); + + var args = new Dictionary + { + [ArgAppId] = app.Id.ToString(), + [ArgAppName] = app.Name, + [ArgSchemaId] = schema.Id.ToString(), + [ArgSchemaName] = schema.Name + }; + + foreach (var field in index) + { + args[$"{ArgFieldName}{field.Name}"] = field.Order.ToString(); + } + + return JobRequest.Create( + actor, + TaskName, + args) with + { + AppId = app.NamedId() + }; + } + + public async Task RunAsync(JobRunContext context, + CancellationToken ct) + { + // The other arguments are just there for debugging purposes. Therefore do not validate them. + if (!context.Job.Arguments.TryGetValue(ArgSchemaId, out var schemaId)) + { + throw new DomainException($"Argument '{ArgSchemaId}' missing."); + } + + if (!context.Job.Arguments.TryGetValue(ArgSchemaName, out var schemaName)) + { + throw new DomainException($"Argument '{ArgSchemaName}' missing."); + } + + var index = new IndexDefinition(); + + foreach (var (arg, value) in context.Job.Arguments) + { + if (!arg.StartsWith(ArgFieldName, StringComparison.Ordinal)) + { + continue; + } + + var field = arg[ArgFieldName.Length..]; + + if (!Enum.TryParse(value, out var order)) + { + throw new DomainException($"Invalid sort order {order} for field {field}."); + } + + index.Add(new IndexField(field, order)); + } + + if (index.Count == 0) + { + throw new DomainException("Index does not contain an field."); + } + + // Use a readable name to describe the job. + context.Job.Description = $"Schema {schemaName}: Create index {index.ToName()}"; + + await contentRepository.CreateIndexAsync(context.OwnerId, DomainId.Create(schemaId), index, ct); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/DropIndexJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/DropIndexJob.cs new file mode 100644 index 000000000..63a823eab --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/DropIndexJob.cs @@ -0,0 +1,80 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Contents.Repositories; +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.Indexes; + +public sealed class DropIndexJob : IJobRunner +{ + public const string TaskName = "dropIndex"; + public const string ArgAppId = "appId"; + public const string ArgAppName = "appName"; + public const string ArgSchemaId = "schemaId"; + public const string ArgSchemaName = "schemaName"; + public const string ArgIndexName = "indexName"; + private readonly IContentRepository contentRepository; + + public string Name => TaskName; + + public DropIndexJob(IContentRepository contentRepository) + { + this.contentRepository = contentRepository; + } + + public static JobRequest BuildRequest(RefToken actor, App app, Schema schema, string name) + { + Guard.NotNull(actor); + Guard.NotNull(app); + Guard.NotNull(schema); + Guard.NotNullOrEmpty(name); + + return JobRequest.Create( + actor, + TaskName, + new Dictionary + { + [ArgAppId] = app.Id.ToString(), + [ArgAppName] = app.Name, + [ArgSchemaId] = schema.Id.ToString(), + [ArgSchemaName] = schema.Name, + [ArgIndexName] = name + }) with + { + AppId = app.NamedId() + }; + } + + public async Task RunAsync(JobRunContext context, + CancellationToken ct) + { + // The other arguments are just there for debugging purposes. Therefore do not validate them. + if (!context.Job.Arguments.TryGetValue(ArgSchemaId, out var schemaId)) + { + throw new DomainException($"Argument '{ArgSchemaId}' missing."); + } + + if (!context.Job.Arguments.TryGetValue(ArgSchemaName, out var schemaName)) + { + throw new DomainException($"Argument '{ArgSchemaName}' missing."); + } + + if (!context.Job.Arguments.TryGetValue(ArgIndexName, out var indexName)) + { + throw new DomainException($"Argument '{ArgIndexName}' missing."); + } + + // Use a readable name to describe the job. + context.Job.Description = $"Schema {schemaName}: Drop index {indexName}"; + + await contentRepository.DropIndexAsync(context.OwnerId, DomainId.Create(schemaId), indexName, ct); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs index c3ea96fe5..0d4ee612c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs @@ -11,6 +11,7 @@ using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Contents.Repositories; @@ -45,4 +46,13 @@ public interface IContentRepository Task ResetScheduledAsync(DomainId appId, DomainId contentId, SearchScope scope, CancellationToken ct = default); + + Task CreateIndexAsync(DomainId appId, DomainId schemaId, IndexDefinition index, + CancellationToken ct = default); + + Task DropIndexAsync(DomainId appId, DomainId schemaId, string name, + CancellationToken ct = default); + + Task> GetIndexesAsync(DomainId appId, DomainId schemaId, + CancellationToken ct = default); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerJob.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerJob.cs index fb57e0c8b..bc15d81c9 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerJob.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerJob.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Amazon.Runtime.Internal.Endpoints.StandardLibrary; using Microsoft.Extensions.Logging; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.HandleRules; @@ -69,6 +70,9 @@ public sealed class RuleRunnerJob : IJobRunner public static JobRequest BuildRequest(RefToken actor, App app, DomainId ruleId, bool snapshot) { + Guard.NotNull(actor); + Guard.NotNull(app); + return JobRequest.Create( actor, TaskName, @@ -87,16 +91,17 @@ public sealed class RuleRunnerJob : IJobRunner { if (!context.Job.Arguments.TryGetValue(ArgRuleId, out var ruleId)) { - throw new DomainException("Argument missing."); + throw new DomainException($"Argument '{ArgRuleId}' missing."); } var rule = await appProvider.GetRuleAsync(context.OwnerId, DomainId.Create(ruleId), ct) ?? throw new DomainObjectNotFoundException(ruleId); - var fromSnapshot = string.Equals(context.Job.Arguments.GetValueOrDefault(ArgSnapshot), "true", StringComparison.OrdinalIgnoreCase); + var fromSnapshotArg = context.Job.Arguments.GetValueOrDefault(ArgSnapshot); + var fromSnapshotValue = string.Equals(fromSnapshotArg, "true", StringComparison.OrdinalIgnoreCase); // Use a readable name to describe the job. - SetDescription(context, rule, fromSnapshot); + SetDescription(context, rule, fromSnapshotValue); // Also run disabled rules, because we want to enable rules to be only used with manual trigger. var ruleContext = new RuleContext @@ -107,7 +112,7 @@ public sealed class RuleRunnerJob : IJobRunner Rule = rule, }; - if (fromSnapshot && ruleService.CanCreateSnapshotEvents(rule)) + if (fromSnapshotValue && ruleService.CanCreateSnapshotEvents(rule)) { await EnqueueFromSnapshotsAsync(ruleContext, ct); } @@ -166,7 +171,8 @@ public sealed class RuleRunnerJob : IJobRunner throw result.EnrichmentError; } - log.LogWarning(result.EnrichmentError, "Failed to run rule with ID {ruleId}, continue with next job.", result.Rule?.Id); + log.LogWarning(result.EnrichmentError, "Failed to run rule with ID {ruleId}, continue with next job.", + result.Rule?.Id); } } } @@ -206,7 +212,8 @@ public sealed class RuleRunnerJob : IJobRunner throw result.EnrichmentError; } - log.LogWarning(result.EnrichmentError, "Failed to run rule with ID {ruleId}, continue with next job.", result.Rule?.Id); + log.LogWarning(result.EnrichmentError, "Failed to run rule with ID {ruleId}, continue with next job.", + result.Rule?.Id); } } } diff --git a/backend/src/Squidex.Infrastructure/States/IndexDefinition.cs b/backend/src/Squidex.Infrastructure/States/IndexDefinition.cs new file mode 100644 index 000000000..e8d568659 --- /dev/null +++ b/backend/src/Squidex.Infrastructure/States/IndexDefinition.cs @@ -0,0 +1,46 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Text; +using Squidex.Infrastructure.Queries; + +#pragma warning disable MA0048 // File name must match type name +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +namespace Squidex.Infrastructure.States; + +public sealed class IndexDefinition : List +{ + public string ToName() + { + var sb = new StringBuilder(); + + foreach (var field in this) + { + if (sb.Length > 0) + { + sb.Append('_'); + } + + sb.Append(field.Name); + sb.Append('_'); + + if (field.Order == SortOrder.Ascending) + { + sb.Append("asc"); + } + else + { + sb.Append("desc"); + } + } + + return sb.ToString(); + } +} + +public sealed record IndexField(string Name, SortOrder Order); diff --git a/backend/src/Squidex.Infrastructure/StringExtensions.cs b/backend/src/Squidex.Infrastructure/StringExtensions.cs index 40504f98d..e7a02a292 100644 --- a/backend/src/Squidex.Infrastructure/StringExtensions.cs +++ b/backend/src/Squidex.Infrastructure/StringExtensions.cs @@ -30,6 +30,13 @@ public static partial class StringExtensions return value; } + public static string JsonUnescape(this string value) + { + value = JsonSerializer.Deserialize($"\"{value}\"", JsonEscapeOptions)!; + + return value; + } + public static bool IsEmail(this string? value) { return value != null && RegexEmail.IsMatch(value); diff --git a/backend/src/Squidex.Shared/PermissionIds.cs b/backend/src/Squidex.Shared/PermissionIds.cs index d5f3d9a90..dae8a43fe 100644 --- a/backend/src/Squidex.Shared/PermissionIds.cs +++ b/backend/src/Squidex.Shared/PermissionIds.cs @@ -204,6 +204,7 @@ public static class PermissionIds public const string AppSchemasScripts = "squidex.apps.{app}.schemas.{schema}.scripts"; public const string AppSchemasPublish = "squidex.apps.{app}.schemas.{schema}.publish"; public const string AppSchemasDelete = "squidex.apps.{app}.schemas.{schema}.delete"; + public const string AppSchemasIndexes = "squidex.apps.{app}.schemas.{schema}.indexes"; // App Contents public const string AppContents = "squidex.apps.{app}.contents.{schema}"; diff --git a/backend/src/Squidex.Web/Resources.cs b/backend/src/Squidex.Web/Resources.cs index ef2936711..de13d53e3 100644 --- a/backend/src/Squidex.Web/Resources.cs +++ b/backend/src/Squidex.Web/Resources.cs @@ -44,6 +44,8 @@ public sealed class Resources public bool CanDeleteSchema(string schema) => Can(PermissionIds.AppSchemasDelete, schema); + public bool CanManageIndexes(string schema) => Can(PermissionIds.AppSchemasIndexes, schema); + public bool CanCreateSchema => Can(PermissionIds.AppSchemasCreate); public bool CanUpdateSettings => Can(PermissionIds.AppUpdateSettings); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs index 811346158..47a844185 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs @@ -67,7 +67,7 @@ public sealed class AppClientsController : ApiController /// [HttpPost] [Route("apps/{app}/clients/")] - [ProducesResponseType(typeof(ClientsDto), 201)] + [ProducesResponseType(typeof(ClientsDto), StatusCodes.Status201Created)] [ApiPermissionOrAnonymous(PermissionIds.AppClientsCreate)] [ApiCosts(1)] public async Task PostClient(string app, [FromBody] CreateClientDto request) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs index 312b9c2aa..fd2e9747e 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs @@ -61,7 +61,7 @@ public sealed class AppLanguagesController : ApiController /// App not found. [HttpPost] [Route("apps/{app}/languages/")] - [ProducesResponseType(typeof(AppLanguagesDto), 201)] + [ProducesResponseType(typeof(AppLanguagesDto), StatusCodes.Status201Created)] [ApiPermissionOrAnonymous(PermissionIds.AppLanguagesCreate)] [ApiCosts(1)] public async Task PostLanguage(string app, [FromBody] AddLanguageDto request) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs index ada0c5464..5c66e9fcd 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs @@ -88,7 +88,7 @@ public sealed class AppRolesController : ApiController /// App not found. [HttpPost] [Route("apps/{app}/roles/")] - [ProducesResponseType(typeof(RolesDto), 201)] + [ProducesResponseType(typeof(RolesDto), StatusCodes.Status201Created)] [ApiPermissionOrAnonymous(PermissionIds.AppRolesCreate)] [ApiCosts(1)] public async Task PostRole(string app, [FromBody] AddRoleDto request) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index 2c8fdfb37..95e29c0f6 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -128,7 +128,7 @@ public sealed class AppsController : ApiController /// [HttpPost] [Route("apps/")] - [ProducesResponseType(typeof(AppDto), 201)] + [ProducesResponseType(typeof(AppDto), StatusCodes.Status201Created)] [ApiPermission] [ApiCosts(0)] public async Task PostApp([FromBody] CreateAppDto request) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs index 30916a097..53e9336fe 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs @@ -112,7 +112,7 @@ public sealed class RulesController : ApiController /// App not found. [HttpPost] [Route("apps/{app}/rules/")] - [ProducesResponseType(typeof(RuleDto), 201)] + [ProducesResponseType(typeof(RuleDto), StatusCodes.Status201Created)] [ApiPermissionOrAnonymous(PermissionIds.AppRulesCreate)] [ApiCosts(1)] public async Task PostRule(string app, [FromBody] CreateRuleDto request) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateIndexDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateIndexDto.cs new file mode 100644 index 000000000..ffc33549d --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateIndexDto.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Validation; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models; + +[OpenApiRequest] +public sealed class CreateIndexDto +{ + /// + /// The index fields. + /// + [LocalizedRequired] + public List Fields { get; set; } + + public IndexDefinition ToIndex() + { + var result = new IndexDefinition(); + + foreach (var field in Fields) + { + result.Add(new IndexField(field.Name, field.Order)); + } + + return result; + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexDto.cs new file mode 100644 index 000000000..e5d65628a --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexDto.cs @@ -0,0 +1,51 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Validation; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models; + +public sealed class IndexDto : Resource +{ + /// + /// The name of the index. + /// + [LocalizedRequired] + public string Name { get; set; } + + /// + /// The index fields. + /// + [LocalizedRequired] + public List Fields { get; set; } + + public static IndexDto FromDomain(IndexDefinition index, Resources resources) + { + var result = new IndexDto + { + Name = index.ToName(), + Fields = index.Select(IndexFieldDto.FromDomain).ToList(), + }; + + return result.CreateLinks(resources); + } + + private IndexDto CreateLinks(Resources resources) + { + var values = new { app = resources.App, schema = resources.Schema, name = Name }; + + if (resources.CanManageIndexes(resources.Schema!)) + { + AddDeleteLink("delete", + resources.Url(x => nameof(x.DeleteIndex), values)); + } + + return this; + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexFieldDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexFieldDto.cs new file mode 100644 index 000000000..4cbbe8584 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexFieldDto.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.Validation; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models; + +public sealed class IndexFieldDto +{ + /// + /// The name of the field. + /// + [LocalizedRequired] + public string Name { get; set; } + + /// + /// The sort order of the field. + /// + public SortOrder Order { get; set; } + + public static IndexFieldDto FromDomain(IndexField field) + { + return SimpleMapper.Map(field, new IndexFieldDto()); + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexesDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexesDto.cs new file mode 100644 index 000000000..2a12c8f56 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexesDto.cs @@ -0,0 +1,44 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.States; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models; + +public sealed class IndexesDto : Resource +{ + /// + /// The indexes. + /// + public IndexDto[] Items { get; set; } + + public static IndexesDto FromDomain(List indexes, Resources resources) + { + var result = new IndexesDto + { + Items = indexes.Select(x => IndexDto.FromDomain(x, resources)).ToArray() + }; + + return result.CreateLinks(resources); + } + + private IndexesDto CreateLinks(Resources resources) + { + var values = new { app = resources.App, schema = resources.Schema }; + + AddSelfLink(resources.Url(x => nameof(x.GetIndexes), values)); + + if (resources.CanManageIndexes(resources.Schema!)) + { + AddPostLink("create", + resources.Url(x => nameof(x.PostIndex), values)); + } + + return this; + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs index 18eca20b2..5a4520d5e 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs @@ -159,6 +159,12 @@ public class SchemaDto : Resource resources.Url(x => nameof(x.GetContents), values)); } + if (resources.CanManageIndexes(Name) && Type == SchemaType.Default) + { + AddGetLink("indexes", + resources.Url(x => nameof(x.GetIndexes), values)); + } + if (resources.CanCreateContent(Name) && Type == SchemaType.Default) { AddPostLink("contents/create", diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs index 3a8cd1239..2a89c5298 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs @@ -38,7 +38,7 @@ public sealed class SchemaFieldsController : ApiController /// Schema field name already in use. [HttpPost] [Route("apps/{app}/schemas/{schema}/fields/")] - [ProducesResponseType(typeof(SchemaDto), 201)] + [ProducesResponseType(typeof(SchemaDto), StatusCodes.Status201Created)] [ApiPermissionOrAnonymous(PermissionIds.AppSchemasUpdate)] [ApiCosts(1)] public async Task PostField(string app, string schema, [FromBody] AddFieldDto request) @@ -63,7 +63,7 @@ public sealed class SchemaFieldsController : ApiController /// Schema, field or app not found. [HttpPost] [Route("apps/{app}/schemas/{schema}/fields/{parentId:long}/nested/")] - [ProducesResponseType(typeof(SchemaDto), 201)] + [ProducesResponseType(typeof(SchemaDto), StatusCodes.Status201Created)] [ApiPermissionOrAnonymous(PermissionIds.AppSchemasUpdate)] [ApiCosts(1)] public async Task PostNestedField(string app, string schema, long parentId, [FromBody] AddFieldDto request) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaIndexesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaIndexesController.cs new file mode 100644 index 000000000..ec2a23917 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaIndexesController.cs @@ -0,0 +1,107 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Mvc; +using Squidex.Areas.Api.Controllers.Schemas.Models; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Domain.Apps.Entities.Contents.Indexes; +using Squidex.Domain.Apps.Entities.Contents.Repositories; +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Security; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Schemas; + +/// +/// Update and query information about schemas. +/// +[ApiExplorerSettings(GroupName = nameof(Schemas))] +[ApiModelValidation(true)] +public class SchemaIndexesController : ApiController +{ + private readonly ICommandBus commandBus; + private readonly IJobService jobService; + private readonly IContentRepository contentRepository; + + public SchemaIndexesController(ICommandBus commandBus, IJobService jobService, IContentRepository contentRepository) + : base(commandBus) + { + this.commandBus = commandBus; + this.jobService = jobService; + this.contentRepository = contentRepository; + } + + /// + /// Gets the schema indexes. + /// + /// The name of the app. + /// The name of the schema. + /// Schema indexes returned. + /// Schema or app not found. + [HttpGet] + [Route("apps/{app}/schemas/{schema}/indexes/")] + [ProducesResponseType(typeof(IndexesDto), StatusCodes.Status200OK)] + [ApiPermissionOrAnonymous(PermissionIds.AppSchemasIndexes)] + [ApiCosts(1)] + public async Task GetIndexes(string app, string schema) + { + var indexes = await contentRepository.GetIndexesAsync(App.Id, Schema.Id, HttpContext.RequestAborted); + + var response = Deferred.Response(() => + { + return IndexesDto.FromDomain(indexes, Resources); + }); + + return Ok(response); + } + + /// + /// Create a schema indexes. + /// + /// The name of the app. + /// The name of the schema. + /// The request object that represents an index. + /// Schema findexes returned. + /// Schema or app not found. + [HttpPost] + [Route("apps/{app}/schemas/{schema}/indexes/")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ApiPermissionOrAnonymous(PermissionIds.AppSchemasIndexes)] + [ApiCosts(1)] + public async Task PostIndex(string app, string schema, [FromBody] CreateIndexDto request) + { + var job = CreateIndexJob.BuildRequest(User.Token()!, App, Schema, request.ToIndex()); + + await jobService.StartAsync(App.Id, job, HttpContext.RequestAborted); + + return NoContent(); + } + + /// + /// Create a schema indexes. + /// + /// The name of the app. + /// The name of the schema. + /// The name of the index. + /// Schema index deletion added to job queue. + /// Schema or app not found. + [HttpPost] + [Route("apps/{app}/schemas/{schema}/indexes/{name}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ApiPermissionOrAnonymous(PermissionIds.AppSchemasIndexes)] + [ApiCosts(1)] + public async Task DeleteIndex(string app, string schema, string name) + { + var job = DropIndexJob.BuildRequest(User.Token()!, App, Schema, name); + + await jobService.StartAsync(App.Id, job, HttpContext.RequestAborted); + + return NoContent(); + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs index ebe90b0ff..b34d390d2 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs @@ -98,7 +98,7 @@ public sealed class SchemasController : ApiController /// Schema name already in use. [HttpPost] [Route("apps/{app}/schemas/")] - [ProducesResponseType(typeof(SchemaDto), 201)] + [ProducesResponseType(typeof(SchemaDto), StatusCodes.Status201Created)] [ApiPermissionOrAnonymous(PermissionIds.AppSchemasCreate)] [ApiCosts(1)] public async Task PostSchema(string app, [FromBody] CreateSchemaDto request) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs index 5d2426cc8..b8252cd4f 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs @@ -93,7 +93,7 @@ public sealed class TeamsController : ApiController /// [HttpPost] [Route("teams/")] - [ProducesResponseType(typeof(TeamDto), 201)] + [ProducesResponseType(typeof(TeamDto), StatusCodes.Status201Created)] [ApiPermission] [ApiCosts(0)] public async Task PostTeam([FromBody] CreateTeamDto request) diff --git a/backend/src/Squidex/Config/Messaging/MessagingServices.cs b/backend/src/Squidex/Config/Messaging/MessagingServices.cs index 9e8e2cf5d..00be68471 100644 --- a/backend/src/Squidex/Config/Messaging/MessagingServices.cs +++ b/backend/src/Squidex/Config/Messaging/MessagingServices.cs @@ -13,6 +13,7 @@ using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Backup; using Squidex.Domain.Apps.Entities.Billing; using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Domain.Apps.Entities.Contents.Indexes; using Squidex.Domain.Apps.Entities.Jobs; using Squidex.Domain.Apps.Entities.Rules; using Squidex.Domain.Apps.Entities.Rules.Runner; @@ -81,6 +82,12 @@ public static class MessagingServices services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .As(); + services.AddSingleton(c => new SystemTextJsonMessagingSerializer(c.GetRequiredService())); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/CreateIndexJobTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/CreateIndexJobTests.cs new file mode 100644 index 000000000..f32da6518 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/CreateIndexJobTests.cs @@ -0,0 +1,172 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using FluentAssertions.Common; +using Jint.Runtime; +using NodaTime; +using Squidex.Domain.Apps.Entities.Contents.Repositories; +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; +using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.States; +using Squidex.Infrastructure.TestHelpers; +using System.Security.Principal; +using IClock = NodaTime.IClock; + +namespace Squidex.Domain.Apps.Entities.Contents.Indexes; + +public class CreateIndexJobTests : GivenContext +{ + private readonly IContentRepository contentRepository = A.Fake(); + private readonly CreateIndexJob sut; + + public CreateIndexJobTests() + { + sut = new CreateIndexJob(contentRepository); + } + + [Fact] + public void Should_create_request() + { + var job = + CreateIndexJob.BuildRequest(User, App, Schema, + [ + new IndexField("field1", SortOrder.Ascending), + new IndexField("field2", SortOrder.Descending), + ]); + + job.Arguments.Should().BeEquivalentTo( + new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["schemaName"] = Schema.Name, + ["field_field1"] = "Ascending", + ["field_field2"] = "Descending" + }); + } + + [Fact] + public async Task Should_throw_exception_if_arguments_do_not_contain_schemaId() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaName"] = Schema.Name, + ["field_field1"] = "Ascending", + ["field_field2"] = "Descending" + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + await Assert.ThrowsAsync(() => sut.RunAsync(context, CancellationToken)); + } + + [Fact] + public async Task Should_throw_exception_if_arguments_do_not_contain_schemaName() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["field_field1"] = "Ascending", + ["field_field2"] = "Descending" + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + await Assert.ThrowsAsync(() => sut.RunAsync(context, CancellationToken)); + } + + [Fact] + public async Task Should_throw_exception_if_field_order_is_invalid() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["schemaName"] = Schema.Name, + ["field_field1"] = "Invalid", + ["field_field2"] = "Descending" + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + await Assert.ThrowsAsync(() => sut.RunAsync(context, CancellationToken)); + } + + [Fact] + public async Task Should_throw_exception_if_fields_are_empty() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["schemaName"] = Schema.Name, + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + await Assert.ThrowsAsync(() => sut.RunAsync(context, CancellationToken)); + } + + [Fact] + public async Task Should_invoke_content_repository() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["schemaName"] = Schema.Name, + ["field_field1"] = "Ascending", + ["field_field2"] = "Descending" + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + IndexDefinition? index = null; + + A.CallTo(() => contentRepository.CreateIndexAsync(App.Id, Schema.Id, A._, CancellationToken)) + .Invokes(x => index = x.GetArgument(2)); + + await sut.RunAsync(context, CancellationToken); + + index.Should().BeEquivalentTo( + [ + new IndexField("field1", SortOrder.Ascending), + new IndexField("field2", SortOrder.Descending) + ]); + } + + private JobRunContext CreateContext(Job job) + { + return new JobRunContext(null!, A.Fake(), default) { Actor = User, Job = job, OwnerId = App.Id }; + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/DropIndexJobTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/DropIndexJobTests.cs new file mode 100644 index 000000000..8abe7f9dd --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/DropIndexJobTests.cs @@ -0,0 +1,132 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using FluentAssertions.Common; +using Jint.Runtime; +using NodaTime; +using Squidex.Domain.Apps.Entities.Contents.Repositories; +using Squidex.Domain.Apps.Entities.Jobs; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Collections; +using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.States; +using IClock = NodaTime.IClock; + +namespace Squidex.Domain.Apps.Entities.Contents.Indexes; + +public class DropIndexJobTests : GivenContext +{ + private readonly IContentRepository contentRepository = A.Fake(); + private readonly DropIndexJob sut; + + public DropIndexJobTests() + { + sut = new DropIndexJob(contentRepository); + } + + [Fact] + public void Should_create_request() + { + var job = DropIndexJob.BuildRequest(User, App, Schema, "MyIndex"); + + job.Arguments.Should().BeEquivalentTo( + new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["schemaName"] = Schema.Name, + ["indexName"] = "MyIndex" + }); + } + + [Fact] + public async Task Should_throw_exception_if_arguments_do_not_contain_schemaId() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaName"] = Schema.Name, + ["indexName"] = "MyIndex" + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + await Assert.ThrowsAsync(() => sut.RunAsync(context, CancellationToken)); + } + + [Fact] + public async Task Should_throw_exception_if_arguments_do_not_contain_schemaName() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["indexName"] = "MyIndex" + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + await Assert.ThrowsAsync(() => sut.RunAsync(context, CancellationToken)); + } + + [Fact] + public async Task Should_throw_exception_if_arguments_do_not_contain_index_name() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["schemaName"] = Schema.Name, + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + await Assert.ThrowsAsync(() => sut.RunAsync(context, CancellationToken)); + } + + [Fact] + public async Task Should_invoke_content_repository() + { + var job = new Job + { + Arguments = new Dictionary + { + ["appId"] = App.Id.ToString(), + ["appName"] = App.Name, + ["schemaId"] = Schema.Id.ToString(), + ["schemaName"] = Schema.Name, + ["indexName"] = "MyIndex" + }.ToReadonlyDictionary() + }; + + var context = CreateContext(job); + + await sut.RunAsync(context, CancellationToken); + + A.CallTo(() => contentRepository.DropIndexAsync(App.Id, Schema.Id, "MyIndex", CancellationToken)) + .MustHaveHappened(); + } + + private JobRunContext CreateContext(Job job) + { + return new JobRunContext(null!, A.Fake(), default) { Actor = User, Job = job, OwnerId = App.Id }; + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/AdaptionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/AdaptionTests.cs new file mode 100644 index 000000000..46db9eddf --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/AdaptionTests.cs @@ -0,0 +1,59 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.MongoDb.Contents; +using Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations; + +namespace Squidex.Domain.Apps.Entities.Contents.MongoDb; + +public class AdaptionTests +{ + static AdaptionTests() + { + MongoContentEntity.RegisterClassMap(); + } + + [Fact] + public void Should_adapt_to_meta_field() + { + var source = "lastModified"; + + var result = Adapt.MapPath(source).ToString(); + + Assert.Equal("mt", result); + } + + [Fact] + public void Should_adapt_to_data_field() + { + var source = "data.test"; + + var result = Adapt.MapPath(source).ToString(); + + Assert.Equal("do.test", result); + } + + [Fact] + public void Should_adapt_from_meta_field() + { + var source = "mt"; + + var result = Adapt.MapPathReverse(source).ToString(); + + Assert.Equal("lastModified", result); + } + + [Fact] + public void Should_adapt_from_data_field() + { + var source = "do.test"; + + var result = Adapt.MapPathReverse(source).ToString(); + + Assert.Equal("data.test", result); + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/IndexParserTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/IndexParserTests.cs new file mode 100644 index 000000000..b642ab2a2 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/IndexParserTests.cs @@ -0,0 +1,124 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using MongoDB.Bson; +using Squidex.Domain.Apps.Entities.MongoDb.Contents; +using Squidex.Infrastructure.Queries; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities.Contents.MongoDb; + +public class IndexParserTests +{ + private readonly BsonDocument validSource = + new BsonDocument + { + ["name"] = "custom_index", + ["key"] = new BsonDocument + { + ["mt"] = 1, + ["mb"] = -1, + ["do.field1"] = 1, + } + }; + + static IndexParserTests() + { + MongoContentEntity.RegisterClassMap(); + } + + [Fact] + public void Should_parse_index() + { + var result = IndexParser.TryParse(validSource, "custom_", out var definition); + + Assert.True(result); + + definition.Should().BeEquivalentTo( + new IndexDefinition() + { + new IndexField("lastModified", SortOrder.Ascending), + new IndexField("lastModifiedBy", SortOrder.Descending), + new IndexField("data.field1", SortOrder.Ascending), + }); + } + + [Fact] + public void Should_not_parse_index_if_prefix_does_not_match() + { + var result = IndexParser.TryParse(validSource, "prefix_", out var definition); + + Assert.False(result); + Assert.Null(definition); + } + + [Fact] + public void Should_not_parse_index_if_name_not_found() + { + validSource.Remove("name"); + + var result = IndexParser.TryParse(validSource, "custom_", out var definition); + + Assert.False(result); + Assert.Null(definition); + } + + [Fact] + public void Should_not_parse_index_if_name_has_invalid_type() + { + validSource["name"] = 42; + + var result = IndexParser.TryParse(validSource, "custom_", out var definition); + + Assert.False(result); + Assert.Null(definition); + } + + [Fact] + public void Should_not_parse_index_if_key_not_found() + { + validSource.Remove("key"); + + var result = IndexParser.TryParse(validSource, "custom_", out var definition); + + Assert.False(result); + Assert.Null(definition); + } + + [Fact] + public void Should_not_parse_index_if_key_has_invalid_type() + { + validSource["key"] = 42; + + var result = IndexParser.TryParse(validSource, "custom_", out var definition); + + Assert.False(result); + Assert.Null(definition); + } + + [Fact] + public void Should_not_parse_index_if_key_is_empty() + { + validSource["key"] = new BsonDocument(); + + var result = IndexParser.TryParse(validSource, "custom_", out var definition); + + Assert.False(result); + Assert.Null(definition); + } + + [Fact] + public void Should_not_parse_index_if_key_property_has_invalid_type() + { + validSource["key"].AsBsonDocument["mt"] = "invalid"; + + var result = IndexParser.TryParse(validSource, "custom_", out var definition); + + Assert.False(result); + Assert.Null(definition); + } +} diff --git a/backend/tests/Squidex.Infrastructure.Tests/States/IndexDefinitionTests.cs b/backend/tests/Squidex.Infrastructure.Tests/States/IndexDefinitionTests.cs new file mode 100644 index 000000000..b7219e885 --- /dev/null +++ b/backend/tests/Squidex.Infrastructure.Tests/States/IndexDefinitionTests.cs @@ -0,0 +1,55 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.Queries; + +namespace Squidex.Infrastructure.States; + +public class IndexDefinitionTests +{ + [Fact] + public void Should_create_name_for_empty_definition() + { + var definition = new IndexDefinition(); + + Assert.Equal(string.Empty, definition.ToName()); + } + + [Fact] + public void Should_create_name_for_asc_order() + { + var definition = new IndexDefinition + { + new IndexField("field1", SortOrder.Ascending) + }; + + Assert.Equal("field1_asc", definition.ToName()); + } + + [Fact] + public void Should_create_name_for_dasc_order() + { + var definition = new IndexDefinition + { + new IndexField("field1", SortOrder.Descending) + }; + + Assert.Equal("field1_desc", definition.ToName()); + } + + [Fact] + public void Should_create_name_for_multiple_fields() + { + var definition = new IndexDefinition + { + new IndexField("field1", SortOrder.Ascending), + new IndexField("field2", SortOrder.Descending) + }; + + Assert.Equal("field1_asc_field2_desc", definition.ToName()); + } +} diff --git a/backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs b/backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs index 2b17116d1..6dad84a88 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs @@ -78,6 +78,14 @@ public class StringExtensionsTests Assert.Equal("Hello \\\"World\\\"", actual); } + [Fact] + public void Should_unescape_json() + { + var actual = StringExtensions.JsonUnescape("Hello \\\"World\\\""); + + Assert.Equal("Hello \"World\"", actual); + } + [Theory] [InlineData("", "")] [InlineData(" ", "")] diff --git a/frontend/src/app/framework/angular/forms/editors/toggle.component.html b/frontend/src/app/framework/angular/forms/editors/toggle.component.html index 650f89f4a..2d5255795 100644 --- a/frontend/src/app/framework/angular/forms/editors/toggle.component.html +++ b/frontend/src/app/framework/angular/forms/editors/toggle.component.html @@ -6,4 +6,6 @@ [class.unchecked]="snapshot.isChecked === false" (click)="changeState()">
+ + diff --git a/frontend/src/app/framework/angular/forms/editors/toggle.component.scss b/frontend/src/app/framework/angular/forms/editors/toggle.component.scss index b1b7c510d..c9013c1fa 100644 --- a/frontend/src/app/framework/angular/forms/editors/toggle.component.scss +++ b/frontend/src/app/framework/angular/forms/editors/toggle.component.scss @@ -39,6 +39,10 @@ $toggle-button-size: $toggle-height - .25rem; .toggle-button { left: $toggle-height * .5; } + + .icon-checkmark { + display: block; + } } &.unchecked { @@ -47,6 +51,10 @@ $toggle-button-size: $toggle-height - .25rem; .toggle-button { left: $toggle-width - $toggle-height * .5; } + + .icon-close { + display: block; + } } &.disabled { @@ -55,4 +63,24 @@ $toggle-button-size: $toggle-height - .25rem; cursor: not-allowed; } } +} + +.icon-close { + @include absolute(50%, null, null, 4px); + color: $color-white; + display: none; + font-size: 60%; + font-weight: normal; + margin-top: -5px; + user-select: none; +} + +.icon-checkmark { + @include absolute(50%, 4px); + color: $color-white; + display: none; + font-size: 70%; + font-weight: normal; + margin-top: -5px; + user-select: none; } \ No newline at end of file diff --git a/frontend/src/app/framework/angular/forms/editors/toggle.stories.ts b/frontend/src/app/framework/angular/forms/editors/toggle.stories.ts new file mode 100644 index 000000000..b14a20997 --- /dev/null +++ b/frontend/src/app/framework/angular/forms/editors/toggle.stories.ts @@ -0,0 +1,56 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { FormsModule } from '@angular/forms'; +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; +import { RadioGroupComponent, ToggleComponent } from '@app/framework'; + +export default { + title: 'Framework/Toggle', + component: ToggleComponent, + argTypes: { + disabled: { + control: 'boolean', + }, + change: { + action:'ngModelChange', + }, + }, + render: args => ({ + props: args, + template: ` + + + `, + }), + decorators: [ + moduleMetadata({ + imports: [ + FormsModule, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Checked: Story = { + args: { + model: true, + }, +}; + +export const Unchecked: Story = { + args: { + model: false, + }, +}; \ No newline at end of file diff --git a/tools/TestSuite/TestSuite.ApiTests/Verify/AppCreationTests.Should_create_app_from_templates.verified.txt b/tools/TestSuite/TestSuite.ApiTests/Verify/AppCreationTests.Should_create_app_from_templates.verified.txt index 6180708e9..cb3973239 100644 --- a/tools/TestSuite/TestSuite.ApiTests/Verify/AppCreationTests.Should_create_app_from_templates.verified.txt +++ b/tools/TestSuite/TestSuite.ApiTests/Verify/AppCreationTests.Should_create_app_from_templates.verified.txt @@ -161,6 +161,9 @@ fields/ui: { Method: PUT }, + indexes: { + Method: GET + }, self: { Method: GET }, @@ -348,6 +351,9 @@ fields/ui: { Method: PUT }, + indexes: { + Method: GET + }, self: { Method: GET }, From 060f43357bee03d89d2a418bb772d68d52b96dcf Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Mon, 11 Nov 2024 10:15:47 +0100 Subject: [PATCH 3/4] Script improvements (#1136) * Custom indexes. * Adjust test. * New script extensions. * New script extensions. --- .../Scripting/Internal/JintExtensions.cs | 13 +- .../Scripting/Internal/JsonMapper.cs | 14 +- .../Assets/AssetsJintExtension.cs | 12 +- .../Contents/ContentsJintExtension.cs | 118 ++++++++++++++ .../Contents/ReferencesJintExtension.cs | 6 +- .../Properties/Resources.Designer.cs | 9 ++ .../Properties/Resources.resx | 3 + .../Squidex.Web/Pipeline/JsonStreamResult.cs | 1 + .../Contents/ContentsController.cs | 5 + .../Assets/AssetsJintExtensionTests.cs | 31 ++++ .../Contents/ContentsJintExtensionTests.cs | 147 ++++++++++++++++++ .../Contents/ReferencesJintExtensionTests.cs | 31 ++++ 12 files changed, 367 insertions(+), 23 deletions(-) create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsJintExtension.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentsJintExtensionTests.cs diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs index bb4f3f51b..7787448fe 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs @@ -17,18 +17,15 @@ public static class JintExtensions { var ids = new List(); - if (value?.IsString() == true) + if (value is JsString s) { - ids.Add(DomainId.Create(value.ToString())); + ids.Add(DomainId.Create(s.AsString())); } - else if (value?.IsArray() == true) + else if (value is JsArray a) { - foreach (var item in value.AsArray()) + foreach (var item in a.OfType()) { - if (item.IsString()) - { - ids.Add(DomainId.Create(item.ToString())); - } + ids.Add(DomainId.Create(item.AsString())); } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs index 5bf3ee5fc..a5874182a 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs @@ -113,15 +113,13 @@ public static class JsonMapper return number; } - if (value.IsArray()) + if (value is JsArray a) { - var arr = value.AsArray(); + var result = new JsonArray((int)a.Length); - var result = new JsonArray((int)arr.Length); - - for (var i = 0; i < arr.Length; i++) + for (var i = 0; i < a.Length; i++) { - result.Add(Map(arr.Get(i.ToString(CultureInfo.InvariantCulture)))); + result.Add(Map(a.Get(i.ToString(CultureInfo.InvariantCulture)))); } return result; @@ -132,10 +130,8 @@ public static class JsonMapper return JsonValue.Create(wrapper.Target); } - if (value.IsObject()) + if (value is ObjectInstance obj) { - var obj = value.AsObject(); - var result = new JsonObject(); foreach (var (key, propertyDescriptor) in obj.GetOwnProperties()) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs index 2d8169483..7f642e781 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs @@ -140,7 +140,8 @@ public sealed class AssetsJintExtension : IJintExtension, IScriptDescriptor context.Engine.SetValue("getAssetBlurHash", getBlurHash); } - private void GetText(ScriptExecutionContext context, JsValue input, Action callback, JsValue? encoding) + private void GetText(ScriptExecutionContext context, + JsValue input, Action callback, JsValue? encoding) { if (callback == null) { @@ -163,7 +164,8 @@ public sealed class AssetsJintExtension : IJintExtension, IScriptDescriptor }); } - private void GetBlurHash(ScriptExecutionContext context, JsValue input, Action callback, JsValue? componentX, JsValue? componentY) + private void GetBlurHash(ScriptExecutionContext context, + JsValue input, Action callback, JsValue? componentX, JsValue? componentY) { if (callback == null) { @@ -199,7 +201,8 @@ public sealed class AssetsJintExtension : IJintExtension, IScriptDescriptor }); } - private void GetAssets(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user, JsValue references, Action callback) + private void GetAssets(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user, + JsValue references, Action callback) { if (callback == null) { @@ -237,7 +240,8 @@ public sealed class AssetsJintExtension : IJintExtension, IScriptDescriptor }); } - private void GetAsset(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user, JsValue references, Action callback) + private void GetAsset(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user, + JsValue references, Action callback) { Guard.NotNull(callback); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsJintExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsJintExtension.cs new file mode 100644 index 000000000..bfffffbb2 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsJintExtension.cs @@ -0,0 +1,118 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Security.Claims; +using Jint; +using Jint.Native; +using Jint.Native.Object; +using Jint.Runtime; +using Microsoft.Extensions.DependencyInjection; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Core.Scripting.Internal; +using Squidex.Domain.Apps.Entities.Properties; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents; + +public sealed class ContentsJintExtension : IJintExtension, IScriptDescriptor +{ + private delegate void GetContentsDelegate(string schema, JsValue query, Action callback); + private readonly IServiceProvider serviceProvider; + + public ContentsJintExtension(IServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider; + } + + public void ExtendAsync(ScriptExecutionContext context) + { + if (!context.TryGetValueIfExists("appId", out var appId)) + { + return; + } + + if (!context.TryGetValueIfExists("user", out var user)) + { + return; + } + + var getContents = new GetContentsDelegate((schemas, query, callback) => + { + GetContents(context, appId, user, schemas, query, callback); + }); + + context.Engine.SetValue("getContents", getContents); + } + + private void GetContents(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user, + string schema, JsValue query, Action callback) + { + if (callback == null) + { + throw new JavaScriptException("Callback is not defined."); + } + + context.Schedule(async (scheduler, ct) => + { + var app = await GetAppAsync(appId); + + if (app == null) + { + scheduler.Run(callback, new JsArray(context.Engine)); + return; + } + + var contentQuery = serviceProvider.GetRequiredService(); + + var requestContext = + new Context(user, app).Clone(b => b + .WithFields(null) + .WithNoEnrichment() + .WithUnpublished() + .WithNoTotal()); + + var q = Q.Empty; + if (query is ObjectInstance obj) + { + if (obj.TryGetValue("query", out var t) && t is JsString oDataQuery) + { + q = q.WithODataQuery(oDataQuery.AsString()); + } + } + else if (query is JsString oDataQuery) + { + q = q.WithODataQuery(oDataQuery.AsString()); + } + + var contents = await contentQuery.QueryAsync(requestContext, schema, q, ct); + + scheduler.Run(callback, JsValue.FromObject(context.Engine, contents.ToArray())); + }); + } + + private async Task GetAppAsync(DomainId appId) + { + var appProvider = serviceProvider.GetRequiredService(); + + var app = await appProvider.GetAppAsync(appId) ?? + throw new JavaScriptException("App does not exist."); + + return app; + } + + public void Describe(AddDescription describe, ScriptScope scope) + { + if (!scope.HasFlag(ScriptScope.Async)) + { + return; + } + + describe(JsonType.Function, "getContents(schema, query, callback)", + Resources.ScriptingGetContents); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesJintExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesJintExtension.cs index e02b3d708..c8e3f3b42 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesJintExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesJintExtension.cs @@ -54,7 +54,8 @@ public sealed class ReferencesJintExtension : IJintExtension, IScriptDescriptor context.Engine.SetValue("getReferences", getReferences); } - private void GetReferences(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user, JsValue references, Action callback) + private void GetReferences(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user, + JsValue references, Action callback) { if (callback == null) { @@ -94,7 +95,8 @@ public sealed class ReferencesJintExtension : IJintExtension, IScriptDescriptor }); } - private void GetReference(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user, JsValue references, Action callback) + private void GetReference(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user, + JsValue references, Action callback) { if (callback == null) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Properties/Resources.Designer.cs b/backend/src/Squidex.Domain.Apps.Entities/Properties/Resources.Designer.cs index 43d445b07..3b7880dbe 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Properties/Resources.Designer.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Properties/Resources.Designer.cs @@ -114,6 +114,15 @@ namespace Squidex.Domain.Apps.Entities.Properties { } } + /// + /// Looks up a localized string similar to Queries contents by schema and query.. + /// + internal static string ScriptingGetContents { + get { + return ResourceManager.GetString("ScriptingGetContents", resourceCulture); + } + } + /// /// Looks up a localized string similar to Queries the content item with the specified ID and invokes the callback with an array of contents.. /// diff --git a/backend/src/Squidex.Domain.Apps.Entities/Properties/Resources.resx b/backend/src/Squidex.Domain.Apps.Entities/Properties/Resources.resx index 436d4ae6d..21b80767f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Properties/Resources.resx +++ b/backend/src/Squidex.Domain.Apps.Entities/Properties/Resources.resx @@ -135,6 +135,9 @@ Gets the blur hash of an asset if it is an image or null otherwise. + + Queries contents by schema and query. + Queries the content item with the specified ID and invokes the callback with an array of contents. diff --git a/backend/src/Squidex.Web/Pipeline/JsonStreamResult.cs b/backend/src/Squidex.Web/Pipeline/JsonStreamResult.cs index 8e5269331..641d230ea 100644 --- a/backend/src/Squidex.Web/Pipeline/JsonStreamResult.cs +++ b/backend/src/Squidex.Web/Pipeline/JsonStreamResult.cs @@ -51,6 +51,7 @@ public sealed class JsonStreamResult : ActionResult // Write the separator after a every json object to simplify deserialization. await body.WriteAsync(Separator, ct); + await body.FlushAsync(ct); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index 3c69c963e..5923441cc 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -18,6 +18,7 @@ using Squidex.Infrastructure.Commands; using Squidex.Shared; using Squidex.Web; using Squidex.Web.Pipeline; +using System.Diagnostics; namespace Squidex.Areas.Api.Controllers.Contents; @@ -56,6 +57,10 @@ public sealed class ContentsController : ApiController [OpenApiIgnore] public IActionResult StreamContents(string app, string schema, [FromQuery] int skip = 0) { + if (schema.Equals("de-studyprogram-details")) + { + Debugger.Break(); + } var contents = contentQuery.StreamAsync(Context, schema, skip, HttpContext.RequestAborted); return new JsonStreamResult(contents); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsJintExtensionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsJintExtensionTests.cs index c2aa45277..cf7ef0751 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsJintExtensionTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsJintExtensionTests.cs @@ -21,6 +21,7 @@ using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Json.Objects; +using Squidex.Infrastructure.Validation; namespace Squidex.Domain.Apps.Entities.Assets; @@ -75,6 +76,16 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture(() => sut.ExecuteAsync(vars, script, ct: CancellationToken)); + } + [Fact] public async Task Should_resolve_asset() { @@ -96,6 +107,16 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture(() => sut.ExecuteAsync(vars, script, ct: CancellationToken)); + } + [Fact] public async Task Should_resolve_asset_v2() { @@ -117,6 +138,16 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture(() => sut.ExecuteAsync(vars, script, ct: CancellationToken)); + } + [Fact] public async Task Should_resolve_assets() { diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentsJintExtensionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentsJintExtensionTests.cs new file mode 100644 index 000000000..c13bf6836 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentsJintExtensionTests.cs @@ -0,0 +1,147 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Security.Claims; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Core.TestHelpers; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Json.Objects; +using Squidex.Infrastructure.Validation; + +namespace Squidex.Domain.Apps.Entities.Contents; + +public class ContentsJintExtensionTests : GivenContext, IClassFixture +{ + private readonly IContentQueryService contentQuery = A.Fake(); + private readonly JintScriptEngine sut; + + public ContentsJintExtensionTests() + { + var serviceProvider = + new ServiceCollection() + .AddSingleton(AppProvider) + .AddSingleton(contentQuery) + .BuildServiceProvider(); + + var extensions = new IJintExtension[] + { + new ContentsJintExtension(serviceProvider) + }; + + sut = new JintScriptEngine(new MemoryCache(Options.Create(new MemoryCacheOptions())), + Options.Create(new JintScriptOptions + { + TimeoutScript = TimeSpan.FromSeconds(2), + TimeoutExecution = TimeSpan.FromSeconds(10) + }), + extensions); + } + + [Fact] + public async Task Should_throw_exception_if_callback_is_null() + { + var (vars, _) = SetupQueryVars("my-schema", "$filter=data/field/iv eq 42", 2); + + var script = @"getContents('my-schema', '$filter=data/field/iv eq 42')"; + + await Assert.ThrowsAsync(() => sut.ExecuteAsync(vars, script, ct: CancellationToken)); + } + + [Fact] + public async Task Should_query_contents() + { + var (vars, _) = SetupQueryVars("my-schema", "$filter=data/field/iv eq 42", 2); + + var expected = @" + Text: Hello 1 World 1 + "; + + var script = @" + getContents('my-schema', { query: '$filter=data/field/iv eq 42' }, function (references) { + var actual1 = `Text: ${references[0].data.field1.iv} ${references[0].data.field2.iv}`; + + complete(`${actual1}`); + })"; + + var actual = (await sut.ExecuteAsync(vars, script, ct: CancellationToken)).ToString(); + + Assert.Equal(Cleanup(expected), Cleanup(actual)); + } + + [Fact] + public async Task Should_query_contents_with_string() + { + var (vars, _) = SetupQueryVars("my-schema", "$filter=data/field/iv eq 42", 2); + + var expected = @" + Text: Hello 1 World 1 + "; + + var script = @" + getContents('my-schema', '$filter=data/field/iv eq 42', function (references) { + var actual1 = `Text: ${references[0].data.field1.iv} ${references[0].data.field2.iv}`; + + complete(`${actual1}`); + })"; + + var actual = (await sut.ExecuteAsync(vars, script, ct: CancellationToken)).ToString(); + + Assert.Equal(Cleanup(expected), Cleanup(actual)); + } + + private (ScriptVars, EnrichedContent[]) SetupQueryVars(string schema, string filter, int count) + { + var references = Enumerable.Range(0, count).Select((x, i) => CreateContent(i + 1)).ToArray(); + var referenceIds = references.Select(x => x.Id); + + var user = new ClaimsPrincipal(); + + A.CallTo(() => contentQuery.QueryAsync( + A.That.Matches(x => x.App == App && x.UserPrincipal == user), + schema, + A.That.Matches(x => x.QueryAsOdata == filter), + A._)) + .Returns(ResultList.CreateFrom(2, [CreateContent(1)])); + + var vars = new ScriptVars + { + ["appId"] = AppId.Id, + ["appName"] = AppId.Name, + ["user"] = user + }; + + return (vars, references); + } + + private EnrichedContent CreateContent(int index) + { + return CreateContent() with + { + Data = + new ContentData() + .AddField("field1", + new ContentFieldData() + .AddInvariant(JsonValue.Create($"Hello {index}"))) + .AddField("field2", + new ContentFieldData() + .AddInvariant(JsonValue.Create($"World {index}"))) + }; + } + + private static string Cleanup(string text) + { + return text + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal) + .Replace(" ", string.Empty, StringComparison.Ordinal); + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferencesJintExtensionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferencesJintExtensionTests.cs index ffb929820..98d24ae89 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferencesJintExtensionTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferencesJintExtensionTests.cs @@ -15,6 +15,7 @@ using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; using Squidex.Infrastructure.Json.Objects; +using Squidex.Infrastructure.Validation; namespace Squidex.Domain.Apps.Entities.Contents; @@ -45,6 +46,16 @@ public class ReferencesJintExtensionTests : GivenContext, IClassFixture(() => sut.ExecuteAsync(vars, script, ct: CancellationToken)); + } + [Fact] public async Task Should_resolve_reference() { @@ -66,6 +77,16 @@ public class ReferencesJintExtensionTests : GivenContext, IClassFixture(() => sut.ExecuteAsync(vars, script, ct: CancellationToken)); + } + [Fact] public async Task Should_resolve_reference_v2() { @@ -87,6 +108,16 @@ public class ReferencesJintExtensionTests : GivenContext, IClassFixture(() => sut.ExecuteAsync(vars, script, ct: CancellationToken)); + } + [Fact] public async Task Should_resolve_references() { From 356c80f14b5023364f63ed245a9368f4b7ed2e9c Mon Sep 17 00:00:00 2001 From: Per Bering Date: Tue, 12 Nov 2024 13:58:55 +0100 Subject: [PATCH 4/4] Enabled OTel logging and metrics OTLP exporters (#1137) * Enabled OTel logging and metrics OTLP exporters * Fixed comment casing --- .../Squidex.Extensions/APM/Otlp/OtlpPlugin.cs | 14 +++-- .../ITelemetryConfigurator.cs | 5 ++ .../Config/Domain/TelemetryServices.cs | 57 ++++++++++++++++--- backend/src/Squidex/Squidex.csproj | 1 + 4 files changed, 65 insertions(+), 12 deletions(-) diff --git a/backend/extensions/Squidex.Extensions/APM/Otlp/OtlpPlugin.cs b/backend/extensions/Squidex.Extensions/APM/Otlp/OtlpPlugin.cs index 5f9ab21dc..b400aa999 100644 --- a/backend/extensions/Squidex.Extensions/APM/Otlp/OtlpPlugin.cs +++ b/backend/extensions/Squidex.Extensions/APM/Otlp/OtlpPlugin.cs @@ -7,6 +7,8 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; using OpenTelemetry.Trace; using Squidex.Infrastructure; using Squidex.Infrastructure.Plugins; @@ -26,9 +28,14 @@ public sealed class OtlpPlugin : IPlugin public void Configure(TracerProviderBuilder builder) { - // See: https://docs.microsoft.com/aspnet/core/grpc/troubleshoot#call-insecure-grpc-services-with-net-core-client - AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); + builder.AddOtlpExporter(options => + { + config.GetSection("logging:otlp").Bind(options); + }); + } + public void Configure(MeterProviderBuilder builder) + { builder.AddOtlpExporter(options => { config.GetSection("logging:otlp").Bind(options); @@ -40,8 +47,7 @@ public sealed class OtlpPlugin : IPlugin { if (config.GetValue("logging:otlp:enabled")) { - services.AddSingleton(); + services.AddSingleton(); } } } diff --git a/backend/src/Squidex.Infrastructure/ITelemetryConfigurator.cs b/backend/src/Squidex.Infrastructure/ITelemetryConfigurator.cs index 7a8cfe005..cce68d3d0 100644 --- a/backend/src/Squidex.Infrastructure/ITelemetryConfigurator.cs +++ b/backend/src/Squidex.Infrastructure/ITelemetryConfigurator.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using OpenTelemetry.Metrics; using OpenTelemetry.Trace; namespace Squidex.Infrastructure; @@ -14,4 +15,8 @@ public interface ITelemetryConfigurator void Configure(TracerProviderBuilder builder) { } + + void Configure(MeterProviderBuilder builder) + { + } } diff --git a/backend/src/Squidex/Config/Domain/TelemetryServices.cs b/backend/src/Squidex/Config/Domain/TelemetryServices.cs index dcd9837cb..5e84a37ef 100644 --- a/backend/src/Squidex/Config/Domain/TelemetryServices.cs +++ b/backend/src/Squidex/Config/Domain/TelemetryServices.cs @@ -6,6 +6,8 @@ // ========================================================================== using OpenTelemetry; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; using Squidex.Infrastructure; @@ -16,21 +18,42 @@ public static class TelemetryServices { public static void AddSquidexTelemetry(this IServiceCollection services, IConfiguration config) { + var serviceName = config.GetValue("logging:name") ?? "Squidex"; + var resourceBuilder = ResourceBuilder.CreateDefault() + .AddService(serviceName, "Squidex", + typeof(TelemetryServices).Assembly.GetName().Version!.ToString()); + services.AddOpenTelemetry(); - services.AddSingleton(serviceProvider => + // Configure logging + services.AddLogging(builder => { - var builder = Sdk.CreateTracerProviderBuilder(); + builder.AddOpenTelemetry(options => + { + options.SetResourceBuilder(resourceBuilder); + options.IncludeFormattedMessage = true; - var serviceName = config.GetValue("logging:name") ?? "Squidex"; + // Add OTLP exporter and bind options directly. Sadly not possible + // to do it through ITelemetryConfigurator as it is not possible to + // get IServiceProvider here. Later when OpenTelemetry.Sdk.CreateLoggerProviderBuilder() + // is available and no longer expermential, we can do it the same way as with tracing and metrics... + if (config.GetValue("logging:otlp:enabled")) + { + options.AddOtlpExporter(options => + { + config.GetSection("logging:otlp").Bind(options); + }); + } + }); + }); - builder.SetResourceBuilder( - ResourceBuilder.CreateDefault() - .AddService(serviceName, "Squidex", - typeof(TelemetryServices).Assembly.GetName().Version!.ToString())); + // Configure tracing + services.AddSingleton(serviceProvider => + { + var builder = Sdk.CreateTracerProviderBuilder(); + builder.SetResourceBuilder(resourceBuilder); builder.AddSource("Squidex"); - builder.AddAspNetCoreInstrumentation(); builder.AddHttpClientInstrumentation(); @@ -50,5 +73,23 @@ public static class TelemetryServices return builder.Build()!; }); + + // Configure metrics + services.AddSingleton(serviceProvider => + { + var builder = Sdk.CreateMeterProviderBuilder(); + + builder.SetResourceBuilder(resourceBuilder); + builder.AddAspNetCoreInstrumentation(); + builder.AddHttpClientInstrumentation(); + builder.AddRuntimeInstrumentation(); + + foreach (var configurator in serviceProvider.GetRequiredService>()) + { + configurator.Configure(builder); + } + + return builder.Build()!; + }); } } diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index 307a8cf02..56d09cbad 100644 --- a/backend/src/Squidex/Squidex.csproj +++ b/backend/src/Squidex/Squidex.csproj @@ -63,6 +63,7 @@ +