From 379caaef5f7af9ecaca35648a4b71173468ee853 Mon Sep 17 00:00:00 2001 From: Noah Date: Wed, 24 Sep 2025 19:37:54 +0900 Subject: [PATCH] Add script request method (#1253) * Add script requestAsync * Refactor return type and line conditions of the code * Refactor request header type * Fix remove unnecessary types from test cases * Fix remove unnecessary types from exception test cases * Fix ScriptingRequestAsync additional comments * Fix incorrectly expressed code * Refactor change the name of the function and modify some sections of the code. --- .../Properties/Resources.Designer.cs | 132 +++--------------- .../Properties/Resources.resx | 5 +- .../Scripting/Extensions/HttpJintExtension.cs | 78 ++++++++++- .../Scripting/JintScriptEngineHelperTests.cs | 118 ++++++++++++++++ 4 files changed, 215 insertions(+), 118 deletions(-) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.Designer.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.Designer.cs index b51169662..5685e196b 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.Designer.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.Designer.cs @@ -1,7 +1,6 @@ //------------------------------------------------------------------------------ // // This code was generated by a tool. -// Runtime Version:4.0.30319.42000 // // Changes to this file may cause incorrect behavior and will be lost if // the code is regenerated. @@ -12,46 +11,32 @@ namespace Squidex.Domain.Apps.Core.Properties { using System; - /// - /// A strongly-typed resource class, for looking up localized strings, etc. - /// - // This class was auto-generated by the StronglyTypedResourceBuilder - // class via a tool like ResGen or Visual Studio. - // To add or remove a member, edit your .ResX file then rerun ResGen - // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] - [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] - [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class Resources { - private static global::System.Resources.ResourceManager resourceMan; + private static System.Resources.ResourceManager resourceMan; - private static global::System.Globalization.CultureInfo resourceCulture; + private static System.Globalization.CultureInfo resourceCulture; - [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] internal Resources() { } - /// - /// Returns the cached ResourceManager instance used by this class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager { + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager { get { - if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Squidex.Domain.Apps.Core.Properties.Resources", typeof(Resources).Assembly); + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Squidex.Domain.Apps.Core.Properties.Resources", typeof(Resources).Assembly); resourceMan = temp; } return resourceMan; } } - /// - /// Overrides the current thread's CurrentUICulture property for all - /// resource lookups using this strongly typed resource class. - /// - [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture { + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture { get { return resourceCulture; } @@ -60,265 +45,184 @@ namespace Squidex.Domain.Apps.Core.Properties { } } - /// - /// Looks up a localized string similar to The download URL to the asset.. - /// internal static string ScriptingAssetContentAppUrl { get { return ResourceManager.GetString("ScriptingAssetContentAppUrl", resourceCulture); } } - /// - /// Looks up a localized string similar to The download URL to the asset using the file slug instead of the ID.. - /// internal static string ScriptingAssetContentSlugUrl { get { return ResourceManager.GetString("ScriptingAssetContentSlugUrl", resourceCulture); } } - /// - /// Looks up a localized string similar to The download URL to the asset without the app name (deprecated).. - /// internal static string ScriptingAssetContentUrl { get { return ResourceManager.GetString("ScriptingAssetContentUrl", resourceCulture); } } - /// - /// Looks up a localized string similar to Counts the number of characters in a text. Useful in combination with html2Text or markdown2Text.. - /// internal static string ScriptingCharacterCount { get { return ResourceManager.GetString("ScriptingCharacterCount", resourceCulture); } } - /// - /// Looks up a localized string similar to Completes the script when an async method is used.. - /// internal static string ScriptingComplete { get { return ResourceManager.GetString("ScriptingComplete", resourceCulture); } } - /// - /// Looks up a localized string similar to The status of the content.. - /// internal static string ScriptingContentAction { get { return ResourceManager.GetString("ScriptingContentAction", resourceCulture); } } - /// - /// Looks up a localized string similar to The URL to the content in the UI.. - /// internal static string ScriptingContentUrl { get { return ResourceManager.GetString("ScriptingContentUrl", resourceCulture); } } - /// - /// Looks up a localized string similar to Makes a DELETE request to the defined URL and parses the result as JSON. Headers are optional.. - /// internal static string ScriptingDeleteJson { get { return ResourceManager.GetString("ScriptingDeleteJson", resourceCulture); } } - /// - /// Looks up a localized string similar to Tell Squidex to not allow the current operation and to return a 400 (BadRequest).. - /// internal static string ScriptingDisallow { get { return ResourceManager.GetString("ScriptingDisallow", resourceCulture); } } - /// - /// Looks up a localized string similar to Formats a JavaScript date object using the specified pattern.. - /// internal static string ScriptingFormatDate { get { return ResourceManager.GetString("ScriptingFormatDate", resourceCulture); } } - /// - /// Looks up a localized string similar to Formats a JavaScript date object using the specified pattern.. - /// internal static string ScriptingFormatTime { get { return ResourceManager.GetString("ScriptingFormatTime", resourceCulture); } } - /// - /// Looks up a localized string similar to Uses OpenAI or other machine learning platforms to generate content from a prompt.. - /// internal static string ScriptingGenerate { get { return ResourceManager.GetString("ScriptingGenerate", resourceCulture); } } - /// - /// Looks up a localized string similar to Makes a GET request to the defined URL and parses the result as JSON. Headers are optional.. - /// internal static string ScriptingGetJSON { get { return ResourceManager.GetString("ScriptingGetJSON", resourceCulture); } } - /// - /// Looks up a localized string similar to Generates a guid.. - /// internal static string ScriptingGuid { get { return ResourceManager.GetString("ScriptingGuid", resourceCulture); } } - /// - /// Looks up a localized string similar to Converts a HTML string to plain text.. - /// internal static string ScriptingHtml2Text { get { return ResourceManager.GetString("ScriptingHtml2Text", resourceCulture); } } - /// - /// Looks up a localized string similar to Converts a markdown string to plain text.. - /// internal static string ScriptingMarkdown2Text { get { return ResourceManager.GetString("ScriptingMarkdown2Text", resourceCulture); } } - /// - /// Looks up a localized string similar to Calculate the MD5 hash from a given string. Use this method for hashing passwords, when backwards compatibility is important.. - /// internal static string ScriptingMD5 { get { return ResourceManager.GetString("ScriptingMD5", resourceCulture); } } - /// - /// Looks up a localized string similar to Makes a PATCH request to the defined URL and parses the result as JSON. Headers are optional.. - /// internal static string ScriptingPatchJson { get { return ResourceManager.GetString("ScriptingPatchJson", resourceCulture); } } - /// - /// Looks up a localized string similar to Makes a POST request to the defined URL and parses the result as JSON. Headers are optional.. - /// internal static string ScriptingPostJSON { get { return ResourceManager.GetString("ScriptingPostJSON", resourceCulture); } } - /// - /// Looks up a localized string similar to Makes a PUT request to the defined URL and parses the result as JSON. Headers are optional.. - /// internal static string ScriptingPutJson { get { return ResourceManager.GetString("ScriptingPutJson", resourceCulture); } } - /// - /// Looks up a localized string similar to Tell Squidex to reject the current operation and to return a 403 (Forbidden).. - /// internal static string ScriptingReject { get { return ResourceManager.GetString("ScriptingReject", resourceCulture); } } - /// - /// Looks up a localized string similar to Tell Squidex that you have modified the data and that the change should be applied.. - /// internal static string ScriptingReplace { get { return ResourceManager.GetString("ScriptingReplace", resourceCulture); } } - /// - /// Looks up a localized string similar to Calculate the SHA256 hash from a given string. Use this method for hashing passwords.. - /// internal static string ScriptingSHA256 { get { return ResourceManager.GetString("ScriptingSHA256", resourceCulture); } } - /// - /// Looks up a localized string similar to Calculate the SHA256 hash from a given string. Use this method for hashing passwords.. - /// internal static string ScriptingSHA512 { get { return ResourceManager.GetString("ScriptingSHA512", resourceCulture); } } - /// - /// Looks up a localized string similar to Calculates the slug of a text by removing all special characters and whitespaces to create a friendly term that can be used for SEO-friendly URLs.. - /// internal static string ScriptingSlugify { get { return ResourceManager.GetString("ScriptingSlugify", resourceCulture); } } - /// - /// Looks up a localized string similar to Converts a text to camelCase.. - /// internal static string ScriptingToCamelCase { get { return ResourceManager.GetString("ScriptingToCamelCase", resourceCulture); } } - /// - /// Looks up a localized string similar to Converts a text to PascalCase. - /// internal static string ScriptingToPascalCase { get { return ResourceManager.GetString("ScriptingToPascalCase", resourceCulture); } } - /// - /// Looks up a localized string similar to Translate from the source text to the specified language.. - /// internal static string ScriptingTranslate { get { return ResourceManager.GetString("ScriptingTranslate", resourceCulture); } } - /// - /// Looks up a localized string similar to Counts the number of words in a text. Useful in combination with html2Text or markdown2Text.. - /// internal static string ScriptingWordCount { get { return ResourceManager.GetString("ScriptingWordCount", resourceCulture); } } + + internal static string ScriptingRequest { + get { + return ResourceManager.GetString("ScriptingRequest", resourceCulture); + } + } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.resx b/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.resx index 786ba4da3..92545f966 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.resx +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Properties/Resources.resx @@ -204,4 +204,7 @@ Counts the number of words in a text. Useful in combination with html2Text or markdown2Text. - \ No newline at end of file + + Makes a request to the defined URL. requestInit dictionary is used to configure the request call, allowing you to specify the url, method, headers, body. The response is received via a callback. + + diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpJintExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpJintExtension.cs index b000d0ae8..6a683b461 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpJintExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpJintExtension.cs @@ -12,7 +12,6 @@ using Jint.Native.Json; using Jint.Native.Object; using Jint.Runtime; using Squidex.Domain.Apps.Core.Properties; -using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Scripting.Extensions; @@ -20,6 +19,7 @@ public sealed class HttpJintExtension(IHttpClientFactory httpClientFactory) : IJ { private delegate void HttpJsonDelegate(string url, Action callback, JsValue? headers = null, bool ignoreError = false); private delegate void HttpJsonWithBodyDelegate(string url, JsValue body, Action callback, JsValue? headers = null, bool ignoreError = false); + private delegate void HttpRequestDelegate(JsValue requestInit, Action callback); public void ExtendAsync(ScriptExecutionContext context) { @@ -28,6 +28,18 @@ public sealed class HttpJintExtension(IHttpClientFactory httpClientFactory) : IJ AddBodyMethod(context, HttpMethod.Put, "putJSON"); AddMethod(context, HttpMethod.Delete, "deleteJSON"); AddMethod(context, HttpMethod.Get, "getJSON"); + AddMethod(context, "request"); + } + + private void AddMethod(ScriptExecutionContext context, string name) + { + var action = new HttpRequestDelegate((requestInit, callback) => + { + var httpRequest = ParseRequestInit(requestInit); + Request(context, httpRequest.Method, httpRequest.Url, httpRequest.Body, callback, httpRequest.Headers, true, true); + }); + + context.Engine.SetValue(name, action); } private void AddMethod(ScriptExecutionContext context, HttpMethod method, string name) @@ -50,7 +62,7 @@ public sealed class HttpJintExtension(IHttpClientFactory httpClientFactory) : IJ context.Engine.SetValue(name, action); } - private void Request(ScriptExecutionContext context, HttpMethod method, string url, JsValue? body, Action callback, JsValue? headers, bool ignoreError) + private void Request(ScriptExecutionContext context, HttpMethod method, string url, JsValue? body, Action callback, JsValue? headers, bool ignoreError, bool forceRawResponse = false) { if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) { @@ -80,7 +92,7 @@ public sealed class HttpJintExtension(IHttpClientFactory httpClientFactory) : IJ var responseString = await response.Content.ReadAsStringAsync(ct); - if (ignoreError && (!response.IsSuccessStatusCode || string.IsNullOrEmpty(responseString))) + if (ignoreError && (forceRawResponse || !response.IsSuccessStatusCode || string.IsNullOrEmpty(responseString))) { responseObject = JsValue.FromObject(context.Engine, new Dictionary { @@ -141,6 +153,17 @@ public sealed class HttpJintExtension(IHttpClientFactory httpClientFactory) : IJ request.Content = new FormUrlEncodedContent(formValues); } + else if (string.Equals(contentType, "text/plain", StringComparison.OrdinalIgnoreCase) || + string.Equals(contentType, "text/xml", StringComparison.OrdinalIgnoreCase) || + string.Equals(contentType, "application/xml", StringComparison.OrdinalIgnoreCase)) + { + if (!body.IsString()) + { + throw new JavaScriptException("Body is not a string."); + } + + request.Content = new StringContent(body.AsString(), Encoding.UTF8, contentType); + } else { var jsonWriter = new JsonSerializer(context.Engine); @@ -190,6 +213,9 @@ public sealed class HttpJintExtension(IHttpClientFactory httpClientFactory) : IJ describe(JsonType.Function, "deleteJSON(url, callback, headers?, ignoreError?)", Resources.ScriptingDeleteJson); + + describe(JsonType.Function, "request(requestInit, callback)", + Resources.ScriptingRequest); } private static IEnumerable<(string, string)> GetNonEmptyProperties(JsValue? source) @@ -209,4 +235,50 @@ public sealed class HttpJintExtension(IHttpClientFactory httpClientFactory) : IJ } } } + + private static (string Url, HttpMethod Method, JsValue Headers, JsValue Body) ParseRequestInit(JsValue? requestInit) + { + if (requestInit?.IsObject() != true || requestInit.AsObject() is not ObjectInstance obj) + { + throw new JavaScriptException("RequestInit is not an object."); + } + + if (!obj.TryGetValue("url", out var urlJsValue)) + { + throw new JavaScriptException("Missing required property 'url'."); + } + + if (!urlJsValue.IsString()) + { + throw new JavaScriptException("URL property must be a string."); + } + + if (!obj.TryGetValue("method", out var methodJsValue)) + { + throw new JavaScriptException("Missing required property 'method'."); + } + + if (!methodJsValue.IsString()) + { + throw new JavaScriptException("Method property must be a string."); + } + + var url = urlJsValue.AsString(); + var method = methodJsValue.AsString().ToUpperInvariant() switch + { + "PATCH" => HttpMethod.Patch, + "POST" => HttpMethod.Post, + "PUT" => HttpMethod.Put, + "DELETE" => HttpMethod.Delete, + "GET" => HttpMethod.Get, + _ => throw new JavaScriptException($"Invalid HTTP method: '{methodJsValue.AsString()}'"), + }; + + return ( + Url: url, + Method: method, + Headers: obj["headers"], + Body: obj["body"] + ); + } } diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs index 4040cda47..4f99fb2c8 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs @@ -861,6 +861,124 @@ public class JintScriptEngineHelperTests : IClassFixture Assert.Equal(expectedResult, actual); } + [Fact] + public async Task Should_make_request() + { + var httpHandler = SetupRequest(); + var vars = new ScriptVars + { + }; + + const string script = @" + request({ + url: 'http://squidex.io/', + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: { + key: 42 + } + }, function(actual) { + complete(actual); + }); + "; + + var actual = await sut.ExecuteAsync(vars, script); + + httpHandler.ShouldBeUrl("http://squidex.io/"); + httpHandler.ShouldBeMethod(HttpMethod.Post); + httpHandler.ShouldBeBody("{\"key\":42}", "application/json"); + + var expectedResult = + JsonValue.Object() + .Add("statusCode", 200) + .Add("headers", + JsonValue.Object() + .Add("Content-Type", "application/json; charset=utf-8") + .Add("Content-Length", "13")) + .Add("body", "{ \"key\": 42 }"); + + Assert.Equal(expectedResult, actual); + } + + [Theory] + [InlineData("text/plain")] + [InlineData("text/xml")] + [InlineData("application/xml")] + public async Task Should_make_request_text_body(string input) + { + var (body, contentLength) = input switch + { + "text/plain" => ("test", "test".Length), + "text/xml" => ("", "".Length), + "application/xml" => ("", "".Length), + _ => (string.Empty, 0), + }; + + var httpHandler = SetupRequest(HttpStatusCode.OK, new StringContent(body, Encoding.UTF8, input)); + var vars = new ScriptVars + { + }; + + var script = $@" + request({{ + url: 'http://squidex.io/', + method: 'POST', + headers: {{ + 'Content-Type': '{input}' + }}, + body: '{body}' + }}, function(actual) {{ + complete(actual); + }}); + "; + + var actual = await sut.ExecuteAsync(vars, script); + + httpHandler.ShouldBeUrl("http://squidex.io/"); + httpHandler.ShouldBeMethod(HttpMethod.Post); + httpHandler.ShouldBeBody(body, input); + + var expectedResult = + JsonValue.Object() + .Add("statusCode", 200) + .Add("headers", JsonValue.Object() + .Add("Content-Type", $"{input}; charset=utf-8") + .Add("Content-Length", $"{contentLength}")) + .Add("body", body); + + Assert.Equal(expectedResult, actual); + } + + [Theory] + [InlineData("text/plain")] + [InlineData("text/xml")] + [InlineData("application/xml")] + public async Task Should_throw_exception_if_request_body_is_not_string(string input) + { + var vars = new ScriptVars + { + }; + + var script = $@" + request({{ + url: 'http://squidex.io/', + method: 'POST', + headers: {{ + 'Content-Type': '{input}' + }}, + body: {{ + test: 1 + }} + }}, function(actual) {{ + complete(actual); + }}); + "; + + await Assert.ThrowsAsync(() => sut.ExecuteAsync(vars, script)); + } + private MockupHttpHandler SetupRequest(HttpStatusCode statusCode = HttpStatusCode.OK, StringContent? responseContent = null) { var httpResponse = new HttpResponseMessage(statusCode)