diff --git a/src/modules/http/Elsa.Http.OpenApi/Contracts/IElsaVersionProvider.cs b/src/modules/http/Elsa.Http.OpenApi/Contracts/IElsaVersionProvider.cs new file mode 100644 index 00000000..b8897b53 --- /dev/null +++ b/src/modules/http/Elsa.Http.OpenApi/Contracts/IElsaVersionProvider.cs @@ -0,0 +1,15 @@ +using System.Reflection; + +namespace Elsa.Http.OpenApi.Contracts; + +/// +/// Service for retrieving Elsa package version information. +/// +public interface IElsaVersionProvider +{ + /// + /// Gets the Elsa package version. + /// + /// The package version string. + string GetVersion(); +} diff --git a/src/modules/http/Elsa.Http.OpenApi/Contracts/IOpenApiGenerator.cs b/src/modules/http/Elsa.Http.OpenApi/Contracts/IOpenApiGenerator.cs new file mode 100644 index 00000000..83abab8b --- /dev/null +++ b/src/modules/http/Elsa.Http.OpenApi/Contracts/IOpenApiGenerator.cs @@ -0,0 +1,16 @@ +using Elsa.Http.OpenApi.Models; + +namespace Elsa.Http.OpenApi.Contracts; + +/// +/// Contract for generating OpenAPI JSON documentation. +/// +public interface IOpenApiGenerator +{ + /// + /// Generates OpenAPI JSON documentation from a list of endpoint definitions. + /// + /// The list of endpoint definitions. + /// OpenAPI JSON string. + string GenerateOpenApiJson(List endpoints); +} diff --git a/src/modules/http/Elsa.Http.OpenApi/Contracts/IWorkflowEndpointExtractor.cs b/src/modules/http/Elsa.Http.OpenApi/Contracts/IWorkflowEndpointExtractor.cs new file mode 100644 index 00000000..6fd37f15 --- /dev/null +++ b/src/modules/http/Elsa.Http.OpenApi/Contracts/IWorkflowEndpointExtractor.cs @@ -0,0 +1,16 @@ +using Elsa.Http.OpenApi.Models; + +namespace Elsa.Http.OpenApi.Contracts; + +/// +/// Contract for extracting workflow HTTP endpoints for OpenAPI documentation. +/// +public interface IWorkflowEndpointExtractor +{ + /// + /// Extracts all HTTP endpoints from workflows. + /// + /// The cancellation token. + /// A list of endpoint definitions. + Task> ExtractEndpointsAsync(CancellationToken cancellationToken = default); +} diff --git a/src/modules/http/Elsa.Http.OpenApi/Elsa.Http.OpenApi.csproj b/src/modules/http/Elsa.Http.OpenApi/Elsa.Http.OpenApi.csproj new file mode 100644 index 00000000..ee4356fd --- /dev/null +++ b/src/modules/http/Elsa.Http.OpenApi/Elsa.Http.OpenApi.csproj @@ -0,0 +1,37 @@ + + + + + Provides OpenAPI functionality for HTTP triggers in Elsa workflows. + + elsa extension module, http, openapi + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/http/Elsa.Http.OpenApi/Extensions/EndpointRouteBuilderExtensions.cs b/src/modules/http/Elsa.Http.OpenApi/Extensions/EndpointRouteBuilderExtensions.cs new file mode 100644 index 00000000..d30e361f --- /dev/null +++ b/src/modules/http/Elsa.Http.OpenApi/Extensions/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,50 @@ +using Elsa.Http.OpenApi.Contracts; +using Elsa.Http.OpenApi.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Http.OpenApi.Extensions; + +/// +/// Extension methods for mapping OpenAPI endpoints. +/// +public static class EndpointRouteBuilderExtensions +{ + /// + /// Maps OpenAPI endpoints for workflow documentation. + /// + /// The endpoint route builder. + /// The endpoint route builder for chaining. + public static IEndpointRouteBuilder MapWorkflowOpenApi(this IEndpointRouteBuilder app) + { + // OpenAPI JSON endpoint + app.MapGet("/openapi.json", async ([FromServices] IWorkflowEndpointExtractor extractor, [FromServices] IOpenApiGenerator generator) => + { + var endpoints = await extractor.ExtractEndpointsAsync(); + var openApiJson = generator.GenerateOpenApiJson(endpoints); + return Results.Content(openApiJson, "application/json"); + }); + + // ReDoc UI endpoint + app.MapGet("/documentation", () => + { + var html = @" + + + + API Documentation + + + + + + "; + return Results.Content(html, "text/html"); + }); + + return app; + } +} diff --git a/src/modules/http/Elsa.Http.OpenApi/Extensions/ModuleExtensions.cs b/src/modules/http/Elsa.Http.OpenApi/Extensions/ModuleExtensions.cs new file mode 100644 index 00000000..03b69616 --- /dev/null +++ b/src/modules/http/Elsa.Http.OpenApi/Extensions/ModuleExtensions.cs @@ -0,0 +1,22 @@ +using Elsa.Extensions; +using Elsa.Features.Services; +using Elsa.Http.Features; +using Elsa.Http.OpenApi.Features; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +/// +/// Provides extensions to install the HTTP OpenAPI feature. +/// +public static class ModuleExtensions +{ + /// + /// Install the feature as part of the HTTP feature configuration. + /// + public static HttpFeature UseOpenApi(this HttpFeature feature, Action? configure = default) + { + feature.Module.Configure(configure); + return feature; + } +} diff --git a/src/modules/http/Elsa.Http.OpenApi/Extensions/ServiceCollectionExtensions.cs b/src/modules/http/Elsa.Http.OpenApi/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..fe13a45a --- /dev/null +++ b/src/modules/http/Elsa.Http.OpenApi/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using Elsa.Http.OpenApi.Contracts; +using Elsa.Http.OpenApi.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Http.OpenApi.Extensions; + +/// +/// Extension methods for configuring HTTP OpenAPI services. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Adds HTTP OpenAPI services to the service collection. + /// + /// The service collection. + /// The service collection for chaining. + public static IServiceCollection AddElsaHttpOpenApi(this IServiceCollection services) + { + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/modules/http/Elsa.Http.OpenApi/Features/HttpOpenApiFeature.cs b/src/modules/http/Elsa.Http.OpenApi/Features/HttpOpenApiFeature.cs new file mode 100644 index 00000000..d298b579 --- /dev/null +++ b/src/modules/http/Elsa.Http.OpenApi/Features/HttpOpenApiFeature.cs @@ -0,0 +1,25 @@ +using Elsa.Features.Abstractions; +using Elsa.Features.Attributes; +using Elsa.Features.Services; +using Elsa.Http.Features; +using Elsa.Http.OpenApi.Extensions; + +namespace Elsa.Http.OpenApi.Features; + +/// +/// Feature for HTTP OpenAPI functionality. +/// +[DependsOn(typeof(HttpFeature))] +public class HttpOpenApiFeature : FeatureBase +{ + /// + public HttpOpenApiFeature(IModule module) : base(module) + { + } + + /// + public override void Apply() + { + Services.AddElsaHttpOpenApi(); + } +} diff --git a/src/modules/http/Elsa.Http.OpenApi/FodyWeavers.xml b/src/modules/http/Elsa.Http.OpenApi/FodyWeavers.xml new file mode 100644 index 00000000..00e1d9a1 --- /dev/null +++ b/src/modules/http/Elsa.Http.OpenApi/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/http/Elsa.Http.OpenApi/Models/EndpointDefinition.cs b/src/modules/http/Elsa.Http.OpenApi/Models/EndpointDefinition.cs new file mode 100644 index 00000000..b7df861c --- /dev/null +++ b/src/modules/http/Elsa.Http.OpenApi/Models/EndpointDefinition.cs @@ -0,0 +1,18 @@ +namespace Elsa.Http.OpenApi.Models; + +/// +/// Represents an HTTP endpoint definition extracted from a workflow. +/// +/// The HTTP path of the endpoint. +/// The HTTP method (GET, POST, etc.). +/// Optional summary description of the endpoint. +/// The workflow definition ID that contains this endpoint. +/// The name of the workflow definition. +/// The version of the workflow definition. +public record EndpointDefinition( + string Path, + string Method, + string? Summary = null, + string? WorkflowDefinitionId = null, + string? WorkflowDefinitionName = null, + int? WorkflowVersion = null); diff --git a/src/modules/http/Elsa.Http.OpenApi/README.md b/src/modules/http/Elsa.Http.OpenApi/README.md new file mode 100644 index 00000000..e69de29b diff --git a/src/modules/http/Elsa.Http.OpenApi/Services/ElsaVersionProvider.cs b/src/modules/http/Elsa.Http.OpenApi/Services/ElsaVersionProvider.cs new file mode 100644 index 00000000..4775ec1d --- /dev/null +++ b/src/modules/http/Elsa.Http.OpenApi/Services/ElsaVersionProvider.cs @@ -0,0 +1,44 @@ +using System.Linq; +using System.Reflection; +using Elsa.Http.OpenApi.Contracts; + +namespace Elsa.Http.OpenApi.Services; + +/// +/// Service for retrieving Elsa package version information by examining loaded assemblies. +/// +public class ElsaVersionProvider : IElsaVersionProvider +{ + /// + /// Gets the Elsa package version by examining loaded assemblies. + /// + /// The package version string. + public string GetVersion() + { + // Try to get version from Elsa.Workflows.Core assembly first + var elsaCoreAssembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name == "Elsa.Workflows.Core"); + + if (elsaCoreAssembly != null) + { + var versionAttribute = elsaCoreAssembly.GetCustomAttribute(); + return RemoveAutoGeneratedPostfix(versionAttribute); + } + + // Fallback to any Elsa assembly + var elsaAssembly = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => a.GetName().Name?.StartsWith("Elsa.") == true); + + if (elsaAssembly != null) + { + var versionAttribute = elsaAssembly.GetCustomAttribute(); + return RemoveAutoGeneratedPostfix(versionAttribute); + } + + // Final fallback + return "1.0.0"; + } + + private static string RemoveAutoGeneratedPostfix(AssemblyInformationalVersionAttribute? versionAttribute) => + versionAttribute?.InformationalVersion.Split("+")[0] ?? "1.0.0"; +} diff --git a/src/modules/http/Elsa.Http.OpenApi/Services/OpenApiGenerator.cs b/src/modules/http/Elsa.Http.OpenApi/Services/OpenApiGenerator.cs new file mode 100644 index 00000000..0b88e617 --- /dev/null +++ b/src/modules/http/Elsa.Http.OpenApi/Services/OpenApiGenerator.cs @@ -0,0 +1,85 @@ +using Elsa.Http.OpenApi.Contracts; +using Elsa.Http.OpenApi.Models; +using System.Text.Json; + +namespace Elsa.Http.OpenApi.Services; + +/// +/// Service for generating OpenAPI JSON documentation from workflow endpoints. +/// +public class OpenApiGenerator : IOpenApiGenerator +{ + private readonly IElsaVersionProvider _versionProvider; + + public OpenApiGenerator(IElsaVersionProvider versionProvider) + { + _versionProvider = versionProvider; + } + + /// + /// Generates OpenAPI JSON documentation from a list of endpoint definitions. + /// + /// The list of endpoint definitions. + /// OpenAPI JSON string. + public string GenerateOpenApiJson(List endpoints) + { + var openApiDoc = new + { + openapi = "3.0.0", + info = new + { + title = "Elsa Workflow HTTP Endpoints", + version = _versionProvider.GetVersion(), + description = "HTTP endpoints exposed by Elsa workflows" + }, + paths = GeneratePaths(endpoints) + }; + + return JsonSerializer.Serialize(openApiDoc, new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + } + + private object GeneratePaths(List endpoints) + { + var paths = new Dictionary(); + + foreach (var endpoint in endpoints) + { + if (!paths.ContainsKey(endpoint.Path)) + { + paths[endpoint.Path] = new Dictionary(); + } + + var pathItem = (Dictionary)paths[endpoint.Path]; + + // Create description with workflow definition ID if available + var description = endpoint.WorkflowDefinitionId != null + ? $"Workflow endpoint from '{endpoint.WorkflowDefinitionName}' (ID: {endpoint.WorkflowDefinitionId})" + : $"Workflow endpoint for {endpoint.Method.ToUpperInvariant()} {endpoint.Path}"; + + // Use workflow name as tag, fallback to "Workflows" + var tags = endpoint.WorkflowDefinitionName != null + ? new[] { endpoint.WorkflowDefinitionName } + : new[] { "Workflows" }; + + pathItem[endpoint.Method.ToLowerInvariant()] = new + { + summary = $"{endpoint.Method.ToUpperInvariant()} {endpoint.Path}", + description = description, + responses = new + { + @default = new + { + description = "Response from workflow execution" + } + }, + tags = tags + }; + } + + return paths; + } +} diff --git a/src/modules/http/Elsa.Http.OpenApi/Services/WorkflowEndpointExtractor.cs b/src/modules/http/Elsa.Http.OpenApi/Services/WorkflowEndpointExtractor.cs new file mode 100644 index 00000000..f0a5dc9c --- /dev/null +++ b/src/modules/http/Elsa.Http.OpenApi/Services/WorkflowEndpointExtractor.cs @@ -0,0 +1,76 @@ +using Elsa.Extensions; +using Elsa.Http; +using Elsa.Http.Bookmarks; +using Elsa.Http.OpenApi.Contracts; +using Elsa.Http.OpenApi.Models; +using Elsa.Workflows.Helpers; +using Elsa.Workflows.Runtime; +using Elsa.Workflows.Runtime.Filters; +using Elsa.Common.Models; +using Elsa.Workflows; +using System.Net; +using Elsa.Scheduling.Activities; +using Elsa.Workflows.Management; +using Elsa.Workflows.Management.Services; +using Elsa.Workflows.Management.Stores; +using Elsa.Workflows.Management.Filters; +using Elsa.Workflows.Models; + +namespace Elsa.Http.OpenApi.Services; + +/// +/// Service for extracting HTTP endpoint definitions from Elsa workflows. +/// +public class WorkflowEndpointExtractor : IWorkflowEndpointExtractor +{ + private readonly ITriggerStore _triggerStore; + private readonly IWorkflowDefinitionStore _workflowDefinitionStore; + + public WorkflowEndpointExtractor(ITriggerStore triggerStore, IWorkflowDefinitionStore workflowDefinitionStore) + { + _triggerStore = triggerStore; + _workflowDefinitionStore = workflowDefinitionStore; + } + + public async Task> ExtractEndpointsAsync(CancellationToken cancellationToken = default) + { + var endpoints = new List(); + + var httpEndpointTypeName = ActivityTypeNameHelper.GenerateTypeName(); + var triggerFilter = new TriggerFilter + { + Name = httpEndpointTypeName + }; + var triggers = (await _triggerStore.FindManyAsync(triggerFilter, cancellationToken)).ToList(); + + var filteredTriggers = triggers.Where(x => x.Name == httpEndpointTypeName && x.Payload != null); + + // Group triggers by workflow definition ID to minimize database queries + var triggersByWorkflow = filteredTriggers.GroupBy(t => t.WorkflowDefinitionId); + + foreach (var workflowGroup in triggersByWorkflow) + { + var workflowDefinitionId = workflowGroup.Key; + + // Get workflow definition information + var filter = new WorkflowDefinitionFilter { DefinitionId = workflowDefinitionId }; + var workflowDefinition = await _workflowDefinitionStore.FindAsync(filter, cancellationToken); + + foreach (var trigger in workflowGroup) + { + var payload = trigger.GetPayload(); + + endpoints.Add(new EndpointDefinition( + Path: payload.Path, + Method: payload.Method, + Summary: null, // Could be enhanced to extract from activity properties + WorkflowDefinitionId: workflowDefinitionId, + WorkflowDefinitionName: workflowDefinition?.Name ?? "Unknown Workflow", + WorkflowVersion: workflowDefinition?.Version + )); + } + } + + return endpoints; + } +}