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 @@ +