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:
parent
d94cbcd4b1
commit
0e1922621e
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
||||
|
|
@ -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);
|
||||
|
|
@ -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};
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
22
src/modules/Elsa.Http/Extensions/OutputExtensions.cs
Normal file
22
src/modules/Elsa.Http/Extensions/OutputExtensions.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>()
|
||||
;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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!)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
using System.Net;
|
||||
|
||||
namespace Elsa.Http.Models;
|
||||
|
||||
public record HttpResponseModel(HttpStatusCode StatusCode, IDictionary<string, string[]> Headers);
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
40
src/modules/Elsa.Http/Parsers/JsonHttpContentParser.cs
Normal file
40
src/modules/Elsa.Http/Parsers/JsonHttpContentParser.cs
Normal 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)!;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
24
src/modules/Elsa.Http/Parsers/StringHttpContentParser.cs
Normal file
24
src/modules/Elsa.Http/Parsers/StringHttpContentParser.cs
Normal 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)!;
|
||||
}
|
||||
}
|
||||
29
src/modules/Elsa.Http/Parsers/XmlHttpContentParser.cs
Normal file
29
src/modules/Elsa.Http/Parsers/XmlHttpContentParser.cs
Normal 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)!;
|
||||
}
|
||||
}
|
||||
23
src/modules/Elsa.Http/Services/IHttpContentParser.cs
Normal file
23
src/modules/Elsa.Http/Services/IHttpContentParser.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()!;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue