From 0e1922621e84e69e152c0f019d8a7653c42e08fc Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Mon, 16 Jan 2023 19:42:45 +0100 Subject: [PATCH] HTTP activity body parsing (#3631) * Implement content parsers and update SendHttpRequest * Fix parsers * Fix parser ordering * Implement content parsing for HTTP Endpoint activity * Cleanup * Remove unused models * Update file name to match class name --- .../src/components.d.ts | 17 +++ .../variable-editor-dialog-content.tsx | 4 +- .../src/components/inputs/type-picker.tsx | 59 ++++++++ .../shared/form-panel/form-panel.tsx | 57 +++++--- .../components/activity-properties-editor.tsx | 1 + .../components/editor.tsx | 3 +- .../src/services/input-control-registry.tsx | 1 + .../tailwind.config.js | 5 +- .../Elsa.Http/Activities/HttpEndpoint.cs | 105 ++++++++++++-- .../Elsa.Http/Activities/SendHttpRequest.cs | 136 +++++++++--------- ....cs => FormUrlEncodedHttpContentWriter.cs} | 2 +- ...ContentWriter.cs => IHttpContentWriter.cs} | 2 +- ...ntWriter.cs => StringHttpContentWriter.cs} | 2 +- .../Extensions/ActivityContextExtensions.cs | 15 ++ .../Elsa.Http/Extensions/OutputExtensions.cs | 22 +++ src/modules/Elsa.Http/Features/HttpFeature.cs | 54 +++++-- .../Middleware/WorkflowsMiddleware.cs | 75 ++++------ .../Elsa.Http/Models/HttpRequestHeaders.cs | 6 +- .../Elsa.Http/Models/HttpRequestModel.cs | 22 --- .../Elsa.Http/Models/HttpResponseModel.cs | 5 - .../Parsers/IHttpResponseContentReader.cs | 9 -- .../JsonElementHttpResponseContentReader.cs | 15 -- .../Parsers/JsonHttpContentParser.cs | 40 ++++++ .../PlainTextHttpResponseContentReader.cs | 9 -- .../Parsers/StringHttpContentParser.cs | 24 ++++ .../Elsa.Http/Parsers/XmlHttpContentParser.cs | 29 ++++ .../Elsa.Http/Services/IHttpContentParser.cs | 23 +++ .../Handlers/ConfigureLiquidEngine.cs | 2 +- .../Converters/ExpandoObjectConverter.cs | 86 +++++++++++ .../Features/WorkflowManagementFeature.cs | 14 +- .../Implementations/ActivityDescriber.cs | 6 + .../Models/InputUIHints.cs | 23 --- 32 files changed, 610 insertions(+), 263 deletions(-) create mode 100644 src/designer/elsa-workflows-designer/src/components/inputs/type-picker.tsx rename src/modules/Elsa.Http/ContentWriters/{FormUrlEncodedHttpRequestContentWriter.cs => FormUrlEncodedHttpContentWriter.cs} (92%) rename src/modules/Elsa.Http/ContentWriters/{IHttpRequestContentWriter.cs => IHttpContentWriter.cs} (78%) rename src/modules/Elsa.Http/ContentWriters/{StringHttpRequestContentWriter.cs => StringHttpContentWriter.cs} (89%) create mode 100644 src/modules/Elsa.Http/Extensions/ActivityContextExtensions.cs create mode 100644 src/modules/Elsa.Http/Extensions/OutputExtensions.cs delete mode 100644 src/modules/Elsa.Http/Models/HttpRequestModel.cs delete mode 100644 src/modules/Elsa.Http/Models/HttpResponseModel.cs delete mode 100644 src/modules/Elsa.Http/Parsers/IHttpResponseContentReader.cs delete mode 100644 src/modules/Elsa.Http/Parsers/JsonElementHttpResponseContentReader.cs create mode 100644 src/modules/Elsa.Http/Parsers/JsonHttpContentParser.cs delete mode 100644 src/modules/Elsa.Http/Parsers/PlainTextHttpResponseContentReader.cs create mode 100644 src/modules/Elsa.Http/Parsers/StringHttpContentParser.cs create mode 100644 src/modules/Elsa.Http/Parsers/XmlHttpContentParser.cs create mode 100644 src/modules/Elsa.Http/Services/IHttpContentParser.cs create mode 100644 src/modules/Elsa.Workflows.Core/Serialization/Converters/ExpandoObjectConverter.cs diff --git a/src/designer/elsa-workflows-designer/src/components.d.ts b/src/designer/elsa-workflows-designer/src/components.d.ts index 224b11395..0d087dddb 100644 --- a/src/designer/elsa-workflows-designer/src/components.d.ts +++ b/src/designer/elsa-workflows-designer/src/components.d.ts @@ -127,6 +127,7 @@ export namespace Components { interface ElsaFormPanel { "actions": Array; "mainTitle": string; + "orientation": 'Landscape' | 'Portrait'; "selectedTabIndex"?: number; "subTitle": string; "tabs": Array; @@ -231,6 +232,9 @@ export namespace Components { "tooltipContent": any; "tooltipPosition"?: string; } + interface ElsaTypePickerInput { + "inputContext": ActivityInputContext; + } interface ElsaVariableEditorDialogContent { "getVariable": () => Promise; "variable": Variable; @@ -667,6 +671,12 @@ declare global { prototype: HTMLElsaTooltipElement; new (): HTMLElsaTooltipElement; }; + interface HTMLElsaTypePickerInputElement extends Components.ElsaTypePickerInput, HTMLStencilElement { + } + var HTMLElsaTypePickerInputElement: { + prototype: HTMLElsaTypePickerInputElement; + new (): HTMLElsaTypePickerInputElement; + }; interface HTMLElsaVariableEditorDialogContentElement extends Components.ElsaVariableEditorDialogContent, HTMLStencilElement { } var HTMLElsaVariableEditorDialogContentElement: { @@ -827,6 +837,7 @@ declare global { "elsa-studio": HTMLElsaStudioElement; "elsa-switch-editor": HTMLElsaSwitchEditorElement; "elsa-tooltip": HTMLElsaTooltipElement; + "elsa-type-picker-input": HTMLElsaTypePickerInputElement; "elsa-variable-editor-dialog-content": HTMLElsaVariableEditorDialogContentElement; "elsa-variable-picker-input": HTMLElsaVariablePickerInputElement; "elsa-variables-editor": HTMLElsaVariablesEditorElement; @@ -943,6 +954,7 @@ declare namespace LocalJSX { "onActionInvoked"?: (event: ElsaFormPanelCustomEvent) => void; "onSelectedTabIndexChanged"?: (event: ElsaFormPanelCustomEvent) => void; "onSubmitted"?: (event: ElsaFormPanelCustomEvent) => void; + "orientation"?: 'Landscape' | 'Portrait'; "selectedTabIndex"?: number; "subTitle"?: string; "tabs"?: Array; @@ -1055,6 +1067,9 @@ declare namespace LocalJSX { "tooltipContent"?: any; "tooltipPosition"?: string; } + interface ElsaTypePickerInput { + "inputContext"?: ActivityInputContext; + } interface ElsaVariableEditorDialogContent { "onVariableChanged"?: (event: ElsaVariableEditorDialogContentCustomEvent) => void; "variable"?: Variable; @@ -1182,6 +1197,7 @@ declare namespace LocalJSX { "elsa-studio": ElsaStudio; "elsa-switch-editor": ElsaSwitchEditor; "elsa-tooltip": ElsaTooltip; + "elsa-type-picker-input": ElsaTypePickerInput; "elsa-variable-editor-dialog-content": ElsaVariableEditorDialogContent; "elsa-variable-picker-input": ElsaVariablePickerInput; "elsa-variables-editor": ElsaVariablesEditor; @@ -1247,6 +1263,7 @@ declare module "@stencil/core" { "elsa-studio": LocalJSX.ElsaStudio & JSXBase.HTMLAttributes; "elsa-switch-editor": LocalJSX.ElsaSwitchEditor & JSXBase.HTMLAttributes; "elsa-tooltip": LocalJSX.ElsaTooltip & JSXBase.HTMLAttributes; + "elsa-type-picker-input": LocalJSX.ElsaTypePickerInput & JSXBase.HTMLAttributes; "elsa-variable-editor-dialog-content": LocalJSX.ElsaVariableEditorDialogContent & JSXBase.HTMLAttributes; "elsa-variable-picker-input": LocalJSX.ElsaVariablePickerInput & JSXBase.HTMLAttributes; "elsa-variables-editor": LocalJSX.ElsaVariablesEditor & JSXBase.HTMLAttributes; diff --git a/src/designer/elsa-workflows-designer/src/components/designer/variables-editor/variable-editor-dialog-content.tsx b/src/designer/elsa-workflows-designer/src/components/designer/variables-editor/variable-editor-dialog-content.tsx index a749575f9..34e48271f 100644 --- a/src/designer/elsa-workflows-designer/src/components/designer/variables-editor/variable-editor-dialog-content.tsx +++ b/src/designer/elsa-workflows-designer/src/components/designer/variables-editor/variable-editor-dialog-content.tsx @@ -1,5 +1,5 @@ import {Component, h, Prop, Event, EventEmitter, Method} from "@stencil/core"; -import {_, groupBy} from 'lodash'; +import {groupBy} from 'lodash'; import {StorageDriverDescriptor, Variable} from "../../../models"; import {FormEntry} from "../../shared/forms/form-entry"; import {isNullOrWhitespace} from "../../../utils"; @@ -25,7 +25,7 @@ export class VariableEditorDialogContent { const variable: Variable = this.variable ?? {name: '', typeName: 'Object'}; const variableTypeName = variable.typeName; const availableTypes: Array = descriptorsStore.variableDescriptors; - const groupedVariableTypes = _.groupBy(availableTypes, x => x.category); + const groupedVariableTypes = groupBy(availableTypes, x => x.category); const storageDrivers: Array = descriptorsStore.storageDrivers; return ( diff --git a/src/designer/elsa-workflows-designer/src/components/inputs/type-picker.tsx b/src/designer/elsa-workflows-designer/src/components/inputs/type-picker.tsx new file mode 100644 index 000000000..9ef1e1cac --- /dev/null +++ b/src/designer/elsa-workflows-designer/src/components/inputs/type-picker.tsx @@ -0,0 +1,59 @@ +import {Component, Prop, h} from '@stencil/core'; +import {groupBy} from 'lodash'; +import {LiteralExpression, SyntaxNames} from "../../models"; +import {ActivityInputContext} from "../../services/node-input-driver"; +import {getInputPropertyValue} from "../../utils"; +import descriptorsStore from '../../data/descriptors-store'; +import {VariableDescriptor} from "../../services/api-client/variable-descriptors-api"; +import {ExpressionChangedArs} from "../designer/input-control-switch/input-control-switch"; + +@Component({ + tag: 'elsa-type-picker-input', + shadow: false +}) +export class TypePickerInput { + @Prop() public inputContext: ActivityInputContext; + + public render() { + const inputContext = this.inputContext; + const inputDescriptor = inputContext.inputDescriptor; + const fieldName = inputDescriptor.name; + const fieldId = inputDescriptor.name; + const displayName = inputDescriptor.displayName; + const description = inputDescriptor.description; + const availableTypes: Array = descriptorsStore.variableDescriptors; + const groupedVariableTypes = groupBy(availableTypes, x => x.category); + const input = getInputPropertyValue(inputContext); + const syntax = input?.expression?.type ?? inputDescriptor.defaultSyntax; + const value = (input?.expression as LiteralExpression)?.value; + let currentValue = value; + + if (currentValue == undefined) { + const defaultValue = inputDescriptor.defaultValue; + currentValue = defaultValue ? defaultValue.toString() : undefined; + } + + return ( + + + + ); + } + + private onChange = (e: Event) => { + const inputElement = e.target as HTMLSelectElement; + this.inputContext.inputChanged(inputElement.value, SyntaxNames.Literal); + } + + private onExpressionChanged = (e: CustomEvent) => { + this.inputContext.inputChanged(e.detail.expression, e.detail.syntax); + } +} diff --git a/src/designer/elsa-workflows-designer/src/components/shared/form-panel/form-panel.tsx b/src/designer/elsa-workflows-designer/src/components/shared/form-panel/form-panel.tsx index 09aa19485..e025a3e3e 100644 --- a/src/designer/elsa-workflows-designer/src/components/shared/form-panel/form-panel.tsx +++ b/src/designer/elsa-workflows-designer/src/components/shared/form-panel/form-panel.tsx @@ -9,6 +9,7 @@ import {PanelActionClickArgs, PanelActionDefinition, PanelActionType} from "./mo export class FormPanel { @Prop() public mainTitle: string; @Prop() public subTitle: string; + @Prop() public orientation: 'Landscape' | 'Portrait' = 'Portrait'; @Prop() public tabs: Array = []; @Prop({mutable: true}) public selectedTabIndex?: number; @Prop() public actions: Array = []; @@ -39,24 +40,39 @@ export class FormPanel { const actions = this.actions; const mainTitle = this.mainTitle; const subTitle = this.subTitle; + const orientation = this.orientation; return (
this.onSubmit(e)} method="post">
-
-
-
-

