Browse Source

Merge branch 'master' of github.com:Squidex/squidex

pull/1139/head
Sebastian Stehle 2 years ago
parent
commit
ee3f022a8f
  1. 2
      .github/workflows/dev.yml
  2. 2
      .github/workflows/release.yml
  3. 14
      backend/extensions/Squidex.Extensions/APM/Otlp/OtlpPlugin.cs
  4. 13
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs
  5. 14
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs
  6. 62
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/IndexParser.cs
  7. 30
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs
  8. 19
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  9. 19
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoShardedContentRepository.cs
  10. 47
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Adapt.cs
  11. 58
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryInDedicatedCollection.cs
  12. 12
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs
  13. 3
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupJob.cs
  14. 12
      backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs
  15. 118
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsJintExtension.cs
  16. 107
      backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/CreateIndexJob.cs
  17. 80
      backend/src/Squidex.Domain.Apps.Entities/Contents/Indexes/DropIndexJob.cs
  18. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesJintExtension.cs
  19. 10
      backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs
  20. 9
      backend/src/Squidex.Domain.Apps.Entities/Properties/Resources.Designer.cs
  21. 3
      backend/src/Squidex.Domain.Apps.Entities/Properties/Resources.resx
  22. 19
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerJob.cs
  23. 5
      backend/src/Squidex.Infrastructure/ITelemetryConfigurator.cs
  24. 46
      backend/src/Squidex.Infrastructure/States/IndexDefinition.cs
  25. 7
      backend/src/Squidex.Infrastructure/StringExtensions.cs
  26. 1
      backend/src/Squidex.Shared/PermissionIds.cs
  27. 1
      backend/src/Squidex.Web/Pipeline/JsonStreamResult.cs
  28. 2
      backend/src/Squidex.Web/Resources.cs
  29. 2
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs
  30. 2
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs
  31. 2
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs
  32. 2
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
  33. 5
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  34. 2
      backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs
  35. 34
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/CreateIndexDto.cs
  36. 51
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexDto.cs
  37. 32
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexFieldDto.cs
  38. 44
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/IndexesDto.cs
  39. 6
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs
  40. 4
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs
  41. 107
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaIndexesController.cs
  42. 2
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs
  43. 2
      backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs
  44. 57
      backend/src/Squidex/Config/Domain/TelemetryServices.cs
  45. 7
      backend/src/Squidex/Config/Messaging/MessagingServices.cs
  46. 1
      backend/src/Squidex/Squidex.csproj
  47. 31
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsJintExtensionTests.cs
  48. 147
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentsJintExtensionTests.cs
  49. 172
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/CreateIndexJobTests.cs
  50. 132
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Indexes/DropIndexJobTests.cs
  51. 59
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/AdaptionTests.cs
  52. 124
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/IndexParserTests.cs
  53. 31
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferencesJintExtensionTests.cs
  54. 55
      backend/tests/Squidex.Infrastructure.Tests/States/IndexDefinitionTests.cs
  55. 8
      backend/tests/Squidex.Infrastructure.Tests/StringExtensionsTests.cs
  56. 2
      frontend/src/app/framework/angular/forms/editors/toggle.component.html
  57. 28
      frontend/src/app/framework/angular/forms/editors/toggle.component.scss
  58. 56
      frontend/src/app/framework/angular/forms/editors/toggle.stories.ts
  59. 6
      tools/TestSuite/TestSuite.ApiTests/Verify/AppCreationTests.Should_create_app_from_templates.verified.txt

2
.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

2
.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

14
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<bool>("logging:otlp:enabled"))
{
services.AddSingleton<ITelemetryConfigurator,
Configurator>();
services.AddSingleton<ITelemetryConfigurator, Configurator>();
}
}
}

13
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs

@ -17,18 +17,15 @@ public static class JintExtensions
{
var ids = new List<DomainId>();
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<JsString>())
{
if (item.IsString())
{
ids.Add(DomainId.Create(item.ToString()));
}
ids.Add(DomainId.Create(item.AsString()));
}
}

14
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())

62
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;
}
}

