Add WebAPI middleware for multi-instance Roslyn Bridge routing

Add RoslynBridge.WebApi - ASP.NET Core 8.0 middleware that:
- Provides a centralized REST API for accessing multiple VS instances
- Manages instance registry with discovery by port, solution, or PID
- Proxies requests to the appropriate VS instance
- Tracks request/response history for debugging
- Auto-cleanup of stale instances via background service

Features:
- Health endpoints: /api/health, /api/health/ping
- Roslyn endpoints: /api/roslyn/projects, /api/roslyn/diagnostics, etc.
- Instance management: /api/instances (register, heartbeat, unregister)
- History tracking: /api/history, /api/history/stats
- Swagger UI at root (/) for API documentation
- CORS enabled for web applications

Services:
- InstanceRegistryService: Thread-safe registry of VS instances
- HistoryService: In-memory request/response history (max 1000 entries)
- InstanceCleanupService: Background service to remove stale instances
- RoslynBridgeClient: HTTP client for proxying to VS instances

Update RoslynBridge.sln to include RoslynBridge.WebApi project.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-26 23:51:33 -04:00
parent 716827a665
commit 1cbfba3893
76 changed files with 4322 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
using System.Collections.Concurrent;
using RoslynBridge.WebApi.Models;
namespace RoslynBridge.WebApi.Services;
/// <summary>
/// In-memory implementation of history service
/// </summary>
public class HistoryService : IHistoryService
{
private readonly ConcurrentQueue<QueryHistoryEntry> _entries = new();
private readonly ILogger<HistoryService> _logger;
private readonly int _maxEntries;
public HistoryService(ILogger<HistoryService> logger, IConfiguration configuration)
{
_logger = logger;
_maxEntries = configuration.GetValue<int>("History:MaxEntries", 1000);
}
public void Add(QueryHistoryEntry entry)
{
_entries.Enqueue(entry);
// Trim old entries if we exceed max
while (_entries.Count > _maxEntries)
{
_entries.TryDequeue(out _);
}
_logger.LogDebug("Added history entry: {Id} - {Path}", entry.Id, entry.Path);
}
public IEnumerable<QueryHistoryEntry> GetAll()
{
return _entries.Reverse();
}
public QueryHistoryEntry? GetById(string id)
{
return _entries.FirstOrDefault(e => e.Id == id);
}
public IEnumerable<QueryHistoryEntry> GetRecent(int count = 50)
{
return _entries.Reverse().Take(count);
}
public void Clear()
{
_entries.Clear();
_logger.LogInformation("History cleared");
}
public int GetCount()
{
return _entries.Count;
}
}

View File

@@ -0,0 +1,46 @@
using RoslynBridge.WebApi.Models;
namespace RoslynBridge.WebApi.Services;
/// <summary>
/// Service for managing query history
/// </summary>
public interface IHistoryService
{
/// <summary>
/// Add a new history entry
/// </summary>
/// <param name="entry">The history entry to add</param>
void Add(QueryHistoryEntry entry);
/// <summary>
/// Get all history entries
/// </summary>
/// <returns>List of all history entries</returns>
IEnumerable<QueryHistoryEntry> GetAll();
/// <summary>
/// Get a specific history entry by ID
/// </summary>
/// <param name="id">The entry ID</param>
/// <returns>The history entry, or null if not found</returns>
QueryHistoryEntry? GetById(string id);
/// <summary>
/// Get recent history entries
/// </summary>
/// <param name="count">Number of entries to return</param>
/// <returns>List of recent history entries</returns>
IEnumerable<QueryHistoryEntry> GetRecent(int count = 50);
/// <summary>
/// Clear all history entries
/// </summary>
void Clear();
/// <summary>
/// Get total count of history entries
/// </summary>
/// <returns>Total number of entries</returns>
int GetCount();
}

View File

@@ -0,0 +1,49 @@
using RoslynBridge.WebApi.Models;
namespace RoslynBridge.WebApi.Services;
/// <summary>
/// Service for managing registered Visual Studio instances
/// </summary>
public interface IInstanceRegistryService
{
/// <summary>
/// Register a new Visual Studio instance
/// </summary>
void Register(VSInstanceInfo instance);
/// <summary>
/// Unregister a Visual Studio instance by process ID
/// </summary>
bool Unregister(int processId);
/// <summary>
/// Update heartbeat for an instance
/// </summary>
bool UpdateHeartbeat(int processId);
/// <summary>
/// Get all registered instances
/// </summary>
IEnumerable<VSInstanceInfo> GetAllInstances();
/// <summary>
/// Get instance by process ID
/// </summary>
VSInstanceInfo? GetByProcessId(int processId);
/// <summary>
/// Get instance by solution path
/// </summary>
VSInstanceInfo? GetBySolutionPath(string solutionPath);
/// <summary>
/// Get instance by port
/// </summary>
VSInstanceInfo? GetByPort(int port);
/// <summary>
/// Remove stale instances (no heartbeat for specified timeout)
/// </summary>
void RemoveStaleInstances(TimeSpan timeout);
}

