diff --git a/src/Utcp.Cli/CliCallTemplate.cs b/src/Utcp.Cli/CliCallTemplate.cs index 1e94e66..53f6d43 100644 --- a/src/Utcp.Cli/CliCallTemplate.cs +++ b/src/Utcp.Cli/CliCallTemplate.cs @@ -8,6 +8,11 @@ namespace Utcp.Cli; public sealed record CliCallTemplate : CallTemplate { + static CliCallTemplate() + { + PolymorphicRegistry.RegisterCallTemplateDerivedType("cli", typeof(CliCallTemplate)); + } + public required string Command { get; init; } public IReadOnlyList? Args { get; init; } public string? WorkingDirectory { get; init; } @@ -17,7 +22,6 @@ public sealed record CliCallTemplate : CallTemplate public CliCallTemplate() { CallTemplateType = "cli"; - PolymorphicRegistry.RegisterCallTemplateDerivedType("cli", typeof(CliCallTemplate)); } } diff --git a/src/Utcp.Core/Interfaces/IConcurrentToolRepository.cs b/src/Utcp.Core/Interfaces/IConcurrentToolRepository.cs index 9725697..5fd20d2 100644 --- a/src/Utcp.Core/Interfaces/IConcurrentToolRepository.cs +++ b/src/Utcp.Core/Interfaces/IConcurrentToolRepository.cs @@ -9,9 +9,14 @@ public interface IConcurrentToolRepository { Task SaveManualAsync(CallTemplate manualCallTemplate, UtcpManual manual, CancellationToken cancellationToken = default); Task RemoveManualAsync(string manualName, CancellationToken cancellationToken = default); + Task RemoveToolAsync(string toolName, CancellationToken cancellationToken = default); Task> GetManualNamesAsync(CancellationToken cancellationToken = default); + Task GetManualCallTemplateAsync(string manualCallTemplateName, CancellationToken cancellationToken = default); Task> GetManualsAsync(CancellationToken cancellationToken = default); + Task GetManualAsync(string manualName, CancellationToken cancellationToken = default); Task> GetToolsAsync(CancellationToken cancellationToken = default); + Task GetToolAsync(string toolName, CancellationToken cancellationToken = default); + Task?> GetToolsByManualAsync(string manualName, CancellationToken cancellationToken = default); Task> GetManualCallTemplatesAsync(CancellationToken cancellationToken = default); } diff --git a/src/Utcp.Core/Models/Serialization/ModelSerializers.cs b/src/Utcp.Core/Models/Serialization/ModelSerializers.cs new file mode 100644 index 0000000..be51840 --- /dev/null +++ b/src/Utcp.Core/Models/Serialization/ModelSerializers.cs @@ -0,0 +1,102 @@ +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace Utcp.Core.Models.Serialization; + +using Utcp.Core.Models; +using Utcp.Core.Serialization; + +public sealed class AuthSerializer : Serializer +{ + public override Auth ValidateDictionary(IReadOnlyDictionary obj) + { + if (!obj.TryGetValue("auth_type", out var discriminator) || discriminator is not string authType) + { + throw new ArgumentException("Dictionary does not contain an auth_type discriminator.", nameof(obj)); + } + + if (!PolymorphicRegistry.TryGetAuthType(authType, out var type) || type is null) + { + throw new InvalidOperationException($"Unknown Auth type: {authType}"); + } + + if (Runtime.Deserialize(type, obj) is not Auth auth) + { + throw new InvalidOperationException($"Unable to deserialize Auth type '{authType}'."); + } + + return auth; + } + + public override Dictionary ToDictionary(Auth obj) + { + return Runtime.ToDictionary(obj); + } +} + +public sealed class CallTemplateSerializer : Serializer +{ + public override CallTemplate ValidateDictionary(IReadOnlyDictionary obj) + { + if (!obj.TryGetValue("call_template_type", out var discriminator) || discriminator is not string templateType) + { + throw new ArgumentException("Dictionary does not contain a call_template_type discriminator.", nameof(obj)); + } + + if (!PolymorphicRegistry.TryGetCallTemplateType(templateType, out var type) || type is null) + { + throw new InvalidOperationException($"Unknown CallTemplate type: {templateType}"); + } + + if (Runtime.Deserialize(type, obj) is not CallTemplate template) + { + throw new InvalidOperationException($"Unable to deserialize CallTemplate type '{templateType}'."); + } + + return template; + } + + public override Dictionary ToDictionary(CallTemplate obj) + { + return Runtime.ToDictionary(obj); + } +} + +public sealed class JsonSchemaSerializer : Serializer +{ + public override JsonSchema ValidateDictionary(IReadOnlyDictionary obj) + { + return Runtime.Deserialize(obj); + } + + public override Dictionary ToDictionary(JsonSchema obj) + { + return Runtime.ToDictionary(obj); + } +} + +public sealed class ToolSerializer : Serializer +{ + public override Tool ValidateDictionary(IReadOnlyDictionary obj) + { + return Runtime.Deserialize(obj); + } + + public override Dictionary ToDictionary(Tool obj) + { + return Runtime.ToDictionary(obj); + } +} + +public sealed class UtcpManualSerializer : Serializer +{ + public override UtcpManual ValidateDictionary(IReadOnlyDictionary obj) + { + return Runtime.Deserialize(obj); + } + + public override Dictionary ToDictionary(UtcpManual obj) + { + return Runtime.ToDictionary(obj); + } +} diff --git a/src/Utcp.Core/Models/Tool.cs b/src/Utcp.Core/Models/Tool.cs index a0666c6..efce225 100644 --- a/src/Utcp.Core/Models/Tool.cs +++ b/src/Utcp.Core/Models/Tool.cs @@ -16,7 +16,9 @@ public sealed record Tool public sealed record JsonSchema { + [JsonPropertyName("$schema")] public string? Schema { get; init; } + [JsonPropertyName("$id")] public string? Id { get; init; } public string? Title { get; init; } public string? Description { get; init; } @@ -28,11 +30,14 @@ public sealed record JsonSchema public object? Const { get; init; } public object? Default { get; init; } public string? Format { get; init; } + [JsonPropertyName("additionalProperties")] public object? AdditionalProperties { get; init; } public string? Pattern { get; init; } public double? Minimum { get; init; } public double? Maximum { get; init; } + [JsonPropertyName("minLength")] public int? MinLength { get; init; } + [JsonPropertyName("maxLength")] public int? MaxLength { get; init; } } diff --git a/src/Utcp.Core/Repositories/InMemToolRepository.cs b/src/Utcp.Core/Repositories/InMemToolRepository.cs index 3c83e76..52fc876 100644 --- a/src/Utcp.Core/Repositories/InMemToolRepository.cs +++ b/src/Utcp.Core/Repositories/InMemToolRepository.cs @@ -68,24 +68,84 @@ public async Task RemoveManualAsync(string manualName, CancellationToken c } } + public async Task RemoveToolAsync(string toolName, CancellationToken cancellationToken = default) + { + await this.writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!this.toolsByName.TryRemove(toolName, out _)) + { + return false; + } + + foreach (var manualName in this.manualsByName.Keys.ToList()) + { + if (!this.manualsByName.TryGetValue(manualName, out var manual)) + { + continue; + } + + var remaining = manual.Tools.Where(t => !string.Equals(t.Name, toolName, StringComparison.OrdinalIgnoreCase)).ToList(); + if (remaining.Count != manual.Tools.Count) + { + this.manualsByName[manualName] = manual with { Tools = remaining }; + } + } + + return true; + } + finally + { + this.writeLock.Release(); + } + } + public Task> GetManualNamesAsync(CancellationToken cancellationToken = default) { var list = (IReadOnlyList)this.manualsByName.Keys.ToList(); return Task.FromResult(list); } + public Task GetManualCallTemplateAsync(string manualCallTemplateName, CancellationToken cancellationToken = default) + { + this.manualTemplatesByName.TryGetValue(manualCallTemplateName, out var template); + return Task.FromResult(template); + } + public Task> GetManualsAsync(CancellationToken cancellationToken = default) { var list = (IReadOnlyList)this.manualsByName.Values.ToList(); return Task.FromResult(list); } + public Task GetManualAsync(string manualName, CancellationToken cancellationToken = default) + { + this.manualsByName.TryGetValue(manualName, out var manual); + return Task.FromResult(manual); + } + public Task> GetToolsAsync(CancellationToken cancellationToken = default) { var list = (IReadOnlyList)this.toolsByName.Values.ToList(); return Task.FromResult(list); } + public Task GetToolAsync(string toolName, CancellationToken cancellationToken = default) + { + this.toolsByName.TryGetValue(toolName, out var tool); + return Task.FromResult(tool); + } + + public Task?> GetToolsByManualAsync(string manualName, CancellationToken cancellationToken = default) + { + if (this.manualsByName.TryGetValue(manualName, out var manual)) + { + return Task.FromResult?>(manual.Tools.ToList()); + } + + return Task.FromResult?>(null); + } + public Task> GetManualCallTemplatesAsync(CancellationToken cancellationToken = default) { var list = (IReadOnlyList)this.manualTemplatesByName.Values.ToList(); diff --git a/src/Utcp.Core/Serialization/Serializer.cs b/src/Utcp.Core/Serialization/Serializer.cs new file mode 100644 index 0000000..74b71d2 --- /dev/null +++ b/src/Utcp.Core/Serialization/Serializer.cs @@ -0,0 +1,112 @@ +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. + +namespace Utcp.Core.Serialization; + +using System.Text.Json; +using System.Text.Json.Serialization; +using Utcp.Core.Models.Serialization; + +public abstract class Serializer +{ + public abstract T ValidateDictionary(IReadOnlyDictionary obj); + + public abstract Dictionary ToDictionary(T obj); + + public virtual T Copy(T obj) + { + return this.ValidateDictionary(this.ToDictionary(obj)); + } + + protected static class Runtime + { + internal static readonly JsonSerializerOptions Options; + + static Runtime() + { + Options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + Options.Converters.Add(new CallTemplateJsonConverter()); + Options.Converters.Add(new AuthJsonConverter()); + } + + public static Dictionary ToDictionary(TValue value) + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + var element = JsonSerializer.SerializeToElement(value, value.GetType(), Options); + if (element.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException("Expected JSON object when converting to dictionary."); + } + + return (Dictionary)ConvertElement(element)!; + } + + public static TTarget Deserialize(IReadOnlyDictionary data) + { + var json = JsonSerializer.Serialize(data); + var result = JsonSerializer.Deserialize(json, Options); + if (result is null) + { + throw new InvalidOperationException("Unable to deserialize dictionary into target type."); + } + + return result; + } + + public static object? Deserialize(Type type, IReadOnlyDictionary data) + { + var json = JsonSerializer.Serialize(data); + return JsonSerializer.Deserialize(json, type, Options); + } + + private static object? ConvertElement(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.Object => ConvertObject(element), + JsonValueKind.Array => element.EnumerateArray().Select(ConvertElement).ToList(), + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => ConvertNumber(element), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + JsonValueKind.Undefined => null, + _ => element.GetRawText(), + }; + } + + private static Dictionary ConvertObject(JsonElement element) + { + var dict = new Dictionary(); + foreach (var property in element.EnumerateObject()) + { + dict[property.Name] = ConvertElement(property.Value); + } + + return dict; + } + + private static object ConvertNumber(JsonElement element) + { + if (element.TryGetInt64(out var l)) + { + return l; + } + + if (element.TryGetDouble(out var d)) + { + return d; + } + + return element.GetDecimal(); + } + } +} diff --git a/src/Utcp.Gql/GraphQlCallTemplate.cs b/src/Utcp.Gql/GraphQlCallTemplate.cs index 4c0d1a8..db1c595 100644 --- a/src/Utcp.Gql/GraphQlCallTemplate.cs +++ b/src/Utcp.Gql/GraphQlCallTemplate.cs @@ -8,13 +8,17 @@ namespace Utcp.Gql; public sealed record GraphQlCallTemplate : CallTemplate { + static GraphQlCallTemplate() + { + PolymorphicRegistry.RegisterCallTemplateDerivedType("gql", typeof(GraphQlCallTemplate)); + } + public required Uri Endpoint { get; init; } public string? Query { get; init; } public GraphQlCallTemplate() { CallTemplateType = "gql"; - PolymorphicRegistry.RegisterCallTemplateDerivedType("gql", typeof(GraphQlCallTemplate)); } } diff --git a/src/Utcp.Http/HttpCallTemplate.cs b/src/Utcp.Http/HttpCallTemplate.cs index fcd1731..c10938f 100644 --- a/src/Utcp.Http/HttpCallTemplate.cs +++ b/src/Utcp.Http/HttpCallTemplate.cs @@ -9,6 +9,11 @@ namespace Utcp.Http; public sealed record HttpCallTemplate : CallTemplate { + static HttpCallTemplate() + { + PolymorphicRegistry.RegisterCallTemplateDerivedType("http", typeof(HttpCallTemplate)); + } + public string Method { get; init; } = "GET"; public required Uri Url { get; init; } public IReadOnlyDictionary? Headers { get; init; } @@ -18,7 +23,6 @@ public sealed record HttpCallTemplate : CallTemplate public HttpCallTemplate() { CallTemplateType = "http"; - PolymorphicRegistry.RegisterCallTemplateDerivedType("http", typeof(HttpCallTemplate)); } } diff --git a/src/Utcp.Http/StreamableHttpCallTemplate.cs b/src/Utcp.Http/StreamableHttpCallTemplate.cs index b896f4d..dbfea55 100644 --- a/src/Utcp.Http/StreamableHttpCallTemplate.cs +++ b/src/Utcp.Http/StreamableHttpCallTemplate.cs @@ -8,6 +8,11 @@ namespace Utcp.Http; public sealed record StreamableHttpCallTemplate : CallTemplate { + static StreamableHttpCallTemplate() + { + PolymorphicRegistry.RegisterCallTemplateDerivedType("streamable_http", typeof(StreamableHttpCallTemplate)); + } + public string Method { get; init; } = "GET"; public required Uri Url { get; init; } public IReadOnlyDictionary? Headers { get; init; } @@ -18,7 +23,6 @@ public sealed record StreamableHttpCallTemplate : CallTemplate public StreamableHttpCallTemplate() { CallTemplateType = "streamable_http"; - PolymorphicRegistry.RegisterCallTemplateDerivedType("streamable_http", typeof(StreamableHttpCallTemplate)); } } diff --git a/src/Utcp.Mcp/McpCallTemplate.cs b/src/Utcp.Mcp/McpCallTemplate.cs index d6cab10..6c759e2 100644 --- a/src/Utcp.Mcp/McpCallTemplate.cs +++ b/src/Utcp.Mcp/McpCallTemplate.cs @@ -8,6 +8,11 @@ namespace Utcp.Mcp; public sealed record McpCallTemplate : CallTemplate { + static McpCallTemplate() + { + PolymorphicRegistry.RegisterCallTemplateDerivedType("mcp", typeof(McpCallTemplate)); + } + public string Transport { get; init; } = "stdio"; // or "http" public string? Command { get; init; } public string? Url { get; init; } @@ -17,7 +22,6 @@ public sealed record McpCallTemplate : CallTemplate public McpCallTemplate() { CallTemplateType = "mcp"; - PolymorphicRegistry.RegisterCallTemplateDerivedType("mcp", typeof(McpCallTemplate)); } } diff --git a/src/Utcp.Socket/TcpCallTemplate.cs b/src/Utcp.Socket/TcpCallTemplate.cs index 9f0f147..d239c81 100644 --- a/src/Utcp.Socket/TcpCallTemplate.cs +++ b/src/Utcp.Socket/TcpCallTemplate.cs @@ -8,13 +8,17 @@ namespace Utcp.Socket; public sealed record TcpCallTemplate : CallTemplate { + static TcpCallTemplate() + { + PolymorphicRegistry.RegisterCallTemplateDerivedType("tcp", typeof(TcpCallTemplate)); + } + public required string Host { get; init; } public int Port { get; init; } public TcpCallTemplate() { CallTemplateType = "tcp"; - PolymorphicRegistry.RegisterCallTemplateDerivedType("tcp", typeof(TcpCallTemplate)); } } diff --git a/src/Utcp.Socket/UdpCallTemplate.cs b/src/Utcp.Socket/UdpCallTemplate.cs index f3621f3..3625972 100644 --- a/src/Utcp.Socket/UdpCallTemplate.cs +++ b/src/Utcp.Socket/UdpCallTemplate.cs @@ -8,13 +8,17 @@ namespace Utcp.Socket; public sealed record UdpCallTemplate : CallTemplate { + static UdpCallTemplate() + { + PolymorphicRegistry.RegisterCallTemplateDerivedType("udp", typeof(UdpCallTemplate)); + } + public required string Host { get; init; } public int Port { get; init; } public UdpCallTemplate() { CallTemplateType = "udp"; - PolymorphicRegistry.RegisterCallTemplateDerivedType("udp", typeof(UdpCallTemplate)); } } diff --git a/src/Utcp.Text/TextCallTemplate.cs b/src/Utcp.Text/TextCallTemplate.cs index cd332bc..f5bacc2 100644 --- a/src/Utcp.Text/TextCallTemplate.cs +++ b/src/Utcp.Text/TextCallTemplate.cs @@ -8,6 +8,11 @@ namespace Utcp.Text; public sealed record TextCallTemplate : CallTemplate { + static TextCallTemplate() + { + PolymorphicRegistry.RegisterCallTemplateDerivedType("text", typeof(TextCallTemplate)); + } + public required string FilePath { get; init; } public string? EncodingName { get; init; } public int ChunkSizeBytes { get; init; } = 0; @@ -16,7 +21,6 @@ public sealed record TextCallTemplate : CallTemplate public TextCallTemplate() { CallTemplateType = "text"; - PolymorphicRegistry.RegisterCallTemplateDerivedType("text", typeof(TextCallTemplate)); } } diff --git a/tests/Utcp.Core.Tests/InMemToolRepositoryTests.cs b/tests/Utcp.Core.Tests/InMemToolRepositoryTests.cs new file mode 100644 index 0000000..76aabbb --- /dev/null +++ b/tests/Utcp.Core.Tests/InMemToolRepositoryTests.cs @@ -0,0 +1,83 @@ +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. + +using FluentAssertions; +using Utcp.Core.Models; +using Utcp.Core.Repositories; +using Utcp.Http; + +public sealed class InMemToolRepositoryTests +{ + [Fact] + public async Task RemoveToolAsync_RemovesFromLookupAndManual() + { + var repository = new InMemToolRepository(); + var manualTemplate = new HttpCallTemplate + { + CallTemplateType = "http", + Name = "manual", + Url = new Uri("https://api.example.com/utcp"), + }; + + var tool = new Tool + { + Name = "manual.echo", + ToolCallTemplate = manualTemplate, + }; + + var manual = new UtcpManual + { + Tools = new[] { tool }, + }; + + await repository.SaveManualAsync(manualTemplate, manual); + + (await repository.GetToolAsync("manual.echo")).Should().NotBeNull(); + + var removed = await repository.RemoveToolAsync("manual.echo"); + removed.Should().BeTrue(); + (await repository.GetToolAsync("manual.echo")).Should().BeNull(); + + var storedManual = await repository.GetManualAsync("manual"); + storedManual.Should().NotBeNull(); + storedManual!.Tools.Should().BeEmpty(); + } + + [Fact] + public async Task GetManualCallTemplateAsync_ReturnsStoredTemplate() + { + var repository = new InMemToolRepository(); + var manualTemplate = new HttpCallTemplate + { + CallTemplateType = "http", + Name = "manual", + Url = new Uri("https://api.example.com/utcp"), + }; + + var manual = new UtcpManual + { + Tools = Array.Empty(), + }; + + await repository.SaveManualAsync(manualTemplate, manual); + + var retrieved = await repository.GetManualCallTemplateAsync("manual"); + retrieved.Should().BeOfType().Which.Should().BeEquivalentTo(manualTemplate); + } + + [Fact] + public async Task GetToolsByManualAsync_ReturnsNullForUnknownManual() + { + var repository = new InMemToolRepository(); + var result = await repository.GetToolsByManualAsync("unknown"); + result.Should().BeNull(); + } + + [Fact] + public async Task RemoveToolAsync_ReturnsFalseWhenMissing() + { + var repository = new InMemToolRepository(); + var removed = await repository.RemoveToolAsync("missing"); + removed.Should().BeFalse(); + } +} diff --git a/tests/Utcp.Core.Tests/SerializationParityTests.cs b/tests/Utcp.Core.Tests/SerializationParityTests.cs new file mode 100644 index 0000000..171cd28 --- /dev/null +++ b/tests/Utcp.Core.Tests/SerializationParityTests.cs @@ -0,0 +1,148 @@ +// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +// If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. + +using System.Text.Json; +using FluentAssertions; +using Utcp.Core.Models; +using Utcp.Core.Models.Serialization; +using Utcp.Http; +using Xunit; + +public sealed class SerializationParityTests +{ + [Fact] + public void AuthSerializer_RoundTripsBuiltInTypes() + { + var serializer = new AuthSerializer(); + var auth = new OAuth2Auth + { + AuthType = "oauth2", + TokenUrl = "https://example.com/token", + ClientId = "client", + ClientSecret = "secret", + Scopes = new[] { "read", "write" }, + }; + + var dict = serializer.ToDictionary(auth); + dict.Should().ContainKey("auth_type").WhoseValue.Should().Be("oauth2"); + + var roundTrip = serializer.ValidateDictionary(dict); + roundTrip.Should().BeOfType().And.BeEquivalentTo(auth); + } + + [Fact] + public void CallTemplateSerializer_RoundTripsHttpTemplate() + { + var serializer = new CallTemplateSerializer(); + var template = new HttpCallTemplate + { + CallTemplateType = "http", + Name = "manual", + Method = "POST", + Url = new Uri("https://api.example.com/tool"), + Headers = new Dictionary { ["X-Test"] = "value" }, + Timeout = TimeSpan.FromSeconds(30), + }; + + var dict = serializer.ToDictionary(template); + dict.Should().ContainKey("call_template_type").WhoseValue.Should().Be("http"); + + var roundTrip = serializer.ValidateDictionary(dict); + roundTrip.Should().BeOfType().Which.Should().BeEquivalentTo(template); + } + + [Fact] + public void JsonSchemaSerializer_HandlesAliases() + { + var serializer = new JsonSchemaSerializer(); + var schema = new JsonSchema + { + Schema = "https://json-schema.org/draft/2020-12/schema", + Id = "https://example.com/schema.json", + Title = "Example", + Description = "Sample schema", + AdditionalProperties = false, + MinLength = 1, + MaxLength = 10, + }; + + var dict = serializer.ToDictionary(schema); + dict.Should().ContainKey("$schema").WhoseValue.Should().Be("https://json-schema.org/draft/2020-12/schema"); + dict.Should().ContainKey("minLength").WhoseValue.Should().Be(1); + dict.Should().ContainKey("additionalProperties").WhoseValue.Should().Be(false); + + var roundTrip = serializer.ValidateDictionary(dict); + roundTrip.Should().BeEquivalentTo(schema, options => options.Excluding(s => s.AdditionalProperties)); + + roundTrip.AdditionalProperties.Should().NotBeNull(); + var additionalProperties = roundTrip.AdditionalProperties switch + { + bool b => b, + JsonElement element when element.ValueKind is JsonValueKind.True or JsonValueKind.False => element.GetBoolean(), + _ => throw new InvalidOperationException("Unexpected AdditionalProperties type."), + }; + additionalProperties.Should().Be(false); + } + + [Fact] + public void ToolSerializer_RoundTripsWithNestedTypes() + { + var callTemplate = new HttpCallTemplate + { + CallTemplateType = "http", + Name = "manual", + Method = "GET", + Url = new Uri("https://api.example.com/tool"), + }; + + var tool = new Tool + { + Name = "manual.echo", + Description = "Echo", + Tags = new[] { "utility", "sample" }, + Inputs = new JsonSchema { Title = "Input", MinLength = 1 }, + Outputs = new JsonSchema { Title = "Output" }, + ToolCallTemplate = callTemplate, + AverageResponseSize = 42, + }; + + var serializer = new ToolSerializer(); + var dict = serializer.ToDictionary(tool); + dict.Should().ContainKey("tool_call_template"); + + var roundTrip = serializer.ValidateDictionary(dict); + roundTrip.Should().BeEquivalentTo(tool, options => options + .ComparingByMembers()); + } + + [Fact] + public void UtcpManualSerializer_RoundTripsManual() + { + var callTemplate = new HttpCallTemplate + { + CallTemplateType = "http", + Name = "manual", + Url = new Uri("https://api.example.com/tool"), + }; + + var tool = new Tool + { + Name = "manual.echo", + ToolCallTemplate = callTemplate, + }; + + var manual = new UtcpManual + { + UtcpVersion = "1.0.1", + ManualVersion = "2.0.0", + Tools = new[] { tool }, + }; + + var serializer = new UtcpManualSerializer(); + var dict = serializer.ToDictionary(manual); + dict.Should().ContainKey("utcp_version").WhoseValue.Should().Be("1.0.1"); + + var roundTrip = serializer.ValidateDictionary(dict); + roundTrip.Should().BeEquivalentTo(manual, options => options.ComparingByMembers()); + } +} diff --git a/tests/Utcp.Core.Tests/Utcp.Core.Tests.csproj b/tests/Utcp.Core.Tests/Utcp.Core.Tests.csproj index db1fd78..78a027b 100644 --- a/tests/Utcp.Core.Tests/Utcp.Core.Tests.csproj +++ b/tests/Utcp.Core.Tests/Utcp.Core.Tests.csproj @@ -21,8 +21,9 @@ - - - - - + + + + + +