30
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<MongoContentEnt
add(Collection, entity);
}
public async Task CreateIndexAsync(DomainId appId, DomainId schemaId, IndexDefinition index,
CancellationToken ct = default)
{
if (queryInDedicatedCollection != null)
{
await queryInDedicatedCollection.CreateIndexAsync(appId, schemaId, index, ct);
}
}
public async Task DropIndexAsync(DomainId appId, DomainId schemaId, string name,
CancellationToken ct = default)
{
if (queryInDedicatedCollection != null)
{
await queryInDedicatedCollection.DropIndexAsync(appId, schemaId, name, ct);
}
}
public async Task<List<IndexDefinition>> GetIndexesAsync(DomainId appId, DomainId schemaId,
CancellationToken ct = default)
{
if (queryInDedicatedCollection != null)
{
return await queryInDedicatedCollection.GetIndexesAsync(appId, schemaId, ct);
}
return [];
}
}

19
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<MongoContentEntity>, 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<List<IndexDefinition>> 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;

19
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<MongoCo
return Shard(appId).StreamReferencing(appId, references, take, scope, ct);
}
public Task CreateIndexAsync(DomainId appId, DomainId schemaId, IndexDefinition index,
CancellationToken ct = default)
{
return Shard(appId).CreateIndexAsync(appId, schemaId, index, ct);
}
public Task DropIndexAsync(DomainId appId, DomainId schemaId, string name,
CancellationToken ct = default)
{
return Shard(appId).DropIndexAsync(appId, schemaId, name, ct);
}
public Task<List<IndexDefinition>> GetIndexesAsync(DomainId appId, DomainId schemaId,
CancellationToken ct = default)
{
return Shard(appId).GetIndexesAsync(appId, schemaId, ct);
}
public async IAsyncEnumerable<Content> StreamScheduledWithoutDataAsync(Instant now, SearchScope scope,
[EnumeratorCancellation] CancellationToken ct = default)
{

47
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<string, PropertyPath> pathMap;
private static Dictionary<string, PropertyPath> pathReverseMap;
private static Dictionary<string, string> propertyMap;
private static Dictionary<string, string> propertyReverseMap;
public static IReadOnlyDictionary<string, string> PropertyMap
{
@ -28,13 +31,29 @@ public static class Adapt
StringComparer.OrdinalIgnoreCase);
}
public static IReadOnlyDictionary<string, string> PropertyReverseMap
{
get => propertyReverseMap ??=
BsonClassMap.LookupClassMap(typeof(MongoContentEntity)).AllMemberMaps
.ToDictionary(
x => x.ElementName,
x => x.MemberName.ToCamelCase(),
StringComparer.OrdinalIgnoreCase);
}
public static IReadOnlyDictionary<string, PropertyPath> PathMap
{
get => pathMap ??= PropertyMap.ToDictionary(x => x.Key, x => (PropertyPath)x.Value);
}
public static IReadOnlyDictionary<string, PropertyPath> 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<string>(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)

58
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<MongoContentEntity>
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<List<IndexDefinition>> GetIndexesAsync(DomainId appId, DomainId schemaId,
CancellationToken ct = default)
{
var result = new List<IndexDefinition>();
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<MongoContentEntity>(
definition,
new CreateIndexOptions
{
Name = name,
}),
cancellationToken: ct);
}
private static FilterDefinition<MongoContentEntity> BuildFilter(FilterNode<ClrValue>? filter)
{
var filters = new List<FilterDefinition<MongoContentEntity>>

12
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<JsValue> callback, JsValue? encoding)
private void GetText(ScriptExecutionContext context,
JsValue input, Action<JsValue> callback, JsValue? encoding)
{
if (callback == null)
{
@ -163,7 +164,8 @@ public sealed class AssetsJintExtension : IJintExtension, IScriptDescriptor
});
}
private void GetBlurHash(ScriptExecutionContext context, JsValue input, Action<JsValue> callback, JsValue? componentX, JsValue? componentY)
private void GetBlurHash(ScriptExecutionContext context,
JsValue input, Action<JsValue> 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<JsValue> callback)
private void GetAssets(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user,
JsValue references, Action<JsValue> 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<JsValue> callback)
private void GetAsset(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user,
JsValue references, Action<JsValue> callback)
{
Guard.NotNull(callback);

3
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,

12
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

118
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<JsValue> callback);
private readonly IServiceProvider serviceProvider;
public ContentsJintExtension(IServiceProvider serviceProvider)
{
this.serviceProvider = serviceProvider;
}
public void ExtendAsync(ScriptExecutionContext context)
{
if (!context.TryGetValueIfExists<DomainId>("appId", out var appId))
{
return;
}
if (!context.TryGetValueIfExists<ClaimsPrincipal>("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<JsValue> 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<IContentQueryService>();
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<App> GetAppAsync(DomainId appId)
{
var appProvider = serviceProvider.GetRequiredService<IAppProvider>();
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);
}
}

107
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<string, string>
{
[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<SortOrder>(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);
}
}

80
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<string, string>
{
[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);
}
}

