< 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@0d738bf294e6281b936d031e1979d928007495ff
Line coverage
92%
Covered lines: 120
Uncovered lines: 10
Coverable lines: 130
Total lines: 428
Line coverage: 92.3%
Branch coverage
84%
Covered branches: 59
Total branches: 70
Branch coverage: 84.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 08/26/2025 - 14:53:17 Line coverage: 85.5% (118/138) Branch coverage: 68% (34/50) Total lines: 329 Tag: Kestrun/Kestrun@78d1e497d8ba989d121b57aa39aa3c6b22de743112/18/2025 - 21:41:58 Line coverage: 92.3% (120/130) Branch coverage: 84.2% (59/70) Total lines: 428 Tag: Kestrun/Kestrun@0d738bf294e6281b936d031e1979d928007495ff 08/26/2025 - 14:53:17 Line coverage: 85.5% (118/138) Branch coverage: 68% (34/50) Total lines: 329 Tag: Kestrun/Kestrun@78d1e497d8ba989d121b57aa39aa3c6b22de743112/18/2025 - 21:41:58 Line coverage: 92.3% (120/130) Branch coverage: 84.2% (59/70) Total lines: 428 Tag: Kestrun/Kestrun@0d738bf294e6281b936d031e1979d928007495ff

Metrics

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;
 6using Kestrun.Logging;
 7
 8namespace Kestrun.Razor;
 9
 10/// <summary>
 11/// Provides middleware for enabling PowerShell-backed Razor Pages, allowing execution of a sibling PowerShell script (*
 12/// </summary>
 13/// <remarks>
 14/// This middleware allows for dynamic content generation in Razor Pages by leveraging PowerShell scripts.
 15/// Middleware that lets any Razor view (*.cshtml) load a sibling PowerShell
 16/// script (*.cshtml.ps1) in the SAME request.  The script can set `$Model` which
 17/// then becomes available to the Razor page through HttpContext.Items.
 18/// -----------------------------------------------------------------------------
 19///
 20/// Usage (inside KestrunHost.ApplyConfiguration):
 21///     builder.Services.AddRazorPages();                    // already present
 22///     …
 23/// /*  AFTER you build App and create _runspacePool:  */
 24///
 25///     App.UsePowerShellRazorPages(_runspacePool);
 26///
 27/// That’s it – no per-page registration.
 28/// </remarks>
 29public static class PowerShellRazorPage
 30{
 31    private const string MODEL_KEY = "PageModel";
 32
 33    /// <summary>
 34    /// Enables <c>.cshtml</c> + <c>.cshtml.ps1</c> pairs.
 35    /// For a request to <c>/Foo</c> it will, in order:
 36    /// <list type="number">
 37    ///   <item><description>Look for <c>Pages/Foo.cshtml</c></description></item>
 38    ///   <item><description>If a <c>Pages/Foo.cshtml.ps1</c> exists, execute it
 39    ///       in the supplied runspace-pool</description></item>
 40    ///   <item><description>Whatever the script assigns to <c>$Model</c>
 41    ///       is copied to <c>HttpContext.Items["PageModel"]</c></description></item>
 42    /// </list>
 43    /// Razor pages (or a generic <see cref="PwshKestrunModel"/>) can then
 44    /// read that dynamic object.
 45    /// </summary>
 46    /// <param name="app">The <see cref="WebApplication"/> pipeline.</param>
 47    /// <param name="pool">Kestrun’s shared <see cref="KestrunRunspacePoolManager"/>.</param>
 48    /// <param name="rootPath">Optional root path for Razor Pages. Defaults to 'Pages'.</param>
 49    /// <returns><paramref name="app"/> for fluent chaining.</returns>
 50    public static IApplicationBuilder UsePowerShellRazorPages(
 51        this IApplicationBuilder app,
 52        KestrunRunspacePoolManager pool,
 53        string? rootPath = null)
 54    {
 955        ArgumentNullException.ThrowIfNull(app);
 956        ArgumentNullException.ThrowIfNull(pool);
 57        // Log setup
 958        var logger = pool.Host.Logger;
 59        // Check if Debug level is enabled once
 960        var isDebugLevelEnabled = logger.IsEnabled(LogEventLevel.Debug);
 61
 62        // Log middleware configuration
 963        if (isDebugLevelEnabled)
 64        {
 765            logger.Debug("Configuring PowerShell Razor Pages middleware");
 66        }
 67
 968        var env = app.ApplicationServices.GetRequiredService<IHostEnvironment>();
 969        var pagesRoot = string.IsNullOrWhiteSpace(rootPath)
 970            ? Path.Combine(env.ContentRootPath, "Pages")
 971            : rootPath;
 972        logger.Information("Using Pages directory: {Path}", pagesRoot);
 973        if (!Directory.Exists(pagesRoot))
 74        {
 175            logger.Warning("Pages directory not found: {Path}", pagesRoot);
 76        }
 77
 78        // MUST run before MapRazorPages()
 979        _ = app.Use(async (context, next) =>
 980        {
 781            await ProcessPowerShellRazorPageRequestAsync(context, next, pagesRoot, pool, logger, isDebugLevelEnabled).Co
 1682        });
 83
 84        // static files & routing can be added earlier in pipeline
 85
 986        _ = app.UseRouting();
 1887        _ = app.UseEndpoints(e => e.MapRazorPages());
 988        return app;
 89    }
 90
 91    /// <summary>
 92    /// Processes a PowerShell Razor Page request by evaluating path conditions, executing the PowerShell script, and ma
 93    /// </summary>
 94    /// <param name="context">The HTTP context for the current request.</param>
 95    /// <param name="next">The next middleware in the pipeline.</param>
 96    /// <param name="pagesRoot">The root directory for Razor Pages.</param>
 97    /// <param name="pool">The runspace pool manager for PowerShell execution.</param>
 98    /// <param name="logger">The logger instance for diagnostic output.</param>
 99    /// <param name="isDebugLevelEnabled">Flag indicating if debug-level logging is enabled.</param>
 100    /// <returns>A task representing the asynchronous operation.</returns>
 101    private static async Task ProcessPowerShellRazorPageRequestAsync(
 102        HttpContext context, RequestDelegate next, string pagesRoot,
 103        KestrunRunspacePoolManager pool, Serilog.ILogger logger, bool isDebugLevelEnabled)
 104    {
 7105        if (isDebugLevelEnabled)
 106        {
 7107            logger.DebugSanitized("Processing PowerShell Razor Page request for {Path}", context.Request.Path);
 108        }
 109
 7110        var relPath = GetRelativePath(context);
 7111        if (relPath is null)
 112        {
 1113            await next(context);
 1114            return;
 115        }
 116
 6117        var (view, psfile, csfile) = BuildCandidatePaths(pagesRoot, relPath);
 118
 119        // Skip if code-behind or files don't exist
 6120        if (ShouldSkipProcessing(csfile, view, psfile))
 121        {
 2122            await next(context);
 2123            return;
 124        }
 125
 126        // Execute the PowerShell script and handle response
 4127        await ExecutePowerShellRazorPageAsync(context, next, psfile, pool, logger, isDebugLevelEnabled);
 7128    }
 129
 130    /// <summary>
 131    /// Determines whether to skip processing based on code-behind presence or file existence.
 132    /// </summary>
 133    /// <param name="codeBehindFile">The path to the potential C# code-behind file.</param>
 134    /// <param name="viewFile">The path to the Razor view file.</param>
 135    /// <param name="powershellFile">The path to the PowerShell script file.</param>
 136    /// <returns>True if processing should be skipped; otherwise, false.</returns>
 137    private static bool ShouldSkipProcessing(string codeBehindFile, string viewFile, string powershellFile) =>
 6138        HasCodeBehind(codeBehindFile) || !FilesExist(viewFile, powershellFile);
 139
 140    /// <summary>
 141    /// Executes a PowerShell Razor Page script and processes the response.
 142    /// </summary>
 143    /// <param name="context">The HTTP context for the current request.</param>
 144    /// <param name="next">The next middleware in the pipeline.</param>
 145    /// <param name="psFilePath">The path to the PowerShell script file to execute.</param>
 146    /// <param name="pool">The runspace pool manager for PowerShell execution.</param>
 147    /// <param name="logger">The logger instance for diagnostic output.</param>
 148    /// <param name="isDebugLevelEnabled">Flag indicating if debug-level logging is enabled.</param>
 149    /// <returns>A task representing the asynchronous operation.</returns>
 150    private static async Task ExecutePowerShellRazorPageAsync(
 151        HttpContext context, RequestDelegate next, string psFilePath,
 152        KestrunRunspacePoolManager pool, Serilog.ILogger logger, bool isDebugLevelEnabled)
 153    {
 4154        PowerShell? ps = null;
 155        try
 156        {
 4157            ps = CreatePowerShell(pool);
 4158            PrepareSession(ps, context);
 4159            await AddScriptFromFileAsync(ps, psFilePath, context.RequestAborted);
 3160            LogExecution(psFilePath);
 161
 3162            var psResults = await InvokePowerShellWithAbortAsync(ps, context, logger, isDebugLevelEnabled).ConfigureAwai
 3163            if (psResults is null)
 164            {
 0165                return;
 166            }
 167
 168            // Process results and continue pipeline
 3169            LogResultsCount(psResults.Count);
 3170            SetModelIfPresent(ps, context);
 171
 3172            if (context.RequestAborted.IsCancellationRequested)
 173            {
 0174                return;
 175            }
 176
 3177            if (HasErrors(ps))
 178            {
 1179                await HandleErrorsAsync(context, ps);
 1180                return;
 181            }
 182
 2183            LogStreamsIfAny(ps);
 2184            await next(context);
 185
 2186            if (isDebugLevelEnabled)
 187            {
 2188                logger.DebugSanitized("PowerShell Razor Page completed for {Path}", context.Request.Path);
 189            }
 2190        }
 1191        catch (Exception ex)
 192        {
 1193            logger.ErrorSanitized(ex, "Error occurred in PowerShell Razor Page middleware for {Path}", context.Request.P
 1194        }
 195        finally
 196        {
 4197            ReturnRunspaceAndDispose(ps, pool);
 198        }
 4199    }
 200
 201    /// <summary>
 202    /// Invokes a PowerShell instance with request cancellation support.
 203    /// </summary>
 204    /// <param name="ps">The PowerShell instance to invoke.</param>
 205    /// <param name="context">The HTTP context containing the cancellation token.</param>
 206    /// <param name="logger">The logger instance for diagnostic output.</param>
 207    /// <param name="isDebugLevelEnabled">Flag indicating if debug-level logging is enabled.</param>
 208    /// <returns>A collection of PowerShell results, or null if the request was cancelled or an error occurred.</returns
 209    private static async Task<PSDataCollection<PSObject>?> InvokePowerShellWithAbortAsync(
 210        PowerShell ps, HttpContext context, Serilog.ILogger logger, bool isDebugLevelEnabled)
 211    {
 212        try
 213        {
 3214            return await ps.InvokeWithRequestAbortAsync(
 3215                context.RequestAborted,
 0216                onAbortLog: () => logger.DebugSanitized("Request aborted; stopping PowerShell pipeline for {Path}", cont
 3217            ).ConfigureAwait(false);
 218        }
 0219        catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
 220        {
 0221            if (isDebugLevelEnabled)
 222            {
 0223                logger.DebugSanitized("PowerShell pipeline cancelled due to request abortion for {Path}", context.Reques
 224            }
 225            // Client went away; don't try to write an error response.
 0226            return null;
 227        }
 3228    }
 229
 230    /// <summary>
 231    /// Gets the relative path for the PowerShell Razor Page from the HTTP context.
 232    /// </summary>
 233    /// <param name="context">The HTTP context.</param>
 234    /// <returns>The relative path for the PowerShell Razor Page.</returns>
 235    private static string? GetRelativePath(HttpContext context)
 236    {
 7237        var relPath = context.Request.Path.Value?.Trim('/');
 7238        if (string.IsNullOrEmpty(relPath))
 239        {
 1240            if (Log.IsEnabled(LogEventLevel.Debug))
 241            {
 1242                Log.Debug("Request path is empty, skipping PowerShell Razor Page processing");
 243            }
 244
 1245            return null;
 246        }
 6247        relPath = relPath.Replace('/', Path.DirectorySeparatorChar);
 6248        if (Log.IsEnabled(LogEventLevel.Debug))
 249        {
 6250            Log.Debug("Transformed request path to relative: {RelPath}", relPath);
 251        }
 252
 6253        return relPath;
 254    }
 255
 256    /// <summary>
 257    /// Builds the candidate file paths for a PowerShell Razor Page.
 258    /// </summary>
 259    /// <param name="pagesRoot">The root directory for the Razor Pages.</param>
 260    /// <param name="relPath">The relative path for the Razor Page.</param>
 261    /// <returns>The candidate file paths for the Razor Page.</returns>
 262    private static (string view, string psfile, string csfile) BuildCandidatePaths(string pagesRoot, string relPath)
 263    {
 6264        var view = Path.Combine(pagesRoot, relPath + ".cshtml");
 6265        var psfile = view + ".ps1";
 6266        var csfile = view + ".cs";
 6267        return (view, psfile, csfile);
 268    }
 269
 270    /// <summary>
 271    /// Checks if the C# code-behind file exists.
 272    /// </summary>
 273    /// <param name="csfile">The path to the C# code-behind file.</param>
 274    /// <returns>True if the code-behind file exists; otherwise, false.</returns>
 275    private static bool HasCodeBehind(string csfile)
 276    {
 6277        if (File.Exists(csfile))
 278        {
 1279            if (Log.IsEnabled(LogEventLevel.Debug))
 280            {
 1281                Log.Debug("Found C# code-behind file: {CsFile}", csfile);
 282            }
 283
 1284            return true;
 285        }
 5286        return false;
 287    }
 288
 289    /// <summary>
 290    /// Checks if the PowerShell Razor Page files exist.
 291    /// </summary>
 292    /// <param name="view">The path to the Razor view file.</param>
 293    /// <param name="psfile">The path to the PowerShell script file.</param>
 294    /// <returns>True if the files exist; otherwise, false.</returns>
 295    private static bool FilesExist(string view, string psfile)
 296    {
 5297        var ok = File.Exists(view) && File.Exists(psfile);
 5298        if (!ok && Log.IsEnabled(LogEventLevel.Debug))
 299        {
 1300            Log.Debug("PowerShell Razor Page files not found: {View} or {PsFile}", view, psfile);
 301        }
 302
 5303        return ok;
 304    }
 305
 306    /// <summary>
 307    /// Creates a PowerShell instance from the runspace pool.
 308    /// </summary>
 309    /// <param name="pool">The runspace pool manager.</param>
 310    /// <returns>The PowerShell instance.</returns>
 311    private static PowerShell CreatePowerShell(KestrunRunspacePoolManager pool)
 4312        => PowerShell.Create(pool.Acquire());
 313
 314    /// <summary>
 315    /// Prepares the PowerShell session with the HTTP context.
 316    /// </summary>
 317    /// <param name="ps">The PowerShell instance.</param>
 318    /// <param name="context">The HTTP context.</param>
 319    private static void PrepareSession(PowerShell ps, HttpContext context)
 320    {
 4321        var ss = ps.Runspace.SessionStateProxy;
 4322        ss.SetVariable("Context", context);
 4323        ss.SetVariable("Model", null);
 4324    }
 325
 326    /// <summary>
 327    /// Adds a PowerShell script from a file to the PowerShell instance.
 328    /// </summary>
 329    /// <param name="ps">The PowerShell instance.</param>
 330    /// <param name="path">The path to the script file.</param>
 331    /// <param name="token">The cancellation token.</param>
 332    private static async Task AddScriptFromFileAsync(PowerShell ps, string path, CancellationToken token)
 333    {
 4334        var script = await File.ReadAllTextAsync(path, token).ConfigureAwait(false);
 3335        _ = ps.AddScript(script);
 3336    }
 337
 338    /// <summary>
 339    /// Logs the execution of a PowerShell script.
 340    /// </summary>
 341    /// <param name="psfile">The path to the PowerShell script file.</param>
 342    private static void LogExecution(string psfile)
 343    {
 3344        if (Log.IsEnabled(LogEventLevel.Debug))
 345        {
 3346            Log.Debug("Executing PowerShell script: {ScriptFile}", psfile);
 347        }
 3348    }
 349
 350    /// <summary>
 351    /// Logs the count of results returned by the PowerShell script.
 352    /// </summary>
 353    /// <param name="count">The number of results returned by the PowerShell script.</param>
 354    private static void LogResultsCount(int count)
 355    {
 3356        if (Log.IsEnabled(LogEventLevel.Debug))
 357        {
 3358            Log.Debug("PowerShell script returned {Count} results", count);
 359        }
 3360    }
 361
 362    /// <summary>
 363    /// Sets the model in the HttpContext if present in the PowerShell session.
 364    /// </summary>
 365    /// <param name="ps">The PowerShell instance.</param>
 366    /// <param name="context">The HTTP context.</param>
 367    private static void SetModelIfPresent(PowerShell ps, HttpContext context)
 368    {
 3369        var model = ps.Runspace.SessionStateProxy.GetVariable("Model");
 3370        if (model is not null)
 371        {
 2372            context.Items[MODEL_KEY] = model;
 373        }
 374
 3375        if (Log.IsEnabled(LogEventLevel.Debug))
 376        {
 3377            Log.Debug("PowerShell Razor Page model set: {Model}", model);
 378        }
 3379    }
 380
 3381    private static bool HasErrors(PowerShell ps) => ps.HadErrors || ps.Streams.Error.Count != 0;
 382
 383    private static async Task HandleErrorsAsync(HttpContext context, PowerShell ps)
 384    {
 1385        Log.Error("PowerShell script encountered errors: {ErrorCount}", ps.Streams.Error.Count);
 1386        if (Log.IsEnabled(LogEventLevel.Debug))
 387        {
 1388            Log.Debug("PowerShell script errors: {Errors}", BuildError.Text(ps));
 389        }
 390
 1391        await BuildError.ResponseAsync(context, ps);
 1392    }
 393
 394    private static void LogStreamsIfAny(PowerShell ps)
 395    {
 2396        if (ps.Streams.Verbose.Count > 0 || ps.Streams.Debug.Count > 0 || ps.Streams.Warning.Count > 0 || ps.Streams.Inf
 397        {
 0398            Log.Verbose("PowerShell script completed with verbose/debug/warning/info messages.");
 0399            Log.Verbose(BuildError.Text(ps));
 400        }
 2401        else if (Log.IsEnabled(LogEventLevel.Debug))
 402        {
 2403            Log.Debug("PowerShell script completed without errors or messages.");
 404        }
 2405    }
 406
 407    private static void ReturnRunspaceAndDispose(PowerShell? ps, KestrunRunspacePoolManager pool)
 408    {
 4409        if (ps is null)
 410        {
 0411            return;
 412        }
 413
 4414        if (Log.IsEnabled(LogEventLevel.Debug))
 415        {
 4416            Log.Debug("Returning runspace to pool: {RunspaceId}", ps.Runspace.InstanceId);
 417        }
 418
 4419        pool.Release(ps.Runspace);
 4420        if (Log.IsEnabled(LogEventLevel.Debug))
 421        {
 4422            Log.Debug("Disposing PowerShell instance: {InstanceId}", ps.InstanceId);
 423        }
 424
 4425        ps.Dispose();
 4426    }
 427}
 428