View File

@@ -0,0 +1,26 @@
using RoslynBridge.WebApi.Models;
namespace RoslynBridge.WebApi.Services;
/// <summary>
/// Interface for communicating with the Roslyn Bridge Visual Studio plugin
/// </summary>
public interface IRoslynBridgeClient
{
/// <summary>
/// Execute a query against the Roslyn Bridge server
/// </summary>
/// <param name="request">The query request</param>
/// <param name="instancePort">Optional port of specific VS instance to target</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The query response</returns>
Task<RoslynQueryResponse> ExecuteQueryAsync(RoslynQueryRequest request, int? instancePort = null, CancellationToken cancellationToken = default);
/// <summary>
/// Check if the Roslyn Bridge server is healthy
/// </summary>
/// <param name="instancePort">Optional port of specific VS instance to check</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True if healthy, false otherwise</returns>
Task<bool> IsHealthyAsync(int? instancePort = null, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,50 @@
namespace RoslynBridge.WebApi.Services;
/// <summary>
/// Background service that periodically removes stale VS instances
/// </summary>
public class InstanceCleanupService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<InstanceCleanupService> _logger;
private readonly TimeSpan _cleanupInterval = TimeSpan.FromMinutes(1);
private readonly TimeSpan _staleTimeout = TimeSpan.FromMinutes(5);
public InstanceCleanupService(
IServiceProvider serviceProvider,
ILogger<InstanceCleanupService> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Instance cleanup service started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(_cleanupInterval, stoppingToken);
// Get the registry service from scope
using var scope = _serviceProvider.CreateScope();
var registryService = scope.ServiceProvider.GetRequiredService<IInstanceRegistryService>();
registryService.RemoveStaleInstances(_staleTimeout);
}
catch (OperationCanceledException)
{
// Expected when stopping
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during instance cleanup");
}
}
_logger.LogInformation("Instance cleanup service stopped");
}
}

View File

@@ -0,0 +1,115 @@
using System.Collections.Concurrent;
using RoslynBridge.WebApi.Models;
namespace RoslynBridge.WebApi.Services;
/// <summary>
/// Thread-safe in-memory registry for Visual Studio instances
/// </summary>
public class InstanceRegistryService : IInstanceRegistryService
{
private readonly ConcurrentDictionary<int, VSInstanceInfo> _instances = new();
private readonly ILogger<InstanceRegistryService> _logger;
public InstanceRegistryService(ILogger<InstanceRegistryService> logger)
{
_logger = logger;
}
public void Register(VSInstanceInfo instance)
{
instance.RegisteredAt = DateTime.UtcNow;
instance.LastHeartbeat = DateTime.UtcNow;
_instances.AddOrUpdate(instance.ProcessId, instance, (_, existing) =>
{
// Update existing instance
existing.Port = instance.Port;
existing.SolutionPath = instance.SolutionPath;
existing.SolutionName = instance.SolutionName;
existing.Projects = instance.Projects;
existing.LastHeartbeat = DateTime.UtcNow;
return existing;
});
_logger.LogInformation(
"Registered VS instance: PID={ProcessId}, Port={Port}, Solution={Solution}",
instance.ProcessId,
instance.Port,
instance.SolutionName ?? "None");
}
public bool Unregister(int processId)
{
var removed = _instances.TryRemove(processId, out var instance);
if (removed)
{
_logger.LogInformation(
"Unregistered VS instance: PID={ProcessId}, Solution={Solution}",
processId,
instance?.SolutionName ?? "None");
}
return removed;
}
public bool UpdateHeartbeat(int processId)
{
if (_instances.TryGetValue(processId, out var instance))
{
instance.LastHeartbeat = DateTime.UtcNow;
_logger.LogDebug("Updated heartbeat for VS instance: PID={ProcessId}", processId);
return true;
}
return false;
}
public IEnumerable<VSInstanceInfo> GetAllInstances()
{
return _instances.Values.ToList();
}
public VSInstanceInfo? GetByProcessId(int processId)
{
_instances.TryGetValue(processId, out var instance);
return instance;
}
public VSInstanceInfo? GetBySolutionPath(string solutionPath)
{
if (string.IsNullOrEmpty(solutionPath))
return null;
var normalizedPath = Path.GetFullPath(solutionPath).ToLowerInvariant();
return _instances.Values.FirstOrDefault(i =>
!string.IsNullOrEmpty(i.SolutionPath) &&
Path.GetFullPath(i.SolutionPath).ToLowerInvariant() == normalizedPath);
}
public VSInstanceInfo? GetByPort(int port)
{
return _instances.Values.FirstOrDefault(i => i.Port == port);
}
public void RemoveStaleInstances(TimeSpan timeout)
{
var cutoff = DateTime.UtcNow - timeout;
var staleInstances = _instances.Values
.Where(i => i.LastHeartbeat < cutoff)
.ToList();
foreach (var instance in staleInstances)
{
if (_instances.TryRemove(instance.ProcessId, out _))
{
_logger.LogWarning(
"Removed stale VS instance: PID={ProcessId}, LastHeartbeat={LastHeartbeat}",
instance.ProcessId,
instance.LastHeartbeat);
}
}
}
}