6
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<JsValue> callback)
private void GetReferences(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user,
JsValue references, Action<JsValue> 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<JsValue> callback)
private void GetReference(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user,
JsValue references, Action<JsValue> callback)
{
if (callback == null)
{

10
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<List<IndexDefinition>> GetIndexesAsync(DomainId appId, DomainId schemaId,
CancellationToken ct = default);
}

9
backend/src/Squidex.Domain.Apps.Entities/Properties/Resources.Designer.cs

@ -114,6 +114,15 @@ namespace Squidex.Domain.Apps.Entities.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Queries contents by schema and query..
/// </summary>
internal static string ScriptingGetContents {
get {
return ResourceManager.GetString("ScriptingGetContents", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Queries the content item with the specified ID and invokes the callback with an array of contents..
/// </summary>

3
backend/src/Squidex.Domain.Apps.Entities/Properties/Resources.resx

@ -135,6 +135,9 @@
<data name="ScriptingGetBlurHash" xml:space="preserve">
<value>Gets the blur hash of an asset if it is an image or null otherwise.</value>
</data>
<data name="ScriptingGetContents" xml:space="preserve">
<value>Queries contents by schema and query.</value>
</data>
<data name="ScriptingGetReference" xml:space="preserve">
<value>Queries the content item with the specified ID and invokes the callback with an array of contents.</value>
</data>

19
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);
}
}
}

5
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)
{
}
}

46
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<IndexField>
{
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);

7
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<string>($"\"{value}\"", JsonEscapeOptions)!;
return value;
}
public static bool IsEmail(this string? value)
{
return value != null && RegexEmail.IsMatch(value);

1
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}";

1
backend/src/Squidex.Web/Pipeline/JsonStreamResult.cs

@ -51,6 +51,7 @@ public sealed class JsonStreamResult<T> : ActionResult
// Write the separator after a every json object to simplify deserialization.
await body.WriteAsync(Separator, ct);
await body.FlushAsync(ct);
}
}

2
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);

2
backend/src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs

@ -67,7 +67,7 @@ public sealed class AppClientsController : ApiController
/// </remarks>
[HttpPost]
[Route("apps/{app}/clients/")]
[ProducesResponseType(typeof(ClientsDto), 201)]
[ProducesResponseType(typeof(ClientsDto), StatusCodes.Status201Created)]
[ApiPermissionOrAnonymous(PermissionIds.AppClientsCreate)]
[ApiCosts(1)]
public async Task<IActionResult> PostClient(string app, [FromBody] CreateClientDto request)

2
backend/src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs

@ -61,7 +61,7 @@ public sealed class AppLanguagesController : ApiController
/// <response code="404">App not found.</response>
[HttpPost]
[Route("apps/{app}/languages/")]
[ProducesResponseType(typeof(AppLanguagesDto), 201)]
[ProducesResponseType(typeof(AppLanguagesDto), StatusCodes.Status201Created)]
[ApiPermissionOrAnonymous(PermissionIds.AppLanguagesCreate)]
[ApiCosts(1)]
public async Task<IActionResult> PostLanguage(string app, [FromBody] AddLanguageDto request)

2
backend/src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs

@ -88,7 +88,7 @@ public sealed class AppRolesController : ApiController
/// <response code="404">App not found.</response>
[HttpPost]
[Route("apps/{app}/roles/")]
[ProducesResponseType(typeof(RolesDto), 201)]
[ProducesResponseType(typeof(RolesDto), StatusCodes.Status201Created)]
[ApiPermissionOrAnonymous(PermissionIds.AppRolesCreate)]
[ApiCosts(1)]
public async Task<IActionResult> PostRole(string app, [FromBody] AddRoleDto request)

2
backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs

@ -128,7 +128,7 @@ public sealed class AppsController : ApiController
/// </remarks>
[HttpPost]
[Route("apps/")]
[ProducesResponseType(typeof(AppDto), 201)]
[ProducesResponseType(typeof(AppDto), StatusCodes.Status201Created)]
[ApiPermission]
[ApiCosts(0)]
public async Task<IActionResult> PostApp([FromBody] CreateAppDto request)

