diff --git a/Foundation.Data.Doublets.Cli.Tests/Foundation.Data.Doublets.Cli.Tests.csproj b/Foundation.Data.Doublets.Cli.Tests/Foundation.Data.Doublets.Cli.Tests.csproj index cade85a..52b2df7 100644 --- a/Foundation.Data.Doublets.Cli.Tests/Foundation.Data.Doublets.Cli.Tests.csproj +++ b/Foundation.Data.Doublets.Cli.Tests/Foundation.Data.Doublets.Cli.Tests.csproj @@ -14,6 +14,8 @@ + + diff --git a/Foundation.Data.Doublets.Cli.Tests/ServerModeIntegrationTests.cs b/Foundation.Data.Doublets.Cli.Tests/ServerModeIntegrationTests.cs new file mode 100644 index 0000000..4b8e7ee --- /dev/null +++ b/Foundation.Data.Doublets.Cli.Tests/ServerModeIntegrationTests.cs @@ -0,0 +1,129 @@ +using Xunit; +using System.Diagnostics; +using System.IO; + +namespace Foundation.Data.Doublets.Cli.Tests +{ + public class ServerModeIntegrationTests : IDisposable + { + private string _tempDbFile; + + public ServerModeIntegrationTests() + { + _tempDbFile = Path.GetTempFileName(); + } + + public void Dispose() + { + if (File.Exists(_tempDbFile)) + { + File.Delete(_tempDbFile); + } + } + + [Fact] + public void Should_Show_Server_Option_In_Help() + { + // Arrange & Act + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"run --project {GetCliProjectPath()} -- --help", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + } + }; + + process.Start(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + // Assert + Assert.Contains("--server", output); + Assert.Contains("Start server listening on a port", output); + Assert.Contains("--port", output); + Assert.Contains("Port to listen on when in server mode", output); + } + + [Fact] + public void Should_Start_Server_With_Correct_Output() + { + // Arrange & Act + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"run --project {GetCliProjectPath()} -- --server --port 0 --db {_tempDbFile}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + } + }; + + process.Start(); + + // Wait a short time to capture startup output + var outputBuffer = ""; + var startTime = DateTime.Now; + + while ((DateTime.Now - startTime).TotalSeconds < 3) + { + if (!process.StandardOutput.EndOfStream) + { + outputBuffer += process.StandardOutput.ReadLine() + "\n"; + } + + if (outputBuffer.Contains("LiNo WebSocket server started")) + { + break; + } + + Thread.Sleep(100); + } + + process.Kill(); + process.WaitForExit(); + + // Assert + Assert.Contains("LiNo WebSocket server started", outputBuffer); + Assert.Contains("Press Ctrl+C to stop the server", outputBuffer); + } + + [Fact] + public void Should_Preserve_Normal_CLI_Functionality() + { + // Arrange & Act + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"run --project {GetCliProjectPath()} -- --db {_tempDbFile} '() ((1 1))' --changes --after", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + } + }; + + process.Start(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + + // Assert - Normal CLI functionality should work + Assert.Equal(0, process.ExitCode); + Assert.Contains("(1: 1 1)", output); // Should show created link + } + + private string GetCliProjectPath() + { + // Get the current test directory and navigate to the CLI project + var currentDir = Directory.GetCurrentDirectory(); + var projectRoot = Directory.GetParent(currentDir)?.Parent?.Parent?.FullName; + return Path.Combine(projectRoot ?? currentDir, "Foundation.Data.Doublets.Cli"); + } + } +} \ No newline at end of file diff --git a/Foundation.Data.Doublets.Cli.Tests/ServerModeTests.cs b/Foundation.Data.Doublets.Cli.Tests/ServerModeTests.cs new file mode 100644 index 0000000..49ad625 --- /dev/null +++ b/Foundation.Data.Doublets.Cli.Tests/ServerModeTests.cs @@ -0,0 +1,360 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Foundation.Data.Doublets.Cli; +using System.IO; +using Xunit; +using Platform.Data.Doublets; + +namespace Foundation.Data.Doublets.Cli.Tests +{ + public class ServerModeTests : IDisposable + { + private string _tempDbFile; + + public ServerModeTests() + { + _tempDbFile = Path.GetTempFileName(); + } + + public void Dispose() + { + if (File.Exists(_tempDbFile)) + { + File.Delete(_tempDbFile); + } + } + + [Fact] + public async Task Should_Accept_WebSocket_Connection() + { + // Arrange + var builder = WebApplication.CreateSlimBuilder(); + builder.Services.AddSingleton(provider => new NamedLinksDecorator(_tempDbFile, false)); + var app = builder.Build(); + app.UseWebSockets(); + app.Map("/ws", HandleWebSocketEndpoint); + + using var server = new TestWebSocketServer(app); + await server.StartAsync(); + + // Act & Assert + using var client = new ClientWebSocket(); + var uri = new Uri($"ws://localhost:{server.Port}/ws"); + + // Should not throw exception + await client.ConnectAsync(uri, CancellationToken.None); + Assert.Equal(WebSocketState.Open, client.State); + + await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); + } + + [Fact] + public async Task Should_Process_LiNo_Query_Via_WebSocket() + { + // Arrange + var builder = WebApplication.CreateSlimBuilder(); + builder.Services.AddSingleton(provider => new NamedLinksDecorator(_tempDbFile, false)); + var app = builder.Build(); + app.UseWebSockets(); + app.Map("/ws", HandleWebSocketEndpoint); + + using var server = new TestWebSocketServer(app); + await server.StartAsync(); + + using var client = new ClientWebSocket(); + var uri = new Uri($"ws://localhost:{server.Port}/ws"); + await client.ConnectAsync(uri, CancellationToken.None); + + // Act - Send a create link query + var query = "() ((1 1))"; + var queryBytes = Encoding.UTF8.GetBytes(query); + await client.SendAsync(new ArraySegment(queryBytes), WebSocketMessageType.Text, true, CancellationToken.None); + + // Assert - Receive and verify response + var buffer = new byte[1024 * 4]; + var result = await client.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + + Assert.Equal(WebSocketMessageType.Text, result.MessageType); + + var responseText = Encoding.UTF8.GetString(buffer, 0, result.Count); + var response = JsonSerializer.Deserialize(responseText); + + Assert.True(response.TryGetProperty("query", out var queryProp)); + Assert.Equal(query, queryProp.GetString()); + + Assert.True(response.TryGetProperty("changes", out var changesProp)); + Assert.True(changesProp.GetArrayLength() > 0); + + Assert.True(response.TryGetProperty("links", out var linksProp)); + Assert.True(linksProp.GetArrayLength() > 0); + + await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); + } + + [Fact] + public async Task Should_Handle_Multiple_Queries() + { + // Arrange + var builder = WebApplication.CreateSlimBuilder(); + builder.Services.AddSingleton(provider => new NamedLinksDecorator(_tempDbFile, false)); + var app = builder.Build(); + app.UseWebSockets(); + app.Map("/ws", HandleWebSocketEndpoint); + + using var server = new TestWebSocketServer(app); + await server.StartAsync(); + + using var client = new ClientWebSocket(); + var uri = new Uri($"ws://localhost:{server.Port}/ws"); + await client.ConnectAsync(uri, CancellationToken.None); + + // Act & Assert - Send multiple queries + var queries = new[] { "() ((1 1))", "() ((2 2))", "((($i:)) (($i:)))" }; + + foreach (var query in queries) + { + var queryBytes = Encoding.UTF8.GetBytes(query); + await client.SendAsync(new ArraySegment(queryBytes), WebSocketMessageType.Text, true, CancellationToken.None); + + var buffer = new byte[1024 * 4]; + var result = await client.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + + Assert.Equal(WebSocketMessageType.Text, result.MessageType); + + var responseText = Encoding.UTF8.GetString(buffer, 0, result.Count); + var response = JsonSerializer.Deserialize(responseText); + + Assert.True(response.TryGetProperty("query", out var queryProp)); + Assert.Equal(query, queryProp.GetString()); + } + + await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); + } + + [Fact] + public async Task Should_Return_Error_For_Invalid_Query() + { + // Arrange + var builder = WebApplication.CreateSlimBuilder(); + builder.Services.AddSingleton(provider => new NamedLinksDecorator(_tempDbFile, false)); + var app = builder.Build(); + app.UseWebSockets(); + app.Map("/ws", HandleWebSocketEndpoint); + + using var server = new TestWebSocketServer(app); + await server.StartAsync(); + + using var client = new ClientWebSocket(); + var uri = new Uri($"ws://localhost:{server.Port}/ws"); + await client.ConnectAsync(uri, CancellationToken.None); + + // Act - Send an invalid query + var invalidQuery = "((invalid query))"; + var queryBytes = Encoding.UTF8.GetBytes(invalidQuery); + await client.SendAsync(new ArraySegment(queryBytes), WebSocketMessageType.Text, true, CancellationToken.None); + + // Assert - Should receive error response + var buffer = new byte[1024 * 4]; + var result = await client.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + + Assert.Equal(WebSocketMessageType.Text, result.MessageType); + + var responseText = Encoding.UTF8.GetString(buffer, 0, result.Count); + var response = JsonSerializer.Deserialize(responseText); + + Assert.True(response.TryGetProperty("error", out _)); + + await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); + } + + [Fact] + public async Task Should_Reject_Non_WebSocket_Requests() + { + // Arrange + var builder = WebApplication.CreateSlimBuilder(); + builder.Services.AddSingleton(provider => new NamedLinksDecorator(_tempDbFile, false)); + var app = builder.Build(); + app.UseWebSockets(); + app.Map("/ws", HandleWebSocketEndpoint); + + using var server = new TestWebSocketServer(app); + await server.StartAsync(); + + // Act & Assert - Regular HTTP request should be rejected + using var httpClient = new HttpClient(); + var response = await httpClient.GetAsync($"http://localhost:{server.Port}/ws"); + + Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.StatusCode); + } + + // Helper method - extracted from Program.cs + private static async Task HandleWebSocketEndpoint(Microsoft.AspNetCore.Http.HttpContext context) + { + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + return; + } + + var decoratedLinks = context.RequestServices.GetRequiredService>(); + using var webSocket = await context.WebSockets.AcceptWebSocketAsync(); + await HandleWebSocketConnection(webSocket, decoratedLinks); + } + + // Helper method - extracted from Program.cs + private static async Task HandleWebSocketConnection(WebSocket webSocket, NamedLinksDecorator decoratedLinks) + { + var buffer = new byte[1024 * 4]; + + while (webSocket.State == WebSocketState.Open) + { + try + { + var result = await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + + if (result.MessageType == WebSocketMessageType.Close) + { + await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); + break; + } + + if (result.MessageType == WebSocketMessageType.Text) + { + var query = Encoding.UTF8.GetString(buffer, 0, result.Count); + var response = await ProcessLinoQuery(query, decoratedLinks); + + var responseBytes = Encoding.UTF8.GetBytes(response); + await webSocket.SendAsync( + new ArraySegment(responseBytes), + WebSocketMessageType.Text, + true, + CancellationToken.None + ); + } + } + catch (WebSocketException) + { + break; + } + catch (Exception ex) + { + var errorResponse = JsonSerializer.Serialize(new { error = ex.Message }); + var errorBytes = Encoding.UTF8.GetBytes(errorResponse); + + try + { + await webSocket.SendAsync( + new ArraySegment(errorBytes), + WebSocketMessageType.Text, + true, + CancellationToken.None + ); + } + catch (WebSocketException) + { + break; + } + } + } + } + + // Helper method - extracted from Program.cs + private static async Task ProcessLinoQuery(string query, NamedLinksDecorator decoratedLinks) + { + return await Task.Run(() => + { + try + { + var changesList = new List<(Platform.Data.Doublets.Link Before, Platform.Data.Doublets.Link After)>(); + var results = new List(); + + if (!string.IsNullOrWhiteSpace(query)) + { + var options = new AdvancedMixedQueryProcessor.Options + { + Query = query, + Trace = false, + ChangesHandler = (beforeLink, afterLink) => + { + changesList.Add((new Platform.Data.Doublets.Link(beforeLink), new Platform.Data.Doublets.Link(afterLink))); + return decoratedLinks.Constants.Continue; + } + }; + + AdvancedMixedQueryProcessor.ProcessQuery(decoratedLinks, options); + + // Collect current state of all links + var any = decoratedLinks.Constants.Any; + var linkQuery = new Platform.Data.Doublets.Link(index: any, source: any, target: any); + + decoratedLinks.Each(linkQuery, link => + { + var formattedLink = decoratedLinks.Format(link); + results.Add(formattedLink); // Note: Namify is not included in tests for simplicity + return decoratedLinks.Constants.Continue; + }); + } + + return JsonSerializer.Serialize(new + { + query, + changes = changesList.Select(change => new + { + before = change.Before.IsNull() ? null : decoratedLinks.Format(change.Before), + after = change.After.IsNull() ? null : decoratedLinks.Format(change.After) + }), + links = results + }); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new { error = ex.Message }); + } + }); + } + } + + // Helper class for testing WebSocket server + public class TestWebSocketServer : IDisposable + { + private readonly WebApplication _app; + private Task? _serverTask; + private readonly CancellationTokenSource _cancellationTokenSource = new(); + + public int Port { get; private set; } + + public TestWebSocketServer(WebApplication app) + { + _app = app; + Port = GetAvailablePort(); + } + + public async Task StartAsync() + { + _app.Urls.Add($"http://localhost:{Port}"); + _serverTask = _app.RunAsync(_cancellationTokenSource.Token); + await Task.Delay(500); // Wait for server to start + } + + private static int GetAvailablePort() + { + using var socket = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Any, 0); + socket.Start(); + var port = ((System.Net.IPEndPoint)socket.LocalEndpoint).Port; + socket.Stop(); + return port; + } + + public void Dispose() + { + _cancellationTokenSource.Cancel(); + _serverTask?.Wait(TimeSpan.FromSeconds(5)); + _app.DisposeAsync().AsTask().Wait(); + _cancellationTokenSource.Dispose(); + } + } +} \ No newline at end of file diff --git a/Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj b/Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj index 1d8f9ab..49087f8 100644 --- a/Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj +++ b/Foundation.Data.Doublets.Cli/Foundation.Data.Doublets.Cli.csproj @@ -15,7 +15,7 @@ link-foundation A CLI tool for links manipulation. clink - 2.2.2 + 2.3.0 Unlicense https://github.com/link-foundation/link-cli @@ -28,6 +28,10 @@ + + + + diff --git a/Foundation.Data.Doublets.Cli/Program.cs b/Foundation.Data.Doublets.Cli/Program.cs index 1f9bfed..9b406fe 100644 --- a/Foundation.Data.Doublets.Cli/Program.cs +++ b/Foundation.Data.Doublets.Cli/Program.cs @@ -1,8 +1,16 @@ using System.CommandLine; +using System.CommandLine.Invocation; using Platform.Data; using Platform.Data.Doublets; using Platform.Data.Doublets.Memory.United.Generic; using Platform.Protocols.Lino; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; using static Foundation.Data.Doublets.Cli.ChangesSimplifier; using DoubletLink = Platform.Data.Doublets.Link; @@ -70,6 +78,19 @@ afterOption.AddAlias("--links"); afterOption.AddAlias("-a"); +var serverOption = new Option( + name: "--server", + description: "Start server listening on a port", + getDefaultValue: () => false +); + +var portOption = new Option( + name: "--port", + description: "Port to listen on when in server mode", + getDefaultValue: () => 8080 +); +portOption.AddAlias("-p"); + var rootCommand = new RootCommand("LiNo CLI Tool for managing links data store") { dbOption, @@ -79,76 +100,93 @@ structureOption, beforeOption, changesOption, - afterOption + afterOption, + serverOption, + portOption }; -rootCommand.SetHandler( - (string db, string queryOptionValue, string queryArgumentValue, bool trace, uint? structure, bool before, bool changes, bool after) => +rootCommand.SetHandler(async (context) => +{ + var db = context.ParseResult.GetValueForOption(dbOption)!; + var queryOptionValue = context.ParseResult.GetValueForOption(queryOption); + var queryArgumentValue = context.ParseResult.GetValueForArgument(queryArgument); + var trace = context.ParseResult.GetValueForOption(traceOption); + var structure = context.ParseResult.GetValueForOption(structureOption); + var before = context.ParseResult.GetValueForOption(beforeOption); + var changes = context.ParseResult.GetValueForOption(changesOption); + var after = context.ParseResult.GetValueForOption(afterOption); + var server = context.ParseResult.GetValueForOption(serverOption); + var port = context.ParseResult.GetValueForOption(portOption); + + // Handle server mode + if (server) { - var decoratedLinks = new NamedLinksDecorator(db, trace); + await StartServer(db, port, trace); + return; + } + + var decoratedLinks = new NamedLinksDecorator(db, trace); - // If --structure is provided, handle it separately - if (structure.HasValue) + // If --structure is provided, handle it separately + if (structure.HasValue) + { + var linkId = structure.Value; + try { - var linkId = structure.Value; - try - { - var structureFormatted = decoratedLinks.FormatStructure(linkId, link => decoratedLinks.IsFullPoint(linkId), true, true); - Console.WriteLine(Namify(decoratedLinks, structureFormatted)); - } - catch (Exception ex) - { - Console.Error.WriteLine($"Error formatting structure for link ID {linkId}: {ex.Message}"); - Environment.Exit(1); - } - return; // Exit after handling --structure + var structureFormatted = decoratedLinks.FormatStructure(linkId, link => decoratedLinks.IsFullPoint(linkId), true, true); + Console.WriteLine(Namify(decoratedLinks, structureFormatted)); } - - if (before) + catch (Exception ex) { - PrintAllLinks(decoratedLinks); + Console.Error.WriteLine($"Error formatting structure for link ID {linkId}: {ex.Message}"); + context.ExitCode = 1; + return; } + return; // Exit after handling --structure + } - var effectiveQuery = !string.IsNullOrWhiteSpace(queryOptionValue) ? queryOptionValue : queryArgumentValue; + if (before) + { + PrintAllLinks(decoratedLinks); + } - var changesList = new List<(DoubletLink Before, DoubletLink After)>(); + var effectiveQuery = !string.IsNullOrWhiteSpace(queryOptionValue) ? queryOptionValue : queryArgumentValue; - if (!string.IsNullOrWhiteSpace(effectiveQuery)) - { - var options = new QueryProcessor.Options - { - Query = effectiveQuery, - Trace = trace, - ChangesHandler = (beforeLink, afterLink) => - { - changesList.Add((new DoubletLink(beforeLink), new DoubletLink(afterLink))); - return decoratedLinks.Constants.Continue; - } - }; + var changesList = new List<(DoubletLink Before, DoubletLink After)>(); - QueryProcessor.ProcessQuery(decoratedLinks, options); - } - - if (changes && changesList.Any()) + if (!string.IsNullOrWhiteSpace(effectiveQuery)) + { + var options = new QueryProcessor.Options { - // Simplify the collected changes - var simplifiedChanges = SimplifyChanges(changesList); - - // Print the simplified changes - foreach (var (linkBefore, linkAfter) in simplifiedChanges) + Query = effectiveQuery, + Trace = trace, + ChangesHandler = (beforeLink, afterLink) => { - PrintChange(decoratedLinks, linkBefore, linkAfter); + changesList.Add((new DoubletLink(beforeLink), new DoubletLink(afterLink))); + return decoratedLinks.Constants.Continue; } - } + }; + + QueryProcessor.ProcessQuery(decoratedLinks, options); + } + + if (changes && changesList.Any()) + { + // Simplify the collected changes + var simplifiedChanges = SimplifyChanges(changesList); - if (after) + // Print the simplified changes + foreach (var (linkBefore, linkAfter) in simplifiedChanges) { - PrintAllLinks(decoratedLinks); + PrintChange(decoratedLinks, linkBefore, linkAfter); } - }, - // Explicitly specify the type parameters - dbOption, queryOption, queryArgument, traceOption, structureOption, beforeOption, changesOption, afterOption -); + } + + if (after) + { + PrintAllLinks(decoratedLinks); + } +}); await rootCommand.InvokeAsync(args); @@ -189,4 +227,147 @@ static void PrintChange(NamedLinksDecorator links, DoubletLink linkBefore, var afterText = linkAfter.IsNull() ? "" : links.Format(linkAfter); var formattedChange = $"({beforeText}) ({afterText})"; Console.WriteLine(Namify(links, formattedChange)); +} + +static async Task StartServer(string db, int port, bool trace) +{ + var builder = WebApplication.CreateSlimBuilder(); + + builder.Services.AddSingleton(provider => new NamedLinksDecorator(db, trace)); + + var app = builder.Build(); + + app.UseWebSockets(); + + app.Map("/ws", HandleWebSocketEndpoint); + + Console.WriteLine($"LiNo WebSocket server started on ws://localhost:{port}/ws"); + Console.WriteLine("Press Ctrl+C to stop the server"); + + await app.RunAsync($"http://localhost:{port}"); +} + +static async Task HandleWebSocketEndpoint(HttpContext context) +{ + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + return; + } + + var decoratedLinks = context.RequestServices.GetRequiredService>(); + using var webSocket = await context.WebSockets.AcceptWebSocketAsync(); + await HandleWebSocketConnection(webSocket, decoratedLinks); +} + +static async Task HandleWebSocketConnection(WebSocket webSocket, NamedLinksDecorator decoratedLinks) +{ + var buffer = new byte[1024 * 4]; + + while (webSocket.State == WebSocketState.Open) + { + try + { + var result = await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + + if (result.MessageType == WebSocketMessageType.Close) + { + await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); + break; + } + + if (result.MessageType == WebSocketMessageType.Text) + { + var query = Encoding.UTF8.GetString(buffer, 0, result.Count); + var response = await ProcessLinoQuery(query, decoratedLinks); + + var responseBytes = Encoding.UTF8.GetBytes(response); + await webSocket.SendAsync( + new ArraySegment(responseBytes), + WebSocketMessageType.Text, + true, + CancellationToken.None + ); + } + } + catch (WebSocketException ex) + { + Console.WriteLine($"WebSocket error: {ex.Message}"); + break; + } + catch (Exception ex) + { + Console.WriteLine($"Error processing request: {ex.Message}"); + var errorResponse = JsonSerializer.Serialize(new { error = ex.Message }); + var errorBytes = Encoding.UTF8.GetBytes(errorResponse); + + try + { + await webSocket.SendAsync( + new ArraySegment(errorBytes), + WebSocketMessageType.Text, + true, + CancellationToken.None + ); + } + catch (WebSocketException) + { + break; + } + } + } +} + +static async Task ProcessLinoQuery(string query, NamedLinksDecorator decoratedLinks) +{ + return await Task.Run(() => + { + try + { + var changesList = new List<(DoubletLink Before, DoubletLink After)>(); + var results = new List(); + + if (!string.IsNullOrWhiteSpace(query)) + { + var options = new QueryProcessor.Options + { + Query = query, + Trace = false, + ChangesHandler = (beforeLink, afterLink) => + { + changesList.Add((new DoubletLink(beforeLink), new DoubletLink(afterLink))); + return decoratedLinks.Constants.Continue; + } + }; + + QueryProcessor.ProcessQuery(decoratedLinks, options); + + // Collect current state of all links + var any = decoratedLinks.Constants.Any; + var linkQuery = new DoubletLink(index: any, source: any, target: any); + + decoratedLinks.Each(linkQuery, link => + { + var formattedLink = decoratedLinks.Format(link); + results.Add(Namify(decoratedLinks, formattedLink)); + return decoratedLinks.Constants.Continue; + }); + } + + return JsonSerializer.Serialize(new + { + query, + changes = changesList.Select(change => new + { + before = change.Before.IsNull() ? null : decoratedLinks.Format(change.Before), + after = change.After.IsNull() ? null : decoratedLinks.Format(change.After) + }), + links = results + }); + } + catch (Exception ex) + { + return JsonSerializer.Serialize(new { error = ex.Message }); + } + }); } \ No newline at end of file diff --git a/README.md b/README.md index 9399258..9ba4fc9 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,81 @@ clink '((1: 2 1) (2: 1 2)) ()' --changes --after | `--before` | bool | `false` | `-b` | Print the state of the database before applying changes | | `--changes` | bool | `false` | `-c` | Print the changes applied by the query | | `--after` | bool | `false` | `--links`, `-a` | Print the state of the database after applying changes | +| `--server` | bool | `false` | _None_ | Start server listening on a port | +| `--port` | int | `8080` | `-p` | Port to listen on when in server mode | + +## Server mode (LiNo API) + +The CLI can be run in server mode, providing a WebSocket API for remote LiNo query execution: + +```bash +clink --server --port 8080 +``` + +This starts a WebSocket server that listens for LiNo queries and returns structured JSON responses. The server endpoint is available at `ws://localhost:PORT/ws`. + +### WebSocket API + +**Send:** LiNo query as plain text +``` +() ((1 1)) +``` + +**Receive:** JSON response with query results +```json +{ + "query": "() ((1 1))", + "changes": [ + { + "before": "", + "after": "(1: 1 1)" + } + ], + "links": [ + "(1: 1 1)" + ] +} +``` + +The response includes: +- `query`: The original query that was processed +- `changes`: Array of before/after pairs showing what changed +- `links`: Current state of all links in the database + +### Server examples + +Start server on default port (8080): +```bash +clink --server +``` + +Start server on custom port: +```bash +clink --server --port 3000 +``` + +Start server with custom database: +```bash +clink --server --db mydata.links --port 8080 +``` + +### Client example (JavaScript) + +```javascript +const ws = new WebSocket('ws://localhost:8080/ws'); + +ws.onopen = function() { + // Send a LiNo query to create a link + ws.send('() ((1 1))'); +}; + +ws.onmessage = function(event) { + const response = JSON.parse(event.data); + console.log('Query:', response.query); + console.log('Changes:', response.changes); + console.log('All links:', response.links); +}; +``` ## For developers and debugging @@ -243,6 +318,19 @@ cd Foundation.Data.Doublets.Cli dotnet run -- '(((1: 1 1) (2: 2 2)) ((1: 1 2) (2: 2 1)))' --changes --after ``` +### Run server mode from root + +```bash +dotnet run --project Foundation.Data.Doublets.Cli -- --server --port 8080 +``` + +### Run server mode from folder + +```bash +cd Foundation.Data.Doublets.Cli +dotnet run -- --server --port 8080 +``` + ### Complete examples: ```bash diff --git a/examples/websocket_client_test.cs b/examples/websocket_client_test.cs new file mode 100644 index 0000000..f3919b2 --- /dev/null +++ b/examples/websocket_client_test.cs @@ -0,0 +1,36 @@ +using System.Net.WebSockets; +using System.Text; + +Console.WriteLine("Testing LiNo WebSocket server..."); + +using var client = new ClientWebSocket(); + +try +{ + await client.ConnectAsync(new Uri("ws://localhost:8081/ws"), CancellationToken.None); + Console.WriteLine("Connected to WebSocket server"); + + // Test query: Create a link + var testQuery = "() ((1 1))"; + var queryBytes = Encoding.UTF8.GetBytes(testQuery); + + await client.SendAsync(new ArraySegment(queryBytes), WebSocketMessageType.Text, true, CancellationToken.None); + Console.WriteLine($"Sent query: {testQuery}"); + + // Receive response + var buffer = new byte[1024 * 4]; + var result = await client.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + + if (result.MessageType == WebSocketMessageType.Text) + { + var response = Encoding.UTF8.GetString(buffer, 0, result.Count); + Console.WriteLine($"Received response: {response}"); + } + + await client.CloseAsync(WebSocketCloseStatus.NormalClosure, "Test completed", CancellationToken.None); + Console.WriteLine("WebSocket test completed successfully"); +} +catch (Exception ex) +{ + Console.WriteLine($"Error: {ex.Message}"); +} \ No newline at end of file