diff --git a/backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchAction.cs b/backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchAction.cs
index 800df9238..d721bc213 100644
--- a/backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchAction.cs
+++ b/backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchAction.cs
@@ -13,10 +13,10 @@ using Squidex.Infrastructure.Validation;
namespace Squidex.Extensions.Actions.ElasticSearch
{
[RuleAction(
- Title = "Elasticsearch",
+ Title = "ElasticSearch",
IconImage = "",
IconColor = "#1e5470",
- Display = "Populate Elasticsearch index",
+ Display = "Populate ElasticSearch index",
Description = "Populate a full text search index in ElasticSearch.",
ReadMore = "https://www.elastic.co/")]
public sealed record ElasticSearchAction : RuleAction
diff --git a/backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchAction.cs b/backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchAction.cs
new file mode 100644
index 000000000..01160ecd8
--- /dev/null
+++ b/backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchAction.cs
@@ -0,0 +1,53 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using System.ComponentModel.DataAnnotations;
+using Squidex.Domain.Apps.Core.HandleRules;
+using Squidex.Domain.Apps.Core.Rules;
+using Squidex.Infrastructure.Validation;
+
+namespace Squidex.Extensions.Actions.OpenSearch
+{
+ [RuleAction(
+ Title = "OpenSearch",
+ IconImage = "",
+ IconColor = "#005EB8",
+ Display = "Populate OpenSearch index",
+ Description = "Populate a full text search index in OpenSearch.",
+ ReadMore = "https://opensearch.org/")]
+ public sealed record OpenSearchAction : RuleAction
+ {
+ [AbsoluteUrl]
+ [LocalizedRequired]
+ [Display(Name = "Server Url", Description = "The url to the elastic search instance or cluster.")]
+ [Editor(RuleFieldEditor.Url)]
+ public Uri Host { get; set; }
+
+ [LocalizedRequired]
+ [Display(Name = "Index Name", Description = "The name of the index.")]
+ [Editor(RuleFieldEditor.Text)]
+ [Formattable]
+ public string IndexName { get; set; }
+
+ [Display(Name = "Username", Description = "The optional username.")]
+ [Editor(RuleFieldEditor.Text)]
+ public string Username { get; set; }
+
+ [Display(Name = "Password", Description = "The optional password.")]
+ [Editor(RuleFieldEditor.Text)]
+ public string Password { get; set; }
+
+ [Display(Name = "Document", Description = "The optional custom document.")]
+ [Editor(RuleFieldEditor.TextArea)]
+ [Formattable]
+ public string Document { get; set; }
+
+ [Display(Name = "Deletion", Description = "The condition when to delete the document.")]
+ [Editor(RuleFieldEditor.Text)]
+ public string Delete { get; set; }
+ }
+}
diff --git a/backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchActionHandler.cs b/backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchActionHandler.cs
new file mode 100644
index 000000000..3c94eeb7b
--- /dev/null
+++ b/backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchActionHandler.cs
@@ -0,0 +1,169 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using System.Text.Json.Serialization;
+using OpenSearch.Net;
+using Squidex.Domain.Apps.Core.HandleRules;
+using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
+using Squidex.Domain.Apps.Core.Scripting;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.Json;
+
+#pragma warning disable IDE0059 // Value assigned to symbol is never used
+#pragma warning disable MA0048 // File name must match type name
+
+namespace Squidex.Extensions.Actions.OpenSearch
+{
+ public sealed class OpenSearchActionHandler : RuleActionHandler
+ {
+ private readonly ClientPool<(Uri Host, string Username, string Password), OpenSearchLowLevelClient> clients;
+ private readonly IScriptEngine scriptEngine;
+ private readonly IJsonSerializer serializer;
+
+ public OpenSearchActionHandler(RuleEventFormatter formatter, IScriptEngine scriptEngine, IJsonSerializer serializer)
+ : base(formatter)
+ {
+ clients = new ClientPool<(Uri Host, string Username, string Password), OpenSearchLowLevelClient>(key =>
+ {
+ var config = new ConnectionConfiguration(key.Host);
+
+ if (!string.IsNullOrEmpty(key.Username) && !string.IsNullOrWhiteSpace(key.Password))
+ {
+ config = config.BasicAuthentication(key.Username, key.Password);
+ }
+
+ return new OpenSearchLowLevelClient(config);
+ });
+
+ this.scriptEngine = scriptEngine;
+ this.serializer = serializer;
+ }
+
+ protected override async Task<(string Description, OpenSearchJob Data)> CreateJobAsync(EnrichedEvent @event, OpenSearchAction action)
+ {
+ var delete = @event.ShouldDelete(scriptEngine, action.Delete);
+
+ string contentId;
+
+ if (@event is IEnrichedEntityEvent enrichedEntityEvent)
+ {
+ contentId = enrichedEntityEvent.Id.ToString();
+ }
+ else
+ {
+ contentId = DomainId.NewGuid().ToString();
+ }
+
+ var ruleDescription = string.Empty;
+ var ruleJob = new OpenSearchJob
+ {
+ IndexName = await FormatAsync(action.IndexName, @event),
+ ServerHost = action.Host.ToString(),
+ ServerUser = action.Username,
+ ServerPassword = action.Password,
+ ContentId = contentId
+ };
+
+ if (delete)
+ {
+ ruleDescription = $"Delete entry index: {action.IndexName}";
+ }
+ else
+ {
+ ruleDescription = $"Upsert to index: {action.IndexName}";
+
+ ElasticContent content;
+ try
+ {
+ string jsonString;
+
+ if (!string.IsNullOrEmpty(action.Document))
+ {
+ jsonString = await FormatAsync(action.Document, @event);
+ jsonString = jsonString?.Trim();
+ }
+ else
+ {
+ jsonString = ToJson(@event);
+ }
+
+ content = serializer.Deserialize(jsonString);
+ }
+ catch (Exception ex)
+ {
+ content = new ElasticContent
+ {
+ More = new Dictionary
+ {
+ ["error"] = $"Invalid JSON: {ex.Message}"
+ }
+ };
+ }
+
+ content.ContentId = contentId;
+
+ ruleJob.Content = serializer.Serialize(content, true);
+ }
+
+ return (ruleDescription, ruleJob);
+ }
+
+ protected override async Task ExecuteJobAsync(OpenSearchJob job,
+ CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(job.ServerHost))
+ {
+ return Result.Ignored();
+ }
+
+ var client = await clients.GetClientAsync((new Uri(job.ServerHost, UriKind.Absolute), job.ServerUser, job.ServerPassword));
+
+ try
+ {
+ if (job.Content != null)
+ {
+ var response = await client.IndexAsync(job.IndexName, job.ContentId, job.Content, ctx: ct);
+
+ return Result.SuccessOrFailed(response.OriginalException, response.Body);
+ }
+ else
+ {
+ var response = await client.DeleteAsync(job.IndexName, job.ContentId, ctx: ct);
+
+ return Result.SuccessOrFailed(response.OriginalException, response.Body);
+ }
+ }
+ catch (OpenSearchClientException ex)
+ {
+ return Result.Failed(ex);
+ }
+ }
+ }
+
+ public sealed class ElasticContent
+ {
+ public string ContentId { get; set; }
+
+ [JsonExtensionData]
+ public Dictionary More { get; set; } = new Dictionary();
+ }
+
+ public sealed class OpenSearchJob
+ {
+ public string ServerHost { get; set; }
+
+ public string ServerUser { get; set; }
+
+ public string ServerPassword { get; set; }
+
+ public string ContentId { get; set; }
+
+ public string Content { get; set; }
+
+ public string IndexName { get; set; }
+ }
+}
diff --git a/backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchPlugin.cs b/backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchPlugin.cs
new file mode 100644
index 000000000..4a4435c25
--- /dev/null
+++ b/backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchPlugin.cs
@@ -0,0 +1,21 @@
+// ==========================================================================
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex UG (haftungsbeschraenkt)
+// All rights reserved. Licensed under the MIT license.
+// ==========================================================================
+
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Squidex.Infrastructure.Plugins;
+
+namespace Squidex.Extensions.Actions.OpenSearch
+{
+ public sealed class OpenSearchPlugin : IPlugin
+ {
+ public void ConfigureServices(IServiceCollection services, IConfiguration config)
+ {
+ services.AddRuleAction();
+ }
+ }
+}
diff --git a/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj b/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj
index 5febbf4b7..7c9d06d7d 100644
--- a/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj
+++ b/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj
@@ -16,7 +16,7 @@
-
+
@@ -29,6 +29,7 @@
+