5
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<EnrichedContent>(contents);

2
backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs

@ -112,7 +112,7 @@ public sealed class RulesController : ApiController
/// <response code="404">App not found.</response>
[HttpPost]
[Route("apps/{app}/rules/")]
[ProducesResponseType(typeof(RuleDto), 201)]
[ProducesResponseType(typeof(RuleDto), StatusCodes.Status201Created)]
[ApiPermissionOrAnonymous(PermissionIds.AppRulesCreate)]
[ApiCosts(1)]
public async Task<IActionResult> PostRule(string app, [FromBody] CreateRuleDto request)

34
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
{
/// <summary>
/// The index fields.
/// </summary>
[LocalizedRequired]
public List<IndexFieldDto> 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;
}
}

51
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
{
/// <summary>
/// The name of the index.
/// </summary>
[LocalizedRequired]
public string Name { get; set; }
/// <summary>
/// The index fields.
/// </summary>
[LocalizedRequired]
public List<IndexFieldDto> 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<SchemaIndexesController>(x => nameof(x.DeleteIndex), values));
}
return this;
}
}

32
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
{
/// <summary>
/// The name of the field.
/// </summary>
[LocalizedRequired]
public string Name { get; set; }
/// <summary>
/// The sort order of the field.
/// </summary>
public SortOrder Order { get; set; }
public static IndexFieldDto FromDomain(IndexField field)
{
return SimpleMapper.Map(field, new IndexFieldDto());
}
}

44
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
{
/// <summary>
/// The indexes.
/// </summary>
public IndexDto[] Items { get; set; }
public static IndexesDto FromDomain(List<IndexDefinition> 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<SchemaIndexesController>(x => nameof(x.GetIndexes), values));
if (resources.CanManageIndexes(resources.Schema!))
{
AddPostLink("create",
resources.Url<SchemaIndexesController>(x => nameof(x.PostIndex), values));
}
return this;
}
}

6
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs

@ -159,6 +159,12 @@ public class SchemaDto : Resource
resources.Url<ContentsController>(x => nameof(x.GetContents), values));
}
if (resources.CanManageIndexes(Name) && Type == SchemaType.Default)
{
AddGetLink("indexes",
resources.Url<SchemaIndexesController>(x => nameof(x.GetIndexes), values));
}
if (resources.CanCreateContent(Name) && Type == SchemaType.Default)
{
AddPostLink("contents/create",

4
backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemaFieldsController.cs

@ -38,7 +38,7 @@ public sealed class SchemaFieldsController : ApiController
/// <response code="409">Schema field name already in use.</response>
[HttpPost]
[Route("apps/{app}/schemas/{schema}/fields/")]
[ProducesResponseType(typeof(SchemaDto), 201)]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status201Created)]
[ApiPermissionOrAnonymous(PermissionIds.AppSchemasUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PostField(string app, string schema, [FromBody] AddFieldDto request)
@ -63,7 +63,7 @@ public sealed class SchemaFieldsController : ApiController
/// <response code="404">Schema, field or app not found.</response>
[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<IActionResult> PostNestedField(string app, string schema, long parentId, [FromBody] AddFieldDto request)

107
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;
/// <summary>
/// Update and query information about schemas.
/// </summary>
[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;
}
/// <summary>
/// Gets the schema indexes.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <response code="200">Schema indexes returned.</response>
/// <response code="404">Schema or app not found.</response>
[HttpGet]
[Route("apps/{app}/schemas/{schema}/indexes/")]
[ProducesResponseType(typeof(IndexesDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(PermissionIds.AppSchemasIndexes)]
[ApiCosts(1)]
public async Task<IActionResult> 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);
}
/// <summary>
/// Create a schema indexes.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="request">The request object that represents an index.</param>
/// <response code="200">Schema findexes returned.</response>
/// <response code="404">Schema or app not found.</response>
[HttpPost]
[Route("apps/{app}/schemas/{schema}/indexes/")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermissionOrAnonymous(PermissionIds.AppSchemasIndexes)]
[ApiCosts(1)]
public async Task<IActionResult> 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();
}
/// <summary>
/// Create a schema indexes.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="name">The name of the index.</param>
/// <response code="204">Schema index deletion added to job queue.</response>
/// <response code="404">Schema or app not found.</response>
[HttpPost]
[Route("apps/{app}/schemas/{schema}/indexes/{name}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ApiPermissionOrAnonymous(PermissionIds.AppSchemasIndexes)]
[ApiCosts(1)]
public async Task<IActionResult> 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();
}
}

2
backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs

@ -98,7 +98,7 @@ public sealed class SchemasController : ApiController
/// <response code="409">Schema name already in use.</response>
[HttpPost]
[Route("apps/{app}/schemas/")]
[ProducesResponseType(typeof(SchemaDto), 201)]
[ProducesResponseType(typeof(SchemaDto), StatusCodes.Status201Created)]
[ApiPermissionOrAnonymous(PermissionIds.AppSchemasCreate)]
[ApiCosts(1)]
public async Task<IActionResult> PostSchema(string app, [FromBody] CreateSchemaDto request)

2
backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs

@ -93,7 +93,7 @@ public sealed class TeamsController : ApiController
/// </remarks>
[HttpPost]
[Route("teams/")]
[ProducesResponseType(typeof(TeamDto), 201)]
[ProducesResponseType(typeof(TeamDto), StatusCodes.Status201Created)]
[ApiPermission]
[ApiCosts(0)]
public async Task<IActionResult> PostTeam([FromBody] CreateTeamDto request)

57
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<string>("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<string>("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<bool>("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<IEnumerable<ITelemetryConfigurator>>())
{
configurator.Configure(builder);
}
return builder.Build()!;
});
}
}

