From 0b5a3355cb38190da32bb4ac9434eb4a50d522e0 Mon Sep 17 00:00:00 2001 From: Ed Ball Date: Wed, 3 Jul 2024 19:20:41 -0700 Subject: [PATCH 1/2] Support events. --- Directory.Packages.props | 4 +- conformance/ConformanceApi.fsd | 9 + .../CSharpGenerator.cs | 107 ++- .../ConformanceApiJsonSerializerContext.g.cs | 2 + .../ConformanceApiMethods.g.cs | 4 + .../DelegatingConformanceApi.g.cs | 55 +- .../FibonacciRequestDto.g.cs | 60 ++ .../FibonacciResponseDto.g.cs | 60 ++ .../Http/ConformanceApiHttpHandler.g.cs | 4 + .../Http/ConformanceApiHttpMapping.g.cs | 26 + .../Http/HttpClientConformanceApi.g.cs | 4 + .../IConformanceApi.g.cs | 3 + .../Testing/ConformanceApiService.cs | 31 + src/Facility.Core/Facility.Core.csproj | 1 + src/Facility.Core/Http/HttpClientService.cs | 202 +++++- src/Facility.Core/Http/ServiceHttpHandler.cs | 245 ++++++- .../Http/StandardHttpContentSerializer.cs | 12 +- src/Facility.Core/IServiceEventInfo.cs | 24 + src/Facility.Core/ServiceDelegate.cs | 37 ++ src/Facility.Core/ServiceDelegates.cs | 94 +++ src/Facility.Core/ServiceDelegators.cs | 1 + src/Facility.Core/ServiceEventInfo.cs | 54 ++ .../System.Net.ServerSentEvents.cs | 623 ++++++++++++++++++ src/fsdgencsharp/FsdGenCSharpApp.cs | 3 + .../DelegatingBenchmarkService.g.cs | 13 +- .../CSharpGeneratorTests.cs | 26 +- .../EdgeCaseTests.cs | 2 +- .../ValidationTests.cs | 2 +- .../CodeGenTests.cs | 2 +- .../DelegationTests.cs | 18 +- .../DtoValidationTests.cs | 2 +- tools/EdgeCases/DelegatingEdgeCases.g.cs | 17 +- 32 files changed, 1626 insertions(+), 121 deletions(-) create mode 100644 src/Facility.ConformanceApi/FibonacciRequestDto.g.cs create mode 100644 src/Facility.ConformanceApi/FibonacciResponseDto.g.cs create mode 100644 src/Facility.Core/IServiceEventInfo.cs create mode 100644 src/Facility.Core/ServiceDelegate.cs create mode 100644 src/Facility.Core/ServiceDelegates.cs create mode 100644 src/Facility.Core/ServiceEventInfo.cs create mode 100644 src/Facility.Core/System.Net.ServerSentEvents.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 5918e29f..9a6fc16d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,8 +6,8 @@ - - + + diff --git a/conformance/ConformanceApi.fsd b/conformance/ConformanceApi.fsd index ff35857e..85a03e25 100644 --- a/conformance/ConformanceApi.fsd +++ b/conformance/ConformanceApi.fsd @@ -253,6 +253,15 @@ service ConformanceApi [http(from: body, type: "application/x-output")] content: bytes; } + [http(method: GET)] + event fibonacci + { + count: int32!; + }: + { + value: int32!; + } + data Any { string: string; diff --git a/src/Facility.CodeGen.CSharp/CSharpGenerator.cs b/src/Facility.CodeGen.CSharp/CSharpGenerator.cs index f3b4dbdc..fd2f2302 100644 --- a/src/Facility.CodeGen.CSharp/CSharpGenerator.cs +++ b/src/Facility.CodeGen.CSharp/CSharpGenerator.cs @@ -1,6 +1,7 @@ using System.Globalization; using Facility.Definition; using Facility.Definition.CodeGen; +using Facility.Definition.Fsd; using Facility.Definition.Http; namespace Facility.CodeGen.CSharp; @@ -13,8 +14,18 @@ public sealed class CSharpGenerator : CodeGenerator /// /// Generates C#. /// + /// The parser. /// The settings. /// The number of updated files. + public static int GenerateCSharp(ServiceParser parser, CSharpGeneratorSettings settings) => + FileGenerator.GenerateFiles(parser, new CSharpGenerator { GeneratorName = nameof(CSharpGenerator) }, settings); + + /// + /// Generates C#. + /// + /// The settings. + /// The number of updated files. + [Obsolete("Use the overload that takes a parser.")] public static int GenerateCSharp(CSharpGeneratorSettings settings) => FileGenerator.GenerateFiles(new CSharpGenerator { GeneratorName = nameof(CSharpGenerator) }, settings); @@ -74,11 +85,11 @@ public override CodeGenOutput GenerateOutput(ServiceInfo service) foreach (var dtoInfo in service.Dtos) outputFiles.Add(GenerateDto(dtoInfo, context)); - if (service.Methods.Count != 0) + if (service.AllMethods.Count != 0) { outputFiles.Add(GenerateInterface(service, context)); - foreach (var methodInfo in service.Methods) + foreach (var methodInfo in service.AllMethods) outputFiles.AddRange(GenerateMethodDtos(methodInfo, context)); outputFiles.Add(GenerateMethodInfos(service, context)); @@ -90,7 +101,7 @@ public override CodeGenOutput GenerateOutput(ServiceInfo service) foreach (var httpErrorSetInfo in httpServiceInfo.ErrorSets) outputFiles.Add(GenerateHttpErrors(httpErrorSetInfo, context)); - if (httpServiceInfo.Methods.Count != 0) + if (httpServiceInfo.AllMethods.Count != 0) { outputFiles.Add(GenerateHttpMapping(httpServiceInfo, context)); outputFiles.Add(GenerateHttpClient(httpServiceInfo, context)); @@ -716,7 +727,7 @@ private CodeGenFile GenerateHttpMapping(HttpServiceInfo httpServiceInfo, Context code.WriteLine($"public static partial class {httpMappingName}"); using (code.Block()) { - foreach (var httpMethodInfo in httpServiceInfo.Methods) + foreach (var httpMethodInfo in httpServiceInfo.AllMethods) { var methodInfo = httpMethodInfo.ServiceMethod; var csharpInfo = context.CSharpServiceInfo; @@ -1092,6 +1103,8 @@ private CodeGenFile GenerateHttpClient(HttpServiceInfo httpServiceInfo, Context "Facility.Core", "Facility.Core.Http", }; + if (serviceInfo.Events.Count != 0) + usings.Add("System.Collections.Generic"); CSharpUtility.WriteUsings(code, usings, namespaceName); code.WriteLine($"namespace {namespaceName}"); @@ -1110,19 +1123,22 @@ private CodeGenFile GenerateHttpClient(HttpServiceInfo httpServiceInfo, Context code.WriteLine(": base(settings, s_defaults)"); code.Block().Dispose(); - foreach (var httpMethodInfo in httpServiceInfo.Methods) + foreach (var httpMethodInfo in httpServiceInfo.AllMethods) { var methodInfo = httpMethodInfo.ServiceMethod; var methodName = csharpInfo.GetMethodName(methodInfo); var requestTypeName = csharpInfo.GetRequestDtoName(methodInfo); var responseTypeName = csharpInfo.GetResponseDtoName(methodInfo); + var isEvent = methodInfo.Kind == ServiceMethodKind.Event; + if (isEvent) + responseTypeName = $"IAsyncEnumerable>"; code.WriteLine(); CSharpUtility.WriteSummary(code, methodInfo.Summary); CSharpUtility.WriteObsoleteAttribute(code, methodInfo); code.WriteLine($"public Task> {methodName}Async({requestTypeName} request, CancellationToken cancellationToken = default) =>"); using (code.Indent()) - code.WriteLine($"TrySendRequestAsync({httpMappingName}.{methodName}Mapping, request, cancellationToken);"); + code.WriteLine($"TrySend{(isEvent ? "Event" : "")}RequestAsync({httpMappingName}.{methodName}Mapping, request, cancellationToken);"); } code.WriteLine(); @@ -1167,7 +1183,7 @@ private CodeGenFile GenerateHttpHandler(HttpServiceInfo httpServiceInfo, Context }; CSharpUtility.WriteUsings(code, usings, namespaceName); - if (serviceInfo.Methods.Any(x => x.IsObsolete)) + if (serviceInfo.AllMethods.Any(x => x.IsObsolete)) { CSharpUtility.WriteObsoletePragma(code); code.WriteLine(); @@ -1206,7 +1222,7 @@ private CodeGenFile GenerateHttpHandler(HttpServiceInfo httpServiceInfo, Context // check 'widgets/get' before 'widgets/{id}' IDisposable? indent = null; code.Write("return "); - foreach (var httpServiceMethod in httpServiceInfo.Methods.OrderBy(x => x, HttpMethodInfo.ByRouteComparer)) + foreach (var httpServiceMethod in httpServiceInfo.AllMethods.OrderBy(x => x, HttpMethodInfo.ByRouteComparer)) { if (indent != null) code.WriteLine(" ??"); @@ -1218,17 +1234,18 @@ private CodeGenFile GenerateHttpHandler(HttpServiceInfo httpServiceInfo, Context indent?.Dispose(); } - foreach (var httpMethodInfo in httpServiceInfo.Methods) + foreach (var httpMethodInfo in httpServiceInfo.AllMethods) { var methodInfo = httpMethodInfo.ServiceMethod; var methodName = csharpInfo.GetMethodName(methodInfo); + var isEvent = methodInfo.Kind == ServiceMethodKind.Event; code.WriteLine(); CSharpUtility.WriteSummary(code, methodInfo.Summary); CSharpUtility.WriteObsoleteAttribute(code, methodInfo); code.WriteLine($"public Task TryHandle{methodName}Async(HttpRequestMessage httpRequest, CancellationToken cancellationToken = default) =>"); using (code.Indent()) - code.WriteLine($"TryHandleServiceMethodAsync({httpMappingName}.{methodName}Mapping, httpRequest, GetService(httpRequest).{methodName}Async, cancellationToken);"); + code.WriteLine($"TryHandleService{(isEvent ? "Event" : "Method")}Async({httpMappingName}.{methodName}Mapping, httpRequest, GetService(httpRequest).{methodName}Async, cancellationToken);"); } if (httpServiceInfo.ErrorSets.Count != 0) @@ -1387,6 +1404,8 @@ private CodeGenFile GenerateInterface(ServiceInfo serviceInfo, Context context) "System.Threading.Tasks", "Facility.Core", }; + if (serviceInfo.Events.Count != 0) + usings.Add("System.Collections.Generic"); CSharpUtility.WriteUsings(code, usings, context.NamespaceName); code.WriteLine($"namespace {context.NamespaceName}"); @@ -1399,12 +1418,17 @@ private CodeGenFile GenerateInterface(ServiceInfo serviceInfo, Context context) code.WriteLine($"public partial interface {interfaceName}"); using (code.Block()) { - foreach (var methodInfo in serviceInfo.Methods) + foreach (var methodInfo in serviceInfo.AllMethods) { + var responseTypeName = csharpInfo.GetResponseDtoName(methodInfo); + var isEvent = methodInfo.Kind == ServiceMethodKind.Event; + if (isEvent) + responseTypeName = $"IAsyncEnumerable>"; + code.WriteLineSkipOnce(); CSharpUtility.WriteSummary(code, methodInfo.Summary); CSharpUtility.WriteObsoleteAttribute(code, methodInfo); - code.WriteLine($"Task> {csharpInfo.GetMethodName(methodInfo)}Async(" + + code.WriteLine($"Task> {csharpInfo.GetMethodName(methodInfo)}Async(" + $"{csharpInfo.GetRequestDtoName(methodInfo)} request, CancellationToken cancellationToken = default);"); } } @@ -1438,14 +1462,15 @@ private CodeGenFile GenerateMethodInfos(ServiceInfo serviceInfo, Context context code.WriteLine($"internal static class {className}"); using (code.Block()) { - foreach (var methodInfo in serviceInfo.Methods) + foreach (var methodInfo in serviceInfo.AllMethods) { + var isEvent = methodInfo.Kind == ServiceMethodKind.Event; code.WriteLineSkipOnce(); CSharpUtility.WriteObsoleteAttribute(code, methodInfo); - code.WriteLine($"public static readonly IServiceMethodInfo {csharpInfo.GetMethodName(methodInfo)} ="); + code.WriteLine($"public static readonly IService{(isEvent ? "Event" : "Method")}Info {csharpInfo.GetMethodName(methodInfo)} ="); using (code.Indent()) { - code.WriteLine($"ServiceMethodInfo.Create<{interfaceName}, {csharpInfo.GetRequestDtoName(methodInfo)}, {csharpInfo.GetResponseDtoName(methodInfo)}>("); + code.WriteLine($"Service{(isEvent ? "Event" : "Method")}Info.Create<{interfaceName}, {csharpInfo.GetRequestDtoName(methodInfo)}, {csharpInfo.GetResponseDtoName(methodInfo)}>("); using (code.Indent()) code.WriteLine($"{CSharpUtility.CreateString(methodInfo.Name)}, {CSharpUtility.CreateString(serviceInfo.Name)}, x => x.{csharpInfo.GetMethodName(methodInfo)}Async);"); } @@ -1473,6 +1498,11 @@ private CodeGenFile GenerateDelegatingService(ServiceInfo serviceInfo, Context c "System.Threading.Tasks", "Facility.Core", }; + if (serviceInfo.Events.Count != 0) + { + usings.Add("System.Collections.Generic"); + usings.Add("System.Runtime.CompilerServices"); + } CSharpUtility.WriteUsings(code, usings, context.NamespaceName); code.WriteLine($"namespace {context.NamespaceName}"); @@ -1485,23 +1515,52 @@ private CodeGenFile GenerateDelegatingService(ServiceInfo serviceInfo, Context c code.WriteLine($"public partial class {className} : {interfaceName}"); using (code.Block()) { + CSharpUtility.WriteSummary(code, "Creates an instance with the specified service delegate."); + code.WriteLine($"public {className}(ServiceDelegate serviceDelegate) =>"); + using (code.Indent()) + code.WriteLine("m_serviceDelegate = serviceDelegate ?? throw new ArgumentNullException(nameof(serviceDelegate));"); + + code.WriteLine(); CSharpUtility.WriteSummary(code, "Creates an instance with the specified delegator."); + code.WriteLine("""[Obsolete("Use the constructor that accepts a ServiceDelegate.")]"""); code.WriteLine($"public {className}(ServiceDelegator delegator) =>"); using (code.Indent()) - code.WriteLine("m_delegator = delegator ?? throw new ArgumentNullException(nameof(delegator));"); + code.WriteLine("m_serviceDelegate = ServiceDelegate.FromDelegator(delegator);"); - foreach (var methodInfo in serviceInfo.Methods) + foreach (var methodInfo in serviceInfo.AllMethods) { code.WriteLine(); CSharpUtility.WriteSummary(code, methodInfo.Summary); CSharpUtility.WriteObsoleteAttribute(code, methodInfo); - code.WriteLine($"public virtual async Task> {csharpInfo.GetMethodName(methodInfo)}Async({csharpInfo.GetRequestDtoName(methodInfo)} request, CancellationToken cancellationToken = default) =>"); - using (code.Indent()) - code.WriteLine($"(await m_delegator({methodsClassName}.{csharpInfo.GetMethodName(methodInfo)}, request, cancellationToken).ConfigureAwait(false)).Cast<{csharpInfo.GetResponseDtoName(methodInfo)}>();"); + + if (methodInfo.Kind == ServiceMethodKind.Event) + { + code.WriteLine($"public virtual async Task>>> {csharpInfo.GetMethodName(methodInfo)}Async({csharpInfo.GetRequestDtoName(methodInfo)} request, CancellationToken cancellationToken = default)"); + using (code.Block()) + { + code.WriteLine($"var result = await m_serviceDelegate.InvokeEventAsync({methodsClassName}.{csharpInfo.GetMethodName(methodInfo)}, request, cancellationToken).ConfigureAwait(false);"); + code.WriteLine("return result.IsFailure ? result.ToFailure() : ServiceResult.Success(Enumerate(result.Value, cancellationToken));"); + + code.WriteLine(); + code.WriteLine($"static async IAsyncEnumerable> Enumerate(IAsyncEnumerable> enumerable, [EnumeratorCancellation] CancellationToken cancellationToken)"); + using (code.Block()) + { + code.WriteLine("await foreach (var result in enumerable.WithCancellation(cancellationToken))"); + using (code.Indent()) + code.WriteLine($"yield return result.Cast<{csharpInfo.GetResponseDtoName(methodInfo)}>();"); + } + } + } + else + { + code.WriteLine($"public virtual async Task> {csharpInfo.GetMethodName(methodInfo)}Async({csharpInfo.GetRequestDtoName(methodInfo)} request, CancellationToken cancellationToken = default) =>"); + using (code.Indent()) + code.WriteLine($"(await m_serviceDelegate.InvokeMethodAsync({methodsClassName}.{csharpInfo.GetMethodName(methodInfo)}, request, cancellationToken).ConfigureAwait(false)).Cast<{csharpInfo.GetResponseDtoName(methodInfo)}>();"); + } } code.WriteLine(); - code.WriteLine("private readonly ServiceDelegator m_delegator;"); + code.WriteLine("private readonly ServiceDelegate m_serviceDelegate;"); } } }); @@ -1539,7 +1598,7 @@ private CodeGenFile GenerateJsonSerializerContext(ServiceInfo serviceInfo, HttpS "ServiceObject", }; - foreach (var methodInfo in serviceInfo.Methods) + foreach (var methodInfo in serviceInfo.AllMethods) { serializables.Add(csharpInfo.GetRequestDtoName(methodInfo)); serializables.Add(csharpInfo.GetResponseDtoName(methodInfo)); @@ -1548,8 +1607,8 @@ private CodeGenFile GenerateJsonSerializerContext(ServiceInfo serviceInfo, HttpS foreach (var dtoInfo in serviceInfo.Dtos) serializables.Add(csharpInfo.GetDtoName(dtoInfo)); - foreach (var bodyFieldType in httpServiceInfo.Methods.Select(x => x.RequestBodyField) - .Concat(httpServiceInfo.Methods.SelectMany(x => x.ValidResponses).Select(x => x.BodyField)) + foreach (var bodyFieldType in httpServiceInfo.AllMethods.Select(x => x.RequestBodyField) + .Concat(httpServiceInfo.AllMethods.SelectMany(x => x.ValidResponses).Select(x => x.BodyField)) .Where(x => x != null) .Select(x => context.GetFieldType(x!.ServiceField)) .Where(x => x.Kind == ServiceTypeKind.Array)) diff --git a/src/Facility.ConformanceApi/ConformanceApiJsonSerializerContext.g.cs b/src/Facility.ConformanceApi/ConformanceApiJsonSerializerContext.g.cs index 8a443eea..ef8745b8 100644 --- a/src/Facility.ConformanceApi/ConformanceApiJsonSerializerContext.g.cs +++ b/src/Facility.ConformanceApi/ConformanceApiJsonSerializerContext.g.cs @@ -25,6 +25,8 @@ namespace Facility.ConformanceApi [JsonSerializable(typeof(CreateWidgetResponseDto))] [JsonSerializable(typeof(DeleteWidgetRequestDto))] [JsonSerializable(typeof(DeleteWidgetResponseDto))] + [JsonSerializable(typeof(FibonacciRequestDto))] + [JsonSerializable(typeof(FibonacciResponseDto))] [JsonSerializable(typeof(GetApiInfoRequestDto))] [JsonSerializable(typeof(GetApiInfoResponseDto))] [JsonSerializable(typeof(GetWidgetBatchRequestDto))] diff --git a/src/Facility.ConformanceApi/ConformanceApiMethods.g.cs b/src/Facility.ConformanceApi/ConformanceApiMethods.g.cs index bf9f7e03..48f33526 100644 --- a/src/Facility.ConformanceApi/ConformanceApiMethods.g.cs +++ b/src/Facility.ConformanceApi/ConformanceApiMethods.g.cs @@ -69,5 +69,9 @@ internal static class ConformanceApiMethods public static readonly IServiceMethodInfo BodyTypes = ServiceMethodInfo.Create( "bodyTypes", "ConformanceApi", x => x.BodyTypesAsync); + + public static readonly IServiceEventInfo Fibonacci = + ServiceEventInfo.Create( + "fibonacci", "ConformanceApi", x => x.FibonacciAsync); } } diff --git a/src/Facility.ConformanceApi/DelegatingConformanceApi.g.cs b/src/Facility.ConformanceApi/DelegatingConformanceApi.g.cs index 94da2948..8aba1a9f 100644 --- a/src/Facility.ConformanceApi/DelegatingConformanceApi.g.cs +++ b/src/Facility.ConformanceApi/DelegatingConformanceApi.g.cs @@ -3,6 +3,8 @@ // #nullable enable using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Facility.Core; @@ -15,75 +17,94 @@ namespace Facility.ConformanceApi [System.CodeDom.Compiler.GeneratedCode("fsdgencsharp", "")] public partial class DelegatingConformanceApi : IConformanceApi { + /// + /// Creates an instance with the specified service delegate. + /// + public DelegatingConformanceApi(ServiceDelegate serviceDelegate) => + m_serviceDelegate = serviceDelegate ?? throw new ArgumentNullException(nameof(serviceDelegate)); + /// /// Creates an instance with the specified delegator. /// + [Obsolete("Use the constructor that accepts a ServiceDelegate.")] public DelegatingConformanceApi(ServiceDelegator delegator) => - m_delegator = delegator ?? throw new ArgumentNullException(nameof(delegator)); + m_serviceDelegate = ServiceDelegate.FromDelegator(delegator); /// /// Gets API information. /// public virtual async Task> GetApiInfoAsync(GetApiInfoRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(ConformanceApiMethods.GetApiInfo, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(ConformanceApiMethods.GetApiInfo, request, cancellationToken).ConfigureAwait(false)).Cast(); /// /// Gets widgets. /// public virtual async Task> GetWidgetsAsync(GetWidgetsRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(ConformanceApiMethods.GetWidgets, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(ConformanceApiMethods.GetWidgets, request, cancellationToken).ConfigureAwait(false)).Cast(); /// /// Creates a new widget. /// public virtual async Task> CreateWidgetAsync(CreateWidgetRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(ConformanceApiMethods.CreateWidget, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(ConformanceApiMethods.CreateWidget, request, cancellationToken).ConfigureAwait(false)).Cast(); /// /// Gets the specified widget. /// public virtual async Task> GetWidgetAsync(GetWidgetRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(ConformanceApiMethods.GetWidget, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(ConformanceApiMethods.GetWidget, request, cancellationToken).ConfigureAwait(false)).Cast(); /// /// Deletes the specified widget. /// public virtual async Task> DeleteWidgetAsync(DeleteWidgetRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(ConformanceApiMethods.DeleteWidget, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(ConformanceApiMethods.DeleteWidget, request, cancellationToken).ConfigureAwait(false)).Cast(); /// /// Gets the specified widgets. /// public virtual async Task> GetWidgetBatchAsync(GetWidgetBatchRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(ConformanceApiMethods.GetWidgetBatch, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(ConformanceApiMethods.GetWidgetBatch, request, cancellationToken).ConfigureAwait(false)).Cast(); public virtual async Task> MirrorFieldsAsync(MirrorFieldsRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(ConformanceApiMethods.MirrorFields, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(ConformanceApiMethods.MirrorFields, request, cancellationToken).ConfigureAwait(false)).Cast(); public virtual async Task> CheckQueryAsync(CheckQueryRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(ConformanceApiMethods.CheckQuery, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(ConformanceApiMethods.CheckQuery, request, cancellationToken).ConfigureAwait(false)).Cast(); public virtual async Task> CheckPathAsync(CheckPathRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(ConformanceApiMethods.CheckPath, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(ConformanceApiMethods.CheckPath, request, cancellationToken).ConfigureAwait(false)).Cast(); public virtual async Task> MirrorHeadersAsync(MirrorHeadersRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(ConformanceApiMethods.MirrorHeaders, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(ConformanceApiMethods.MirrorHeaders, request, cancellationToken).ConfigureAwait(false)).Cast(); public virtual async Task> MixedAsync(MixedRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(ConformanceApiMethods.Mixed, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(ConformanceApiMethods.Mixed, request, cancellationToken).ConfigureAwait(false)).Cast(); public virtual async Task> RequiredAsync(RequiredRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(ConformanceApiMethods.Required, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(ConformanceApiMethods.Required, request, cancellationToken).ConfigureAwait(false)).Cast(); public virtual async Task> MirrorBytesAsync(MirrorBytesRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(ConformanceApiMethods.MirrorBytes, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(ConformanceApiMethods.MirrorBytes, request, cancellationToken).ConfigureAwait(false)).Cast(); public virtual async Task> MirrorTextAsync(MirrorTextRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(ConformanceApiMethods.MirrorText, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(ConformanceApiMethods.MirrorText, request, cancellationToken).ConfigureAwait(false)).Cast(); public virtual async Task> BodyTypesAsync(BodyTypesRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(ConformanceApiMethods.BodyTypes, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(ConformanceApiMethods.BodyTypes, request, cancellationToken).ConfigureAwait(false)).Cast(); + + public virtual async Task>>> FibonacciAsync(FibonacciRequestDto request, CancellationToken cancellationToken = default) + { + var result = await m_serviceDelegate.InvokeEventAsync(ConformanceApiMethods.Fibonacci, request, cancellationToken).ConfigureAwait(false); + return result.IsFailure ? result.ToFailure() : ServiceResult.Success(Enumerate(result.Value, cancellationToken)); + + static async IAsyncEnumerable> Enumerate(IAsyncEnumerable> enumerable, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var result in enumerable.WithCancellation(cancellationToken)) + yield return result.Cast(); + } + } - private readonly ServiceDelegator m_delegator; + private readonly ServiceDelegate m_serviceDelegate; } } diff --git a/src/Facility.ConformanceApi/FibonacciRequestDto.g.cs b/src/Facility.ConformanceApi/FibonacciRequestDto.g.cs new file mode 100644 index 00000000..090073c1 --- /dev/null +++ b/src/Facility.ConformanceApi/FibonacciRequestDto.g.cs @@ -0,0 +1,60 @@ +// +// DO NOT EDIT: generated by fsdgencsharp +// +#nullable enable +using System; +using System.Collections.Generic; +using Facility.Core; +using Facility.Core.MessagePack; + +namespace Facility.ConformanceApi +{ + /// + /// Request for Fibonacci. + /// + [System.CodeDom.Compiler.GeneratedCode("fsdgencsharp", "")] + [MessagePack.MessagePackObject] + public sealed partial class FibonacciRequestDto : ServiceDto + { + /// + /// Creates an instance. + /// + public FibonacciRequestDto() + { + } + + [MessagePack.Key("count")] + public int? Count { get; set; } + + /// + /// The JSON serializer. + /// + protected override JsonServiceSerializer JsonSerializer => SystemTextJsonServiceSerializer.Instance; + + /// + /// Determines if two DTOs are equivalent. + /// + public override bool IsEquivalentTo(FibonacciRequestDto? other) + { + return other != null && + Count == other.Count; + } + + /// + /// Validates the DTO. + /// + public override bool Validate(out string? errorMessage) + { + errorMessage = GetValidationErrorMessage(); + return errorMessage == null; + } + + private string? GetValidationErrorMessage() + { + if (Count == null) + return ServiceDataUtility.GetRequiredFieldErrorMessage("count"); + + return null; + } + } +} diff --git a/src/Facility.ConformanceApi/FibonacciResponseDto.g.cs b/src/Facility.ConformanceApi/FibonacciResponseDto.g.cs new file mode 100644 index 00000000..ed615575 --- /dev/null +++ b/src/Facility.ConformanceApi/FibonacciResponseDto.g.cs @@ -0,0 +1,60 @@ +// +// DO NOT EDIT: generated by fsdgencsharp +// +#nullable enable +using System; +using System.Collections.Generic; +using Facility.Core; +using Facility.Core.MessagePack; + +namespace Facility.ConformanceApi +{ + /// + /// Response for Fibonacci. + /// + [System.CodeDom.Compiler.GeneratedCode("fsdgencsharp", "")] + [MessagePack.MessagePackObject] + public sealed partial class FibonacciResponseDto : ServiceDto + { + /// + /// Creates an instance. + /// + public FibonacciResponseDto() + { + } + + [MessagePack.Key("value")] + public int? Value { get; set; } + + /// + /// The JSON serializer. + /// + protected override JsonServiceSerializer JsonSerializer => SystemTextJsonServiceSerializer.Instance; + + /// + /// Determines if two DTOs are equivalent. + /// + public override bool IsEquivalentTo(FibonacciResponseDto? other) + { + return other != null && + Value == other.Value; + } + + /// + /// Validates the DTO. + /// + public override bool Validate(out string? errorMessage) + { + errorMessage = GetValidationErrorMessage(); + return errorMessage == null; + } + + private string? GetValidationErrorMessage() + { + if (Value == null) + return ServiceDataUtility.GetRequiredFieldErrorMessage("value"); + + return null; + } + } +} diff --git a/src/Facility.ConformanceApi/Http/ConformanceApiHttpHandler.g.cs b/src/Facility.ConformanceApi/Http/ConformanceApiHttpHandler.g.cs index da7c1425..e8a265e9 100644 --- a/src/Facility.ConformanceApi/Http/ConformanceApiHttpHandler.g.cs +++ b/src/Facility.ConformanceApi/Http/ConformanceApiHttpHandler.g.cs @@ -45,6 +45,7 @@ public ConformanceApiHttpHandler(Func getSe await AdaptTask(TryHandleBodyTypesAsync(httpRequest, cancellationToken)).ConfigureAwait(true) ?? await AdaptTask(TryHandleCheckPathAsync(httpRequest, cancellationToken)).ConfigureAwait(true) ?? await AdaptTask(TryHandleCheckQueryAsync(httpRequest, cancellationToken)).ConfigureAwait(true) ?? + await AdaptTask(TryHandleFibonacciAsync(httpRequest, cancellationToken)).ConfigureAwait(true) ?? await AdaptTask(TryHandleMirrorBytesAsync(httpRequest, cancellationToken)).ConfigureAwait(true) ?? await AdaptTask(TryHandleMirrorFieldsAsync(httpRequest, cancellationToken)).ConfigureAwait(true) ?? await AdaptTask(TryHandleMirrorHeadersAsync(httpRequest, cancellationToken)).ConfigureAwait(true) ?? @@ -121,6 +122,9 @@ await AdaptTask(TryHandleGetWidgetAsync(httpRequest, cancellationToken)).Configu public Task TryHandleBodyTypesAsync(HttpRequestMessage httpRequest, CancellationToken cancellationToken = default) => TryHandleServiceMethodAsync(ConformanceApiHttpMapping.BodyTypesMapping, httpRequest, GetService(httpRequest).BodyTypesAsync, cancellationToken); + public Task TryHandleFibonacciAsync(HttpRequestMessage httpRequest, CancellationToken cancellationToken = default) => + TryHandleServiceEventAsync(ConformanceApiHttpMapping.FibonacciMapping, httpRequest, GetService(httpRequest).FibonacciAsync, cancellationToken); + /// /// Returns the HTTP status code for a custom error code. /// diff --git a/src/Facility.ConformanceApi/Http/ConformanceApiHttpMapping.g.cs b/src/Facility.ConformanceApi/Http/ConformanceApiHttpMapping.g.cs index d3c2e170..f10e6bdf 100644 --- a/src/Facility.ConformanceApi/Http/ConformanceApiHttpMapping.g.cs +++ b/src/Facility.ConformanceApi/Http/ConformanceApiHttpMapping.g.cs @@ -742,5 +742,31 @@ public static partial class ConformanceApiHttpMapping }.Build(), }, }.Build(); + + public static readonly HttpMethodMapping FibonacciMapping = + new HttpMethodMapping.Builder + { + HttpMethod = HttpMethod.Get, + Path = "/fibonacci", + GetUriParameters = request => + new Dictionary + { + { "count", request.Count == null ? null : request.Count.Value.ToString(CultureInfo.InvariantCulture) }, + }, + SetUriParameters = (request, parameters) => + { + parameters.TryGetValue("count", out var queryParameterCount); + request.Count = ServiceDataUtility.TryParseInt32(queryParameterCount); + return request; + }, + ResponseMappings = + { + new HttpResponseMapping.Builder + { + StatusCode = (HttpStatusCode) 200, + ResponseBodyType = typeof(FibonacciResponseDto), + }.Build(), + }, + }.Build(); } } diff --git a/src/Facility.ConformanceApi/Http/HttpClientConformanceApi.g.cs b/src/Facility.ConformanceApi/Http/HttpClientConformanceApi.g.cs index 91baa59c..1b652bf0 100644 --- a/src/Facility.ConformanceApi/Http/HttpClientConformanceApi.g.cs +++ b/src/Facility.ConformanceApi/Http/HttpClientConformanceApi.g.cs @@ -3,6 +3,7 @@ // #nullable enable using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Facility.Core; @@ -87,6 +88,9 @@ public Task> MirrorTextAsync(MirrorTextRequ public Task> BodyTypesAsync(BodyTypesRequestDto request, CancellationToken cancellationToken = default) => TrySendRequestAsync(ConformanceApiHttpMapping.BodyTypesMapping, request, cancellationToken); + public Task>>> FibonacciAsync(FibonacciRequestDto request, CancellationToken cancellationToken = default) => + TrySendEventRequestAsync(ConformanceApiHttpMapping.FibonacciMapping, request, cancellationToken); + private static readonly HttpClientServiceDefaults s_defaults = new HttpClientServiceDefaults { #if NET8_0_OR_GREATER diff --git a/src/Facility.ConformanceApi/IConformanceApi.g.cs b/src/Facility.ConformanceApi/IConformanceApi.g.cs index 19e4f720..a944c35c 100644 --- a/src/Facility.ConformanceApi/IConformanceApi.g.cs +++ b/src/Facility.ConformanceApi/IConformanceApi.g.cs @@ -3,6 +3,7 @@ // #nullable enable using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Facility.Core; @@ -62,5 +63,7 @@ public partial interface IConformanceApi Task> MirrorTextAsync(MirrorTextRequestDto request, CancellationToken cancellationToken = default); Task> BodyTypesAsync(BodyTypesRequestDto request, CancellationToken cancellationToken = default); + + Task>>> FibonacciAsync(FibonacciRequestDto request, CancellationToken cancellationToken = default); } } diff --git a/src/Facility.ConformanceApi/Testing/ConformanceApiService.cs b/src/Facility.ConformanceApi/Testing/ConformanceApiService.cs index 7105e53c..7899aad3 100644 --- a/src/Facility.ConformanceApi/Testing/ConformanceApiService.cs +++ b/src/Facility.ConformanceApi/Testing/ConformanceApiService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Facility.Core; @@ -91,6 +92,36 @@ public Task> MirrorTextAsync(MirrorTextRequ public Task> BodyTypesAsync(BodyTypesRequestDto request, CancellationToken cancellationToken = default) => Task.FromResult(Execute(request)); + /// + public async Task>>> FibonacciAsync(FibonacciRequestDto request, CancellationToken cancellationToken = default) + { + if (request.Count is not { } count) + return ServiceResult.Failure(ServiceErrors.CreateRequestFieldRequired("count")); + + return ServiceResult.Success(Enumerate(count, cancellationToken)); + + static async IAsyncEnumerable> Enumerate(int count, [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (count < 0) + { + yield return ServiceResult.Failure(ServiceErrors.CreateInvalidRequest("Count must not be negative.")); + yield break; + } + + var a = 0; + var b = 1; + for (var i = 0; i < count; i++) + { + await Task.Delay(500, cancellationToken).ConfigureAwait(false); + yield return ServiceResult.Success(new FibonacciResponseDto { Value = a }); + cancellationToken.ThrowIfCancellationRequested(); + var next = a + b; + a = b; + b = next; + } + } + } + private ServiceResult Execute(ServiceDto request) { if (request is null) diff --git a/src/Facility.Core/Facility.Core.csproj b/src/Facility.Core/Facility.Core.csproj index c880ff81..ac4e22f5 100644 --- a/src/Facility.Core/Facility.Core.csproj +++ b/src/Facility.Core/Facility.Core.csproj @@ -7,6 +7,7 @@ true README.md $(NoWarn);CA1510 + true diff --git a/src/Facility.Core/Http/HttpClientService.cs b/src/Facility.Core/Http/HttpClientService.cs index a3fcfc18..9a826feb 100644 --- a/src/Facility.Core/Http/HttpClientService.cs +++ b/src/Facility.Core/Http/HttpClientService.cs @@ -1,5 +1,8 @@ using System.Diagnostics.CodeAnalysis; using System.Net.Http.Headers; +using System.Net.ServerSentEvents; +using System.Runtime.CompilerServices; +using System.Text; namespace Facility.Core.Http; @@ -60,18 +63,12 @@ protected HttpClientService(HttpClientServiceSettings? settings, Uri? defaultBas /// protected Uri? BaseUri { get; } - /// - /// Sends an HTTP request and processes the response. - /// - protected async Task> TrySendRequestAsync(HttpMethodMapping mapping, TRequest request, CancellationToken cancellationToken) + private async Task ResponseMapping)>> TryStartRequestAsync(HttpMethodMapping mapping, TRequest request, CancellationToken cancellationToken) where TRequest : ServiceDto, new() where TResponse : ServiceDto, new() { - if (mapping == null) - throw new ArgumentNullException(nameof(mapping)); - if (request == null) - throw new ArgumentNullException(nameof(request)); - + HttpRequestMessage? httpRequest = null; + HttpResponseMessage? httpResponse = null; try { // validate the request DTO @@ -90,7 +87,7 @@ protected async Task> TrySendRequestAsync> TrySendRequestAsync> TrySendRequestAsync + /// Sends an HTTP request and processes the response. + /// + protected async Task> TrySendRequestAsync(HttpMethodMapping mapping, TRequest request, CancellationToken cancellationToken) + where TRequest : ServiceDto, new() + where TResponse : ServiceDto, new() + { + if (mapping == null) + throw new ArgumentNullException(nameof(mapping)); + if (request == null) + throw new ArgumentNullException(nameof(request)); + + HttpRequestMessage? httpRequest = null; + HttpResponseMessage? httpResponse = null; + try + { + var startRequestResult = await TryStartRequestAsync(mapping, request, cancellationToken).ConfigureAwait(false); + if (startRequestResult.IsFailure) + return startRequestResult.ToFailure(); + (httpRequest, httpResponse, var responseMapping) = startRequestResult.Value; + // read the response body if necessary object? responseBody = null; if (responseMapping.ResponseBodyType != null) @@ -158,6 +188,156 @@ protected async Task> TrySendRequestAsync + /// Sends an HTTP request for an event and processes the response. + /// + protected async Task>>> TrySendEventRequestAsync(HttpMethodMapping mapping, TRequest request, CancellationToken cancellationToken) + where TRequest : ServiceDto, new() + where TResponse : ServiceDto, new() + { + if (mapping == null) + throw new ArgumentNullException(nameof(mapping)); + if (request == null) + throw new ArgumentNullException(nameof(request)); + + HttpRequestMessage? httpRequest = null; + HttpResponseMessage? httpResponse = null; + try + { + var startRequestResult = await TryStartRequestAsync(mapping, request, cancellationToken).ConfigureAwait(false); + if (startRequestResult.IsFailure) + return startRequestResult.ToFailure(); + (httpRequest, httpResponse, var responseMapping) = startRequestResult.Value; + + var enumerable = CreateAsyncEnumerable(httpRequest, httpResponse, responseMapping, ContentSerializer, m_skipResponseValidation, cancellationToken); + httpResponse = null; + httpRequest = null; + + return ServiceResult.Success(enumerable); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // HttpClient timeout + return ServiceResult.Failure(ServiceErrors.CreateTimeout()); + } + catch (Exception exception) when (ShouldCreateErrorFromException(exception)) + { + // cancellation can cause the wrong exception + cancellationToken.ThrowIfCancellationRequested(); + + // error contacting service + return ServiceResult.Failure(CreateErrorFromException(exception)); + } + finally + { + httpResponse?.Dispose(); + httpRequest?.Dispose(); + } + } + + private async IAsyncEnumerable> CreateAsyncEnumerable(HttpRequestMessage httpRequest, HttpResponseMessage httpResponse, HttpResponseMapping responseMapping, HttpContentSerializer contentSerializer, bool skipResponseValidation, [EnumeratorCancellation] CancellationToken cancellationToken) + where TResponse : ServiceDto, new() + { + Stream? stream = null; + try + { +#if NET6_0_OR_GREATER + stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); +#else + stream = await httpResponse.Content.ReadAsStreamAsync().ConfigureAwait(false); +#endif + + var sseParser = SseParser.Create(stream); + var enumerator = sseParser.EnumerateAsync(cancellationToken).GetAsyncEnumerator(cancellationToken); + await using var enumeratorScope = enumerator.ConfigureAwait(false); + while (true) + { + ServiceResult nextResult; + var stopStream = false; + + try + { + if (!await enumerator.MoveNextAsync().ConfigureAwait(false)) + break; + + var sseItem = enumerator.Current; + var isError = sseItem.EventType == "error"; + if (!isError && sseItem.EventType != SseParser.EventTypeDefault) + { + nextResult = ServiceResult.Failure(ServiceErrors.CreateInternalError("Unexpected event type: " + sseItem.EventType)); + stopStream = true; + } + else + { + var type = isError ? typeof(ServiceErrorDto) : responseMapping.ResponseBodyType!; + using var content = new StringContent(sseItem.Data, Encoding.UTF8, HttpServiceUtility.JsonMediaType); + var responseResult = await contentSerializer.ReadHttpContentAsync(type, content, cancellationToken).ConfigureAwait(false); + if (responseResult.IsFailure) + { + var error = responseResult.Error!; + error.Code = ServiceErrors.InvalidResponse; + nextResult = ServiceResult.Failure(error); + stopStream = true; + } + else if (isError) + { + nextResult = ServiceResult.Failure((ServiceErrorDto) responseResult.Value); + } + else + { + var response = responseMapping.CreateResponse(responseResult.Value); + if (!skipResponseValidation && !response.Validate(out var responseErrorMessage)) + { + nextResult = ServiceResult.Failure(ServiceErrors.CreateInvalidResponse(responseErrorMessage)); + stopStream = true; + } + else + { + nextResult = ServiceResult.Success((TResponse) responseResult.Value); + } + } + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // HttpClient timeout + nextResult = ServiceResult.Failure(ServiceErrors.CreateTimeout()); + stopStream = true; + } + catch (Exception exception) when (ShouldCreateErrorFromException(exception)) + { + // cancellation can cause the wrong exception + cancellationToken.ThrowIfCancellationRequested(); + + // error contacting service + nextResult = ServiceResult.Failure(CreateErrorFromException(exception)); + stopStream = true; + } + + yield return nextResult; + + if (stopStream) + break; + } + } + finally + { +#if NETSTANDARD2_1_OR_GREATER || NET6_0_OR_GREATER + if (stream is not null) + await stream.DisposeAsync().ConfigureAwait(false); +#else + stream?.Dispose(); +#endif + httpResponse.Dispose(); + httpRequest.Dispose(); + } } /// diff --git a/src/Facility.Core/Http/ServiceHttpHandler.cs b/src/Facility.Core/Http/ServiceHttpHandler.cs index ab451647..c5cd2bd8 100644 --- a/src/Facility.Core/Http/ServiceHttpHandler.cs +++ b/src/Facility.Core/Http/ServiceHttpHandler.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.Net; +using System.Net.Http.Headers; using System.Text.RegularExpressions; namespace Facility.Core.Http; @@ -41,10 +42,7 @@ protected ServiceHttpHandler(ServiceHttpHandlerSettings? settings) { } - /// - /// Attempts to handle a service method. - /// - protected async Task TryHandleServiceMethodAsync(HttpMethodMapping mapping, HttpRequestMessage httpRequest, Func>> invokeMethodAsync, CancellationToken cancellationToken) + private async Task<(ServiceHttpContext? Context, HttpResponseMessage? Response)> StartHandleServiceMethodAsync(HttpMethodMapping mapping, HttpRequestMessage httpRequest, CancellationToken cancellationToken) where TRequest : ServiceDto, new() where TResponse : ServiceDto, new() { @@ -52,24 +50,22 @@ protected ServiceHttpHandler(ServiceHttpHandlerSettings? settings) throw new ArgumentNullException(nameof(mapping)); if (httpRequest == null) throw new ArgumentNullException(nameof(httpRequest)); - if (invokeMethodAsync == null) - throw new ArgumentNullException(nameof(invokeMethodAsync)); if (httpRequest.RequestUri == null) throw new ArgumentException("RequestUri must be specified.", nameof(httpRequest)); if (httpRequest.Method != mapping.HttpMethod) - return null; + return default; var pathParameters = TryMatchHttpRoute(httpRequest.RequestUri, m_rootPath + mapping.Path); if (pathParameters == null) - return null; + return default; var context = new ServiceHttpContext(); ServiceHttpContext.SetContext(httpRequest, context); var aspectHttpResponse = await AdaptTask(RequestReceivedAsync(httpRequest, cancellationToken)).ConfigureAwait(true); if (aspectHttpResponse != null) - return aspectHttpResponse; + return (Context: context, Response: aspectHttpResponse); ServiceErrorDto? error = null; @@ -95,7 +91,6 @@ protected ServiceHttpHandler(ServiceHttpHandlerSettings? settings) } } - TResponse? response = null; if (error == null) { var request = mapping.CreateRequest(requestBody); @@ -111,32 +106,57 @@ protected ServiceHttpHandler(ServiceHttpHandlerSettings? settings) context.Request = request; if (!m_skipRequestValidation && !request.Validate(out var requestErrorMessage)) - { error = ServiceErrors.CreateInvalidRequest(requestErrorMessage); - } - else - { - var methodResult = await invokeMethodAsync(request, cancellationToken).ConfigureAwait(true); - if (methodResult.IsFailure) - { - error = methodResult.Error; - } - else - { - response = methodResult.Value; + } - if (!m_skipResponseValidation && !response.Validate(out var responseErrorMessage)) - { - error = ServiceErrors.CreateInvalidResponse(responseErrorMessage); - response = null; - } - } - } + if (error != null) + { + context.Result = ServiceResult.Failure(error); + var httpResponse = await CreateHttpResponseForErrorAsync(error, httpRequest).ConfigureAwait(false); + await AdaptTask(ResponseReadyAsync(httpResponse, cancellationToken)).ConfigureAwait(true); + return (context, httpResponse); + } + + return (context, null); + } + + /// + /// Attempts to handle a service method. + /// + protected async Task TryHandleServiceMethodAsync(HttpMethodMapping mapping, HttpRequestMessage httpRequest, Func>> invokeMethodAsync, CancellationToken cancellationToken) + where TRequest : ServiceDto, new() + where TResponse : ServiceDto, new() + { + if (invokeMethodAsync == null) + throw new ArgumentNullException(nameof(invokeMethodAsync)); - context.Result = error != null ? ServiceResult.Failure(error) : ServiceResult.Success(response!); + var (context, httpResponse) = await StartHandleServiceMethodAsync(mapping, httpRequest, cancellationToken).ConfigureAwait(true); + if (context == null) + return null; + if (httpResponse != null) + return httpResponse; + + var request = (TRequest) context.Request!; + ServiceErrorDto? error = null; + TResponse? response = null; + var methodResult = await invokeMethodAsync(request, cancellationToken).ConfigureAwait(true); + if (methodResult.IsFailure) + { + error = methodResult.Error; } + else + { + response = methodResult.Value; + + if (!m_skipResponseValidation && !response.Validate(out var responseErrorMessage)) + { + error = ServiceErrors.CreateInvalidResponse(responseErrorMessage); + response = null; + } + } + + context.Result = error != null ? ServiceResult.Failure(error) : ServiceResult.Success(response!); - HttpResponseMessage httpResponse; if (error == null) { var responseMappingGroups = mapping.ResponseMappings @@ -167,8 +187,23 @@ protected ServiceHttpHandler(ServiceHttpHandlerSettings? settings) { throw new InvalidOperationException($"Found {responseMappingGroups.Sum(x => x.Count())} valid HTTP responses for {typeof(TResponse).Name}: {response}"); } + + httpResponse.RequestMessage = httpRequest; } else + { + httpResponse = await CreateHttpResponseForErrorAsync(error, httpRequest).ConfigureAwait(false); + } + + await AdaptTask(ResponseReadyAsync(httpResponse, cancellationToken)).ConfigureAwait(true); + + return httpResponse; + } + + private async Task CreateHttpResponseForErrorAsync(ServiceErrorDto error, HttpRequestMessage httpRequest) + { + HttpResponseMessage? httpResponse = null; + try { var statusCode = error.Code == null ? HttpStatusCode.InternalServerError : (TryGetCustomHttpStatusCode(error.Code) ?? HttpServiceErrors.TryGetHttpStatusCode(error.Code) ?? HttpStatusCode.InternalServerError); @@ -180,14 +215,156 @@ protected ServiceHttpHandler(ServiceHttpHandlerSettings? settings) if (m_disableChunkedTransfer) await httpResponse.Content.LoadIntoBufferAsync().ConfigureAwait(false); } + + httpResponse.RequestMessage = httpRequest; + + var returnValue = httpResponse; + httpResponse = null; + return returnValue; + } + finally + { + httpResponse?.Dispose(); + } + } + + /// + /// Attempts to handle a service method. + /// + protected async Task TryHandleServiceEventAsync(HttpMethodMapping mapping, HttpRequestMessage httpRequest, Func>>>> invokeEventAsync, CancellationToken cancellationToken) + where TRequest : ServiceDto, new() + where TResponse : ServiceDto, new() + { + if (invokeEventAsync == null) + throw new ArgumentNullException(nameof(invokeEventAsync)); + + var (context, httpResponse) = await StartHandleServiceMethodAsync(mapping, httpRequest, cancellationToken).ConfigureAwait(true); + if (context == null) + return null; + if (httpResponse != null) + return httpResponse; + + var request = (TRequest) context.Request!; + var eventResult = await invokeEventAsync(request, cancellationToken).ConfigureAwait(true); + if (eventResult.IsFailure) + { + var error = eventResult.Error!; + context.Result = ServiceResult.Failure(error); + httpResponse = await CreateHttpResponseForErrorAsync(error, httpRequest).ConfigureAwait(false); + } + else + { + if (mapping.ResponseMappings.Count != 1) + throw new InvalidOperationException($"Expected exactly one response mapping for {typeof(TResponse).Name}."); + + var responseMapping = mapping.ResponseMappings[0]; + httpResponse = new HttpResponseMessage(responseMapping.StatusCode) + { + Content = new EventStreamHttpContent(eventResult.Value, responseMapping, m_contentSerializer, m_skipResponseValidation), + RequestMessage = httpRequest, + }; } - httpResponse.RequestMessage = httpRequest; await AdaptTask(ResponseReadyAsync(httpResponse, cancellationToken)).ConfigureAwait(true); return httpResponse; } + private sealed class EventStreamHttpContent : HttpContent + where TResponse : ServiceDto, new() + { + public EventStreamHttpContent(IAsyncEnumerable> enumerable, HttpResponseMapping responseMapping, HttpContentSerializer contentSerializer, bool skipResponseValidation) + { + m_enumerable = enumerable; + m_responseMapping = responseMapping; + m_contentSerializer = contentSerializer; + m_skipResponseValidation = skipResponseValidation; + + Headers.ContentType = new MediaTypeHeaderValue("text/event-stream"); + } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + DoSerializeToStreamAsync(stream, CancellationToken.None); + +#if NET6_0_OR_GREATER + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + DoSerializeToStreamAsync(stream, cancellationToken); +#endif + + private async Task DoSerializeToStreamAsync(Stream stream, CancellationToken cancellationToken) + { + await foreach (var result in m_enumerable.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + object dto; + var isError = false; + var shouldStop = false; + + if (result.IsSuccess) + { + var response = result.Value; + if (!m_skipResponseValidation && !response.Validate(out var responseErrorMessage)) + { + dto = ServiceErrors.CreateInvalidResponse(responseErrorMessage); + isError = true; + shouldStop = true; + } + else + { + dto = m_responseMapping.GetResponseBody(response)!; + } + } + else + { + dto = result.Error!; + isError = true; + } + + if (isError) + { +#if NETSTANDARD2_1_OR_GREATER || NET6_0_OR_GREATER + await stream.WriteAsync(s_errorEventLine, cancellationToken).ConfigureAwait(false); +#else + await stream.WriteAsync(s_errorEventLine.ToArray(), 0, s_errorEventLine.Length, cancellationToken).ConfigureAwait(false); +#endif + } + +#if NETSTANDARD2_1_OR_GREATER || NET6_0_OR_GREATER + await stream.WriteAsync(s_dataPrefix, cancellationToken).ConfigureAwait(false); +#else + await stream.WriteAsync(s_dataPrefix.ToArray(), 0, s_dataPrefix.Length, cancellationToken).ConfigureAwait(false); +#endif + +#if NET6_0_OR_GREATER + using (var content = m_contentSerializer.CreateHttpContent(dto)) + await content.CopyToAsync(stream, cancellationToken).ConfigureAwait(false); +#else + using (var content = m_contentSerializer.CreateHttpContent(dto)) + await content.CopyToAsync(stream).ConfigureAwait(false); +#endif + +#if NETSTANDARD2_1_OR_GREATER || NET6_0_OR_GREATER + await stream.WriteAsync(s_twoNewlines, cancellationToken).ConfigureAwait(false); +#else + await stream.WriteAsync(s_twoNewlines.ToArray(), 0, s_twoNewlines.Length, cancellationToken).ConfigureAwait(false); +#endif + + if (shouldStop) + break; + } + } + + protected override bool TryComputeLength(out long length) + { + length = -1; + return false; + } + + private readonly IAsyncEnumerable> m_enumerable; + private readonly HttpResponseMapping m_responseMapping; + private readonly HttpContentSerializer m_contentSerializer; + private readonly bool m_skipResponseValidation; + } + /// /// Returns the HTTP status code for a custom error code. /// @@ -334,6 +511,10 @@ private HttpContentSerializer GetHttpContentSerializer(Type objectType) => private static readonly Regex s_regexPathParameterRegex = new("""\{([a-zA-Z][a-zA-Z0-9]*)\}""", RegexOptions.CultureInvariant); private static readonly char[] s_equalSign = ['=']; + private static readonly ReadOnlyMemory s_dataPrefix = "data: "u8.ToArray(); + private static readonly ReadOnlyMemory s_errorEventLine = "event: error\n"u8.ToArray(); + private static readonly ReadOnlyMemory s_twoNewlines = "\n\n"u8.ToArray(); + private readonly string m_rootPath; private readonly bool m_synchronous; private readonly HttpContentSerializer m_contentSerializer; diff --git a/src/Facility.Core/Http/StandardHttpContentSerializer.cs b/src/Facility.Core/Http/StandardHttpContentSerializer.cs index 059e9853..b67f9fb7 100644 --- a/src/Facility.Core/Http/StandardHttpContentSerializer.cs +++ b/src/Facility.Core/Http/StandardHttpContentSerializer.cs @@ -51,8 +51,16 @@ public DelegateHttpContent(string mediaType, object content, ServiceSerializer s m_serializer = serializer; } - protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) => - await m_serializer.ToStreamAsync(m_content, stream, CancellationToken.None).ConfigureAwait(false); + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + DoSerializeToStreamAsync(stream, CancellationToken.None); + +#if NET6_0_OR_GREATER + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + DoSerializeToStreamAsync(stream, cancellationToken); +#endif + + private async Task DoSerializeToStreamAsync(Stream stream, CancellationToken cancellationToken) => + await m_serializer.ToStreamAsync(m_content, stream, cancellationToken).ConfigureAwait(false); protected override bool TryComputeLength(out long length) { diff --git a/src/Facility.Core/IServiceEventInfo.cs b/src/Facility.Core/IServiceEventInfo.cs new file mode 100644 index 00000000..fae2d5d7 --- /dev/null +++ b/src/Facility.Core/IServiceEventInfo.cs @@ -0,0 +1,24 @@ +namespace Facility.Core; + +/// +/// Information about a Facility service event. +/// +/// Do not implement this interface. New members on this interface +/// will not be considered a breaking change. +public interface IServiceEventInfo +{ + /// + /// The name of the event. + /// + string Name { get; } + + /// + /// The name of the service. + /// + string ServiceName { get; } + + /// + /// Invokes the method on the specified service instance. + /// + Task>>> InvokeAsync(object service, ServiceDto request, CancellationToken cancellationToken = default); +} diff --git a/src/Facility.Core/ServiceDelegate.cs b/src/Facility.Core/ServiceDelegate.cs new file mode 100644 index 00000000..991494d5 --- /dev/null +++ b/src/Facility.Core/ServiceDelegate.cs @@ -0,0 +1,37 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Facility.Core; + +/// +/// Used to delegate a service. +/// +[SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix", Justification = "Best alternative to obsolete ServiceDelegator.")] +public abstract class ServiceDelegate +{ + /// + /// Delegates the service method. + /// + public virtual Task> InvokeMethodAsync(IServiceMethodInfo methodInfo, ServiceDto request, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + /// + /// Delegates the service event. + /// + public virtual Task>>> InvokeEventAsync(IServiceEventInfo eventInfo, ServiceDto request, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + /// + /// Creates a service delegate from a service delegator. + /// + public static ServiceDelegate FromDelegator(ServiceDelegator delegator) => new DelegatorServiceDelegate(delegator); + + private sealed class DelegatorServiceDelegate : ServiceDelegate + { + public DelegatorServiceDelegate(ServiceDelegator delegator) => m_delegator = delegator ?? throw new ArgumentNullException(nameof(delegator)); + + public override Task> InvokeMethodAsync(IServiceMethodInfo methodInfo, ServiceDto request, CancellationToken cancellationToken = default) => + m_delegator(methodInfo, request, cancellationToken); + + private readonly ServiceDelegator m_delegator; + } +} diff --git a/src/Facility.Core/ServiceDelegates.cs b/src/Facility.Core/ServiceDelegates.cs new file mode 100644 index 00000000..27a8acf7 --- /dev/null +++ b/src/Facility.Core/ServiceDelegates.cs @@ -0,0 +1,94 @@ +using System.Runtime.CompilerServices; + +namespace Facility.Core; + +/// +/// Common service delegates. +/// +public static class ServiceDelegates +{ + /// + /// All methods throw . + /// + public static ServiceDelegate NotImplemented => NotImplementedServiceDelegate.Instance; + + /// + /// Forwards all methods to the inner service. + /// + public static ServiceDelegate Forward(object inner) => new ForwardingServiceDelegate(inner); + + /// + /// Validates requests and responses. + /// + public static ServiceDelegate Validate(object inner) => new ValidatingServiceDelegate(inner); + + private sealed class NotImplementedServiceDelegate : ServiceDelegate + { + public static ServiceDelegate Instance { get; } = new NotImplementedServiceDelegate(); + } + + private sealed class ForwardingServiceDelegate : ServiceDelegate + { + public ForwardingServiceDelegate(object inner) => m_inner = inner ?? throw new ArgumentNullException(nameof(inner)); + + public override async Task> InvokeMethodAsync(IServiceMethodInfo methodInfo, ServiceDto request, CancellationToken cancellationToken = default) => + await methodInfo.InvokeAsync(m_inner, request, cancellationToken).ConfigureAwait(false); + + public override async Task>>> InvokeEventAsync(IServiceEventInfo eventInfo, ServiceDto request, CancellationToken cancellationToken = default) => + await eventInfo.InvokeAsync(m_inner, request, cancellationToken).ConfigureAwait(false); + + private readonly object m_inner; + } + + private sealed class ValidatingServiceDelegate : ServiceDelegate + { + public ValidatingServiceDelegate(object inner) => m_inner = inner ?? throw new ArgumentNullException(nameof(inner)); + + public override async Task> InvokeMethodAsync(IServiceMethodInfo methodInfo, ServiceDto request, CancellationToken cancellationToken = default) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + if (!request.Validate(out var requestErrorMessage)) + return ServiceResult.Failure(ServiceErrors.CreateInvalidRequest(requestErrorMessage)); + + var response = await methodInfo.InvokeAsync(m_inner, request, cancellationToken).ConfigureAwait(false); + + if (!response.Validate(out var responseErrorMessage)) + return ServiceResult.Failure(ServiceErrors.CreateInvalidResponse(responseErrorMessage)); + + return response; + } + + public override async Task>>> InvokeEventAsync(IServiceEventInfo eventInfo, ServiceDto request, CancellationToken cancellationToken = default) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + if (!request.Validate(out var requestErrorMessage)) + return ServiceResult.Failure(ServiceErrors.CreateInvalidRequest(requestErrorMessage)); + + var result = await eventInfo.InvokeAsync(m_inner, request, cancellationToken).ConfigureAwait(false); + if (result.IsFailure) + return result.ToFailure(); + + return ServiceResult.Success(Enumerate(result.Value, cancellationToken)); + + static async IAsyncEnumerable> Enumerate(IAsyncEnumerable> enumerable, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var result in enumerable.WithCancellation(cancellationToken)) + { + if (!result.Validate(out var resultErrorMessage)) + { + yield return ServiceResult.Failure(ServiceErrors.CreateInvalidResponse(resultErrorMessage)); + yield break; + } + + yield return result; + } + } + } + + private readonly object m_inner; + } +} diff --git a/src/Facility.Core/ServiceDelegators.cs b/src/Facility.Core/ServiceDelegators.cs index 6a88ff2c..cc238b2e 100644 --- a/src/Facility.Core/ServiceDelegators.cs +++ b/src/Facility.Core/ServiceDelegators.cs @@ -3,6 +3,7 @@ namespace Facility.Core; /// /// Common service delegators. /// +[Obsolete("Use ServiceDelegates.")] public static class ServiceDelegators { /// diff --git a/src/Facility.Core/ServiceEventInfo.cs b/src/Facility.Core/ServiceEventInfo.cs new file mode 100644 index 00000000..933ddd80 --- /dev/null +++ b/src/Facility.Core/ServiceEventInfo.cs @@ -0,0 +1,54 @@ +using System.Runtime.CompilerServices; + +namespace Facility.Core; + +/// +/// Helpers for service event information. +/// +public static class ServiceEventInfo +{ + /// + /// Creates service event information. + /// + /// For internal use by generated code. + public static IServiceEventInfo Create(string name, string serviceName, Func>>>>> getInvokeAsync) + where TRequestDto : ServiceDto + where TResponseDto : ServiceDto + { + async Task>>> InvokeAsync(object api, ServiceDto request, CancellationToken cancellationToken) + { + var result = await getInvokeAsync((TApi) api)((TRequestDto) request, cancellationToken).ConfigureAwait(false); + if (result.IsFailure) + return result.ToFailure(); + + return ServiceResult.Success(Enumerate(result.Value, cancellationToken)); + + static async IAsyncEnumerable> Enumerate(IAsyncEnumerable> enumerable, [EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var result in enumerable.WithCancellation(cancellationToken)) + yield return result.Cast(); + } + } + + return new StandardServiceEventInfo(name, serviceName, InvokeAsync); + } + + private sealed class StandardServiceEventInfo : IServiceEventInfo + { + public string Name { get; } + + public string ServiceName { get; } + + public Task>>> InvokeAsync(object service, ServiceDto request, CancellationToken cancellationToken = default) => + m_invokeAsync(service, request, cancellationToken); + + internal StandardServiceEventInfo(string name, string serviceName, Func>>>> invokeAsync) + { + Name = name; + ServiceName = serviceName; + m_invokeAsync = invokeAsync; + } + + private readonly Func>>>> m_invokeAsync; + } +} diff --git a/src/Facility.Core/System.Net.ServerSentEvents.cs b/src/Facility.Core/System.Net.ServerSentEvents.cs new file mode 100644 index 00000000..c5ae3e14 --- /dev/null +++ b/src/Facility.Core/System.Net.ServerSentEvents.cs @@ -0,0 +1,623 @@ +// Copied from: https://github.com/stephentoub/openai-dotnet/blob/6a273fd6ce8083d3dd8d8339f4df4bdd39038b74/src/Utility/System.Net.ServerSentEvents.cs +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// This file contains a source copy of: +// https://github.com/dotnet/runtime/tree/2bd15868f12ace7cee9999af61d5c130b2603f04/src/libraries/System.Net.ServerSentEvents/src/System/Net/ServerSentEvents +// Once the System.Net.ServerSentEvents package is available, this file should be removed and replaced with a package reference. +// +// The only changes made to this code from the original are: +// - Enabled nullable reference types at file scope, and use a few null suppression operators to work around the lack of [NotNull] +// - Put into a single file for ease of management (it should not be edited in this repo). +// - Changed public types to be internal. +// - Removed a use of a [NotNull] attribute to assist in netstandard2.0 compilation. +// - Replaced a reference to a .resx string with an inline constant. + +#nullable enable + +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using System.Threading; + +namespace System.Net.ServerSentEvents +{ + /// Represents a server-sent event. + /// Specifies the type of data payload in the event. + internal readonly struct SseItem + { + /// Initializes the server-sent event. + /// The event's payload. + /// The event's type. + public SseItem(T data, string eventType) + { + Data = data; + EventType = eventType; + } + + /// Gets the event's payload. + public T Data { get; } + + /// Gets the event's type. + public string EventType { get; } + } + + /// Encapsulates a method for parsing the bytes payload of a server-sent event. + /// Specifies the type of the return value of the parser. + /// The event's type. + /// The event's payload bytes. + /// The parsed . + internal delegate T SseItemParser(string eventType, ReadOnlySpan data); + + /// Provides a parser for parsing server-sent events. + internal static class SseParser + { + /// The default ("message") for an event that did not explicitly specify a type. + public const string EventTypeDefault = "message"; + + /// Creates a parser for parsing a of server-sent events into a sequence of values. + /// The stream containing the data to parse. + /// + /// The enumerable of strings, which may be enumerated synchronously or asynchronously. The strings + /// are decoded from the UTF8-encoded bytes of the payload of each event. + /// + /// is null. + /// + /// This overload has behavior equivalent to calling with a delegate + /// that decodes the data of each event using 's GetString method. + /// + public static SseParser Create(Stream sseStream) => + Create(sseStream, static (_, bytes) => Utf8GetString(bytes)); + + /// Creates a parser for parsing a of server-sent events into a sequence of values. + /// Specifies the type of data in each event. + /// The stream containing the data to parse. + /// The parser to use to transform each payload of bytes into a data element. + /// The enumerable, which may be enumerated synchronously or asynchronously. + /// is null. + /// is null. + public static SseParser Create(Stream sseStream, SseItemParser itemParser) => + new SseParser( + sseStream ?? throw new ArgumentNullException(nameof(sseStream)), + itemParser ?? throw new ArgumentNullException(nameof(itemParser))); + + /// Encoding.UTF8.GetString(bytes) + internal static string Utf8GetString(ReadOnlySpan bytes) + { +#if NET + return Encoding.UTF8.GetString(bytes); +#else + unsafe + { + fixed (byte* ptr = bytes) + { + return ptr is null ? + string.Empty : + Encoding.UTF8.GetString(ptr, bytes.Length); + } + } +#endif + } + } + + /// Provides a parser for server-sent events information. + /// Specifies the type of data parsed from an event. + internal sealed class SseParser + { + // For reference: + // Specification: https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events + + /// Carriage Return. + private const byte CR = (byte)'\r'; + /// Line Feed. + private const byte LF = (byte)'\n'; + /// Carriage Return Line Feed. + private static ReadOnlySpan CRLF => "\r\n"u8; + + /// The default size of an ArrayPool buffer to rent. + /// Larger size used by default to minimize number of reads. Smaller size used in debug to stress growth/shifting logic. + private const int DefaultArrayPoolRentSize = +#if DEBUG + 16; +#else + 1024; +#endif + + /// The stream to be parsed. + private readonly Stream _stream; + /// The parser delegate used to transform bytes into a . + private readonly SseItemParser _itemParser; + + /// Indicates whether the enumerable has already been used for enumeration. + private int _used; + + /// Buffer, either empty or rented, containing the data being read from the stream while looking for the next line. + private byte[] _lineBuffer = []; + /// The starting offset of valid data in . + private int _lineOffset; + /// The length of valid data in , starting from . + private int _lineLength; + /// The index in where a newline ('\r', '\n', or "\r\n") was found. + private int _newlineIndex; + /// The index in of characters already checked for newlines. + /// + /// This is to avoid O(LineLength^2) behavior in the rare case where we have long lines that are built-up over multiple reads. + /// We want to avoid re-checking the same characters we've already checked over and over again. + /// + private int _lastSearchedForNewline; + /// Set when eof has been reached in the stream. + private bool _eof; + + /// Rented buffer containing buffered data for the next event. + private byte[]? _dataBuffer; + /// The length of valid data in , starting from index 0. + private int _dataLength; + /// Whether data has been appended to . + /// This can be different than != 0 if empty data was appended. + private bool _dataAppended; + + /// The event type for the next event. + private string _eventType = SseParser.EventTypeDefault; + + /// Initialize the enumerable. + /// The stream to parse. + /// The function to use to parse payload bytes into a . + internal SseParser(Stream stream, SseItemParser itemParser) + { + _stream = stream; + _itemParser = itemParser; + } + + /// Gets an enumerable of the server-sent events from this parser. + public IEnumerable> Enumerate() + { + // Validate that the parser is only used for one enumeration. + ThrowIfNotFirstEnumeration(); + + // Rent a line buffer. This will grow as needed. The line buffer is what's passed to the stream, + // so we want it to be large enough to reduce the number of reads we need to do when data is + // arriving quickly. (In debug, we use a smaller buffer to stress the growth and shifting logic.) + _lineBuffer = ArrayPool.Shared.Rent(DefaultArrayPoolRentSize); + try + { + // Spec: "Event streams in this format must always be encoded as UTF-8". + // Skip a UTF8 BOM if it exists at the beginning of the stream. (The BOM is defined as optional in the SSE grammar.) + while (FillLineBuffer() != 0 && _lineLength < Utf8Bom.Length) ; + SkipBomIfPresent(); + + // Process all events in the stream. + while (true) + { + // See if there's a complete line in data already read from the stream. Lines are permitted to + // end with CR, LF, or CRLF. Look for all of them and if we find one, process the line. However, + // if we only find a CR and it's at the end of the read data, don't process it now, as we want + // to process it together with an LF that might immediately follow, rather than treating them + // as two separate characters, in which case we'd incorrectly process the CR as a line by itself. + GetNextSearchOffsetAndLength(out int searchOffset, out int searchLength); + _newlineIndex = _lineBuffer.AsSpan(searchOffset, searchLength).IndexOfAny(CR, LF); + if (_newlineIndex >= 0) + { + _lastSearchedForNewline = -1; + _newlineIndex += searchOffset; + if (_lineBuffer[_newlineIndex] is LF || // the newline is LF + _newlineIndex - _lineOffset + 1 < _lineLength || // we must have CR and we have whatever comes after it + _eof) // if we get here, we know we have a CR at the end of the buffer, so it's definitely the whole newline if we've hit EOF + { + // Process the line. + if (ProcessLine(out SseItem sseItem, out int advance)) + { + yield return sseItem; + } + + // Move past the line. + _lineOffset += advance; + _lineLength -= advance; + continue; + } + } + else + { + // Record the last position searched for a newline. The next time we search, + // we'll search from here rather than from _lineOffset, in order to avoid searching + // the same characters again. + _lastSearchedForNewline = _lineOffset + _lineLength; + } + + // We've processed everything in the buffer we currently can, so if we've already read EOF, we're done. + if (_eof) + { + // Spec: "Once the end of the file is reached, any pending data must be discarded. (If the file ends in the middle of an + // event, before the final empty line, the incomplete event is not dispatched.)" + break; + } + + // Read more data into the buffer. + FillLineBuffer(); + } + } + finally + { + ArrayPool.Shared.Return(_lineBuffer); + if (_dataBuffer is not null) + { + ArrayPool.Shared.Return(_dataBuffer); + } + } + } + + /// Gets an asynchronous enumerable of the server-sent events from this parser. + /// The cancellation token to use to cancel the enumeration. + /// The parser has already been enumerated. Such an exception may propagate out of a call to . + /// The enumeration was canceled. Such an exception may propagate out of a call to . + public async IAsyncEnumerable> EnumerateAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Validate that the parser is only used for one enumeration. + ThrowIfNotFirstEnumeration(); + + // Rent a line buffer. This will grow as needed. The line buffer is what's passed to the stream, + // so we want it to be large enough to reduce the number of reads we need to do when data is + // arriving quickly. (In debug, we use a smaller buffer to stress the growth and shifting logic.) + _lineBuffer = ArrayPool.Shared.Rent(DefaultArrayPoolRentSize); + try + { + // Spec: "Event streams in this format must always be encoded as UTF-8". + // Skip a UTF8 BOM if it exists at the beginning of the stream. (The BOM is defined as optional in the SSE grammar.) + while (await FillLineBufferAsync(cancellationToken).ConfigureAwait(false) != 0 && _lineLength < Utf8Bom.Length) ; + SkipBomIfPresent(); + + // Process all events in the stream. + while (true) + { + // See if there's a complete line in data already read from the stream. Lines are permitted to + // end with CR, LF, or CRLF. Look for all of them and if we find one, process the line. However, + // if we only find a CR and it's at the end of the read data, don't process it now, as we want + // to process it together with an LF that might immediately follow, rather than treating them + // as two separate characters, in which case we'd incorrectly process the CR as a line by itself. + GetNextSearchOffsetAndLength(out int searchOffset, out int searchLength); + _newlineIndex = _lineBuffer.AsSpan(searchOffset, searchLength).IndexOfAny(CR, LF); + if (_newlineIndex >= 0) + { + _lastSearchedForNewline = -1; + _newlineIndex += searchOffset; + if (_lineBuffer[_newlineIndex] is LF || // newline is LF + _newlineIndex - _lineOffset + 1 < _lineLength || // newline is CR, and we have whatever comes after it + _eof) // if we get here, we know we have a CR at the end of the buffer, so it's definitely the whole newline if we've hit EOF + { + // Process the line. + if (ProcessLine(out SseItem sseItem, out int advance)) + { + yield return sseItem; + } + + // Move past the line. + _lineOffset += advance; + _lineLength -= advance; + continue; + } + } + else + { + // Record the last position searched for a newline. The next time we search, + // we'll search from here rather than from _lineOffset, in order to avoid searching + // the same characters again. + _lastSearchedForNewline = searchOffset + searchLength; + } + + // We've processed everything in the buffer we currently can, so if we've already read EOF, we're done. + if (_eof) + { + // Spec: "Once the end of the file is reached, any pending data must be discarded. (If the file ends in the middle of an + // event, before the final empty line, the incomplete event is not dispatched.)" + break; + } + + // Read more data into the buffer. + await FillLineBufferAsync(cancellationToken).ConfigureAwait(false); + } + } + finally + { + ArrayPool.Shared.Return(_lineBuffer); + if (_dataBuffer is not null) + { + ArrayPool.Shared.Return(_dataBuffer); + } + } + } + + /// Gets the next index and length with which to perform a newline search. + private void GetNextSearchOffsetAndLength(out int searchOffset, out int searchLength) + { + if (_lastSearchedForNewline > _lineOffset) + { + searchOffset = _lastSearchedForNewline; + searchLength = _lineLength - (_lastSearchedForNewline - _lineOffset); + } + else + { + searchOffset = _lineOffset; + searchLength = _lineLength; + } + + Debug.Assert(searchOffset >= _lineOffset, $"{searchOffset}, {_lineLength}"); + Debug.Assert(searchOffset <= _lineOffset + _lineLength, $"{searchOffset}, {_lineOffset}, {_lineLength}"); + Debug.Assert(searchOffset <= _lineBuffer.Length, $"{searchOffset}, {_lineBuffer.Length}"); + + Debug.Assert(searchLength >= 0, $"{searchLength}"); + Debug.Assert(searchLength <= _lineLength, $"{searchLength}, {_lineLength}"); + } + + private int GetNewLineLength() + { + Debug.Assert(_newlineIndex - _lineOffset < _lineLength, "Expected to be positioned at a non-empty newline"); + return _lineBuffer.AsSpan(_newlineIndex, _lineLength - (_newlineIndex - _lineOffset)).StartsWith(CRLF) ? 2 : 1; + } + + /// + /// If there's no room remaining in the line buffer, either shifts the contents + /// left or grows the buffer in order to make room for the next read. + /// + private void ShiftOrGrowLineBufferIfNecessary() + { + // If data we've read is butting up against the end of the buffer and + // it's not taking up the entire buffer, slide what's there down to + // the beginning, making room to read more data into the buffer (since + // there's no newline in the data that's there). Otherwise, if the whole + // buffer is full, grow the buffer to accommodate more data, since, again, + // what's there doesn't contain a newline and thus a line is longer than + // the current buffer accommodates. + if (_lineOffset + _lineLength == _lineBuffer.Length) + { + if (_lineOffset != 0) + { + _lineBuffer.AsSpan(_lineOffset, _lineLength).CopyTo(_lineBuffer); + if (_lastSearchedForNewline >= 0) + { + _lastSearchedForNewline -= _lineOffset; + } + _lineOffset = 0; + } + else if (_lineLength == _lineBuffer.Length) + { + GrowBuffer(ref _lineBuffer!, _lineBuffer.Length * 2); + } + } + } + + /// Processes a complete line from the SSE stream. + /// The parsed item if the method returns true. + /// How many characters to advance in the line buffer. + /// true if an SSE item was successfully parsed; otherwise, false. + private bool ProcessLine(out SseItem sseItem, out int advance) + { + ReadOnlySpan line = _lineBuffer.AsSpan(_lineOffset, _newlineIndex - _lineOffset); + + // Spec: "If the line is empty (a blank line) Dispatch the event" + if (line.IsEmpty) + { + advance = GetNewLineLength(); + + if (_dataAppended) + { + sseItem = new SseItem(_itemParser(_eventType, _dataBuffer.AsSpan(0, _dataLength)), _eventType); + _eventType = SseParser.EventTypeDefault; + _dataLength = 0; + _dataAppended = false; + return true; + } + + sseItem = default; + return false; + } + + // Find the colon separating the field name and value. + int colonPos = line.IndexOf((byte)':'); + ReadOnlySpan fieldName; + ReadOnlySpan fieldValue; + if (colonPos >= 0) + { + // Spec: "Collect the characters on the line before the first U+003A COLON character (:), and let field be that string." + fieldName = line.Slice(0, colonPos); + + // Spec: "Collect the characters on the line after the first U+003A COLON character (:), and let value be that string. + // If value starts with a U+0020 SPACE character, remove it from value." + fieldValue = line.Slice(colonPos + 1); + if (!fieldValue.IsEmpty && fieldValue[0] == (byte)' ') + { + fieldValue = fieldValue.Slice(1); + } + } + else + { + // Spec: "using the whole line as the field name, and the empty string as the field value." + fieldName = line; + fieldValue = []; + } + + if (fieldName.SequenceEqual("data"u8)) + { + // Spec: "Append the field value to the data buffer, then append a single U+000A LINE FEED (LF) character to the data buffer." + // Spec: "If the data buffer's last character is a U+000A LINE FEED (LF) character, then remove the last character from the data buffer." + + // If there's nothing currently in the data buffer and we can easily detect that this line is immediately followed by + // an empty line, we can optimize it to just handle the data directly from the line buffer, rather than first copying + // into the data buffer and dispatching from there. + if (!_dataAppended) + { + int newlineLength = GetNewLineLength(); + ReadOnlySpan remainder = _lineBuffer.AsSpan(_newlineIndex + newlineLength, _lineLength - line.Length - newlineLength); + if (!remainder.IsEmpty && + (remainder[0] is LF || (remainder[0] is CR && remainder.Length > 1))) + { + advance = line.Length + newlineLength + (remainder.StartsWith(CRLF) ? 2 : 1); + sseItem = new SseItem(_itemParser(_eventType, fieldValue), _eventType); + _eventType = SseParser.EventTypeDefault; + return true; + } + } + + // We need to copy the data from the data buffer to the line buffer. Make sure there's enough room. + if (_dataBuffer is null || _dataLength + _lineLength + 1 > _dataBuffer.Length) + { + GrowBuffer(ref _dataBuffer, _dataLength + _lineLength + 1); + } + + // Append a newline if there's already content in the buffer. + // Then copy the field value to the data buffer + if (_dataAppended) + { + _dataBuffer![_dataLength++] = LF; + } + fieldValue.CopyTo(_dataBuffer.AsSpan(_dataLength)); + _dataLength += fieldValue.Length; + _dataAppended = true; + } + else if (fieldName.SequenceEqual("event"u8)) + { + // Spec: "Set the event type buffer to field value." + _eventType = SseParser.Utf8GetString(fieldValue); + } + else if (fieldName.SequenceEqual("id"u8)) + { + // Spec: "If the field value does not contain U+0000 NULL, then set the last event ID buffer to the field value. Otherwise, ignore the field." + if (fieldValue.IndexOf((byte)'\0') < 0) + { + // Note that fieldValue might be empty, in which case LastEventId will naturally be reset to the empty string. This is per spec. + LastEventId = SseParser.Utf8GetString(fieldValue); + } + } + else if (fieldName.SequenceEqual("retry"u8)) + { + // Spec: "If the field value consists of only ASCII digits, then interpret the field value as an integer in base ten, + // and set the event stream's reconnection time to that integer. Otherwise, ignore the field." + if (long.TryParse( +#if NET8_0_OR_GREATER + fieldValue, +#else + SseParser.Utf8GetString(fieldValue), +#endif + NumberStyles.None, CultureInfo.InvariantCulture, out long milliseconds)) + { + ReconnectionInterval = TimeSpan.FromMilliseconds(milliseconds); + } + } + else + { + // We'll end up here if the line starts with a colon, producing an empty field name, or if the field name is otherwise unrecognized. + // Spec: "If the line starts with a U+003A COLON character (:) Ignore the line." + // Spec: "Otherwise, The field is ignored" + } + + advance = line.Length + GetNewLineLength(); + sseItem = default; + return false; + } + + /// Gets the last event ID. + /// This value is updated any time a new last event ID is parsed. It is not reset between SSE items. + public string LastEventId { get; private set; } = string.Empty; // Spec: "must be initialized to the empty string" + + /// Gets the reconnection interval. + /// + /// If no retry event was received, this defaults to , and it will only + /// ever be in that situation. If a client wishes to retry, the server-sent + /// events specification states that the interval may then be decided by the client implementation and should be a + /// few seconds. + /// + public TimeSpan ReconnectionInterval { get; private set; } = Timeout.InfiniteTimeSpan; + + /// Transitions the object to a used state, throwing if it's already been used. + private void ThrowIfNotFirstEnumeration() + { + if (Interlocked.Exchange(ref _used, 1) != 0) + { + throw new InvalidOperationException("The enumerable may be enumerated only once."); + } + } + + /// Reads data from the stream into the line buffer. + private int FillLineBuffer() + { + ShiftOrGrowLineBufferIfNecessary(); + + int offset = _lineOffset + _lineLength; + int bytesRead = _stream.Read( +#if NET + _lineBuffer.AsSpan(offset)); +#else + _lineBuffer, offset, _lineBuffer.Length - offset); +#endif + + if (bytesRead > 0) + { + _lineLength += bytesRead; + } + else + { + _eof = true; + bytesRead = 0; + } + + return bytesRead; + } + + /// Reads data asynchronously from the stream into the line buffer. + private async ValueTask FillLineBufferAsync(CancellationToken cancellationToken) + { + ShiftOrGrowLineBufferIfNecessary(); + + int offset = _lineOffset + _lineLength; + int bytesRead = await +#if NET + _stream.ReadAsync(_lineBuffer.AsMemory(offset), cancellationToken) +#else + new ValueTask(_stream.ReadAsync(_lineBuffer, offset, _lineBuffer.Length - offset, cancellationToken)) +#endif + .ConfigureAwait(false); + + if (bytesRead > 0) + { + _lineLength += bytesRead; + } + else + { + _eof = true; + bytesRead = 0; + } + + return bytesRead; + } + + /// Gets the UTF8 BOM. + private static ReadOnlySpan Utf8Bom => [0xEF, 0xBB, 0xBF]; + + /// Called at the beginning of processing to skip over an optional UTF8 byte order mark. + private void SkipBomIfPresent() + { + Debug.Assert(_lineOffset == 0, $"Expected _lineOffset == 0, got {_lineOffset}"); + + if (_lineBuffer.AsSpan(0, _lineLength).StartsWith(Utf8Bom)) + { + _lineOffset += 3; + _lineLength -= 3; + } + } + + /// Grows the buffer, returning the existing one to the ArrayPool and renting an ArrayPool replacement. + private static void GrowBuffer(ref byte[]? buffer, int minimumLength) + { + byte[]? toReturn = buffer; + buffer = ArrayPool.Shared.Rent(Math.Max(minimumLength, DefaultArrayPoolRentSize)); + if (toReturn is not null) + { + Array.Copy(toReturn, buffer, toReturn.Length); + ArrayPool.Shared.Return(toReturn); + } + } + } +} diff --git a/src/fsdgencsharp/FsdGenCSharpApp.cs b/src/fsdgencsharp/FsdGenCSharpApp.cs index ef58091b..bf885b96 100644 --- a/src/fsdgencsharp/FsdGenCSharpApp.cs +++ b/src/fsdgencsharp/FsdGenCSharpApp.cs @@ -2,6 +2,7 @@ using Facility.CodeGen.Console; using Facility.CodeGen.CSharp; using Facility.Definition.CodeGen; +using Facility.Definition.Fsd; namespace fsdgencsharp; @@ -32,6 +33,8 @@ public sealed class FsdGenCSharpApp : CodeGeneratorApp " The #if condition to use around generated JSON source.", ]; + protected override ServiceParser CreateParser() => new FsdParser(new FsdParserSettings { SupportsEvents = true }); + protected override CodeGenerator CreateGenerator() => new CSharpGenerator(); protected override FileGeneratorSettings CreateSettings(ArgsReader args) => diff --git a/tests/Facility.Benchmarks/DelegatingBenchmarkService.g.cs b/tests/Facility.Benchmarks/DelegatingBenchmarkService.g.cs index 51e0be92..a018d2b2 100644 --- a/tests/Facility.Benchmarks/DelegatingBenchmarkService.g.cs +++ b/tests/Facility.Benchmarks/DelegatingBenchmarkService.g.cs @@ -15,15 +15,22 @@ namespace Facility.Benchmarks [System.CodeDom.Compiler.GeneratedCode("fsdgencsharp", "")] public partial class DelegatingBenchmarkService : IBenchmarkService { + /// + /// Creates an instance with the specified service delegate. + /// + public DelegatingBenchmarkService(ServiceDelegate serviceDelegate) => + m_serviceDelegate = serviceDelegate ?? throw new ArgumentNullException(nameof(serviceDelegate)); + /// /// Creates an instance with the specified delegator. /// + [Obsolete("Use the constructor that accepts a ServiceDelegate.")] public DelegatingBenchmarkService(ServiceDelegator delegator) => - m_delegator = delegator ?? throw new ArgumentNullException(nameof(delegator)); + m_serviceDelegate = ServiceDelegate.FromDelegator(delegator); public virtual async Task> GetUsersAsync(GetUsersRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(BenchmarkServiceMethods.GetUsers, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(BenchmarkServiceMethods.GetUsers, request, cancellationToken).ConfigureAwait(false)).Cast(); - private readonly ServiceDelegator m_delegator; + private readonly ServiceDelegate m_serviceDelegate; } } diff --git a/tests/Facility.CodeGen.CSharp.UnitTests/CSharpGeneratorTests.cs b/tests/Facility.CodeGen.CSharp.UnitTests/CSharpGeneratorTests.cs index ae4138a4..ff0fdd4d 100644 --- a/tests/Facility.CodeGen.CSharp.UnitTests/CSharpGeneratorTests.cs +++ b/tests/Facility.CodeGen.CSharp.UnitTests/CSharpGeneratorTests.cs @@ -51,7 +51,7 @@ public void UnknownFieldAttributeParameter() public void UnspecifiedServiceNamespace() { var definition = "service TestApi { method do {}: {} }"; - var parser = new FsdParser(); + var parser = CreateParser(); var service = parser.ParseDefinition(new ServiceDefinitionText("TestApi.fsd", definition)); var generator = new CSharpGenerator { GeneratorName = nameof(CSharpGeneratorTests) }; var output = generator.GenerateOutput(service); @@ -65,7 +65,7 @@ public void UnspecifiedServiceNamespace() public void DefaultServiceNamespace() { var definition = "service TestApi { method do {}: {} }"; - var parser = new FsdParser(); + var parser = CreateParser(); var service = parser.ParseDefinition(new ServiceDefinitionText("TestApi.fsd", definition)); var generator = new CSharpGenerator { GeneratorName = nameof(CSharpGeneratorTests), DefaultNamespaceName = "DefaultNamespace" }; var output = generator.GenerateOutput(service); @@ -79,7 +79,7 @@ public void DefaultServiceNamespace() public void NoOverrideDefaultServiceNamespace() { var definition = "[csharp(namespace: DefinitionNamespace)] service TestApi { method do {}: {} }"; - var parser = new FsdParser(); + var parser = CreateParser(); var service = parser.ParseDefinition(new ServiceDefinitionText("TestApi.fsd", definition)); var generator = new CSharpGenerator { GeneratorName = nameof(CSharpGeneratorTests), DefaultNamespaceName = "OverrideNamespace" }; var output = generator.GenerateOutput(service); @@ -94,7 +94,7 @@ public void NoOverrideDefaultServiceNamespace() public void OverrideServiceNamespace() { var definition = "[csharp(namespace: DefinitionNamespace)] service TestApi { method do {}: {} }"; - var parser = new FsdParser(); + var parser = CreateParser(); var service = parser.ParseDefinition(new ServiceDefinitionText("TestApi.fsd", definition)); var generator = new CSharpGenerator { GeneratorName = nameof(CSharpGeneratorTests), NamespaceName = "OverrideNamespace" }; var output = generator.GenerateOutput(service); @@ -109,7 +109,7 @@ public void OverrideServiceNamespace() public void GenerateEnumStringConstants() { const string definition = "service TestApi { enum Answer { yes, no, maybe } }"; - var parser = new FsdParser(); + var parser = CreateParser(); var service = parser.ParseDefinition(new ServiceDefinitionText("TestApi.fsd", definition)); var generator = new CSharpGenerator { GeneratorName = nameof(CSharpGeneratorTests) }; @@ -126,7 +126,7 @@ public void GenerateEnumStringConstants() public void GenerateExternalDtoPropertyWithNamespace() { const string definition = "service TestApi { [csharp(name: \"ExternThingDto\", namespace: \"Some.Name.Space\")] extern data Thing; data Test { thing: Thing; } }"; - var parser = new FsdParser(); + var parser = CreateParser(); var service = parser.ParseDefinition(new ServiceDefinitionText("TestApi.fsd", definition)); var generator = new CSharpGenerator { GeneratorName = nameof(CSharpGeneratorTests) }; @@ -141,7 +141,7 @@ public void GenerateExternalDtoPropertyWithNamespace() public void GenerateExternalDtoPropertyWithoutNamespace() { const string definition = "service TestApi { [csharp(name: \"ExternThingDto\")] extern data Thing; data Test { thing: Thing; } }"; - var parser = new FsdParser(); + var parser = CreateParser(); var service = parser.ParseDefinition(new ServiceDefinitionText("TestApi.fsd", definition)); var generator = new CSharpGenerator { GeneratorName = nameof(CSharpGeneratorTests) }; @@ -156,7 +156,7 @@ public void GenerateExternalDtoPropertyWithoutNamespace() public void GenerateExternalDtoPropertyWithoutTypeName() { const string definition = "service TestApi { extern data Thing; data Test { thing: Thing; } }"; - var parser = new FsdParser(); + var parser = CreateParser(); var service = parser.ParseDefinition(new ServiceDefinitionText("TestApi.fsd", definition)); var generator = new CSharpGenerator { GeneratorName = nameof(CSharpGeneratorTests) }; @@ -171,7 +171,7 @@ public void GenerateExternalDtoPropertyWithoutTypeName() public void GenerateExternalEnumPropertyWithNamespace() { const string definition = "service TestApi { [csharp(name: \"ExternSomeEnum\", namespace: \"Some.Name.Space\")] extern enum SomeEnum; data Test { thing: SomeEnum; } }"; - var parser = new FsdParser(); + var parser = CreateParser(); var service = parser.ParseDefinition(new ServiceDefinitionText("TestApi.fsd", definition)); var generator = new CSharpGenerator { GeneratorName = nameof(CSharpGeneratorTests) }; @@ -186,7 +186,7 @@ public void GenerateExternalEnumPropertyWithNamespace() public void GenerateExternalEnumPropertyWithoutNamespace() { const string definition = "service TestApi { [csharp(name: \"ExternSomeEnum\")] extern enum SomeEnum; data Test { thing: SomeEnum; } }"; - var parser = new FsdParser(); + var parser = CreateParser(); var service = parser.ParseDefinition(new ServiceDefinitionText("TestApi.fsd", definition)); var generator = new CSharpGenerator { GeneratorName = nameof(CSharpGeneratorTests) }; @@ -201,7 +201,7 @@ public void GenerateExternalEnumPropertyWithoutNamespace() public void GenerateExternalEnumPropertyWithoutTypeName() { const string definition = "service TestApi { extern enum SomeEnum; data Test { thing: SomeEnum; } }"; - var parser = new FsdParser(); + var parser = CreateParser(); var service = parser.ParseDefinition(new ServiceDefinitionText("TestApi.fsd", definition)); var generator = new CSharpGenerator { GeneratorName = nameof(CSharpGeneratorTests) }; @@ -214,10 +214,12 @@ public void GenerateExternalEnumPropertyWithoutTypeName() private void ThrowsServiceDefinitionException(string definition, string message) { - var parser = new FsdParser(); + var parser = CreateParser(); var service = parser.ParseDefinition(new ServiceDefinitionText("TestApi.fsd", definition)); var generator = new CSharpGenerator { GeneratorName = "CodeGenTests" }; Action action = () => generator.GenerateOutput(service); action.Should().Throw().WithMessage(message); } + + private static FsdParser CreateParser() => new FsdParser(new FsdParserSettings { SupportsEvents = true }); } diff --git a/tests/Facility.CodeGen.CSharp.UnitTests/EdgeCaseTests.cs b/tests/Facility.CodeGen.CSharp.UnitTests/EdgeCaseTests.cs index 37184fad..099355ab 100644 --- a/tests/Facility.CodeGen.CSharp.UnitTests/EdgeCaseTests.cs +++ b/tests/Facility.CodeGen.CSharp.UnitTests/EdgeCaseTests.cs @@ -13,7 +13,7 @@ public void GenerateEdgeCases() using (var fsdTextReader = new StreamReader(GetType().Assembly.GetManifestResourceStream("Facility.CodeGen.CSharp.UnitTests.EdgeCases.fsd")!)) fsdText = fsdTextReader.ReadToEnd(); - var parser = new FsdParser(); + var parser = new FsdParser(new FsdParserSettings { SupportsEvents = true }); var service = parser.ParseDefinition(new ServiceDefinitionText("EdgeCases.fsd", fsdText)); var generator = new CSharpGenerator { GeneratorName = nameof(CSharpGeneratorTests) }; diff --git a/tests/Facility.CodeGen.CSharp.UnitTests/ValidationTests.cs b/tests/Facility.CodeGen.CSharp.UnitTests/ValidationTests.cs index 85e0b145..61f2dc6d 100644 --- a/tests/Facility.CodeGen.CSharp.UnitTests/ValidationTests.cs +++ b/tests/Facility.CodeGen.CSharp.UnitTests/ValidationTests.cs @@ -77,7 +77,7 @@ public void GeneratesCollectionCount() private CodeGenFile GetGeneratedFile(string definition, string fileName) { - var parser = new FsdParser(); + var parser = new FsdParser(new FsdParserSettings { SupportsEvents = true }); var service = parser.ParseDefinition(new ServiceDefinitionText("TestApi.fsd", definition)); var generator = new CSharpGenerator { GeneratorName = nameof(CSharpGeneratorTests) }; var output = generator.GenerateOutput(service); diff --git a/tests/Facility.ConformanceApi.UnitTests/CodeGenTests.cs b/tests/Facility.ConformanceApi.UnitTests/CodeGenTests.cs index d88e5074..4cd94af2 100644 --- a/tests/Facility.ConformanceApi.UnitTests/CodeGenTests.cs +++ b/tests/Facility.ConformanceApi.UnitTests/CodeGenTests.cs @@ -17,7 +17,7 @@ public void GenerateConformanceApi() using (var fsdTextReader = new StreamReader(GetType().Assembly.GetManifestResourceStream("Facility.ConformanceApi.UnitTests.ConformanceApi.fsd")!)) fsdText = fsdTextReader.ReadToEnd(); - var parser = new FsdParser(); + var parser = new FsdParser(new FsdParserSettings { SupportsEvents = true }); var service = parser.ParseDefinition( new ServiceDefinitionText("ConformanceApi.fsd", fsdText)); diff --git a/tests/Facility.ConformanceApi.UnitTests/DelegationTests.cs b/tests/Facility.ConformanceApi.UnitTests/DelegationTests.cs index 267bff86..cbc3ab90 100644 --- a/tests/Facility.ConformanceApi.UnitTests/DelegationTests.cs +++ b/tests/Facility.ConformanceApi.UnitTests/DelegationTests.cs @@ -14,7 +14,7 @@ public sealed class DelegationTests [Test] public async Task NotImplemented() { - var api = new DelegatingConformanceApi(ServiceDelegators.NotImplemented); + var api = new DelegatingConformanceApi(ServiceDelegates.NotImplemented); await Awaiting(async () => await api.CheckQueryAsync(new CheckQueryRequestDto())).Should().ThrowAsync(); } @@ -30,7 +30,7 @@ public async Task Override() public async Task Forward() { var inner = new CheckPathCounter(); - var api = new DelegatingConformanceApi(ServiceDelegators.Forward(inner)); + var api = new DelegatingConformanceApi(ServiceDelegates.Forward(inner)); (await api.CheckPathAsync(new CheckPathRequestDto())).Should().BeSuccess(); inner.Count.Should().Be(1); } @@ -39,12 +39,12 @@ public async Task Forward() public async Task CallTwice() { var inner = new CheckPathCounter(); - var api = new DelegatingConformanceApi( + var api = new DelegatingConformanceApi(ServiceDelegate.FromDelegator( async (method, request, cancellationToken) => { await method.InvokeAsync(inner, request, cancellationToken); return await method.InvokeAsync(inner, request, cancellationToken); - }); + })); (await api.CheckPathAsync(new CheckPathRequestDto())).Should().BeSuccess(); inner.Count.Should().Be(2); } @@ -52,14 +52,14 @@ public async Task CallTwice() [Test] public async Task RightResponse() { - var api = new DelegatingConformanceApi(async (_, _, _) => ServiceResult.Success(new CheckPathResponseDto())); + var api = new DelegatingConformanceApi(ServiceDelegate.FromDelegator(async (_, _, _) => ServiceResult.Success(new CheckPathResponseDto()))); (await api.CheckPathAsync(new CheckPathRequestDto())).Should().BeSuccess(); } [Test] public async Task WrongResponse() { - var api = new DelegatingConformanceApi(async (_, _, _) => ServiceResult.Success(new CheckQueryResponseDto())); + var api = new DelegatingConformanceApi(ServiceDelegate.FromDelegator(async (_, _, _) => ServiceResult.Success(new CheckQueryResponseDto()))); await Awaiting(async () => await api.CheckPathAsync(new CheckPathRequestDto())).Should().ThrowAsync(); } @@ -70,10 +70,10 @@ public async Task Validate() var validWidget = new WidgetDto { Id = 1, Name = "one" }; var createdWidget = invalidWidget; - var api = new DelegatingConformanceApi(async (_, _, _) => ServiceResult.Success(new CreateWidgetResponseDto { Widget = createdWidget })); + var api = new DelegatingConformanceApi(ServiceDelegate.FromDelegator(async (_, _, _) => ServiceResult.Success(new CreateWidgetResponseDto { Widget = createdWidget }))); (await api.CreateWidgetAsync(new CreateWidgetRequestDto { Widget = invalidWidget })).Should().BeSuccess(); - var validatingApi = new DelegatingConformanceApi(ServiceDelegators.Validate(api)); + var validatingApi = new DelegatingConformanceApi(ServiceDelegates.Validate(api)); (await validatingApi.CreateWidgetAsync(new CreateWidgetRequestDto { Widget = invalidWidget })).Should().BeFailure(ServiceErrors.InvalidRequest); (await validatingApi.CreateWidgetAsync(new CreateWidgetRequestDto { Widget = validWidget })).Should().BeFailure(ServiceErrors.InvalidResponse); @@ -85,7 +85,7 @@ public async Task Validate() private sealed class CheckPathCounter : DelegatingConformanceApi { public CheckPathCounter() - : base(ServiceDelegators.NotImplemented) + : base(ServiceDelegates.NotImplemented) { } diff --git a/tests/Facility.ConformanceApi.UnitTests/DtoValidationTests.cs b/tests/Facility.ConformanceApi.UnitTests/DtoValidationTests.cs index 28513a34..30f18b29 100644 --- a/tests/Facility.ConformanceApi.UnitTests/DtoValidationTests.cs +++ b/tests/Facility.ConformanceApi.UnitTests/DtoValidationTests.cs @@ -338,7 +338,7 @@ private HttpClientConformanceApi CreateHttpApi(bool skipClientValidation = false private sealed class FakeConformanceApiService : DelegatingConformanceApi { public FakeConformanceApiService(ServiceSerializer serializer, RequiredResponseDto? requiredResponse = null, WidgetDto? widgetResponse = null) - : base(ServiceDelegators.NotImplemented) + : base(ServiceDelegates.NotImplemented) { m_serializer = serializer; m_requiredResponse = requiredResponse ?? CreateRequiredResponse(); diff --git a/tools/EdgeCases/DelegatingEdgeCases.g.cs b/tools/EdgeCases/DelegatingEdgeCases.g.cs index f2d47892..7a0bad54 100644 --- a/tools/EdgeCases/DelegatingEdgeCases.g.cs +++ b/tools/EdgeCases/DelegatingEdgeCases.g.cs @@ -15,28 +15,35 @@ namespace EdgeCases [System.CodeDom.Compiler.GeneratedCode("fsdgencsharp", "")] public partial class DelegatingEdgeCases : IEdgeCases { + /// + /// Creates an instance with the specified service delegate. + /// + public DelegatingEdgeCases(ServiceDelegate serviceDelegate) => + m_serviceDelegate = serviceDelegate ?? throw new ArgumentNullException(nameof(serviceDelegate)); + /// /// Creates an instance with the specified delegator. /// + [Obsolete("Use the constructor that accepts a ServiceDelegate.")] public DelegatingEdgeCases(ServiceDelegator delegator) => - m_delegator = delegator ?? throw new ArgumentNullException(nameof(delegator)); + m_serviceDelegate = ServiceDelegate.FromDelegator(delegator); /// /// An old method. /// [Obsolete] public virtual async Task> OldMethodAsync(OldMethodRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(EdgeCasesMethods.OldMethod, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(EdgeCasesMethods.OldMethod, request, cancellationToken).ConfigureAwait(false)).Cast(); /// /// Custom HTTP method. /// public virtual async Task> CustomHttpAsync(CustomHttpRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(EdgeCasesMethods.CustomHttp, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(EdgeCasesMethods.CustomHttp, request, cancellationToken).ConfigureAwait(false)).Cast(); public virtual async Task> SnakeMethodAsync(SnakeMethodRequestDto request, CancellationToken cancellationToken = default) => - (await m_delegator(EdgeCasesMethods.SnakeMethod, request, cancellationToken).ConfigureAwait(false)).Cast(); + (await m_serviceDelegate.InvokeMethodAsync(EdgeCasesMethods.SnakeMethod, request, cancellationToken).ConfigureAwait(false)).Cast(); - private readonly ServiceDelegator m_delegator; + private readonly ServiceDelegate m_serviceDelegate; } } From 55518ca94c6bcdc7577c082584880b9d9bc58ede Mon Sep 17 00:00:00 2001 From: Ed Ball Date: Wed, 3 Jul 2024 19:20:50 -0700 Subject: [PATCH 2/2] Publish 2.29.0. --- Directory.Build.props | 4 ++-- ReleaseNotes.md | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 198e9f5b..15f712d0 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,8 +1,8 @@ - 2.28.2 - 2.28.0 + 2.29.0 + 2.28.2 12.0 enable enable diff --git a/ReleaseNotes.md b/ReleaseNotes.md index e69714ec..a9ae995f 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,5 +1,9 @@ # Release Notes +## 2.29.0 + +* Support events. (Must opt-in via `FsdParserSettings.SupportsEvents`.) + ## 2.28.2 * Don't use `ToArray` unless needed for JSON source generation.