diff --git a/src/bundles/Elsa.WorkflowServer.Web/Program.cs b/src/bundles/Elsa.WorkflowServer.Web/Program.cs index 230aa5119..e310afb84 100644 --- a/src/bundles/Elsa.WorkflowServer.Web/Program.cs +++ b/src/bundles/Elsa.WorkflowServer.Web/Program.cs @@ -67,6 +67,7 @@ services .AddActivity() .AddActivity() .AddActivity() + .AddActivity() ) .Use(identity => { diff --git a/src/designer/elsa-workflows-designer/src/components.d.ts b/src/designer/elsa-workflows-designer/src/components.d.ts index b4cb603fb..1bc34b46e 100644 --- a/src/designer/elsa-workflows-designer/src/components.d.ts +++ b/src/designer/elsa-workflows-designer/src/components.d.ts @@ -79,6 +79,9 @@ export namespace Components { interface ElsaCheckListInput { "inputContext": ActivityInputContext; } + interface ElsaCheckboxInput { + "inputContext": ActivityInputContext; + } interface ElsaCodeEditorInput { "inputContext": ActivityInputContext; } @@ -358,6 +361,12 @@ declare global { prototype: HTMLElsaCheckListInputElement; new (): HTMLElsaCheckListInputElement; }; + interface HTMLElsaCheckboxInputElement extends Components.ElsaCheckboxInput, HTMLStencilElement { + } + var HTMLElsaCheckboxInputElement: { + prototype: HTMLElsaCheckboxInputElement; + new (): HTMLElsaCheckboxInputElement; + }; interface HTMLElsaCodeEditorInputElement extends Components.ElsaCodeEditorInput, HTMLStencilElement { } var HTMLElsaCodeEditorInputElement: { @@ -650,6 +659,7 @@ declare global { "elsa-button-group": HTMLElsaButtonGroupElement; "elsa-canvas": HTMLElsaCanvasElement; "elsa-check-list-input": HTMLElsaCheckListInputElement; + "elsa-checkbox-input": HTMLElsaCheckboxInputElement; "elsa-code-editor-input": HTMLElsaCodeEditorInputElement; "elsa-context-menu": HTMLElsaContextMenuElement; "elsa-copy-button": HTMLElsaCopyButtonElement; @@ -739,6 +749,9 @@ declare namespace LocalJSX { interface ElsaCheckListInput { "inputContext"?: ActivityInputContext; } + interface ElsaCheckboxInput { + "inputContext"?: ActivityInputContext; + } interface ElsaCodeEditorInput { "inputContext"?: ActivityInputContext; } @@ -980,6 +993,7 @@ declare namespace LocalJSX { "elsa-button-group": ElsaButtonGroup; "elsa-canvas": ElsaCanvas; "elsa-check-list-input": ElsaCheckListInput; + "elsa-checkbox-input": ElsaCheckboxInput; "elsa-code-editor-input": ElsaCodeEditorInput; "elsa-context-menu": ElsaContextMenu; "elsa-copy-button": ElsaCopyButton; @@ -1042,6 +1056,7 @@ declare module "@stencil/core" { "elsa-button-group": LocalJSX.ElsaButtonGroup & JSXBase.HTMLAttributes; "elsa-canvas": LocalJSX.ElsaCanvas & JSXBase.HTMLAttributes; "elsa-check-list-input": LocalJSX.ElsaCheckListInput & JSXBase.HTMLAttributes; + "elsa-checkbox-input": LocalJSX.ElsaCheckboxInput & JSXBase.HTMLAttributes; "elsa-code-editor-input": LocalJSX.ElsaCodeEditorInput & JSXBase.HTMLAttributes; "elsa-context-menu": LocalJSX.ElsaContextMenu & JSXBase.HTMLAttributes; "elsa-copy-button": LocalJSX.ElsaCopyButton & JSXBase.HTMLAttributes; diff --git a/src/designer/elsa-workflows-designer/src/components/icons/activities/send-http-request.tsx b/src/designer/elsa-workflows-designer/src/components/icons/activities/send-http-request.tsx new file mode 100644 index 000000000..e1263e568 --- /dev/null +++ b/src/designer/elsa-workflows-designer/src/components/icons/activities/send-http-request.tsx @@ -0,0 +1,14 @@ +import {FunctionalComponent, h} from '@stencil/core'; +import {ActivityIconSettings, getActivityIconCssClass} from "./models"; + +export const SendHttpRequestIcon: FunctionalComponent = (settings) => ( + + + + + + + + +); diff --git a/src/designer/elsa-workflows-designer/src/components/inputs/checkbox.tsx b/src/designer/elsa-workflows-designer/src/components/inputs/checkbox.tsx new file mode 100644 index 000000000..db1a3648a --- /dev/null +++ b/src/designer/elsa-workflows-designer/src/components/inputs/checkbox.tsx @@ -0,0 +1,54 @@ +import {Component, Prop, h, State} from '@stencil/core'; +import {LiteralExpression, SyntaxNames} from "../../models"; +import {ActivityInputContext} from "../../services/node-input-driver"; +import {getInputPropertyValue } from "../../utils"; +import {ExpressionChangedArs} from "../designer/input-control-switch/input-control-switch"; + +@Component({ + tag: 'elsa-checkbox-input', + shadow: false +}) +export class Checkbox { + @Prop() public inputContext: ActivityInputContext; + @State() private isChecked?: boolean; + + public async componentWillLoad() { + this.isChecked = this.getSelectedValue(); + } + + private getSelectedValue = (): boolean => { + const input = getInputPropertyValue(this.inputContext); + return (input?.expression as LiteralExpression)?.value; + }; + + render() { + const inputContext = this.inputContext; + const inputDescriptor = inputContext.inputDescriptor; + const fieldName = inputDescriptor.name; + const fieldId = inputDescriptor.name; + const displayName = inputDescriptor.displayName; + const hint = inputDescriptor.description; + const input = getInputPropertyValue(inputContext); + const value = (input?.expression as LiteralExpression)?.value; + const syntax = input?.expression?.type ?? inputDescriptor.defaultSyntax; + const isChecked = this.isChecked; + + return ( + + + + ); + } + + private onPropertyEditorChanged = (e: Event) => { + const inputElement = e.target as HTMLInputElement; + this.isChecked = inputElement.checked; + this.inputContext.inputChanged(inputElement.checked, SyntaxNames.Literal); + } + + private onExpressionChanged = (e: CustomEvent) => { + debugger; + this.inputContext.inputChanged(e.detail.expression, e.detail.syntax); + } +} diff --git a/src/designer/elsa-workflows-designer/src/services/activity-icon-registry.tsx b/src/designer/elsa-workflows-designer/src/services/activity-icon-registry.tsx index e6bda67ee..c7ea5c726 100644 --- a/src/designer/elsa-workflows-designer/src/services/activity-icon-registry.tsx +++ b/src/designer/elsa-workflows-designer/src/services/activity-icon-registry.tsx @@ -16,6 +16,7 @@ import { } from "../components/icons/activities"; import {WriteHttpResponseIcon} from "../components/icons/activities/write-http-response"; import {FlowJoinIcon} from "../components/icons/activities/flow-join"; +import {SendHttpRequestIcon} from "../components/icons/activities/send-http-request" export type ActivityType = string; export type ActivityIcon = (ActivityIconSettings?) => any; @@ -39,6 +40,7 @@ export class ActivityIconRegistry { this.add('Elsa.Event', settings => ); this.add('Elsa.RunJavaScript', settings => ); this.add('Elsa.FlowJoin', settings => ); + this.add('Elsa.SendHttpRequest', settings => ); } public add(activityType: ActivityType, icon: ActivityIcon) { diff --git a/src/designer/elsa-workflows-designer/src/services/input-control-registry.tsx b/src/designer/elsa-workflows-designer/src/services/input-control-registry.tsx index 1e5f66631..1ae75d6eb 100644 --- a/src/designer/elsa-workflows-designer/src/services/input-control-registry.tsx +++ b/src/designer/elsa-workflows-designer/src/services/input-control-registry.tsx @@ -17,6 +17,7 @@ export class InputControlRegistry { this.add('radio-list', c => ); this.add('multi-text', c => ); this.add('code-editor', c => ); + this.add('checkbox', c => ); } public add(uiHint: UIHint, control: RenderActivityPropInputControl) { diff --git a/src/modules/Elsa.Http/Activities/SendHttpRequest.cs b/src/modules/Elsa.Http/Activities/SendHttpRequest.cs new file mode 100644 index 000000000..4c0d2a228 --- /dev/null +++ b/src/modules/Elsa.Http/Activities/SendHttpRequest.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Elsa.Http.ContentWriters; +using Elsa.Http.Models; +using Elsa.Http.Parsers; +using Elsa.Workflows.Core.Attributes; +using Elsa.Workflows.Core.Models; +using Elsa.Workflows.Management.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using HttpRequestHeaders = Elsa.Http.Models.HttpRequestHeaders; + +namespace Elsa.Http; + +[Activity("Elsa", "HTTP", "Send Http Request.", DisplayName = "Send HTTP Request")] +public class SendHttpRequest : Activity +{ + [Input] + public Input Url { get; set; } = default!; + + [Input( + Options = new[] {"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"}, + UIHint = InputUIHints.Dropdown + )] + public Input Method { get; set; } + + public Input Content { get; set; } = default!; + + [Input( + Options = new[] { "", "text/plain", "text/html", "application/json", "application/xml", "application/x-www-form-urlencoded" }, + UIHint = InputUIHints.Dropdown + )] + public Input ContentType { get; set; } = default!; + + [Input(Category = "Security")] + public Input Authorization { get; set; } = default!; + + public Input ReadContent { get; set; } = new(false); + + [Input( + Options = new[] { "", "JsonElement", "Plain Text" }, + UIHint = InputUIHints.Dropdown + )] + public Input ResponseContentParserName { get; set; } = default!; + + [Input(Category = "Security")] + public Input?> RequestHeaders { get; set; } = new(new HttpRequestHeaders()); + + [Output] + public Output? ResponseContent { get; set; } + + [Output] + public Output Response { get; set; } + + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var request = PrepareRequest(context); + + var httpClientFactory = context.GetService(); + var httpClient = httpClientFactory.CreateClient(nameof(SendHttpRequest)); + + var cancellationToken = context.CancellationToken; + var response = await httpClient.SendAsync(request, cancellationToken); + + var allHeaders = + response.Headers.ToDictionary(x => x.Key, x => x.Value.ToArray()) + .Concat(response.Content.Headers.ToDictionary(x => x.Key, x => x.Value.ToArray())); + + var responseModel = new HttpResponseModel(response.StatusCode, new Dictionary()) + { + StatusCode = response!.StatusCode, + Headers = new Dictionary(allHeaders) + }; + + context.Set(Response, responseModel); + + if (HasContent(response) && context.Get(ReadContent)) + { + var parsers = context.GetService>(); + var formatter = SelectContentParser(parsers.ToList(), context.Get(ResponseContentParserName), context.Get(ContentType)); + context.Set(ResponseContent, await formatter.ReadAsync(response, context, cancellationToken)); + } + } + + private IHttpResponseContentReader SelectContentParser(List parsers, string? parserName, string? contentType) + { + if (string.IsNullOrWhiteSpace(parserName)) + { + var simpleContentType = contentType?.Split(';').First() ?? ""; + var parser = parsers.OrderByDescending(x => x.Priority).ToList(); + + return parser.FirstOrDefault(x => x.GetSupportsContentType(simpleContentType)) ?? parser.Last(); + } + else + { + var parser = parsers.FirstOrDefault(x => x.Name == parserName); + + if (parser == null) + throw new InvalidOperationException("The specified parser does not exist"); + + return parser; + } + } + + private bool HasContent(HttpResponseMessage response) + { + return response?.Content != null && + response.Content.Headers.ContentLength > 0; + } + + private HttpRequestMessage PrepareRequest(ActivityExecutionContext context) + { + var method = context.Get(Method); + + var request = new HttpRequestMessage( + new HttpMethod(method), + context.Get(Url)); + + var headers = context.Get(RequestHeaders); + var requestHeaders = new HeaderDictionary(headers + .ToDictionary(x => x.Key, x => new StringValues(x.Value.Split(',')))); + + if (!string.IsNullOrWhiteSpace(context.Get(Authorization))) + request.Headers.Authorization = AuthenticationHeaderValue.Parse(context.Get(Authorization)); + + foreach (var header in requestHeaders) + request.Headers.Add(header.Key, header.Value.AsEnumerable()); + + var contentType = context.Get(ContentType); + + var contentWriters = context.GetRequiredService>(); + var contentWriter = SelectContentWriter(contentType, contentWriters); + request.Content = contentWriter.GetContent(contentType, context.Get(Content)); + + return request; + } + + private IHttpRequestContentWriter SelectContentWriter(string contentType, + IEnumerable _requestContentWriters) + { + if (string.IsNullOrWhiteSpace(contentType)) + { + return new StringHttpRequestContentWriter(); + } + + return _requestContentWriters.First(w => w.SupportsContentType(contentType)); + } +} + diff --git a/src/modules/Elsa.Http/Constants/MimeTypes.cs b/src/modules/Elsa.Http/Constants/MimeTypes.cs new file mode 100644 index 000000000..a8f1274a5 --- /dev/null +++ b/src/modules/Elsa.Http/Constants/MimeTypes.cs @@ -0,0 +1,8 @@ +namespace Elsa.Http.Constants; + +public static class MimeTypes +{ + public const string ApplicationXml = "application/xml"; + public const string ApplicationJson = "application/json"; + public const string ApplicationWwwFormUrlEncoded = "application/x-www-form-urlencoded"; +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/ContentWriters/FormUrlEncodedHttpRequestContentWriter.cs b/src/modules/Elsa.Http/ContentWriters/FormUrlEncodedHttpRequestContentWriter.cs new file mode 100644 index 000000000..428aa7e41 --- /dev/null +++ b/src/modules/Elsa.Http/ContentWriters/FormUrlEncodedHttpRequestContentWriter.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text.Json; +using Elsa.Http.Constants; + +namespace Elsa.Http.ContentWriters; + +public class FormUrlEncodedHttpRequestContentWriter : IHttpRequestContentWriter +{ + private List SupportedContentTypes = new() {MimeTypes.ApplicationWwwFormUrlEncoded}; + + public bool SupportsContentType(string contentType) + { + return SupportedContentTypes.Contains(contentType); + } + + public HttpContent GetContent( T content, string? contentType = null) + { + return new FormUrlEncodedContent(GetContentAsDictionary(content)); + } + + private Dictionary GetContentAsDictionary(TType body) + { + return body is string || body is JsonObject ? + JsonSerializer.Deserialize>(JsonSerializer.Serialize(body)) : + (Dictionary)Convert.ChangeType(body, typeof(Dictionary)); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/ContentWriters/IHttpRequestContentWriter.cs b/src/modules/Elsa.Http/ContentWriters/IHttpRequestContentWriter.cs new file mode 100644 index 000000000..b2d3efbc5 --- /dev/null +++ b/src/modules/Elsa.Http/ContentWriters/IHttpRequestContentWriter.cs @@ -0,0 +1,9 @@ +using System.Net.Http; + +namespace Elsa.Http.ContentWriters; + +public interface IHttpRequestContentWriter +{ + bool SupportsContentType(string contentType); + HttpContent GetContent(T content, string? contentType = null); +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/ContentWriters/StringHttpRequestContentWriter.cs b/src/modules/Elsa.Http/ContentWriters/StringHttpRequestContentWriter.cs new file mode 100644 index 000000000..f94459364 --- /dev/null +++ b/src/modules/Elsa.Http/ContentWriters/StringHttpRequestContentWriter.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using Elsa.Http.Constants; + +namespace Elsa.Http.ContentWriters; + +public class StringHttpRequestContentWriter : IHttpRequestContentWriter +{ + private List SupportedContentTypes = new() {MimeTypes.ApplicationJson, MimeTypes.ApplicationXml}; + + public bool SupportsContentType(string contentType) + { + return SupportedContentTypes.Contains(contentType); + } + + public HttpContent GetContent(T content, string? contentType = null) + { + var serializedContent = JsonSerializer.Serialize(content); + return new StringContent(serializedContent, Encoding. UTF8, contentType); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Elsa.Http.csproj b/src/modules/Elsa.Http/Elsa.Http.csproj index 65b16e51b..8b88c5f4c 100644 --- a/src/modules/Elsa.Http/Elsa.Http.csproj +++ b/src/modules/Elsa.Http/Elsa.Http.csproj @@ -12,6 +12,7 @@ + diff --git a/src/modules/Elsa.Http/Features/HttpFeature.cs b/src/modules/Elsa.Http/Features/HttpFeature.cs index a3bdeaebf..c59c65d77 100644 --- a/src/modules/Elsa.Http/Features/HttpFeature.cs +++ b/src/modules/Elsa.Http/Features/HttpFeature.cs @@ -1,9 +1,11 @@ using System; using Elsa.Features.Abstractions; using Elsa.Features.Services; +using Elsa.Http.ContentWriters; using Elsa.Http.Handlers; using Elsa.Http.Implementations; using Elsa.Http.Options; +using Elsa.Http.Parsers; using Elsa.Http.Services; using Elsa.Mediator.Extensions; using Microsoft.Extensions.DependencyInjection; @@ -43,9 +45,18 @@ public class HttpFeature : FeatureBase Services.Configure(options => options.BasePath = BasePath); Services + .AddHttpClient() .AddSingleton() .AddSingleton() .AddNotificationHandlersFrom() - .AddHttpContextAccessor(); + .AddHttpContextAccessor() + + // Add Content Parsers + .AddSingleton() + + //Add Request Content Writers + .AddSingleton() + .AddSingleton() + ; } } \ No newline at end of file diff --git a/src/modules/Elsa.Http/Models/HttpRequestHeaders.cs b/src/modules/Elsa.Http/Models/HttpRequestHeaders.cs new file mode 100644 index 000000000..94ac1f5ca --- /dev/null +++ b/src/modules/Elsa.Http/Models/HttpRequestHeaders.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace Elsa.Http.Models; + +public class HttpRequestHeaders : Dictionary +{ + public string ContentType => this["content-type"]; +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Models/HttpResponseModel.cs b/src/modules/Elsa.Http/Models/HttpResponseModel.cs new file mode 100644 index 000000000..df9f02fe8 --- /dev/null +++ b/src/modules/Elsa.Http/Models/HttpResponseModel.cs @@ -0,0 +1,6 @@ +using System.Collections.Generic; +using System.Net; + +namespace Elsa.Http.Models; + +public record HttpResponseModel(HttpStatusCode StatusCode, IDictionary Headers); \ No newline at end of file diff --git a/src/modules/Elsa.Http/Parsers/IHttpResponseContentReader.cs b/src/modules/Elsa.Http/Parsers/IHttpResponseContentReader.cs new file mode 100644 index 000000000..e1f40a633 --- /dev/null +++ b/src/modules/Elsa.Http/Parsers/IHttpResponseContentReader.cs @@ -0,0 +1,13 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Elsa.Http.Parsers; + +public interface IHttpResponseContentReader +{ + string Name { get; } + int Priority { get; } + bool GetSupportsContentType(string contentType); + Task ReadAsync(HttpResponseMessage response, object context, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Parsers/JsonElementHttpResponseContentReader.cs b/src/modules/Elsa.Http/Parsers/JsonElementHttpResponseContentReader.cs new file mode 100644 index 000000000..0d25dd714 --- /dev/null +++ b/src/modules/Elsa.Http/Parsers/JsonElementHttpResponseContentReader.cs @@ -0,0 +1,19 @@ +using System; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Elsa.Http.Parsers; + +public class JsonElementHttpResponseContentReader : IHttpResponseContentReader +{ + public string Name => "JsonElement"; + public int Priority => 0; + public bool GetSupportsContentType(string contentType) => contentType.Contains("/json", StringComparison.OrdinalIgnoreCase); + public async Task ReadAsync(HttpResponseMessage response, object context, CancellationToken cancellationToken) + { + var json = (await response.Content.ReadAsStringAsync()).Trim(); + return JsonDocument.Parse(json).RootElement; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Parsers/PlainTextHttpResponseContentReader.cs b/src/modules/Elsa.Http/Parsers/PlainTextHttpResponseContentReader.cs new file mode 100644 index 000000000..581b94342 --- /dev/null +++ b/src/modules/Elsa.Http/Parsers/PlainTextHttpResponseContentReader.cs @@ -0,0 +1,13 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Elsa.Http.Parsers; + +public class PlainTextHttpResponseContentReader : IHttpResponseContentReader +{ + public string Name => "Plain Text"; + public int Priority => -1; + public bool GetSupportsContentType(string contentType) => true; + public async Task ReadAsync(HttpResponseMessage response, object context, CancellationToken cancellationToken) => await response.Content.ReadAsStringAsync(); +} \ No newline at end of file diff --git a/src/samples/aspnet/Elsa.Samples.Web1/Program.cs b/src/samples/aspnet/Elsa.Samples.Web1/Program.cs index 67a5f0bcc..01ffac87b 100644 --- a/src/samples/aspnet/Elsa.Samples.Web1/Program.cs +++ b/src/samples/aspnet/Elsa.Samples.Web1/Program.cs @@ -30,7 +30,7 @@ services .UseRuntime(runtime => { runtime - .UseEntityFrameworkCore(ef => ef.UseSqlite()) + .UseEntityFrameworkCore(f =>f.UseSqlite()) .AddWorkflow() .AddWorkflow() .AddWorkflow() @@ -61,13 +61,13 @@ var app = builder.Build(); var serviceProvider = app.Services; // Configure workflow engine execution pipeline. -serviceProvider.ConfigureDefaultWorkflowExecutionPipeline(pipeline => - pipeline - .UseWorkflowExecutionLogPersistence() - .UsePersistentVariables() - .UseWorkflowContexts() - .UseStackBasedActivityScheduler() -); +// serviceProvider.ConfigureDefaultWorkflowExecutionPipeline(pipeline => +// pipeline +// .UseWorkflowExecutionLogPersistence() +// .UsePersistentVariables() +// .UseWorkflowContexts() +// .UseStackBasedActivityScheduler() +// ); if (app.Environment.IsDevelopment()) app.UseDeveloperExceptionPage(); diff --git a/src/samples/aspnet/Elsa.Samples.Web3/Program.cs b/src/samples/aspnet/Elsa.Samples.Web3/Program.cs index a69f28c16..c0d0a6545 100644 --- a/src/samples/aspnet/Elsa.Samples.Web3/Program.cs +++ b/src/samples/aspnet/Elsa.Samples.Web3/Program.cs @@ -36,6 +36,16 @@ services // Razor Pages. services.AddRazorPages(); +services.AddCors(options => +{ + options.AddPolicy("AllowSpecificOrigin", + builder => builder.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader() + ); + +}); + // Configure middleware pipeline. var app = builder.Build(); @@ -54,4 +64,10 @@ app.UseAuthorization(); app.UseHttpActivities(); app.MapRazorPages(); +app.UseCors(x => x + .AllowAnyMethod() + .AllowAnyHeader() + .SetIsOriginAllowed(origin => true) // allow any origin + .AllowCredentials()); + app.Run(); \ No newline at end of file