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:
59
RoslynBridge.WebApi/Services/HistoryService.cs
Normal file
59
RoslynBridge.WebApi/Services/HistoryService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
46
RoslynBridge.WebApi/Services/IHistoryService.cs
Normal file
46
RoslynBridge.WebApi/Services/IHistoryService.cs
Normal 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();
|
||||
}
|
||||
49
RoslynBridge.WebApi/Services/IInstanceRegistryService.cs
Normal file
49
RoslynBridge.WebApi/Services/IInstanceRegistryService.cs
Normal 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);
|
||||
}
|
||||
26
RoslynBridge.WebApi/Services/IRoslynBridgeClient.cs
Normal file
26
RoslynBridge.WebApi/Services/IRoslynBridgeClient.cs
Normal 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);
|
||||
}
|
||||
50
RoslynBridge.WebApi/Services/InstanceCleanupService.cs
Normal file
50
RoslynBridge.WebApi/Services/InstanceCleanupService.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
115
RoslynBridge.WebApi/Services/InstanceRegistryService.cs
Normal file
115
RoslynBridge.WebApi/Services/InstanceRegistryService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
174
RoslynBridge.WebApi/Services/RoslynBridgeClient.cs
Normal file
174
RoslynBridge.WebApi/Services/RoslynBridgeClient.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user