7
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<RuleRunnerJob>()
.As<IJobRunner>();
services.AddSingletonAs<CreateIndexJob>()
.As<IJobRunner>();
services.AddSingletonAs<DropIndexJob>()
.As<IJobRunner>();
services.AddSingleton<IMessagingSerializer>(c =>
new SystemTextJsonMessagingSerializer(c.GetRequiredService<JsonSerializerOptions>()));

1
backend/src/Squidex/Squidex.csproj

@ -63,6 +63,7 @@
<PackageReference Include="OpenCover" Version="4.7.1221" PrivateAssets="all" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.9.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="5.3.8" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets.Azure" Version="6.19.0" />

31
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<Translations
}
}
[Fact]
public async Task Should_throw_exception_if_callback_is_null_on_getAsset()
{
var (vars, _) = SetupAssetsVars(1);
var script = @"getAsset('id')";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken));
}
[Fact]
public async Task Should_resolve_asset()
{
@ -96,6 +107,16 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture<Translations
Assert.Equal(Cleanup(expected), Cleanup(actual));
}
[Fact]
public async Task Should_throw_exception_if_callback_is_null_on_getAssetV2()
{
var (vars, _) = SetupAssetsVars(1);
var script = @"getAssetV2('id')";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken));
}
[Fact]
public async Task Should_resolve_asset_v2()
{
@ -117,6 +138,16 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture<Translations
Assert.Equal(Cleanup(expected), Cleanup(actual));
}
[Fact]
public async Task Should_throw_exception_if_callback_is_null_on_getAssets()
{
var (vars, _) = SetupAssetsVars(1);
var script = @"getAssetV2('id')";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken));
}
[Fact]
public async Task Should_resolve_assets()
{

147
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<TranslationsFixture>
{
private readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>();
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<ValidationException>(() => 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<Context>.That.Matches(x => x.App == App && x.UserPrincipal == user),
schema,
A<Q>.That.Matches(x => x.QueryAsOdata == filter),
A<CancellationToken>._))
.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);
}
}

