diff --git a/doc/api-reference.md b/doc/api-reference.md index ce32b2a..e3ed968 100644 --- a/doc/api-reference.md +++ b/doc/api-reference.md @@ -454,22 +454,21 @@ builder.Services.AddNLWebNet(); // Map endpoints app.MapNLWebNet(); -// Use in controller/service -public class MyController : ControllerBase +// Use in service or endpoint +public class MyService { private readonly INLWebService _nlweb; - public MyController(INLWebService nlweb) + public MyService(INLWebService nlweb) { _nlweb = nlweb; } - [HttpGet] - public async Task Search(string query) + public async Task SearchAsync(string query) { var request = new NLWebRequest { Query = query }; - var response = await _nlweb.ProcessQueryAsync(request); - return Ok(response); + var response = await _nlweb.ProcessRequestAsync(request); + return response; } } ``` diff --git a/doc/development-guide.md b/doc/development-guide.md index 72474da..46a833e 100644 --- a/doc/development-guide.md +++ b/doc/development-guide.md @@ -31,10 +31,9 @@ This document provides comprehensive guidance for developers working with the NL ### 1. Minimal API Approach - **Primary approach**: Uses **Minimal APIs** for modern, lightweight endpoints -- **Legacy support**: Traditional controllers still present but being phased out - Endpoints are organized in static classes: `AskEndpoints`, `McpEndpoints` - Extension methods provide clean endpoint mapping: `app.MapNLWebNet()` -- Both `app.MapNLWebNet()` (minimal APIs) and `app.MapNLWebNetControllers()` (legacy) available +- Modern .NET 9 features including TypedResults for improved type safety ### 2. Dependency Injection @@ -46,8 +45,8 @@ This document provides comprehensive guidance for developers working with the NL ### 3. Service Layer Architecture ```text -Controllers/Endpoints → Services → Data Backends - ↘ MCP Integration +Endpoints → Services → Data Backends + ↘ MCP Integration ``` Key interfaces: @@ -65,14 +64,13 @@ Key interfaces: - User secrets for sensitive data (API keys) - Environment-specific configurations -### 5. Architectural Migration +### 5. Modern Architecture -**Important**: The project is transitioning from traditional MVC controllers to Minimal APIs. +The project uses **Minimal APIs exclusively** for a modern, lightweight approach. -- **Preferred**: Use `Endpoints/` classes with static mapping methods -- **Legacy**: `Controllers/` still exists but should not be extended -- **Extension methods**: Both approaches supported via `MapNLWebNet()` (minimal APIs) and `MapNLWebNetControllers()` (legacy) -- **New development**: Always use Minimal API patterns in the `Endpoints/` directory +- **Current**: Uses `Endpoints/` classes with static mapping methods and TypedResults +- **Extension methods**: Clean API surface via `MapNLWebNet()` for minimal APIs +- **Best practices**: .NET 9 features including TypedResults for type safety ## Code Conventions @@ -82,8 +80,7 @@ Key interfaces: - **Models**: Request/response DTOs with JSON serialization attributes - **Services**: Interface + implementation pattern (`IService` → `Service`) - **Extensions**: Static extension classes for framework integration -- **Endpoints**: Static classes with minimal API mapping methods (preferred) -- **Controllers**: Traditional MVC controllers (legacy, being phased out) +- **Endpoints**: Static classes with minimal API mapping methods ### C# Style Guidelines @@ -99,9 +96,8 @@ Key interfaces: src/NLWebNet/ ├── Models/ # Request/response DTOs ├── Services/ # Business logic interfaces/implementations -├── Endpoints/ # Minimal API endpoint definitions (preferred) +├── Endpoints/ # Minimal API endpoint definitions with TypedResults ├── Extensions/ # DI and middleware extensions -├── Controllers/ # Legacy MVC controllers (being phased out) ├── MCP/ # Model Context Protocol integration └── Middleware/ # Custom middleware components ``` @@ -203,7 +199,7 @@ src/NLWebNet/ ### When Modifying Endpoints -1. **Use Minimal APIs** - Prefer `Endpoints/` classes over `Controllers/` +1. **Use Minimal APIs** - All endpoints use the modern `Endpoints/` classes with TypedResults 2. **Maintain protocol compliance** - Follow NLWeb specification 3. **Add OpenAPI documentation** - Use `.WithSummary()` and `.WithDescription()` 4. **Include error responses** - Proper status codes and problem details diff --git a/doc/todo.md b/doc/todo.md index dbcaa99..9f6866d 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -57,7 +57,7 @@ The NLWebNet library is now fully functional and feature complete with a modern - ✅ **API Testing**: Comprehensive interface for testing all NLWeb endpoints - ✅ **Modern UI**: Bootstrap-based responsive design with FontAwesome icons - ✅ **Real-time Features**: Streaming demonstrations and live response displays -- ✅ **Minimal API Endpoints**: Converted Controllers to modern Minimal API endpoints with route groups and OpenAPI support +- ✅ **Minimal API Endpoints**: Modern Minimal API endpoints with TypedResults, route groups and OpenAPI support (legacy controllers completely removed) - ✅ **Extension Methods**: Added `MapNLWebNet()` extension method for easy endpoint mapping in consuming applications - ✅ **Dependency Injection**: Created `AddNLWebNet()` extension method with options configuration - ✅ **GET/POST Support**: Both endpoints support GET and POST with appropriate parameter binding @@ -73,7 +73,7 @@ The NLWebNet library is now fully functional and feature complete with a modern - ✅ **Testing Framework**: Using MSTest 3.9.3 with NSubstitute 5.3.0 for comprehensive unit testing - ✅ **Production Ready**: All builds (Debug/Release) work correctly, with properly configured NuGet packaging -**Phases 1-11 are now complete.** The library provides a complete implementation of the NLWeb protocol with both traditional controller-based endpoints (legacy) and modern minimal API endpoints for improved performance and maintainability. The project includes comprehensive configuration management, CORS support, extensive testing infrastructure, and complete documentation for real AI integration. +**Phases 1-11 are now complete.** The library provides a complete implementation of the NLWeb protocol using modern minimal API endpoints exclusively, fully migrated from legacy controllers. Features include improved performance and maintainability with .NET 9 TypedResults, comprehensive configuration management, CORS support, extensive testing infrastructure, and complete documentation for real AI integration. **✅ MAJOR BREAKTHROUGH: NuGet Package PUBLISHED SUCCESSFULLY** - The NLWebNet package is now live on NuGet.org at ! The package is fully functional with working extension methods accessible via `using NLWebNet;`. End-to-end testing confirms that consumer applications can successfully install the package, use the `AddNLWebNet()` and `MapNLWebNet()` extension methods, and run working HTTP servers. @@ -278,21 +278,24 @@ All Phase 5 objectives have been completed successfully, initially using the tra ### Phase 6.5: Minimal API Migration (Completed) - [x] **Convert Controllers to Minimal API Endpoints**: - - [x] Created `/src/NLWebNet/Endpoints/AskEndpoints.cs` with static endpoint methods - - [x] Created `/src/NLWebNet/Endpoints/McpEndpoints.cs` with static endpoint methods - - [x] Updated `ApplicationBuilderExtensions.MapNLWebNet()` to use endpoint mapping + - [x] Created `/src/NLWebNet/Endpoints/AskEndpoints.cs` with static endpoint methods and TypedResults + - [x] Created `/src/NLWebNet/Endpoints/McpEndpoints.cs` with static endpoint methods and TypedResults + - [x] Updated `ApplicationBuilderExtensions.MapNLWebNet()` to use endpoint mapping exclusively - [x] Maintained feature parity with existing controller functionality for `/ask` endpoints - [x] Implemented and enabled `/mcp` endpoints with full functionality + - [x] **REMOVED**: All legacy controller code (`AskController.cs`, `McpController.cs`) + - [x] **CLEANED**: Removed controller dependencies from DI registration - [x] **Testing and Validation**: - [x] Tested GET and POST endpoints for `/ask` with successful results - [x] Fixed logger DI for minimal APIs by using ILoggerFactory - [x] Fixed parameter binding and routing for minimal APIs - [x] Added [FromServices] attributes to McpEndpoints parameters for proper DI - [x] Complete test migration from controller tests to endpoint tests + - [x] **REMOVED**: Legacy controller test files -**Current Status**: Minimal API migration is complete, with all endpoints successfully implemented and tested. Both the `/ask` and `/mcp` endpoints (GET and POST) are fully functional and have been verified with test requests. The library builds successfully and can be consumed by applications with a clean, modern API. The migration to ILoggerFactory provides proper logging support in all endpoint methods. +**Current Status**: Complete migration to Minimal APIs with full removal of legacy controller code. All endpoints use modern .NET 9 patterns including TypedResults for better type safety. The library is now exclusively using Minimal APIs with improved performance and maintainability. -**Benefits**: More modern approach, better performance, cleaner API surface, improved compatibility with .NET 9 and future versions, and enhanced developer experience through fluent endpoint definitions. +**Benefits**: Modern .NET 9 approach with TypedResults, better performance, cleaner API surface, improved compatibility, enhanced developer experience, and complete removal of legacy code. ### Phase 7: Demo Application Development diff --git a/src/NLWebNet/Controllers/AskController.cs b/src/NLWebNet/Controllers/AskController.cs deleted file mode 100644 index 59b737f..0000000 --- a/src/NLWebNet/Controllers/AskController.cs +++ /dev/null @@ -1,209 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using NLWebNet.Models; -using NLWebNet.Services; -using System.ComponentModel.DataAnnotations; -using System.Text.Json; - -namespace NLWebNet.Controllers; - -/// -/// Controller for the NLWeb /ask endpoint. -/// Implements the core NLWeb protocol for natural language queries. -/// -[ApiController] -[Route("ask")] -[Produces("application/json")] -public class AskController : ControllerBase -{ - private readonly INLWebService _nlWebService; - private readonly ILogger _logger; - - public AskController(INLWebService nlWebService, ILogger logger) - { - _nlWebService = nlWebService ?? throw new ArgumentNullException(nameof(nlWebService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// Process a natural language query using the NLWeb protocol. - /// Supports all three query modes: list, summarize, and generate. - /// - /// The NLWeb request containing the query and options - /// Cancellation token - /// NLWeb response with results - [HttpPost] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task ProcessQuery( - [FromBody] NLWebRequest request, - CancellationToken cancellationToken = default) - { - try - { - // Validate the request - if (request == null) - { - _logger.LogWarning("Received null request"); - return BadRequest(new ProblemDetails - { - Title = "Invalid Request", - Detail = "Request body is required", - Status = StatusCodes.Status400BadRequest - }); - } - - if (string.IsNullOrWhiteSpace(request.Query)) - { - _logger.LogWarning("Received request with empty query"); - return BadRequest(new ProblemDetails - { - Title = "Invalid Query", - Detail = "Query parameter is required and cannot be empty", - Status = StatusCodes.Status400BadRequest - }); - } - - // Generate query ID if not provided - if (string.IsNullOrEmpty(request.QueryId)) - { - request.QueryId = Guid.NewGuid().ToString(); - _logger.LogDebug("Generated query ID: {QueryId}", request.QueryId); - } - - _logger.LogInformation("Processing NLWeb query: {QueryId}, Mode: {Mode}, Query: {Query}", - request.QueryId, request.Mode, request.Query); - - // Check if streaming is requested - if (request.Streaming == true) - { - return await ProcessStreamingQuery(request, cancellationToken); - } - - // Process non-streaming query - var response = await _nlWebService.ProcessRequestAsync(request, cancellationToken); - - _logger.LogInformation("Successfully processed query {QueryId} with {ResultCount} results", - response.QueryId, response.Results?.Count ?? 0); - - return Ok(response); - } - catch (ValidationException ex) - { - _logger.LogWarning(ex, "Validation error for query {QueryId}: {Message}", - request?.QueryId, ex.Message); - - return BadRequest(new ProblemDetails - { - Title = "Validation Error", - Detail = ex.Message, - Status = StatusCodes.Status400BadRequest - }); - } - catch (OperationCanceledException) - { - _logger.LogInformation("Query {QueryId} was cancelled", request?.QueryId); - return StatusCode(StatusCodes.Status499ClientClosedRequest); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing query {QueryId}: {Message}", - request?.QueryId, ex.Message); - - return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails - { - Title = "Internal Server Error", - Detail = "An error occurred while processing your request", - Status = StatusCodes.Status500InternalServerError - }); - } - } - - /// - /// Process a natural language query via GET request with query parameters. - /// This provides a simple interface for basic queries. - /// - /// The natural language query - /// Query mode (list, summarize, generate) - /// Site filter (optional) - /// Enable streaming responses (default: true) - /// Cancellation token - /// NLWeb response with results - [HttpGet] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - public async Task ProcessQueryGet( - [FromQuery, Required] string query, - [FromQuery] QueryMode mode = QueryMode.List, - [FromQuery] string? site = null, - [FromQuery] bool streaming = true, - CancellationToken cancellationToken = default) - { - var request = new NLWebRequest - { - Query = query, - Mode = mode, - Site = site, - Streaming = streaming, - QueryId = Guid.NewGuid().ToString() - }; - - return await ProcessQuery(request, cancellationToken); - } - - /// - /// Process a streaming query using Server-Sent Events. - /// - private async Task ProcessStreamingQuery( - NLWebRequest request, - CancellationToken cancellationToken) - { - _logger.LogDebug("Starting streaming response for query {QueryId}", request.QueryId); - - // Set SSE headers - Response.Headers.Append("Content-Type", "text/event-stream"); - Response.Headers.Append("Cache-Control", "no-cache"); - Response.Headers.Append("Connection", "keep-alive"); - Response.Headers.Append("Access-Control-Allow-Origin", "*"); - - try - { - await foreach (var response in _nlWebService.ProcessRequestStreamAsync(request, cancellationToken)) - { - var json = JsonSerializer.Serialize(response, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }); - - await Response.WriteAsync($"data: {json}\n\n", cancellationToken); - await Response.Body.FlushAsync(cancellationToken); - - _logger.LogDebug("Sent streaming chunk for query {QueryId}", request.QueryId); - } - - // Send end-of-stream marker - await Response.WriteAsync("data: [DONE]\n\n", cancellationToken); - await Response.Body.FlushAsync(cancellationToken); - - _logger.LogInformation("Completed streaming response for query {QueryId}", request.QueryId); - } - catch (OperationCanceledException) - { - _logger.LogInformation("Streaming query {QueryId} was cancelled", request.QueryId); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error during streaming for query {QueryId}: {Message}", - request.QueryId, ex.Message); - - // Send error as SSE - var errorResponse = new { error = "An error occurred during streaming" }; - var errorJson = JsonSerializer.Serialize(errorResponse); - await Response.WriteAsync($"data: {errorJson}\n\n", cancellationToken); - } - - return new EmptyResult(); - } -} diff --git a/src/NLWebNet/Controllers/McpController.cs b/src/NLWebNet/Controllers/McpController.cs deleted file mode 100644 index d5f64c6..0000000 --- a/src/NLWebNet/Controllers/McpController.cs +++ /dev/null @@ -1,342 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using NLWebNet.MCP; -using NLWebNet.Models; -using System.Text.Json; - -namespace NLWebNet.Controllers; - -/// -/// Controller for the NLWeb /mcp endpoint. -/// Implements the Model Context Protocol for AI client integration. -/// -[ApiController] -[Route("mcp")] -[Produces("application/json")] -public class McpController : ControllerBase -{ - private readonly IMcpService _mcpService; - private readonly ILogger _logger; - - public McpController(IMcpService mcpService, ILogger logger) - { - _mcpService = mcpService ?? throw new ArgumentNullException(nameof(mcpService)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } - - /// - /// List available MCP tools. - /// - /// List of available tools with their schemas - [HttpPost("list_tools")] - [HttpGet("tools")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task ListTools() - { - try - { - _logger.LogDebug("Listing available MCP tools"); - - var response = await _mcpService.ListToolsAsync(); - - _logger.LogInformation("Listed {ToolCount} MCP tools", response.Tools?.Count ?? 0); - - return Ok(response); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error listing MCP tools: {Message}", ex.Message); - - return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails - { - Title = "Internal Server Error", - Detail = "An error occurred while listing tools", - Status = StatusCodes.Status500InternalServerError - }); - } - } - - /// - /// List available MCP prompts. - /// - /// List of available prompts with their schemas - [HttpPost("list_prompts")] - [HttpGet("prompts")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task ListPrompts() - { - try - { - _logger.LogDebug("Listing available MCP prompts"); - - var response = await _mcpService.ListPromptsAsync(); - - _logger.LogInformation("Listed {PromptCount} MCP prompts", response.Prompts?.Count ?? 0); - - return Ok(response); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error listing MCP prompts: {Message}", ex.Message); - - return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails - { - Title = "Internal Server Error", - Detail = "An error occurred while listing prompts", - Status = StatusCodes.Status500InternalServerError - }); - } - } - - /// - /// Call an MCP tool with the specified arguments. - /// - /// Tool call request with name and arguments - /// Cancellation token - /// Tool execution result - [HttpPost("call_tool")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task CallTool( - [FromBody] McpCallToolRequest request, - CancellationToken cancellationToken = default) - { - try - { - if (request == null) - { - _logger.LogWarning("Received null MCP tool call request"); - return BadRequest(new ProblemDetails - { - Title = "Invalid Request", - Detail = "Request body is required", - Status = StatusCodes.Status400BadRequest - }); - } - - if (string.IsNullOrWhiteSpace(request.Name)) - { - _logger.LogWarning("Received MCP tool call request with empty tool name"); - return BadRequest(new ProblemDetails - { - Title = "Invalid Tool Name", - Detail = "Tool name is required and cannot be empty", - Status = StatusCodes.Status400BadRequest - }); - } - - _logger.LogInformation("Calling MCP tool: {ToolName} with {ArgCount} arguments", - request.Name, request.Arguments?.Count ?? 0); - - var response = await _mcpService.CallToolAsync(request); - - if (response.IsError) - { - _logger.LogWarning("MCP tool call failed: {ToolName}, Error: {Error}", - request.Name, response.Content?.FirstOrDefault()?.Text); - } - else - { - _logger.LogInformation("Successfully called MCP tool: {ToolName}", request.Name); - } - - return Ok(response); - } - catch (ArgumentNullException ex) - { - _logger.LogWarning(ex, "Null argument in MCP tool call: {Message}", ex.Message); - - return BadRequest(new ProblemDetails - { - Title = "Invalid Arguments", - Detail = ex.Message, - Status = StatusCodes.Status400BadRequest - }); - } - catch (OperationCanceledException) - { - _logger.LogInformation("MCP tool call was cancelled: {ToolName}", request?.Name); - return StatusCode(StatusCodes.Status499ClientClosedRequest); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error calling MCP tool {ToolName}: {Message}", - request?.Name, ex.Message); - - return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails - { - Title = "Internal Server Error", - Detail = "An error occurred while calling the tool", - Status = StatusCodes.Status500InternalServerError - }); - } - } - - /// - /// Get an MCP prompt with argument substitution. - /// - /// Prompt request with name and arguments - /// Cancellation token - /// Prompt with substituted arguments - [HttpPost("get_prompt")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetPrompt( - [FromBody] McpGetPromptRequest request, - CancellationToken cancellationToken = default) - { - try - { - if (request == null) - { - _logger.LogWarning("Received null MCP prompt request"); - return BadRequest(new ProblemDetails - { - Title = "Invalid Request", - Detail = "Request body is required", - Status = StatusCodes.Status400BadRequest - }); - } - - if (string.IsNullOrWhiteSpace(request.Name)) - { - _logger.LogWarning("Received MCP prompt request with empty prompt name"); - return BadRequest(new ProblemDetails - { - Title = "Invalid Prompt Name", - Detail = "Prompt name is required and cannot be empty", - Status = StatusCodes.Status400BadRequest - }); - } - - _logger.LogInformation("Getting MCP prompt: {PromptName} with {ArgCount} arguments", - request.Name, request.Arguments?.Count ?? 0); - - var response = await _mcpService.GetPromptAsync(request); - - if (response.Messages?.Any() == true && - response.Messages.Any(m => m.Content?.Text?.Contains("Error:") == true)) - { - _logger.LogWarning("MCP prompt request failed: {PromptName}, Error: {Error}", - request.Name, response.Description); - } - else - { - _logger.LogInformation("Successfully retrieved MCP prompt: {PromptName}", request.Name); - } - - return Ok(response); - } - catch (ArgumentNullException ex) - { - _logger.LogWarning(ex, "Null argument in MCP prompt request: {Message}", ex.Message); - - return BadRequest(new ProblemDetails - { - Title = "Invalid Arguments", - Detail = ex.Message, - Status = StatusCodes.Status400BadRequest - }); - } - catch (OperationCanceledException) - { - _logger.LogInformation("MCP prompt request was cancelled: {PromptName}", request?.Name); - return StatusCode(StatusCodes.Status499ClientClosedRequest); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error getting MCP prompt {PromptName}: {Message}", - request?.Name, ex.Message); - - return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails - { - Title = "Internal Server Error", - Detail = "An error occurred while getting the prompt", - Status = StatusCodes.Status500InternalServerError - }); - } - } - - /// - /// Process an NLWeb query through the MCP interface. - /// This provides direct access to NLWeb functionality for MCP clients. - /// - /// NLWeb request - /// Cancellation token - /// NLWeb response formatted for MCP consumption - [HttpPost("query")] - [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task ProcessNLWebQuery( - [FromBody] NLWebRequest request, - CancellationToken cancellationToken = default) - { - try - { - if (request == null) - { - _logger.LogWarning("Received null NLWeb request via MCP"); - return BadRequest(new ProblemDetails - { - Title = "Invalid Request", - Detail = "Request body is required", - Status = StatusCodes.Status400BadRequest - }); - } - - if (string.IsNullOrWhiteSpace(request.Query)) - { - _logger.LogWarning("Received NLWeb request with empty query via MCP"); - return BadRequest(new ProblemDetails - { - Title = "Invalid Query", - Detail = "Query parameter is required and cannot be empty", - Status = StatusCodes.Status400BadRequest - }); - } - - _logger.LogInformation("Processing NLWeb query via MCP: {Query}, Mode: {Mode}", - request.Query, request.Mode); - - var response = await _mcpService.ProcessNLWebQueryAsync(request); - - _logger.LogInformation("Successfully processed NLWeb query via MCP: {QueryId}", - response.QueryId); - - return Ok(response); - } - catch (ArgumentNullException ex) - { - _logger.LogWarning(ex, "Null argument in NLWeb query via MCP: {Message}", ex.Message); - - return BadRequest(new ProblemDetails - { - Title = "Invalid Arguments", - Detail = ex.Message, - Status = StatusCodes.Status400BadRequest - }); - } - catch (OperationCanceledException) - { - _logger.LogInformation("NLWeb query via MCP was cancelled: {Query}", request?.Query); - return StatusCode(StatusCodes.Status499ClientClosedRequest); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error processing NLWeb query via MCP: {Message}", ex.Message); - - return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails - { - Title = "Internal Server Error", - Detail = "An error occurred while processing the query", - Status = StatusCodes.Status500InternalServerError - }); - } - } -} diff --git a/src/NLWebNet/Endpoints/AskEndpoints.cs b/src/NLWebNet/Endpoints/AskEndpoints.cs index 69f2603..7fd434a 100644 --- a/src/NLWebNet/Endpoints/AskEndpoints.cs +++ b/src/NLWebNet/Endpoints/AskEndpoints.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; @@ -64,7 +65,7 @@ public static RouteGroupBuilder MapAskEndpoints(this IEndpointRouteBuilder app) /// The logger factory /// Cancellation token /// NLWeb response with results - private static async Task ProcessQueryAsync( + private static async Task, BadRequest, StatusCodeHttpResult>> ProcessQueryAsync( [FromBody] NLWebRequest request, INLWebService nlWebService, ILoggerFactory loggerFactory, CancellationToken cancellationToken = default) @@ -83,7 +84,7 @@ private static async Task ProcessQueryAsync( if (request == null) { logger.LogWarning("Received null request"); - return Results.BadRequest(new ProblemDetails + return TypedResults.BadRequest(new ProblemDetails { Title = "Bad Request", Detail = "Request body is required", @@ -94,7 +95,7 @@ private static async Task ProcessQueryAsync( if (string.IsNullOrWhiteSpace(request.Query)) { logger.LogWarning("Received request with empty query"); - return Results.BadRequest(new ProblemDetails + return TypedResults.BadRequest(new ProblemDetails { Title = "Bad Request", Detail = "Query parameter is required and cannot be empty", @@ -112,12 +113,12 @@ private static async Task ProcessQueryAsync( response.QueryId, response.Results?.Count ?? 0); logger.LogInformation("[EXIT] /ask POST ProcessQueryAsync for QueryId={QueryId}", response.QueryId); - return Results.Ok(response); + return TypedResults.Ok(response); } catch (ValidationException ex) { logger.LogWarning(ex, "Validation error processing query: {Message}", ex.Message); - return Results.BadRequest(new ProblemDetails + return TypedResults.BadRequest(new ProblemDetails { Title = "Validation Error", Detail = ex.Message, @@ -127,7 +128,7 @@ private static async Task ProcessQueryAsync( catch (Exception ex) { logger.LogError(ex, "[FAIL] Error processing NLWeb query: {Message}", ex.Message); - return Results.StatusCode(StatusCodes.Status500InternalServerError); + return TypedResults.StatusCode(StatusCodes.Status500InternalServerError); } } /// /// Process a streaming natural language query with server-sent events. @@ -141,7 +142,7 @@ private static async Task ProcessQueryAsync( /// Optional query ID for correlation /// Cancellation token /// Streaming response - private static Task ProcessStreamingQueryAsync( + private static Task, PushStreamHttpResult>> ProcessStreamingQueryAsync( [FromQuery] string query, INLWebService nlWebService, ILoggerFactory loggerFactory, @@ -159,7 +160,7 @@ private static Task ProcessStreamingQueryAsync( if (string.IsNullOrWhiteSpace(query)) { logger.LogWarning("Received streaming request with empty query"); - return Task.FromResult(Results.BadRequest(new ProblemDetails + return Task.FromResult, PushStreamHttpResult>>(TypedResults.BadRequest(new ProblemDetails { Title = "Bad Request", Detail = "Query parameter is required and cannot be empty", @@ -172,7 +173,7 @@ private static Task ProcessStreamingQueryAsync( { if (!Enum.TryParse(mode, true, out queryMode)) { - return Task.FromResult(Results.BadRequest(new ProblemDetails + return Task.FromResult, PushStreamHttpResult>>(TypedResults.BadRequest(new ProblemDetails { Title = "Bad Request", Detail = $"Invalid mode '{mode}'. Valid values are: {string.Join(", ", Enum.GetNames())}", @@ -199,7 +200,7 @@ private static Task ProcessStreamingQueryAsync( var streamingResults = nlWebService.ProcessRequestStreamAsync(request, cancellationToken); logger.LogDebug("ProcessRequestStreamAsync in NLWebService started for QueryId={QueryId}", request.QueryId); - return Task.FromResult(Results.Stream(async stream => + return Task.FromResult, PushStreamHttpResult>>(TypedResults.Stream(async stream => { var writer = new StreamWriter(stream); await writer.WriteLineAsync("Content-Type: text/event-stream"); @@ -232,7 +233,7 @@ private static Task ProcessStreamingQueryAsync( catch (ValidationException ex) { logger.LogWarning(ex, "Validation error processing streaming query: {Message}", ex.Message); - return Task.FromResult(Results.BadRequest(new ProblemDetails + return Task.FromResult, PushStreamHttpResult>>(TypedResults.BadRequest(new ProblemDetails { Title = "Validation Error", Detail = ex.Message, @@ -242,7 +243,12 @@ private static Task ProcessStreamingQueryAsync( catch (Exception ex) { logger.LogError(ex, "[FAIL] Error processing streaming NLWeb query: {Message}", ex.Message); - return Task.FromResult(Results.StatusCode(StatusCodes.Status500InternalServerError)); + return Task.FromResult, PushStreamHttpResult>>(TypedResults.BadRequest(new ProblemDetails + { + Title = "Internal Server Error", + Detail = "An error occurred while processing the streaming request", + Status = StatusCodes.Status500InternalServerError + })); } } /// /// Process a simple query via GET request with query parameters. @@ -257,7 +263,7 @@ private static Task ProcessStreamingQueryAsync( /// Optional query ID for correlation /// Cancellation token /// NLWeb response with results - private static async Task ProcessSimpleQueryAsync( + private static async Task, BadRequest, StatusCodeHttpResult>> ProcessSimpleQueryAsync( [FromQuery] string query, INLWebService nlWebService, ILoggerFactory loggerFactory, @@ -275,7 +281,7 @@ private static async Task ProcessSimpleQueryAsync( if (string.IsNullOrWhiteSpace(query)) { logger.LogWarning("Received simple request with empty query"); - return Results.BadRequest(new ProblemDetails + return TypedResults.BadRequest(new ProblemDetails { Title = "Bad Request", Detail = "Query parameter is required and cannot be empty", @@ -288,7 +294,7 @@ private static async Task ProcessSimpleQueryAsync( { if (!Enum.TryParse(mode, true, out queryMode)) { - return Results.BadRequest(new ProblemDetails + return TypedResults.BadRequest(new ProblemDetails { Title = "Bad Request", Detail = $"Invalid mode '{mode}'. Valid values are: {string.Join(", ", Enum.GetNames())}", @@ -319,12 +325,12 @@ private static async Task ProcessSimpleQueryAsync( response.QueryId, response.Results?.Count ?? 0); logger.LogInformation("[EXIT] /ask GET ProcessSimpleQueryAsync for QueryId={QueryId}", response.QueryId); - return Results.Ok(response); + return TypedResults.Ok(response); } catch (ValidationException ex) { logger.LogWarning(ex, "Validation error processing simple query: {Message}", ex.Message); - return Results.BadRequest(new ProblemDetails + return TypedResults.BadRequest(new ProblemDetails { Title = "Validation Error", Detail = ex.Message, @@ -334,7 +340,7 @@ private static async Task ProcessSimpleQueryAsync( catch (Exception ex) { logger.LogError(ex, "[FAIL] Error processing simple NLWeb query: {Message}", ex.Message); - return Results.StatusCode(StatusCodes.Status500InternalServerError); + return TypedResults.StatusCode(StatusCodes.Status500InternalServerError); } } } diff --git a/src/NLWebNet/Endpoints/McpEndpoints.cs b/src/NLWebNet/Endpoints/McpEndpoints.cs index abe7e62..bf547c7 100644 --- a/src/NLWebNet/Endpoints/McpEndpoints.cs +++ b/src/NLWebNet/Endpoints/McpEndpoints.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; @@ -92,7 +93,7 @@ public static RouteGroupBuilder MapMcpEndpoints(this IEndpointRouteBuilder app) /// The MCP service /// The logger factory /// List of available tools with their schemas - private static async Task ListToolsAsync( + private static async Task, StatusCodeHttpResult>> ListToolsAsync( [FromServices] IMcpService mcpService, [FromServices] ILoggerFactory loggerFactory) { @@ -105,14 +106,14 @@ private static async Task ListToolsAsync( logger.LogInformation("Listed {ToolCount} MCP tools", response.Tools?.Count ?? 0); - return Results.Ok(response); + return TypedResults.Ok(response); } catch (Exception ex) { var logger = loggerFactory.CreateLogger(typeof(McpEndpoints)); logger.LogError(ex, "Error listing MCP tools: {Message}", ex.Message); - return Results.StatusCode(StatusCodes.Status500InternalServerError); + return TypedResults.StatusCode(StatusCodes.Status500InternalServerError); } } /// /// List available MCP prompts. @@ -120,7 +121,7 @@ private static async Task ListToolsAsync( /// The MCP service /// The logger factory /// List of available prompts with their schemas - private static async Task ListPromptsAsync( + private static async Task, StatusCodeHttpResult>> ListPromptsAsync( [FromServices] IMcpService mcpService, [FromServices] ILoggerFactory loggerFactory) { @@ -133,14 +134,14 @@ private static async Task ListPromptsAsync( logger.LogInformation("Listed {PromptCount} MCP prompts", response.Prompts?.Count ?? 0); - return Results.Ok(response); + return TypedResults.Ok(response); } catch (Exception ex) { var logger = loggerFactory.CreateLogger(typeof(McpEndpoints)); logger.LogError(ex, "Error listing MCP prompts: {Message}", ex.Message); - return Results.StatusCode(StatusCodes.Status500InternalServerError); + return TypedResults.StatusCode(StatusCodes.Status500InternalServerError); } } /// /// Call an MCP tool with the specified arguments. @@ -150,7 +151,7 @@ private static async Task ListPromptsAsync( /// The logger factory /// Cancellation token /// Tool execution result - private static async Task CallToolAsync( + private static async Task, BadRequest, NotFound, StatusCodeHttpResult>> CallToolAsync( [FromBody] McpCallToolRequest request, [FromServices] IMcpService mcpService, [FromServices] ILoggerFactory loggerFactory, @@ -164,7 +165,7 @@ private static async Task CallToolAsync( if (request == null) { logger.LogWarning("Received null tool call request"); - return Results.BadRequest(new ProblemDetails + return TypedResults.BadRequest(new ProblemDetails { Title = "Bad Request", Detail = "Request body is required", @@ -175,7 +176,7 @@ private static async Task CallToolAsync( if (string.IsNullOrWhiteSpace(request.Name)) { logger.LogWarning("Received tool call request with empty name"); - return Results.BadRequest(new ProblemDetails + return TypedResults.BadRequest(new ProblemDetails { Title = "Bad Request", Detail = "Tool name is required", @@ -194,7 +195,7 @@ private static async Task CallToolAsync( if (errorMessage?.Contains("not found", StringComparison.OrdinalIgnoreCase) == true) { logger.LogWarning("Tool not found: {ToolName}", request.Name); - return Results.NotFound(new ProblemDetails + return TypedResults.NotFound(new ProblemDetails { Title = "Tool Not Found", Detail = errorMessage, @@ -203,7 +204,7 @@ private static async Task CallToolAsync( } logger.LogWarning("Tool call error: {ErrorMessage}", errorMessage); - return Results.BadRequest(new ProblemDetails + return TypedResults.BadRequest(new ProblemDetails { Title = "Tool Call Error", Detail = errorMessage, @@ -212,14 +213,14 @@ private static async Task CallToolAsync( } logger.LogInformation("Successfully called tool {ToolName}", request.Name); - return Results.Ok(response); + return TypedResults.Ok(response); } catch (Exception ex) { var logger = loggerFactory.CreateLogger(typeof(McpEndpoints)); logger.LogError(ex, "Error calling MCP tool {ToolName}: {Message}", request?.Name, ex.Message); - return Results.StatusCode(StatusCodes.Status500InternalServerError); + return TypedResults.StatusCode(StatusCodes.Status500InternalServerError); } } /// /// Get a specific prompt with template substitution. @@ -229,7 +230,7 @@ private static async Task CallToolAsync( /// The logger factory /// Cancellation token /// Prompt with substituted arguments - private static async Task GetPromptAsync( + private static async Task, BadRequest, NotFound, StatusCodeHttpResult>> GetPromptAsync( [FromBody] McpGetPromptRequest request, [FromServices] IMcpService mcpService, [FromServices] ILoggerFactory loggerFactory, @@ -243,7 +244,7 @@ private static async Task GetPromptAsync( if (request == null) { logger.LogWarning("Received null prompt request"); - return Results.BadRequest(new ProblemDetails + return TypedResults.BadRequest(new ProblemDetails { Title = "Bad Request", Detail = "Request body is required", @@ -254,7 +255,7 @@ private static async Task GetPromptAsync( if (string.IsNullOrWhiteSpace(request.Name)) { logger.LogWarning("Received prompt request with empty name"); - return Results.BadRequest(new ProblemDetails + return TypedResults.BadRequest(new ProblemDetails { Title = "Bad Request", Detail = "Prompt name is required", @@ -270,7 +271,7 @@ private static async Task GetPromptAsync( if (response.Messages == null || !response.Messages.Any()) { logger.LogWarning("Prompt not found: {PromptName}", request.Name); - return Results.NotFound(new ProblemDetails + return TypedResults.NotFound(new ProblemDetails { Title = "Prompt Not Found", Detail = $"Prompt '{request.Name}' was not found", @@ -279,14 +280,14 @@ private static async Task GetPromptAsync( } logger.LogInformation("Successfully retrieved prompt {PromptName}", request.Name); - return Results.Ok(response); + return TypedResults.Ok(response); } catch (Exception ex) { var logger = loggerFactory.CreateLogger(typeof(McpEndpoints)); logger.LogError(ex, "Error getting MCP prompt {PromptName}: {Message}", request?.Name, ex.Message); - return Results.StatusCode(StatusCodes.Status500InternalServerError); + return TypedResults.StatusCode(StatusCodes.Status500InternalServerError); } } /// /// Process a unified MCP request (for compatibility with existing clients). @@ -309,7 +310,7 @@ private static async Task ProcessMcpRequestAsync( if (request == null) { logger.LogWarning("Received null MCP request"); - return Results.BadRequest(new ProblemDetails + return TypedResults.BadRequest(new ProblemDetails { Title = "Bad Request", Detail = "Request body is required", @@ -336,7 +337,7 @@ private static async Task ProcessMcpRequestAsync( "list_prompts" => await ListPromptsAsync(mcpService, loggerFactory), "call_tool" => await HandleCallToolFromUnified(root, mcpService, loggerFactory, cancellationToken), "get_prompt" => await HandleGetPromptFromUnified(root, mcpService, loggerFactory, cancellationToken), - _ => Results.BadRequest(new ProblemDetails + _ => TypedResults.BadRequest(new ProblemDetails { Title = "Unknown Method", Detail = $"Unknown MCP method: {method}", @@ -360,7 +361,7 @@ private static async Task ProcessMcpRequestAsync( } logger.LogWarning("Could not determine MCP request type"); - return Results.BadRequest(new ProblemDetails + return TypedResults.BadRequest(new ProblemDetails { Title = "Invalid Request", Detail = "Could not determine MCP request type", @@ -372,7 +373,7 @@ private static async Task ProcessMcpRequestAsync( var logger = loggerFactory.CreateLogger(typeof(McpEndpoints)); logger.LogError(ex, "Error processing unified MCP request: {Message}", ex.Message); - return Results.StatusCode(StatusCodes.Status500InternalServerError); + return TypedResults.StatusCode(StatusCodes.Status500InternalServerError); } } private static async Task HandleCallToolFromUnified( @@ -389,7 +390,7 @@ private static async Task HandleCallToolFromUnified( return await CallToolAsync(request, mcpService, loggerFactory, cancellationToken); } - return Results.BadRequest(new ProblemDetails + return TypedResults.BadRequest(new ProblemDetails { Title = "Invalid Tool Call", Detail = "Could not parse tool call request", @@ -400,7 +401,7 @@ private static async Task HandleCallToolFromUnified( { var logger = loggerFactory.CreateLogger(typeof(McpEndpoints)); logger.LogError(ex, "Error handling unified tool call: {Message}", ex.Message); - return Results.StatusCode(StatusCodes.Status500InternalServerError); + return TypedResults.StatusCode(StatusCodes.Status500InternalServerError); } } private static async Task HandleGetPromptFromUnified( @@ -417,7 +418,7 @@ private static async Task HandleGetPromptFromUnified( return await GetPromptAsync(request, mcpService, loggerFactory, cancellationToken); } - return Results.BadRequest(new ProblemDetails + return TypedResults.BadRequest(new ProblemDetails { Title = "Invalid Prompt Request", Detail = "Could not parse prompt request", @@ -428,7 +429,7 @@ private static async Task HandleGetPromptFromUnified( { var logger = loggerFactory.CreateLogger(typeof(McpEndpoints)); logger.LogError(ex, "Error handling unified prompt request: {Message}", ex.Message); - return Results.StatusCode(StatusCodes.Status500InternalServerError); + return TypedResults.StatusCode(StatusCodes.Status500InternalServerError); } } } diff --git a/src/NLWebNet/Extensions/ApplicationBuilderExtensions.cs b/src/NLWebNet/Extensions/ApplicationBuilderExtensions.cs index cad385e..3bd9692 100644 --- a/src/NLWebNet/Extensions/ApplicationBuilderExtensions.cs +++ b/src/NLWebNet/Extensions/ApplicationBuilderExtensions.cs @@ -51,15 +51,4 @@ public static IEndpointRouteBuilder MapNLWebNet(this IEndpointRouteBuilder app) return app; } - /// - /// Maps NLWebNet API controllers (/ask and /mcp) - Legacy controller support - /// - /// The application builder - /// The application builder for chaining - public static IApplicationBuilder MapNLWebNetControllers(this IApplicationBuilder app) - { - // Controllers will be automatically mapped via [Route] attributes when using AddControllers() - // This method is available for future route configuration if needed - return app; - } } diff --git a/src/NLWebNet/Extensions/ServiceCollectionExtensions.cs b/src/NLWebNet/Extensions/ServiceCollectionExtensions.cs index 9fe6d64..d247166 100644 --- a/src/NLWebNet/Extensions/ServiceCollectionExtensions.cs +++ b/src/NLWebNet/Extensions/ServiceCollectionExtensions.cs @@ -3,7 +3,6 @@ using NLWebNet.Models; using NLWebNet.Services; using NLWebNet.MCP; -using NLWebNet.Controllers; using NLWebNet.Health; using NLWebNet.RateLimiting; using NLWebNet.Metrics; @@ -39,10 +38,6 @@ public static IServiceCollection AddNLWebNet(this IServiceCollection services, A services.AddScoped(); // Register default data backend (can be overridden) services.AddScoped(); - // Register controllers - services.AddTransient(); - services.AddTransient(); - // Add health checks services.AddHealthChecks() .AddCheck("nlweb") diff --git a/tests/NLWebNet.Tests/Controllers/AskControllerTests.cs b/tests/NLWebNet.Tests/Controllers/AskControllerTests.cs deleted file mode 100644 index 3739818..0000000 --- a/tests/NLWebNet.Tests/Controllers/AskControllerTests.cs +++ /dev/null @@ -1,196 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using NSubstitute; -using NLWebNet.Controllers; -using NLWebNet.Models; -using NLWebNet.Services; -using Microsoft.AspNetCore.Mvc; - -namespace NLWebNet.Tests.Controllers; - -[TestClass] -public class AskControllerTests -{ - private INLWebService _mockNLWebService = null!; - private ILogger _mockLogger = null!; - private AskController _controller = null!; - - [TestInitialize] - public void Setup() - { - _mockNLWebService = Substitute.For(); - _mockLogger = Substitute.For>(); - _controller = new AskController(_mockNLWebService, _mockLogger); - } - - [TestMethod] - public async Task ProcessQuery_ValidRequest_ReturnsOkResult() - { // Arrange - var request = new NLWebRequest - { - Query = "test query", - Mode = QueryMode.List, - QueryId = "test-123", - Streaming = false // Disable streaming for this test - }; - - var expectedResponse = new NLWebResponse - { - QueryId = "test-123", - Results = new List - { - new() - { - Name = "Test Result", - Url = "https://example.com", - Score = 0.95, - Description = "A test result" - } - } - }; _mockNLWebService - .ProcessRequestAsync(Arg.Any(), Arg.Any()) - .Returns(expectedResponse); // Act - var result = await _controller.ProcessQuery(request); - - // Assert - Assert.IsInstanceOfType(result, typeof(OkObjectResult)); - var okResult = (OkObjectResult)result; - Assert.IsInstanceOfType(okResult.Value, typeof(NLWebResponse)); - - var response = (NLWebResponse)okResult.Value!; - Assert.AreEqual("test-123", response.QueryId); - Assert.AreEqual(1, response.Results?.Count); - - await _mockNLWebService.Received(1).ProcessRequestAsync(Arg.Any(), Arg.Any()); - } - - [TestMethod] - public async Task ProcessQuery_NullRequest_ReturnsBadRequest() - { - // Act - var result = await _controller.ProcessQuery(null!); - - // Assert - Assert.IsInstanceOfType(result, typeof(BadRequestObjectResult)); - var badRequestResult = (BadRequestObjectResult)result; - Assert.IsInstanceOfType(badRequestResult.Value, typeof(ProblemDetails)); - - var problemDetails = (ProblemDetails)badRequestResult.Value!; - Assert.AreEqual("Invalid Request", problemDetails.Title); - Assert.AreEqual(400, problemDetails.Status); - } - - [TestMethod] - public async Task ProcessQuery_EmptyQuery_ReturnsBadRequest() - { - // Arrange - var request = new NLWebRequest - { - Query = "", - Mode = QueryMode.List - }; - - // Act - var result = await _controller.ProcessQuery(request); - - // Assert - Assert.IsInstanceOfType(result, typeof(BadRequestObjectResult)); - var badRequestResult = (BadRequestObjectResult)result; - Assert.IsInstanceOfType(badRequestResult.Value, typeof(ProblemDetails)); - - var problemDetails = (ProblemDetails)badRequestResult.Value!; - Assert.AreEqual("Invalid Query", problemDetails.Title); - Assert.AreEqual(400, problemDetails.Status); - } - - [TestMethod] - public async Task ProcessQuery_GeneratesQueryIdWhenMissing() - { // Arrange - // QueryId is intentionally left null to test auto-generation - var request = new NLWebRequest - { - Query = "test query", - Mode = QueryMode.List, - Streaming = false - }; - - var expectedResponse = new NLWebResponse - { - QueryId = "generated-id", - Results = new List() - }; - - _mockNLWebService - .ProcessRequestAsync(Arg.Any(), Arg.Any()) - .Returns(expectedResponse); - - // Act - var result = await _controller.ProcessQuery(request); - - // Assert - Assert.IsInstanceOfType(result, typeof(OkObjectResult)); - - // Verify that QueryId was generated (not null or empty) - Assert.IsNotNull(request.QueryId); - Assert.IsFalse(string.IsNullOrEmpty(request.QueryId)); - - await _mockNLWebService.Received(1).ProcessRequestAsync( - Arg.Is(r => !string.IsNullOrEmpty(r.QueryId)), - Arg.Any()); - } - - [TestMethod] - public async Task ProcessQueryGet_ValidParameters_ReturnsOkResult() - { - // Arrange - var expectedResponse = new NLWebResponse - { - QueryId = "get-test", - Results = new List() - }; - - _mockNLWebService - .ProcessRequestAsync(Arg.Any(), Arg.Any()) - .Returns(expectedResponse); - - // Act - var result = await _controller.ProcessQueryGet("test query", QueryMode.Summarize, "test-site", false); - - // Assert - Assert.IsInstanceOfType(result, typeof(OkObjectResult)); - - await _mockNLWebService.Received(1).ProcessRequestAsync( - Arg.Is(r => - r.Query == "test query" && - r.Mode == QueryMode.Summarize && - r.Site == "test-site" && - r.Streaming == false), - Arg.Any()); - } - - [TestMethod] - public async Task ProcessQuery_ServiceThrowsException_ReturnsInternalServerError() - { - // Arrange - var request = new NLWebRequest - { - Query = "test query", - Mode = QueryMode.List - }; _mockNLWebService - .When(x => x.ProcessRequestAsync(Arg.Any(), Arg.Any())) - .Throw(new Exception("Service error")); - - // Act - var result = await _controller.ProcessQuery(request); - - // Assert - Assert.IsInstanceOfType(result, typeof(ObjectResult)); - var objectResult = (ObjectResult)result; - Assert.AreEqual(500, objectResult.StatusCode); - Assert.IsInstanceOfType(objectResult.Value, typeof(ProblemDetails)); - - var problemDetails = (ProblemDetails)objectResult.Value!; - Assert.AreEqual("Internal Server Error", problemDetails.Title); - Assert.AreEqual(500, problemDetails.Status); - } -} diff --git a/tests/NLWebNet.Tests/Controllers/McpControllerTests.cs b/tests/NLWebNet.Tests/Controllers/McpControllerTests.cs deleted file mode 100644 index 799df4a..0000000 --- a/tests/NLWebNet.Tests/Controllers/McpControllerTests.cs +++ /dev/null @@ -1,290 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using NSubstitute; -using NLWebNet.Controllers; -using NLWebNet.MCP; -using NLWebNet.Models; -using Microsoft.AspNetCore.Mvc; - -namespace NLWebNet.Tests.Controllers; - -[TestClass] -public class McpControllerTests -{ - private IMcpService _mockMcpService = null!; - private ILogger _mockLogger = null!; - private McpController _controller = null!; - - [TestInitialize] - public void Setup() - { - _mockMcpService = Substitute.For(); - _mockLogger = Substitute.For>(); - _controller = new McpController(_mockMcpService, _mockLogger); - } - - [TestMethod] - public async Task ListTools_ReturnsOkResult() - { - // Arrange - var expectedResponse = new McpListToolsResponse - { - Tools = new List - { - new() - { - Name = "nlweb_search", - Description = "Search tool" - } - } - }; - - _mockMcpService.ListToolsAsync().Returns(expectedResponse); - - // Act - var result = await _controller.ListTools(); - - // Assert - Assert.IsInstanceOfType(result, typeof(OkObjectResult)); - var okResult = (OkObjectResult)result; - Assert.IsInstanceOfType(okResult.Value, typeof(McpListToolsResponse)); - - var response = (McpListToolsResponse)okResult.Value!; - Assert.AreEqual(1, response.Tools?.Count); - - await _mockMcpService.Received(1).ListToolsAsync(); - } - - [TestMethod] - public async Task ListPrompts_ReturnsOkResult() - { - // Arrange - var expectedResponse = new McpListPromptsResponse - { - Prompts = new List - { - new() - { - Name = "nlweb_search_prompt", - Description = "Search prompt" - } - } - }; - - _mockMcpService.ListPromptsAsync().Returns(expectedResponse); - - // Act - var result = await _controller.ListPrompts(); - - // Assert - Assert.IsInstanceOfType(result, typeof(OkObjectResult)); - var okResult = (OkObjectResult)result; - Assert.IsInstanceOfType(okResult.Value, typeof(McpListPromptsResponse)); - - var response = (McpListPromptsResponse)okResult.Value!; - Assert.AreEqual(1, response.Prompts?.Count); - - await _mockMcpService.Received(1).ListPromptsAsync(); - } - - [TestMethod] - public async Task CallTool_ValidRequest_ReturnsOkResult() - { - // Arrange - var request = new McpCallToolRequest - { - Name = "nlweb_search", - Arguments = new Dictionary - { - ["query"] = "test query" - } - }; - - var expectedResponse = new McpCallToolResponse - { - IsError = false, - Content = new List - { - new() - { - Type = "text", - Text = "Tool executed successfully" - } - } - }; - - _mockMcpService.CallToolAsync(request).Returns(expectedResponse); - - // Act - var result = await _controller.CallTool(request); - - // Assert - Assert.IsInstanceOfType(result, typeof(OkObjectResult)); - var okResult = (OkObjectResult)result; - Assert.IsInstanceOfType(okResult.Value, typeof(McpCallToolResponse)); - - var response = (McpCallToolResponse)okResult.Value!; - Assert.IsFalse(response.IsError); - Assert.AreEqual(1, response.Content?.Count); - - await _mockMcpService.Received(1).CallToolAsync(request); - } - - [TestMethod] - public async Task CallTool_NullRequest_ReturnsBadRequest() - { - // Act - var result = await _controller.CallTool(null!); - - // Assert - Assert.IsInstanceOfType(result, typeof(BadRequestObjectResult)); - var badRequestResult = (BadRequestObjectResult)result; - Assert.IsInstanceOfType(badRequestResult.Value, typeof(ProblemDetails)); - - var problemDetails = (ProblemDetails)badRequestResult.Value!; - Assert.AreEqual("Invalid Request", problemDetails.Title); - Assert.AreEqual(400, problemDetails.Status); - } - - [TestMethod] - public async Task CallTool_EmptyToolName_ReturnsBadRequest() - { - // Arrange - var request = new McpCallToolRequest - { - Name = "", - Arguments = new Dictionary() - }; - - // Act - var result = await _controller.CallTool(request); - - // Assert - Assert.IsInstanceOfType(result, typeof(BadRequestObjectResult)); - var badRequestResult = (BadRequestObjectResult)result; - Assert.IsInstanceOfType(badRequestResult.Value, typeof(ProblemDetails)); - - var problemDetails = (ProblemDetails)badRequestResult.Value!; - Assert.AreEqual("Invalid Tool Name", problemDetails.Title); - Assert.AreEqual(400, problemDetails.Status); - } - - [TestMethod] - public async Task GetPrompt_ValidRequest_ReturnsOkResult() - { - // Arrange - var request = new McpGetPromptRequest - { - Name = "nlweb_search_prompt", - Arguments = new Dictionary - { - ["topic"] = "test topic" - } - }; var expectedResponse = new McpGetPromptResponse - { - Description = "Search prompt", - Messages = new List - { - new() - { - Role = "user", - Content = new McpContent - { - Type = "text", - Text = "Test prompt content" - } - } - } - }; - - _mockMcpService.GetPromptAsync(request).Returns(expectedResponse); - - // Act - var result = await _controller.GetPrompt(request); - - // Assert - Assert.IsInstanceOfType(result, typeof(OkObjectResult)); - var okResult = (OkObjectResult)result; - Assert.IsInstanceOfType(okResult.Value, typeof(McpGetPromptResponse)); - - var response = (McpGetPromptResponse)okResult.Value!; - Assert.AreEqual("Search prompt", response.Description); - Assert.AreEqual(1, response.Messages?.Count); - - await _mockMcpService.Received(1).GetPromptAsync(request); - } - - [TestMethod] - public async Task ProcessNLWebQuery_ValidRequest_ReturnsOkResult() - { - // Arrange - var request = new NLWebRequest - { - Query = "test query", - Mode = QueryMode.List - }; - - var expectedResponse = new NLWebResponse - { - QueryId = "mcp-test", - Results = new List() - }; - - _mockMcpService.ProcessNLWebQueryAsync(request).Returns(expectedResponse); - - // Act - var result = await _controller.ProcessNLWebQuery(request); - - // Assert - Assert.IsInstanceOfType(result, typeof(OkObjectResult)); - var okResult = (OkObjectResult)result; - Assert.IsInstanceOfType(okResult.Value, typeof(NLWebResponse)); - - var response = (NLWebResponse)okResult.Value!; - Assert.AreEqual("mcp-test", response.QueryId); - - await _mockMcpService.Received(1).ProcessNLWebQueryAsync(request); - } - - [TestMethod] - public async Task ProcessNLWebQuery_NullRequest_ReturnsBadRequest() - { - // Act - var result = await _controller.ProcessNLWebQuery(null!); - - // Assert - Assert.IsInstanceOfType(result, typeof(BadRequestObjectResult)); - var badRequestResult = (BadRequestObjectResult)result; - Assert.IsInstanceOfType(badRequestResult.Value, typeof(ProblemDetails)); - - var problemDetails = (ProblemDetails)badRequestResult.Value!; - Assert.AreEqual("Invalid Request", problemDetails.Title); - Assert.AreEqual(400, problemDetails.Status); - } - - [TestMethod] - public async Task CallTool_ServiceThrowsException_ReturnsInternalServerError() - { - // Arrange - var request = new McpCallToolRequest - { - Name = "nlweb_search", - Arguments = new Dictionary() - }; _mockMcpService - .When(x => x.CallToolAsync(Arg.Any())) - .Throw(new Exception("Service error")); - - // Act - var result = await _controller.CallTool(request); - - // Assert - Assert.IsInstanceOfType(result, typeof(ObjectResult)); - var objectResult = (ObjectResult)result; - Assert.AreEqual(500, objectResult.StatusCode); - Assert.IsInstanceOfType(objectResult.Value, typeof(ProblemDetails)); - - var problemDetails = (ProblemDetails)objectResult.Value!; - Assert.AreEqual("Internal Server Error", problemDetails.Title); - Assert.AreEqual(500, problemDetails.Status); - } -}