diff --git a/README.md b/README.md index 85a29ea..faa3703 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Architecture/Platform | Windows | macOS | Linux | Android | iOS - TLS 1.2/1.3 with ALPN - TLS support is powered by Rustls + webpki - Client certificate + - Unix Domain Socket (macOS, Linux) ### Not supported yet - HTTP proxy support @@ -229,6 +230,7 @@ Once the handler sends a request, these settings become immutable and cannot be |Http2MaxConcurrentResetStreams|Gets or sets the maximum number of HTTP2 concurrent locally reset streams. See the documentation of h2::client::Builder::max_concurrent_reset_streams for more details. The default value is determined by the h2 crate.| |Http2MaxSendBufferSize|Gets or sets the maximum write buffer size for each HTTP/2 stream. Default is currently 1MB, but may change.| |Http2InitialMaxSendStreams|Gets or sets the initial maximum of locally initiated (send) streams. This value will be overwritten by the value included in the initial SETTINGS frame received from the peer as part of a connection preface.| +|UnixDomainSocketPath|Gets or sets the path to a Unix Domain Socket to be used as HTTP communication channel instead of the default TCP.| Most of them expose [hyper client settings](https://docs.rs/hyper-util/latest/hyper_util/client/legacy/struct.Builder.html), so please check those as well. @@ -301,6 +303,23 @@ using var httpHandler = new YetAnotherHttpHandler() }; ``` +### Using Unix Domain Sockets as HTTP transport layer + +Unix Domain Sockets can be used as the HTTP transport layer for local usecases (e.g. IPC based on gRPC), instead of network-based TCP. + +Set the `UnixDomainSocketPath` property to enable UDS-based communication to a server listening at the given path: + +```csharp +using var handler = new YetAnotherHttpHandler() { Http2Only = true, UnixDomainSocketPath = "/tmp/example.sock" }; +using var channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions() { HttpHandler = handler }); +``` + +Note: +- HTTPS is not supported over UDS. All HTTPS related configuration properties are ignored if UDS is enabled. +- The grpc-dotnet library doesn't handle non-HTTP schemes (like "unix://"), so keep passing an HTTP URI to `GrpcChannel`, e.g. http://localhost. + The actual HTTP requests will be redirected to `UnixDomainSocketPath` by YetAnotherHttpHandler internally. +- When using Kestrel on the server, you need to set the `KestrelServerOptions.AllowAlternateSchemes` option to `true`. + ## Development ### Build & Tests @@ -318,6 +337,10 @@ When creating a package, The following artifacts directory is used. - native/artifacts/{.NET RID}/{lib}yaha_native.{dll,so} +```bash +# Generate THIRD-PARTY-NOTICES using cargo-about +cargo about generate about.hbs > ../THIRD-PARTY-NOTICES +``` ## License MIT License diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES index fd92936..d87aade 100644 Binary files a/THIRD-PARTY-NOTICES and b/THIRD-PARTY-NOTICES differ diff --git a/native/Cargo.lock b/native/Cargo.lock index 68326ad..bfb05ca 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -252,6 +252,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.1.0" @@ -292,6 +298,12 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.3.1" @@ -305,6 +317,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -367,6 +380,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "indexmap" version = "2.2.6" @@ -1192,6 +1220,7 @@ dependencies = [ "hyper-rustls", "hyper-tls", "hyper-util", + "hyperlocal", "rustls", "rustls-pemfile", "tokio", diff --git a/native/yaha_native/Cargo.toml b/native/yaha_native/Cargo.toml index 291570e..4e9244c 100644 --- a/native/yaha_native/Cargo.toml +++ b/native/yaha_native/Cargo.toml @@ -42,6 +42,8 @@ tokio-stream = "0.1.15" futures-channel = "0.3.30" futures-util = "0.3.30" +[target.'cfg(unix)'.dependencies] +hyperlocal = "0.9.1" [features] default = [ "rustls" ] diff --git a/native/yaha_native/src/binding.rs b/native/yaha_native/src/binding.rs index c553ce2..7eb8973 100644 --- a/native/yaha_native/src/binding.rs +++ b/native/yaha_native/src/binding.rs @@ -335,6 +335,19 @@ pub extern "C" fn yaha_client_config_http2_initial_max_send_streams( .http2_initial_max_send_streams(initial); } +#[cfg(unix)] +#[no_mangle] +pub extern "C" fn yaha_client_config_unix_domain_socket_path( + ctx: *mut YahaNativeContext, + uds_path: *const StringBuffer, +) { + let ctx = YahaNativeContextInternal::from_raw_context(ctx); + + let uds_socket_path = unsafe { (*uds_path).to_str() }; + ctx.uds_socket_path.get_or_insert(uds_socket_path.into()); +} + + #[no_mangle] pub extern "C" fn yaha_build_client(ctx: *mut YahaNativeContext) { let ctx = YahaNativeContextInternal::from_raw_context(ctx); @@ -497,8 +510,12 @@ pub extern "C" fn yaha_request_begin( (req_ctx.seq, builder.body(body).unwrap()) }; - // - if ctx.client.as_ref().is_none() { + #[cfg(unix)] + let client_is_none = ctx.tcp_client.is_none() && ctx.uds_client.is_none(); + #[cfg(not(unix))] + let client_is_none = ctx.tcp_client.is_none(); + + if client_is_none { LAST_ERROR.with(|v| { *v.borrow_mut() = Some("The client has not been built. You need to build it before sending the request. ".to_string()); }); @@ -512,7 +529,7 @@ pub extern "C" fn yaha_request_begin( (ctx.on_complete)(seq, state, CompletionReason::Aborted, 0); return; } - res = ctx.client.as_ref().unwrap().request(req) => { + res = ctx.request(req) => { if let Err(err) = res { complete_with_error(ctx, seq, state, err); return; diff --git a/native/yaha_native/src/context.rs b/native/yaha_native/src/context.rs index 63aaf93..71d742f 100644 --- a/native/yaha_native/src/context.rs +++ b/native/yaha_native/src/context.rs @@ -8,12 +8,12 @@ use http_body_util::combinators::BoxBody; use tokio::runtime::{Handle, Runtime}; use hyper::{ - body::Bytes, - StatusCode, + body::Bytes, + Request, StatusCode }; use hyper_util::{ - client::{self, legacy::{connect::HttpConnector, Client}}, + client::{self, legacy::{connect::HttpConnector, Client, ResponseFuture}}, rt::{TokioExecutor, TokioTimer}, }; @@ -23,6 +23,9 @@ use hyper_rustls::ConfigBuilderExt; use hyper_rustls::HttpsConnector; #[cfg(feature = "native")] use hyper_tls::HttpsConnector; +#[cfg(unix)] +use hyperlocal::UnixConnector; + use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use tokio_util::sync::CancellationToken; @@ -61,10 +64,15 @@ pub struct YahaNativeContextInternal<'a> { pub connect_timeout: Option, pub client_auth_certificates: Option>>, pub client_auth_key: Option>, - pub client: Option, BoxBody>>, + pub tcp_client: Option, BoxBody>>, pub on_status_code_and_headers_receive: OnStatusCodeAndHeadersReceive, pub on_receive: OnReceive, pub on_complete: OnComplete, + + #[cfg(unix)] + pub uds_client: Option>>, + #[cfg(unix)] + pub uds_socket_path: Option, } impl YahaNativeContextInternal<'_> { @@ -80,7 +88,7 @@ impl YahaNativeContextInternal<'_> { ) -> Self { YahaNativeContextInternal { runtime: runtime_handle, - client: None, + tcp_client: None, client_builder: Some(Client::builder(TokioExecutor::new())), skip_certificate_verification: None, server_certificate_verification_handler: None, @@ -92,13 +100,31 @@ impl YahaNativeContextInternal<'_> { on_status_code_and_headers_receive, on_receive, on_complete, + #[cfg(unix)] + uds_client: None, + #[cfg(unix)] + uds_socket_path: None, } } pub fn build_client(&mut self) { let mut builder = self.client_builder.take().unwrap(); - let https = self.new_connector(); - self.client = Some(builder.timer(TokioTimer::new()).build(https)); + builder.timer(TokioTimer::new()); + + #[cfg(unix)] + { + if self.uds_socket_path.is_some() { + self.uds_client = Some(builder.build(UnixConnector)); + } else { + let https = self.new_connector(); + self.tcp_client = Some(builder.build(https)); + } + } + #[cfg(not(unix))] + { + let https = self.new_connector(); + self.tcp_client = Some(builder.build(https)); + } } #[cfg(feature = "rustls")] @@ -180,6 +206,29 @@ impl YahaNativeContextInternal<'_> { let https = HttpsConnector::new(); https } + + #[cfg(unix)] + pub fn request(&self, mut req: Request>) -> ResponseFuture { + // Precondition (`uds_client` or `tcp_client` is set) ensured by `Self::build_client` and `yaha_request_begin` + if let Some(uds_socket_path) = &self.uds_socket_path { + // Transform HTTP URIs to the format expected by hyperlocal + let path_and_query = req + .uri() + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or("/"); + let uds_uri = hyperlocal::Uri::new(uds_socket_path, path_and_query); + *req.uri_mut() = uds_uri.into(); + + self.uds_client.as_ref().unwrap().request(req) + } else { + self.tcp_client.as_ref().unwrap().request(req) + } + } + #[cfg(not(unix))] + pub fn request(&self, req: Request>) -> ResponseFuture { + self.tcp_client.as_ref().unwrap().request(req) + } } #[cfg(feature = "rustls")] diff --git a/src/YetAnotherHttpHandler/NativeHttpHandlerCore.cs b/src/YetAnotherHttpHandler/NativeHttpHandlerCore.cs index a477fbd..cb10290 100644 --- a/src/YetAnotherHttpHandler/NativeHttpHandlerCore.cs +++ b/src/YetAnotherHttpHandler/NativeHttpHandlerCore.cs @@ -184,6 +184,16 @@ private unsafe void Initialize(YahaNativeContext* ctx, NativeClientSettings sett if (YahaEventSource.Log.IsEnabled()) YahaEventSource.Log.Info($"Option '{nameof(settings.Http2InitialMaxSendStreams)}' = {http2InitialMaxSendStreams}"); NativeMethods.yaha_client_config_http2_initial_max_send_streams(ctx, (nuint)http2InitialMaxSendStreams); } + if (settings.UnixDomainSocketPath is { } unixDomainSocketPath) + { + if (YahaEventSource.Log.IsEnabled()) YahaEventSource.Log.Info($"Option '{nameof(settings.UnixDomainSocketPath)}' = {unixDomainSocketPath}"); + var strBytes = Encoding.UTF8.GetBytes(unixDomainSocketPath); + fixed (byte* buffer = strBytes) + { + var sb = new StringBuffer(buffer, strBytes.Length); + NativeMethods.yaha_client_config_unix_domain_socket_path(ctx, &sb); + } + } NativeMethods.yaha_build_client(ctx); diff --git a/src/YetAnotherHttpHandler/NativeMethods.Uwp.g.cs b/src/YetAnotherHttpHandler/NativeMethods.Uwp.g.cs index 0fb06f8..dcc95ce 100644 --- a/src/YetAnotherHttpHandler/NativeMethods.Uwp.g.cs +++ b/src/YetAnotherHttpHandler/NativeMethods.Uwp.g.cs @@ -107,6 +107,9 @@ internal static unsafe partial class NativeMethods [DllImport(__DllName, EntryPoint = "yaha_client_config_http2_initial_max_send_streams", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] public static extern void yaha_client_config_http2_initial_max_send_streams(YahaNativeContext* ctx, nuint initial); + [DllImport(__DllName, EntryPoint = "yaha_client_config_unix_domain_socket_path", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern void yaha_client_config_unix_domain_socket_path(YahaNativeContext* ctx, StringBuffer* uds_path); + [DllImport(__DllName, EntryPoint = "yaha_build_client", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] public static extern void yaha_build_client(YahaNativeContext* ctx); diff --git a/src/YetAnotherHttpHandler/NativeMethods.g.cs b/src/YetAnotherHttpHandler/NativeMethods.g.cs index 37d62d5..97d399c 100644 --- a/src/YetAnotherHttpHandler/NativeMethods.g.cs +++ b/src/YetAnotherHttpHandler/NativeMethods.g.cs @@ -112,6 +112,9 @@ internal static unsafe partial class NativeMethods [DllImport(__DllName, EntryPoint = "yaha_client_config_http2_initial_max_send_streams", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] public static extern void yaha_client_config_http2_initial_max_send_streams(YahaNativeContext* ctx, nuint initial); + [DllImport(__DllName, EntryPoint = "yaha_client_config_unix_domain_socket_path", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + public static extern void yaha_client_config_unix_domain_socket_path(YahaNativeContext* ctx, StringBuffer* uds_path); + [DllImport(__DllName, EntryPoint = "yaha_build_client", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] public static extern void yaha_build_client(YahaNativeContext* ctx); diff --git a/src/YetAnotherHttpHandler/YetAnotherHttpHandler.cs b/src/YetAnotherHttpHandler/YetAnotherHttpHandler.cs index 0efa4c8..1935193 100644 --- a/src/YetAnotherHttpHandler/YetAnotherHttpHandler.cs +++ b/src/YetAnotherHttpHandler/YetAnotherHttpHandler.cs @@ -169,6 +169,11 @@ public class YetAnotherHttpHandler : HttpMessageHandler /// public ulong? Http2InitialMaxSendStreams { get => _settings.Http2InitialMaxSendStreams; set => _settings.Http2InitialMaxSendStreams = value; } + /// + /// Gets or sets the path to a Unix Domain Socket to be used as HTTP communication channel instead of the default TCP. + /// + public string? UnixDomainSocketPath { get => _settings.UnixDomainSocketPath; set => _settings.UnixDomainSocketPath = value; } + private NativeHttpHandlerCore SetupHandler() { var settings = _settings.Clone(); @@ -236,6 +241,7 @@ internal class NativeClientSettings public ulong? Http2MaxConcurrentResetStreams { get; set; } public ulong? Http2MaxSendBufferSize { get; set; } public ulong? Http2InitialMaxSendStreams { get; set; } + public string? UnixDomainSocketPath { get; set; } public NativeClientSettings Clone() { @@ -261,6 +267,7 @@ public NativeClientSettings Clone() Http2MaxConcurrentResetStreams = this.Http2MaxConcurrentResetStreams, Http2MaxSendBufferSize = this.Http2MaxSendBufferSize, Http2InitialMaxSendStreams = this.Http2InitialMaxSendStreams, + UnixDomainSocketPath = this.UnixDomainSocketPath, }; } } diff --git a/test/YetAnotherHttpHandler.Test/Helpers/Testing/ConditionalFactAttribute.cs b/test/YetAnotherHttpHandler.Test/Helpers/Testing/ConditionalFactAttribute.cs new file mode 100644 index 0000000..38df7ee --- /dev/null +++ b/test/YetAnotherHttpHandler.Test/Helpers/Testing/ConditionalFactAttribute.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Xunit; +using Xunit.Sdk; + +namespace _YetAnotherHttpHandler.Test.Helpers.Testing; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +[XunitTestCaseDiscoverer("_YetAnotherHttpHandler.Test.Helpers.Testing." + nameof(ConditionalFactDiscoverer), "YetAnotherHttpHandler.Test")] +public class ConditionalFactAttribute : FactAttribute +{ +} \ No newline at end of file diff --git a/test/YetAnotherHttpHandler.Test/Helpers/Testing/ConditionalFactDiscoverer.cs b/test/YetAnotherHttpHandler.Test/Helpers/Testing/ConditionalFactDiscoverer.cs new file mode 100644 index 0000000..574884f --- /dev/null +++ b/test/YetAnotherHttpHandler.Test/Helpers/Testing/ConditionalFactDiscoverer.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit.Abstractions; +using Xunit.Sdk; + +// Do not change this namespace without changing the usage in ConditionalFactAttribute +namespace _YetAnotherHttpHandler.Test.Helpers.Testing; + +internal sealed class ConditionalFactDiscoverer : FactDiscoverer +{ + private readonly IMessageSink _diagnosticMessageSink; + + public ConditionalFactDiscoverer(IMessageSink diagnosticMessageSink) + : base(diagnosticMessageSink) + { + _diagnosticMessageSink = diagnosticMessageSink; + } + + protected override IXunitTestCase CreateTestCase(ITestFrameworkDiscoveryOptions discoveryOptions, ITestMethod testMethod, IAttributeInfo factAttribute) + { + var skipReason = testMethod.EvaluateSkipConditions(); + return skipReason != null + ? new SkippedTestCase(skipReason, _diagnosticMessageSink, discoveryOptions.MethodDisplayOrDefault(), TestMethodDisplayOptions.None, testMethod) + : base.CreateTestCase(discoveryOptions, testMethod, factAttribute); + } +} \ No newline at end of file diff --git a/test/YetAnotherHttpHandler.Test/Helpers/Testing/ITestCondition.cs b/test/YetAnotherHttpHandler.Test/Helpers/Testing/ITestCondition.cs new file mode 100644 index 0000000..661bb62 --- /dev/null +++ b/test/YetAnotherHttpHandler.Test/Helpers/Testing/ITestCondition.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace _YetAnotherHttpHandler.Test.Helpers.Testing; + +public interface ITestCondition +{ + bool IsMet { get; } + + string SkipReason { get; } +} \ No newline at end of file diff --git a/test/YetAnotherHttpHandler.Test/Helpers/Testing/OSSkipConditionAttribute.cs b/test/YetAnotherHttpHandler.Test/Helpers/Testing/OSSkipConditionAttribute.cs new file mode 100644 index 0000000..4ddf399 --- /dev/null +++ b/test/YetAnotherHttpHandler.Test/Helpers/Testing/OSSkipConditionAttribute.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// https://github.com/dotnet/aspnetcore/tree/v7.0.0/src/Testing/src + +using System.Runtime.InteropServices; +using System; + +namespace _YetAnotherHttpHandler.Test.Helpers.Testing; + +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = true)] +public class OSSkipConditionAttribute : Attribute, ITestCondition +{ + private readonly OperatingSystems _excludedOperatingSystem; + private readonly OperatingSystems _osPlatform; + + public OSSkipConditionAttribute(OperatingSystems operatingSystem) : + this(operatingSystem, GetCurrentOS()) + { + } + + [Obsolete("Use the Minimum/MaximumOSVersionAttribute for version checks.", error: true)] + public OSSkipConditionAttribute(OperatingSystems operatingSystem, params string[] versions) : + this(operatingSystem, GetCurrentOS()) + { + } + + // to enable unit testing + internal OSSkipConditionAttribute(OperatingSystems operatingSystem, OperatingSystems osPlatform) + { + _excludedOperatingSystem = operatingSystem; + _osPlatform = osPlatform; + } + + public bool IsMet + { + get + { + var skip = (_excludedOperatingSystem & _osPlatform) == _osPlatform; + // Since a test would be excuted only if 'IsMet' is true, return false if we want to skip + return !skip; + } + } + + public string SkipReason { get; set; } = "Test cannot run on this operating system."; + + private static OperatingSystems GetCurrentOS() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return OperatingSystems.Windows; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return OperatingSystems.Linux; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return OperatingSystems.MacOSX; + } + throw new PlatformNotSupportedException(); + } +} \ No newline at end of file diff --git a/test/YetAnotherHttpHandler.Test/Helpers/Testing/OperatingSystems.cs b/test/YetAnotherHttpHandler.Test/Helpers/Testing/OperatingSystems.cs new file mode 100644 index 0000000..9c4d9f7 --- /dev/null +++ b/test/YetAnotherHttpHandler.Test/Helpers/Testing/OperatingSystems.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace _YetAnotherHttpHandler.Test.Helpers.Testing; + +[Flags] +public enum OperatingSystems +{ + Linux = 1, + MacOSX = 2, + Windows = 4, +} \ No newline at end of file diff --git a/test/YetAnotherHttpHandler.Test/Helpers/Testing/SkippedTestCase.cs b/test/YetAnotherHttpHandler.Test/Helpers/Testing/SkippedTestCase.cs new file mode 100644 index 0000000..a5f08f6 --- /dev/null +++ b/test/YetAnotherHttpHandler.Test/Helpers/Testing/SkippedTestCase.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using Xunit.Abstractions; +using Xunit.Sdk; +#pragma warning disable CS8625 + +namespace _YetAnotherHttpHandler.Test.Helpers.Testing; + +public class SkippedTestCase : XunitTestCase +{ + private string? _skipReason; + + [Obsolete("Called by the de-serializer; should only be called by deriving classes for de-serialization purposes")] + public SkippedTestCase() : base() + { + } + + public SkippedTestCase( + string skipReason, + IMessageSink diagnosticMessageSink, + TestMethodDisplay defaultMethodDisplay, + TestMethodDisplayOptions defaultMethodDisplayOptions, + ITestMethod testMethod, + object[] testMethodArguments = null) + : base(diagnosticMessageSink, defaultMethodDisplay, defaultMethodDisplayOptions, testMethod, testMethodArguments) + { + _skipReason = skipReason; + } + + protected override string GetSkipReason(IAttributeInfo factAttribute) + => _skipReason ?? base.GetSkipReason(factAttribute); + + public override void Deserialize(IXunitSerializationInfo data) + { + _skipReason = data.GetValue(nameof(_skipReason)); + + // We need to call base after reading our value, because Deserialize will call + // into GetSkipReason. + base.Deserialize(data); + } + + public override void Serialize(IXunitSerializationInfo data) + { + base.Serialize(data); + data.AddValue(nameof(_skipReason), _skipReason); + } +} \ No newline at end of file diff --git a/test/YetAnotherHttpHandler.Test/Helpers/Testing/TestMethodExtensions.cs b/test/YetAnotherHttpHandler.Test/Helpers/Testing/TestMethodExtensions.cs new file mode 100644 index 0000000..ad83947 --- /dev/null +++ b/test/YetAnotherHttpHandler.Test/Helpers/Testing/TestMethodExtensions.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Linq; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace _YetAnotherHttpHandler.Test.Helpers.Testing; + +public static class TestMethodExtensions +{ + public static string? EvaluateSkipConditions(this ITestMethod testMethod) + { + var testClass = testMethod.TestClass.Class; + var assembly = testMethod.TestClass.TestCollection.TestAssembly.Assembly; + var conditionAttributes = testMethod.Method + .GetCustomAttributes(typeof(ITestCondition)) + .Concat(testClass.GetCustomAttributes(typeof(ITestCondition))) + .Concat(assembly.GetCustomAttributes(typeof(ITestCondition))) + .OfType() + .Select(attributeInfo => attributeInfo.Attribute); + + foreach (ITestCondition condition in conditionAttributes) + { + if (!condition.IsMet) + { + return condition.SkipReason; + } + } + + return null; + } +} \ No newline at end of file diff --git a/test/YetAnotherHttpHandler.Test/Http2TestBase.cs b/test/YetAnotherHttpHandler.Test/Http2TestBase.cs index 14ba7c6..4ce0ed1 100644 --- a/test/YetAnotherHttpHandler.Test/Http2TestBase.cs +++ b/test/YetAnotherHttpHandler.Test/Http2TestBase.cs @@ -18,7 +18,7 @@ public abstract class Http2TestBase(ITestOutputHelper testOutputHelper) : UseTes protected Task LaunchServerAsync(Action? configure = null) where T : ITestServerBuilder => LaunchServerAsyncCore(configure); - [Fact] + [ConditionalFact] public async Task Get_Ok() { // Arrange @@ -40,7 +40,7 @@ public async Task Get_Ok() Assert.Equal(HttpStatusCode.OK, response.StatusCode); } - [Fact] + [ConditionalFact] public async Task Get_NotOk() { // Arrange @@ -62,7 +62,7 @@ public async Task Get_NotOk() Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } - [Fact] + [ConditionalFact] public async Task Post_Body() { // Arrange @@ -88,7 +88,7 @@ public async Task Post_Body() Assert.Equal("application/octet-stream", response.Headers.TryGetValues("x-request-content-type", out var values) ? string.Join(',', values) : null); } - [Fact] + [ConditionalFact] public async Task Post_NotDuplex_Receive_ResponseHeaders_Before_ResponseBody() { // Arrange @@ -118,7 +118,7 @@ public async Task Post_NotDuplex_Receive_ResponseHeaders_Before_ResponseBody() // NOTE: SocketHttpHandler waits for the completion of sending the request body before the response headers. // https://github.com/dotnet/runtime/blob/v7.0.0/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs#L1980-L1988 // https://github.com/dotnet/runtime/blob/v7.0.0/src/libraries/System.Net.Http/src/System/Net/Http/HttpContent.cs#L343-L349 - //[Fact] + //[ConditionalFact] //public async Task Post_NotDuplex_DoNot_Receive_ResponseHeaders_Before_RequestBodyCompleted() //{ // // Arrange @@ -142,7 +142,7 @@ public async Task Post_NotDuplex_Receive_ResponseHeaders_Before_ResponseBody() // Assert.Equal(timeout.Token, ex.CancellationToken); //} - [Fact] + [ConditionalFact] public async Task Post_NotDuplex_Body_StreamingBody() { // Arrange @@ -183,7 +183,7 @@ public async Task Post_NotDuplex_Body_StreamingBody() Assert.Equal((1024 * 1024 * 10).ToString(), responseBody); } - [Fact] + [ConditionalFact] public async Task Post_Duplex_Body_StreamingBody() { // Arrange @@ -225,7 +225,7 @@ public async Task Post_Duplex_Body_StreamingBody() Assert.Equal((1024 * 1024 * 10).ToString(), responseBody); } - [Fact] + [ConditionalFact] public async Task Post_ResponseTrailers() { // Arrange @@ -251,7 +251,7 @@ public async Task Post_ResponseTrailers() Assert.Equal("bar", response.TrailingHeaders.TryGetValues("x-trailer-2", out var values2) ? string.Join(',', values2) : null); } - [Fact] + [ConditionalFact] public async Task AbortOnServer_Post_SendingBody() { // Arrange @@ -273,7 +273,7 @@ public async Task AbortOnServer_Post_SendingBody() Assert.IsType(ex); } - [Fact] + [ConditionalFact] public async Task Cancel_Post_SendingBody() { // Arrange @@ -304,7 +304,7 @@ public async Task Cancel_Post_SendingBody() } #if !UNITY_2021_1_OR_NEWER - [Fact] + [ConditionalFact] public async Task Cancel_Post_SendingBody_Duplex() { // Arrange @@ -331,7 +331,7 @@ public async Task Cancel_Post_SendingBody_Duplex() } #endif - [Fact] + [ConditionalFact] public async Task DisposeHttpResponseMessage_Post_SendingBody_Duplex() { // Arrange @@ -361,7 +361,7 @@ public async Task DisposeHttpResponseMessage_Post_SendingBody_Duplex() Assert.IsAssignableFrom(ex.InnerException); } - [Fact] + [ConditionalFact] public async Task Cancel_Get_BeforeReceivingResponseHeaders() { // Arrange @@ -392,7 +392,7 @@ public async Task Cancel_Get_BeforeReceivingResponseHeaders() Assert.Equal("True", isCanceled); } - [Fact] + [ConditionalFact] public async Task Cancel_Post_BeforeRequest() { // Arrange @@ -420,7 +420,7 @@ public async Task Cancel_Post_BeforeRequest() #endif } - [Fact] + [ConditionalFact] public async Task Grpc_Unary() { // Arrange @@ -436,7 +436,7 @@ public async Task Grpc_Unary() Assert.Equal("Hello Alice", response.Message); } - [Fact] + [ConditionalFact] public async Task Grpc_Duplex() { // Arrange @@ -473,7 +473,7 @@ public async Task Grpc_Duplex() } - [Fact] + [ConditionalFact] public async Task Grpc_Duplex_Concurrency() { // Arrange @@ -526,7 +526,7 @@ public async Task Grpc_Duplex_Concurrency() } } - [Fact] + [ConditionalFact] public async Task Grpc_ShutdownAndDispose() { await using var server = await LaunchServerAsync(); @@ -572,7 +572,7 @@ async Task RunAsync() } } - [Fact] + [ConditionalFact] public async Task Grpc_Error_Status_ErrorCode() { // Arrange @@ -589,7 +589,7 @@ public async Task Grpc_Error_Status_ErrorCode() Assert.Equal(StatusCode.Cancelled, ((RpcException)ex).StatusCode); } - [Fact] + [ConditionalFact] public async Task Grpc_Error_Status_Unavailable_By_IOException() { // Arrange @@ -605,7 +605,7 @@ public async Task Grpc_Error_Status_Unavailable_By_IOException() Assert.Equal(StatusCode.Unavailable, ((RpcException)ex).StatusCode); } - [Fact] + [ConditionalFact] public async Task Grpc_Error_TimedOut_With_HttpClientTimeout() { // Arrange @@ -627,7 +627,7 @@ public async Task Grpc_Error_TimedOut_With_HttpClientTimeout() #endif } - [Fact] + [ConditionalFact] public async Task Grpc_Error_TimedOut_With_Deadline() { // Arrange @@ -644,7 +644,7 @@ public async Task Grpc_Error_TimedOut_With_Deadline() Assert.Equal(StatusCode.DeadlineExceeded, ((RpcException)ex).StatusCode); } - [Fact] + [ConditionalFact] public async Task Grpc_Error_TimedOut_With_CancellationToken() { // Arrange @@ -662,7 +662,7 @@ public async Task Grpc_Error_TimedOut_With_CancellationToken() Assert.Equal(StatusCode.Cancelled, ((RpcException)ex).StatusCode); } - [Fact] + [ConditionalFact] public async Task Enable_Http2KeepAlive() { // Arrange diff --git a/test/YetAnotherHttpHandler.Test/UdsTest.cs b/test/YetAnotherHttpHandler.Test/UdsTest.cs new file mode 100644 index 0000000..2a473c3 --- /dev/null +++ b/test/YetAnotherHttpHandler.Test/UdsTest.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography.X509Certificates; +using _YetAnotherHttpHandler.Test.Helpers.Testing; +using Cysharp.Net.Http; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Xunit.Abstractions; + +namespace _YetAnotherHttpHandler.Test; + +[OSSkipCondition(OperatingSystems.Windows)] +public class UdsTest(ITestOutputHelper testOutputHelper) : Http2TestBase(testOutputHelper) +{ + private readonly string _udsPath = Path.Combine(Path.GetTempPath(), $"yaha-uds-test-{Guid.NewGuid()}"); + protected override YetAnotherHttpHandler CreateHandler() + { + return new YetAnotherHttpHandler() + { + UnixDomainSocketPath = _udsPath, + Http2Only = true, + }; + } + + protected override Task LaunchServerAsyncCore(Action? configure = null) + { + return LaunchServerAsync(TestWebAppServerListenMode.SecureHttp2Only, builder => + { + builder.WebHost.ConfigureKestrel(options => + { + options.ListenUnixSocket(_udsPath, listenOptions => + { + listenOptions.Protocols = HttpProtocols.Http2; + }); + + // hyperlocal uses the 'unix' scheme and passes the URI to hyper. As a result, the ':scheme' header in the request is set to 'unix'. + // By default, Kestrel does not accept non-HTTP schemes. To allow non-HTTP schemes, we need to set 'AllowAlternateSchemes' to true. + options.AllowAlternateSchemes = true; + }); + + configure?.Invoke(builder); + }); + } +} \ No newline at end of file diff --git a/test/YetAnotherHttpHandler.Test/Usings.cs b/test/YetAnotherHttpHandler.Test/Usings.cs index 28c3087..20fb7f3 100644 --- a/test/YetAnotherHttpHandler.Test/Usings.cs +++ b/test/YetAnotherHttpHandler.Test/Usings.cs @@ -1,3 +1,4 @@ global using Xunit; global using _YetAnotherHttpHandler.Test.Helpers; +global using _YetAnotherHttpHandler.Test.Helpers.Testing;