From 060f43357bee03d89d2a418bb772d68d52b96dcf Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Mon, 11 Nov 2024 10:15:47 +0100 Subject: [PATCH] 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() {