172
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<IContentRepository>();
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<string, string>
{
["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<string, string>
{
["appId"] = App.Id.ToString(),
["appName"] = App.Name,
["schemaName"] = Schema.Name,
["field_field1"] = "Ascending",
["field_field2"] = "Descending"
}.ToReadonlyDictionary()
};
var context = CreateContext(job);
await Assert.ThrowsAsync<DomainException>(() => sut.RunAsync(context, CancellationToken));
}
[Fact]
public async Task Should_throw_exception_if_arguments_do_not_contain_schemaName()
{
var job = new Job
{
Arguments = new Dictionary<string, string>
{
["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<DomainException>(() => sut.RunAsync(context, CancellationToken));
}
[Fact]
public async Task Should_throw_exception_if_field_order_is_invalid()
{
var job = new Job
{
Arguments = new Dictionary<string, string>
{
["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<DomainException>(() => sut.RunAsync(context, CancellationToken));
}
[Fact]
public async Task Should_throw_exception_if_fields_are_empty()
{
var job = new Job
{
Arguments = new Dictionary<string, string>
{
["appId"] = App.Id.ToString(),
["appName"] = App.Name,
["schemaId"] = Schema.Id.ToString(),
["schemaName"] = Schema.Name,
}.ToReadonlyDictionary()
};
var context = CreateContext(job);
await Assert.ThrowsAsync<DomainException>(() => sut.RunAsync(context, CancellationToken));
}
[Fact]
public async Task Should_invoke_content_repository()
{
var job = new Job
{
Arguments = new Dictionary<string, string>
{
["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<IndexDefinition>._, CancellationToken))
.Invokes(x => index = x.GetArgument<IndexDefinition>(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<IClock>(), default) { Actor = User, Job = job, OwnerId = App.Id };
}
}

132
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<IContentRepository>();
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<string, string>
{
["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<string, string>
{
["appId"] = App.Id.ToString(),
["appName"] = App.Name,
["schemaName"] = Schema.Name,
["indexName"] = "MyIndex"
}.ToReadonlyDictionary()
};
var context = CreateContext(job);
await Assert.ThrowsAsync<DomainException>(() => sut.RunAsync(context, CancellationToken));
}
[Fact]
public async Task Should_throw_exception_if_arguments_do_not_contain_schemaName()
{
var job = new Job
{
Arguments = new Dictionary<string, string>
{
["appId"] = App.Id.ToString(),
["appName"] = App.Name,
["schemaId"] = Schema.Id.ToString(),
["indexName"] = "MyIndex"
}.ToReadonlyDictionary()
};
var context = CreateContext(job);
await Assert.ThrowsAsync<DomainException>(() => 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<string, string>
{
["appId"] = App.Id.ToString(),
["appName"] = App.Name,
["schemaId"] = Schema.Id.ToString(),
["schemaName"] = Schema.Name,
}.ToReadonlyDictionary()
};
var context = CreateContext(job);
await Assert.ThrowsAsync<DomainException>(() => sut.RunAsync(context, CancellationToken));
}
[Fact]
public async Task Should_invoke_content_repository()
{
var job = new Job
{
Arguments = new Dictionary<string, string>
{
["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<IClock>(), default) { Actor = User, Job = job, OwnerId = App.Id };
}
}

59
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);
}
}

124
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);
}
}

31
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<Translat
extensions);
}
[Fact]
public async Task Should_throw_exception_if_callback_is_null_on_getReference()
{
var (vars, _) = SetupReferenceVars(1);
var script = @"getReference('id')";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken));
}
[Fact]
public async Task Should_resolve_reference()
{
@ -66,6 +77,16 @@ public class ReferencesJintExtensionTests : GivenContext, IClassFixture<Translat
Assert.Equal(Cleanup(expected), Cleanup(actual));
}
[Fact]
public async Task Should_throw_exception_if_callback_is_null_on_getReferenceV2()
{
var (vars, _) = SetupReferenceVars(1);
var script = @"getReferenceV2('id')";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken));
}
[Fact]
public async Task Should_resolve_reference_v2()
{
@ -87,6 +108,16 @@ public class ReferencesJintExtensionTests : GivenContext, IClassFixture<Translat
Assert.Equal(Cleanup(expected), Cleanup(actual));
}
[Fact]
public async Task Should_throw_exception_if_callback_is_null_on_getReferences()
{
var (vars, _) = SetupReferenceVars(1);
var script = @"getReferences('id')";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken));
}
[Fact]
public async Task Should_resolve_references()
{

55
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());
}
}

8
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(" ", "")]

2
frontend/src/app/framework/angular/forms/editors/toggle.component.html

@ -6,4 +6,6 @@
[class.unchecked]="snapshot.isChecked === false"
(click)="changeState()">
<div class="toggle-button"></div>
<i class="icon-close"></i>
<i class="icon-checkmark"></i>
</div>

28
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;
}

56
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: `
<sqx-toggle
[disabled]="disabled"
(ngModelChange)="change($event)"
[ngModel]="model">
</sqx-toggle>
`,
}),
decorators: [
moduleMetadata({
imports: [
FormsModule,
],
}),
],
} as Meta;
type Story = StoryObj<RadioGroupComponent & { model: any }>;
export const Default: Story = {};
export const Checked: Story = {
args: {
model: true,
},
};
export const Unchecked: Story = {
args: {
model: false,
},
};

6
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
},

Loading…
Cancel
Save