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
This commit is contained in:
Sipke Schoorstra 2023-01-16 19:42:45 +01:00 committed by GitHub
parent d94cbcd4b1
commit 0e1922621e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 610 additions and 263 deletions

View file

@ -127,6 +127,7 @@ export namespace Components {
interface ElsaFormPanel {
"actions": Array<PanelActionDefinition>;
"mainTitle": string;
"orientation": 'Landscape' | 'Portrait';
"selectedTabIndex"?: number;
"subTitle": string;
"tabs": Array<TabDefinition>;
@ -231,6 +232,9 @@ export namespace Components {
"tooltipContent": any;
"tooltipPosition"?: string;
}
interface ElsaTypePickerInput {
"inputContext": ActivityInputContext;
}
interface ElsaVariableEditorDialogContent {
"getVariable": () => Promise<Variable>;
"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<PanelActionClickArgs>) => void;
"onSelectedTabIndexChanged"?: (event: ElsaFormPanelCustomEvent<TabChangedArgs>) => void;
"onSubmitted"?: (event: ElsaFormPanelCustomEvent<FormData>) => void;
"orientation"?: 'Landscape' | 'Portrait';
"selectedTabIndex"?: number;
"subTitle"?: string;
"tabs"?: Array<TabDefinition>;
@ -1055,6 +1067,9 @@ declare namespace LocalJSX {
"tooltipContent"?: any;
"tooltipPosition"?: string;
}
interface ElsaTypePickerInput {
"inputContext"?: ActivityInputContext;
}
interface ElsaVariableEditorDialogContent {
"onVariableChanged"?: (event: ElsaVariableEditorDialogContentCustomEvent<Variable>) => 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<HTMLElsaStudioElement>;
"elsa-switch-editor": LocalJSX.ElsaSwitchEditor & JSXBase.HTMLAttributes<HTMLElsaSwitchEditorElement>;
"elsa-tooltip": LocalJSX.ElsaTooltip & JSXBase.HTMLAttributes<HTMLElsaTooltipElement>;
"elsa-type-picker-input": LocalJSX.ElsaTypePickerInput & JSXBase.HTMLAttributes<HTMLElsaTypePickerInputElement>;
"elsa-variable-editor-dialog-content": LocalJSX.ElsaVariableEditorDialogContent & JSXBase.HTMLAttributes<HTMLElsaVariableEditorDialogContentElement>;
"elsa-variable-picker-input": LocalJSX.ElsaVariablePickerInput & JSXBase.HTMLAttributes<HTMLElsaVariablePickerInputElement>;
"elsa-variables-editor": LocalJSX.ElsaVariablesEditor & JSXBase.HTMLAttributes<HTMLElsaVariablesEditorElement>;

View file

@ -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<VariableDescriptor> = descriptorsStore.variableDescriptors;
const groupedVariableTypes = _.groupBy(availableTypes, x => x.category);
const groupedVariableTypes = groupBy(availableTypes, x => x.category);
const storageDrivers: Array<StorageDriverDescriptor> = descriptorsStore.storageDrivers;
return (

View file

@ -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<VariableDescriptor> = 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 (
<elsa-input-control-switch label={displayName} hint={description} syntax={syntax} expression={value} onExpressionChanged={this.onExpressionChanged}>
<select id={fieldId} name={fieldName} onChange={e => this.onChange(e)}>
<option value="" selected={(!currentValue || currentValue == "")}></option>
{Object.keys(groupedVariableTypes).map(category => {
const variableTypes = groupedVariableTypes[category] as Array<VariableDescriptor>;
return (<optgroup label={category}>
{variableTypes.map(descriptor => <option value={descriptor.typeName} selected={descriptor.typeName == currentValue}>{descriptor.displayName}</option>)}
</optgroup>);
})}
</select>
</elsa-input-control-switch>
);
}
private onChange = (e: Event) => {
const inputElement = e.target as HTMLSelectElement;
this.inputContext.inputChanged(inputElement.value, SyntaxNames.Literal);
}
private onExpressionChanged = (e: CustomEvent<ExpressionChangedArs>) => {
this.inputContext.inputChanged(e.detail.expression, e.detail.syntax);
}
}

View file

@ -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<TabDefinition> = [];
@Prop({mutable: true}) public selectedTabIndex?: number;
@Prop() public actions: Array<PanelActionDefinition> = [];
@ -39,24 +40,39 @@ export class FormPanel {
const actions = this.actions;
const mainTitle = this.mainTitle;
const subTitle = this.subTitle;
const orientation = this.orientation;
return (
<div class="absolute inset-0 overflow-hidden">
<form class="h-full flex flex-col bg-white shadow-xl" onSubmit={e => this.onSubmit(e)} method="post">
<div class="flex flex-col flex-1">
<div class="px-4 py-6 bg-gray-50 sm:px-6">
<div class="flex items-start justify-between space-x-3">
<div class="space-y-1">
<h2 class="text-lg font-medium text-gray-900">
{mainTitle}
</h2>
{!isNullOrWhitespace(subTitle) ? <h3 class="text-sm text-gray-700">{subTitle}</h3> : undefined}
</div>
</div>
</div>
{orientation == 'Portrait' && (
<div class="px-4 py-6 bg-gray-50">
<div class="border-b border-gray-200 pl-4">
<div class="flex items-start justify-between space-x-3">
<div class="space-y-1">
<h2 class="text-lg font-medium text-gray-900">
{mainTitle}
</h2>
{!isNullOrWhitespace(subTitle) ? <h3 class="text-sm text-gray-700">{subTitle}</h3> : undefined}
</div>
</div>
</div>)}
{orientation == 'Landscape' && (
<div class="px-10 py-4 bg-gray-50">
<div class="flex items-start justify-between space-x-3">
<div class="space-y-0">
<h2 class="text-md font-medium text-gray-900">
{mainTitle}
</h2>
{!isNullOrWhitespace(subTitle) ? <h3 class="text-xs text-gray-700">{subTitle}</h3> : undefined}
</div>
</div>
</div>)}
<div class={`border-b border-gray-200 ${orientation == 'Landscape' ? 'pl-10' : 'pl-4'}`}>
<nav class="-mb-px flex justify-start space-x-5" aria-label="Tabs">
{tabs.map((tab, tabIndex) => {
const cssClass = tabIndex == selectedTabIndex ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300';
@ -69,17 +85,16 @@ export class FormPanel {
</nav>
</div>
<div class="flex-1 relative">
<div class="absolute inset-0 overflow-y-scroll">
{tabs.map((tab, tabIndex) => {
const cssClass = tabIndex == selectedTabIndex ? '' : 'hidden';
return <div class={cssClass}>
{tab.content()}
</div>
})}
<div class={`flex-1 relative`}>
<div class={`absolute inset-0 overflow-y-scroll ${orientation == 'Landscape' ? 'px-6' : ''}`}>
{tabs.map((tab, tabIndex) => {
const cssClass = tabIndex == selectedTabIndex ? '' : 'hidden';
return <div class={cssClass}>
{tab.content()}
</div>
})}
</div>
</div>
</div>
</div>
{actions.length > 0 ? (

View file

@ -137,6 +137,7 @@ export class ActivityPropertiesEditor {
<elsa-form-panel
mainTitle={mainTitle}
subTitle={subTitle}
orientation="Landscape"
tabs={tabs}
selectedTabIndex={selectedTabIndex}
onSelectedTabIndexChanged={e => this.onSelectedTabIndexChanged(e)}

View file

@ -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);
}

View file

@ -19,6 +19,7 @@ export class InputControlRegistry {
this.add('code-editor', c => <elsa-code-editor-input inputContext={c}/>);
this.add('checkbox', c => <elsa-checkbox-input inputContext={c}/>);
this.add('variable-picker', c => <elsa-variable-picker-input inputContext={c}/>);
this.add('type-picker', c => <elsa-type-picker-input inputContext={c}/>);
}
public add(uiHint: UIHint, control: RenderActivityPropInputControl) {

View file

@ -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'],

View file

@ -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;
/// <summary>
/// Wait for an inbound HTTP request that matches the specified path and methods.
/// </summary>
[Activity("Elsa", "HTTP", "Wait for an inbound HTTP request that matches the specified path and methods.")]
public class HttpEndpoint : Trigger<HttpRequestModel>
public class HttpEndpoint : Trigger<HttpRequest>
{
public const string InputKey = "HttpRequest";
internal const string HttpContextInputKey = "HttpContext";
internal const string RequestPathInputKey = "RequestPath";
/// <inheritdoc />
[JsonConstructor]
public HttpEndpoint()
{
}
[Input] public Input<string> Path { get; set; } = default!;
/// <summary>
/// The path to associate with the workflow.
/// </summary>
[Input(Description = "The path to associate with the workflow.")]
public Input<string> Path { get; set; } = default!;
/// <summary>
/// The HTTP methods to accept.
/// </summary>
[Input(
Description = "The HTTP methods to accept.",
Options = new[] { "GET", "POST", "PUT", "HEAD", "DELETE" },
UIHint = InputUIHints.CheckList
)]
UIHint = InputUIHints.CheckList)]
public Input<ICollection<string>> SupportedMethods { get; set; } = new(new[] { HttpMethod.Get.Method });
[Input(
Description = "Allow authenticated requests only",
Category = "Security"
)]
/// <summary>
/// Allow authenticated requests only.
/// </summary>
[Input(Description = "Allow authenticated requests only.", Category = "Security")]
public Input<bool> Authorize { get; set; } = new(false);
[Input(
Description = "Provide a policy to evaluate. If the policy fails, the request is forbidden.",
Category = "Security"
)]
/// <summary>
/// Provide a policy to evaluate. If the policy fails, the request is forbidden.
/// </summary>
[Input(Description = "Provide a policy to evaluate. If the policy fails, the request is forbidden.", Category = "Security")]
public Input<string?> Policy { get; set; } = new(default(string?));
/// <summary>
/// The parsed request content, if any.
/// </summary>
[Output(Description = "The parsed request content, if any.")]
public Output<object?> ParsedContent { get; set; } = default!;
/// <summary>
/// The parsed route data, if any.
/// </summary>
[Output(Description = "The parsed route data, if any.")]
public Output<RouteData> RouteData { get; set; } = default!;
/// <inheritdoc />
protected override IEnumerable<object> GetTriggerPayloads(TriggerIndexingContext context) => GetBookmarkPayloads(context.ExpressionExecutionContext);
@ -45,7 +73,7 @@ public class HttpEndpoint : Trigger<HttpRequestModel>
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<HttpRequestModel>(InputKey, out var request))
if (!context.TryGetInput<HttpContext>(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<HttpRequestModel>
}
// Provide the received HTTP request as output.
var request = httpContext.Request;
context.Set(Result, request);
// Read route data, if any.
var path = context.GetInput<PathString>(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<object?> 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<object> GetBookmarkPayloads(ExpressionExecutionContext context)
{
@ -66,4 +119,28 @@ public class HttpEndpoint : Trigger<HttpRequestModel>
var methods = context.Get(SupportedMethods);
return methods!.Select(x => new HttpEndpointBookmarkPayload(path!, x.ToLowerInvariant())).Cast<object>().ToArray();
}
private static RouteData GetRouteData(HttpContext httpContext, string path)
{
var routeData = httpContext.GetRouteData();
var routeTable = httpContext.RequestServices.GetRequiredService<IRouteTable>();
var routeMatcher = httpContext.RequestServices.GetRequiredService<IRouteMatcher>();
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;
}
}

View file

@ -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
/// <summary>
/// Send an HTTP request.
/// </summary>
[Activity("Elsa", "HTTP", "Send an HTTP request.", DisplayName = "HTTP Request", Kind = ActivityKind.Task)]
public class SendHttpRequest : Activity<HttpResponse>
{
[Input] public Input<Uri?> Url { get; set; } = default!;
/// <summary>
/// The HTTP method to use when sending the request.
/// </summary>
[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<string?> Method { get; set; }
public Input<string> Method { get; set; } = new("GET");
public Input<string?> Content { get; set; } = default!;
/// <summary>
/// The content to send with the request. Can be a string, an object, a byte array or a stream.
/// </summary>
[Input(Description = "The content to send with the request. Can be a string, an object, a byte array or a stream.")]
public Input<object?> Content { get; set; } = default!;
/// <summary>
/// The content type to use when sending the request.
/// </summary>
[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<string?> ContentType { get; set; } = default!;
[Input(Category = "Security")] public Input<string?> Authorization { get; set; } = default!;
public Input<bool> ReadContent { get; set; } = new(false);
/// <summary>
/// The Authorization header value to send with the request.
/// </summary>
/// <example>Bearer {some-access-token}</example>
[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<string?> ResponseContentParserName { get; set; } = default!;
public Input<string?> Authorization { get; set; } = default!;
[Input(Category = "Security")] public Input<Dictionary<string, string>?> RequestHeaders { get; set; } = new(new HttpRequestHeaders());
/// <summary>
/// The headers to send along with the request.
/// </summary>
[Input(Description = "The headers to send along with the request.", Category = "Advanced")]
public Input<HttpRequestHeaders?> RequestHeaders { get; set; } = new(new HttpRequestHeaders());
[Output] public Output<object>? ResponseContent { get; set; }
[Output] public Output<HttpResponseModel>? Response { get; set; }
/// <summary>
/// The parsed content, if any.
/// </summary>
[Output(Description = "The parsed content, if any.")]
public Output<object?> ParsedContent { get; set; } = default!;
/// <inheritdoc />
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
var request = PrepareRequest(context);
var httpClientFactory = context.GetRequiredService<IHttpClientFactory>();
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<string, string[]>())
{
StatusCode = response!.StatusCode,
Headers = new Dictionary<string, string[]>(allHeaders)
};
context.Set(Response, responseModel);
if (HasContent(response) && context.Get(ReadContent))
{
var parsers = context.GetServices<IHttpResponseContentReader>();
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<IHttpResponseContentReader> parsers, string? parserName, string? contentType)
private async Task<object?> 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<IHttpRequestContentWriter>();
var contentType = ContentType.TryGet(context);
var contentWriters = context.GetServices<IHttpContentWriter>();
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<IHttpRequestContentWriter> requestContentWriters) =>
string.IsNullOrWhiteSpace(contentType) ? new StringHttpRequestContentWriter() : requestContentWriters.First(w => w.SupportsContentType(contentType));
private IHttpContentWriter SelectContentWriter(string? contentType, IEnumerable<IHttpContentWriter> requestContentWriters) =>
string.IsNullOrWhiteSpace(contentType) ? new StringHttpContentWriter() : requestContentWriters.First(w => w.SupportsContentType(contentType));
}

View file

@ -7,7 +7,7 @@ namespace Elsa.Http.ContentWriters;
/// <summary>
/// A content writer that writes content in the application/x-www-form-urlencoded format.
/// </summary>
public class FormUrlEncodedHttpRequestContentWriter : IHttpRequestContentWriter
public class FormUrlEncodedHttpContentWriter : IHttpContentWriter
{
private readonly List<string> _supportedContentTypes = new() {MimeTypes.ApplicationWwwFormUrlEncoded};

View file

@ -1,6 +1,6 @@
namespace Elsa.Http.ContentWriters;
public interface IHttpRequestContentWriter
public interface IHttpContentWriter
{
bool SupportsContentType(string contentType);
HttpContent GetContent<T>(T content, string? contentType = null);

View file

@ -4,7 +4,7 @@ using Elsa.Http.Constants;
namespace Elsa.Http.ContentWriters;
public class StringHttpRequestContentWriter : IHttpRequestContentWriter
public class StringHttpContentWriter : IHttpContentWriter
{
private List<string> SupportedContentTypes = new() {MimeTypes.ApplicationJson, MimeTypes.ApplicationXml};

View file

@ -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<object?> ParseContentAsync(this ActivityExecutionContext context, Stream content, string contentType, Type? returnType, CancellationToken cancellationToken)
{
var parsers = context.GetServices<IHttpContentParser>().OrderByDescending(x => x.Priority).ToList();
var contentParser = parsers.First(x => x.GetSupportsContentType(contentType));
return await contentParser.ReadAsync(content, returnType, cancellationToken);
}
}

View file

@ -0,0 +1,22 @@
using Elsa.Workflows.Core.Models;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
internal static class OutputExtensions
{
/// <summary>
/// Gets the target type of the specified variable type.
/// </summary>
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();
}
}

View file

@ -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<HttpActivityOptions>? ConfigureHttpOptions { get; set; }
/// <summary>
/// A delegate to configure <see cref="HttpActivityOptions"/>.
/// </summary>
public Action<HttpActivityOptions>? ConfigureHttpOptions { get; set; }
public Func<IServiceProvider, IHttpEndpointAuthorizationHandler> HttpEndpointAuthorizationHandlerFactory { get; set; } = ActivatorUtilities.GetServiceOrCreateInstance<AllowAnonymousHttpEndpointAuthorizationHandler>;
public Func<IServiceProvider, IHttpEndpointWorkflowFaultHandler> HttpEndpointWorkflowFaultHandlerFactory { get; set; } = ActivatorUtilities.GetServiceOrCreateInstance<DefaultHttpEndpointWorkflowFaultHandler>;
/// <summary>
/// A delegate to configure the <see cref="HttpClient"/> used when by the <see cref="SendHttpRequest"/> activity.
/// </summary>
public Action<IServiceProvider, HttpClient> HttpClient { get; set; } = (_, _) => { };
/// <summary>
/// A delegate to configure the <see cref="HttpClientBuilder"/> for <see cref="HttpClient"/>.
/// </summary>
public Action<IHttpClientBuilder> HttpClientBuilder { get; set; } = _ => { };
/// <inheritdoc />
public override void Configure()
{
Module.UseWorkflowManagement(management => management.AddVariableTypes(new[]
Module.UseWorkflowManagement(management =>
{
typeof(HttpRequestHeaders),
typeof(HttpRequestModel),
typeof(HttpResponseModel)
}, "HTTP").AddActivitiesFrom<HttpFeature>());
management.AddVariableTypes(new[]
{
typeof(RouteData),
typeof(HttpRequest),
typeof(HttpResponse)
}, "HTTP");
management.AddActivitiesFrom<HttpFeature>();
});
}
/// <inheritdoc />
@ -44,23 +64,27 @@ public class HttpFeature : FeatureBase
options.BasePath = "/workflows";
options.BaseUrl = new Uri("http://localhost");
});
Services.Configure(configureOptions);
var httpClientBuilder = Services.AddHttpClient<SendHttpRequest>(HttpClient);
HttpClientBuilder(httpClientBuilder);
Services
.AddHttpClient()
.AddSingleton<IRouteMatcher, RouteMatcher>()
.AddSingleton<IRouteTable, RouteTable>()
.AddSingleton<IAbsoluteUrlProvider, DefaultAbsoluteUrlProvider>()
.AddNotificationHandlersFrom<UpdateRouteTable>()
.AddHttpContextAccessor()
// Add Content Parsers
.AddSingleton<IHttpResponseContentReader, JsonElementHttpResponseContentReader>()
//Add Request Content Writers
.AddSingleton<IHttpRequestContentWriter, StringHttpRequestContentWriter>()
.AddSingleton<IHttpRequestContentWriter, FormUrlEncodedHttpRequestContentWriter>()
// Add Content Parsers.
.AddSingleton<IHttpContentParser, StringHttpContentParser>()
.AddSingleton<IHttpContentParser, JsonHttpContentParser>()
.AddSingleton<IHttpContentParser, XmlHttpContentParser>()
// Add Request Content Writers.
.AddSingleton<IHttpContentWriter, StringHttpContentWriter>()
.AddSingleton<IHttpContentWriter, FormUrlEncodedHttpContentWriter>()
;
}
}

View file

@ -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;
/// <summary>
/// An ASP.NET middleware component that tries to match the inbound request path to an associated workflow and then run that workflow.
/// </summary>
[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<HttpEndpoint>();
/// <summary>
/// Constructor.
/// </summary>
public WorkflowsMiddleware(
RequestDelegate next,
IBookmarkHasher hasher,
IWorkflowRuntime workflowRuntime,
IWorkflowHostFactory workflowHostFactory,
IWorkflowDefinitionService workflowDefinitionService,
IOptions<HttpActivityOptions> options)
{
_next = next;
_hasher = hasher;
_workflowRuntime = workflowRuntime;
_workflowHostFactory = workflowHostFactory;
_workflowDefinitionService = workflowDefinitionService;
_options = options.Value;
}
/// <summary>
/// Attempts to matches the inbound request path to an associated workflow and then run that workflow.
/// </summary>
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<string, object> { [HttpEndpoint.InputKey] = requestModel };
var input = new Dictionary<string, object>
{
[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<WriteHttpResponse>();
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<IRouteTable>();
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();
}

View file

@ -1,6 +1,8 @@
using Elsa.Extensions;
namespace Elsa.Http.Models;
public class HttpRequestHeaders : Dictionary<string, string>
public class HttpRequestHeaders : Dictionary<string, string[]>
{
public string ContentType => this["content-type"];
public string? ContentType => this.GetValue("content-type")?[0];
}

View file

@ -1,22 +0,0 @@
using System.Text.Json.Serialization;
namespace Elsa.Http.Models;
public record HttpRequestModel(
Uri RequestUri,
string Path,
string Method,
IDictionary<string, string> QueryString,
IDictionary<string, object> RouteValues,
IDictionary<string, string> Headers
)
{
/// <summary>
/// Constructor used for deserialization.
/// </summary>
[JsonConstructor]
public HttpRequestModel() : this(default!, default!, default!, default!, default!, default!)
{
}
}

View file

@ -1,5 +0,0 @@
using System.Net;
namespace Elsa.Http.Models;
public record HttpResponseModel(HttpStatusCode StatusCode, IDictionary<string, string[]> Headers);

View file

@ -1,9 +0,0 @@
namespace Elsa.Http.Parsers;
public interface IHttpResponseContentReader
{
string Name { get; }
int Priority { get; }
bool GetSupportsContentType(string contentType);
Task<object> ReadAsync(HttpResponseMessage response, object context, CancellationToken cancellationToken);
}

View file

@ -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<object> ReadAsync(HttpResponseMessage response, object context, CancellationToken cancellationToken)
{
var json = (await response.Content.ReadAsStringAsync(cancellationToken)).Trim();
return JsonDocument.Parse(json).RootElement;
}
}

View file

@ -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;
/// <summary>
/// Reads application/json and text/json content type streams.
/// </summary>
public class JsonHttpContentParser : IHttpContentParser
{
/// <inheritdoc />
public int Priority => 0;
/// <inheritdoc />
public bool GetSupportsContentType(string contentType) => contentType.Contains("json", StringComparison.InvariantCultureIgnoreCase);
/// <inheritdoc />
public async Task<object> 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<object>(json, options)!;
}
}

View file

@ -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<object> ReadAsync(HttpResponseMessage response, object context, CancellationToken cancellationToken) => await response.Content.ReadAsStringAsync();
}

View file

@ -0,0 +1,24 @@
using Elsa.Expressions.Helpers;
using Elsa.Http.Services;
namespace Elsa.Http.Parsers;
/// <summary>
/// Reads any content type streams as a string and attempts to convert the string to the specified return type.
/// </summary>
public class StringHttpContentParser : IHttpContentParser
{
/// <inheritdoc />
public int Priority => -10;
/// <inheritdoc />
public bool GetSupportsContentType(string contentType) => true;
/// <inheritdoc />
public async Task<object> 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)!;
}
}

View file

@ -0,0 +1,29 @@
using System.Xml.Serialization;
using Elsa.Http.Services;
namespace Elsa.Http.Parsers;
/// <summary>
/// Reads application/xml and text/xml content type streams.
/// </summary>
public class XmlHttpContentParser : IHttpContentParser
{
/// <inheritdoc />
public int Priority => 0;
/// <inheritdoc />
public bool GetSupportsContentType(string contentType) => contentType.Contains("xml", StringComparison.InvariantCultureIgnoreCase);
/// <inheritdoc />
public async Task<object> 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)!;
}
}

View file

@ -0,0 +1,23 @@
namespace Elsa.Http.Services;
/// <summary>
/// A strategy that reads a <see cref="HttpResponseMessage"/> of a given content type.
/// </summary>
public interface IHttpContentParser
{
/// <summary>
/// The priority of the parser as compared to other parsers.
/// The higher the number, the higher priority.
/// </summary>
int Priority { get; }
/// <summary>
/// Returns a value indicating whether this reader supports the specified content type.
/// </summary>
bool GetSupportsContentType(string contentType);
/// <summary>
/// Reads the specified <see cref="stream"/> and returns a parsed object of the specified type. If no type is specified, a string is returned.
/// </summary>
Task<object> ReadAsync(Stream content, Type? returnType, CancellationToken cancellationToken);
}

View file

@ -52,7 +52,7 @@ internal class ConfigureLiquidEngine : INotificationHandler<RenderingLiquidTempl
}
// Register all variable types.
foreach (var variableDescriptor in _managementOptions.VariableDescriptors.Where(x => x.Type.IsClass))
foreach (var variableDescriptor in _managementOptions.VariableDescriptors.Where(x => x.Type.IsClass && !x.Type.ContainsGenericParameters))
memberAccessStrategy.Register(variableDescriptor.Type);
return Task.CompletedTask;

View file

@ -0,0 +1,86 @@
using System.Dynamic;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Elsa.Workflows.Core.Serialization.Converters;
/// <summary>
/// Parses a JON string into a dynamic <see cref="ExpandoObject"/>.
/// </summary>
public class ExpandoObjectConverter : JsonConverter<object>
{
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
{
throw new NotImplementedException();
}
/// <inheritdoc />
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<object>();
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<string, object> CreateDictionary() => new ExpandoObject()!;
}

View file

@ -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";
/// <inheritdoc />
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.")
};
/// <summary>
@ -98,7 +103,12 @@ public class WorkflowManagementFeature : FeatureBase
/// <summary>
/// Adds the specified variable type to the system.
/// </summary>
public WorkflowManagementFeature AddVariableType<T>(string category) => AddVariableTypes(new[] { typeof(T) }, category);
public WorkflowManagementFeature AddVariableType<T>(string category) => AddVariableType(typeof(T), category);
/// <summary>
/// Adds the specified variable type to the system.
/// </summary>
public WorkflowManagementFeature AddVariableType(Type type, string category) => AddVariableTypes(new[] { type }, category);
/// <summary>
/// Adds the specified variable types to the system.

View file

@ -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;
}

View file

@ -21,27 +21,4 @@ public static class InputUIHints
/// An editor that allows the user to write a blob of JSON.
/// </summary>
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;
}
}