- {mainTitle} -

- {!isNullOrWhitespace(subTitle) ?

{subTitle}

: undefined} -
-
-
+ {orientation == 'Portrait' && ( +
-
+
+
+

+ {mainTitle} +

+ {!isNullOrWhitespace(subTitle) ?

{subTitle}

: undefined} +
+
+
)} + + {orientation == 'Landscape' && ( +
+
+
+

+ {mainTitle} +

+ {!isNullOrWhitespace(subTitle) ?

{subTitle}

: undefined} +
+
+
)} + +
-
- -
- {tabs.map((tab, tabIndex) => { - const cssClass = tabIndex == selectedTabIndex ? '' : 'hidden'; - return
- {tab.content()} -
- })} +
+
+ {tabs.map((tab, tabIndex) => { + const cssClass = tabIndex == selectedTabIndex ? '' : 'hidden'; + return
+ {tab.content()} +
+ })} +
-
{actions.length > 0 ? ( diff --git a/src/designer/elsa-workflows-designer/src/modules/workflow-definitions/components/activity-properties-editor.tsx b/src/designer/elsa-workflows-designer/src/modules/workflow-definitions/components/activity-properties-editor.tsx index 9ecb51d7f..576a0838e 100644 --- a/src/designer/elsa-workflows-designer/src/modules/workflow-definitions/components/activity-properties-editor.tsx +++ b/src/designer/elsa-workflows-designer/src/modules/workflow-definitions/components/activity-properties-editor.tsx @@ -137,6 +137,7 @@ export class ActivityPropertiesEditor { this.onSelectedTabIndexChanged(e)} diff --git a/src/designer/elsa-workflows-designer/src/modules/workflow-definitions/components/editor.tsx b/src/designer/elsa-workflows-designer/src/modules/workflow-definitions/components/editor.tsx index a82f44e31..2a0402e17 100644 --- a/src/designer/elsa-workflows-designer/src/modules/workflow-definitions/components/editor.tsx +++ b/src/designer/elsa-workflows-designer/src/modules/workflow-definitions/components/editor.tsx @@ -43,7 +43,8 @@ export class WorkflowDefinitionEditor { this.pluginRegistry = Container.get(PluginRegistry); this.activityNameFormatter = Container.get(ActivityNameFormatter); this.portProviderRegistry = Container.get(PortProviderRegistry); - this.emitActivityChangedDebounced = debounce(this.emitActivityChanged, 100); + //this.emitActivityChangedDebounced = debounce(this.emitActivityChanged, 100); + this.emitActivityChangedDebounced = e => this.emitActivityChanged(e.activity, e.propertyName); this.saveChangesDebounced = debounce(this.saveChanges, 1000); this.workflowDefinitionApi = Container.get(WorkflowDefinitionsApi); } 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 0f0307bfd..aaa6bbc81 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 @@ -19,6 +19,7 @@ export class InputControlRegistry { this.add('code-editor', c => ); this.add('checkbox', c => ); this.add('variable-picker', c => ); + this.add('type-picker', c => ); } public add(uiHint: UIHint, control: RenderActivityPropInputControl) { diff --git a/src/designer/elsa-workflows-designer/tailwind.config.js b/src/designer/elsa-workflows-designer/tailwind.config.js index 1a5946c04..b082356da 100644 --- a/src/designer/elsa-workflows-designer/tailwind.config.js +++ b/src/designer/elsa-workflows-designer/tailwind.config.js @@ -1,5 +1,8 @@ const defaultTheme = require('tailwindcss/defaultTheme'); -const colors = require('tailwindcss/colors') +const colors = require('tailwindcss/colors'); + +// @ts-ignore +const dev = process.argv && process.argv.indexOf('--dev') > -1; module.exports = { content: ['./src/**/*.tsx', './src/**/*.ts'], diff --git a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs index 060160fb5..efb75454e 100644 --- a/src/modules/Elsa.Http/Activities/HttpEndpoint.cs +++ b/src/modules/Elsa.Http/Activities/HttpEndpoint.cs @@ -2,41 +2,69 @@ using Elsa.Expressions.Models; using Elsa.Extensions; using Elsa.Http.Models; +using Elsa.Http.Services; using Elsa.Workflows.Core.Attributes; using Elsa.Workflows.Core.Models; using Elsa.Workflows.Management.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; namespace Elsa.Http; +/// +/// Wait for an inbound HTTP request that matches the specified path and methods. +/// [Activity("Elsa", "HTTP", "Wait for an inbound HTTP request that matches the specified path and methods.")] -public class HttpEndpoint : Trigger +public class HttpEndpoint : Trigger { - public const string InputKey = "HttpRequest"; + internal const string HttpContextInputKey = "HttpContext"; + internal const string RequestPathInputKey = "RequestPath"; + /// [JsonConstructor] public HttpEndpoint() { } - [Input] public Input Path { get; set; } = default!; + /// + /// The path to associate with the workflow. + /// + [Input(Description = "The path to associate with the workflow.")] + public Input Path { get; set; } = default!; + /// + /// The HTTP methods to accept. + /// [Input( + Description = "The HTTP methods to accept.", Options = new[] { "GET", "POST", "PUT", "HEAD", "DELETE" }, - UIHint = InputUIHints.CheckList - )] + UIHint = InputUIHints.CheckList)] public Input> SupportedMethods { get; set; } = new(new[] { HttpMethod.Get.Method }); - [Input( - Description = "Allow authenticated requests only", - Category = "Security" - )] + /// + /// Allow authenticated requests only. + /// + [Input(Description = "Allow authenticated requests only.", Category = "Security")] public Input Authorize { get; set; } = new(false); - [Input( - Description = "Provide a policy to evaluate. If the policy fails, the request is forbidden.", - Category = "Security" - )] + /// + /// Provide a policy to evaluate. If the policy fails, the request is forbidden. + /// + [Input(Description = "Provide a policy to evaluate. If the policy fails, the request is forbidden.", Category = "Security")] public Input Policy { get; set; } = new(default(string?)); + + /// + /// The parsed request content, if any. + /// + [Output(Description = "The parsed request content, if any.")] + public Output ParsedContent { get; set; } = default!; + + /// + /// The parsed route data, if any. + /// + [Output(Description = "The parsed route data, if any.")] + public Output RouteData { get; set; } = default!; /// protected override IEnumerable GetTriggerPayloads(TriggerIndexingContext context) => GetBookmarkPayloads(context.ExpressionExecutionContext); @@ -45,7 +73,7 @@ public class HttpEndpoint : Trigger protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) { // If we did not receive external input, it means we are just now encountering this activity and we need to block execution by creating a bookmark. - if (!context.TryGetInput(InputKey, out var request)) + if (!context.TryGetInput(HttpContextInputKey, out var httpContext)) { // Create bookmarks for when we receive the expected HTTP request. context.CreateBookmarks(GetBookmarkPayloads(context.ExpressionExecutionContext)); @@ -53,11 +81,36 @@ public class HttpEndpoint : Trigger } // Provide the received HTTP request as output. + var request = httpContext.Request; context.Set(Result, request); + // Read route data, if any. + var path = context.GetInput(RequestPathInputKey); + var routeData = GetRouteData(httpContext, path); + context.Set(RouteData, routeData); + + // Read content, if any. + var content = await ParseContentAsync(context, request); + context.Set(ParsedContent, content); + // Complete. await context.CompleteActivityAsync(); } + + private async Task ParseContentAsync(ActivityExecutionContext context, HttpRequest httpRequest) + { + if (!HasContent(httpRequest)) + return null; + + var cancellationToken = context.CancellationToken; + var targetType = ParsedContent.GetTargetType(context); + var contentStream = httpRequest.Body; + var contentType = httpRequest.ContentType; + + return await context.ParseContentAsync(contentStream, contentType, targetType, cancellationToken); + } + + private static bool HasContent(HttpRequest httpRequest) => httpRequest.Headers.ContentLength > 0; private IEnumerable GetBookmarkPayloads(ExpressionExecutionContext context) { @@ -66,4 +119,28 @@ public class HttpEndpoint : Trigger var methods = context.Get(SupportedMethods); return methods!.Select(x => new HttpEndpointBookmarkPayload(path!, x.ToLowerInvariant())).Cast().ToArray(); } + + private static RouteData GetRouteData(HttpContext httpContext, string path) + { + var routeData = httpContext.GetRouteData(); + var routeTable = httpContext.RequestServices.GetRequiredService(); + var routeMatcher = httpContext.RequestServices.GetRequiredService(); + + var matchingRouteQuery = + from route in routeTable + let routeValues = routeMatcher.Match(route, path) + where routeValues != null + select new { route, routeValues }; + + var matchingRoute = matchingRouteQuery.FirstOrDefault(); + + if (matchingRoute == null) + return routeData; + + foreach (var (key, value) in matchingRoute.routeValues!) + routeData.Values[key] = value; + + return routeData; + } + } \ No newline at end of file diff --git a/src/modules/Elsa.Http/Activities/SendHttpRequest.cs b/src/modules/Elsa.Http/Activities/SendHttpRequest.cs index 3584e3a0a..37abcdad3 100644 --- a/src/modules/Elsa.Http/Activities/SendHttpRequest.cs +++ b/src/modules/Elsa.Http/Activities/SendHttpRequest.cs @@ -1,125 +1,123 @@ using System.Net.Http.Headers; +using Elsa.Extensions; 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 = "HTTP Request", Kind = ActivityKind.Task)] -public class SendHttpRequest : Activity +/// +/// Send an HTTP request. +/// +[Activity("Elsa", "HTTP", "Send an HTTP request.", DisplayName = "HTTP Request", Kind = ActivityKind.Task)] +public class SendHttpRequest : Activity { [Input] public Input Url { get; set; } = default!; + /// + /// The HTTP method to use when sending the request. + /// [Input( + Description = "The HTTP method to use when sending the request.", Options = new[] { "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD" }, + DefaultValue = "GET", UIHint = InputUIHints.Dropdown )] - public Input Method { get; set; } + public Input Method { get; set; } = new("GET"); - public Input Content { get; set; } = default!; + /// + /// The content to send with the request. Can be a string, an object, a byte array or a stream. + /// + [Input(Description = "The content to send with the request. Can be a string, an object, a byte array or a stream.")] + public Input Content { get; set; } = default!; + /// + /// The content type to use when sending the request. + /// [Input( + Description = "The content type to use when sending the request.", 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); - + /// + /// The Authorization header value to send with the request. + /// + /// Bearer {some-access-token} [Input( - Options = new[] { "", "JsonElement", "Plain Text" }, - UIHint = InputUIHints.Dropdown + Description = "The Authorization header value to send with the request. For example: Bearer {some-access-token}", + Category = "Security" )] - public Input ResponseContentParserName { get; set; } = default!; + public Input Authorization { get; set; } = default!; - [Input(Category = "Security")] public Input?> RequestHeaders { get; set; } = new(new HttpRequestHeaders()); + /// + /// The headers to send along with the request. + /// + [Input(Description = "The headers to send along with the request.", Category = "Advanced")] + public Input RequestHeaders { get; set; } = new(new HttpRequestHeaders()); - [Output] public Output? ResponseContent { get; set; } - - [Output] public Output? Response { get; set; } + /// + /// The parsed content, if any. + /// + [Output(Description = "The parsed content, if any.")] + public Output ParsedContent { get; set; } = default!; + /// protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) { var request = PrepareRequest(context); - var httpClientFactory = context.GetRequiredService(); var httpClient = httpClientFactory.CreateClient(nameof(SendHttpRequest)); - var cancellationToken = context.CancellationToken; var response = await httpClient.SendAsync(request, cancellationToken); + var parsedContent = await ParseContentAsync(context, response.Content); - 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.GetServices(); - var formatter = SelectContentParser(parsers.ToList(), context.Get(ResponseContentParserName), context.Get(ContentType)); - context.Set(ResponseContent, await formatter.ReadAsync(response, context, cancellationToken)); - } + context.Set(Result, response); + context.Set(ParsedContent, parsedContent); } - private IHttpResponseContentReader SelectContentParser(List parsers, string? parserName, string? contentType) + private async Task ParseContentAsync(ActivityExecutionContext context, HttpContent httpContent) { - if (string.IsNullOrWhiteSpace(parserName)) - { - var simpleContentType = contentType?.Split(';').First() ?? ""; - var parser = parsers.OrderByDescending(x => x.Priority).ToList(); + if (!HasContent(httpContent)) + return null; - 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; - } + var cancellationToken = context.CancellationToken; + var targetType = ParsedContent.GetTargetType(context); + var contentStream = await httpContent.ReadAsStreamAsync(cancellationToken); + var contentType = httpContent.Headers.ContentType?.MediaType!; + + return await context.ParseContentAsync(contentStream, contentType, targetType, cancellationToken); } - - private bool HasContent(HttpResponseMessage response) => response?.Content != null && response.Content.Headers.ContentLength > 0; + + private static bool HasContent(HttpContent httpContent) => httpContent.Headers.ContentLength > 0; private HttpRequestMessage PrepareRequest(ActivityExecutionContext context) { - var method = context.Get(Method)!; - var request = new HttpRequestMessage(new HttpMethod(method), context.Get(Url)); + var method = Method.TryGet(context) ?? "GET"; + var url = Url.Get(context); + var request = new HttpRequestMessage(new HttpMethod(method), url); + var headers = RequestHeaders.TryGet(context) ?? new HttpRequestHeaders(); + var authorization = Authorization.TryGet(context); - var headers = context.Get(RequestHeaders)!; - var requestHeaders = new HeaderDictionary(headers.ToDictionary(x => x.Key, x => new StringValues(x.Value.Split(',')))); + if (!string.IsNullOrWhiteSpace(authorization)) + request.Headers.Authorization = AuthenticationHeaderValue.Parse(authorization); - if (!string.IsNullOrWhiteSpace(context.Get(Authorization))) - request.Headers.Authorization = AuthenticationHeaderValue.Parse(context.Get(Authorization)); - - foreach (var header in requestHeaders) + foreach (var header in headers) request.Headers.Add(header.Key, header.Value.AsEnumerable()); - var contentType = context.Get(ContentType)!; - var contentWriters = context.GetServices(); + var contentType = ContentType.TryGet(context); + var contentWriters = context.GetServices(); var contentWriter = SelectContentWriter(contentType, contentWriters); - request.Content = contentWriter.GetContent(contentType, context.Get(Content)); + var content = Content.TryGet(context); + request.Content = contentWriter.GetContent(content, contentType); return request; } - private IHttpRequestContentWriter SelectContentWriter(string contentType, IEnumerable requestContentWriters) => - string.IsNullOrWhiteSpace(contentType) ? new StringHttpRequestContentWriter() : requestContentWriters.First(w => w.SupportsContentType(contentType)); + private IHttpContentWriter SelectContentWriter(string? contentType, IEnumerable requestContentWriters) => + string.IsNullOrWhiteSpace(contentType) ? new StringHttpContentWriter() : requestContentWriters.First(w => w.SupportsContentType(contentType)); } \ No newline at end of file diff --git a/src/modules/Elsa.Http/ContentWriters/FormUrlEncodedHttpRequestContentWriter.cs b/src/modules/Elsa.Http/ContentWriters/FormUrlEncodedHttpContentWriter.cs similarity index 92% rename from src/modules/Elsa.Http/ContentWriters/FormUrlEncodedHttpRequestContentWriter.cs rename to src/modules/Elsa.Http/ContentWriters/FormUrlEncodedHttpContentWriter.cs index f70403d5f..4543b68a3 100644 --- a/src/modules/Elsa.Http/ContentWriters/FormUrlEncodedHttpRequestContentWriter.cs +++ b/src/modules/Elsa.Http/ContentWriters/FormUrlEncodedHttpContentWriter.cs @@ -7,7 +7,7 @@ namespace Elsa.Http.ContentWriters; /// /// A content writer that writes content in the application/x-www-form-urlencoded format. /// -public class FormUrlEncodedHttpRequestContentWriter : IHttpRequestContentWriter +public class FormUrlEncodedHttpContentWriter : IHttpContentWriter { private readonly List _supportedContentTypes = new() {MimeTypes.ApplicationWwwFormUrlEncoded}; diff --git a/src/modules/Elsa.Http/ContentWriters/IHttpRequestContentWriter.cs b/src/modules/Elsa.Http/ContentWriters/IHttpContentWriter.cs similarity index 78% rename from src/modules/Elsa.Http/ContentWriters/IHttpRequestContentWriter.cs rename to src/modules/Elsa.Http/ContentWriters/IHttpContentWriter.cs index ec3885276..b4a3b3d64 100644 --- a/src/modules/Elsa.Http/ContentWriters/IHttpRequestContentWriter.cs +++ b/src/modules/Elsa.Http/ContentWriters/IHttpContentWriter.cs @@ -1,6 +1,6 @@ namespace Elsa.Http.ContentWriters; -public interface IHttpRequestContentWriter +public interface IHttpContentWriter { bool SupportsContentType(string contentType); HttpContent GetContent(T content, string? contentType = null); diff --git a/src/modules/Elsa.Http/ContentWriters/StringHttpRequestContentWriter.cs b/src/modules/Elsa.Http/ContentWriters/StringHttpContentWriter.cs similarity index 89% rename from src/modules/Elsa.Http/ContentWriters/StringHttpRequestContentWriter.cs rename to src/modules/Elsa.Http/ContentWriters/StringHttpContentWriter.cs index c88814648..e2bbbbf5d 100644 --- a/src/modules/Elsa.Http/ContentWriters/StringHttpRequestContentWriter.cs +++ b/src/modules/Elsa.Http/ContentWriters/StringHttpContentWriter.cs @@ -4,7 +4,7 @@ using Elsa.Http.Constants; namespace Elsa.Http.ContentWriters; -public class StringHttpRequestContentWriter : IHttpRequestContentWriter +public class StringHttpContentWriter : IHttpContentWriter { private List SupportedContentTypes = new() {MimeTypes.ApplicationJson, MimeTypes.ApplicationXml}; diff --git a/src/modules/Elsa.Http/Extensions/ActivityContextExtensions.cs b/src/modules/Elsa.Http/Extensions/ActivityContextExtensions.cs new file mode 100644 index 000000000..68d874566 --- /dev/null +++ b/src/modules/Elsa.Http/Extensions/ActivityContextExtensions.cs @@ -0,0 +1,15 @@ +using Elsa.Http.Services; +using Elsa.Workflows.Core.Models; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +internal static class ActivityContextExtensions +{ + public static async Task ParseContentAsync(this ActivityExecutionContext context, Stream content, string contentType, Type? returnType, CancellationToken cancellationToken) + { + var parsers = context.GetServices().OrderByDescending(x => x.Priority).ToList(); + var contentParser = parsers.First(x => x.GetSupportsContentType(contentType)); + return await contentParser.ReadAsync(content, returnType, cancellationToken); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Extensions/OutputExtensions.cs b/src/modules/Elsa.Http/Extensions/OutputExtensions.cs new file mode 100644 index 000000000..977fc9377 --- /dev/null +++ b/src/modules/Elsa.Http/Extensions/OutputExtensions.cs @@ -0,0 +1,22 @@ +using Elsa.Workflows.Core.Models; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +internal static class OutputExtensions +{ + /// + /// Gets the target type of the specified variable type. + /// + public static Type? GetTargetType(this Output output, ActivityExecutionContext context) + { + var memoryBlock = output.MemoryBlockReference() is Variable variable + ? context.WorkflowExecutionContext.MemoryRegister.TryGetBlock(variable.Id, out var block) + ? block + : default + : default; + + var parsedContentVariableType = (memoryBlock?.Metadata as VariableBlockMetadata)?.Variable.GetType(); + return parsedContentVariableType?.GenericTypeArguments.FirstOrDefault(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Features/HttpFeature.cs b/src/modules/Elsa.Http/Features/HttpFeature.cs index 10d7b3389..fbe19748a 100644 --- a/src/modules/Elsa.Http/Features/HttpFeature.cs +++ b/src/modules/Elsa.Http/Features/HttpFeature.cs @@ -6,10 +6,11 @@ using Elsa.Features.Services; using Elsa.Http.ContentWriters; using Elsa.Http.Handlers; using Elsa.Http.Implementations; -using Elsa.Http.Models; using Elsa.Http.Options; using Elsa.Http.Parsers; using Elsa.Http.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; namespace Elsa.Http.Features; @@ -21,19 +22,38 @@ public class HttpFeature : FeatureBase { } - public Action? ConfigureHttpOptions { get; set; } + /// + /// A delegate to configure . + /// + public Action? ConfigureHttpOptions { get; set; } + public Func HttpEndpointAuthorizationHandlerFactory { get; set; } = ActivatorUtilities.GetServiceOrCreateInstance; public Func HttpEndpointWorkflowFaultHandlerFactory { get; set; } = ActivatorUtilities.GetServiceOrCreateInstance; + /// + /// A delegate to configure the used when by the activity. + /// + public Action HttpClient { get; set; } = (_, _) => { }; + + /// + /// A delegate to configure the for . + /// + public Action HttpClientBuilder { get; set; } = _ => { }; + /// public override void Configure() { - Module.UseWorkflowManagement(management => management.AddVariableTypes(new[] + Module.UseWorkflowManagement(management => { - typeof(HttpRequestHeaders), - typeof(HttpRequestModel), - typeof(HttpResponseModel) - }, "HTTP").AddActivitiesFrom()); + management.AddVariableTypes(new[] + { + typeof(RouteData), + typeof(HttpRequest), + typeof(HttpResponse) + }, "HTTP"); + + management.AddActivitiesFrom(); + }); } /// @@ -44,23 +64,27 @@ public class HttpFeature : FeatureBase options.BasePath = "/workflows"; options.BaseUrl = new Uri("http://localhost"); }); - + Services.Configure(configureOptions); + var httpClientBuilder = Services.AddHttpClient(HttpClient); + HttpClientBuilder(httpClientBuilder); + Services - .AddHttpClient() .AddSingleton() .AddSingleton() .AddSingleton() .AddNotificationHandlersFrom() .AddHttpContextAccessor() - - // Add Content Parsers - .AddSingleton() - //Add Request Content Writers - .AddSingleton() - .AddSingleton() + // Add Content Parsers. + .AddSingleton() + .AddSingleton() + .AddSingleton() + + // Add Request Content Writers. + .AddSingleton() + .AddSingleton() ; } } \ No newline at end of file diff --git a/src/modules/Elsa.Http/Middleware/WorkflowsMiddleware.cs b/src/modules/Elsa.Http/Middleware/WorkflowsMiddleware.cs index 50d1e34ff..931e603ea 100644 --- a/src/modules/Elsa.Http/Middleware/WorkflowsMiddleware.cs +++ b/src/modules/Elsa.Http/Middleware/WorkflowsMiddleware.cs @@ -5,42 +5,47 @@ using Elsa.Http.Models; using Elsa.Http.Options; using Elsa.Http.Services; using Elsa.Workflows.Core.Helpers; -using Elsa.Workflows.Core.Services; using Elsa.Workflows.Runtime.Services; +using JetBrains.Annotations; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; namespace Elsa.Http.Middleware; +/// +/// An ASP.NET middleware component that tries to match the inbound request path to an associated workflow and then run that workflow. +/// +[PublicAPI] public class WorkflowsMiddleware { private readonly RequestDelegate _next; - private readonly IBookmarkHasher _hasher; private readonly IWorkflowRuntime _workflowRuntime; private readonly IWorkflowHostFactory _workflowHostFactory; private readonly IWorkflowDefinitionService _workflowDefinitionService; private readonly HttpActivityOptions _options; private readonly string _activityTypeName = ActivityTypeNameHelper.GenerateTypeName(); + /// + /// Constructor. + /// public WorkflowsMiddleware( RequestDelegate next, - IBookmarkHasher hasher, IWorkflowRuntime workflowRuntime, IWorkflowHostFactory workflowHostFactory, IWorkflowDefinitionService workflowDefinitionService, IOptions options) { _next = next; - _hasher = hasher; _workflowRuntime = workflowRuntime; _workflowHostFactory = workflowHostFactory; _workflowDefinitionService = workflowDefinitionService; _options = options.Value; } + + /// + /// Attempts to matches the inbound request path to an associated workflow and then run that workflow. + /// public async Task InvokeAsync(HttpContext httpContext, IRouteMatcher routeMatcher) { var path = GetPath(httpContext); @@ -59,36 +64,30 @@ public class WorkflowsMiddleware path = path.Substring(basePath.Value.Value.Length); } - var request = httpContext.Request; - var method = request.Method!.ToLowerInvariant(); - var cancellationToken = httpContext.RequestAborted; - var routeData = GetRouteData(httpContext, routeMatcher, path); - - var requestModel = new HttpRequestModel( - new Uri(request.GetEncodedUrl()), - request.Path, - request.Method, - request.Query.ToDictionary(x => x.Key, x => x.Value.ToString()), - routeData.Values, - request.Headers.ToDictionary(x => x.Key, x => x.Value.ToString()) - ); - - var input = new Dictionary { [HttpEndpoint.InputKey] = requestModel }; + var input = new Dictionary + { + [HttpEndpoint.HttpContextInputKey] = httpContext, + [HttpEndpoint.RequestPathInputKey] = path + }; // TODO: Get correlation ID from query string or header etc. var correlationId = default(string); - - // Trigger the workflow. + var request = httpContext.Request; + var method = request.Method!.ToLowerInvariant(); var bookmarkPayload = new HttpEndpointBookmarkPayload(path, method); var triggerOptions = new TriggerWorkflowsRuntimeOptions(correlationId, input); - + var cancellationToken = httpContext.RequestAborted; + + // Trigger the workflow. var triggerResult = await _workflowRuntime.TriggerWorkflowsAsync( _activityTypeName, bookmarkPayload, triggerOptions, cancellationToken); - // Check to see if we received any WriteHttpResponse activity bookmarks. If we do, acquire a lock on the workflow instance and resume it from here within an actual HTTP context so that the activity can complete its HTTP response. + // We must assume that the workflow executed in a different process (when e.g. using Proto.Actor) + // and check if we received any `WriteHttpResponse` activity bookmarks. + // If we did, acquire a lock on the workflow instance and resume it from here within an actual HTTP context so that the activity can complete its HTTP response. var writeHttpResponseTypeName = ActivityTypeNameHelper.GenerateTypeName(); var query = @@ -154,28 +153,6 @@ public class WorkflowsMiddleware await response.WriteAsync(json, cancellationToken); } } - - private static RouteData GetRouteData(HttpContext httpContext, IRouteMatcher routeMatcher, string path) - { - var routeData = httpContext.GetRouteData(); - var routeTable = httpContext.RequestServices.GetRequiredService(); - - var matchingRouteQuery = - from route in routeTable - let routeValues = routeMatcher.Match(route, path) - where routeValues != null - select new { route, routeValues }; - - var matchingRoute = matchingRouteQuery.FirstOrDefault(); - - if (matchingRoute == null) - return routeData; - - foreach (var (key, value) in matchingRoute.routeValues!) - routeData.Values[key] = value; - - return routeData; - } - + private string GetPath(HttpContext httpContext) => httpContext.Request.Path.Value.ToLowerInvariant(); } \ No newline at end of file diff --git a/src/modules/Elsa.Http/Models/HttpRequestHeaders.cs b/src/modules/Elsa.Http/Models/HttpRequestHeaders.cs index ac6b37a6b..0c7b7cef8 100644 --- a/src/modules/Elsa.Http/Models/HttpRequestHeaders.cs +++ b/src/modules/Elsa.Http/Models/HttpRequestHeaders.cs @@ -1,6 +1,8 @@ +using Elsa.Extensions; + namespace Elsa.Http.Models; -public class HttpRequestHeaders : Dictionary +public class HttpRequestHeaders : Dictionary { - public string ContentType => this["content-type"]; + public string? ContentType => this.GetValue("content-type")?[0]; } \ No newline at end of file diff --git a/src/modules/Elsa.Http/Models/HttpRequestModel.cs b/src/modules/Elsa.Http/Models/HttpRequestModel.cs deleted file mode 100644 index ceb8c1174..000000000 --- a/src/modules/Elsa.Http/Models/HttpRequestModel.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Text.Json.Serialization; - -namespace Elsa.Http.Models; - -public record HttpRequestModel( - Uri RequestUri, - string Path, - string Method, - IDictionary QueryString, - IDictionary RouteValues, - IDictionary Headers -) -{ - - /// - /// Constructor used for deserialization. - /// - [JsonConstructor] - public HttpRequestModel() : this(default!, default!, default!, default!, default!, default!) - { - } -} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Models/HttpResponseModel.cs b/src/modules/Elsa.Http/Models/HttpResponseModel.cs deleted file mode 100644 index 10b362c03..000000000 --- a/src/modules/Elsa.Http/Models/HttpResponseModel.cs +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 0985b398d..000000000 --- a/src/modules/Elsa.Http/Parsers/IHttpResponseContentReader.cs +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 6f91694e5..000000000 --- a/src/modules/Elsa.Http/Parsers/JsonElementHttpResponseContentReader.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Text.Json; - -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(cancellationToken)).Trim(); - return JsonDocument.Parse(json).RootElement; - } -} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Parsers/JsonHttpContentParser.cs b/src/modules/Elsa.Http/Parsers/JsonHttpContentParser.cs new file mode 100644 index 000000000..606bfbb0c --- /dev/null +++ b/src/modules/Elsa.Http/Parsers/JsonHttpContentParser.cs @@ -0,0 +1,40 @@ +using System.Dynamic; +using System.Text.Json; +using Elsa.Expressions.Helpers; +using Elsa.Http.Services; +using Elsa.Workflows.Core.Serialization.Converters; + +namespace Elsa.Http.Parsers; + +/// +/// Reads application/json and text/json content type streams. +/// +public class JsonHttpContentParser : IHttpContentParser +{ + /// + public int Priority => 0; + + /// + public bool GetSupportsContentType(string contentType) => contentType.Contains("json", StringComparison.InvariantCultureIgnoreCase); + + /// + public async Task ReadAsync(Stream content, Type? returnType, CancellationToken cancellationToken) + { + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + + using var reader = new StreamReader(content, leaveOpen: true); + var json = await reader.ReadToEndAsync(); + + if (returnType == null || returnType.IsPrimitive) + return json.ConvertTo(returnType ?? typeof(string))!; + + if (returnType != typeof(ExpandoObject)) + return JsonSerializer.Deserialize(json, returnType, options)!; + + options.Converters.Add(new ExpandoObjectConverter()); + return JsonSerializer.Deserialize(json, options)!; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Parsers/PlainTextHttpResponseContentReader.cs b/src/modules/Elsa.Http/Parsers/PlainTextHttpResponseContentReader.cs deleted file mode 100644 index b6dc93e2c..000000000 --- a/src/modules/Elsa.Http/Parsers/PlainTextHttpResponseContentReader.cs +++ /dev/null @@ -1,9 +0,0 @@ -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/modules/Elsa.Http/Parsers/StringHttpContentParser.cs b/src/modules/Elsa.Http/Parsers/StringHttpContentParser.cs new file mode 100644 index 000000000..071af2f86 --- /dev/null +++ b/src/modules/Elsa.Http/Parsers/StringHttpContentParser.cs @@ -0,0 +1,24 @@ +using Elsa.Expressions.Helpers; +using Elsa.Http.Services; + +namespace Elsa.Http.Parsers; + +/// +/// Reads any content type streams as a string and attempts to convert the string to the specified return type. +/// +public class StringHttpContentParser : IHttpContentParser +{ + /// + public int Priority => -10; + + /// + public bool GetSupportsContentType(string contentType) => true; + + /// + public async Task ReadAsync(Stream content, Type? returnType, CancellationToken cancellationToken) + { + using var reader = new StreamReader(content, leaveOpen: true); + var text = await reader.ReadToEndAsync(); + return returnType == null ? text : text.ConvertTo(returnType)!; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Parsers/XmlHttpContentParser.cs b/src/modules/Elsa.Http/Parsers/XmlHttpContentParser.cs new file mode 100644 index 000000000..e96e9cd1d --- /dev/null +++ b/src/modules/Elsa.Http/Parsers/XmlHttpContentParser.cs @@ -0,0 +1,29 @@ +using System.Xml.Serialization; +using Elsa.Http.Services; + +namespace Elsa.Http.Parsers; + +/// +/// Reads application/xml and text/xml content type streams. +/// +public class XmlHttpContentParser : IHttpContentParser +{ + /// + public int Priority => 0; + + /// + public bool GetSupportsContentType(string contentType) => contentType.Contains("xml", StringComparison.InvariantCultureIgnoreCase); + + /// + public async Task ReadAsync(Stream content, Type? returnType, CancellationToken cancellationToken) + { + using var reader = new StreamReader(content, leaveOpen: true); + var xml = await reader.ReadToEndAsync(); + + if (returnType == null || returnType == typeof(string)) + return xml; + + var serializer = new XmlSerializer(returnType); + return serializer.Deserialize(reader)!; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Http/Services/IHttpContentParser.cs b/src/modules/Elsa.Http/Services/IHttpContentParser.cs new file mode 100644 index 000000000..7b26ff08e --- /dev/null +++ b/src/modules/Elsa.Http/Services/IHttpContentParser.cs @@ -0,0 +1,23 @@ +namespace Elsa.Http.Services; + +/// +/// A strategy that reads a of a given content type. +/// +public interface IHttpContentParser +{ + /// + /// The priority of the parser as compared to other parsers. + /// The higher the number, the higher priority. + /// + int Priority { get; } + + /// + /// Returns a value indicating whether this reader supports the specified content type. + /// + bool GetSupportsContentType(string contentType); + + /// + /// Reads the specified and returns a parsed object of the specified type. If no type is specified, a string is returned. + /// + Task ReadAsync(Stream content, Type? returnType, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/modules/Elsa.Liquid/Handlers/ConfigureLiquidEngine.cs b/src/modules/Elsa.Liquid/Handlers/ConfigureLiquidEngine.cs index 6d57c069a..a5f97da92 100644 --- a/src/modules/Elsa.Liquid/Handlers/ConfigureLiquidEngine.cs +++ b/src/modules/Elsa.Liquid/Handlers/ConfigureLiquidEngine.cs @@ -52,7 +52,7 @@ internal class ConfigureLiquidEngine : INotificationHandler x.Type.IsClass)) + foreach (var variableDescriptor in _managementOptions.VariableDescriptors.Where(x => x.Type.IsClass && !x.Type.ContainsGenericParameters)) memberAccessStrategy.Register(variableDescriptor.Type); return Task.CompletedTask; diff --git a/src/modules/Elsa.Workflows.Core/Serialization/Converters/ExpandoObjectConverter.cs b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ExpandoObjectConverter.cs new file mode 100644 index 000000000..6f1d60a03 --- /dev/null +++ b/src/modules/Elsa.Workflows.Core/Serialization/Converters/ExpandoObjectConverter.cs @@ -0,0 +1,86 @@ +using System.Dynamic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elsa.Workflows.Core.Serialization.Converters; + +/// +/// Parses a JON string into a dynamic . +/// +public class ExpandoObjectConverter : JsonConverter +{ + /// + public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + /// + public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Null: + return null!; + case JsonTokenType.False: + return false; + case JsonTokenType.True: + return true; + case JsonTokenType.String: + return reader.GetString()!; + case JsonTokenType.Number: + { + if (reader.TryGetInt32(out var i)) + return i; + if (reader.TryGetInt64(out var l)) + return l; + // BigInteger could be added here. + + if (reader.TryGetDouble(out var d)) + return d; + using var doc = JsonDocument.ParseValue(ref reader); + return doc.RootElement.Clone(); + } + case JsonTokenType.StartArray: + { + var list = new List(); + while (reader.Read()) + { + switch (reader.TokenType) + { + default: + list.Add(Read(ref reader, typeof(object), options)); + break; + case JsonTokenType.EndArray: + return list; + } + } + + throw new JsonException(); + } + case JsonTokenType.StartObject: + var dict = CreateDictionary(); + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonTokenType.EndObject: + return dict; + case JsonTokenType.PropertyName: + var key = reader.GetString()!; + reader.Read(); + dict.Add(key, Read(ref reader, typeof(object), options)); + break; + default: + throw new JsonException(); + } + } + + throw new JsonException(); + default: + throw new JsonException($"Unknown token {reader.TokenType}"); + } + } + + protected virtual IDictionary CreateDictionary() => new ExpandoObject()!; +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs index 0c089e079..50801c8ff 100644 --- a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs +++ b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs @@ -1,5 +1,7 @@ using System.ComponentModel; +using System.Dynamic; using System.Reflection; +using System.Text.Json.Nodes; using Elsa.Common.Features; using Elsa.Expressions.Services; using Elsa.Extensions; @@ -33,6 +35,7 @@ namespace Elsa.Workflows.Management.Features; public class WorkflowManagementFeature : FeatureBase { private const string PrimitivesCategory = "Primitives"; + private const string DynamicCategory = "Dynamic"; /// public WorkflowManagementFeature(IModule module) : base(module) @@ -63,7 +66,9 @@ public class WorkflowManagementFeature : FeatureBase new(typeof(DateTimeOffset), PrimitivesCategory, "A value type that consists of a DateTime and a time zone offset."), new(typeof(TimeSpan), PrimitivesCategory, "Represents a duration of time."), new(typeof(DateOnly), PrimitivesCategory, "Represents dates with values ranging from January 1, 0001 Anno Domini (Common Era) through December 31, 9999 A.D. (C.E.) in the Gregorian calendar."), - new(typeof(TimeOnly), PrimitivesCategory, "Represents a time of day, as would be read from a clock, within the range 00:00:00 to 23:59:59.9999999.") + new(typeof(TimeOnly), PrimitivesCategory, "Represents a time of day, as would be read from a clock, within the range 00:00:00 to 23:59:59.9999999."), + new (typeof(ExpandoObject), DynamicCategory, "A dictionary that can be typed as dynamic to access members using dot notation."), + new (typeof(JsonObject), DynamicCategory, "A type from System.Text.Json that provides dynamic access to the object.") }; /// @@ -98,7 +103,12 @@ public class WorkflowManagementFeature : FeatureBase /// /// Adds the specified variable type to the system. /// - public WorkflowManagementFeature AddVariableType(string category) => AddVariableTypes(new[] { typeof(T) }, category); + public WorkflowManagementFeature AddVariableType(string category) => AddVariableType(typeof(T), category); + + /// + /// Adds the specified variable type to the system. + /// + public WorkflowManagementFeature AddVariableType(Type type, string category) => AddVariableTypes(new[] { type }, category); /// /// Adds the specified variable types to the system. diff --git a/src/modules/Elsa.Workflows.Management/Implementations/ActivityDescriber.cs b/src/modules/Elsa.Workflows.Management/Implementations/ActivityDescriber.cs index 9c3ac1d23..62d832c00 100644 --- a/src/modules/Elsa.Workflows.Management/Implementations/ActivityDescriber.cs +++ b/src/modules/Elsa.Workflows.Management/Implementations/ActivityDescriber.cs @@ -108,6 +108,9 @@ public class ActivityDescriber : IActivityDescriber var isWrappedProperty = typeof(Input).IsAssignableFrom(propertyType); var wrappedPropertyType = !isWrappedProperty ? propertyType : propertyInfo.PropertyType.GenericTypeArguments[0]; + if (wrappedPropertyType.IsNullableType()) + wrappedPropertyType = wrappedPropertyType.GetTypeOfNullable(); + yield return new InputDescriptor ( inputAttribute?.Name ?? propertyInfo.Name, @@ -167,6 +170,9 @@ public class ActivityDescriber : IActivityDescriber if (wrappedPropertyType == typeof(Variable)) return InputUIHints.VariablePicker; + + if (wrappedPropertyType == typeof(Type)) + return InputUIHints.TypePicker; return InputUIHints.SingleLine; } diff --git a/src/modules/Elsa.Workflows.Management/Models/InputUIHints.cs b/src/modules/Elsa.Workflows.Management/Models/InputUIHints.cs index eea7102bd..fad9c84e0 100644 --- a/src/modules/Elsa.Workflows.Management/Models/InputUIHints.cs +++ b/src/modules/Elsa.Workflows.Management/Models/InputUIHints.cs @@ -21,27 +21,4 @@ public static class InputUIHints /// An editor that allows the user to write a blob of JSON. /// public const string Json = "json"; - - public static string GetUIHint(Type wrappedPropertyType, InputAttribute? inputAttribute) - { - if (inputAttribute?.UIHint != null) - return inputAttribute.UIHint; - - if (wrappedPropertyType == typeof(bool) || wrappedPropertyType == typeof(bool?)) - return Checkbox; - - if (wrappedPropertyType == typeof(string)) - return SingleLine; - - if (wrappedPropertyType == typeof(Type)) - return TypePicker; - - if (typeof(IEnumerable).IsAssignableFrom(wrappedPropertyType)) - return Dropdown; - - if (wrappedPropertyType.IsEnum || wrappedPropertyType.IsNullableType() && wrappedPropertyType.GetTypeOfNullable().IsEnum) - return Dropdown; - - return SingleLine; - } } \ No newline at end of file