View File

@@ -0,0 +1,174 @@
using System.Text;
using System.Text.Json;
using RoslynBridge.WebApi.Models;
namespace RoslynBridge.WebApi.Services;
/// <summary>
/// HTTP client for communicating with the Roslyn Bridge Visual Studio plugin
/// </summary>
public class RoslynBridgeClient : IRoslynBridgeClient
{
private readonly HttpClient _httpClient;
private readonly IInstanceRegistryService _registryService;
private readonly ILogger<RoslynBridgeClient> _logger;
private readonly JsonSerializerOptions _jsonOptions;
public RoslynBridgeClient(
HttpClient httpClient,
IInstanceRegistryService registryService,
ILogger<RoslynBridgeClient> logger)
{
_httpClient = httpClient;
_registryService = registryService;
_logger = logger;
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
}
public async Task<RoslynQueryResponse> ExecuteQueryAsync(
RoslynQueryRequest request,
int? instancePort = null,
CancellationToken cancellationToken = default)
{
try
{
var targetPort = await ResolveInstancePortAsync(instancePort, request);
if (targetPort == null)
{
return new RoslynQueryResponse
{
Success = false,
Error = "No Visual Studio instance available"
};
}
_logger.LogInformation("Executing query: {QueryType} on port {Port}", request.QueryType, targetPort);
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var url = $"http://localhost:{targetPort}/query";
var response = await _httpClient.PostAsync(url, content, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogError("Query failed with status {StatusCode}: {Error}", response.StatusCode, errorContent);
return new RoslynQueryResponse
{
Success = false,
Error = $"Request failed with status {response.StatusCode}: {errorContent}"
};
}
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonSerializer.Deserialize<RoslynQueryResponse>(responseContent, _jsonOptions);
if (result == null)
{
return new RoslynQueryResponse
{
Success = false,
Error = "Failed to deserialize response"
};
}
_logger.LogInformation("Query executed successfully: {Success}", result.Success);
return result;
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "HTTP request failed while executing query");
return new RoslynQueryResponse
{
Success = false,
Error = $"Failed to connect to Roslyn Bridge server: {ex.Message}"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error while executing query");
return new RoslynQueryResponse
{
Success = false,
Error = $"Unexpected error: {ex.Message}"
};
}
}
public async Task<bool> IsHealthyAsync(int? instancePort = null, CancellationToken cancellationToken = default)
{
try
{
var targetPort = await ResolveInstancePortAsync(instancePort, null);
if (targetPort == null)
{
return false;
}
var request = new RoslynQueryRequest { QueryType = "health" };
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var url = $"http://localhost:{targetPort}/health";
var response = await _httpClient.PostAsync(url, content, cancellationToken);
return response.IsSuccessStatusCode;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Health check failed");
return false;
}
}
/// <summary>
/// Resolves which VS instance port to use based on provided hints
/// </summary>
private Task<int?> ResolveInstancePortAsync(int? explicitPort, RoslynQueryRequest? request)
{
// If explicit port specified, use it
if (explicitPort.HasValue)
{
return Task.FromResult<int?>(explicitPort.Value);
}
// Try to find instance by solution path from request
if (request != null && !string.IsNullOrEmpty(request.FilePath))
{
// Extract solution path by looking for .sln file in the path hierarchy
var directory = Path.GetDirectoryName(request.FilePath);
while (!string.IsNullOrEmpty(directory))
{
var solutionFiles = Directory.GetFiles(directory, "*.sln");
if (solutionFiles.Length > 0)
{
var instance = _registryService.GetBySolutionPath(solutionFiles[0]);
if (instance != null)
{
_logger.LogDebug("Found instance by solution path: {SolutionPath}", solutionFiles[0]);
return Task.FromResult<int?>(instance.Port);
}
}
directory = Path.GetDirectoryName(directory);
}
}
// Fall back to first available instance
var instances = _registryService.GetAllInstances().ToList();
if (instances.Any())
{
_logger.LogDebug("Using first available instance: port {Port}", instances[0].Port);
return Task.FromResult<int?>(instances[0].Port);
}
_logger.LogWarning("No Visual Studio instances registered");
return Task.FromResult<int?>(null);
}
}