< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Razor.PowerShellRazorPage
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Razor/PowerShellRazorPage.cs
Tag: Kestrun/Kestrun@9d3a582b2d63930269564a7591aa77ef297cadeb
Line coverage
85%
Covered lines: 118
Uncovered lines: 20
Coverable lines: 138
Total lines: 329
Line coverage: 85.5%
Branch coverage
68%
Covered branches: 34
Total branches: 50
Branch coverage: 68%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
UsePowerShellRazorPages(...)75%4486.48%
GetRelativePath(...)50%10866.66%
BuildCandidatePaths(...)100%11100%
HasCodeBehind(...)25%7440%
FilesExist(...)50%7675%
CreatePowerShell(...)100%11100%
PrepareSession(...)100%11100%
AddScriptFromFileAsync()100%11100%
LogExecution(...)100%22100%
InvokePowerShellAsync(...)100%11100%
LogResultsCount(...)100%22100%
SetModelIfPresent(...)100%44100%
HasErrors(...)100%22100%
HandleErrorsAsync()100%22100%
LogStreamsIfAny(...)60%141066.66%
ReturnRunspaceAndDispose(...)83.33%6688.88%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Razor/PowerShellRazorPage.cs

#LineLine coverage
 1using System.Management.Automation;
 2using Kestrun.Scripting;
 3using Kestrun.Utilities;
 4using Serilog;
 5using Serilog.Events;
 6
 7namespace Kestrun.Razor;
 8
 9/// <summary>
 10/// Provides middleware for enabling PowerShell-backed Razor Pages, allowing execution of a sibling PowerShell script (*
 11/// </summary>
 12/// <remarks>
 13/// This middleware allows for dynamic content generation in Razor Pages by leveraging PowerShell scripts.
 14/// Middleware that lets any Razor view (*.cshtml) load a sibling PowerShell
 15/// script (*.cshtml.ps1) in the SAME request.  The script can set `$Model` which
 16/// then becomes available to the Razor page through HttpContext.Items.
 17/// -----------------------------------------------------------------------------
 18///
 19/// Usage (inside KestrunHost.ApplyConfiguration):
 20///     builder.Services.AddRazorPages();                    // already present
 21///     …
 22/// /*  AFTER you build App and create _runspacePool:  */
 23///
 24///     App.UsePowerShellRazorPages(_runspacePool);
 25///
 26/// That’s it – no per-page registration.
 27/// </remarks>
 28public static class PowerShellRazorPage
 29{
 30    private const string MODEL_KEY = "PageModel";
 31
 32    /// <summary>
 33    /// Enables <c>.cshtml</c> + <c>.cshtml.ps1</c> pairs.
 34    /// For a request to <c>/Foo</c> it will, in order:
 35    /// <list type="number">
 36    ///   <item><description>Look for <c>Pages/Foo.cshtml</c></description></item>
 37    ///   <item><description>If a <c>Pages/Foo.cshtml.ps1</c> exists, execute it
 38    ///       in the supplied runspace-pool</description></item>
 39    ///   <item><description>Whatever the script assigns to <c>$Model</c>
 40    ///       is copied to <c>HttpContext.Items["PageModel"]</c></description></item>
 41    /// </list>
 42    /// Razor pages (or a generic <see cref="PwshKestrunModel"/>) can then
 43    /// read that dynamic object.
 44    /// </summary>
 45    /// <param name="app">The <see cref="WebApplication"/> pipeline.</param>
 46    /// <param name="pool">Kestrun’s shared <see cref="KestrunRunspacePoolManager"/>.</param>
 47    /// <returns><paramref name="app"/> for fluent chaining.</returns>
 48    public static IApplicationBuilder UsePowerShellRazorPages(
 49        this IApplicationBuilder app,
 50        KestrunRunspacePoolManager pool)
 51    {
 252        ArgumentNullException.ThrowIfNull(app);
 253        ArgumentNullException.ThrowIfNull(pool);
 54
 255        if (Log.IsEnabled(LogEventLevel.Debug))
 56        {
 257            Log.Debug("Configuring PowerShell Razor Pages middleware");
 58        }
 59
 260        var env = app.ApplicationServices.GetRequiredService<IHostEnvironment>();
 261        var pagesRoot = Path.Combine(env.ContentRootPath, "Pages");
 262        Log.Information("Using Pages directory: {Path}", pagesRoot);
 263        if (!Directory.Exists(pagesRoot))
 64        {
 065            Log.Warning("Pages directory not found: {Path}", pagesRoot);
 66        }
 67
 68        // MUST run before MapRazorPages()
 269        _ = app.Use(async (context, next) =>
 270        {
 271            if (Log.IsEnabled(LogEventLevel.Debug))
 272            {
 273                Log.Debug("Processing PowerShell Razor Page request for {Path}", context.Request.Path);
 274            }
 275
 276            var relPath = GetRelativePath(context);
 277            if (relPath is null)
 278            {
 079                await next();
 080                return;
 281            }
 282
 283            var (view, psfile, csfile) = BuildCandidatePaths(pagesRoot, relPath);
 284            if (HasCodeBehind(csfile))
 285            {
 086                await next();
 087                return;
 288            }
 289
 290            if (!FilesExist(view, psfile))
 291            {
 092                await next();
 093                return;
 294            }
 295
 296            PowerShell? ps = null;
 297            try
 298            {
 299                ps = CreatePowerShell(pool);
 2100                PrepareSession(ps, context);
 2101                await AddScriptFromFileAsync(ps, psfile, context.RequestAborted);
 2102                LogExecution(psfile);
 2103                var psResults = await InvokePowerShellAsync(ps).ConfigureAwait(false);
 2104                LogResultsCount(psResults.Count);
 2105
 2106                SetModelIfPresent(ps, context);
 2107
 2108                if (HasErrors(ps))
 2109                {
 1110                    await HandleErrorsAsync(context, ps);
 1111                    return;
 2112                }
 2113
 1114                LogStreamsIfAny(ps);
 2115
 1116                await next(); // continue the pipeline
 1117                if (Log.IsEnabled(LogEventLevel.Debug))
 2118                {
 1119                    Log.Debug("PowerShell Razor Page completed for {Path}", context.Request.Path);
 2120                }
 1121            }
 0122            catch (Exception ex)
 2123            {
 0124                Log.Error(ex, "Error occurred in PowerShell Razor Page middleware for {Path}", context.Request.Path);
 0125            }
 2126            finally
 2127            {
 2128                ReturnRunspaceAndDispose(ps, pool);
 2129            }
 4130        });
 131
 132        // static files & routing can be added earlier in pipeline
 133
 2134        _ = app.UseRouting();
 4135        _ = app.UseEndpoints(e => e.MapRazorPages());
 2136        return app;
 137    }
 138    /// <summary>
 139    /// Gets the relative path for the PowerShell Razor Page from the HTTP context.
 140    /// </summary>
 141    /// <param name="context">The HTTP context.</param>
 142    /// <returns>The relative path for the PowerShell Razor Page.</returns>
 143    private static string? GetRelativePath(HttpContext context)
 144    {
 2145        var relPath = context.Request.Path.Value?.Trim('/');
 2146        if (string.IsNullOrEmpty(relPath))
 147        {
 0148            if (Log.IsEnabled(LogEventLevel.Debug))
 149            {
 0150                Log.Debug("Request path is empty, skipping PowerShell Razor Page processing");
 151            }
 152
 0153            return null;
 154        }
 2155        relPath = relPath.Replace('/', Path.DirectorySeparatorChar);
 2156        if (Log.IsEnabled(LogEventLevel.Debug))
 157        {
 2158            Log.Debug("Transformed request path to relative: {RelPath}", relPath);
 159        }
 160
 2161        return relPath;
 162    }
 163
 164    /// <summary>
 165    /// Builds the candidate file paths for a PowerShell Razor Page.
 166    /// </summary>
 167    /// <param name="pagesRoot">The root directory for the Razor Pages.</param>
 168    /// <param name="relPath">The relative path for the Razor Page.</param>
 169    /// <returns>The candidate file paths for the Razor Page.</returns>
 170    private static (string view, string psfile, string csfile) BuildCandidatePaths(string pagesRoot, string relPath)
 171    {
 2172        var view = Path.Combine(pagesRoot, relPath + ".cshtml");
 2173        var psfile = view + ".ps1";
 2174        var csfile = view + ".cs";
 2175        return (view, psfile, csfile);
 176    }
 177
 178    /// <summary>
 179    /// Checks if the C# code-behind file exists.
 180    /// </summary>
 181    /// <param name="csfile">The path to the C# code-behind file.</param>
 182    /// <returns>True if the code-behind file exists; otherwise, false.</returns>
 183    private static bool HasCodeBehind(string csfile)
 184    {
 2185        if (File.Exists(csfile))
 186        {
 0187            if (Log.IsEnabled(LogEventLevel.Debug))
 188            {
 0189                Log.Debug("Found C# code-behind file: {CsFile}", csfile);
 190            }
 191
 0192            return true;
 193        }
 2194        return false;
 195    }
 196
 197    /// <summary>
 198    /// Checks if the PowerShell Razor Page files exist.
 199    /// </summary>
 200    /// <param name="view">The path to the Razor view file.</param>
 201    /// <param name="psfile">The path to the PowerShell script file.</param>
 202    /// <returns>True if the files exist; otherwise, false.</returns>
 203    private static bool FilesExist(string view, string psfile)
 204    {
 2205        var ok = File.Exists(view) && File.Exists(psfile);
 2206        if (!ok && Log.IsEnabled(LogEventLevel.Debug))
 207        {
 0208            Log.Debug("PowerShell Razor Page files not found: {View} or {PsFile}", view, psfile);
 209        }
 210
 2211        return ok;
 212    }
 213
 214    /// <summary>
 215    /// Creates a PowerShell instance from the runspace pool.
 216    /// </summary>
 217    /// <param name="pool">The runspace pool manager.</param>
 218    /// <returns>The PowerShell instance.</returns>
 219    private static PowerShell CreatePowerShell(KestrunRunspacePoolManager pool)
 2220        => PowerShell.Create(pool.Acquire());
 221
 222    /// <summary>
 223    /// Prepares the PowerShell session with the HTTP context.
 224    /// </summary>
 225    /// <param name="ps">The PowerShell instance.</param>
 226    /// <param name="context">The HTTP context.</param>
 227    private static void PrepareSession(PowerShell ps, HttpContext context)
 228    {
 2229        var ss = ps.Runspace.SessionStateProxy;
 2230        ss.SetVariable("Context", context);
 2231        ss.SetVariable("Model", null);
 2232    }
 233
 234    /// <summary>
 235    /// Adds a PowerShell script from a file to the PowerShell instance.
 236    /// </summary>
 237    /// <param name="ps">The PowerShell instance.</param>
 238    /// <param name="path">The path to the script file.</param>
 239    /// <param name="token">The cancellation token.</param>
 240    private static async Task AddScriptFromFileAsync(PowerShell ps, string path, CancellationToken token)
 241    {
 2242        var script = await File.ReadAllTextAsync(path, token).ConfigureAwait(false);
 2243        _ = ps.AddScript(script);
 2244    }
 245
 246    /// <summary>
 247    /// Logs the execution of a PowerShell script.
 248    /// </summary>
 249    /// <param name="psfile">The path to the PowerShell script file.</param>
 250    private static void LogExecution(string psfile)
 251    {
 2252        if (Log.IsEnabled(LogEventLevel.Debug))
 253        {
 2254            Log.Debug("Executing PowerShell script: {ScriptFile}", psfile);
 255        }
 2256    }
 257
 258    private static Task<PSDataCollection<PSObject>> InvokePowerShellAsync(PowerShell ps)
 2259        => ps.InvokeAsync();
 260
 261    private static void LogResultsCount(int count)
 262    {
 2263        if (Log.IsEnabled(LogEventLevel.Debug))
 264        {
 2265            Log.Debug("PowerShell script returned {Count} results", count);
 266        }
 2267    }
 268
 269    private static void SetModelIfPresent(PowerShell ps, HttpContext context)
 270    {
 2271        var model = ps.Runspace.SessionStateProxy.GetVariable("Model");
 2272        if (model is not null)
 273        {
 1274            context.Items[MODEL_KEY] = model;
 275        }
 276
 2277        if (Log.IsEnabled(LogEventLevel.Debug))
 278        {
 2279            Log.Debug("PowerShell Razor Page model set: {Model}", model);
 280        }
 2281    }
 282
 2283    private static bool HasErrors(PowerShell ps) => ps.HadErrors || ps.Streams.Error.Count != 0;
 284
 285    private static async Task HandleErrorsAsync(HttpContext context, PowerShell ps)
 286    {
 1287        Log.Error("PowerShell script encountered errors: {ErrorCount}", ps.Streams.Error.Count);
 1288        if (Log.IsEnabled(LogEventLevel.Debug))
 289        {
 1290            Log.Debug("PowerShell script errors: {Errors}", BuildError.Text(ps));
 291        }
 292
 1293        await BuildError.ResponseAsync(context, ps);
 1294    }
 295
 296    private static void LogStreamsIfAny(PowerShell ps)
 297    {
 1298        if (ps.Streams.Verbose.Count > 0 || ps.Streams.Debug.Count > 0 || ps.Streams.Warning.Count > 0 || ps.Streams.Inf
 299        {
 0300            Log.Verbose("PowerShell script completed with verbose/debug/warning/info messages.");
 0301            Log.Verbose(BuildError.Text(ps));
 302        }
 1303        else if (Log.IsEnabled(LogEventLevel.Debug))
 304        {
 1305            Log.Debug("PowerShell script completed without errors or messages.");
 306        }
 1307    }
 308
 309    private static void ReturnRunspaceAndDispose(PowerShell? ps, KestrunRunspacePoolManager pool)
 310    {
 2311        if (ps is null)
 312        {
 0313            return;
 314        }
 315
 2316        if (Log.IsEnabled(LogEventLevel.Debug))
 317        {
 2318            Log.Debug("Returning runspace to pool: {RunspaceId}", ps.Runspace.InstanceId);
 319        }
 320
 2321        pool.Release(ps.Runspace);
 2322        if (Log.IsEnabled(LogEventLevel.Debug))
 323        {
 2324            Log.Debug("Disposing PowerShell instance: {InstanceId}", ps.InstanceId);
 325        }
 326
 2327        ps.Dispose();
 2328    }
 329}