diff --git a/README.md b/README.md index 25654d7b5..3ed5eb7c9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![Refit](images/logo.png) -## Refit: The automatic type-safe REST library for .NET Core, Xamarin and .NET +## Refit: The automatic type-safe REST library for modern .NET [![Build](https://github.com/reactiveui/refit/actions/workflows/ci-build.yml/badge.svg)](https://github.com/reactiveui/refit/actions/workflows/ci-build.yml) [![codecov](https://codecov.io/github/reactiveui/refit/branch/main/graph/badge.svg?token=2guEgHsDU2)](https://codecov.io/github/reactiveui/refit) @@ -28,7 +28,7 @@ var gitHubApi = RestService.For("https://api.github.com"); var octocat = await gitHubApi.GetUser("octocat"); ``` -.NET Core supports registering via HttpClientFactory +.NET supports registering Refit clients via HttpClientFactory: ```csharp services @@ -42,6 +42,8 @@ services * [Where does this work?](#where-does-this-work) * [Breaking changes in 6.x](#breaking-changes-in-6x) * [Breaking changes in 11.x](#breaking-changes-in-11x) +* [Source generation](#source-generation) + * [Generated request building](#generated-request-building) * [API Attributes](#api-attributes) * [Querystrings](#querystrings) * [Dynamic Querystring Parameters](#dynamic-querystring-parameters) @@ -86,11 +88,11 @@ Refit is sponsored by the following: ### Where does this work? -Refit currently supports the following platforms and any .NET Standard 2.0 target: +Refit currently supports the following platforms and modern .NET targets: * WinUI * Desktop .NET Framework 4.6.2+ -* .NET 8 / 9 / 10 +* .NET 8 / 9 / 10 / 11 * Blazor * Uno Platform @@ -159,6 +161,81 @@ mark those properties as non-null. The original `IApiResponse.IsSuccessful` and `IApiResponse.IsSuccessStatusCode` properties can still be used to check if the response was received and is successful. +### Source generation + +Refit ships Roslyn source generators with the main `Refit` package. Projects that reference Refit through +`PackageReference` get generated client implementations at build time without adding another package. + +The generated clients are still created with the normal APIs: + +```csharp +var api = RestService.For("https://api.github.com"); +``` + +or through `Refit.HttpClientFactory`: + +```csharp +services + .AddRefitClient() + .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://api.github.com")); +``` + +The generator uses the same `RefitSettings` you pass to `RestService.For` or `AddRefitClient`. That means generated +clients continue to honor settings such as: + +* `ContentSerializer` +* `UrlParameterFormatter` +* `UrlParameterKeyFormatter` +* `CollectionFormat` +* `AuthorizationHeaderValueGetter` +* `ExceptionFactory` +* `DeserializationExceptionFactory` +* `HttpRequestMessageOptions` +* `Version` and `VersionPolicy` + +#### Generated request building + +Refit's source generator now emits request construction directly for supported methods by default. Instead of generating +a method body that calls the reflective runtime request-building pipeline through `BuildRestResultFuncForMethod`, the +generated client can create the `HttpRequestMessage`, apply headers/properties/body content, and dispatch it through +Refit's generated-request runtime helpers. + +This default path reduces runtime reflection, method metadata lookup, object-array argument packing, and delegate +construction for request shapes the generator can safely model. It currently covers common request features including: + +* static `[Headers]` +* dynamic `[Header]` parameters +* `[HeaderCollection]` dictionaries +* `[Body]` content +* `[Property]` parameters +* `[Property]` interface properties +* cancellation tokens +* `Task`, `Task`, `Task>`, and related response wrappers + +If a method uses a shape the generator cannot safely emit yet, Refit falls back to the existing runtime request-builder +path for that method. + +You can explicitly turn generated request building off in a project file: + +```xml + + false + +``` + +That keeps the source-generated interface implementation but uses the legacy reflective request-building call path +inside generated methods. + +If you need to disable Refit source generation entirely, set: + +```xml + + true + +``` + +Most applications should leave both settings unset. + ### API Attributes Every method must have an HTTP attribute that provides the request method and @@ -1425,7 +1502,7 @@ internal class ApiClient : IApiClient ### Using HttpClientFactory -Refit has first class support for the ASP.Net Core 2.1 HttpClientFactory. Add a reference to `Refit.HttpClientFactory` +Refit has first class support for `IHttpClientFactory`. Add a reference to `Refit.HttpClientFactory` and call the provided extension method in your `ConfigureServices` method to configure your Refit interface: diff --git a/config/filelist.txt b/config/filelist.txt deleted file mode 100644 index f087d88bd..000000000 --- a/config/filelist.txt +++ /dev/null @@ -1,2 +0,0 @@ -**/Refit.* -**/InterfaceStubGenerator.* \ No newline at end of file diff --git a/config/signclient.json b/config/signclient.json deleted file mode 100644 index 3276a45de..000000000 --- a/config/signclient.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "SignClient": { - "AzureAd": { - "AADInstance": "https://login.microsoftonline.com/", - "ClientId": "c248d68a-ba6f-4aa9-8a68-71fe872063f8", - "TenantId": "16076fdc-fcc1-4a15-b1ca-32c9a255900e" - }, - "Service": { - "Url": "https://codesign.dotnetfoundation.org/", - "ResourceId": "https://SignService/3c30251f-36f3-490b-a955-520addb85001" - } - } -} \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 7d6057304..e51709ec3 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -6,33 +6,48 @@ $(MSBuildProjectName.EndsWith('Tests')) embedded ReactiveUI and Contributors + ReactiveUI and Contributors Copyright (c) ReactiveUI and Contributors MIT https://github.com/reactiveui/refit logo.png README.md + https://github.com/reactiveui/refit + git + true en-US - The automatic type-safe REST library for Xamarin and .NET + Type-safe REST API clients for .NET, generated from annotated interfaces and optimized for modern HttpClient, System.Text.Json, source generation, trimming, and AOT scenarios. + refit;rest;http;httpclient;api-client;typed-client;source-generator;roslyn;system-text-json;json;aot;trimming + https://github.com/reactiveui/refit/releases true - preview true latest + AnyCPU true latest true true true + true enable nullable;CS4014 - true + enable true + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb true $(MSBuildProjectDirectory)/obj/Generated + + + true + + + + netstandard2.0 net462;net470;net471;net472;net48;net481 net8.0;net9.0;net10.0;net11.0 @@ -40,13 +55,12 @@ $(RefitModernTargets) $(RefitModernTargets) $(RefitShippingTargets) - - - true - - true + + true + true + true @@ -56,6 +70,10 @@ true + + $(Features);runtime-async=on + + @@ -70,6 +88,7 @@ + @@ -81,6 +100,23 @@ + + + + + + + + + + + + + + + + + @@ -103,6 +139,10 @@ + + $(MSBuildThisFileDirectory) + + minor alpha.0 @@ -111,7 +151,6 @@ - diff --git a/src/InterfaceStubGenerator.Roslyn48/InterfaceStubGenerator.Roslyn48.csproj b/src/InterfaceStubGenerator.Roslyn48/InterfaceStubGenerator.Roslyn48.csproj index 2aa9a65be..9647db04a 100644 --- a/src/InterfaceStubGenerator.Roslyn48/InterfaceStubGenerator.Roslyn48.csproj +++ b/src/InterfaceStubGenerator.Roslyn48/InterfaceStubGenerator.Roslyn48.csproj @@ -9,7 +9,6 @@ enable $(DefineConstants);ROSLYN_4 4.8.0 - true true @@ -20,6 +19,11 @@ + + + + + @@ -29,6 +33,4 @@ - - diff --git a/src/InterfaceStubGenerator.Roslyn50/InterfaceStubGenerator.Roslyn50.csproj b/src/InterfaceStubGenerator.Roslyn50/InterfaceStubGenerator.Roslyn50.csproj index a32501300..ff3ea8869 100644 --- a/src/InterfaceStubGenerator.Roslyn50/InterfaceStubGenerator.Roslyn50.csproj +++ b/src/InterfaceStubGenerator.Roslyn50/InterfaceStubGenerator.Roslyn50.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 @@ -9,7 +9,6 @@ enable $(DefineConstants);ROSLYN_4;ROSLYN_5 5.0.0 - true true @@ -18,6 +17,14 @@ + + + + + + + + $(BuildVersion) @@ -25,6 +32,4 @@ - - diff --git a/src/InterfaceStubGenerator.Shared/Emitter.Helpers.cs b/src/InterfaceStubGenerator.Shared/Emitter.Helpers.cs new file mode 100644 index 000000000..9b6301a30 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Emitter.Helpers.cs @@ -0,0 +1,207 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace Refit.Generator; + +/// Internal emitter helpers that are directly covered by focused tests. +internal static partial class Emitter +{ + /// Builds the request-body buffering expression for an inline generated method. + /// The parsed body parameter, if any. + /// The buffering expression. + internal static string BuildBufferBodyExpression(RequestParameterModel? bodyParameter) => + bodyParameter is null + ? FalseLiteral + : bodyParameter.BodyBufferMode switch + { + BodyBufferMode.Settings => "______settings.Buffered", + BodyBufferMode.Buffered => TrueLiteral, + _ => FalseLiteral + }; + + /// Builds the serialized-body streaming expression for an inline generated method. + /// The parsed body parameter. + /// The streaming expression. + internal static string BuildStreamBodyExpression(RequestParameterModel bodyParameter) => + bodyParameter.BodySerializationMethod == "UrlEncoded" + ? FalseLiteral + : bodyParameter.BodyBufferMode switch + { + BodyBufferMode.Settings => "!______settings.Buffered", + BodyBufferMode.Buffered => FalseLiteral, + BodyBufferMode.Streaming => TrueLiteral, + _ => FalseLiteral + }; + + /// Builds the expression used to read an implemented interface property. + /// The property model. + /// The generated property access expression. + internal static string BuildPropertyAccessExpression(InterfacePropertyModel property) + { + if (property.IsSatisfiedByGeneratedMember) + { + return "this.Client"; + } + + return property.IsExplicitInterface + ? $"(({EnsureGlobalPrefix(property.ContainingType)})this).{property.Name}" + : $"this.{property.Name}"; + } + + /// Ensures a type display name is prefixed with global::. + /// The type display name. + /// The globally qualified type display name. + internal static string EnsureGlobalPrefix(string typeName) => + typeName.StartsWith(GlobalPrefix, StringComparison.Ordinal) + ? typeName + : GlobalPrefix + typeName; + + /// Maps a parsed HTTP method name to an expression that creates or returns an . + /// The HTTP method text. + /// The HTTP method expression. + [ExcludeFromCodeCoverage] + internal static string ToHttpMethodExpression(string httpMethod) => + httpMethod switch + { + "DELETE" => "global::System.Net.Http.HttpMethod.Delete", + "GET" => "global::System.Net.Http.HttpMethod.Get", + "HEAD" => "global::System.Net.Http.HttpMethod.Head", + "OPTIONS" => "global::System.Net.Http.HttpMethod.Options", + "POST" => "global::System.Net.Http.HttpMethod.Post", + "PUT" => "global::System.Net.Http.HttpMethod.Put", + "PATCH" => "new global::System.Net.Http.HttpMethod(\"PATCH\")", + _ => throw new ArgumentOutOfRangeException(nameof(httpMethod), httpMethod, "Unsupported HTTP method.") + }; + + /// Gets the invocation text used for a generated method return type. + /// The method return type shape. + /// The async flag, return prefix, and configure-await suffix. + /// Thrown when is unsupported. + internal static (bool IsAsync, string ReturnPrefix, string ConfigureAwaitSuffix) GetReturnInvocationParts( + ReturnTypeInfo returnTypeInfo) => + returnTypeInfo switch + { + ReturnTypeInfo.AsyncVoid => (true, "await (", ").ConfigureAwait(false)"), + ReturnTypeInfo.AsyncResult => (true, "return await (", ").ConfigureAwait(false)"), + ReturnTypeInfo.Return => (false, "return ", string.Empty), + ReturnTypeInfo.SyncVoid => (false, string.Empty, string.Empty), + _ => throw new ArgumentOutOfRangeException( + nameof(returnTypeInfo), + returnTypeInfo, + "Unsupported value.") + }; + + /// Converts a nullable string into a C# string literal or null literal. + /// The value to quote. + /// The generated expression. + internal static string ToNullableCSharpStringLiteral(string? value) => + value is null ? "null" : ToCSharpStringLiteral(value); + + /// Appends one escaped C# string-literal character. + /// The target builder. + /// The character to append. + [SuppressMessage( + "CodeQuality", + "S1541:Methods and properties should not be too complex", + Justification = "A compact switch avoids a dictionary or repeated helper calls on the generator hot path.")] + internal static void AppendEscapedCharacter(StringBuilder builder, char character) => + _ = character switch + { + '\\' => builder.Append(@"\\"), + '"' => builder.Append("\\\""), + '\0' => builder.Append(@"\0"), + '\a' => builder.Append(@"\a"), + '\b' => builder.Append(@"\b"), + '\f' => builder.Append(@"\f"), + '\n' => builder.Append(@"\n"), + '\r' => builder.Append(@"\r"), + '\t' => builder.Append(@"\t"), + '\v' => builder.Append(@"\v"), + _ => builder.Append(character) + }; + + /// Strips an explicit interface prefix from a method name (e.g. IFoo.Bar becomes Bar). + /// The method name to normalize. + /// The method name without any explicit interface prefix. + internal static string StripExplicitInterfacePrefix(string name) + { + var lastDotIndex = name.LastIndexOf('.'); + return lastDotIndex >= 0 && lastDotIndex < name.Length - 1 + ? name[(lastDotIndex + 1)..] + : name; + } + + /// Emits the method signature, constraints, and opening brace. + /// The source writer to emit to. + /// The method model being emitted. + /// True if the method is a derived explicit implementation. + /// True if the method is an explicit interface implementation. + /// True if the method should be emitted as async. + internal static void WriteMethodOpening( + SourceWriter source, + MethodModel methodModel, + bool isDerivedExplicitImpl, + bool isExplicitInterface, + bool isAsync = false) + { + var visibility = !isExplicitInterface ? "public " : string.Empty; + var asyncKeyword = isAsync ? "async " : string.Empty; + + source.WriteLine(); + source.WriteLine("/// "); + source.WriteIndentation(); + source.Append(visibility); + source.Append(asyncKeyword); + source.Append(methodModel.ReturnType); + source.Append(' '); + + if (isExplicitInterface) + { + var ct = methodModel.ContainingType; + if (!ct.StartsWith(GlobalPrefix, StringComparison.Ordinal)) + { + source.Append(GlobalPrefix); + } + + source.Append(ct); + source.Append('.'); + } + + source.Append(methodModel.DeclaredMethod); + source.Append('('); + + var parameters = methodModel.Parameters.AsArray(); + if (parameters.Length > 0) + { + for (var i = 0; i < parameters.Length; i++) + { + if (i > 0) + { + source.Append(", "); + } + + var (metadataName, type, annotation, _) = parameters[i]; + source.Append(type); + if (annotation) + { + source.Append('?'); + } + + source.Append(" @"); + source.Append(metadataName); + } + } + + source.Append(')'); + source.WriteLine(); + source.Indentation++; + GenerateConstraints(source, methodModel.Constraints, isDerivedExplicitImpl || isExplicitInterface); + source.Indentation--; + source.WriteLine("{"); + source.Indentation++; + } +} diff --git a/src/InterfaceStubGenerator.Shared/Emitter.cs b/src/InterfaceStubGenerator.Shared/Emitter.cs index 42221f710..bea5d74d0 100644 --- a/src/InterfaceStubGenerator.Shared/Emitter.cs +++ b/src/InterfaceStubGenerator.Shared/Emitter.cs @@ -8,14 +8,38 @@ namespace Refit.Generator; /// Emits the generated source code for Refit interface implementations. -internal static class Emitter +internal static partial class Emitter { + /// The generated literal for . + private const string FalseLiteral = "false"; + + /// The generated literal for . + private const string TrueLiteral = "true"; + + /// The C# global namespace alias prefix. + private const string GlobalPrefix = "global::"; + /// The variable name used for the cached type parameter array field. private const string TypeParameterVariableName = "______typeParameters"; /// Indentation levels spanned by the generated namespace and class nesting. private const int NamespaceAndClassIndentation = 2; + /// Initial buffer size for shared generated infrastructure source. + private const int SharedGeneratedSourceBaseCapacity = 2048; + + /// Estimated characters needed for one generated factory registration. + private const int EstimatedFactoryRegistrationCapacity = 256; + + /// Initial buffer size for a typical generated interface implementation. + private const int InterfaceSourceBaseCapacity = 4096; + + /// Estimated characters needed for one generated method body. + private const int EstimatedMethodSourceCapacity = 768; + + /// Estimated characters needed for one generated property. + private const int EstimatedPropertySourceCapacity = 128; + /// Emits the shared preserve attribute and factory registration code. /// The context generation model describing the interfaces. /// Callback used to add generated source files. @@ -38,6 +62,8 @@ public static void EmitSharedCode( var attributeText = $$""" + // This file is generated into consumer projects; suppress all analyzers so + // consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace {{model.RefitInternalNamespace}} { @@ -61,49 +87,45 @@ sealed class PreserveAttribute : global::System.Attribute // add the attribute text addSource("PreserveAttribute.g.cs", SourceText.From(attributeText, Encoding.UTF8)); - var generatedFactoryRegistrations = string.Join( - "\n", - model.Interfaces - .Where(static interfaceModel => !interfaceModel.ClassDeclaration.Contains("<")) - .Select(static interfaceModel => - " global::Refit.RestService.RegisterGeneratedFactory(typeof(" - + $"{interfaceModel.InterfaceDisplayName}), static (client, requestBuilder) => new " - + $"global::Refit.Implementation.Generated.{interfaceModel.Ns}{interfaceModel.ClassSuffix}" - + "(client, requestBuilder));")); - const string dynamicDependencyLine = "[System.Diagnostics.CodeAnalysis.DynamicDependency(" + "System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, " + "typeof(global::Refit.Implementation.Generated))]"; - var generatedClassText = $$""" - - #pragma warning disable - namespace Refit.Implementation - { - - /// - [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - [global::System.Diagnostics.DebuggerNonUserCode] - [{{model.PreserveAttributeDisplayName}}] - [global::System.Reflection.Obfuscation(Exclude=true)] - [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - internal static partial class Generated - { - #if NET5_0_OR_GREATER - [System.Runtime.CompilerServices.ModuleInitializer] - {{dynamicDependencyLine}} - public static void Initialize() - { - {{generatedFactoryRegistrations}} - } - #endif - } - } - #pragma warning restore - - """; - addSource("Generated.g.cs", SourceText.From(generatedClassText, Encoding.UTF8)); + var generatedSource = new SourceWriter(EstimateSharedSourceCapacity(model)); + generatedSource.WriteLine( + $$""" + + // This file is generated into consumer projects; suppress all analyzers so + // consumer analyzer policy does not report Refit implementation details. + #pragma warning disable + namespace Refit.Implementation + { + + /// + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::System.Diagnostics.DebuggerNonUserCode] + [{{model.PreserveAttributeDisplayName}}] + [global::System.Reflection.Obfuscation(Exclude=true)] + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + internal static partial class Generated + { + #if NET5_0_OR_GREATER + [System.Runtime.CompilerServices.ModuleInitializer] + {{dynamicDependencyLine}} + public static void Initialize() + { + """); + WriteGeneratedFactoryRegistrations(generatedSource, model.Interfaces); + generatedSource.WriteLine( + """ + } + #endif + } + } + #pragma warning restore + """); + addSource("Generated.g.cs", generatedSource.ToSourceText()); } /// Emits the generated implementation source for a single interface. @@ -111,7 +133,7 @@ public static void Initialize() /// The generated source text for the interface implementation. public static SourceText EmitInterface(InterfaceModel model) { - var source = new SourceWriter(); + var source = new SourceWriter(EstimateInterfaceSourceCapacity(model)); // if nullability is supported emit the nullable directive if (model.Nullability != Nullability.None) @@ -122,6 +144,8 @@ public static SourceText EmitInterface(InterfaceModel model) source.WriteLine( $$""" + // This file is generated into consumer projects; suppress all analyzers so + // consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -163,15 +187,20 @@ partial class {{model.Ns}}{{model.ClassDeclaration}} var uniqueNames = new UniqueNameBuilder(); uniqueNames.Reserve(model.MemberNames); + foreach (var property in model.Properties) + { + WriteInterfaceProperty(source, property); + } + // Handle Refit Methods foreach (var method in model.RefitMethods) { - WriteRefitMethod(source, method, true, uniqueNames); + WriteRefitMethod(source, method, true, model, uniqueNames); } foreach (var method in model.DerivedRefitMethods) { - WriteRefitMethod(source, method, false, uniqueNames); + WriteRefitMethod(source, method, false, model, uniqueNames); } // Handle non-refit Methods that aren't static or properties or have a method body @@ -198,10 +227,56 @@ partial class {{model.Ns}}{{model.ClassDeclaration}} return source.ToSourceText(); } + /// Estimates the initial buffer size for shared generated infrastructure source. + /// The context generation model describing the interfaces. + /// The estimated source buffer capacity. + private static int EstimateSharedSourceCapacity(ContextGenerationModel model) => + SharedGeneratedSourceBaseCapacity + (model.Interfaces.Count * EstimatedFactoryRegistrationCapacity); + + /// Estimates the initial buffer size for one generated interface implementation. + /// The interface model being emitted. + /// The estimated source buffer capacity. + private static int EstimateInterfaceSourceCapacity(InterfaceModel model) + { + var methodCount = + model.RefitMethods.Count + model.DerivedRefitMethods.Count + model.NonRefitMethods.Count; + return InterfaceSourceBaseCapacity + + (methodCount * EstimatedMethodSourceCapacity) + + (model.Properties.Count * EstimatedPropertySourceCapacity); + } + + /// Writes the generated factory registrations for non-generic interfaces. + /// The source writer to emit to. + /// The parsed interface models. + private static void WriteGeneratedFactoryRegistrations( + SourceWriter source, + ImmutableEquatableArray interfaces) + { + for (var i = 0; i < interfaces.Count; i++) + { + var interfaceModel = interfaces[i]; + if (interfaceModel.ClassDeclaration.Contains("<")) + { + continue; + } + + source.WriteLine(" global::Refit.RestService.RegisterGeneratedFactory("); + source.Append(" typeof("); + source.Append(interfaceModel.InterfaceDisplayName); + source.WriteLine("),"); + source.Append( + " static (client, requestBuilder) => new global::Refit.Implementation.Generated."); + source.Append(interfaceModel.Ns); + source.Append(interfaceModel.ClassSuffix); + source.WriteLine("(client, requestBuilder));"); + } + } + /// Generates the body of the Refit method. /// The source writer to emit to. /// The method model being emitted. /// True if directly from the type we're generating for, false for methods found on base interfaces. + /// The interface model being emitted. /// Contains the unique member names in the interface scope. [SuppressMessage( "Usage", @@ -212,111 +287,372 @@ private static void WriteRefitMethod( SourceWriter source, MethodModel methodModel, bool isTopLevel, + InterfaceModel interfaceModel, UniqueNameBuilder uniqueNames) { - var parameterTypesExpression = GenerateTypeParameterExpression( + if (interfaceModel.GeneratedRequestBuilding && methodModel.Request.CanGenerateInline) + { + WriteInlineRefitMethod(source, methodModel, interfaceModel, isTopLevel); + return; + } + + var cachedTypeParameterFieldName = GenerateTypeParameterField( source, methodModel, uniqueNames); var returnType = methodModel.ReturnType; - var (isAsync, @return, configureAwait) = methodModel.ReturnTypeMetadata switch - { - ReturnTypeInfo.AsyncVoid => (true, "await (", ").ConfigureAwait(false)"), - ReturnTypeInfo.AsyncResult => (true, "return await (", ").ConfigureAwait(false)"), - ReturnTypeInfo.Return => (false, "return ", string.Empty), - ReturnTypeInfo.SyncVoid => (false, string.Empty, string.Empty), - _ => throw new ArgumentOutOfRangeException( - nameof(methodModel.ReturnTypeMetadata), - methodModel.ReturnTypeMetadata, - "Unsupported value.") - }; + var (isAsync, @return, configureAwait) = GetReturnInvocationParts(methodModel.ReturnTypeMetadata); var isExplicit = methodModel.IsExplicitInterface || !isTopLevel; WriteMethodOpening(source, methodModel, isExplicit, isExplicit, isAsync); - var argumentsArrayString = BuildArgumentsArrayLiteral(methodModel); - var genericString = BuildGenericTypesArgument(methodModel); var lookupName = StripExplicitInterfacePrefix(methodModel.Name); - var callExpression = methodModel.ReturnTypeMetadata == ReturnTypeInfo.SyncVoid - ? "______func(this.Client, ______arguments);" - : $"{@return}({returnType})______func(this.Client, ______arguments){configureAwait};"; + source.WriteIndentation(); + source.Append("var ______arguments = "); + AppendArgumentsArrayLiteral(source, methodModel); + source.Append(';'); + source.WriteLine(); + + source.WriteIndentation(); + source.Append("var ______func = requestBuilder.BuildRestResultFuncForMethod(\""); + source.Append(lookupName); + source.Append("\", "); + AppendTypeParameterExpression(source, methodModel.Parameters, cachedTypeParameterFieldName); + AppendGenericTypesArgument(source, methodModel); + source.Append(" );"); + source.WriteLine(); + + source.WriteLine(); + if (methodModel.ReturnTypeMetadata == ReturnTypeInfo.SyncVoid) + { + source.WriteLine("______func(this.Client, ______arguments);"); + } + else + { + source.WriteIndentation(); + source.Append(@return); + source.Append('('); + source.Append(returnType); + source.Append(")______func(this.Client, ______arguments)"); + source.Append(configureAwait); + source.Append(';'); + source.WriteLine(); + } + + WriteMethodClosing(source); + } + /// Generates a Refit method that constructs the request directly in generated code. + /// The source writer to emit to. + /// The method model being emitted. + /// The interface model being emitted. + /// True if directly from the type we're generating for, false for methods found on base interfaces. + private static void WriteInlineRefitMethod( + SourceWriter source, + MethodModel methodModel, + InterfaceModel interfaceModel, + bool isTopLevel) + { + var isExplicit = methodModel.IsExplicitInterface || !isTopLevel; + WriteMethodOpening(source, methodModel, isExplicit, isExplicit); + + var request = methodModel.Request; + var bodyParameter = FindRequestParameter(request, RequestParameterKind.Body); + var cancellationTokenExpression = BuildCancellationTokenExpression(request); + var bufferBodyExpression = BuildBufferBodyExpression(bodyParameter); + + source.WriteLine("var ______settings = requestBuilder.Settings;"); source.WriteLine( - $""" - var ______arguments = {argumentsArrayString}; - var ______func = requestBuilder.BuildRestResultFuncForMethod("{lookupName}", {parameterTypesExpression}{genericString} ); + """var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance");"""); + source.WriteLine( + "______basePath = ______basePath == \"/\" ? string.Empty : ______basePath.TrimEnd('/');"); + var requestUriExpression = + $"new global::System.Uri(______basePath + {ToCSharpStringLiteral(request.Path)}, global::System.UriKind.Relative)"; + source.WriteLine( + $"var ______rq = new global::System.Net.Http.HttpRequestMessage({ToHttpMethodExpression(request.HttpMethod)}, {requestUriExpression});"); + source.WriteLine( + """ + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + """); - {callExpression} - """); + if (bodyParameter is not null) + { + var streamBodyExpression = BuildStreamBodyExpression(bodyParameter); + var serializationMethodExpression = BuildBodySerializationMethodExpression(bodyParameter); + var contentExpression = + $""" + global::Refit.GeneratedRequestRunner.CreateBodyContent<{bodyParameter.Type}>( + ______settings, + @{bodyParameter.Name}, + {serializationMethodExpression}, + {streamBodyExpression}) + """; + source.WriteLine( + $"______rq.Content = {contentExpression};"); + } + WriteInlineHeaders(source, request); + WriteInlineRequestProperties(source, request, interfaceModel); + WriteInlineReturn(source, methodModel, request, bufferBodyExpression, cancellationTokenExpression); WriteMethodClosing(source); } - /// Builds the object[] literal that holds the method's argument values. + /// Emits the return statement for an inline generated Refit method. + /// The source writer to emit to. + /// The method model being emitted. + /// The parsed request model. + /// The expression indicating whether request content should be buffered. + /// The cancellation token expression. + private static void WriteInlineReturn( + SourceWriter source, + MethodModel methodModel, + RequestModel request, + string bufferBodyExpression, + string cancellationTokenExpression) + { + if (methodModel.ReturnTypeMetadata == ReturnTypeInfo.AsyncVoid) + { + var sendVoidExpression = + $""" + global::Refit.GeneratedRequestRunner.SendVoidAsync( + this.Client, + ______rq, + ______settings, + {bufferBodyExpression}, + {cancellationTokenExpression}) + """; + source.WriteLine( + $"return {sendVoidExpression};"); + return; + } + + var sendExpression = + $""" + global::Refit.GeneratedRequestRunner.SendAsync<{request.ResultType}, {request.DeserializedResultType}>( + this.Client, + ______rq, + ______settings, + {ToLowerInvariantString(request.IsApiResponse)}, + {ToLowerInvariantString(request.ShouldDisposeResponse)}, + {bufferBodyExpression}, + {cancellationTokenExpression}) + """; + + if (methodModel.ReturnType.StartsWith("global::System.Threading.Tasks.ValueTask<", StringComparison.Ordinal)) + { + source.WriteLine($"return new {methodModel.ReturnType}({sendExpression});"); + return; + } + + source.WriteLine($"return {sendExpression};"); + } + + /// Emits static and dynamic header application for an inline generated method. + /// The source writer to emit to. + /// The parsed request model. + private static void WriteInlineHeaders(SourceWriter source, RequestModel request) + { + foreach (var header in request.StaticHeaders) + { + source.WriteLine( + $"global::Refit.GeneratedRequestRunner.SetHeader(______rq, {ToCSharpStringLiteral(header.Name)}, " + + $"{ToNullableCSharpStringLiteral(header.Value)});"); + } + + foreach (var parameter in request.Parameters) + { + switch (parameter.Kind) + { + case RequestParameterKind.Header: + { + source.WriteLine( + $"global::Refit.GeneratedRequestRunner.SetHeader(______rq, {ToCSharpStringLiteral(parameter.HeaderName)}, " + + $"{BuildHeaderValueExpression(parameter)});"); + continue; + } + + case RequestParameterKind.HeaderCollection: + { + source.WriteLine( + $"global::Refit.GeneratedRequestRunner.AddHeaderCollection(______rq, @{parameter.Name});"); + break; + } + } + } + } + + /// Builds a header value expression without null-conditionals on non-nullable value types. + /// The header parameter to format. + /// The generated header value expression. + private static string BuildHeaderValueExpression(RequestParameterModel parameter) + { + var parameterExpression = $"@{parameter.Name}"; + return parameter.CanBeNull + ? $"{parameterExpression}?.ToString()" + : $"{parameterExpression}.ToString()"; + } + + /// Emits request-option/property application for an inline generated method. + /// The source writer to emit to. + /// The parsed request model. + /// The interface model being emitted. + private static void WriteInlineRequestProperties( + SourceWriter source, + RequestModel request, + InterfaceModel interfaceModel) + { + source.WriteLine( + "global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, " + + $"typeof({interfaceModel.InterfaceDisplayName}));"); + + foreach (var property in interfaceModel.Properties) + { + if (property.RequestPropertyKey.Length == 0 || !property.HasGetter) + { + continue; + } + + source.WriteLine( + $"global::Refit.GeneratedRequestRunner.AddRequestProperty<{property.Type}>" + + $"(______rq, {ToCSharpStringLiteral(property.RequestPropertyKey)}, " + + $"{BuildPropertyAccessExpression(property)});"); + } + + foreach (var parameter in request.Parameters) + { + if (parameter.Kind == RequestParameterKind.Property) + { + source.WriteLine( + $"global::Refit.GeneratedRequestRunner.AddRequestProperty<{parameter.Type}>" + + $"(______rq, {ToCSharpStringLiteral(parameter.PropertyKey)}, @{parameter.Name});"); + } + } + } + + /// Finds the first request parameter of the given kind. + /// The request model to inspect. + /// The parameter kind to find. + /// The parameter model, if present. + private static RequestParameterModel? FindRequestParameter(RequestModel request, RequestParameterKind kind) + { + foreach (var parameter in request.Parameters) + { + if (parameter.Kind == kind) + { + return parameter; + } + } + + return null; + } + + /// Builds the cancellation token expression for an inline generated method. + /// The request model to inspect. + /// The cancellation token expression. + private static string BuildCancellationTokenExpression(RequestModel request) + { + var cancellationToken = FindRequestParameter(request, RequestParameterKind.CancellationToken); + if (cancellationToken is null) + { + return "global::System.Threading.CancellationToken.None"; + } + + return cancellationToken.CanBeNull + ? $"@{cancellationToken.Name}.GetValueOrDefault()" + : $"@{cancellationToken.Name}"; + } + + /// Builds the body serialization enum expression for an inline generated method. + /// The parsed body parameter. + /// The serialization method expression. + private static string BuildBodySerializationMethodExpression(RequestParameterModel bodyParameter) + { + var serializationMethod = bodyParameter.BodySerializationMethod == "Json" + ? "Serialized" + : bodyParameter.BodySerializationMethod; + return $"global::Refit.BodySerializationMethod.{serializationMethod}"; + } + + /// Converts a bool to a lowercase C# literal. + /// The value to convert. + /// The lowercase bool literal. + private static string ToLowerInvariantString(bool value) => value ? TrueLiteral : FalseLiteral; + + /// Converts a string into a C# string literal. + /// The value to quote. + /// The escaped C# string literal. + private static string ToCSharpStringLiteral(string value) + { + var builder = new StringBuilder(value.Length + 2); + builder.Append('"'); + foreach (var c in value) + { + AppendEscapedCharacter(builder, c); + } + + builder.Append('"'); + return builder.ToString(); + } + + /// Appends the object[] literal that holds the method's argument values. + /// The source writer to append to. /// The method model being emitted. - /// The arguments array expression. - private static string BuildArgumentsArrayLiteral(MethodModel methodModel) + private static void AppendArgumentsArrayLiteral(SourceWriter source, MethodModel methodModel) { // Build the arguments array literal directly. This runs for every Refit method, so we // avoid LINQ Select/ToArray + string.Join and their intermediate array/iterator allocations. var parameters = methodModel.Parameters.AsArray(); if (parameters.Length == 0) { - return "global::System.Array.Empty()"; + source.Append("global::System.Array.Empty()"); + return; } - var argsBuilder = new StringBuilder("new object[] { "); + source.Append("new object[] { "); for (var i = 0; i < parameters.Length; i++) { if (i > 0) { - argsBuilder.Append(", "); + source.Append(", "); } - argsBuilder.Append('@').Append(parameters[i].MetadataName); + source.Append('@'); + source.Append(parameters[i].MetadataName); } - argsBuilder.Append(" }"); - return argsBuilder.ToString(); + source.Append(" }"); } - /// Builds the optional generic Type[] argument for the request builder call. + /// Appends the optional generic Type[] argument for the request builder call. + /// The source writer to append to. /// The method model being emitted. - /// The generic type argument, or an empty string when the method has no constraints. - private static string BuildGenericTypesArgument(MethodModel methodModel) + private static void AppendGenericTypesArgument(SourceWriter source, MethodModel methodModel) { var constraints = methodModel.Constraints.AsArray(); if (constraints.Length == 0) { - return string.Empty; + return; } - var genericBuilder = new StringBuilder(", new global::System.Type[] { "); + source.Append(", new global::System.Type[] { "); for (var i = 0; i < constraints.Length; i++) { if (i > 0) { - genericBuilder.Append(", "); + source.Append(", "); } - genericBuilder.Append("typeof(").Append(constraints[i].DeclaredName).Append(')'); + source.Append("typeof("); + source.Append(constraints[i].DeclaredName); + source.Append(')'); } - genericBuilder.Append(" }"); - return genericBuilder.ToString(); - } - - /// Strips an explicit interface prefix from a method name (e.g. IFoo.Bar becomes Bar). - /// The method name to normalize. - /// The method name without any explicit interface prefix. - private static string StripExplicitInterfacePrefix(string name) - { - var lastDotIndex = name.LastIndexOf('.'); - return lastDotIndex >= 0 && lastDotIndex < name.Length - 1 - ? name.Substring(lastDotIndex + 1) - : name; + source.Append(" }"); } /// Emits a stub body for a non-Refit method that throws at runtime. @@ -335,6 +671,32 @@ private static void WriteNonRefitMethod(SourceWriter source, MethodModel methodM WriteMethodClosing(source); } + /// Emits an interface property implementation. + /// The source writer to emit to. + /// The property model being emitted. + private static void WriteInterfaceProperty(SourceWriter source, InterfacePropertyModel property) + { + if (property.IsSatisfiedByGeneratedMember) + { + return; + } + + var visibility = property.IsExplicitInterface ? string.Empty : "public "; + var annotation = property.Annotation ? "?" : string.Empty; + var explicitInterface = property.IsExplicitInterface + ? EnsureGlobalPrefix(property.ContainingType) + "." + : string.Empty; + var getter = property.HasGetter ? " get;" : string.Empty; + var setter = property.HasSetter ? " set;" : string.Empty; + + source.WriteLine( + $$""" + + /// + {{visibility}}{{property.Type}}{{annotation}} {{explicitInterface}}{{property.Name}} { {{getter}}{{setter}} } + """); + } + /// Emits the explicit IDisposable.Dispose implementation. /// The source writer to emit to. private static void WriteDisposableMethod(SourceWriter source) => @@ -348,104 +710,97 @@ private static void WriteDisposableMethod(SourceWriter source) => } """); - /// Generates the expression used to pass the method's parameter types to the request builder. + /// Generates a cached field for non-generic method parameter types, when possible. /// The source writer to emit any backing field to. /// The method model being emitted. /// Contains the unique member names in the interface scope. - /// The expression that resolves the parameter type array. - private static string GenerateTypeParameterExpression( + /// The generated field name, or when the type array must be emitted inline. + private static string? GenerateTypeParameterField( SourceWriter source, MethodModel methodModel, UniqueNameBuilder uniqueNames) { - // use Array.Empty if method has no parameters. - if (methodModel.Parameters.Count == 0) + // Use Array.Empty when there are no parameters and inline arrays when method type parameters are involved. + if (methodModel.Parameters.Count == 0 || ContainsGenericParameter(methodModel.Parameters)) { - return "global::System.Array.Empty()"; - } - - // if one of the parameters is/contains a type parameter then it cannot be cached as it will change type between calls. - if (methodModel.Parameters.Any(x => x.IsGeneric)) - { - var typeEnumerable = methodModel.Parameters.Select(param => $"typeof({param.Type})"); - return $"new global::System.Type[] {{ {string.Join(", ", typeEnumerable)} }}"; + return null; } // find a name and generate field declaration. var typeParameterFieldName = uniqueNames.New(TypeParameterVariableName); - var types = string.Join(", ", methodModel.Parameters.Select(x => $"typeof({x.Type})")); - - source.WriteLine( - $$""" - private static readonly global::System.Type[] {{typeParameterFieldName}} = new global::System.Type[] {{{types}} }; - """); + source.WriteLine(); + source.WriteIndentation(); + source.Append("private static readonly global::System.Type[] "); + source.Append(typeParameterFieldName); + source.Append(" = new global::System.Type[] {"); + AppendParameterTypeList(source, methodModel.Parameters); + source.Append(" };"); + source.WriteLine(); return typeParameterFieldName; } - /// Emits the method signature, constraints, and opening brace. - /// The source writer to emit to. - /// The method model being emitted. - /// True if the method is a derived explicit implementation. - /// True if the method is an explicit interface implementation. - /// True if the method should be emitted as async. - [SuppressMessage( - "Performance", - "CA1834:Consider using 'StringBuilder.Append(char)' when applicable", - Justification = "Generator emit path; keeping the string overload preserves the existing output.")] - private static void WriteMethodOpening( - SourceWriter source, - MethodModel methodModel, - bool isDerivedExplicitImpl, - bool isExplicitInterface, - bool isAsync = false) + /// Determines whether any parameter type depends on a method type parameter. + /// The parameter models to inspect. + /// True when at least one parameter is generic. + private static bool ContainsGenericParameter(ImmutableEquatableArray parameters) { - var visibility = !isExplicitInterface ? "public " : string.Empty; - var asyncKeyword = isAsync ? "async " : string.Empty; - - var builder = new StringBuilder(); - builder.Append( - @$"/// -{visibility}{asyncKeyword}{methodModel.ReturnType} "); - - if (isExplicitInterface) + for (var i = 0; i < parameters.Count; i++) { - var ct = methodModel.ContainingType; - if (!ct.StartsWith("global::", StringComparison.Ordinal)) + if (parameters[i].IsGeneric) { - ct = "global::" + ct; + return true; } + } + + return false; + } + + /// Appends the expression used to pass the method's parameter types to the request builder. + /// The source writer to append to. + /// The parameter models to emit. + /// The cached field name, if one was generated. + private static void AppendTypeParameterExpression( + SourceWriter source, + ImmutableEquatableArray parameters, + string? cachedTypeParameterFieldName) + { + if (parameters.Count == 0) + { + source.Append("global::System.Array.Empty()"); + return; + } - builder.Append(@$"{ct}."); + if (cachedTypeParameterFieldName is not null) + { + source.Append(cachedTypeParameterFieldName); + return; } - builder.Append(@$"{methodModel.DeclaredMethod}("); + source.Append("new global::System.Type[] { "); + AppendParameterTypeList(source, parameters); + source.Append(" }"); + } - var parameters = methodModel.Parameters.AsArray(); - if (parameters.Length > 0) + /// Appends the generated typeof(...) argument list for method parameters. + /// The source writer to append to. + /// The parameter models to emit. + private static void AppendParameterTypeList( + SourceWriter source, + ImmutableEquatableArray parameters) + { + for (var i = 0; i < parameters.Count; i++) { - // Size known up front: use an array rather than a growing List. - var list = new string[parameters.Length]; - for (var i = 0; i < parameters.Length; i++) + if (i > 0) { - var param = parameters[i]; - var annotation = param.Annotation; - list[i] = $@"{param.Type}{(annotation ? '?' : string.Empty)} @{param.MetadataName}"; + source.Append(", "); } - builder.Append(string.Join(", ", list)); + source.Append("typeof("); + source.Append(parameters[i].Type); + source.Append(')'); } - - builder.Append(")"); - - source.WriteLine(); - source.WriteLine(builder.ToString()); - source.Indentation++; - GenerateConstraints(source, methodModel.Constraints, isDerivedExplicitImpl || isExplicitInterface); - source.Indentation--; - source.WriteLine("{"); - source.Indentation++; } /// Emits the closing brace for a method body. @@ -481,68 +836,112 @@ private static void GenerateConstraints( /// True if emitting for an override or explicit implementation. private static void WriteConstraintsForTypeParameter( SourceWriter source, - TypeConstraint typeParameter, + in TypeConstraint typeParameter, bool isOverrideOrExplicitImplementation) { - var parameters = CollectConstraintKeywords(typeParameter, isOverrideOrExplicitImplementation); - if (parameters.Count == 0) + if (!HasConstraintKeywords(typeParameter, isOverrideOrExplicitImplementation)) { return; } - source.WriteLine($"where {typeParameter.TypeName} : {string.Join(", ", parameters)}"); + var wroteConstraint = false; + source.WriteIndentation(); + source.Append("where "); + source.Append(typeParameter.TypeName); + source.Append(" : "); + + var knownConstraints = typeParameter.KnownTypeConstraint; + AppendConstraintKeywordIf(source, "class", knownConstraints.HasFlag(KnownTypeConstraint.Class), ref wroteConstraint); + AppendConstraintKeywordIf( + source, + "unmanaged", + knownConstraints.HasFlag(KnownTypeConstraint.Unmanaged) && !isOverrideOrExplicitImplementation, + ref wroteConstraint); + AppendConstraintKeywordIf(source, "struct", knownConstraints.HasFlag(KnownTypeConstraint.Struct), ref wroteConstraint); + AppendConstraintKeywordIf( + source, + "notnull", + knownConstraints.HasFlag(KnownTypeConstraint.NotNull) && !isOverrideOrExplicitImplementation, + ref wroteConstraint); + + if (!isOverrideOrExplicitImplementation) + { + foreach (var constraint in typeParameter.Constraints) + { + AppendConstraintKeyword(source, constraint, ref wroteConstraint); + } + } + + AppendConstraintKeywordIf( + source, + "new()", + knownConstraints.HasFlag(KnownTypeConstraint.New) && !isOverrideOrExplicitImplementation, + ref wroteConstraint); + source.WriteLine(); } - /// Collects the ordered constraint keywords that apply to a type parameter. + /// Determines whether a type parameter has constraints that should be emitted. /// The type parameter constraint to inspect. /// True if emitting for an override or explicit implementation. - /// The constraint keywords in the order they must be emitted. - private static List CollectConstraintKeywords( - TypeConstraint typeParameter, + /// when at least one constraint should be emitted. + private static bool HasConstraintKeywords( + in TypeConstraint typeParameter, bool isOverrideOrExplicitImplementation) { - // Explicit interface implementations and overrides can only have class or struct constraints - var parameters = new List(); var knownConstraints = typeParameter.KnownTypeConstraint; if (knownConstraints.HasFlag(KnownTypeConstraint.Class)) { - parameters.Add("class"); + return true; } - if ( - knownConstraints.HasFlag(KnownTypeConstraint.Unmanaged) - && !isOverrideOrExplicitImplementation - ) + if (knownConstraints.HasFlag(KnownTypeConstraint.Unmanaged) && !isOverrideOrExplicitImplementation) { - parameters.Add("unmanaged"); + return true; } if (knownConstraints.HasFlag(KnownTypeConstraint.Struct)) { - parameters.Add("struct"); + return true; } - if ( - knownConstraints.HasFlag(KnownTypeConstraint.NotNull) - && !isOverrideOrExplicitImplementation - ) - { - parameters.Add("notnull"); - } + return (knownConstraints.HasFlag(KnownTypeConstraint.NotNull) && !isOverrideOrExplicitImplementation) + || (!isOverrideOrExplicitImplementation && (typeParameter.Constraints.Count > 0 || knownConstraints.HasFlag(KnownTypeConstraint.New))); + } - if (!isOverrideOrExplicitImplementation) + /// Appends a constraint keyword when the condition is true. + /// The source writer to append to. + /// The constraint keyword. + /// Whether the keyword should be emitted. + /// Tracks whether a previous keyword has been emitted. + private static void AppendConstraintKeywordIf( + SourceWriter source, + string keyword, + bool condition, + ref bool wroteConstraint) + { + if (!condition) { - parameters.AddRange(typeParameter.Constraints); + return; } - // new constraint has to be last - if ( - knownConstraints.HasFlag(KnownTypeConstraint.New) && !isOverrideOrExplicitImplementation - ) + AppendConstraintKeyword(source, keyword, ref wroteConstraint); + } + + /// Appends one constraint keyword, including any required separator. + /// The source writer to append to. + /// The constraint keyword. + /// Tracks whether a previous keyword has been emitted. + private static void AppendConstraintKeyword( + SourceWriter source, + string keyword, + ref bool wroteConstraint) + { + if (wroteConstraint) { - parameters.Add("new()"); + source.Append(", "); } - return parameters; + source.Append(keyword); + wroteConstraint = true; } } diff --git a/src/InterfaceStubGenerator.Shared/ITypeSymbolExtensions.cs b/src/InterfaceStubGenerator.Shared/ITypeSymbolExtensions.cs index 3a02dd4ad..7e58b6330 100644 --- a/src/InterfaceStubGenerator.Shared/ITypeSymbolExtensions.cs +++ b/src/InterfaceStubGenerator.Shared/ITypeSymbolExtensions.cs @@ -18,38 +18,45 @@ internal static class ITypeSymbolExtensions /// True if the type inherits from or equals the base type. public bool InheritsFromOrEquals(ITypeSymbol baseType, bool includeInterfaces) { + if (type.InheritsFromOrEquals(baseType)) + { + return true; + } + if (!includeInterfaces) { - return type.InheritsFromOrEquals(baseType); + return false; + } + + var interfaces = type.AllInterfaces; + for (var i = 0; i < interfaces.Length; i++) + { + if (interfaces[i].Equals(baseType, SymbolEqualityComparer.Default)) + { + return true; + } } - return type.GetBaseTypesAndThis() - .Concat(type.AllInterfaces) - .Any(t => t.Equals(baseType, SymbolEqualityComparer.Default)); + return false; } /// Determines whether the type inherits from or equals the base type, ignoring interfaces. /// The base type to look for. /// True if the type inherits from or equals the base type. - public bool InheritsFromOrEquals(ITypeSymbol baseType) => - type.GetBaseTypesAndThis() - .Any(t => t.Equals(baseType, SymbolEqualityComparer.Default)); - } - - /// Extensions for a nullable receiver. - /// The nullable type symbol to operate on. - extension(ITypeSymbol? type) - { - /// Enumerates the type itself followed by each of its base types. - /// The type and its base types. - public IEnumerable GetBaseTypesAndThis() + public bool InheritsFromOrEquals(ITypeSymbol baseType) { var current = type; while (current is not null) { - yield return current; + if (current.Equals(baseType, SymbolEqualityComparer.Default)) + { + return true; + } + current = current.BaseType; } + + return false; } } } diff --git a/src/InterfaceStubGenerator.Shared/ImmutableEquatableArray.cs b/src/InterfaceStubGenerator.Shared/ImmutableEquatableArray.cs index bce1795f3..e440961ef 100644 --- a/src/InterfaceStubGenerator.Shared/ImmutableEquatableArray.cs +++ b/src/InterfaceStubGenerator.Shared/ImmutableEquatableArray.cs @@ -1,17 +1,127 @@ // Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Collections; using System.Diagnostics.CodeAnalysis; namespace Refit.Generator; -/// Helper methods for creating ImmutableEquatableArray instances. -internal static class ImmutableEquatableArray +/// Provides an immutable list implementation which implements sequence equality. +/// The element type. +internal sealed class ImmutableEquatableArray + : IEquatable>, + IReadOnlyList + where T : IEquatable { - /// Gets an empty immutable equatable array. - /// The element type. - /// An empty array instance. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter is intentionally specified explicitly by callers.")] - public static ImmutableEquatableArray Empty() - where T : IEquatable => ImmutableEquatableArray.Empty; + /// The backing array of values. + private readonly T[] _values; + + /// Initializes a new instance of the class. + /// The array to wrap. + public ImmutableEquatableArray(T[] values) => _values = values; + + /// Gets a shared empty array instance. + public static ImmutableEquatableArray Empty { get; } = new([]); + + /// Gets the number of elements in the array. + public int Count => _values.Length; + + /// Gets the element at the specified index. + /// The zero-based index. + /// The element at the given index. + public T this[int index] => _values[index]; + + /// Returns the underlying array. + /// The backing array of values. + public T[] AsArray() => _values; + + /// + public bool Equals(ImmutableEquatableArray? other) + { + if (other is null || other._values.Length != _values.Length) + { + return false; + } + + for (var i = 0; i < _values.Length; i++) + { + if (!_values[i].Equals(other._values[i])) + { + return false; + } + } + + return true; + } + + /// + public override bool Equals(object? obj) => + obj is ImmutableEquatableArray other && Equals(other); + + /// + public override int GetHashCode() + { + var hash = 0; + for (var i = 0; i < _values.Length; i++) + { + hash = Combine(hash, _values[i].GetHashCode()); + } + + return hash; + + static int Combine(int h1, int h2) + { + // RyuJIT optimizes this to use the ROL instruction + // Related GitHub pull request: https://github.com/dotnet/coreclr/pull/1830 + var rol5 = ((uint)h1 << 5) | ((uint)h1 >> 27); + return ((int)rol5 + h1) ^ h2; + } + } + + /// Returns an allocation-free enumerator over the array. + /// An enumerator for the array. + public Enumerator GetEnumerator() => new(_values); + + /// + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_values).GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => _values.GetEnumerator(); + + /// A struct enumerator that iterates the backing array without allocation. + [SuppressMessage("Style", "SST1803:Type can be made readonly", Justification = "Mutable iterator state (_index); cannot be readonly.")] + public record struct Enumerator + { + /// The array being enumerated. + private readonly T[] _values; + + /// The current zero-based position within the array. + private int _index; + + /// Initializes a new instance of the struct. + /// The array to enumerate. + internal Enumerator(T[] values) + { + _values = values; + _index = -1; + } + + /// Gets the element at the current position. + public readonly T Current => _values[_index]; + + /// Advances the enumerator to the next element. + /// if there is another element; otherwise . + public bool MoveNext() + { + var newIndex = _index + 1; + + if ((uint)newIndex >= (uint)_values.Length) + { + return false; + } + + _index = newIndex; + return true; + } + } } diff --git a/src/InterfaceStubGenerator.Shared/ImmutableEquatableArrayExtensions.cs b/src/InterfaceStubGenerator.Shared/ImmutableEquatableArrayExtensions.cs index 2f56a1e94..397ace97e 100644 --- a/src/InterfaceStubGenerator.Shared/ImmutableEquatableArrayExtensions.cs +++ b/src/InterfaceStubGenerator.Shared/ImmutableEquatableArrayExtensions.cs @@ -7,15 +7,17 @@ namespace Refit.Generator; /// Extension methods for creating instances. internal static class ImmutableEquatableArrayExtensions { - /// Extensions for converting a sequence into an . + /// Extensions for converting a list into an . /// The element type. - /// The sequence of values to convert. - extension(IEnumerable? values) + /// The values to convert. + extension(List? values) where T : IEquatable { - /// Creates an immutable equatable array from a sequence of values. - /// An immutable equatable array containing the values. + /// Creates an immutable equatable array from a list. + /// An immutable equatable array containing the list values. public ImmutableEquatableArray ToImmutableEquatableArray() => - values is null ? ImmutableEquatableArray.Empty() : new(values); + values is null + ? ImmutableEquatableArrayFactory.Empty() + : ImmutableEquatableArrayFactory.FromList(values); } } diff --git a/src/InterfaceStubGenerator.Shared/ImmutableEquatableArrayFactory.cs b/src/InterfaceStubGenerator.Shared/ImmutableEquatableArrayFactory.cs new file mode 100644 index 000000000..b79f5bad8 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/ImmutableEquatableArrayFactory.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +using System.Diagnostics.CodeAnalysis; + +namespace Refit.Generator; + +/// Helper methods for creating ImmutableEquatableArray instances. +internal static class ImmutableEquatableArrayFactory +{ + /// Gets an empty immutable equatable array. + /// The element type. + /// An empty array instance. + [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter is intentionally specified explicitly by callers.")] + public static ImmutableEquatableArray Empty() + where T : IEquatable => ImmutableEquatableArray.Empty; + + /// Wraps an array without another copy. + /// The values to wrap. + /// The element type. + /// An immutable equatable array over . + public static ImmutableEquatableArray FromArray(T[] values) + where T : IEquatable => + values.Length == 0 ? Empty() : new(values); + + /// Copies a list into the immutable equatable array backing storage. + /// The values to copy. + /// The element type. + /// An immutable equatable array containing . + public static ImmutableEquatableArray FromList(List values) + where T : IEquatable + { + if (values.Count == 0) + { + return Empty(); + } + + var array = new T[values.Count]; + values.CopyTo(array); + return new(array); + } +} diff --git a/src/InterfaceStubGenerator.Shared/ImmutableEquatableArrayOfT.cs b/src/InterfaceStubGenerator.Shared/ImmutableEquatableArrayOfT.cs deleted file mode 100644 index ace2fb3a9..000000000 --- a/src/InterfaceStubGenerator.Shared/ImmutableEquatableArrayOfT.cs +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. -// ReactiveUI and Contributors licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. -using System.Collections; -using System.Diagnostics.CodeAnalysis; - -namespace Refit.Generator; - -/// Provides an immutable list implementation which implements sequence equality. -/// The element type. -[SuppressMessage( - "StyleCop.CSharp.DocumentationRules", - "SST1649:File name should match first type name", - Justification = "Generic type ImmutableEquatableArray kept in a dedicated file; backtick file names are avoided.")] -internal sealed class ImmutableEquatableArray - : IEquatable>, - IReadOnlyList - where T : IEquatable -{ - /// The backing array of values. - private readonly T[] _values; - - /// Initializes a new instance of the class. - /// The array to wrap. - public ImmutableEquatableArray(T[] values) => _values = values; - - /// Initializes a new instance of the class. - /// The values to copy. - public ImmutableEquatableArray(IEnumerable values) => _values = [.. values]; - - /// Gets a shared empty array instance. - public static ImmutableEquatableArray Empty { get; } = new([]); - - /// Gets the number of elements in the array. - public int Count => _values.Length; - - /// Gets the element at the specified index. - /// The zero-based index. - /// The element at the given index. - public T this[int index] => _values[index]; - - /// Returns the underlying array. - /// The backing array of values. - public T[] AsArray() => _values; - - /// - public bool Equals(ImmutableEquatableArray? other) => - other is not null && ((ReadOnlySpan)_values).SequenceEqual(other._values); - - /// - public override bool Equals(object? obj) => - obj is ImmutableEquatableArray other && Equals(other); - - /// - public override int GetHashCode() - { - var hash = 0; - foreach (var value in _values) - { - hash = Combine(hash, value.GetHashCode()); - } - - static int Combine(int h1, int h2) - { - // RyuJIT optimizes this to use the ROL instruction - // Related GitHub pull request: https://github.com/dotnet/coreclr/pull/1830 - var rol5 = ((uint)h1 << 5) | ((uint)h1 >> 27); - return ((int)rol5 + h1) ^ h2; - } - - return hash; - } - - /// Returns an allocation-free enumerator over the array. - /// An enumerator for the array. - public Enumerator GetEnumerator() => new(_values); - - /// - IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_values).GetEnumerator(); - - /// - IEnumerator IEnumerable.GetEnumerator() => _values.GetEnumerator(); - - /// A struct enumerator that iterates the backing array without allocation. - [SuppressMessage("Style", "SST1803:Type can be made readonly", Justification = "Mutable iterator state (_index); cannot be readonly.")] - public record struct Enumerator - { - /// The array being enumerated. - private readonly T[] _values; - - /// The current zero-based position within the array. - private int _index; - - /// Initializes a new instance of the struct. - /// The array to enumerate. - internal Enumerator(T[] values) - { - _values = values; - _index = -1; - } - - /// Gets the element at the current position. - public readonly T Current => _values[_index]; - - /// Advances the enumerator to the next element. - /// if there is another element; otherwise . - public bool MoveNext() - { - var newIndex = _index + 1; - - if ((uint)newIndex >= (uint)_values.Length) - { - return false; - } - - _index = newIndex; - return true; - } - } -} diff --git a/src/InterfaceStubGenerator.Shared/IncrementalValuesProviderExtensions.cs b/src/InterfaceStubGenerator.Shared/IncrementalValuesProviderExtensions.cs index bc69ed17e..e8eeb075b 100644 --- a/src/InterfaceStubGenerator.Shared/IncrementalValuesProviderExtensions.cs +++ b/src/InterfaceStubGenerator.Shared/IncrementalValuesProviderExtensions.cs @@ -13,18 +13,10 @@ internal static class IncrementalValuesProviderExtensions /// The generator initialization context to register outputs on. extension(IncrementalGeneratorInitializationContext context) { - /// Registers an output node into an to output a diagnostic. - /// The input sequence of diagnostics. - public void ReportDiagnostics( - IncrementalValuesProvider diagnostic) => - context.RegisterSourceOutput( - diagnostic, - static (context, diagnostic) => context.ReportDiagnostic(diagnostic)); - /// Registers an output node into an to output diagnostics. /// The input sequence of diagnostics. public void ReportDiagnostics( - IncrementalValueProvider> diagnostics) => + in IncrementalValueProvider> diagnostics) => context.RegisterSourceOutput( diagnostics, static (context, diagnostics) => @@ -38,7 +30,7 @@ public void ReportDiagnostics( /// Registers an implementation source output for the provided mappers. /// The interfaces stubs. public void EmitSource( - IncrementalValuesProvider model) => + in IncrementalValuesProvider model) => context.RegisterImplementationSourceOutput( model, static (spc, model) => diff --git a/src/InterfaceStubGenerator.Shared/InterfaceStubGenerator.Shared.projitems b/src/InterfaceStubGenerator.Shared/InterfaceStubGenerator.Shared.projitems deleted file mode 100644 index 48ebae94e..000000000 --- a/src/InterfaceStubGenerator.Shared/InterfaceStubGenerator.Shared.projitems +++ /dev/null @@ -1,38 +0,0 @@ - - - - $(MSBuildAllProjects);$(MSBuildThisFileFullPath) - true - b591423d-f92d-4e00-b0eb-615c9853506c - - - InterfaceStubGenerator.Shared - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/InterfaceStubGenerator.Shared/InterfaceStubGenerator.Shared.shproj b/src/InterfaceStubGenerator.Shared/InterfaceStubGenerator.Shared.shproj deleted file mode 100644 index 0026180d3..000000000 --- a/src/InterfaceStubGenerator.Shared/InterfaceStubGenerator.Shared.shproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - b591423d-f92d-4e00-b0eb-615c9853506c - 14.0 - - - - - - - - diff --git a/src/InterfaceStubGenerator.Shared/InterfaceStubGenerator.cs b/src/InterfaceStubGenerator.Shared/InterfaceStubGenerator.cs deleted file mode 100644 index 3c61a2dcd..000000000 --- a/src/InterfaceStubGenerator.Shared/InterfaceStubGenerator.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. -// ReactiveUI and Contributors licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace Refit.Generator; - -/// An incremental source generator that produces Refit interface stub implementations. -[Generator] -[System.Diagnostics.CodeAnalysis.SuppressMessage( - "Documentation", - "SST1649:The file name should match the first type name", - Justification = "File name retained for compatibility with the shared project (.projitems) and public docs.")] -public class InterfaceStubGeneratorV2 : IIncrementalGenerator -{ - /// - public void Initialize(IncrementalGeneratorInitializationContext context) - { - var candidateMethodsProvider = context.SyntaxProvider.CreateSyntaxProvider( - (syntax, _) => - syntax - is MethodDeclarationSyntax - { - Parent: InterfaceDeclarationSyntax, - AttributeLists.Count: > 0 - }, - (context, _) => (MethodDeclarationSyntax)context.Node); - - var candidateInterfacesProvider = context.SyntaxProvider.CreateSyntaxProvider( - (syntax, _) => - syntax is InterfaceDeclarationSyntax { BaseList: not null }, - (context, _) => (InterfaceDeclarationSyntax)context.Node); - - var refitInternalNamespace = - context.AnalyzerConfigOptionsProvider.Select((analyzerConfigOptionsProvider, _) => - analyzerConfigOptionsProvider.GlobalOptions.TryGetValue( - "build_property.RefitInternalNamespace", - out var refitInternalNamespace) - ? refitInternalNamespace - : null); - - var inputs = candidateMethodsProvider - .Collect() - .Combine(candidateInterfacesProvider.Collect()) - .Select((combined, _) => - (candidateMethods: combined.Left, candidateInterfaces: combined.Right)) - .Combine(refitInternalNamespace) - .Combine(context.CompilationProvider) - .Select((combined, _) => - ( - combined.Left.Left.candidateMethods, - combined.Left.Left.candidateInterfaces, - refitInternalNamespace: combined.Left.Right, - compilation: combined.Right - )); - - var parseStep = inputs.Select((collectedValues, cancellationToken) => - { - return Parser.GenerateInterfaceStubs( - (CSharpCompilation)collectedValues.compilation, - collectedValues.refitInternalNamespace, - collectedValues.candidateMethods, - collectedValues.candidateInterfaces, - cancellationToken); - }); - - var diagnostics = parseStep - .Select(static (x, _) => x.diagnostics.ToImmutableEquatableArray()) - .WithTrackingName(RefitGeneratorStepName.ReportDiagnostics); - context.ReportDiagnostics(diagnostics); - - var contextModel = parseStep.Select(static (x, _) => x.contextGenerationSpec); - var interfaceModels = contextModel - .SelectMany(static (x, _) => x.Interfaces) - .WithTrackingName(RefitGeneratorStepName.BuildRefit); - context.EmitSource(interfaceModels); - - context.RegisterImplementationSourceOutput( - contextModel, - static (spc, model) => Emitter.EmitSharedCode(model, (name, code) => spc.AddSource(name, code))); - } -} diff --git a/src/InterfaceStubGenerator.Shared/InterfaceStubGeneratorV2.cs b/src/InterfaceStubGenerator.Shared/InterfaceStubGeneratorV2.cs new file mode 100644 index 000000000..2054f88b0 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/InterfaceStubGeneratorV2.cs @@ -0,0 +1,220 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Refit.Generator; + +/// An incremental source generator that produces Refit interface stub implementations. +[Generator] +public class InterfaceStubGeneratorV2 : IIncrementalGenerator +{ + /// The MSBuild property prefix used by analyzer config options. + private const string BuildPropertyPrefix = "build_property."; + + /// The option that disables all Refit source generation. + private const string DisableRefitSourceGeneratorOption = "DisableRefitSourceGenerator"; + + /// The option that enables direct generated request construction. + private const string RefitGeneratedRequestBuildingOption = "RefitGeneratedRequestBuilding"; + + /// The option that overrides the generated internal namespace prefix. + private const string RefitInternalNamespaceOption = "RefitInternalNamespace"; + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidateMethodsProvider = context.SyntaxProvider.CreateSyntaxProvider( + (syntax, _) => + syntax + is MethodDeclarationSyntax + { + Parent: InterfaceDeclarationSyntax, + AttributeLists.Count: > 0 + }, + (generatorContext, _) => (MethodDeclarationSyntax)generatorContext.Node); + + var candidateInterfacesProvider = context.SyntaxProvider.CreateSyntaxProvider( + (syntax, _) => + syntax is InterfaceDeclarationSyntax { BaseList: not null }, + (generatorContext, _) => (InterfaceDeclarationSyntax)generatorContext.Node); + + var generatorOptions = + context.AnalyzerConfigOptionsProvider.Select( + static (analyzerConfigOptionsProvider, _) => + CreateGeneratorOptions(analyzerConfigOptionsProvider.GlobalOptions)); + + var candidateSyntax = candidateMethodsProvider + .Collect() + .Combine(candidateInterfacesProvider.Collect()) + .Select(static (combined, _) => CreateCandidateSyntax(combined)); + + var syntaxAndOptions = candidateSyntax + .Combine(generatorOptions) + .Select(static (combined, _) => CreateSyntaxAndOptions(combined)); + + var inputs = syntaxAndOptions + .Combine(context.CompilationProvider) + .Select(static (combined, _) => CreateGeneratorInputs(combined)); + + var parseStep = inputs.Select( + static (collectedValues, cancellationToken) => + collectedValues.DisableSourceGenerator + ? CreateDisabledGenerationResult() + : Parser.GenerateInterfaceStubs( + (CSharpCompilation)collectedValues.Compilation, + collectedValues.RefitInternalNamespace, + collectedValues.GeneratedRequestBuilding, + collectedValues.CandidateMethods, + collectedValues.CandidateInterfaces, + cancellationToken)); + + var diagnostics = parseStep + .Select(static (x, _) => x.diagnostics.ToImmutableEquatableArray()) + .WithTrackingName(RefitGeneratorStepName.ReportDiagnostics); + context.ReportDiagnostics(diagnostics); + + var contextModel = parseStep.Select(static (x, _) => x.contextGenerationSpec); + var interfaceModels = contextModel + .SelectMany(static (x, _) => x.Interfaces) + .WithTrackingName(RefitGeneratorStepName.BuildRefit); + context.EmitSource(interfaceModels); + + context.RegisterImplementationSourceOutput( + contextModel, + static (spc, model) => Emitter.EmitSharedCode(model, spc.AddSource)); + } + + /// Reads a global analyzer-config option by bare name or MSBuild build-property name. + /// The analyzer-config options. + /// The option name without the build-property prefix. + /// The option value when found. + /// when an option value was found. + internal static bool TryGetGlobalOption( + AnalyzerConfigOptions options, + string name, + out string? value) + { + if (options.TryGetValue(BuildPropertyPrefix + name, out var buildPropertyValue)) + { + value = buildPropertyValue; + return true; + } + + if (options.TryGetValue(name, out var analyzerConfigValue)) + { + value = analyzerConfigValue; + return true; + } + + value = null; + return false; + } + + /// Creates the collected candidate syntax input. + /// The combined method and interface candidate collections. + /// The named candidate syntax input. + private static CandidateSyntax CreateCandidateSyntax( + (ImmutableArray Methods, ImmutableArray Interfaces) combined) => + new(combined.Methods, combined.Interfaces); + + /// Creates the generator options input. + /// The global analyzer-config options. + /// The named generator options input. + private static GeneratorOptions CreateGeneratorOptions(AnalyzerConfigOptions options) + { + TryGetGlobalOption(options, RefitInternalNamespaceOption, out var refitInternalNamespace); + + return new( + refitInternalNamespace, + GetBooleanOption(options, RefitGeneratedRequestBuildingOption, defaultValue: true), + GetBooleanOption(options, DisableRefitSourceGeneratorOption, defaultValue: false)); + } + + /// Creates the combined syntax and options input. + /// The combined candidate syntax and generator options. + /// The named syntax-and-options input. + private static SyntaxAndOptions CreateSyntaxAndOptions( + (CandidateSyntax CandidateSyntax, GeneratorOptions Options) combined) => + new(combined.CandidateSyntax, combined.Options); + + /// Creates the final parser input. + /// The combined syntax/options input and compilation. + /// The named parser input. + private static GeneratorInputs CreateGeneratorInputs( + (SyntaxAndOptions SyntaxAndOptions, Compilation Compilation) combined) => + new( + combined.SyntaxAndOptions.CandidateSyntax.CandidateMethods, + combined.SyntaxAndOptions.CandidateSyntax.CandidateInterfaces, + combined.SyntaxAndOptions.Options.RefitInternalNamespace, + combined.SyntaxAndOptions.Options.GeneratedRequestBuilding, + combined.SyntaxAndOptions.Options.DisableSourceGenerator, + combined.Compilation); + + /// Creates an empty result when generation is explicitly disabled. + /// The disabled generation result. + private static (List diagnostics, ContextGenerationModel contextGenerationSpec) + CreateDisabledGenerationResult() => + new( + [], + new( + string.Empty, + string.Empty, + false, + ImmutableEquatableArrayFactory.Empty())); + + /// Reads a boolean analyzer-config option. + /// The analyzer-config options. + /// The option name without the build-property prefix. + /// The value to use when the option is not present or cannot be parsed. + /// The parsed option value. + private static bool GetBooleanOption( + AnalyzerConfigOptions options, + string name, + bool defaultValue) => + TryGetGlobalOption(options, name, out var value) && bool.TryParse(value, out var parsed) + ? parsed + : defaultValue; + + /// The collected syntax candidates for one generator pass. + /// The candidate method declarations. + /// The candidate interface declarations. + private readonly record struct CandidateSyntax( + ImmutableArray CandidateMethods, + ImmutableArray CandidateInterfaces); + + /// The generator options visible through analyzer config. + /// The optional Refit internal namespace prefix. + /// Whether inline request construction is enabled. + /// Whether source generation is disabled. + private readonly record struct GeneratorOptions( + string? RefitInternalNamespace, + bool GeneratedRequestBuilding, + bool DisableSourceGenerator); + + /// The collected syntax candidates plus generator options. + /// The candidate syntax input. + /// The generator options input. + private readonly record struct SyntaxAndOptions( + CandidateSyntax CandidateSyntax, + GeneratorOptions Options); + + /// The full input consumed by the parser. + /// The candidate method declarations. + /// The candidate interface declarations. + /// The optional Refit internal namespace prefix. + /// Whether inline request construction is enabled. + /// Whether source generation is disabled. + /// The current compilation. + private readonly record struct GeneratorInputs( + ImmutableArray CandidateMethods, + ImmutableArray CandidateInterfaces, + string? RefitInternalNamespace, + bool GeneratedRequestBuilding, + bool DisableSourceGenerator, + Compilation Compilation); +} diff --git a/src/InterfaceStubGenerator.Shared/IsExternalInit.cs b/src/InterfaceStubGenerator.Shared/IsExternalInit.cs index 539a3296a..04c68be2e 100644 --- a/src/InterfaceStubGenerator.Shared/IsExternalInit.cs +++ b/src/InterfaceStubGenerator.Shared/IsExternalInit.cs @@ -14,13 +14,14 @@ #if REFIT_REQUIRES_ISEXTERNALINIT using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; // ReSharper disable once CheckNamespace namespace System.Runtime.CompilerServices; /// Reserved to be used by the compiler for tracking metadata. This class should not be used by developers in source code. [EditorBrowsable(EditorBrowsableState.Never)] -[System.Diagnostics.CodeAnalysis.SuppressMessage( +[SuppressMessage( "Maintainability", "SST1436:Empty types should not be declared", Justification = "Compiler-required init-only marker type; intentionally empty.")] diff --git a/src/InterfaceStubGenerator.Shared/Models/BodyBufferMode.cs b/src/InterfaceStubGenerator.Shared/Models/BodyBufferMode.cs new file mode 100644 index 000000000..5bb2d31c6 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/BodyBufferMode.cs @@ -0,0 +1,20 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +namespace Refit.Generator; + +/// Describes how a generated body parameter chooses request-content buffering. +internal enum BodyBufferMode +{ + /// The method has no request body. + None, + + /// The generated method should use RefitSettings.Buffered at runtime. + Settings, + + /// The body is explicitly buffered. + Buffered, + + /// The body is explicitly streamed. + Streaming +} diff --git a/src/InterfaceStubGenerator.Shared/Models/ContextGenerationModel.cs b/src/InterfaceStubGenerator.Shared/Models/ContextGenerationModel.cs index 71a27323f..11081f86f 100644 --- a/src/InterfaceStubGenerator.Shared/Models/ContextGenerationModel.cs +++ b/src/InterfaceStubGenerator.Shared/Models/ContextGenerationModel.cs @@ -6,8 +6,10 @@ namespace Refit.Generator; /// Model describing the shared generation context for a set of Refit interfaces. /// The namespace used for Refit internal generated types. /// The display name of the preserve attribute. +/// Whether generated request construction is enabled. /// The interfaces to generate implementations for. internal sealed record ContextGenerationModel( string RefitInternalNamespace, string PreserveAttributeDisplayName, + bool GeneratedRequestBuilding, ImmutableEquatableArray Interfaces); diff --git a/src/InterfaceStubGenerator.Shared/Models/HeaderModel.cs b/src/InterfaceStubGenerator.Shared/Models/HeaderModel.cs new file mode 100644 index 000000000..481eababf --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/HeaderModel.cs @@ -0,0 +1,11 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +namespace Refit.Generator; + +/// Represents one parsed static header. +/// The header name. +/// The header value, or null when the header removes an earlier value. +internal sealed record HeaderModel( + string Name, + string? Value); diff --git a/src/InterfaceStubGenerator.Shared/Models/InterfaceGenerationContext.cs b/src/InterfaceStubGenerator.Shared/Models/InterfaceGenerationContext.cs index 1b3fa6c72..4b2c371d6 100644 --- a/src/InterfaceStubGenerator.Shared/Models/InterfaceGenerationContext.cs +++ b/src/InterfaceStubGenerator.Shared/Models/InterfaceGenerationContext.cs @@ -10,10 +10,12 @@ namespace Refit.Generator; /// The display name of the generated preserve attribute. /// The IDisposable symbol, if available. /// The Refit HTTP method attribute symbol. +/// Whether generated request construction is enabled. /// Whether the compilation supports nullable reference types. internal readonly record struct InterfaceGenerationContext( List Diagnostics, string PreserveAttributeDisplayName, ISymbol? DisposableInterfaceSymbol, INamedTypeSymbol HttpMethodBaseAttributeSymbol, + bool GeneratedRequestBuilding, bool SupportsNullable); diff --git a/src/InterfaceStubGenerator.Shared/Models/InterfaceModel.cs b/src/InterfaceStubGenerator.Shared/Models/InterfaceModel.cs index 14f2fdd52..f873d8d68 100644 --- a/src/InterfaceStubGenerator.Shared/Models/InterfaceModel.cs +++ b/src/InterfaceStubGenerator.Shared/Models/InterfaceModel.cs @@ -11,8 +11,10 @@ namespace Refit.Generator; /// The generated class declaration text. /// The display name of the interface. /// The suffix appended to the generated class name. +/// Whether generated request construction is enabled for this interface. /// The generic type constraints of the interface. /// The names of the interface members. +/// The interface properties implemented by the generated stub. /// The non-Refit methods declared on the interface. /// The Refit methods declared on the interface. /// The Refit methods inherited from base interfaces. @@ -26,8 +28,10 @@ internal sealed record InterfaceModel( string ClassDeclaration, string InterfaceDisplayName, string ClassSuffix, + bool GeneratedRequestBuilding, ImmutableEquatableArray Constraints, ImmutableEquatableArray MemberNames, + ImmutableEquatableArray Properties, ImmutableEquatableArray NonRefitMethods, ImmutableEquatableArray RefitMethods, ImmutableEquatableArray DerivedRefitMethods, diff --git a/src/InterfaceStubGenerator.Shared/Models/InterfacePropertyModel.cs b/src/InterfaceStubGenerator.Shared/Models/InterfacePropertyModel.cs new file mode 100644 index 000000000..6590e4269 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/InterfacePropertyModel.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +namespace Refit.Generator; + +/// Model describing an interface property implemented by the generated stub. +/// The property name. +/// The fully qualified property type. +/// A value indicating whether the property is nullable-annotated. +/// The fully qualified interface that declares the property. +/// The request-property key, or empty when the property is not request-bound. +/// A value indicating whether the property has a getter. +/// A value indicating whether the property has a setter. +/// A value indicating whether an existing generated member satisfies this interface property. +/// A value indicating whether the property is an explicit interface implementation. +internal sealed record InterfacePropertyModel( + string Name, + string Type, + bool Annotation, + string ContainingType, + string RequestPropertyKey, + bool HasGetter, + bool HasSetter, + bool IsSatisfiedByGeneratedMember, + bool IsExplicitInterface); diff --git a/src/InterfaceStubGenerator.Shared/Models/MethodModel.cs b/src/InterfaceStubGenerator.Shared/Models/MethodModel.cs index 5509b999f..f6b1f048e 100644 --- a/src/InterfaceStubGenerator.Shared/Models/MethodModel.cs +++ b/src/InterfaceStubGenerator.Shared/Models/MethodModel.cs @@ -9,6 +9,7 @@ namespace Refit.Generator; /// The fully qualified type that declares the method. /// The declared method signature. /// Metadata describing the shape of the return type. +/// The parsed request metadata for Refit methods. /// The method parameters. /// The generic type constraints for the method. /// A value indicating whether the method is an explicit interface implementation. @@ -18,6 +19,7 @@ internal sealed record MethodModel( string ContainingType, string DeclaredMethod, ReturnTypeInfo ReturnTypeMetadata, + RequestModel Request, ImmutableEquatableArray Parameters, ImmutableEquatableArray Constraints, bool IsExplicitInterface); diff --git a/src/InterfaceStubGenerator.Shared/Models/RequestModel.cs b/src/InterfaceStubGenerator.Shared/Models/RequestModel.cs new file mode 100644 index 000000000..ccc100e6c --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/RequestModel.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +namespace Refit.Generator; + +/// Parsed request metadata for one generated Refit method. +/// The HTTP method name. +/// The relative URL path template. +/// The method result type, unwrapped from Task or ValueTask when applicable. +/// The response body type used by API response wrappers. +/// Whether the method result is an API response wrapper. +/// Whether the response should be disposed by the shared runner. +/// Whether this method is eligible for generated request construction. +/// The static headers parsed from inherited interfaces, the declaring interface, and the method. +/// The parsed request parameter bindings. +internal sealed record RequestModel( + string HttpMethod, + string Path, + string ResultType, + string DeserializedResultType, + bool IsApiResponse, + bool ShouldDisposeResponse, + bool CanGenerateInline, + ImmutableEquatableArray StaticHeaders, + ImmutableEquatableArray Parameters) +{ + /// Gets an empty model used for non-Refit method placeholders. + public static RequestModel Empty { get; } = new( + string.Empty, + string.Empty, + string.Empty, + string.Empty, + false, + true, + false, + ImmutableEquatableArray.Empty, + ImmutableEquatableArray.Empty); +} diff --git a/src/InterfaceStubGenerator.Shared/Models/RequestParameterKind.cs b/src/InterfaceStubGenerator.Shared/Models/RequestParameterKind.cs new file mode 100644 index 000000000..91965b396 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/RequestParameterKind.cs @@ -0,0 +1,26 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +namespace Refit.Generator; + +/// Classifies how a method parameter participates in generated request construction. +internal enum RequestParameterKind +{ + /// The parameter is not yet supported by generated request construction. + Unsupported, + + /// The parameter supplies the request body. + Body, + + /// The parameter supplies one dynamic request header. + Header, + + /// The parameter supplies a collection of dynamic request headers. + HeaderCollection, + + /// The parameter supplies one request property/option value. + Property, + + /// The parameter supplies the request cancellation token. + CancellationToken +} diff --git a/src/InterfaceStubGenerator.Shared/Models/RequestParameterModel.cs b/src/InterfaceStubGenerator.Shared/Models/RequestParameterModel.cs new file mode 100644 index 000000000..135384487 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/RequestParameterModel.cs @@ -0,0 +1,23 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +namespace Refit.Generator; + +/// Parsed request-binding metadata for one method parameter. +/// The parameter metadata name. +/// The fully-qualified parameter type. +/// The generated request binding kind. +/// Whether generated code must null-check the parameter before dereferencing. +/// The request header name, when this is a header parameter. +/// The request property key, when this is a property parameter. +/// The Refit body serialization method name, when this is a body parameter. +/// The body buffering mode, when this is a body parameter. +internal sealed record RequestParameterModel( + string Name, + string Type, + RequestParameterKind Kind, + bool CanBeNull, + string HeaderName, + string PropertyKey, + string BodySerializationMethod, + BodyBufferMode BodyBufferMode); diff --git a/src/InterfaceStubGenerator.Shared/Models/WellKnownTypes.cs b/src/InterfaceStubGenerator.Shared/Models/WellKnownTypes.cs index d8271e233..2c0508e33 100644 --- a/src/InterfaceStubGenerator.Shared/Models/WellKnownTypes.cs +++ b/src/InterfaceStubGenerator.Shared/Models/WellKnownTypes.cs @@ -1,7 +1,6 @@ // Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; using Microsoft.CodeAnalysis; @@ -14,26 +13,13 @@ public class WellKnownTypes(Compilation compilation) /// Caches resolved type symbols by their full metadata name. private readonly Dictionary _cachedTypes = []; - /// Gets the named type symbol for the given type. - /// The type to resolve. - /// The resolved named type symbol. - [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", - Justification = "Type parameter intentionally specified explicitly by callers.")] - public INamedTypeSymbol Get() => Get(typeof(T)); - /// Gets the named type symbol for the specified type. /// The type. /// The resolved named type symbol. /// Could not get name of type " + type public INamedTypeSymbol Get(Type type) { - if (type is null) - { - throw new ArgumentNullException(nameof(type)); - } - + ArgumentExceptionHelper.ThrowIfNull(type); return Get(type.FullName ?? throw new InvalidOperationException("Could not get name of type " + type)); } diff --git a/src/InterfaceStubGenerator.Shared/Parser.Helpers.cs b/src/InterfaceStubGenerator.Shared/Parser.Helpers.cs new file mode 100644 index 000000000..47af77a02 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Parser.Helpers.cs @@ -0,0 +1,94 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +using System.Text; +using Microsoft.CodeAnalysis; + +namespace Refit.Generator; + +/// Internal parser helpers that are directly covered by focused tests. +internal static partial class Parser +{ + /// Builds the unqualified declared method name, including any generic type parameters. + /// The method symbol. + /// The declared method name without its interface qualifier. + internal static string BuildDeclaredBaseName(IMethodSymbol methodSymbol) + { + // Keep the declared method name unqualified. + var declaredBaseName = methodSymbol.Name; + var lastDot = declaredBaseName.LastIndexOf('.'); + if (lastDot >= 0) + { + declaredBaseName = declaredBaseName[(lastDot + 1)..]; + } + + if (methodSymbol.TypeParameters.Length == 0) + { + return declaredBaseName; + } + + var typeParameters = methodSymbol.TypeParameters; + var estimatedCapacity = declaredBaseName.Length + 2 + (typeParameters.Length * 32); + var builder = new StringBuilder(estimatedCapacity) + .Append(declaredBaseName) + .Append('<'); + + for (var i = 0; i < typeParameters.Length; i++) + { + if (i > 0) + { + builder.Append(", "); + } + + builder.Append(typeParameters[i].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)); + } + + builder.Append('>'); + return builder.ToString(); + } + + /// Determines whether a method is decorated with a Refit HTTP method attribute. + /// The method symbol to inspect. + /// The Refit HTTP method attribute symbol. + /// if the method is a Refit method; otherwise, . + internal static bool IsRefitMethod(IMethodSymbol? methodSymbol, INamedTypeSymbol httpMethodAttribute) + { + if (methodSymbol is null) + { + return false; + } + + // Avoid LINQ here: this is called for every candidate method and every inherited member. + foreach (var attributeData in methodSymbol.GetAttributes()) + { + if (attributeData.AttributeClass?.InheritsFromOrEquals(httpMethodAttribute) == true) + { + return true; + } + } + + return false; + } + + /// Determines whether any base interface declares a Refit method. + /// The interface symbol to inspect. + /// The Refit HTTP method attribute symbol. + /// if a base interface declares a Refit method; otherwise, . + internal static bool HasDerivedRefitMethods( + INamedTypeSymbol interfaceSymbol, + INamedTypeSymbol httpMethodAttribute) + { + foreach (var baseInterface in interfaceSymbol.AllInterfaces) + { + foreach (var member in baseInterface.GetMembers()) + { + if (member is IMethodSymbol method && IsRefitMethod(method, httpMethodAttribute)) + { + return true; + } + } + } + + return false; + } +} diff --git a/src/InterfaceStubGenerator.Shared/Parser.Request.Helpers.cs b/src/InterfaceStubGenerator.Shared/Parser.Request.Helpers.cs new file mode 100644 index 000000000..037bd2fd0 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Parser.Request.Helpers.cs @@ -0,0 +1,204 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +using Microsoft.CodeAnalysis; + +namespace Refit.Generator; + +/// Internal request parser helpers that are directly covered by focused tests. +internal static partial class Parser +{ + /// Finds the HTTP method attribute on a Refit method. + /// The method to inspect. + /// The Refit HTTP method base attribute symbol. + /// The matching attribute, if any. + internal static AttributeData? FindHttpMethodAttribute( + IMethodSymbol methodSymbol, + INamedTypeSymbol httpMethodAttribute) + { + foreach (var attributeData in methodSymbol.GetAttributes()) + { + if (attributeData.AttributeClass?.InheritsFromOrEquals(httpMethodAttribute) == true) + { + return attributeData; + } + } + + return null; + } + + /// Determines whether the initial inline emitter can use the path as a literal URI. + /// The path to inspect. + /// when the path is supported. + internal static bool IsConstantPathSupported(string path) => + (path.Length == 0 || path[0] == '/') + && path.IndexOf('{') < 0 + && path.IndexOf('}') < 0 + && path.IndexOf('\r') < 0 + && path.IndexOf('\n') < 0; + + /// Normalizes constant inline paths to match the reflection request builder URI cleanup. + /// The source path from the HTTP method attribute. + /// The normalized path used by generated request construction. + internal static string NormalizeConstantPathForInline(string path) + { + var fragmentIndex = path.IndexOf('#'); + if (fragmentIndex >= 0) + { + path = path[..fragmentIndex]; + } + + var queryIndex = path.IndexOf('?'); + if (queryIndex < 0) + { + return path; + } + + var pathOnly = path[..queryIndex]; + if (queryIndex == path.Length - 1) + { + return pathOnly; + } + + var queryStart = queryIndex + 1; + var partStart = queryStart; + char[]? queryBuffer = null; + var queryLength = 0; + + for (var i = queryStart; i <= path.Length; i++) + { + if (i < path.Length && path[i] != '&') + { + continue; + } + + var partLength = i - partStart; + AppendNonEmptyQueryPart(path, queryStart, partStart, partLength, ref queryBuffer, ref queryLength); + partStart = i + 1; + } + + return queryBuffer is null + ? pathOnly + : $"{pathOnly}?{new string(queryBuffer, 0, queryLength)}"; + } + + /// Determines whether a string slice is empty or all whitespace. + /// The string containing the slice. + /// The slice start index. + /// The slice length. + /// if the slice is empty or all whitespace. + internal static bool IsWhiteSpace(string value, int start, int length) + { + if (length <= 0) + { + return true; + } + + var end = start + length; + for (var i = start; i < end; i++) + { + if (!char.IsWhiteSpace(value[i])) + { + return false; + } + } + + return true; + } + + /// Adds one static header to the final header list. + /// The mutable header list. + /// The raw header declaration. + internal static void AddStaticHeader(List headers, string header) + { + if (string.IsNullOrWhiteSpace(header)) + { + return; + } + + var separator = header.IndexOf(':'); + var name = separator >= 0 + ? header[..separator].Trim() + : header.Trim(); + var value = separator >= 0 + ? header[(separator + 1)..].Trim() + : null; + + for (var i = 0; i < headers.Count; i++) + { + if (!string.Equals(headers[i].Name, name, StringComparison.Ordinal)) + { + continue; + } + + headers[i] = new(name, value); + return; + } + + headers.Add(new(name, value)); + } + + /// Tries to parse a body buffered constructor argument. + /// The constructor argument. + /// Receives the buffered value. + /// when the argument is a boolean buffered argument. + internal static bool TryGetBodyBufferedValue(in TypedConstant argument, out bool buffered) + { + if (argument is + { + Type.SpecialType: SpecialType.System_Boolean, + Value: bool boolValue + }) + { + buffered = boolValue; + return true; + } + + buffered = false; + return false; + } + + /// Gets the Refit body serialization enum member name for an underlying value. + /// The enum value. + /// The enum member name. + internal static string GetBodySerializationMethodName(int value) => + value switch + { + 0 => "Default", + 1 => "Json", + BodySerializationUrlEncoded => "UrlEncoded", + BodySerializationSerialized => "Serialized", + _ => string.Empty + }; + + /// Determines whether all body bindings are supported by the initial inline emitter. + /// The parsed request parameter models. + /// when every body binding is supported. + internal static bool IsSupportedInlineBody(ImmutableEquatableArray parameters) + { + foreach (var parameter in parameters) + { + if (parameter.Kind != RequestParameterKind.Body) + { + continue; + } + + if (parameter.BodySerializationMethod.Length == 0 + || parameter.BodySerializationMethod == "UrlEncoded") + { + return false; + } + } + + return true; + } + + /// Determines whether the shared runner should dispose the response. + /// The deserialized result type. + /// when the runner owns response disposal. + internal static bool ShouldDisposeResponse(string deserializedResultType) => + deserializedResultType is not + "global::System.Net.Http.HttpResponseMessage" and not + "global::System.Net.Http.HttpContent" and not + "global::System.IO.Stream"; +} diff --git a/src/InterfaceStubGenerator.Shared/Parser.Request.cs b/src/InterfaceStubGenerator.Shared/Parser.Request.cs new file mode 100644 index 000000000..cd0304cbd --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Parser.Request.cs @@ -0,0 +1,640 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis; + +namespace Refit.Generator; + +/// Parses candidate interfaces and methods into the models used to generate Refit stubs. +/// Request parsing helpers for the Refit source generator. +internal static partial class Parser +{ + /// Parses the request metadata needed by generated request construction. + /// The Refit method symbol. + /// The classified return type shape. + /// The shared generation context. + /// The parsed request metadata. + private static RequestModel ParseRequest( + IMethodSymbol methodSymbol, + ReturnTypeInfo returnTypeInfo, + in InterfaceGenerationContext context) + { + if (!context.GeneratedRequestBuilding) + { + return RequestModel.Empty; + } + + var httpMethodAttribute = FindHttpMethodAttribute( + methodSymbol, + context.HttpMethodBaseAttributeSymbol)!; + + var httpMethod = GetHttpMethodName(httpMethodAttribute.AttributeClass); + var path = GetHttpPath(httpMethodAttribute); + var returnTypes = GetRequestReturnTypes(methodSymbol); + var parameters = ParseRequestParameters(methodSymbol.Parameters, out var parameterEligibility); + var staticHeaders = ParseStaticHeaders(methodSymbol); + + var canGenerateInline = + parameterEligibility + && returnTypeInfo is ReturnTypeInfo.AsyncVoid or ReturnTypeInfo.AsyncResult + && methodSymbol.TypeParameters.Length == 0 + && httpMethod.Length > 0 + && IsConstantPathSupported(path) + && IsSupportedInlineBody(parameters) + && !HasUnsupportedInlineRequestMetadata(methodSymbol); + + return new( + httpMethod, + NormalizeConstantPathForInline(path), + returnTypes.ResultType, + returnTypes.DeserializedResultType, + returnTypes.IsApiResponse, + returnTypes.DisposeResponse, + canGenerateInline, + staticHeaders, + parameters); + } + + /// Gets the HTTP method name represented by a Refit method attribute. + /// The attribute type. + /// The HTTP method name, or an empty string for unsupported custom attributes. + [ExcludeFromCodeCoverage] + private static string GetHttpMethodName(INamedTypeSymbol? attributeClass) => + attributeClass?.MetadataName switch + { + "DeleteAttribute" => "DELETE", + "GetAttribute" => "GET", + "HeadAttribute" => "HEAD", + "OptionsAttribute" => "OPTIONS", + "PatchAttribute" => "PATCH", + "PostAttribute" => "POST", + "PutAttribute" => "PUT", + _ => string.Empty + }; + + /// Gets the literal path from a Refit HTTP method attribute. + /// The attribute data. + /// The path literal. + private static string GetHttpPath(AttributeData attributeData) + { + var arguments = attributeData.ConstructorArguments; + return arguments.Length > 0 && arguments[0].Value is string path + ? path + : string.Empty; + } + + /// Appends a query segment unless its key is empty or whitespace. + /// The full path containing the query segment. + /// The query start index, used to size the lazy buffer. + /// The query segment start index. + /// The query segment length. + /// The query buffer, allocated only when a segment is retained. + /// The number of characters currently written to the query buffer. + private static void AppendNonEmptyQueryPart( + string path, + int queryStart, + int partStart, + int partLength, + ref char[]? queryBuffer, + ref int queryLength) + { + if (partLength <= 0) + { + return; + } + + var equalsIndex = path.IndexOf('=', partStart, partLength); + var keyLength = equalsIndex >= 0 + ? equalsIndex - partStart + : partLength; + if (IsWhiteSpace(path, partStart, keyLength)) + { + return; + } + + queryBuffer ??= new char[path.Length - queryStart]; + if (queryLength > 0) + { + queryBuffer[queryLength++] = '&'; + } + + path.CopyTo(partStart, queryBuffer, queryLength, partLength); + queryLength += partLength; + } + + /// Determines whether a method carries request metadata the initial inline emitter does not handle. + /// The method to inspect. + /// when request construction must use the runtime builder. + private static bool HasUnsupportedInlineRequestMetadata(IMethodSymbol methodSymbol) => + HasUnsupportedMethodAttribute(methodSymbol.GetAttributes()); + + /// Determines whether method attributes contain request metadata unsupported by the initial inline emitter. + /// The attributes to inspect. + /// when an attribute name matches. + private static bool HasUnsupportedMethodAttribute(in ImmutableArray attributes) + { + foreach (var attribute in attributes) + { + var displayName = attribute.AttributeClass?.ToDisplayString(); + if (displayName is + "Refit.MultipartAttribute" or + "Refit.QueryUriFormatAttribute") + { + return true; + } + } + + return false; + } + + /// Parses the static headers declared on inherited interfaces, the declaring interface, and the method. + /// The method whose header metadata should be parsed. + /// The final static header set. + private static ImmutableEquatableArray ParseStaticHeaders(IMethodSymbol methodSymbol) + { + var headers = new List(); + + var inheritedInterfaces = methodSymbol.ContainingType.AllInterfaces; + for (var i = inheritedInterfaces.Length - 1; i >= 0; i--) + { + AddStaticHeaders(headers, inheritedInterfaces[i].GetAttributes()); + } + + AddStaticHeaders(headers, methodSymbol.ContainingType.GetAttributes()); + AddStaticHeaders(headers, methodSymbol.GetAttributes()); + + return headers.ToImmutableEquatableArray(); + } + + /// Adds headers from a collection of attributes, replacing earlier values for the same header name. + /// The mutable header list. + /// The attributes to inspect. + private static void AddStaticHeaders( + List headers, + in ImmutableArray attributes) + { + foreach (var attribute in attributes) + { + if (attribute.AttributeClass?.ToDisplayString() != "Refit.HeadersAttribute") + { + continue; + } + + AddHeadersAttributeValues(headers, attribute); + } + } + + /// Adds the string values from a HeadersAttribute to the static header list. + /// The mutable header list. + /// The attribute data. + private static void AddHeadersAttributeValues(List headers, AttributeData attribute) + { + foreach (var argument in attribute.ConstructorArguments) + { + if (argument.Kind == TypedConstantKind.Array) + { + foreach (var value in argument.Values) + { + if (value.Value is string header) + { + AddStaticHeader(headers, header); + } + } + } + + // HeadersAttribute is declared as params string[], so Roslyn always exposes + // values as an array typed constant for supported Refit metadata. + } + } + + /// Parses request parameter bindings for the conservative initial inline path. + /// The method parameters. + /// Receives whether every parameter is supported. + /// The parsed request parameter models. + private static ImmutableEquatableArray ParseRequestParameters( + in ImmutableArray parameters, + out bool canGenerateInline) + { + if (parameters.Length == 0) + { + canGenerateInline = true; + return ImmutableEquatableArray.Empty; + } + + var requestParameters = new RequestParameterModel[parameters.Length]; + var bodyCount = 0; + var cancellationTokenCount = 0; + var headerCollectionCount = 0; + canGenerateInline = true; + + for (var i = 0; i < parameters.Length; i++) + { + var parsedParameter = ParseRequestParameter(parameters[i]); + requestParameters[i] = parsedParameter.Parameter; + bodyCount += parsedParameter.BodyCount; + cancellationTokenCount += parsedParameter.CancellationTokenCount; + headerCollectionCount += parsedParameter.HeaderCollectionCount; + canGenerateInline &= parsedParameter.CanGenerateInline; + } + + if (bodyCount > 1 || cancellationTokenCount > 1 || headerCollectionCount > 1) + { + canGenerateInline = false; + } + + return ImmutableEquatableArrayFactory.FromArray(requestParameters); + } + + /// Parses one request parameter binding. + /// The parameter to parse. + /// The parsed parameter and eligibility counters. + private static ParsedRequestParameter ParseRequestParameter(IParameterSymbol parameter) + { + var parameterType = parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var canBeNull = CanBeNull(parameter.Type, parameter.NullableAnnotation); + if (IsCancellationToken(parameter.Type)) + { + return new( + new( + parameter.MetadataName, + parameterType, + RequestParameterKind.CancellationToken, + canBeNull, + string.Empty, + string.Empty, + string.Empty, + BodyBufferMode.None), + true, + 0, + 1, + 0); + } + + if (TryParseBodyParameter(parameter, parameterType, out var bodyParameter)) + { + return new(bodyParameter, true, 1, 0, 0); + } + + if (TryParseHeaderParameter(parameter, parameterType, out var headerParameter)) + { + return new(headerParameter, true, 0, 0, 0); + } + + if (TryParseHeaderCollectionParameter(parameter, parameterType, out var headerCollectionParameter)) + { + return new( + headerCollectionParameter, + headerCollectionParameter.Kind == RequestParameterKind.HeaderCollection, + 0, + 0, + 1); + } + + return TryParsePropertyParameter(parameter, parameterType, out var propertyParameter) + ? new(propertyParameter, true, 0, 0, 0) + : new(UnsupportedRequestParameter(parameter, parameterType), false, 0, 0, 0); + } + + /// Determines whether a type is or nullable . + /// The type to inspect. + /// when the type is a cancellation token. + private static bool IsCancellationToken(ITypeSymbol type) => + type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + == "global::System.Threading.CancellationToken" || (type is INamedTypeSymbol + { + OriginalDefinition.SpecialType: SpecialType.System_Nullable_T, + TypeArguments.Length: 1 + } namedType + && namedType.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + == "global::System.Threading.CancellationToken"); + + /// Tries to parse an explicitly attributed body parameter. + /// The parameter to inspect. + /// The parameter type display string. + /// Receives the body parameter model. + /// when the parameter has a body attribute. + private static bool TryParseBodyParameter( + IParameterSymbol parameter, + string parameterType, + out RequestParameterModel bodyParameter) + { + foreach (var attribute in parameter.GetAttributes()) + { + if (attribute.AttributeClass?.ToDisplayString() != "Refit.BodyAttribute") + { + continue; + } + + var bodyInfo = ParseBodyAttribute(attribute); + bodyParameter = new( + parameter.MetadataName, + parameterType, + RequestParameterKind.Body, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + string.Empty, + string.Empty, + bodyInfo.SerializationMethod, + bodyInfo.BufferMode); + return true; + } + + bodyParameter = new( + parameter.MetadataName, + parameterType, + RequestParameterKind.Unsupported, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + string.Empty, + string.Empty, + string.Empty, + BodyBufferMode.None); + return false; + } + + /// Tries to parse a dynamic header parameter. + /// The parameter to inspect. + /// The parameter type display string. + /// Receives the header parameter model. + /// when the parameter has a supported header attribute. + private static bool TryParseHeaderParameter( + IParameterSymbol parameter, + string parameterType, + out RequestParameterModel headerParameter) + { + foreach (var attribute in parameter.GetAttributes()) + { + if (attribute.AttributeClass?.ToDisplayString() != "Refit.HeaderAttribute") + { + continue; + } + + var arguments = attribute.ConstructorArguments; + if (arguments.Length == 0 || arguments[0].Value is not string headerName || + string.IsNullOrWhiteSpace(headerName)) + { + continue; + } + + headerParameter = new( + parameter.MetadataName, + parameterType, + RequestParameterKind.Header, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + headerName.Trim(), + string.Empty, + string.Empty, + BodyBufferMode.None); + return true; + } + + headerParameter = UnsupportedRequestParameter(parameter, parameterType); + return false; + } + + /// Tries to parse a dynamic header collection parameter. + /// The parameter to inspect. + /// The parameter type display string. + /// Receives the header collection parameter model. + /// when the parameter has a supported header collection attribute. + private static bool TryParseHeaderCollectionParameter( + IParameterSymbol parameter, + string parameterType, + out RequestParameterModel headerCollectionParameter) + { + foreach (var attribute in parameter.GetAttributes()) + { + if (attribute.AttributeClass?.ToDisplayString() != "Refit.HeaderCollectionAttribute") + { + continue; + } + + if (IsSupportedHeaderCollectionType(parameter.Type)) + { + headerCollectionParameter = new( + parameter.MetadataName, + parameterType, + RequestParameterKind.HeaderCollection, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + string.Empty, + string.Empty, + string.Empty, + BodyBufferMode.None); + return true; + } + + headerCollectionParameter = UnsupportedRequestParameter(parameter, parameterType); + return false; + } + + headerCollectionParameter = UnsupportedRequestParameter(parameter, parameterType); + return false; + } + + /// Tries to parse a request property parameter. + /// The parameter to inspect. + /// The parameter type display string. + /// Receives the property parameter model. + /// when the parameter has a property attribute. + private static bool TryParsePropertyParameter( + IParameterSymbol parameter, + string parameterType, + out RequestParameterModel propertyParameter) + { + foreach (var attribute in parameter.GetAttributes()) + { + if (attribute.AttributeClass?.ToDisplayString() != "Refit.PropertyAttribute") + { + continue; + } + + var arguments = attribute.ConstructorArguments; + var propertyKey = arguments.Length > 0 && arguments[0].Value is string { Length: > 0 } key + ? key + : parameter.MetadataName; + propertyParameter = new( + parameter.MetadataName, + parameterType, + RequestParameterKind.Property, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + string.Empty, + propertyKey, + string.Empty, + BodyBufferMode.None); + return true; + } + + propertyParameter = UnsupportedRequestParameter(parameter, parameterType); + return false; + } + + /// Builds an unsupported request parameter model. + /// The parameter symbol. + /// The parameter type display string. + /// The unsupported parameter model. + private static RequestParameterModel UnsupportedRequestParameter( + IParameterSymbol parameter, + string parameterType) => + new( + parameter.MetadataName, + parameterType, + RequestParameterKind.Unsupported, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + string.Empty, + string.Empty, + string.Empty, + BodyBufferMode.None); + + /// Determines whether generated code needs a null-safe dereference for a parameter value. + /// The parameter type. + /// The parameter nullable annotation. + /// when generated code should guard the value before dereferencing it. + private static bool CanBeNull(ITypeSymbol type, NullableAnnotation nullableAnnotation) => + type switch + { + INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } => true, + ITypeParameterSymbol typeParameter => !typeParameter.HasValueTypeConstraint, + _ => !type.IsValueType || nullableAnnotation == NullableAnnotation.Annotated + }; + + /// Determines whether a header collection parameter matches existing runtime semantics. + /// The parameter type. + /// when the type is supported. + private static bool IsSupportedHeaderCollectionType(ITypeSymbol type) => + type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + == "global::System.Collections.Generic.IDictionary"; + + /// Parses the constructor-supplied data from a body attribute. + /// The attribute data. + /// The parsed body serialization and buffering data. + private static BodyAttributeInfo ParseBodyAttribute(AttributeData attribute) + { + var serializationMethod = "Default"; + bool? buffered = null; + + foreach (var argument in attribute.ConstructorArguments) + { + if (TryGetBodySerializationMethodName(argument, out var methodName)) + { + serializationMethod = methodName; + continue; + } + + if (TryGetBodyBufferedValue(argument, out var boolValue)) + { + buffered = boolValue; + } + } + + var bufferMode = buffered switch + { + true => BodyBufferMode.Buffered, + false => BodyBufferMode.Streaming, + _ => BodyBufferMode.Settings + }; + + return new(serializationMethod, bufferMode); + } + + /// Tries to parse a body serialization method constructor argument. + /// The constructor argument. + /// Receives the enum member name. + /// when the argument is a body serialization method. + private static bool TryGetBodySerializationMethodName(in TypedConstant argument, out string methodName) + { + if (argument.Type?.ToDisplayString() == "Refit.BodySerializationMethod" + && argument.Value is int enumValue) + { + methodName = GetBodySerializationMethodName(enumValue); + return true; + } + + methodName = string.Empty; + return false; + } + + /// Gets return-type details required by the shared generated request runner. + /// The Refit method symbol. + /// The parsed return type details. + private static RequestReturnTypes GetRequestReturnTypes(IMethodSymbol methodSymbol) + { + var resultType = GetReturnResultType(methodSymbol.ReturnType); + var isApiResponse = IsApiResponseType(resultType); + var deserializedResultType = GetDeserializedResultTypeName(resultType, isApiResponse); + var disposeResponse = ShouldDisposeResponse(deserializedResultType); + + return new( + resultType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + deserializedResultType, + isApiResponse, + disposeResponse); + } + + /// Gets the result type wrapped by Task or ValueTask. + /// The declared return type. + /// The result type. + private static ITypeSymbol GetReturnResultType(ITypeSymbol returnType) => + returnType is INamedTypeSymbol + { + MetadataName: "Task`1" or "ValueTask`1", + TypeArguments.Length: 1 + } namedType + && namedType.ContainingNamespace.ToDisplayString() == "System.Threading.Tasks" + ? namedType.TypeArguments[0] + : returnType; + + /// Determines whether a type is one of Refit's API response wrappers. + /// The type to inspect. + /// for API response wrappers. + private static bool IsApiResponseType(ITypeSymbol type) => + type is INamedTypeSymbol namedType + && namedType.ContainingNamespace.ToDisplayString() == "Refit" && namedType.MetadataName is "IApiResponse" or "ApiResponse`1" or "IApiResponse`1"; + + /// Gets the response-content deserialization type for a method result type. + /// The method result type. + /// Whether the result is an API response wrapper. + /// The deserialization target type. + private static string GetDeserializedResultTypeName(ITypeSymbol resultType, bool isApiResponse) + { + if (!isApiResponse) + { + return resultType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + var namedType = (INamedTypeSymbol)resultType; + return namedType.MetadataName == "IApiResponse" + ? "global::System.Net.Http.HttpContent" + : namedType.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + /// Parsed body attribute data. + /// The body serialization method name. + /// The body buffering mode. + private readonly record struct BodyAttributeInfo( + string SerializationMethod, + BodyBufferMode BufferMode); + + /// Parsed return-type data for generated requests. + /// The method result type. + /// The response-body deserialization type. + /// Whether the result type is an API response wrapper. + /// Whether the runner should dispose the response. + private readonly record struct RequestReturnTypes( + string ResultType, + string DeserializedResultType, + bool IsApiResponse, + bool DisposeResponse); + + /// Parsed request parameter data plus duplicate-detection counters. + /// The parsed parameter model. + /// Whether this parameter can be emitted inline. + /// The number of body parameters represented by this parameter. + /// The number of cancellation tokens represented by this parameter. + /// The number of header collections represented by this parameter. + private readonly record struct ParsedRequestParameter( + RequestParameterModel Parameter, + bool CanGenerateInline, + int BodyCount, + int CancellationTokenCount, + int HeaderCollectionCount); +} diff --git a/src/InterfaceStubGenerator.Shared/Parser.cs b/src/InterfaceStubGenerator.Shared/Parser.cs index e0dce8343..226c9c9eb 100644 --- a/src/InterfaceStubGenerator.Shared/Parser.cs +++ b/src/InterfaceStubGenerator.Shared/Parser.cs @@ -2,6 +2,7 @@ // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -9,11 +10,18 @@ namespace Refit.Generator; /// Parses candidate interfaces and methods into the models used to generate Refit stubs. -internal static class Parser +internal static partial class Parser { + /// The underlying value for BodySerializationMethod.UrlEncoded. + private const int BodySerializationUrlEncoded = 2; + + /// The underlying value for BodySerializationMethod.Serialized. + private const int BodySerializationSerialized = 3; + /// Builds the generator model for the candidate Refit interfaces. /// The compilation. /// The refit internal namespace. + /// Whether generated request construction is enabled. /// The candidate methods. /// The candidate interfaces. /// The cancellation token. @@ -23,14 +31,12 @@ public static ( ContextGenerationModel contextGenerationSpec) GenerateInterfaceStubs( CSharpCompilation compilation, string? refitInternalNamespace, - ImmutableArray candidateMethods, - ImmutableArray candidateInterfaces, + bool generatedRequestBuilding, + in ImmutableArray candidateMethods, + in ImmutableArray candidateInterfaces, CancellationToken cancellationToken) { - if (compilation is null) - { - throw new ArgumentNullException(nameof(compilation)); - } + ArgumentExceptionHelper.ThrowIfNull(compilation); refitInternalNamespace = $"{refitInternalNamespace ?? string.Empty}RefitInternalGenerated"; @@ -54,7 +60,8 @@ public static ( new( refitInternalNamespace, string.Empty, - ImmutableEquatableArray.Empty()) + generatedRequestBuilding, + ImmutableEquatableArrayFactory.Empty()) ); } @@ -78,7 +85,8 @@ public static ( new( refitInternalNamespace, string.Empty, - ImmutableEquatableArray.Empty()) + generatedRequestBuilding, + ImmutableEquatableArrayFactory.Empty()) ); } @@ -96,6 +104,7 @@ public static ( preserveAttributeDisplayName, disposableInterfaceSymbol, httpMethodBaseAttributeSymbol, + generatedRequestBuilding, supportsNullable); var interfaceModels = BuildInterfaceModels( @@ -107,7 +116,8 @@ public static ( var contextGenerationSpec = new ContextGenerationModel( refitInternalNamespace, preserveAttributeDisplayName, - interfaceModels.ToImmutableEquatableArray()); + generatedRequestBuilding, + interfaceModels); return (diagnostics, contextGenerationSpec); } @@ -121,8 +131,8 @@ public static ( /// A map of interface symbol to its directly declared Refit methods. private static Dictionary> CollectRefitInterfaces( CSharpCompilation compilation, - ImmutableArray candidateMethods, - ImmutableArray candidateInterfaces, + in ImmutableArray candidateMethods, + in ImmutableArray candidateInterfaces, INamedTypeSymbol httpMethodBaseAttributeSymbol, Dictionary interfaceToNullableEnabledMap, CancellationToken cancellationToken) @@ -159,7 +169,7 @@ private static Dictionary> CollectRefitInt interfaces[containingType] = interfaceMethods; } - interfaceMethods.Add(methodSymbol!); + interfaceMethods.Add(methodSymbol); } // Add interfaces whose Refit methods are inherited from base interfaces. @@ -175,14 +185,16 @@ private static Dictionary> CollectRefitInt } // The interface has no refit methods of its own, but its base interfaces might. - if (HasDerivedRefitMethods(ifaceSymbol, httpMethodBaseAttributeSymbol)) + if (!HasDerivedRefitMethods(ifaceSymbol, httpMethodBaseAttributeSymbol)) { - // Add the interface with an empty method set; downstream processing already - // looks for inherited Refit methods. - interfaces.Add(ifaceSymbol, []); - interfaceToNullableEnabledMap[ifaceSymbol] = - model.GetNullableContext(iface.SpanStart) == NullableContext.Enabled; + continue; } + + // Add the interface with an empty method set; downstream processing already + // looks for inherited Refit methods. + interfaces.Add(ifaceSymbol, []); + interfaceToNullableEnabledMap[ifaceSymbol] = + model.GetNullableContext(iface.SpanStart) == NullableContext.Enabled; } return interfaces; @@ -194,14 +206,15 @@ private static Dictionary> CollectRefitInt /// The shared generation context. /// The cancellation token. /// The interface models, one per collected interface. - private static List BuildInterfaceModels( + private static ImmutableEquatableArray BuildInterfaceModels( Dictionary> interfaces, Dictionary interfaceToNullableEnabledMap, - InterfaceGenerationContext context, + in InterfaceGenerationContext context, CancellationToken cancellationToken) { var keyCount = new Dictionary(StringComparer.OrdinalIgnoreCase); - var interfaceModels = new List(interfaces.Count); + var interfaceModels = new InterfaceModel[interfaces.Count]; + var index = 0; // Process each interface into the generation model. foreach (var group in interfaces) @@ -220,17 +233,15 @@ private static List BuildInterfaceModels( keyCount[keyName] = value; var fileName = $"{keyName}.g.cs"; - var interfaceModel = ProcessInterface( + interfaceModels[index++] = ProcessInterface( fileName, group.Key, group.Value, interfaceToNullableEnabledMap[group.Key], context); - - interfaceModels.Add(interfaceModel); } - return interfaceModels; + return ImmutableEquatableArrayFactory.FromArray(interfaceModels); } /// Builds the model for a single interface from its Refit and non-Refit members. @@ -245,7 +256,7 @@ private static InterfaceModel ProcessInterface( INamedTypeSymbol interfaceSymbol, List refitMethods, bool nullableEnabled, - InterfaceGenerationContext context) + in InterfaceGenerationContext context) { var names = ComputeInterfaceNames(interfaceSymbol); var members = interfaceSymbol.GetMembers(); @@ -258,16 +269,17 @@ private static InterfaceModel ProcessInterface( var memberNames = CollectMemberNames(members); - var refitMethodsArray = ParseMethods(refitMethods, true); + var refitMethodsArray = ParseMethods(refitMethods, true, context); // Only include refit methods discovered on base interfaces here. // Do NOT duplicate the current interface's refit methods. - var derivedRefitMethodsArray = ParseMethods(partition.DerivedRefitMethods, false); + var derivedRefitMethodsArray = ParseMethods(partition.DerivedRefitMethods, false, context); var nonRefitMethodModels = BuildNonRefitMethodModels( partition.NonRefitMethods, partition.DerivedNonRefitMethods, context.Diagnostics); + var properties = BuildInterfacePropertyModels(interfaceSymbol, members); var constraints = GenerateConstraints(interfaceSymbol.TypeParameters, false); var nullability = (context.SupportsNullable, nullableEnabled) switch @@ -284,8 +296,10 @@ private static InterfaceModel ProcessInterface( names.ClassDeclaration, names.InterfaceDisplayName, names.ClassSuffix, + context.GeneratedRequestBuilding, constraints, memberNames, + properties, nonRefitMethodModels, refitMethodsArray, derivedRefitMethodsArray, @@ -303,7 +317,7 @@ private static InterfaceNames ComputeInterfaceNames(INamedTypeSymbol interfaceSy var lastDot = className.LastIndexOf('.'); if (lastDot > 0) { - className = className.Substring(lastDot + 1); + className = className[(lastDot + 1)..]; } var classDeclaration = (interfaceSymbol.ContainingType?.Name) + className; @@ -333,9 +347,9 @@ private static InterfaceNames ComputeInterfaceNames(INamedTypeSymbol interfaceSy /// The partitioned method sets. private static MethodPartition PartitionMethods( INamedTypeSymbol interfaceSymbol, - ImmutableArray members, + in ImmutableArray members, List refitMethods, - InterfaceGenerationContext context) + in InterfaceGenerationContext context) { var nonRefitMethods = CollectDirectNonRefitMethods(members, refitMethods); @@ -363,7 +377,7 @@ private static MethodPartition PartitionMethods( /// The Refit methods declared on the interface. /// The non-Refit methods declared directly on the interface. private static List CollectDirectNonRefitMethods( - ImmutableArray members, + in ImmutableArray members, List refitMethods) { // Get any other (non-Refit) methods declared directly on the interface. LINQ is avoided @@ -397,7 +411,7 @@ member is IMethodSymbol method /// if the interface inherits IDisposable.Dispose; otherwise, . private static bool CollectDerivedMethods( INamedTypeSymbol interfaceSymbol, - InterfaceGenerationContext context, + in InterfaceGenerationContext context, List derivedRefitMethods, List derivedNonRefitMethods) { @@ -449,7 +463,7 @@ private static bool IsDisposeMethod(IMethodSymbol method, ISymbol? disposableInt /// The non-Refit methods discovered on base interfaces. /// The filtered list of derived non-Refit methods. private static List ExcludeExplicitlyImplementedBaseMethods( - ImmutableArray members, + in ImmutableArray members, List derivedNonRefitMethods) { if (derivedNonRefitMethods.Count == 0) @@ -469,7 +483,7 @@ private static List ExcludeExplicitlyImplementedBaseMethods( foreach (var baseMethod in method.ExplicitInterfaceImplementations) { // Compare generic methods by definition so explicit implementations line up. - explicitlyImplementedBaseMethods.Add(baseMethod.OriginalDefinition ?? baseMethod); + explicitlyImplementedBaseMethods.Add(baseMethod.OriginalDefinition); } } @@ -481,7 +495,7 @@ private static List ExcludeExplicitlyImplementedBaseMethods( var filteredDerivedNonRefitMethods = new List(derivedNonRefitMethods.Count); foreach (var method in derivedNonRefitMethods) { - if (!explicitlyImplementedBaseMethods.Contains(method.OriginalDefinition ?? method)) + if (!explicitlyImplementedBaseMethods.Contains(method.OriginalDefinition)) { filteredDerivedNonRefitMethods.Add(method); } @@ -493,36 +507,174 @@ private static List ExcludeExplicitlyImplementedBaseMethods( /// Collects the distinct member names declared on the interface, preserving order. /// The directly declared members of the interface. /// The distinct member names. - private static ImmutableEquatableArray CollectMemberNames(ImmutableArray members) + private static ImmutableEquatableArray CollectMemberNames(in ImmutableArray members) { var seenMemberNames = new HashSet(); - var memberNameList = new List(members.Length); + var memberNames = new string[members.Length]; + var count = 0; foreach (var member in members) { if (seenMemberNames.Add(member.Name)) { - memberNameList.Add(member.Name); + memberNames[count++] = member.Name; } } - return memberNameList.ToImmutableEquatableArray(); + return TrimAndWrap(memberNames, count); + } + + /// Wraps a fully populated array, or copies the populated prefix before wrapping. + /// The array containing populated values at the front. + /// The number of populated entries. + /// The element type. + /// The immutable equatable array. + private static ImmutableEquatableArray TrimAndWrap(T[] values, int count) + where T : IEquatable + { + if (count == 0) + { + return ImmutableEquatableArrayFactory.Empty(); + } + + if (count == values.Length) + { + return ImmutableEquatableArrayFactory.FromArray(values); + } + + var trimmed = new T[count]; + Array.Copy(values, trimmed, count); + return ImmutableEquatableArrayFactory.FromArray(trimmed); + } + + /// Builds models for interface properties implemented by the generated stub. + /// The interface symbol being processed. + /// The directly declared interface members. + /// The property models. + private static ImmutableEquatableArray BuildInterfacePropertyModels( + INamedTypeSymbol interfaceSymbol, + in ImmutableArray members) + { + var properties = new List(); + foreach (var member in members) + { + if (member is IPropertySymbol property && IsEmittableProperty(property)) + { + properties.Add(ParseInterfaceProperty(property, false)); + } + } + + var seenInheritedProperties = new HashSet(SymbolEqualityComparer.Default); + foreach (var baseInterface in interfaceSymbol.AllInterfaces) + { + foreach (var member in baseInterface.GetMembers()) + { + if (member is IPropertySymbol property + && IsEmittableProperty(property) + && seenInheritedProperties.Add(property)) + { + properties.Add(ParseInterfaceProperty(property, true)); + } + } + } + + return properties.ToImmutableEquatableArray(); + } + + /// Determines whether an interface property should be implemented by the generated stub. + /// The property to inspect. + /// when the property should be emitted. + private static bool IsEmittableProperty(IPropertySymbol property) => + !property.IsStatic + && property.IsAbstract + && property.Parameters.Length == 0; + + /// Builds an interface property model. + /// The property to parse. + /// Whether the property comes from a base interface. + /// The property model. + private static InterfacePropertyModel ParseInterfaceProperty(IPropertySymbol property, bool isDerived) + { + var annotation = + !property.Type.IsValueType && property.NullableAnnotation == NullableAnnotation.Annotated; + var propertyType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var containingType = property.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var requestPropertyKey = GetInterfacePropertyRequestKey(property); + var isSatisfiedByGeneratedMember = IsGeneratedClientProperty(property, propertyType); + var isExplicitInterface = + isDerived || (HasGeneratedMemberNameCollision(property) && !isSatisfiedByGeneratedMember); + + return new( + property.MetadataName, + propertyType, + annotation, + containingType, + requestPropertyKey, + property.GetMethod is not null, + property.SetMethod is not null, + isSatisfiedByGeneratedMember, + isExplicitInterface); + } + + /// Determines whether the generated stub's existing Client property satisfies an interface property. + /// The property to inspect. + /// The fully-qualified property type display string. + /// when no extra property emission is required. + private static bool IsGeneratedClientProperty(IPropertySymbol property, string propertyType) => + property.MetadataName == "Client" + && propertyType == "global::System.Net.Http.HttpClient" + && property.GetMethod is not null + && property.SetMethod is null; + + /// Determines whether an interface property name collides with generated stub infrastructure members. + /// The property to inspect. + /// when the property should be emitted explicitly to avoid a member collision. + private static bool HasGeneratedMemberNameCollision(IPropertySymbol property) => + property.MetadataName is "Client" or "requestBuilder"; + + /// Gets the request-property key declared on an interface property. + /// The property to inspect. + /// The request-property key, or an empty string when the property is not request-bound. + [ExcludeFromCodeCoverage] + private static string GetInterfacePropertyRequestKey(IPropertySymbol property) + { + foreach (var attribute in property.GetAttributes()) + { + if (attribute.AttributeClass?.ToDisplayString() != "Refit.PropertyAttribute") + { + continue; + } + + var arguments = attribute.ConstructorArguments; + return arguments.Length > 0 && arguments[0].Value is string { Length: > 0 } key + ? key + : property.MetadataName; + } + + return string.Empty; } /// Parses a set of Refit methods into method models. /// The Refit methods to parse. /// Whether the methods belong to the implicitly implemented interface. + /// The shared generation context. /// The method models. private static ImmutableEquatableArray ParseMethods( List methods, - bool isImplicitInterface) + bool isImplicitInterface, + in InterfaceGenerationContext context) { - var methodModels = new List(methods.Count); - foreach (var method in methods) + if (methods.Count == 0) { - methodModels.Add(ParseMethod(method, isImplicitInterface)); + return ImmutableEquatableArrayFactory.Empty(); } - return methodModels.ToImmutableEquatableArray(); + var methodModels = new MethodModel[methods.Count]; + for (var i = 0; i < methods.Count; i++) + { + methodModels[i] = ParseMethod(methods[i], isImplicitInterface, context); + } + + return ImmutableEquatableArrayFactory.FromArray(methodModels); } /// Builds the non-Refit method models from the interface's direct and derived methods. @@ -536,12 +688,13 @@ private static ImmutableEquatableArray BuildNonRefitMethodModels( List diagnostics) { // Only abstract instance methods become non-Refit method models. - var nonRefitMethodModelList = new List(nonRefitMethods.Count + derivedNonRefitMethods.Count); + var methodModels = new MethodModel[nonRefitMethods.Count + derivedNonRefitMethods.Count]; + var count = 0; foreach (var method in nonRefitMethods) { if (IsEmittableNonRefitMethod(method)) { - nonRefitMethodModelList.Add(ParseNonRefitMethod(method, diagnostics, false)); + methodModels[count++] = ParseNonRefitMethod(method, diagnostics, false); } } @@ -550,11 +703,11 @@ private static ImmutableEquatableArray BuildNonRefitMethodModels( if (IsEmittableNonRefitMethod(method)) { // Derived non-Refit methods are emitted as explicit interface implementations. - nonRefitMethodModelList.Add(ParseNonRefitMethod(method, diagnostics, true)); + methodModels[count++] = ParseNonRefitMethod(method, diagnostics, true); } } - return nonRefitMethodModelList.ToImmutableEquatableArray(); + return TrimAndWrap(methodModels, count); } /// Determines whether a non-Refit method should be emitted as a method model. @@ -588,14 +741,14 @@ private static MethodModel ParseNonRefitMethod( } // Derived base-interface methods are emitted as explicit implementations. - var explicitImpl = methodSymbol.ExplicitInterfaceImplementations.FirstOrDefault(); + var explicitImpl = FirstExplicitInterfaceImplementation(methodSymbol); var containingTypeSymbol = explicitImpl?.ContainingType ?? methodSymbol.ContainingType; var containingType = containingTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var declaredBaseName = BuildDeclaredBaseName(methodSymbol); var returnType = methodSymbol.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var returnTypeInfo = GetReturnTypeInfo(methodSymbol); - var parameters = methodSymbol.Parameters.Select(ParseParameter).ToImmutableEquatableArray(); + var parameters = ParseParameters(methodSymbol.Parameters); var isExplicit = isDerived || explicitImpl is not null; var constraints = GenerateConstraints(methodSymbol.TypeParameters, isExplicit); @@ -606,33 +759,19 @@ private static MethodModel ParseNonRefitMethod( containingType, declaredBaseName, returnTypeInfo, + RequestModel.Empty, parameters, constraints, isExplicit); } - /// Builds the unqualified declared method name, including any generic type parameters. - /// The method symbol. - /// The declared method name without its interface qualifier. - private static string BuildDeclaredBaseName(IMethodSymbol methodSymbol) + /// Gets the first explicit interface implementation for a method, if one exists. + /// The method symbol to inspect. + /// The first explicit interface implementation, or when there is none. + private static IMethodSymbol? FirstExplicitInterfaceImplementation(IMethodSymbol methodSymbol) { - // Keep the declared method name unqualified. - var declaredBaseName = methodSymbol.Name; - var lastDot = declaredBaseName.LastIndexOf('.'); - if (lastDot >= 0) - { - declaredBaseName = declaredBaseName.Substring(lastDot + 1); - } - - if (methodSymbol.TypeParameters.Length > 0) - { - var typeParams = string.Join( - ", ", - methodSymbol.TypeParameters.Select(tp => tp.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))); - declaredBaseName += $"<{typeParams}>"; - } - - return declaredBaseName; + var implementations = methodSymbol.ExplicitInterfaceImplementations; + return implementations.Length == 0 ? null : implementations[0]; } /// Classifies a method's return type into its shape. @@ -647,67 +786,30 @@ private static ReturnTypeInfo GetReturnTypeInfo(IMethodSymbol methodSymbol) => _ => ReturnTypeInfo.Return }; - /// Determines whether a method is decorated with a Refit HTTP method attribute. - /// The method symbol to inspect. - /// The Refit HTTP method attribute symbol. - /// if the method is a Refit method; otherwise, . - private static bool IsRefitMethod(IMethodSymbol? methodSymbol, INamedTypeSymbol httpMethodAttribute) + /// Builds the constraint models for a set of type parameters. + /// The type parameters to generate constraints for. + /// Whether the member is an override or explicit implementation. + /// The constraint models for the type parameters. + private static ImmutableEquatableArray GenerateConstraints( + in ImmutableArray typeParameters, + bool isOverrideOrExplicitImplementation) { - if (methodSymbol is null) + if (typeParameters.Length == 0) { - return false; + return ImmutableEquatableArrayFactory.Empty(); } - // Avoid LINQ here: this is called for every candidate method and every inherited member. - foreach (var attributeData in methodSymbol.GetAttributes()) + var constraints = new TypeConstraint[typeParameters.Length]; + for (var i = 0; i < typeParameters.Length; i++) { - if (attributeData.AttributeClass?.InheritsFromOrEquals(httpMethodAttribute) == true) - { - return true; - } + constraints[i] = ParseConstraintsForTypeParameter( + typeParameters[i], + isOverrideOrExplicitImplementation); } - return false; + return ImmutableEquatableArrayFactory.FromArray(constraints); } - /// Determines whether any base interface declares a Refit method. - /// The interface symbol to inspect. - /// The Refit HTTP method attribute symbol. - /// if a base interface declares a Refit method; otherwise, . - private static bool HasDerivedRefitMethods( - INamedTypeSymbol interfaceSymbol, - INamedTypeSymbol httpMethodAttribute) - { - foreach (var baseInterface in interfaceSymbol.AllInterfaces) - { - foreach (var member in baseInterface.GetMembers()) - { - if (member is IMethodSymbol method && IsRefitMethod(method, httpMethodAttribute)) - { - return true; - } - } - } - - return false; - } - - /// Builds the constraint models for a set of type parameters. - /// The type parameters to generate constraints for. - /// Whether the member is an override or explicit implementation. - /// The constraint models for the type parameters. - private static ImmutableEquatableArray GenerateConstraints( - ImmutableArray typeParameters, - bool isOverrideOrExplicitImplementation) => - - // Build the constraint models explicitly for each type parameter. - typeParameters - .Select(typeParameter => - ParseConstraintsForTypeParameter( - typeParameter, - isOverrideOrExplicitImplementation)) - .ToImmutableEquatableArray(); - /// Builds the constraint model for a single type parameter. /// The type parameter to parse. /// Whether the member is an override or explicit implementation. @@ -721,10 +823,7 @@ private static TypeConstraint ParseConstraintsForTypeParameter( var constraints = ImmutableEquatableArray.Empty; if (!isOverrideOrExplicitImplementation) { - constraints = typeParameter - .ConstraintTypes.Select(typeConstraint => - typeConstraint.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)) - .ToImmutableEquatableArray(); + constraints = ParseConstraintTypes(typeParameter.ConstraintTypes); } var declaredName = typeParameter.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); @@ -787,6 +886,46 @@ private static ParameterModel ParseParameter(IParameterSymbol param) return new(param.MetadataName, paramType, annotation, isGeneric); } + /// Builds parameter models from a fixed Roslyn parameter array. + /// The parameters to parse. + /// The parsed parameter models. + private static ImmutableEquatableArray ParseParameters( + in ImmutableArray parameters) + { + if (parameters.Length == 0) + { + return ImmutableEquatableArrayFactory.Empty(); + } + + var parameterModels = new ParameterModel[parameters.Length]; + for (var i = 0; i < parameters.Length; i++) + { + parameterModels[i] = ParseParameter(parameters[i]); + } + + return ImmutableEquatableArrayFactory.FromArray(parameterModels); + } + + /// Builds constraint display names from a fixed Roslyn type array. + /// The constraint types to parse. + /// The parsed constraint type display names. + private static ImmutableEquatableArray ParseConstraintTypes( + in ImmutableArray constraintTypes) + { + if (constraintTypes.Length == 0) + { + return ImmutableEquatableArrayFactory.Empty(); + } + + var constraints = new string[constraintTypes.Length]; + for (var i = 0; i < constraintTypes.Length; i++) + { + constraints[i] = constraintTypes[i].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + } + + return ImmutableEquatableArrayFactory.FromArray(constraints); + } + /// Determines whether a type or any of its type arguments is a type parameter. /// The type symbol to inspect. /// if the type involves a type parameter; otherwise, . @@ -816,20 +955,25 @@ private static bool ContainsTypeParameter(ITypeSymbol symbol) /// Builds a method model for a Refit interface method. /// The Refit method symbol. /// Whether the method belongs to the implicitly implemented interface. + /// The shared generation context. /// The model describing the Refit method. - private static MethodModel ParseMethod(IMethodSymbol methodSymbol, bool isImplicitInterface) + private static MethodModel ParseMethod( + IMethodSymbol methodSymbol, + bool isImplicitInterface, + in InterfaceGenerationContext context) { var returnType = methodSymbol.ReturnType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); // For explicit interface implementations, emit the interface being implemented, not the // interface that originally declared the method. - var explicitImpl = methodSymbol.ExplicitInterfaceImplementations.FirstOrDefault(); + var explicitImpl = FirstExplicitInterfaceImplementation(methodSymbol); var containingTypeSymbol = explicitImpl?.ContainingType ?? methodSymbol.ContainingType; var containingType = containingTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); var declaredBaseName = BuildDeclaredBaseName(methodSymbol); var returnTypeInfo = GetReturnTypeInfo(methodSymbol); - var parameters = methodSymbol.Parameters.Select(ParseParameter).ToImmutableEquatableArray(); + var request = ParseRequest(methodSymbol, returnTypeInfo, context); + var parameters = ParseParameters(methodSymbol.Parameters); var isExplicit = explicitImpl is not null; var constraints = GenerateConstraints(methodSymbol.TypeParameters, isExplicit || !isImplicitInterface); @@ -840,6 +984,7 @@ private static MethodModel ParseMethod(IMethodSymbol methodSymbol, bool isImplic containingType, declaredBaseName, returnTypeInfo, + request, parameters, constraints, isExplicit); diff --git a/src/InterfaceStubGenerator.Shared/Polyfills/IndexRange.cs b/src/InterfaceStubGenerator.Shared/Polyfills/Index.cs similarity index 87% rename from src/InterfaceStubGenerator.Shared/Polyfills/IndexRange.cs rename to src/InterfaceStubGenerator.Shared/Polyfills/Index.cs index dcfab4754..fed6d744a 100644 --- a/src/InterfaceStubGenerator.Shared/Polyfills/IndexRange.cs +++ b/src/InterfaceStubGenerator.Shared/Polyfills/Index.cs @@ -14,15 +14,12 @@ namespace System /// System.Index is not available. It should not be used as a general-purpose /// substitute outside of this project. /// + [Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] [Diagnostics.CodeAnalysis.SuppressMessage( "Design", "CA2225:Operator overloads have named alternates", Justification = "Polyfill mirroring the shape of the BCL System.Index; it is internal to the generator and intentionally matches the framework type.")] - [Diagnostics.CodeAnalysis.SuppressMessage( - "StyleCop.CSharp.DocumentationRules", - "SST1649:File name should match first type name", - Justification = "File name retained for the shared project; contains the Index/Range polyfills.")] public readonly record struct Index { /// Initializes a new instance of the struct. @@ -59,6 +56,11 @@ public Index(int value, bool fromEnd) /// Implicitly converts an to an from the start. /// The zero-based index from the start. public static implicit operator Index(int value) => new(value); + + /// Calculates the zero-based offset for a sequence of the given length. + /// The sequence length. + /// The zero-based offset from the start of the sequence. + public int GetOffset(int length) => IsFromEnd ? length - Value : Value; } } #endif diff --git a/src/InterfaceStubGenerator.Shared/Polyfills/Range.cs b/src/InterfaceStubGenerator.Shared/Polyfills/Range.cs index cf9483130..635c03ab0 100644 --- a/src/InterfaceStubGenerator.Shared/Polyfills/Range.cs +++ b/src/InterfaceStubGenerator.Shared/Polyfills/Range.cs @@ -14,6 +14,7 @@ namespace System /// System.Range is not available. It should not be used as a general-purpose /// substitute outside of this project. /// + [Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] public readonly record struct Range { /// Initializes a new instance of the struct. diff --git a/src/InterfaceStubGenerator.Shared/SourceWriter.cs b/src/InterfaceStubGenerator.Shared/SourceWriter.cs index 1fc1e4b75..503fd489f 100644 --- a/src/InterfaceStubGenerator.Shared/SourceWriter.cs +++ b/src/InterfaceStubGenerator.Shared/SourceWriter.cs @@ -23,23 +23,28 @@ internal sealed class SourceWriter private const int DefaultCapacity = 4096; /// The underlying buffer that accumulates the written text. - private readonly StringBuilder _sb = new(DefaultCapacity); + private readonly StringBuilder _sb; /// The current indentation level. private int _indentation; + /// Initializes a new instance of the class. + public SourceWriter() + : this(DefaultCapacity) + { + } + + /// Initializes a new instance of the class. + /// The initial backing buffer capacity. + public SourceWriter(int capacity) => _sb = new(capacity); + /// Gets or sets the current indentation level. public int Indentation { get => _indentation; set { - if (value < 0) - { - Throw(); - static void Throw() => throw new ArgumentOutOfRangeException(nameof(value)); - } - + ArgumentOutOfRangeExceptionHelper.ThrowIfNegative(value); _indentation = value; } } @@ -48,14 +53,12 @@ public int Indentation /// The text to append. public void Append(string text) => _sb.Append(text); - /// Writes a single character on its own indented line. - /// The character to write. - public void WriteLine(char value) - { - AddIndentation(); - _sb.Append(value) - .AppendLine(); - } + /// Appends a single character without any indentation or line break. + /// The character to append. + public void Append(char value) => _sb.Append(value); + + /// Appends indentation for the current indentation level. + public void WriteIndentation() => AddIndentation(); /// Writes the given text, applying the current indentation to each line. /// The text to write. @@ -68,18 +71,22 @@ public void WriteLine(string text) } bool isFinalLine; - var remainingText = text.AsSpan(); + var lineStart = 0; do { - var nextLine = GetNextLine(ref remainingText, out isFinalLine); + var lineLength = GetNextLineLength( + text, + lineStart, + out var nextLineStart, + out isFinalLine); - if (!nextLine.IsEmpty) + if (lineLength > 0) { AddIndentation(); } - AppendSpan(_sb, nextLine); - _sb.AppendLine(); + _sb.Append(text, lineStart, lineLength).AppendLine(); + lineStart = nextLineStart; } while (!isFinalLine); } @@ -101,55 +108,46 @@ public void Reset() _indentation = 0; } - /// Extracts the next line from the remaining text. - /// The remaining text, advanced past the returned line. + /// Gets the length of the next line in the supplied text. + /// The text to inspect. + /// The start index of the next line. + /// Set to the index where the following line starts. /// Set to true when the returned line is the last one. - /// The next line of text. - private static ReadOnlySpan GetNextLine( - ref ReadOnlySpan remainingText, + /// The next line length, excluding a trailing carriage return before a newline. + private static int GetNextLineLength( + string text, + int lineStart, + out int nextLineStart, out bool isFinalLine) { - if (remainingText.IsEmpty) + if ((uint)lineStart >= (uint)text.Length) { + nextLineStart = text.Length; isFinalLine = true; - return default; + return 0; } - ReadOnlySpan next; - ReadOnlySpan rest; - - var lineLength = remainingText.IndexOf('\n'); - if (lineLength == -1) + var newLineIndex = text.IndexOf('\n', lineStart); + int lineLength; + if (newLineIndex == -1) { - lineLength = remainingText.Length; + lineLength = text.Length - lineStart; + nextLineStart = text.Length; isFinalLine = true; - rest = default; } else { - rest = remainingText.Slice(lineLength + 1); + lineLength = newLineIndex - lineStart; + nextLineStart = newLineIndex + 1; isFinalLine = false; } - if ((uint)lineLength > 0 && remainingText[lineLength - 1] == '\r') + if (lineLength > 0 && text[lineStart + lineLength - 1] == '\r') { lineLength--; } - next = remainingText.Slice(0, lineLength); - remainingText = rest; - return next; - } - - /// Appends a span of characters to the given builder. - /// The builder to append to. - /// The characters to append. - private static unsafe void AppendSpan(StringBuilder builder, ReadOnlySpan span) - { - fixed (char* ptr = span) - { - builder.Append(ptr, span.Length); - } + return lineLength; } /// Appends the indentation characters for the current indentation level. diff --git a/src/InterfaceStubGenerator.Shared/UniqueNameBuilder.cs b/src/InterfaceStubGenerator.Shared/UniqueNameBuilder.cs index e270b9b09..6d87c9f28 100644 --- a/src/InterfaceStubGenerator.Shared/UniqueNameBuilder.cs +++ b/src/InterfaceStubGenerator.Shared/UniqueNameBuilder.cs @@ -12,24 +12,6 @@ public class UniqueNameBuilder /// The set of names already used in this scope. private readonly HashSet _usedNames = new(StringComparer.Ordinal); - /// The parent scope, or null for a root scope. - private readonly UniqueNameBuilder? _parentScope; - - /// Initializes a new instance of the class representing a root scope. - public UniqueNameBuilder() - { - } - - /// Initializes a new instance of the UniqueNameBuilder class as a child scope. - /// The parent scope. - private UniqueNameBuilder(UniqueNameBuilder parentScope) - : this() => - _parentScope = parentScope; - - /// Reserve a name. - /// The name to reserve. - public void Reserve(string name) => _usedNames.Add(name); - /// Reserve names. /// The name. public void Reserve(IEnumerable names) @@ -45,10 +27,6 @@ public void Reserve(IEnumerable names) } } - /// Create a new scope. - /// The new child scope. - public UniqueNameBuilder NewScope() => new(this); - /// Generate a unique name. /// The desired base name. /// A unique name not used in this or any parent scope. @@ -70,18 +48,6 @@ public string New(string name) /// Determines whether the name is used in this or any parent scope. /// The name to check. /// True if the name is already used; otherwise, false. - private bool Contains(string name) - { - if (_usedNames.Contains(name)) - { - return true; - } - - if (_parentScope is null) - { - return false; - } - - return _parentScope.Contains(name); - } + private bool Contains(string name) => + _usedNames.Contains(name); } diff --git a/src/Polyfills/ArgumentExceptionHelper.cs b/src/Polyfills/ArgumentExceptionHelper.cs new file mode 100644 index 000000000..064d0d192 --- /dev/null +++ b/src/Polyfills/ArgumentExceptionHelper.cs @@ -0,0 +1,31 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace Refit.Internal; + +/// +/// Polyfill for ArgumentNullException.ThrowIfNull on target frameworks that predate it. +/// On modern targets, projects alias ArgumentExceptionHelper directly to . +/// +[ExcludeFromCodeCoverage] +internal static class ArgumentExceptionHelper +{ + /// Throws an if is . + /// The reference type argument to validate as non-null. + /// The parameter name. + public static void ThrowIfNull( + [NotNull] object? argument, + [CallerArgumentExpression(nameof(argument))] string? paramName = null) + { + if (argument is not null) + { + return; + } + + throw new ArgumentNullException(paramName); + } +} diff --git a/src/Polyfills/ArgumentOutOfRangeExceptionHelper.cs b/src/Polyfills/ArgumentOutOfRangeExceptionHelper.cs new file mode 100644 index 000000000..d8917bd1a --- /dev/null +++ b/src/Polyfills/ArgumentOutOfRangeExceptionHelper.cs @@ -0,0 +1,39 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace Refit.Internal; + +/// Polyfill for modern guard helpers on target frameworks that predate them. +[ExcludeFromCodeCoverage] +internal static class ArgumentOutOfRangeExceptionHelper +{ + /// Throws when is negative. + /// The value to validate. + /// The parameter name. + public static void ThrowIfNegative(int value, [CallerArgumentExpression(nameof(value))] string? paramName = null) + { + if (value >= 0) + { + return; + } + + throw new ArgumentOutOfRangeException(paramName, value, null); + } + + /// Throws when is negative or zero. + /// The value to validate. + /// The parameter name. + public static void ThrowIfNegativeOrZero(int value, [CallerArgumentExpression(nameof(value))] string? paramName = null) + { + if (value > 0) + { + return; + } + + throw new ArgumentOutOfRangeException(paramName, value, null); + } +} diff --git a/src/Polyfills/CallerArgumentExpressionAttribute.cs b/src/Polyfills/CallerArgumentExpressionAttribute.cs new file mode 100644 index 000000000..84d6bd9ca --- /dev/null +++ b/src/Polyfills/CallerArgumentExpressionAttribute.cs @@ -0,0 +1,21 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +#if !NET6_0_OR_GREATER +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace System.Runtime.CompilerServices; + +/// Polyfill for the caller-argument-expression attribute on older target frameworks. +/// The parameter whose source expression should be captured. +[ExcludeFromCodeCoverage] +[DebuggerNonUserCode] +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] +internal sealed class CallerArgumentExpressionAttribute(string parameterName) : Attribute +{ + /// Gets the parameter whose source expression should be captured. + public string ParameterName { get; } = parameterName; +} +#endif diff --git a/src/Polyfills/DynamicallyAccessedMemberTypes.cs b/src/Polyfills/DynamicallyAccessedMemberTypes.cs index c591a0681..c32e331af 100644 --- a/src/Polyfills/DynamicallyAccessedMemberTypes.cs +++ b/src/Polyfills/DynamicallyAccessedMemberTypes.cs @@ -7,8 +7,8 @@ namespace System.Diagnostics.CodeAnalysis; /// Specifies the member types that are dynamically accessed and must be preserved. [Flags] -[System.Diagnostics.CodeAnalysis.SuppressMessage("Minor Code Smell", "S4070", Justification = "Mirrors the BCL flags enum shape.")] -[System.Diagnostics.CodeAnalysis.SuppressMessage("Roslynator", "RCS1157", Justification = "Mirrors the BCL enum; PublicConstructors combines bits 1 and 2.")] +[SuppressMessage("Minor Code Smell", "S4070", Justification = "Mirrors the BCL flags enum shape.")] +[SuppressMessage("Roslynator", "RCS1157", Justification = "Mirrors the BCL enum; PublicConstructors combines bits 1 and 2.")] internal enum DynamicallyAccessedMemberTypes { /// No members are dynamically accessed. diff --git a/src/Polyfills/HashCode.cs b/src/Polyfills/HashCode.cs new file mode 100644 index 000000000..13312d9db --- /dev/null +++ b/src/Polyfills/HashCode.cs @@ -0,0 +1,181 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +#if NETFRAMEWORK +using System.Collections.Generic; + +namespace System; + +/// Polyfill for System.HashCode on .NET Framework targets. +/// +/// This is intentionally small: it provides the compiler/runtime surface Refit uses without +/// attempting to clone the randomized framework implementation. +/// +[Diagnostics.CodeAnalysis.SuppressMessage( + "Style", + "S3898:Value types should implement IEquatable", + Justification = "BCL-shaped mutable hash accumulator; equality is intentionally not part of the public surface.")] +[Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1815:Override equals and operator equals on value types", + Justification = "BCL-shaped mutable hash accumulator; equality is intentionally not part of the public surface.")] +[Diagnostics.CodeAnalysis.SuppressMessage( + "Style", + "S1118:Utility classes should not have public constructors", + Justification = "BCL-shaped mutable hash accumulator must be constructible.")] +internal struct HashCode +{ + /// The second prime multiplier. + private const int Prime2 = -2_048_144_777; + + /// The third prime multiplier. + private const int Prime3 = -1_028_477_379; + + /// The fourth prime multiplier. + private const int Prime4 = 668_265_263; + + /// The fifth prime multiplier. + private const int Prime5 = 374_761_393; + + /// The number of bytes represented by each queued integer value. + private const int BytesPerQueuedValue = 4; + + /// The first avalanche shift. + private const int AvalancheShift1 = 15; + + /// The second avalanche shift. + private const int AvalancheShift2 = 13; + + /// The third avalanche shift. + private const int AvalancheShift3 = 16; + + /// The queued-value rotation offset. + private const int QueueRoundRotation = 17; + + /// The number of bits in an integer. + private const int BitsPerInt32 = 32; + + /// The running hash value. + private int _hash; + + /// The number of values added to this accumulator. + private int _count; + + /// Combines one value into a hash code. + /// The first value type. + /// The first value. + /// The combined hash code. + public static int Combine(T1 value1) + { + HashCode hashCode = default; + hashCode.Add(value1); + return hashCode.ToHashCode(); + } + + /// Combines two values into a hash code. + /// The first value type. + /// The second value type. + /// The first value. + /// The second value. + /// The combined hash code. + public static int Combine(T1 value1, T2 value2) + { + HashCode hashCode = default; + hashCode.Add(value1); + hashCode.Add(value2); + return hashCode.ToHashCode(); + } + + /// Combines three values into a hash code. + /// The first value type. + /// The second value type. + /// The third value type. + /// The first value. + /// The second value. + /// The third value. + /// The combined hash code. + public static int Combine(T1 value1, T2 value2, T3 value3) + { + HashCode hashCode = default; + hashCode.Add(value1); + hashCode.Add(value2); + hashCode.Add(value3); + return hashCode.ToHashCode(); + } + + /// Combines four values into a hash code. + /// The first value type. + /// The second value type. + /// The third value type. + /// The fourth value type. + /// The first value. + /// The second value. + /// The third value. + /// The fourth value. + /// The combined hash code. + public static int Combine(T1 value1, T2 value2, T3 value3, T4 value4) + { + HashCode hashCode = default; + hashCode.Add(value1); + hashCode.Add(value2); + hashCode.Add(value3); + hashCode.Add(value4); + return hashCode.ToHashCode(); + } + + /// Adds a value to the hash code. + /// The value type. + /// The value. + public void Add(T value) => Add(value, EqualityComparer.Default); + + /// Adds a value to the hash code using the specified comparer. + /// The value type. + /// The value. + /// The equality comparer. + public void Add(T value, IEqualityComparer? comparer) + { + var hashCode = value is null ? 0 : (comparer?.GetHashCode(value) ?? value.GetHashCode()); + Add(hashCode); + } + + /// Returns the final hash code. + /// The final hash code. + public readonly int ToHashCode() + { + var hash = _count == 0 ? Prime5 : _hash; + hash += _count * BytesPerQueuedValue; + hash ^= (int)((uint)hash >> AvalancheShift1); + hash *= Prime2; + hash ^= (int)((uint)hash >> AvalancheShift2); + hash *= Prime3; + hash ^= (int)((uint)hash >> AvalancheShift3); + return hash; + } + + /// + public override readonly int GetHashCode() => ToHashCode(); + + /// Mixes one queued value into the hash. + /// The current hash. + /// The queued value. + /// The mixed hash. + private static int QueueRound(int hash, int queuedValue) => + RotateLeft(hash + (queuedValue * Prime3), QueueRoundRotation) * Prime4; + + /// Rotates a value left by the requested number of bits. + /// The value to rotate. + /// The bit offset. + /// The rotated value. + private static int RotateLeft(int value, int offset) => + (int)(((uint)value << offset) | ((uint)value >> (BitsPerInt32 - offset))); + + /// Adds a raw hash code value. + /// The raw hash code. + private void Add(int value) + { + _hash = QueueRound(_count == 0 ? Prime5 : _hash, value); + _count++; + } +} +#endif diff --git a/src/Polyfills/Index.cs b/src/Polyfills/Index.cs new file mode 100644 index 000000000..ef0515f98 --- /dev/null +++ b/src/Polyfills/Index.cs @@ -0,0 +1,84 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +#if NETFRAMEWORK +namespace System; + +/// Minimal polyfill for System.Index so C# index/range syntax compiles on .NET Framework targets. +/// +/// This is a compiler hook for older target frameworks. It intentionally mirrors the BCL member names used +/// by range lowering and should not grow into a general-purpose replacement for the framework type. +/// +[Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA2225:Operator overloads have named alternates", + Justification = "Compiler-required polyfill matching the BCL System.Index shape for C# index syntax.")] +internal readonly struct Index : IEquatable +{ + /// Initializes a new instance of the struct. + /// The index value. + public Index(int value) + : this(value, false) + { + } + + /// Initializes a new instance of the struct. + /// The index value. + /// Whether the index is relative to the end of the sequence. + public Index(int value, bool fromEnd) + { + Value = value < 0 ? throw new ArgumentOutOfRangeException(nameof(value)) : value; + IsFromEnd = fromEnd; + } + + /// Gets an that points to the start of a sequence. + public static Index Start => new(0); + + /// Gets an that points just past the end of a sequence. + public static Index End => new(0, true); + + /// Gets the index value. + public int Value { get; } + + /// Gets a value indicating whether the index is from the end of the sequence. + public bool IsFromEnd { get; } + + /// Implicitly converts an to an from the start. + /// The zero-based index from the start. + public static implicit operator Index(int value) => new(value); + + /// + public static bool operator ==(Index left, Index right) => left.Equals(right); + + /// + public static bool operator !=(Index left, Index right) => !left.Equals(right); + + /// Creates an from the start of a sequence. + /// The zero-based index from the start. + /// The created . + public static Index FromStart(int value) => new(value); + + /// Creates an from the end of a sequence. + /// The zero-based index from the end. + /// The created . + public static Index FromEnd(int value) => new(value, true); + + /// Calculates the zero-based offset for a sequence of the given length. + /// The sequence length. + /// The zero-based offset from the start of the sequence. + public int GetOffset(int length) => IsFromEnd ? length - Value : Value; + + /// + public bool Equals(Index other) => Value == other.Value && IsFromEnd == other.IsFromEnd; + + /// + public override bool Equals(object? obj) => obj is Index other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(Value, IsFromEnd); + + /// + public override string ToString() => IsFromEnd ? "^" + Value.ToString() : Value.ToString(); +} +#endif diff --git a/src/Polyfills/IsExternalInit.cs b/src/Polyfills/IsExternalInit.cs index 71a91400b..e849affc5 100644 --- a/src/Polyfills/IsExternalInit.cs +++ b/src/Polyfills/IsExternalInit.cs @@ -6,6 +6,6 @@ namespace System.Runtime.CompilerServices; /// Polyfill marker enabling init-only setters on older target frameworks. -[System.Diagnostics.CodeAnalysis.SuppressMessage("RoslynCommonAnalyzers", "SST1436:Add members to a type or remove it", Justification = "Compiler-required marker type; intentionally empty.")] +[Diagnostics.CodeAnalysis.SuppressMessage("RoslynCommonAnalyzers", "SST1436:Add members to a type or remove it", Justification = "Compiler-required marker type; intentionally empty.")] internal static class IsExternalInit; #endif diff --git a/src/Polyfills/NotNullAttribute.cs b/src/Polyfills/NotNullAttribute.cs new file mode 100644 index 000000000..0f5090435 --- /dev/null +++ b/src/Polyfills/NotNullAttribute.cs @@ -0,0 +1,15 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +#if !NETCOREAPP3_0_OR_GREATER && !NETSTANDARD2_1_OR_GREATER +using System.Diagnostics; + +namespace System.Diagnostics.CodeAnalysis; + +/// Specifies that an input value is not null after the method returns. +[ExcludeFromCodeCoverage] +[DebuggerNonUserCode] +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.ReturnValue)] +internal sealed class NotNullAttribute : Attribute; +#endif diff --git a/src/Polyfills/Range.cs b/src/Polyfills/Range.cs new file mode 100644 index 000000000..4273b4609 --- /dev/null +++ b/src/Polyfills/Range.cs @@ -0,0 +1,81 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +#if NETFRAMEWORK +namespace System; + +/// Minimal polyfill for System.Range so C# range syntax compiles on .NET Framework targets. +/// +/// This is a compiler hook for older target frameworks. It intentionally mirrors the BCL member names used +/// by range lowering and should not grow into a general-purpose replacement for the framework type. +/// +[Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA2225:Operator overloads have named alternates", + Justification = "Compiler-required polyfill matching the BCL System.Range shape for C# range syntax.")] +internal readonly struct Range : IEquatable +{ + /// Initializes a new instance of the struct. + /// The inclusive start index. + /// The exclusive end index. + public Range(Index start, Index end) + { + Start = start; + End = end; + } + + /// Gets a that represents the entire sequence. + public static Range All => new(Index.Start, Index.End); + + /// Gets the inclusive start index. + public Index Start { get; } + + /// Gets the exclusive end index. + public Index End { get; } + + /// Creates a that starts at the specified index and ends at . + /// The inclusive start index. + /// The created . + public static Range StartAt(Index start) => new(start, Index.End); + + /// Creates a that starts at and ends at the specified index. + /// The exclusive end index. + /// The created . + public static Range EndAt(Index end) => new(Index.Start, end); + + /// + public static bool operator ==(Range left, Range right) => left.Equals(right); + + /// + public static bool operator !=(Range left, Range right) => !left.Equals(right); + + /// Calculates the start offset and length for a sequence of the given length. + /// The sequence length. + /// The offset and length represented by the range. + public (int Offset, int Length) GetOffsetAndLength(int length) + { + var start = Start.GetOffset(length); + var end = End.GetOffset(length); + + if ((uint)end > (uint)length || (uint)start > (uint)end) + { + throw new ArgumentOutOfRangeException(nameof(length)); + } + + return (start, end - start); + } + + /// + public bool Equals(Range other) => Start.Equals(other.Start) && End.Equals(other.End); + + /// + public override bool Equals(object? obj) => obj is Range other && Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(Start, End); + + /// + public override string ToString() => Start.ToString() + ".." + End.ToString(); +} +#endif diff --git a/src/Polyfills/UnconditionalSuppressMessageAttribute.cs b/src/Polyfills/UnconditionalSuppressMessageAttribute.cs new file mode 100644 index 000000000..e9a6712ec --- /dev/null +++ b/src/Polyfills/UnconditionalSuppressMessageAttribute.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +#if !NET5_0_OR_GREATER +namespace System.Diagnostics.CodeAnalysis; + +/// Polyfill of the attribute that suppresses analysis diagnostics independently of build configuration. +/// The category for the suppressed diagnostic. +/// The identifier for the suppressed diagnostic. +[AttributeUsage( + AttributeTargets.All, + AllowMultiple = true, + Inherited = false)] +internal sealed class UnconditionalSuppressMessageAttribute(string category, string checkId) : Attribute +{ + /// Gets the category for the suppressed diagnostic. + public string Category { get; } = category; + + /// Gets the identifier for the suppressed diagnostic. + public string CheckId { get; } = checkId; + + /// Gets or sets the suppression justification. + public string? Justification { get; set; } + + /// Gets or sets the diagnostic scope. + public string? Scope { get; set; } + + /// Gets or sets the target covered by this suppression. + public string? Target { get; set; } +} +#endif diff --git a/src/Refit.HttpClientFactory/HttpClientFactoryCore.cs b/src/Refit.HttpClientFactory/HttpClientFactoryCore.cs index ad6ea0b53..11849ed22 100644 --- a/src/Refit.HttpClientFactory/HttpClientFactoryCore.cs +++ b/src/Refit.HttpClientFactory/HttpClientFactoryCore.cs @@ -35,15 +35,9 @@ internal static IHttpClientBuilder AddRefitClientCore( Func? settings, string? httpClientName) { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); // register settings var settingsType = typeof(SettingsFor<>).MakeGenericType(refitInterfaceType); @@ -103,10 +97,7 @@ internal static IHttpClientBuilder AddRefitClientCore( string? httpClientName) where T : class { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); // register settings services.AddSingleton(provider => new SettingsFor(settings?.Invoke(provider))); @@ -147,20 +138,11 @@ internal static IHttpClientBuilder AddKeyedRefitClientCore( Func? settings, string? httpClientName) { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(serviceKey); // register settings var settingsType = typeof(SettingsFor<>).MakeGenericType(refitInterfaceType); @@ -224,15 +206,9 @@ internal static IHttpClientBuilder AddKeyedRefitClientCore( string? httpClientName) where T : class { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(serviceKey); // register settings services.AddKeyedSingleton( @@ -274,9 +250,34 @@ internal static IHttpClientBuilder AddKeyedRefitClientCore( [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] [RequiresDynamicCode(RequiresDynamicCodeMessage)] private static MethodInfo GetRequestBuilderGenericForTypeMethod() => - _requestBuilderGenericForTypeMethod ??= typeof(RequestBuilder) - .GetMethods(BindingFlags.Public | BindingFlags.Static) - .Single(z => z.IsGenericMethodDefinition && z.GetParameters().Length == 1); + _requestBuilderGenericForTypeMethod ??= FindRequestBuilderGenericForTypeMethod(); + + /// Finds the open generic method. + /// The matching method definition. + [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(RequiresDynamicCodeMessage)] + private static MethodInfo FindRequestBuilderGenericForTypeMethod() + { + var methods = typeof(RequestBuilder).GetMethods(BindingFlags.Public | BindingFlags.Static); + MethodInfo? match = null; + for (var i = 0; i < methods.Length; i++) + { + var method = methods[i]; + if (!method.IsGenericMethodDefinition || method.GetParameters().Length != 1) + { + continue; + } + + if (match is not null) + { + throw new InvalidOperationException("Sequence contains more than one matching element"); + } + + match = method; + } + + return match ?? throw new InvalidOperationException("Sequence contains no matching element"); + } /// Configures the primary and authorization handlers for a keyed Refit client from its settings. /// The HTTP client builder to configure. diff --git a/src/Refit.HttpClientFactory/HttpClientFactoryExtensions.HttpClientBuilder.cs b/src/Refit.HttpClientFactory/HttpClientFactoryExtensions.HttpClientBuilder.cs index 3fc4ce7ea..605fbd102 100644 --- a/src/Refit.HttpClientFactory/HttpClientFactoryExtensions.HttpClientBuilder.cs +++ b/src/Refit.HttpClientFactory/HttpClientFactoryExtensions.HttpClientBuilder.cs @@ -20,15 +20,9 @@ public static partial class HttpClientFactoryExtensions [RequiresDynamicCode(RequiresDynamicCodeMessage)] public IHttpClientBuilder AddRefitClient(Type refitInterfaceType) { - if (builder is null) - { - throw new ArgumentNullException(nameof(builder)); - } + ArgumentExceptionHelper.ThrowIfNull(builder); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); return HttpClientFactoryCore.AddRefitClientCore( builder.Services, @@ -47,15 +41,9 @@ public IHttpClientBuilder AddRefitClient( Type refitInterfaceType, RefitSettings? settings) { - if (builder is null) - { - throw new ArgumentNullException(nameof(builder)); - } + ArgumentExceptionHelper.ThrowIfNull(builder); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); return HttpClientFactoryCore.AddRefitClientCore( builder.Services, @@ -77,15 +65,9 @@ public IHttpClientBuilder AddRefitClient( Type refitInterfaceType, Func? settingsAction) { - if (builder is null) - { - throw new ArgumentNullException(nameof(builder)); - } + ArgumentExceptionHelper.ThrowIfNull(builder); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); return HttpClientFactoryCore.AddRefitClientCore( builder.Services, @@ -103,10 +85,7 @@ public IHttpClientBuilder AddRefitClient( public IHttpClientBuilder AddRefitClient() where T : class { - if (builder is null) - { - throw new ArgumentNullException(nameof(builder)); - } + ArgumentExceptionHelper.ThrowIfNull(builder); return HttpClientFactoryCore.AddRefitClientCore( builder.Services, @@ -124,10 +103,7 @@ public IHttpClientBuilder AddRefitClient() public IHttpClientBuilder AddRefitClient(RefitSettings? settings) where T : class { - if (builder is null) - { - throw new ArgumentNullException(nameof(builder)); - } + ArgumentExceptionHelper.ThrowIfNull(builder); return HttpClientFactoryCore.AddRefitClientCore( builder.Services, @@ -148,10 +124,7 @@ public IHttpClientBuilder AddRefitClient(RefitSettings? settings) public IHttpClientBuilder AddRefitClient(Func? settingsAction) where T : class { - if (builder is null) - { - throw new ArgumentNullException(nameof(builder)); - } + ArgumentExceptionHelper.ThrowIfNull(builder); return HttpClientFactoryCore.AddRefitClientCore( builder.Services, @@ -169,20 +142,11 @@ public IHttpClientBuilder AddKeyedRefitClient( Type refitInterfaceType, object? serviceKey) { - if (builder is null) - { - throw new ArgumentNullException(nameof(builder)); - } + ArgumentExceptionHelper.ThrowIfNull(builder); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(serviceKey); return HttpClientFactoryCore.AddKeyedRefitClientCore( builder.Services, @@ -204,20 +168,11 @@ public IHttpClientBuilder AddKeyedRefitClient( object? serviceKey, RefitSettings? settings) { - if (builder is null) - { - throw new ArgumentNullException(nameof(builder)); - } + ArgumentExceptionHelper.ThrowIfNull(builder); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(serviceKey); return HttpClientFactoryCore.AddKeyedRefitClientCore( builder.Services, @@ -242,20 +197,11 @@ public IHttpClientBuilder AddKeyedRefitClient( object? serviceKey, Func? settingsAction) { - if (builder is null) - { - throw new ArgumentNullException(nameof(builder)); - } + ArgumentExceptionHelper.ThrowIfNull(builder); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(serviceKey); return HttpClientFactoryCore.AddKeyedRefitClientCore( builder.Services, @@ -275,15 +221,9 @@ public IHttpClientBuilder AddKeyedRefitClient( public IHttpClientBuilder AddKeyedRefitClient(object? serviceKey) where T : class { - if (builder is null) - { - throw new ArgumentNullException(nameof(builder)); - } + ArgumentExceptionHelper.ThrowIfNull(builder); - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(serviceKey); return HttpClientFactoryCore.AddKeyedRefitClientCore( builder.Services, @@ -305,15 +245,9 @@ public IHttpClientBuilder AddKeyedRefitClient( RefitSettings? settings) where T : class { - if (builder is null) - { - throw new ArgumentNullException(nameof(builder)); - } + ArgumentExceptionHelper.ThrowIfNull(builder); - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(serviceKey); return HttpClientFactoryCore.AddKeyedRefitClientCore( builder.Services, @@ -338,15 +272,9 @@ public IHttpClientBuilder AddKeyedRefitClient( Func? settingsAction) where T : class { - if (builder is null) - { - throw new ArgumentNullException(nameof(builder)); - } - - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(builder); + + ArgumentExceptionHelper.ThrowIfNull(serviceKey); return HttpClientFactoryCore.AddKeyedRefitClientCore( builder.Services, diff --git a/src/Refit.HttpClientFactory/HttpClientFactoryExtensions.ServiceCollection.cs b/src/Refit.HttpClientFactory/HttpClientFactoryExtensions.ServiceCollection.cs index 0445fc86c..f94513c03 100644 --- a/src/Refit.HttpClientFactory/HttpClientFactoryExtensions.ServiceCollection.cs +++ b/src/Refit.HttpClientFactory/HttpClientFactoryExtensions.ServiceCollection.cs @@ -28,15 +28,9 @@ public static partial class HttpClientFactoryExtensions [RequiresDynamicCode(RequiresDynamicCodeMessage)] public IHttpClientBuilder AddRefitClient(Type refitInterfaceType) { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); return HttpClientFactoryCore.AddRefitClientCore( services, @@ -55,15 +49,9 @@ public IHttpClientBuilder AddRefitClient( Type refitInterfaceType, RefitSettings? settings) { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); return HttpClientFactoryCore.AddRefitClientCore( services, @@ -84,15 +72,9 @@ public IHttpClientBuilder AddRefitClient( RefitSettings? settings, string? httpClientName) { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); return HttpClientFactoryCore.AddRefitClientCore( services, @@ -114,15 +96,9 @@ public IHttpClientBuilder AddRefitClient( Type refitInterfaceType, Func? settingsAction) { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); return HttpClientFactoryCore.AddRefitClientCore( services, @@ -146,15 +122,9 @@ public IHttpClientBuilder AddRefitClient( Func? settingsAction, string? httpClientName) { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); return HttpClientFactoryCore.AddRefitClientCore( services, @@ -172,10 +142,7 @@ public IHttpClientBuilder AddRefitClient( public IHttpClientBuilder AddRefitClient() where T : class { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); return HttpClientFactoryCore.AddRefitClientCore( services, @@ -193,10 +160,7 @@ public IHttpClientBuilder AddRefitClient() public IHttpClientBuilder AddRefitClient(RefitSettings? settings) where T : class { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); return HttpClientFactoryCore.AddRefitClientCore( services, @@ -217,10 +181,7 @@ public IHttpClientBuilder AddRefitClient( string? httpClientName) where T : class { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); return HttpClientFactoryCore.AddRefitClientCore( services, @@ -241,10 +202,7 @@ public IHttpClientBuilder AddRefitClient( public IHttpClientBuilder AddRefitClient(Func? settingsAction) where T : class { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); return HttpClientFactoryCore.AddRefitClientCore( services, @@ -268,10 +226,7 @@ public IHttpClientBuilder AddRefitClient( string? httpClientName) where T : class { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); return HttpClientFactoryCore.AddRefitClientCore( services, @@ -289,20 +244,11 @@ public IHttpClientBuilder AddKeyedRefitClient( Type refitInterfaceType, object? serviceKey) { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(serviceKey); return HttpClientFactoryCore.AddKeyedRefitClientCore( services, @@ -324,20 +270,11 @@ public IHttpClientBuilder AddKeyedRefitClient( object? serviceKey, RefitSettings? settings) { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(serviceKey); return HttpClientFactoryCore.AddKeyedRefitClientCore( services, @@ -361,20 +298,11 @@ public IHttpClientBuilder AddKeyedRefitClient( RefitSettings? settings, string? httpClientName) { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(serviceKey); return HttpClientFactoryCore.AddKeyedRefitClientCore( services, @@ -399,20 +327,11 @@ public IHttpClientBuilder AddKeyedRefitClient( object? serviceKey, Func? settingsAction) { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(serviceKey); return HttpClientFactoryCore.AddKeyedRefitClientCore( services, @@ -439,20 +358,11 @@ public IHttpClientBuilder AddKeyedRefitClient( Func? settingsAction, string? httpClientName) { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(serviceKey); return HttpClientFactoryCore.AddKeyedRefitClientCore( services, @@ -472,15 +382,9 @@ public IHttpClientBuilder AddKeyedRefitClient( public IHttpClientBuilder AddKeyedRefitClient(object? serviceKey) where T : class { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(serviceKey); return HttpClientFactoryCore.AddKeyedRefitClientCore( services, @@ -502,15 +406,9 @@ public IHttpClientBuilder AddKeyedRefitClient( RefitSettings? settings) where T : class { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(serviceKey); return HttpClientFactoryCore.AddKeyedRefitClientCore( services, @@ -534,15 +432,9 @@ public IHttpClientBuilder AddKeyedRefitClient( string? httpClientName) where T : class { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(serviceKey); return HttpClientFactoryCore.AddKeyedRefitClientCore( services, @@ -567,15 +459,9 @@ public IHttpClientBuilder AddKeyedRefitClient( Func? settingsAction) where T : class { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } + ArgumentExceptionHelper.ThrowIfNull(services); - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(serviceKey); return HttpClientFactoryCore.AddKeyedRefitClientCore( services, @@ -602,15 +488,9 @@ public IHttpClientBuilder AddKeyedRefitClient( string? httpClientName) where T : class { - if (services is null) - { - throw new ArgumentNullException(nameof(services)); - } - - if (serviceKey is null) - { - throw new ArgumentNullException(nameof(serviceKey)); - } + ArgumentExceptionHelper.ThrowIfNull(services); + + ArgumentExceptionHelper.ThrowIfNull(serviceKey); return HttpClientFactoryCore.AddKeyedRefitClientCore( services, diff --git a/src/Refit.HttpClientFactory/Refit.HttpClientFactory.csproj b/src/Refit.HttpClientFactory/Refit.HttpClientFactory.csproj index b6fef775e..064e3b41c 100644 --- a/src/Refit.HttpClientFactory/Refit.HttpClientFactory.csproj +++ b/src/Refit.HttpClientFactory/Refit.HttpClientFactory.csproj @@ -1,7 +1,8 @@ - + Refit HTTP Client Factory Extensions - Refit HTTP Client Factory Extensions + Microsoft.Extensions.Http integration for Refit, adding AddRefitClient support for typed HttpClientFactory clients, dependency injection, handlers, and Polly-style HTTP pipelines. + refit;rest;http;httpclient;httpclientfactory;ihttpclientfactory;dependency-injection;microsoft-extensions-http;typed-client;api-client $(RefitTargets) enable true @@ -15,4 +16,4 @@ - \ No newline at end of file + diff --git a/src/Refit.NativeAotSmoke/Refit.NativeAotSmoke.csproj b/src/Refit.NativeAotSmoke/Refit.NativeAotSmoke.csproj index 2fdf92b28..222a272dd 100644 --- a/src/Refit.NativeAotSmoke/Refit.NativeAotSmoke.csproj +++ b/src/Refit.NativeAotSmoke/Refit.NativeAotSmoke.csproj @@ -1,4 +1,4 @@ - + Exe net8.0 diff --git a/src/Refit.Newtonsoft.Json/NewtonsoftJsonContentSerializer.cs b/src/Refit.Newtonsoft.Json/NewtonsoftJsonContentSerializer.cs index e2df8f4a7..2a89a473e 100644 --- a/src/Refit.Newtonsoft.Json/NewtonsoftJsonContentSerializer.cs +++ b/src/Refit.Newtonsoft.Json/NewtonsoftJsonContentSerializer.cs @@ -33,10 +33,6 @@ public NewtonsoftJsonContentSerializer() } /// -#if !NET8_0_OR_GREATER - [RequiresUnreferencedCode("Refit's Newtonsoft.Json serialization uses reflection that trimming cannot statically preserve. Use the Refit source generator for trimmed/AOT apps.")] - [RequiresDynamicCode("Refit's Newtonsoft.Json serialization may generate code dynamically for runtime types. Use the Refit source generator for AOT apps.")] -#else [UnconditionalSuppressMessage( "Trimming", "IL2026:Members annotated with RequiresUnreferencedCodeAttribute may break when trimming", @@ -45,7 +41,6 @@ public NewtonsoftJsonContentSerializer() "AOT", "IL3050:Calling members annotated with RequiresDynamicCodeAttribute may break when AOT compiling", Justification = "Interface method is unannotated on net8.0+ so cannot propagate; Newtonsoft path is documented as unsuitable for trimmed/AOT apps.")] -#endif public HttpContent ToHttpContent(T item) { return new StringContent( @@ -55,10 +50,6 @@ public HttpContent ToHttpContent(T item) } /// -#if !NET8_0_OR_GREATER - [RequiresUnreferencedCode("Refit's Newtonsoft.Json serialization uses reflection that trimming cannot statically preserve. Use the Refit source generator for trimmed/AOT apps.")] - [RequiresDynamicCode("Refit's Newtonsoft.Json serialization may generate code dynamically for runtime types. Use the Refit source generator for AOT apps.")] -#else [UnconditionalSuppressMessage( "Trimming", "IL2026:Members annotated with RequiresUnreferencedCodeAttribute may break when trimming", @@ -67,7 +58,6 @@ public HttpContent ToHttpContent(T item) "AOT", "IL3050:Calling members annotated with RequiresDynamicCodeAttribute may break when AOT compiling", Justification = "Interface method is unannotated on net8.0+ so cannot propagate; Newtonsoft path is documented as unsuitable for trimmed/AOT apps.")] -#endif [SuppressMessage( "Major Code Smell", "S4018:Generic methods should provide type parameters", @@ -114,16 +104,11 @@ public HttpContent ToHttpContent(T item) /// The calculated field name. /// /// propertyInfo - public string? GetFieldNameForProperty(PropertyInfo propertyInfo) => - propertyInfo switch - { - null => throw new ArgumentNullException(nameof(propertyInfo)), - _ - => propertyInfo - .GetCustomAttributes(true) - .Select(a => a.PropertyName) - .FirstOrDefault() - }; + public string? GetFieldNameForProperty(PropertyInfo propertyInfo) + { + ArgumentExceptionHelper.ThrowIfNull(propertyInfo); + return propertyInfo.GetCustomAttribute(true)?.PropertyName; + } /// Resolves the text encoding from the content type charset, if present. /// The HTTP content to inspect. diff --git a/src/Refit.Newtonsoft.Json/Refit.Newtonsoft.Json.csproj b/src/Refit.Newtonsoft.Json/Refit.Newtonsoft.Json.csproj index 39fddc8c7..1d7494801 100644 --- a/src/Refit.Newtonsoft.Json/Refit.Newtonsoft.Json.csproj +++ b/src/Refit.Newtonsoft.Json/Refit.Newtonsoft.Json.csproj @@ -1,8 +1,9 @@ - + Refit Serializer for Newtonsoft.Json ($(TargetFramework)) - Refit Serializers for Newtonsoft.Json + Newtonsoft.Json content serializer for Refit clients that need Json.NET converters, settings, or compatibility behavior instead of the default System.Text.Json serializer. + refit;rest;http;api-client;json;newtonsoft-json;json-net;serializer;content-serializer $(RefitShippingTargets) true diff --git a/src/Refit.Xml/Refit.Xml.csproj b/src/Refit.Xml/Refit.Xml.csproj index 3d7acbc4f..2bf58b159 100644 --- a/src/Refit.Xml/Refit.Xml.csproj +++ b/src/Refit.Xml/Refit.Xml.csproj @@ -1,7 +1,8 @@ - + Refit Xml Serializer ($(TargetFramework)) - Refit Serializers for Xml + XML content serializer for Refit clients that send or receive XML payloads through System.Xml.Serialization.XmlSerializer. + refit;rest;http;api-client;xml;xmlserializer;serializer;content-serializer $(RefitShippingTargets) true @@ -15,4 +16,4 @@ - \ No newline at end of file + diff --git a/src/Refit.Xml/XmlContentSerializer.cs b/src/Refit.Xml/XmlContentSerializer.cs index 7c8571919..5aa679d40 100644 --- a/src/Refit.Xml/XmlContentSerializer.cs +++ b/src/Refit.Xml/XmlContentSerializer.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for full license information. using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Headers; using System.Reflection; using System.Text; using System.Xml; @@ -14,11 +15,8 @@ namespace Refit; /// /// Initializes a new instance of the class. /// -/// The settings. -/// settings -public class XmlContentSerializer(XmlContentSerializerSettings settings) : IHttpContentSerializer +public class XmlContentSerializer : IHttpContentSerializer { -#if NET8_0_OR_GREATER /// Explains why the trimming warning is suppressed for XML reflection. private const string XmlReflectionTrimmingJustification = "Refit's XML serialization uses System.Xml.Serialization reflection that trimming cannot statically preserve. Use the Refit source generator for trimmed/AOT apps."; @@ -26,15 +24,22 @@ public class XmlContentSerializer(XmlContentSerializerSettings settings) : IHttp /// Explains why the AOT warning is suppressed for XML reflection. private const string XmlReflectionAotJustification = "Refit's XML serialization may generate serialization assemblies at runtime. Use the Refit source generator for AOT apps."; -#endif /// The settings controlling XML serialization. - private readonly XmlContentSerializerSettings _settings = - settings ?? throw new ArgumentNullException(nameof(settings)); + private readonly XmlContentSerializerSettings _settings; /// Caches XML serializers keyed by the serialized type. private readonly ConcurrentDictionary _serializerCache = new(); + /// Initializes a new instance of the class. + /// The settings. + /// settings + public XmlContentSerializer(XmlContentSerializerSettings settings) + { + ArgumentExceptionHelper.ThrowIfNull(settings); + _settings = settings; + } + /// Initializes a new instance of the class. public XmlContentSerializer() : this(new()) @@ -46,20 +51,15 @@ public XmlContentSerializer() /// Object to serialize. /// that contains the serialized object in Xml. /// Thrown when is . -#if NET8_0_OR_GREATER [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = XmlReflectionTrimmingJustification)] [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = XmlReflectionAotJustification)] -#endif public HttpContent ToHttpContent(T item) { - if (item is null) - { - throw new ArgumentNullException(nameof(item)); - } + ArgumentExceptionHelper.ThrowIfNull(item); var xmlSerializer = _serializerCache.GetOrAdd( item.GetType(), - t => new XmlSerializer(t, _settings.XmlAttributeOverrides)); + t => new(t, _settings.XmlAttributeOverrides)); using var stream = new MemoryStream(); using var writer = XmlWriter.Create( @@ -68,8 +68,11 @@ public HttpContent ToHttpContent(T item) var encoding = _settings.XmlReaderWriterSettings.WriterSettings?.Encoding ?? Encoding.Unicode; xmlSerializer.Serialize(writer, item, _settings.XmlNamespaces); - var str = encoding.GetString(stream.ToArray()); - return new StringContent(str, encoding, "application/xml"); + writer.Flush(); + + var content = new ByteArrayContent(stream.GetBuffer(), 0, (int)stream.Length); + content.Headers.ContentType = new("application/xml") { CharSet = encoding.WebName }; + return content; } /// Deserializes an object of type from a object that contains Xml content. @@ -77,10 +80,8 @@ public HttpContent ToHttpContent(T item) /// HttpContent object with Xml content to deserialize. /// CancellationToken to abort the deserialization. /// The deserialized object of type . -#if NET8_0_OR_GREATER [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = XmlReflectionTrimmingJustification)] [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = XmlReflectionAotJustification)] -#endif [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter selected explicitly by callers.")] public async Task FromHttpContentAsync( HttpContent content, @@ -89,7 +90,7 @@ public HttpContent ToHttpContent(T item) var xmlSerializer = _serializerCache.GetOrAdd( typeof(T), t => - new XmlSerializer( + new( t, _settings.XmlAttributeOverrides, [], @@ -108,18 +109,9 @@ public HttpContent ToHttpContent(T item) /// public string? GetFieldNameForProperty(PropertyInfo propertyInfo) { - if (propertyInfo is null) - { - throw new ArgumentNullException(nameof(propertyInfo)); - } - - return propertyInfo - .GetCustomAttributes(true) - .Select(a => a.ElementName) - .FirstOrDefault() - ?? propertyInfo - .GetCustomAttributes(true) - .Select(a => a.AttributeName) - .FirstOrDefault(); + ArgumentExceptionHelper.ThrowIfNull(propertyInfo); + + return propertyInfo.GetCustomAttribute(true)?.ElementName + ?? propertyInfo.GetCustomAttribute(true)?.AttributeName; } } diff --git a/src/Refit.Xml/XmlContentSerializerSettings.cs b/src/Refit.Xml/XmlContentSerializerSettings.cs index d5469b87e..bc4173484 100644 --- a/src/Refit.Xml/XmlContentSerializerSettings.cs +++ b/src/Refit.Xml/XmlContentSerializerSettings.cs @@ -1,7 +1,6 @@ // Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Xml; using System.Xml.Serialization; namespace Refit; @@ -14,10 +13,10 @@ public XmlContentSerializerSettings() { XmlDefaultNamespace = null; XmlReaderWriterSettings = new(); - XmlNamespaces = new XmlSerializerNamespaces( - [new XmlQualifiedName(string.Empty, string.Empty)]); + XmlNamespaces = new( + [new(string.Empty, string.Empty)]); - XmlAttributeOverrides = new XmlAttributeOverrides(); + XmlAttributeOverrides = new(); } /// Gets or sets the XML default namespace. diff --git a/src/Refit.Xml/XmlReaderWriterSettings.cs b/src/Refit.Xml/XmlReaderWriterSettings.cs index 93d70b005..3c389b7c3 100644 --- a/src/Refit.Xml/XmlReaderWriterSettings.cs +++ b/src/Refit.Xml/XmlReaderWriterSettings.cs @@ -16,21 +16,21 @@ public class XmlReaderWriterSettings /// Initializes a new instance of the class. public XmlReaderWriterSettings() - : this(new XmlReaderSettings(), new XmlWriterSettings()) + : this(new(), new()) { } /// Initializes a new instance of the class. /// The reader settings. public XmlReaderWriterSettings(XmlReaderSettings readerSettings) - : this(readerSettings, new XmlWriterSettings()) + : this(readerSettings, new()) { } /// Initializes a new instance of the class. /// The writer settings. public XmlReaderWriterSettings(XmlWriterSettings writerSettings) - : this(new XmlReaderSettings(), writerSettings) + : this(new(), writerSettings) { } @@ -59,7 +59,11 @@ public XmlReaderSettings ReaderSettings ApplyOverrideSettings(); return _readerSettings; } - set => _readerSettings = value ?? throw new ArgumentNullException(nameof(value)); + set + { + ArgumentExceptionHelper.ThrowIfNull(value); + _readerSettings = value; + } } /// Gets or sets the writer settings. @@ -74,7 +78,11 @@ public XmlWriterSettings WriterSettings ApplyOverrideSettings(); return _writerSettings; } - set => _writerSettings = value ?? throw new ArgumentNullException(nameof(value)); + set + { + ArgumentExceptionHelper.ThrowIfNull(value); + _writerSettings = value; + } } /// diff --git a/src/Refit.slnx b/src/Refit.slnx index abf8c2ae4..515cbf289 100644 --- a/src/Refit.slnx +++ b/src/Refit.slnx @@ -38,7 +38,6 @@ - diff --git a/src/Refit/ApiException.cs b/src/Refit/ApiException.cs index e1c8f3e99..eea81467f 100644 --- a/src/Refit/ApiException.cs +++ b/src/Refit/ApiException.cs @@ -234,10 +234,6 @@ public static Task Create( "Major Code Smell", "CA1031:Do not catch general exception types", Justification = "Best-effort content read while already handling an error; any failure must not hide the original error.")] - [SuppressMessage( - "Minor Code Smell", - "SST1429:Do not use an empty catch of the base exception", - Justification = "Best-effort content read while already handling an error; any failure must not hide the original error.")] public static async Task Create( string exceptionMessage, HttpRequestMessage message, @@ -286,8 +282,10 @@ public static async Task Create( response.Content.Dispose(); } - catch + catch (Exception readException) { + _ = readException; + // NB: We're already handling an exception at this point, // so we want to make sure we don't throw another one // that hides the real error. diff --git a/src/Refit/ApiResponse{T}.cs b/src/Refit/ApiResponse{T}.cs index d47b82cc2..10e26de99 100644 --- a/src/Refit/ApiResponse{T}.cs +++ b/src/Refit/ApiResponse{T}.cs @@ -129,28 +129,22 @@ public ApiResponse( /// The current /// Thrown when an unsuccessful response was received from the server. /// Thrown when the request failed before receiving a response from the server. - public async Task> EnsureSuccessStatusCodeAsync() + public Task> EnsureSuccessStatusCodeAsync() { - if (!IsSuccessStatusCode) - { - await ThrowsApiExceptionAsync().ConfigureAwait(false); - } - - return this; + return IsSuccessStatusCode + ? Task.FromResult(this) + : EnsureSlowAsync(); } /// Ensures the request was successful and without any other error by throwing an exception in case of failure. /// The current /// Thrown when an unsuccessful response was received from the server. /// Thrown when the request failed before receiving a response from the server. - public async Task> EnsureSuccessfulAsync() + public Task> EnsureSuccessfulAsync() { - if (!IsSuccessful) - { - await ThrowsApiExceptionAsync().ConfigureAwait(false); - } - - return this; + return IsSuccessful + ? Task.FromResult(this) + : EnsureSlowAsync(); } /// @@ -192,9 +186,13 @@ private void Dispose(bool disposing) response?.Dispose(); } + /// Throws the appropriate API exception for an unsuccessful response. + /// A task that represents the asynchronous validation operation. + private Task> EnsureSlowAsync() => ThrowsApiExceptionAsync(); + /// Throws the appropriate API exception for an unsuccessful response. /// A task that represents the asynchronous throw operation. - private async Task ThrowsApiExceptionAsync() + private async Task> ThrowsApiExceptionAsync() { var responseMessage = response ?? throw new InvalidOperationException( diff --git a/src/Refit/Buffers/PooledBufferWriter.Stream.NETStandard21.cs b/src/Refit/Buffers/PooledBufferWriter.Stream.NETStandard21.cs index 392e33336..f4ca6b5aa 100644 --- a/src/Refit/Buffers/PooledBufferWriter.Stream.NETStandard21.cs +++ b/src/Refit/Buffers/PooledBufferWriter.Stream.NETStandard21.cs @@ -62,10 +62,6 @@ public override ValueTask ReadAsync( return new(result); } - catch (OperationCanceledException e) - { - return new(Task.FromCanceled(e.CancellationToken)); - } catch (Exception e) { return new(Task.FromException(e)); @@ -91,9 +87,9 @@ public override int Read(Span buffer) var bytesCopied = Math.Min(source.Length, buffer.Length); - var destination = buffer.Slice(0, bytesCopied); + var destination = buffer[..bytesCopied]; - source.CopyTo(destination); + source[..bytesCopied].CopyTo(destination); _position += bytesCopied; diff --git a/src/Refit/Buffers/PooledBufferWriter.Stream.cs b/src/Refit/Buffers/PooledBufferWriter.Stream.cs index 09ecdd0b8..72d244a5e 100644 --- a/src/Refit/Buffers/PooledBufferWriter.Stream.cs +++ b/src/Refit/Buffers/PooledBufferWriter.Stream.cs @@ -37,6 +37,7 @@ public PooledMemoryStream(PooledBufferWriter writer) } /// Finalizes an instance of the class. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] ~PooledMemoryStream() => Dispose(false); /// @@ -140,7 +141,7 @@ public override int Read(byte[] buffer, int offset, int count) } var destination = buffer.AsSpan(offset, count); - var source = _pooledBuffer.AsSpan(0, _length).Slice(_position); + var source = _pooledBuffer.AsSpan(0, _length)[_position..]; // If the source is contained within the destination, copy the entire span if (source.Length <= destination.Length) @@ -153,7 +154,7 @@ public override int Read(byte[] buffer, int offset, int count) } // Resize the source slice and only copy the overlapping region - source.Slice(0, destination.Length).CopyTo(destination); + source[..destination.Length].CopyTo(destination); _position += destination.Length; @@ -178,10 +179,6 @@ public override Task ReadAsync( return Task.FromResult(result); } - catch (OperationCanceledException e) - { - return Task.FromCanceled(e.CancellationToken); - } catch (Exception e) { return Task.FromException(e); @@ -191,17 +188,18 @@ public override Task ReadAsync( /// public override int ReadByte() { - if (_pooledBuffer is null) + var pooledBuffer = _pooledBuffer; + if (pooledBuffer is null) { ThrowObjectDisposedException(); } - if (_position >= _pooledBuffer!.Length) + if (_position >= _length) { return -1; } - return _pooledBuffer[_position++]; + return pooledBuffer![_position++]; } /// diff --git a/src/Refit/CachedRequestBuilderImplementation.cs b/src/Refit/CachedRequestBuilderImplementation.cs index 2c907fd10..537e4a4e2 100644 --- a/src/Refit/CachedRequestBuilderImplementation.cs +++ b/src/Refit/CachedRequestBuilderImplementation.cs @@ -18,10 +18,13 @@ internal class CachedRequestBuilderImplementation : IRequestBuilder /// The request builder whose results are cached. public CachedRequestBuilderImplementation(IRequestBuilder innerBuilder) { - _innerBuilder = - innerBuilder ?? throw new ArgumentNullException(nameof(innerBuilder)); + ArgumentExceptionHelper.ThrowIfNull(innerBuilder); + _innerBuilder = innerBuilder; } + /// + public RefitSettings Settings => _innerBuilder.Settings; + /// Gets the cache of method keys to their built result functions. internal ConcurrentDictionary< MethodTableKey, @@ -48,10 +51,10 @@ internal ConcurrentDictionary< // use GetOrAdd with cloned array method table key. This prevents the array from being modified, breaking the dictionary. return MethodDictionary.GetOrAdd( - new MethodTableKey( + new( methodName, - parameterTypes?.ToArray() ?? [], - genericArgumentTypes?.ToArray() ?? []), + parameterTypes is not null ? (Type[])parameterTypes.Clone() : [], + genericArgumentTypes is not null ? (Type[])genericArgumentTypes.Clone() : []), _ => _innerBuilder.BuildRestResultFuncForMethod( methodName, diff --git a/src/Refit/CamelCaseStringEnumConverter.cs b/src/Refit/CamelCaseStringEnumConverter.cs index eb829a35d..8fd65c281 100644 --- a/src/Refit/CamelCaseStringEnumConverter.cs +++ b/src/Refit/CamelCaseStringEnumConverter.cs @@ -18,7 +18,6 @@ public override bool CanConvert(Type typeToConvert) => (Nullable.GetUnderlyingType(typeToConvert) ?? typeToConvert).IsEnum; /// -#if NET6_0_OR_GREATER [UnconditionalSuppressMessage( "AssemblyLoadTrimming", "IL2026:RequiresUnreferencedCode", @@ -44,7 +43,6 @@ public override bool CanConvert(Type typeToConvert) => Justification = "The enum value type's parameterless constructor is intrinsically preserved; this path is " + "reflection-only and trimmed/AOT apps use the Refit source generator instead.")] -#endif public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) { var underlyingType = Nullable.GetUnderlyingType(typeToConvert); @@ -188,7 +186,7 @@ private static string GetPreferredSerializedName(FieldInfo field) private static string ToCamelCase(string value) => string.IsNullOrEmpty(value) || !char.IsUpper(value[0]) ? value - : char.ToLowerInvariant(value[0]) + value.Substring(1); + : char.ToLowerInvariant(value[0]) + value[1..]; /// Reads an enum value from either a string name or a numeric value. /// The reader positioned on the value to read. diff --git a/src/Refit/CamelCaseUrlParameterKeyFormatter.cs b/src/Refit/CamelCaseUrlParameterKeyFormatter.cs index 7f53b2bf8..8ebc981e4 100644 --- a/src/Refit/CamelCaseUrlParameterKeyFormatter.cs +++ b/src/Refit/CamelCaseUrlParameterKeyFormatter.cs @@ -28,7 +28,7 @@ public string Format(string key) #else char[] chars = key.ToCharArray(); FixCasing(chars); - return new string(chars); + return new(chars); #endif } diff --git a/src/Refit/CloseGenericMethodKey.cs b/src/Refit/CloseGenericMethodKey.cs index fd7641107..d3c1fbe16 100644 --- a/src/Refit/CloseGenericMethodKey.cs +++ b/src/Refit/CloseGenericMethodKey.cs @@ -26,8 +26,23 @@ internal CloseGenericMethodKey(MethodInfo openMethodInfo, Type[] types) /// Determines whether this key equals another key by open method definition and type arguments. /// The key to compare against. /// if the keys are equal; otherwise, . - public bool Equals(CloseGenericMethodKey other) => - OpenMethodInfo == other.OpenMethodInfo && Types.SequenceEqual(other.Types); + public bool Equals(CloseGenericMethodKey other) + { + if (OpenMethodInfo != other.OpenMethodInfo || Types.Length != other.Types.Length) + { + return false; + } + + for (var i = 0; i < Types.Length; i++) + { + if (Types[i] != other.Types[i]) + { + return false; + } + } + + return true; + } /// public override bool Equals(object? obj) @@ -43,16 +58,13 @@ public override bool Equals(object? obj) /// public override int GetHashCode() { - unchecked + HashCode hashCode = default; + hashCode.Add(OpenMethodInfo); + for (var i = 0; i < Types.Length; i++) { - var hash = 17; - hash = (hash * 23) + OpenMethodInfo.GetHashCode(); - foreach (var type in Types) - { - hash = (hash * 23) + type.GetHashCode(); - } - - return hash; + hashCode.Add(Types[i]); } + + return hashCode.ToHashCode(); } } diff --git a/src/Refit/DefaultFormUrlEncodedParameterFormatter.cs b/src/Refit/DefaultFormUrlEncodedParameterFormatter.cs index 438d18048..4487d459c 100644 --- a/src/Refit/DefaultFormUrlEncodedParameterFormatter.cs +++ b/src/Refit/DefaultFormUrlEncodedParameterFormatter.cs @@ -43,10 +43,7 @@ private static readonly ConcurrentDictionary< enumMember = cached.GetOrAdd( value.ToString()!, val => - parameterType - .GetTypeInfo() - .DeclaredFields.FirstOrDefault(field => field.Name == val) - ?.GetCustomAttribute()); + GetEnumField(parameterType, val)?.GetCustomAttribute()); } return string.Format( @@ -54,4 +51,25 @@ private static readonly ConcurrentDictionary< string.IsNullOrWhiteSpace(formatString) ? "{0}" : $"{{0:{formatString}}}", enumMember?.Value ?? value); } + + /// Finds an enum field by name. + /// The enum type to inspect. + /// The field name. + /// The matching field, or null when absent. + [UnconditionalSuppressMessage( + "Trimming", + "IL2070:DynamicallyAccessedMembers", + Justification = "The caller is already annotated with RequiresUnreferencedCode because enum metadata may be trimmed.")] + private static FieldInfo? GetEnumField(Type enumType, string name) + { + foreach (var field in enumType.GetTypeInfo().DeclaredFields) + { + if (field.Name == name) + { + return field; + } + } + + return null; + } } diff --git a/src/Refit/DefaultUrlParameterFormatter.cs b/src/Refit/DefaultUrlParameterFormatter.cs index cbb8c578c..1b340731e 100644 --- a/src/Refit/DefaultUrlParameterFormatter.cs +++ b/src/Refit/DefaultUrlParameterFormatter.cs @@ -55,22 +55,15 @@ public void AddFormat(string format) => ICustomAttributeProvider attributeProvider, Type type) { - if (attributeProvider is null) - { - throw new ArgumentNullException(nameof(attributeProvider)); - } + ArgumentExceptionHelper.ThrowIfNull(attributeProvider); if (value is null) { return null; } - // See if we have a format - var formatString = attributeProvider - .GetCustomAttributes(typeof(QueryAttribute), true) - .OfType() - .FirstOrDefault() - ?.Format; + var queryAttribute = GetFirstQueryAttribute(attributeProvider); + var formatString = queryAttribute?.Format; var parameterType = value.GetType(); var enumMember = ResolveEnumMember(parameterType, value); @@ -100,10 +93,45 @@ public void AddFormat(string format) => return cached.GetOrAdd( value.ToString()!, val => - parameterType - .GetTypeInfo() - .DeclaredFields.FirstOrDefault(field => field.Name == val) - ?.GetCustomAttribute()); + GetEnumField(parameterType, val)?.GetCustomAttribute()); + } + + /// Gets the first query attribute from an attribute provider. + /// The attribute provider to inspect. + /// The first query attribute, or null when absent. + private static QueryAttribute? GetFirstQueryAttribute(ICustomAttributeProvider attributeProvider) + { + var attributes = attributeProvider.GetCustomAttributes(typeof(QueryAttribute), true); + for (var i = 0; i < attributes.Length; i++) + { + if (attributes[i] is QueryAttribute attribute) + { + return attribute; + } + } + + return null; + } + + /// Finds an enum field by name. + /// The enum type to inspect. + /// The field name. + /// The matching field, or null when absent. + [UnconditionalSuppressMessage( + "Trimming", + "IL2070:DynamicallyAccessedMembers", + Justification = "The caller is already annotated with RequiresUnreferencedCode because enum metadata may be trimmed.")] + private static FieldInfo? GetEnumField(Type enumType, string name) + { + foreach (var field in enumType.GetTypeInfo().DeclaredFields) + { + if (field.Name == name) + { + return field; + } + } + + return null; } /// Selects the effective format string, preferring the attribute format, then specific, then general formats. diff --git a/src/Refit/FormValueMultimap.cs b/src/Refit/FormValueMultimap.cs index c5b82002d..a6fbe373d 100644 --- a/src/Refit/FormValueMultimap.cs +++ b/src/Refit/FormValueMultimap.cs @@ -4,6 +4,7 @@ using System.Collections; using System.Diagnostics.CodeAnalysis; using System.Reflection; +using System.Runtime.CompilerServices; namespace Refit; @@ -13,15 +14,17 @@ namespace Refit; /// same or different values. internal sealed class FormValueMultimap : IEnumerable> { - /// Guards access to the shared property cache. -#if NET9_0_OR_GREATER - private static readonly System.Threading.Lock _lock = new(); -#else - private static readonly object _lock = new(); -#endif - - /// Caches the readable public properties for each source type. - private static readonly Dictionary _propertyCache = []; + /// Caches the readable public properties for each source type without keeping collectible types alive. + [SuppressMessage( + "Style", + "IDE0028:Simplify collection initialization", + Justification = "ConditionalWeakTable collection expressions do not compile for all target frameworks.")] + [SuppressMessage( + "Style", + "IDE0090:Simplify new expression", + Justification = "Keeping the explicit type avoids collection-expression suggestions that do not compile for all target frameworks.")] + private static readonly ConditionalWeakTable _propertyCache = + new ConditionalWeakTable(); /// Holds the collected form key/value entries. private readonly List> _formEntries = []; @@ -36,10 +39,7 @@ internal sealed class FormValueMultimap : IEnumerableGets a key for each entry. If multiple entries share the same key, the key is returned multiple times. - public IEnumerable Keys => this.Select(it => it.Key); + public IEnumerable Keys => GetKeys(); /// Returns an enumerator over the form key/value entries. /// An enumerator over the entries. @@ -72,30 +72,44 @@ public FormValueMultimap(object source, RefitSettings settings) /// The readable public properties. private static PropertyInfo[] GetProperties( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] - Type type) => - [ - .. type.GetProperties(BindingFlags.Instance | BindingFlags.Public) - .Where(p => p.CanRead && p.GetMethod?.IsPublic == true)]; - - /// Resolves the cached readable public properties for the given source type. - /// The type to inspect. - /// The cached readable public properties. - [RequiresUnreferencedCode( - "Form URL encoded bodies reflect over runtime object properties and serializer metadata.")] - private static PropertyInfo[] GetCachedProperties(Type type) + Type type) { - lock (_lock) + var properties = type.GetProperties(BindingFlags.Instance | BindingFlags.Public); + var count = 0; + for (var i = 0; i < properties.Length; i++) { - if (!_propertyCache.TryGetValue(type, out var properties)) + if (IsReadablePublicProperty(properties[i])) { - properties = GetProperties(type); - _propertyCache[type] = properties; + count++; } + } + if (count == properties.Length) + { return properties; } + + var readableProperties = new PropertyInfo[count]; + var index = 0; + for (var i = 0; i < properties.Length; i++) + { + if (IsReadablePublicProperty(properties[i])) + { + readableProperties[index++] = properties[i]; + } + } + + return readableProperties; } + /// Resolves the cached readable public properties for the given source type. + /// The type to inspect. + /// The cached readable public properties. + [RequiresUnreferencedCode( + "Form URL encoded bodies reflect over runtime object properties and serializer metadata.")] + private static PropertyInfo[] GetCachedProperties(Type type) + => _propertyCache.GetValue(type, GetProperties); + /// Gets the delimiter string for a delimited collection format. /// The delimited collection format. /// The delimiter string. @@ -108,6 +122,54 @@ private static string GetDelimiter(CollectionFormat collectionFormat) => _ => "|" }; + /// Determines whether a property can be read through its public getter. + /// The property to inspect. + /// when the property is readable; otherwise . + private static bool IsReadablePublicProperty(PropertyInfo property) => + property.CanRead && property.GetMethod?.IsPublic == true; + + /// Formats and joins a collection-valued form field without LINQ adapters. + /// The collection to format. + /// The delimiter between formatted values. + /// The query attribute, if any. + /// The Refit settings controlling formatting. + /// The joined formatted value. + [RequiresUnreferencedCode( + "Form URL encoded value formatting may reflect over runtime enum metadata; use the Refit source generator for trimmed/AOT apps.")] + [SuppressMessage( + "Major Code Smell", + "S2930:\"IDisposables\" should be disposed", + Justification = "ValueStringBuilder.ToString() disposes the builder and returns its pooled buffer; Dispose is idempotent.")] + private static string JoinFormattedValues( + IEnumerable enumerable, + string delimiter, + QueryAttribute? attrib, + RefitSettings settings) + { + var enumerator = enumerable.GetEnumerator(); + try + { + if (!enumerator.MoveNext()) + { + return string.Empty; + } + + var builder = new ValueStringBuilder(stackalloc char[256]); + builder.Append(settings.FormUrlEncodedParameterFormatter.Format(enumerator.Current, attrib?.Format)); + while (enumerator.MoveNext()) + { + builder.Append(delimiter); + builder.Append(settings.FormUrlEncodedParameterFormatter.Format(enumerator.Current, attrib?.Format)); + } + + return builder.ToString(); + } + finally + { + (enumerator as IDisposable)?.Dispose(); + } + } + /// Adds the entries from an source. /// The dictionary source. /// The Refit settings controlling formatting. @@ -134,8 +196,10 @@ private void AddDictionary(IDictionary dictionary, RefitSettings settings) "Form URL encoded bodies reflect over runtime object properties and serializer metadata.")] private void AddObject(object source, RefitSettings settings) { - foreach (var property in GetCachedProperties(source.GetType())) + var properties = GetCachedProperties(source.GetType()); + for (var i = 0; i < properties.Length; i++) { + var property = properties[i]; var value = property.GetValue(source, null); if (value is null) { @@ -205,13 +269,7 @@ or CollectionFormat.Tsv { var delimiter = GetDelimiter(collectionFormat); - var formattedValues = enumerable - .Cast() - .Select(v => - settings.FormUrlEncodedParameterFormatter.Format( - v, - attrib?.Format)); - Add(fieldName, string.Join(delimiter, formattedValues)); + Add(fieldName, JoinFormattedValues(enumerable, delimiter, attrib, settings)); break; } @@ -232,27 +290,28 @@ or CollectionFormat.Tsv /// The form field value. private void Add(string? key, string? value) => _formEntries.Add(new(key, value)); + /// Returns each key from the collected form entries. + /// The form keys. + private IEnumerable GetKeys() + { + for (var i = 0; i < _formEntries.Count; i++) + { + yield return _formEntries[i].Key; + } + } + /// Resolves the form field name for the given property. /// The property to resolve the name for. /// The resolved form field name. private string GetFieldNameForProperty(PropertyInfo propertyInfo) { - var name = - propertyInfo - .GetCustomAttributes(true) - .Select(a => a.Name) - .FirstOrDefault() - ?? _contentSerializer.GetFieldNameForProperty(propertyInfo) - ?? propertyInfo.Name; - - var qattrib = propertyInfo - .GetCustomAttributes(true) - .Select(attr => - !string.IsNullOrWhiteSpace(attr.Prefix) - ? attr.Prefix + attr.Delimiter + name - : name) - .FirstOrDefault(); - - return qattrib ?? name; + var name = propertyInfo.GetCustomAttribute(true)?.Name + ?? _contentSerializer.GetFieldNameForProperty(propertyInfo) + ?? propertyInfo.Name; + + var qattrib = propertyInfo.GetCustomAttribute(true); + return qattrib is not null && !string.IsNullOrWhiteSpace(qattrib.Prefix) + ? qattrib.Prefix + qattrib.Delimiter + name + : name; } } diff --git a/src/Refit/GeneratedRequestRunner.cs b/src/Refit/GeneratedRequestRunner.cs new file mode 100644 index 000000000..d453dbada --- /dev/null +++ b/src/Refit/GeneratedRequestRunner.cs @@ -0,0 +1,305 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +namespace Refit; + +/// Shared runtime helpers used by source-generated request construction. +[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] +public static class GeneratedRequestRunner +{ + /// The underlying value of the obsolete BodySerializationMethod.Json member. + private const int ObsoleteJsonBodySerializationMethodValue = 1; + + /// Sends a generated request with no response body, throwing on HTTP errors. + /// The HTTP client to send with. + /// The generated request message. + /// The Refit settings to use. + /// Whether request content should be buffered before sending. + /// A token to cancel the request. + /// A task that completes when the request finishes. + public static async Task SendVoidAsync( + HttpClient client, + HttpRequestMessage request, + RefitSettings settings, + bool bufferBody, + CancellationToken cancellationToken) + { + RequestExecutionHelpers.ThrowIfBaseAddressMissing(client); + + using (request) + { + await RequestExecutionHelpers.SendVoidAsync( + client, + request, + settings, + bufferBody, + true, + cancellationToken) + .ConfigureAwait(false); + } + } + + /// Sends a generated request and deserializes or wraps its response. + /// The result type returned to the caller. + /// The deserialized body type for API response wrappers. + /// The HTTP client to send with. + /// The generated request message. + /// The Refit settings to use. + /// Whether the result type is an API response wrapper. + /// Whether the response should be disposed by this helper. + /// Whether request content should be buffered before sending. + /// A token to cancel the request. + /// The deserialized or wrapped response. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "Type parameter intentionally specified explicitly by generated callers.")] + public static async Task SendAsync( + HttpClient client, + HttpRequestMessage request, + RefitSettings settings, + bool isApiResponse, + bool shouldDisposeResponse, + bool bufferBody, + CancellationToken cancellationToken) + { + RequestExecutionHelpers.ThrowIfBaseAddressMissing(client); + + using (request) + { + return await RequestExecutionHelpers.SendAndProcessResponseAsync( + client, + request, + settings, + new RequestExecutionOptions( + isApiResponse, + shouldDisposeResponse, + bufferBody, + true), + cancellationToken) + .ConfigureAwait(false); + } + } + + /// Serializes a generated request body using Refit body rules. + /// The declared body type. + /// The Refit settings to use. + /// The body value. + /// The configured body serialization method. + /// Whether serialized content should be streamed into the request. + /// The HTTP content for the body. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "Type parameter intentionally specified explicitly by generated callers.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Usage", + "CA2208:Instantiate argument exceptions correctly", + Justification = "The exception matches existing Refit body-serialization behavior.")] + public static HttpContent CreateBodyContent( + RefitSettings settings, + TBody body, + BodySerializationMethod serializationMethod, + bool streamBody) + { + if (body is HttpContent httpContent) + { + return httpContent; + } + + if (body is Stream stream) + { + return new StreamContent(stream); + } + + if (serializationMethod == BodySerializationMethod.Default && body is string stringBody) + { + return new StringContent(stringBody); + } + + var content = CreateSerializedBodyContent(settings, body, serializationMethod); + + if (!streamBody) + { + return content; + } + + return new PushStreamContent( + async (stream, _, _) => + { + using (stream) + { + await content.CopyToAsync(stream).ConfigureAwait(false); + } + }, + content.Headers.ContentType); + } + + /// Sets, replaces, or removes a generated request header. + /// The request to modify. + /// The header name. + /// The header value, or null to remove the header. + public static void SetHeader(HttpRequestMessage request, string name, string? value) + { + if (ContainsHeader(request.Headers, name)) + { + request.Headers.Remove(name); + } + + if (request.Content is not null && ContainsHeader(request.Content.Headers, name)) + { + request.Content.Headers.Remove(name); + } + + if (value is null) + { + return; + } + + if (request.Content is null && !IsBodyless(request.Method)) + { + request.Content = new ByteArrayContent([]); + } + + name = EnsureSafeHeaderValue(name); + value = EnsureSafeHeaderValue(value); + + var added = request.Headers.TryAddWithoutValidation(name, value); + if (added || request.Content is null) + { + return; + } + + request.Content.Headers.TryAddWithoutValidation(name, value); + } + + /// Adds a generated request header collection, replacing earlier values by key. + /// The request to modify. + /// The header collection argument. + public static void AddHeaderCollection( + HttpRequestMessage request, + IDictionary? headers) + { + if (headers is null) + { + return; + } + + foreach (var header in headers) + { + SetHeader(request, header.Key, header.Value); + } + } + + /// Adds configured request options/properties shared by every generated request. + /// The request to modify. + /// The Refit settings to use. + /// The generated interface type. + public static void AddConfiguredRequestOptions( + HttpRequestMessage request, + RefitSettings settings, + Type interfaceType) + { + if (settings.HttpRequestMessageOptions is not null) + { + foreach (var option in settings.HttpRequestMessageOptions) + { + AddBoxedRequestProperty(request, option.Key, option.Value); + } + } + + AddRequestProperty(request, HttpRequestMessageOptions.InterfaceType, interfaceType); + } + + /// Adds one generated request property or option value. + /// The property value type. + /// The request to modify. + /// The property key. + /// The property value. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "Generated callers specify the declared property type to avoid call-site boxing.")] + public static void AddRequestProperty(HttpRequestMessage request, string key, TValue value) + { +#if NET6_0_OR_GREATER + request.Options.Set(new(key), value); +#else + request.Properties[key] = value; +#endif + } + + /// Adds one pre-boxed configured request property or option value. + /// The request to modify. + /// The property key. + /// The pre-boxed property value. + private static void AddBoxedRequestProperty(HttpRequestMessage request, string key, object value) + { +#if NET6_0_OR_GREATER + request.Options.Set(new(key), value); +#else + request.Properties[key] = value; +#endif + } + + /// Serializes a non-special body value through the configured content serializer. + /// The declared body type. + /// The Refit settings to use. + /// The body value. + /// The configured body serialization method. + /// The serialized HTTP content. + private static HttpContent CreateSerializedBodyContent( + RefitSettings settings, + TBody body, + BodySerializationMethod serializationMethod) + { + if (serializationMethod is BodySerializationMethod.Default or BodySerializationMethod.Serialized + || IsObsoleteJsonSerializationMethod(serializationMethod)) + { + return settings.ContentSerializer.ToHttpContent(body); + } + + throw new ArgumentOutOfRangeException(nameof(serializationMethod), serializationMethod, null); + } + + /// Determines whether the body should use the legacy JSON enum member. + /// The body serialization method. + /// for the legacy JSON value. + private static bool IsObsoleteJsonSerializationMethod(BodySerializationMethod serializationMethod) => + (int)serializationMethod == ObsoleteJsonBodySerializationMethodValue; + + /// Determines whether the HTTP method must not carry generated placeholder content for content headers. + /// The HTTP method to inspect. + /// for bodyless methods. + private static bool IsBodyless(HttpMethod method) => + method == HttpMethod.Get || method == HttpMethod.Head; + + /// Checks whether a header collection contains a key without throwing for unsupported header types. + /// The header collection to inspect. + /// The header name. + /// when the header key exists; otherwise . + private static bool ContainsHeader(System.Net.Http.Headers.HttpHeaders headers, string name) + { + foreach (var header in headers) + { + if (string.Equals(header.Key, name, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// Removes CR and LF characters from a generated header name or value. + /// The header name or value. + /// The sanitized value. + private static string EnsureSafeHeaderValue(string value) => +#if NET8_0_OR_GREATER + value.Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", string.Empty, StringComparison.Ordinal); +#else + value.Replace("\r", string.Empty) + .Replace("\n", string.Empty); +#endif +} diff --git a/src/Refit/IRequestBuilder.cs b/src/Refit/IRequestBuilder.cs index 284ab1b8e..dd124f829 100644 --- a/src/Refit/IRequestBuilder.cs +++ b/src/Refit/IRequestBuilder.cs @@ -16,6 +16,9 @@ namespace Refit; /// be preserved when trimming assemblies. public interface IRequestBuilder { + /// Gets the settings used by this request builder. + RefitSettings Settings { get; } + /// Builds a delegate that executes the specified REST method using the provided HTTP client and arguments. /// The returned delegate uses reflection to invoke the specified method and may require /// referenced interfaces and data transfer objects (DTOs) to be preserved when trimming assemblies. This method diff --git a/src/Refit/JsonContentSerializer.cs b/src/Refit/JsonContentSerializer.cs index 34bb546dd..7aa831633 100644 --- a/src/Refit/JsonContentSerializer.cs +++ b/src/Refit/JsonContentSerializer.cs @@ -11,6 +11,7 @@ namespace Refit; [Obsolete( "Use NewtonsoftJsonContentSerializer in the Refit.Newtonsoft.Json package instead", true)] +[ExcludeFromCodeCoverage] [SuppressMessage( "Major Code Smell", "S1133:Deprecated code should be removed", diff --git a/src/Refit/MethodTableKey.cs b/src/Refit/MethodTableKey.cs index a62bcc7be..69745ffe5 100644 --- a/src/Refit/MethodTableKey.cs +++ b/src/Refit/MethodTableKey.cs @@ -29,22 +29,20 @@ public MethodTableKey(string methodName, Type[] parameters, Type[] genericArgume /// public override int GetHashCode() { - unchecked - { - var hashCode = MethodName.GetHashCode(); - - foreach (var argument in Parameters) - { - hashCode = (hashCode * 397) ^ argument.GetHashCode(); - } + HashCode hashCode = default; + hashCode.Add(MethodName); - foreach (var genericArgument in GenericArguments) - { - hashCode = (hashCode * 397) ^ genericArgument.GetHashCode(); - } + for (var i = 0; i < Parameters.Length; i++) + { + hashCode.Add(Parameters[i]); + } - return hashCode; + for (var i = 0; i < GenericArguments.Length; i++) + { + hashCode.Add(GenericArguments[i]); } + + return hashCode.ToHashCode(); } /// diff --git a/src/Refit/NameValueCollection.cs b/src/Refit/NameValueCollection.cs index e76b217b6..e89a91bee 100644 --- a/src/Refit/NameValueCollection.cs +++ b/src/Refit/NameValueCollection.cs @@ -4,6 +4,7 @@ namespace System.Collections.Specialized; /// A minimal string-keyed collection that mirrors the shape of the framework name/value collection. +[Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] internal class NameValueCollection : Dictionary { /// Gets all of the keys currently stored in the collection. diff --git a/src/Refit/PropertyAttribute.cs b/src/Refit/PropertyAttribute.cs index 7f80837fb..4d05e5a7d 100644 --- a/src/Refit/PropertyAttribute.cs +++ b/src/Refit/PropertyAttribute.cs @@ -9,7 +9,7 @@ namespace Refit; /// If a string is supplied to the constructor then it will be used as the key in the HttpRequestMessage.Properties dictionary. /// If no key is specified then the key will be defaulted to the name of the parameter. /// -[AttributeUsage(AttributeTargets.Parameter)] +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] public sealed class PropertyAttribute : Attribute { /// Initializes a new instance of the class. diff --git a/src/Refit/PublicAPI/net10.0/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net10.0/PublicAPI.Shipped.txt index d049b6bd5..2dc849b90 100644 --- a/src/Refit/PublicAPI/net10.0/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net10.0/PublicAPI.Shipped.txt @@ -95,6 +95,7 @@ Refit.DeleteAttribute.DeleteAttribute(string! path) -> void Refit.FileInfoPart Refit.FileInfoPart.FileInfoPart(System.IO.FileInfo! value, string! fileName, string? contentType = null, string? name = null) -> void Refit.FileInfoPart.Value.get -> System.IO.FileInfo! +Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void Refit.HeadAttribute @@ -137,6 +138,7 @@ Refit.IHttpContentSerializer.GetFieldNameForProperty(System.Reflection.PropertyI Refit.IHttpContentSerializer.ToHttpContent(T item) -> System.Net.Http.HttpContent! Refit.IRequestBuilder Refit.IRequestBuilder.BuildRestResultFuncForMethod(string! methodName, System.Type![]? parameterTypes = null, System.Type![]? genericArgumentTypes = null) -> System.Func! +Refit.IRequestBuilder.Settings.get -> Refit.RefitSettings! Refit.IRequestBuilder Refit.IUrlParameterFormatter Refit.IUrlParameterFormatter.Format(object? value, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string? @@ -310,6 +312,13 @@ static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, Sy static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Type! interfaceType) -> void +static Refit.GeneratedRequestRunner.AddHeaderCollection(System.Net.Http.HttpRequestMessage! request, System.Collections.Generic.IDictionary? headers) -> void +static Refit.GeneratedRequestRunner.AddRequestProperty(System.Net.Http.HttpRequestMessage! request, string! key, TValue value) -> void +static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.SendAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SendVoidAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SetHeader(System.Net.Http.HttpRequestMessage! request, string! name, string? value) -> void static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! static Refit.RequestBuilder.ForType(System.Type! refitInterfaceType) -> Refit.IRequestBuilder! diff --git a/src/Refit/PublicAPI/net11.0/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net11.0/PublicAPI.Shipped.txt index d049b6bd5..2dc849b90 100644 --- a/src/Refit/PublicAPI/net11.0/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net11.0/PublicAPI.Shipped.txt @@ -95,6 +95,7 @@ Refit.DeleteAttribute.DeleteAttribute(string! path) -> void Refit.FileInfoPart Refit.FileInfoPart.FileInfoPart(System.IO.FileInfo! value, string! fileName, string? contentType = null, string? name = null) -> void Refit.FileInfoPart.Value.get -> System.IO.FileInfo! +Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void Refit.HeadAttribute @@ -137,6 +138,7 @@ Refit.IHttpContentSerializer.GetFieldNameForProperty(System.Reflection.PropertyI Refit.IHttpContentSerializer.ToHttpContent(T item) -> System.Net.Http.HttpContent! Refit.IRequestBuilder Refit.IRequestBuilder.BuildRestResultFuncForMethod(string! methodName, System.Type![]? parameterTypes = null, System.Type![]? genericArgumentTypes = null) -> System.Func! +Refit.IRequestBuilder.Settings.get -> Refit.RefitSettings! Refit.IRequestBuilder Refit.IUrlParameterFormatter Refit.IUrlParameterFormatter.Format(object? value, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string? @@ -310,6 +312,13 @@ static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, Sy static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Type! interfaceType) -> void +static Refit.GeneratedRequestRunner.AddHeaderCollection(System.Net.Http.HttpRequestMessage! request, System.Collections.Generic.IDictionary? headers) -> void +static Refit.GeneratedRequestRunner.AddRequestProperty(System.Net.Http.HttpRequestMessage! request, string! key, TValue value) -> void +static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.SendAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SendVoidAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SetHeader(System.Net.Http.HttpRequestMessage! request, string! name, string? value) -> void static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! static Refit.RequestBuilder.ForType(System.Type! refitInterfaceType) -> Refit.IRequestBuilder! diff --git a/src/Refit/PublicAPI/net462/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net462/PublicAPI.Shipped.txt index a473f1ef8..662249393 100644 --- a/src/Refit/PublicAPI/net462/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net462/PublicAPI.Shipped.txt @@ -95,6 +95,7 @@ Refit.DeleteAttribute.DeleteAttribute(string! path) -> void Refit.FileInfoPart Refit.FileInfoPart.FileInfoPart(System.IO.FileInfo! value, string! fileName, string? contentType = null, string? name = null) -> void Refit.FileInfoPart.Value.get -> System.IO.FileInfo! +Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void Refit.HeadAttribute @@ -137,6 +138,7 @@ Refit.IHttpContentSerializer.GetFieldNameForProperty(System.Reflection.PropertyI Refit.IHttpContentSerializer.ToHttpContent(T item) -> System.Net.Http.HttpContent! Refit.IRequestBuilder Refit.IRequestBuilder.BuildRestResultFuncForMethod(string! methodName, System.Type![]? parameterTypes = null, System.Type![]? genericArgumentTypes = null) -> System.Func! +Refit.IRequestBuilder.Settings.get -> Refit.RefitSettings! Refit.IRequestBuilder Refit.IUrlParameterFormatter Refit.IUrlParameterFormatter.Format(object? value, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string? @@ -306,6 +308,13 @@ static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, Sy static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Type! interfaceType) -> void +static Refit.GeneratedRequestRunner.AddHeaderCollection(System.Net.Http.HttpRequestMessage! request, System.Collections.Generic.IDictionary? headers) -> void +static Refit.GeneratedRequestRunner.AddRequestProperty(System.Net.Http.HttpRequestMessage! request, string! key, TValue value) -> void +static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.SendAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SendVoidAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SetHeader(System.Net.Http.HttpRequestMessage! request, string! name, string? value) -> void static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! static Refit.RequestBuilder.ForType(System.Type! refitInterfaceType) -> Refit.IRequestBuilder! diff --git a/src/Refit/PublicAPI/net470/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net470/PublicAPI.Shipped.txt index a473f1ef8..662249393 100644 --- a/src/Refit/PublicAPI/net470/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net470/PublicAPI.Shipped.txt @@ -95,6 +95,7 @@ Refit.DeleteAttribute.DeleteAttribute(string! path) -> void Refit.FileInfoPart Refit.FileInfoPart.FileInfoPart(System.IO.FileInfo! value, string! fileName, string? contentType = null, string? name = null) -> void Refit.FileInfoPart.Value.get -> System.IO.FileInfo! +Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void Refit.HeadAttribute @@ -137,6 +138,7 @@ Refit.IHttpContentSerializer.GetFieldNameForProperty(System.Reflection.PropertyI Refit.IHttpContentSerializer.ToHttpContent(T item) -> System.Net.Http.HttpContent! Refit.IRequestBuilder Refit.IRequestBuilder.BuildRestResultFuncForMethod(string! methodName, System.Type![]? parameterTypes = null, System.Type![]? genericArgumentTypes = null) -> System.Func! +Refit.IRequestBuilder.Settings.get -> Refit.RefitSettings! Refit.IRequestBuilder Refit.IUrlParameterFormatter Refit.IUrlParameterFormatter.Format(object? value, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string? @@ -306,6 +308,13 @@ static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, Sy static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Type! interfaceType) -> void +static Refit.GeneratedRequestRunner.AddHeaderCollection(System.Net.Http.HttpRequestMessage! request, System.Collections.Generic.IDictionary? headers) -> void +static Refit.GeneratedRequestRunner.AddRequestProperty(System.Net.Http.HttpRequestMessage! request, string! key, TValue value) -> void +static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.SendAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SendVoidAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SetHeader(System.Net.Http.HttpRequestMessage! request, string! name, string? value) -> void static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! static Refit.RequestBuilder.ForType(System.Type! refitInterfaceType) -> Refit.IRequestBuilder! diff --git a/src/Refit/PublicAPI/net471/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net471/PublicAPI.Shipped.txt index a473f1ef8..662249393 100644 --- a/src/Refit/PublicAPI/net471/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net471/PublicAPI.Shipped.txt @@ -95,6 +95,7 @@ Refit.DeleteAttribute.DeleteAttribute(string! path) -> void Refit.FileInfoPart Refit.FileInfoPart.FileInfoPart(System.IO.FileInfo! value, string! fileName, string? contentType = null, string? name = null) -> void Refit.FileInfoPart.Value.get -> System.IO.FileInfo! +Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void Refit.HeadAttribute @@ -137,6 +138,7 @@ Refit.IHttpContentSerializer.GetFieldNameForProperty(System.Reflection.PropertyI Refit.IHttpContentSerializer.ToHttpContent(T item) -> System.Net.Http.HttpContent! Refit.IRequestBuilder Refit.IRequestBuilder.BuildRestResultFuncForMethod(string! methodName, System.Type![]? parameterTypes = null, System.Type![]? genericArgumentTypes = null) -> System.Func! +Refit.IRequestBuilder.Settings.get -> Refit.RefitSettings! Refit.IRequestBuilder Refit.IUrlParameterFormatter Refit.IUrlParameterFormatter.Format(object? value, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string? @@ -306,6 +308,13 @@ static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, Sy static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Type! interfaceType) -> void +static Refit.GeneratedRequestRunner.AddHeaderCollection(System.Net.Http.HttpRequestMessage! request, System.Collections.Generic.IDictionary? headers) -> void +static Refit.GeneratedRequestRunner.AddRequestProperty(System.Net.Http.HttpRequestMessage! request, string! key, TValue value) -> void +static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.SendAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SendVoidAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SetHeader(System.Net.Http.HttpRequestMessage! request, string! name, string? value) -> void static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! static Refit.RequestBuilder.ForType(System.Type! refitInterfaceType) -> Refit.IRequestBuilder! diff --git a/src/Refit/PublicAPI/net472/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net472/PublicAPI.Shipped.txt index a473f1ef8..662249393 100644 --- a/src/Refit/PublicAPI/net472/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net472/PublicAPI.Shipped.txt @@ -95,6 +95,7 @@ Refit.DeleteAttribute.DeleteAttribute(string! path) -> void Refit.FileInfoPart Refit.FileInfoPart.FileInfoPart(System.IO.FileInfo! value, string! fileName, string? contentType = null, string? name = null) -> void Refit.FileInfoPart.Value.get -> System.IO.FileInfo! +Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void Refit.HeadAttribute @@ -137,6 +138,7 @@ Refit.IHttpContentSerializer.GetFieldNameForProperty(System.Reflection.PropertyI Refit.IHttpContentSerializer.ToHttpContent(T item) -> System.Net.Http.HttpContent! Refit.IRequestBuilder Refit.IRequestBuilder.BuildRestResultFuncForMethod(string! methodName, System.Type![]? parameterTypes = null, System.Type![]? genericArgumentTypes = null) -> System.Func! +Refit.IRequestBuilder.Settings.get -> Refit.RefitSettings! Refit.IRequestBuilder Refit.IUrlParameterFormatter Refit.IUrlParameterFormatter.Format(object? value, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string? @@ -306,6 +308,13 @@ static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, Sy static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Type! interfaceType) -> void +static Refit.GeneratedRequestRunner.AddHeaderCollection(System.Net.Http.HttpRequestMessage! request, System.Collections.Generic.IDictionary? headers) -> void +static Refit.GeneratedRequestRunner.AddRequestProperty(System.Net.Http.HttpRequestMessage! request, string! key, TValue value) -> void +static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.SendAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SendVoidAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SetHeader(System.Net.Http.HttpRequestMessage! request, string! name, string? value) -> void static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! static Refit.RequestBuilder.ForType(System.Type! refitInterfaceType) -> Refit.IRequestBuilder! diff --git a/src/Refit/PublicAPI/net48/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net48/PublicAPI.Shipped.txt index a473f1ef8..662249393 100644 --- a/src/Refit/PublicAPI/net48/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net48/PublicAPI.Shipped.txt @@ -95,6 +95,7 @@ Refit.DeleteAttribute.DeleteAttribute(string! path) -> void Refit.FileInfoPart Refit.FileInfoPart.FileInfoPart(System.IO.FileInfo! value, string! fileName, string? contentType = null, string? name = null) -> void Refit.FileInfoPart.Value.get -> System.IO.FileInfo! +Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void Refit.HeadAttribute @@ -137,6 +138,7 @@ Refit.IHttpContentSerializer.GetFieldNameForProperty(System.Reflection.PropertyI Refit.IHttpContentSerializer.ToHttpContent(T item) -> System.Net.Http.HttpContent! Refit.IRequestBuilder Refit.IRequestBuilder.BuildRestResultFuncForMethod(string! methodName, System.Type![]? parameterTypes = null, System.Type![]? genericArgumentTypes = null) -> System.Func! +Refit.IRequestBuilder.Settings.get -> Refit.RefitSettings! Refit.IRequestBuilder Refit.IUrlParameterFormatter Refit.IUrlParameterFormatter.Format(object? value, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string? @@ -306,6 +308,13 @@ static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, Sy static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Type! interfaceType) -> void +static Refit.GeneratedRequestRunner.AddHeaderCollection(System.Net.Http.HttpRequestMessage! request, System.Collections.Generic.IDictionary? headers) -> void +static Refit.GeneratedRequestRunner.AddRequestProperty(System.Net.Http.HttpRequestMessage! request, string! key, TValue value) -> void +static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.SendAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SendVoidAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SetHeader(System.Net.Http.HttpRequestMessage! request, string! name, string? value) -> void static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! static Refit.RequestBuilder.ForType(System.Type! refitInterfaceType) -> Refit.IRequestBuilder! diff --git a/src/Refit/PublicAPI/net481/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net481/PublicAPI.Shipped.txt index a473f1ef8..662249393 100644 --- a/src/Refit/PublicAPI/net481/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net481/PublicAPI.Shipped.txt @@ -95,6 +95,7 @@ Refit.DeleteAttribute.DeleteAttribute(string! path) -> void Refit.FileInfoPart Refit.FileInfoPart.FileInfoPart(System.IO.FileInfo! value, string! fileName, string? contentType = null, string? name = null) -> void Refit.FileInfoPart.Value.get -> System.IO.FileInfo! +Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void Refit.HeadAttribute @@ -137,6 +138,7 @@ Refit.IHttpContentSerializer.GetFieldNameForProperty(System.Reflection.PropertyI Refit.IHttpContentSerializer.ToHttpContent(T item) -> System.Net.Http.HttpContent! Refit.IRequestBuilder Refit.IRequestBuilder.BuildRestResultFuncForMethod(string! methodName, System.Type![]? parameterTypes = null, System.Type![]? genericArgumentTypes = null) -> System.Func! +Refit.IRequestBuilder.Settings.get -> Refit.RefitSettings! Refit.IRequestBuilder Refit.IUrlParameterFormatter Refit.IUrlParameterFormatter.Format(object? value, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string? @@ -306,6 +308,13 @@ static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, Sy static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Type! interfaceType) -> void +static Refit.GeneratedRequestRunner.AddHeaderCollection(System.Net.Http.HttpRequestMessage! request, System.Collections.Generic.IDictionary? headers) -> void +static Refit.GeneratedRequestRunner.AddRequestProperty(System.Net.Http.HttpRequestMessage! request, string! key, TValue value) -> void +static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.SendAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SendVoidAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SetHeader(System.Net.Http.HttpRequestMessage! request, string! name, string? value) -> void static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! static Refit.RequestBuilder.ForType(System.Type! refitInterfaceType) -> Refit.IRequestBuilder! diff --git a/src/Refit/PublicAPI/net8.0/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net8.0/PublicAPI.Shipped.txt index d049b6bd5..2dc849b90 100644 --- a/src/Refit/PublicAPI/net8.0/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net8.0/PublicAPI.Shipped.txt @@ -95,6 +95,7 @@ Refit.DeleteAttribute.DeleteAttribute(string! path) -> void Refit.FileInfoPart Refit.FileInfoPart.FileInfoPart(System.IO.FileInfo! value, string! fileName, string? contentType = null, string? name = null) -> void Refit.FileInfoPart.Value.get -> System.IO.FileInfo! +Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void Refit.HeadAttribute @@ -137,6 +138,7 @@ Refit.IHttpContentSerializer.GetFieldNameForProperty(System.Reflection.PropertyI Refit.IHttpContentSerializer.ToHttpContent(T item) -> System.Net.Http.HttpContent! Refit.IRequestBuilder Refit.IRequestBuilder.BuildRestResultFuncForMethod(string! methodName, System.Type![]? parameterTypes = null, System.Type![]? genericArgumentTypes = null) -> System.Func! +Refit.IRequestBuilder.Settings.get -> Refit.RefitSettings! Refit.IRequestBuilder Refit.IUrlParameterFormatter Refit.IUrlParameterFormatter.Format(object? value, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string? @@ -310,6 +312,13 @@ static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, Sy static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Type! interfaceType) -> void +static Refit.GeneratedRequestRunner.AddHeaderCollection(System.Net.Http.HttpRequestMessage! request, System.Collections.Generic.IDictionary? headers) -> void +static Refit.GeneratedRequestRunner.AddRequestProperty(System.Net.Http.HttpRequestMessage! request, string! key, TValue value) -> void +static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.SendAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SendVoidAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SetHeader(System.Net.Http.HttpRequestMessage! request, string! name, string? value) -> void static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! static Refit.RequestBuilder.ForType(System.Type! refitInterfaceType) -> Refit.IRequestBuilder! diff --git a/src/Refit/PublicAPI/net9.0/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net9.0/PublicAPI.Shipped.txt index d049b6bd5..2dc849b90 100644 --- a/src/Refit/PublicAPI/net9.0/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net9.0/PublicAPI.Shipped.txt @@ -95,6 +95,7 @@ Refit.DeleteAttribute.DeleteAttribute(string! path) -> void Refit.FileInfoPart Refit.FileInfoPart.FileInfoPart(System.IO.FileInfo! value, string! fileName, string? contentType = null, string? name = null) -> void Refit.FileInfoPart.Value.get -> System.IO.FileInfo! +Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void Refit.HeadAttribute @@ -137,6 +138,7 @@ Refit.IHttpContentSerializer.GetFieldNameForProperty(System.Reflection.PropertyI Refit.IHttpContentSerializer.ToHttpContent(T item) -> System.Net.Http.HttpContent! Refit.IRequestBuilder Refit.IRequestBuilder.BuildRestResultFuncForMethod(string! methodName, System.Type![]? parameterTypes = null, System.Type![]? genericArgumentTypes = null) -> System.Func! +Refit.IRequestBuilder.Settings.get -> Refit.RefitSettings! Refit.IRequestBuilder Refit.IUrlParameterFormatter Refit.IUrlParameterFormatter.Format(object? value, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string? @@ -310,6 +312,13 @@ static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, Sy static Refit.ApiException.Create(System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings) -> System.Threading.Tasks.Task! static Refit.ApiException.Create(string! exceptionMessage, System.Net.Http.HttpRequestMessage! message, System.Net.Http.HttpMethod! httpMethod, System.Net.Http.HttpResponseMessage! response, Refit.RefitSettings! refitSettings, System.Exception? innerException) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Type! interfaceType) -> void +static Refit.GeneratedRequestRunner.AddHeaderCollection(System.Net.Http.HttpRequestMessage! request, System.Collections.Generic.IDictionary? headers) -> void +static Refit.GeneratedRequestRunner.AddRequestProperty(System.Net.Http.HttpRequestMessage! request, string! key, TValue value) -> void +static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.SendAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SendVoidAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, bool bufferBody, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static Refit.GeneratedRequestRunner.SetHeader(System.Net.Http.HttpRequestMessage! request, string! name, string? value) -> void static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! static Refit.RequestBuilder.ForType(System.Type! refitInterfaceType) -> Refit.IRequestBuilder! diff --git a/src/Refit/PushStreamContent.cs b/src/Refit/PushStreamContent.cs index 4ef368692..48ddacf41 100644 --- a/src/Refit/PushStreamContent.cs +++ b/src/Refit/PushStreamContent.cs @@ -147,10 +147,6 @@ protected override bool TryComputeLength(out long length) /// /// https://github.com/ASP-NET-MVC/aspnetwebstack/blob/5118a14040b13f95bf778d1fc4522eb4ea2eef18/src/Common/TaskHelpers.cs#L65. /// - [SuppressMessage( - "Design", - "SST1436:Add members to the type or remove it", - Justification = "Intentional empty placeholder struct for the void TaskCompletionSource pattern.")] private readonly struct AsyncVoid : IEquatable { /// @@ -175,12 +171,10 @@ internal sealed class CompleteTaskOnCloseStream : Refit.DelegatingStream public CompleteTaskOnCloseStream( Stream innerStream, TaskCompletionSource serializeToStreamTask) - : base(innerStream, ownsInnerStream: false) - { + : base(innerStream, ownsInnerStream: false) => _serializeToStreamTask = serializeToStreamTask ?? throw new ArgumentNullException(nameof(serializeToStreamTask)); - } /// protected override void Dispose(bool disposing) diff --git a/src/Refit/Refit.csproj b/src/Refit/Refit.csproj index 46bd694d9..df8a6069c 100644 --- a/src/Refit/Refit.csproj +++ b/src/Refit/Refit.csproj @@ -1,7 +1,9 @@ - + Refit ($(TargetFramework)) + Type-safe REST API clients for .NET, generated from annotated interfaces with build-time source generation, direct request construction, System.Text.Json defaults, and support for trimming and AOT. + refit;rest;http;httpclient;api-client;typed-client;source-generator;roslyn;system-text-json;json;aot;trimming $(RefitTargets) enable @@ -55,12 +57,8 @@ - - - - diff --git a/src/Refit/RefitSettings.cs b/src/Refit/RefitSettings.cs index a3983f3db..48021fae7 100644 --- a/src/Refit/RefitSettings.cs +++ b/src/Refit/RefitSettings.cs @@ -133,7 +133,7 @@ public Func< /// /// The version policy. /// - public System.Net.Http.HttpVersionPolicy VersionPolicy { get; set; } = - System.Net.Http.HttpVersionPolicy.RequestVersionOrLower; + public HttpVersionPolicy VersionPolicy { get; set; } = + HttpVersionPolicy.RequestVersionOrLower; #endif } diff --git a/src/Refit/RequestBuilder.cs b/src/Refit/RequestBuilder.cs index 5c4f9c3b9..f62daeb1a 100644 --- a/src/Refit/RequestBuilder.cs +++ b/src/Refit/RequestBuilder.cs @@ -76,11 +76,9 @@ public static IRequestBuilder ForType( DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] Type refitInterfaceType, - RefitSettings? settings) - { - return new CachedRequestBuilderImplementation( + RefitSettings? settings) => + new CachedRequestBuilderImplementation( new RequestBuilderImplementation(refitInterfaceType, settings)); - } /// Creates an instance of an IRequestBuilder for the specified Refit interface type. /// The specified interface type must be decorated with Refit attributes to define the diff --git a/src/Refit/RequestBuilderFactory.cs b/src/Refit/RequestBuilderFactory.cs index bb403779c..04daf7aab 100644 --- a/src/Refit/RequestBuilderFactory.cs +++ b/src/Refit/RequestBuilderFactory.cs @@ -20,11 +20,9 @@ public IRequestBuilder Create< [DynamicallyAccessedMembers( DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] - T>(RefitSettings? settings) - { - return new CachedRequestBuilderImplementation( + T>(RefitSettings? settings) => + new CachedRequestBuilderImplementation( new RequestBuilderImplementation(settings)); - } /// [RequiresUnreferencedCode( @@ -35,9 +33,7 @@ public IRequestBuilder Create( DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] Type refitInterfaceType, - RefitSettings? settings) - { - return new CachedRequestBuilderImplementation( + RefitSettings? settings) => + new CachedRequestBuilderImplementation( new RequestBuilderImplementation(refitInterfaceType, settings)); - } } diff --git a/src/Refit/RequestBuilderImplementation.Execution.cs b/src/Refit/RequestBuilderImplementation.Execution.cs index f6dc6e8d0..1049fbd04 100644 --- a/src/Refit/RequestBuilderImplementation.Execution.cs +++ b/src/Refit/RequestBuilderImplementation.Execution.cs @@ -2,10 +2,7 @@ // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Net.Http.Headers; -#if NET5_0_OR_GREATER using System.Diagnostics.CodeAnalysis; -#endif namespace Refit { @@ -18,41 +15,8 @@ internal partial class RequestBuilderImplementation /// if the body should be buffered; otherwise . private static bool IsBodyBuffered( RestMethodInfoInternal restMethod, - HttpRequestMessage? request) - { - return (restMethod.BodyParameterInfo?.Item2 ?? false) && (request?.Content is not null); - } - - /// Attempts to buffer content into memory, ignoring buffering failures. - /// The content to buffer. - /// A token to cancel the buffering. - /// A task that completes once buffering has been attempted. - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Design", - "CA1031:Do not catch general exception types", - Justification = "Best-effort buffering: any failure falls back to streaming deserialization (pre-existing behavior).")] - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Minor Code Smell", - "SST1429:Do not use an empty catch of the base exception", - Justification = "Best-effort buffering: any failure falls back to streaming deserialization (pre-existing behavior).")] - private static async Task TryBufferContentAsync(HttpContent content, CancellationToken cancellationToken) - { - try - { -#if NET8_0_OR_GREATER - await content.LoadIntoBufferAsync(cancellationToken).ConfigureAwait(false); -#else - _ = cancellationToken; - await content.LoadIntoBufferAsync().ConfigureAwait(false); -#endif - } - catch - { - // Best-effort: if the content cannot be buffered we fall back to - // streaming deserialization. The only downside is that the raw body - // may be unavailable on failure, which is the pre-existing behavior. - } - } + HttpRequestMessage? request) => + (restMethod.BodyParameterInfo?.Item2 ?? false) && (request?.Content is not null); /// Builds and sends the request for a method with no response body, throwing on error. /// The HTTP client to send with. @@ -61,10 +25,8 @@ private static async Task TryBufferContentAsync(HttpContent content, Cancellatio /// Whether the argument list contains a cancellation token. /// A token to cancel the request. /// A task that completes when the request finishes. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private async Task ExecuteVoidRequestAsync( HttpClient client, RestMethodInfoInternal restMethod, @@ -72,34 +34,23 @@ private async Task ExecuteVoidRequestAsync( bool paramsContainsCancellationToken, CancellationToken cancellationToken) { - if (client.BaseAddress is null) - { - throw new InvalidOperationException(BaseAddressRequiredMessage); - } + RequestExecutionHelpers.ThrowIfBaseAddressMissing(client); - using var rq = await BuildRequestMessageForMethodAsync( + using var request = await BuildRequestMessageForMethodAsync( restMethod, - client.BaseAddress.AbsolutePath, + client.BaseAddress!.AbsolutePath, paramsContainsCancellationToken, paramList) .ConfigureAwait(false); - if (IsBodyBuffered(restMethod, rq)) - { - await rq!.Content!.LoadIntoBufferAsync(cancellationToken).ConfigureAwait(false); - } - - using var resp = await client - .SendAsync(rq!, cancellationToken) + await RequestExecutionHelpers.SendVoidAsync( + client, + request!, + _settings, + IsBodyBuffered(restMethod, request), + false, + cancellationToken) .ConfigureAwait(false); - - var exception = await _settings.ExceptionFactory(resp).ConfigureAwait(false); - if (exception is null) - { - return; - } - - throw exception; } /// Builds, sends and deserializes the request for a method that returns a value. @@ -111,11 +62,9 @@ private async Task ExecuteVoidRequestAsync( /// Whether the argument list contains a cancellation token. /// A token to cancel the request. /// The deserialized result, or default when there is no content. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif - [System.Diagnostics.CodeAnalysis.SuppressMessage( + [SuppressMessage( "Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] @@ -126,19 +75,16 @@ private async Task ExecuteVoidRequestAsync( bool paramsContainsCancellationToken, CancellationToken cancellationToken) { - if (client.BaseAddress is null) - { - throw new InvalidOperationException(BaseAddressRequiredMessage); - } + RequestExecutionHelpers.ThrowIfBaseAddressMissing(client); - using var rq = await BuildRequestMessageForMethodAsync( + using var request = await BuildRequestMessageForMethodAsync( restMethod, - client.BaseAddress.AbsolutePath, + client.BaseAddress!.AbsolutePath, paramsContainsCancellationToken, paramList) .ConfigureAwait(false); - return await SendAndProcessResponseAsync(client, restMethod, rq!, cancellationToken) + return await SendAndProcessResponseAsync(client, restMethod, request!, cancellationToken) .ConfigureAwait(false); } @@ -147,11 +93,9 @@ private async Task ExecuteVoidRequestAsync( /// The body type used for API responses. /// The rest method to build a delegate for. /// A delegate that sends the request with a cancellation token. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif - [System.Diagnostics.CodeAnalysis.SuppressMessage( + [SuppressMessage( "Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] @@ -160,425 +104,53 @@ private async Task ExecuteVoidRequestAsync( { return async (client, ct, paramList) => { - if (client.BaseAddress is null) - { - throw new InvalidOperationException(BaseAddressRequiredMessage); - } + RequestExecutionHelpers.ThrowIfBaseAddressMissing(client); - var rq = await BuildRequestMessageForMethodAsync( + var request = await BuildRequestMessageForMethodAsync( restMethod, - client.BaseAddress.AbsolutePath, + client.BaseAddress!.AbsolutePath, restMethod.CancellationToken is not null, paramList).ConfigureAwait(false); try { - return await SendAndProcessResponseAsync(client, restMethod, rq!, ct) + return await SendAndProcessResponseAsync(client, restMethod, request!, ct) .ConfigureAwait(false); } finally { - // Ensure we clean up the request - // Especially important if it has open files/streams - rq?.Dispose(); + // Ensure we clean up the request, especially if it has open files/streams. + request?.Dispose(); } }; } - /// Buffers, sends and processes the request, deserializing the response or building an API response. + /// Processes a response for a reflection-built request using the shared runtime state machine. /// The result type returned to the caller. /// The body type used for API responses. /// The HTTP client to send with. /// The rest method being invoked. - /// The request message to send. + /// The request message to send. /// A token to cancel the request. /// The deserialized result, or default when there is no content. -#if NET5_0_OR_GREATER - [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] - [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", - Justification = "Type parameter intentionally specified explicitly by callers.")] - private async Task SendAndProcessResponseAsync( - HttpClient client, - RestMethodInfoInternal restMethod, - HttpRequestMessage rq, - CancellationToken cancellationToken) - { - HttpResponseMessage? resp = null; - HttpContent? content = null; - var disposeResponse = true; - try - { - if (IsBodyBuffered(restMethod, rq)) - { - await rq.Content!.LoadIntoBufferAsync(cancellationToken).ConfigureAwait(false); - } - - var sendResult = await SendOrCaptureExceptionAsync( - client, - restMethod, - rq, - HttpCompletionOption.ResponseHeadersRead, - cancellationToken) - .ConfigureAwait(false); - if (!sendResult.HasResponse) - { - return sendResult.FailureResult; - } - - resp = sendResult.Response!; - content = resp.Content ?? new StringContent(string.Empty); - disposeResponse = restMethod.ShouldDisposeResponse; - - var e = typeof(T) != typeof(HttpResponseMessage) - ? await _settings.ExceptionFactory(resp).ConfigureAwait(false) - : null; - - if (restMethod.IsApiResponse) - { - return await BuildApiResponseAsync(rq, resp, content, e, cancellationToken) - .ConfigureAwait(false); - } - - if (e is not null) - { - disposeResponse = false; // caller has to dispose - throw e; - } - - return await DeserializeOrThrowAsync(rq, resp, content, cancellationToken) - .ConfigureAwait(false); - } - finally - { - if (disposeResponse) - { - resp?.Dispose(); - content?.Dispose(); - } - } - } - - /// Sends the request, capturing a transport failure as a wrapped result for API-response methods. - /// The result type returned to the caller. - /// The body type used for API responses. - /// The HTTP client to send with. - /// The rest method being invoked. - /// The request message to send. - /// The completion option to use. - /// A token to cancel the request. - /// The send result, either a response or a captured failure result. -#if NET5_0_OR_GREATER - [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] - [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif - [System.Diagnostics.CodeAnalysis.SuppressMessage( + [SuppressMessage( "Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] - private async Task> SendOrCaptureExceptionAsync( + private Task SendAndProcessResponseAsync( HttpClient client, RestMethodInfoInternal restMethod, - HttpRequestMessage rq, - HttpCompletionOption completionOption, - CancellationToken cancellationToken) - { - try - { - var response = await client - .SendAsync(rq, completionOption, cancellationToken) - .ConfigureAwait(false); - return SendResult.FromResponse(response); - } - catch (Exception ex) - { - if (!restMethod.IsApiResponse) - { - throw new ApiRequestException(rq, rq.Method, _settings, ex); - } - - var failure = ApiResponse.Create( - rq, - null, - default, - _settings, - new ApiRequestException(rq, rq.Method, _settings, ex)); - return SendResult.FromFailure(failure); - } - } - - /// Builds an API response, deserializing the body and capturing any deserialization failure. - /// The result type returned to the caller. - /// The body type used for API responses. - /// The request message. - /// The response message. - /// The response content. - /// An exception already produced by the exception factory, if any. - /// A token to cancel the read. - /// The constructed API response. -#if NET5_0_OR_GREATER - [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] - [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", - Justification = "Type parameter intentionally specified explicitly by callers.")] - private async Task BuildApiResponseAsync( - HttpRequestMessage rq, - HttpResponseMessage resp, - HttpContent content, - Exception? existingException, - CancellationToken cancellationToken) - { - var e = existingException; - var body = default(TBody); - - try - { - // Only attempt to deserialize content if no error present for backward-compatibility. - body = - e is null - ? await DeserializeContentAsync(resp, content, cancellationToken) - .ConfigureAwait(false) - : default; - } - catch (Exception ex) - { - // If an error occured while attempting to deserialize, return the wrapped ApiException. - e = await CreateDeserializationExceptionAsync(rq, resp, ex).ConfigureAwait(false); - } - - return ApiResponse.Create( - rq, - resp, - body, - _settings, - e as ApiException); - } - - /// Deserializes the response content, throwing a wrapped exception on failure. - /// The result type returned to the caller. - /// The request message. - /// The response message. - /// The response content. - /// A token to cancel the read. - /// The deserialized result. -#if NET5_0_OR_GREATER - [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] - [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", - Justification = "Type parameter intentionally specified explicitly by callers.")] - private async Task DeserializeOrThrowAsync( - HttpRequestMessage rq, - HttpResponseMessage resp, - HttpContent content, - CancellationToken cancellationToken) - { - try - { - return await DeserializeContentAsync(resp, content, cancellationToken) - .ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception ex) - { - if (_settings.DeserializationExceptionFactory is not null) - { - var customEx = await _settings - .DeserializationExceptionFactory(resp, ex) - .ConfigureAwait(false); - if (customEx is not null) - { - throw customEx; - } - - return default; - } - - throw await ApiException.Create( - DeserializationErrorMessage, - rq, - rq.Method, - resp, - _settings, - ex).ConfigureAwait(false); - } - } - - /// Produces a wrapped deserialization exception using the configured factory or a default. - /// The request message. - /// The response message. - /// The original deserialization exception. - /// The wrapped exception, or null when a configured factory returns null. - private async Task CreateDeserializationExceptionAsync( - HttpRequestMessage rq, - HttpResponseMessage resp, - Exception ex) - { - if (_settings.DeserializationExceptionFactory is not null) - { - return await _settings.DeserializationExceptionFactory(resp, ex).ConfigureAwait(false); - } - - return await ApiException.Create( - DeserializationErrorMessage, - rq, - rq.Method, - resp, + HttpRequestMessage request, + CancellationToken cancellationToken) => + RequestExecutionHelpers.SendAndProcessResponseAsync( + client, + request, _settings, - ex).ConfigureAwait(false); - } - - /// Deserializes the response content into the requested type. - /// The type to deserialize into. - /// The response message. - /// The response content. - /// A token to cancel the read. - /// The deserialized value, or default when there is no content. -#if NET5_0_OR_GREATER - [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] - [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", - Justification = "Type parameter intentionally specified explicitly by callers.")] - private async Task DeserializeContentAsync( - HttpResponseMessage resp, - HttpContent content, - CancellationToken cancellationToken) - { - T? result; - if (typeof(T) == typeof(HttpResponseMessage)) - { - // NB: This double-casting manual-boxing hate crime is the only way to make - // this work without a 'class' generic constraint. It could blow up at runtime - // and would be A Bad Idea if we hadn't already vetted the return type. - result = (T)(object)resp; - } - else if (typeof(T) == typeof(HttpContent)) - { - result = (T)(object)content; - } - else if (typeof(T) == typeof(Stream)) - { - var stream = (object) - await content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - result = (T)stream; - } - else if (typeof(T) == typeof(string)) - { - var stream = await content - .ReadAsStreamAsync(cancellationToken) - .ConfigureAwait(false); - using (stream) - { - using var reader = new StreamReader(stream); -#if NET8_0_OR_GREATER - var str = (object)await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); -#else - cancellationToken.ThrowIfCancellationRequested(); - var str = (object)await reader.ReadToEndAsync().ConfigureAwait(false); -#endif - result = (T)str; - } - } - else - { - result = await DeserializeSerializedContentAsync(resp, content, cancellationToken) - .ConfigureAwait(false); - } - - return result; - } - - /// Buffers and deserializes serialized content (e.g. JSON or XML) via the configured serializer. - /// The type to deserialize into. - /// The response message. - /// The response content. - /// A token to cancel the read. - /// The deserialized value, or default when there is no content. -#if NET5_0_OR_GREATER - [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] - [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", - Justification = "Type parameter intentionally specified explicitly by callers.")] - private async Task DeserializeSerializedContentAsync( - HttpResponseMessage resp, - HttpContent content, - CancellationToken cancellationToken) - { - // A 204 No Content response (or an explicitly empty body) has nothing to - // deserialize. Return the default value rather than letting the serializer - // fail on empty content. - if (resp.StatusCode == System.Net.HttpStatusCode.NoContent - || content.Headers.ContentLength == 0) - { - return default; - } - - // Buffer the content into memory before deserializing so that, if the - // serializer throws, ApiException.Create can still re-read the raw body - // (see #2098). We deliberately do NOT probe the stream via - // ReadAsStreamAsync first: that consumes non-seekable network streams and - // breaks serializers that re-read via ReadAsStringAsync, e.g. XML (#1729, - // which reverted #1705). LoadIntoBufferAsync is a no-op for the already - // buffered content that HttpClient produces by default. - await TryBufferContentAsync(content, cancellationToken).ConfigureAwait(false); - - return await _serializer - .FromHttpContentAsync(content, cancellationToken) - .ConfigureAwait(false); - } - - /// The outcome of attempting to send a request: either a response or a captured failure result. - /// The result type returned to the caller. - private readonly record struct SendResult - { - /// Initializes a new instance of the struct. - /// Whether the send produced a response. - /// The response, or null when the send failed. - /// The captured failure result, valid only when is false. - private SendResult(bool hasResponse, HttpResponseMessage? response, T? failureResult) - { - HasResponse = hasResponse; - Response = response; - FailureResult = failureResult; - } - - /// Gets a value indicating whether the send produced a response. - public bool HasResponse { get; } - - /// Gets the response, or null when the send failed. - public HttpResponseMessage? Response { get; } - - /// Gets the captured failure result, valid only when is false. - public T? FailureResult { get; } - - /// Creates a successful result wrapping the given response. - /// The response produced by the send. - /// A result indicating a response is present. - public static SendResult FromResponse(HttpResponseMessage response) => - new(true, response, default); - - /// Creates a failed result wrapping the captured failure value. - /// The captured failure result. - /// A result indicating the send failed. - public static SendResult FromFailure(T? failureResult) => - new(false, null, failureResult); - } + new RequestExecutionOptions( + restMethod.IsApiResponse, + restMethod.ShouldDisposeResponse, + IsBodyBuffered(restMethod, request), + false), + cancellationToken); } } diff --git a/src/Refit/RequestBuilderImplementation.QueryAndHeaders.Helpers.cs b/src/Refit/RequestBuilderImplementation.QueryAndHeaders.Helpers.cs new file mode 100644 index 000000000..dc4724916 --- /dev/null +++ b/src/Refit/RequestBuilderImplementation.QueryAndHeaders.Helpers.cs @@ -0,0 +1,117 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Reflection; + +namespace Refit +{ + /// Internal query and header helpers exposed to focused tests. + internal partial class RequestBuilderImplementation + { + /// Determines whether a value should be emitted directly rather than expanded into a query map. + /// The value to inspect. + /// if the value is a simple/formattable type; otherwise . + [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] + internal static bool DoNotConvertToQueryMap(object value) + { + var type = value.GetType(); + + // Bail out early & match string + if (ShouldReturn(type)) + { + return true; + } + + if (value is not IEnumerable) + { + return false; + } + + // Get the element type for enumerables + var ienu = typeof(IEnumerable<>); + + // We don't want to enumerate to get the type, so we'll just look for IEnumerable + Type? intType = null; + var interfaces = type.GetInterfaces(); + for (var i = 0; i < interfaces.Length; i++) + { + var interfaceType = interfaces[i]; + if (interfaceType.GetTypeInfo().IsGenericType + && interfaceType.GetGenericTypeDefinition() == ienu) + { + intType = interfaceType; + break; + } + } + + if (intType is null) + { + return false; + } + + type = intType.GetGenericArguments()[0]; + return ShouldReturn(type); + + // Check if type is a simple string or IFormattable type, check underlying type if Nullable + static bool ShouldReturn(Type type) => + Nullable.GetUnderlyingType(type) is { } underlyingType + ? ShouldReturn(underlyingType) + : type == typeof(string) + || type == typeof(bool) + || type == typeof(char) + || typeof(IFormattable).IsAssignableFrom(type) + || type == typeof(Uri) + || typeof(CultureInfo).IsAssignableFrom(type); + } + + /// Sets or replaces a header on the request or its content, with CRLF-injection protection. + /// The request to modify. + /// The header name. + /// The header value, or null to only remove the header. + internal static void SetHeader(HttpRequestMessage request, string name, string? value) + { + // Clear any existing version of this header that might be set, because + // we want to allow removal/redefinition of headers. + // We also don't want to double up content headers which may have been + // set for us automatically. + // NB: We have to enumerate the header names to check existence because + // Contains throws if it's the wrong header type for the collection. + // HTTP header names are case-insensitive, so compare them that way; otherwise a + // differently cased header (e.g. "Content-type" vs "Content-Type") is not removed + // and ends up duplicated. + if (ContainsHeader(request.Headers, name)) + { + request.Headers.Remove(name); + } + + if (request.Content is not null && ContainsHeader(request.Content.Headers, name)) + { + request.Content.Headers.Remove(name); + } + + if (value is null) + { + return; + } + + // CRLF injection protection + name = EnsureSafe(name); + value = EnsureSafe(value); + + var added = request.Headers.TryAddWithoutValidation(name, value); + + // Don't even bother trying to add the header as a content header + // if we just added it to the other collection. + if (added || request.Content is null) + { + return; + } + + request.Content.Headers.TryAddWithoutValidation(name, value); + } + } +} diff --git a/src/Refit/RequestBuilderImplementation.QueryAndHeaders.cs b/src/Refit/RequestBuilderImplementation.QueryAndHeaders.cs index 068c62c24..9cc65af78 100644 --- a/src/Refit/RequestBuilderImplementation.QueryAndHeaders.cs +++ b/src/Refit/RequestBuilderImplementation.QueryAndHeaders.cs @@ -3,19 +3,28 @@ // See the LICENSE file in the project root for full license information. using System.Collections; -using System.Globalization; -using System.Net.Http.Headers; +using System.Diagnostics.CodeAnalysis; using System.Reflection; +using System.Runtime.CompilerServices; using System.Web; -#if NET5_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; -#endif namespace Refit { /// Reflection-based request builder that turns Refit interface calls into HTTP requests. internal partial class RequestBuilderImplementation { + /// Caches query-map properties by type without keeping collectible types alive. + [SuppressMessage( + "Style", + "IDE0028:Simplify collection initialization", + Justification = "ConditionalWeakTable collection expressions do not compile for all target frameworks.")] + [SuppressMessage( + "Style", + "IDE0090:Simplify new expression", + Justification = "Keeping the explicit type avoids collection-expression suggestions that do not compile for all target frameworks.")] + private static readonly ConditionalWeakTable QueryPropertyCache = + new ConditionalWeakTable(); + /// Determines whether a property should be skipped when building the query map. /// The property to inspect. /// Optional parameter info used to skip path-bound properties. @@ -92,13 +101,15 @@ private static void ParseExistingQueryString(UriBuilder uri, ref List(key, query[key])); + new(key, query[key])); } } } @@ -106,7 +117,7 @@ private static void ParseExistingQueryString(UriBuilder uri, ref ListBuilds an escaped query string from the collected key/value pairs. /// The query parameters to encode. /// The encoded query string. - [System.Diagnostics.CodeAnalysis.SuppressMessage( + [SuppressMessage( "Major Code Smell", "S2930:\"IDisposables\" should be disposed", Justification = "ValueStringBuilder.ToString() disposes the builder and returns its pooled buffer; Dispose is idempotent.")] @@ -114,8 +125,9 @@ private static string CreateQueryString(List> quer { var vsb = new ValueStringBuilder(stackalloc char[StackallocThreshold]); var firstQuery = true; - foreach (var queryParam in queryParamsToAdd) + for (var i = 0; i < queryParamsToAdd.Count; i++) { + var queryParam = queryParamsToAdd[i]; if (queryParam is not { Key: not null, Value: not null }) { continue; @@ -148,106 +160,6 @@ private static string CreateQueryString(List> quer return vsb.ToString(); } - /// Determines whether a value should be emitted directly rather than expanded into a query map. - /// The value to inspect. - /// if the value is a simple/formattable type; otherwise . -#if NET5_0_OR_GREATER - [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] -#endif - private static bool DoNotConvertToQueryMap(object? value) - { - if (value is null) - { - return false; - } - - var type = value.GetType(); - - // Bail out early & match string - if (ShouldReturn(type)) - { - return true; - } - - if (value is not IEnumerable) - { - return false; - } - - // Get the element type for enumerables - var ienu = typeof(IEnumerable<>); - - // We don't want to enumerate to get the type, so we'll just look for IEnumerable - var intType = type.GetInterfaces() - .FirstOrDefault( - i => i.GetTypeInfo().IsGenericType && i.GetGenericTypeDefinition() == ienu); - - if (intType is null) - { - return false; - } - - type = intType.GetGenericArguments()[0]; - return ShouldReturn(type); - - // Check if type is a simple string or IFormattable type, check underlying type if Nullable - static bool ShouldReturn(Type type) => - Nullable.GetUnderlyingType(type) is { } underlyingType - ? ShouldReturn(underlyingType) - : type == typeof(string) - || type == typeof(bool) - || type == typeof(char) - || typeof(IFormattable).IsAssignableFrom(type) - || type == typeof(Uri) - || typeof(CultureInfo).IsAssignableFrom(type); - } - - /// Sets or replaces a header on the request or its content, with CRLF-injection protection. - /// The request to modify. - /// The header name. - /// The header value, or null to only remove the header. - private static void SetHeader(HttpRequestMessage request, string name, string? value) - { - // Clear any existing version of this header that might be set, because - // we want to allow removal/redefinition of headers. - // We also don't want to double up content headers which may have been - // set for us automatically. - // NB: We have to enumerate the header names to check existence because - // Contains throws if it's the wrong header type for the collection. - // HTTP header names are case-insensitive, so compare them that way; otherwise a - // differently cased header (e.g. "Content-type" vs "Content-Type") is not removed - // and ends up duplicated. - if (request.Headers.Any(x => string.Equals(x.Key, name, StringComparison.OrdinalIgnoreCase))) - { - request.Headers.Remove(name); - } - - if (request.Content?.Headers.Any(x => string.Equals(x.Key, name, StringComparison.OrdinalIgnoreCase)) == true) - { - request.Content.Headers.Remove(name); - } - - if (value is null) - { - return; - } - - // CRLF injection protection - name = EnsureSafe(name); - value = EnsureSafe(value); - - var added = request.Headers.TryAddWithoutValidation(name, value); - - // Don't even bother trying to add the header as a content header - // if we just added it to the other collection. - if (added || request.Content is null) - { - return; - } - - request.Content.Headers.TryAddWithoutValidation(name, value); - } - /// Strips CR and LF characters from a header value to prevent header injection. /// The value to sanitize. /// The value with carriage-return and line-feed characters removed. @@ -265,28 +177,78 @@ private static string EnsureSafe(string value) => /// for GET and HEAD; otherwise . private static bool IsBodyless(HttpMethod method) => method == HttpMethod.Get || method == HttpMethod.Head; - /// Populates the Authorization header from the configured token getter when present. - /// The request to add the header to. - /// A token to cancel the getter. - /// A task that completes when the header has been set. - private async Task AddAuthorizationHeadersFromGetterAsync(HttpRequestMessage request, CancellationToken cancellationToken) + /// Checks whether a header collection contains a key without throwing for unsupported header types. + /// The header collection to inspect. + /// The header name. + /// when the header key exists; otherwise . + private static bool ContainsHeader(System.Net.Http.Headers.HttpHeaders headers, string name) { - if (_settings.AuthorizationHeaderValueGetter is null) + foreach (var header in headers) { - return; + if (string.Equals(header.Key, name, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// Determines whether a property can be read through its public getter. + /// The property to inspect. + /// when the property is readable; otherwise . + private static bool IsReadablePublicProperty(PropertyInfo property) => + property.CanRead && property.GetMethod?.IsPublic == true; + + /// Gets cached readable public instance properties for query-map expansion. + /// The object type to inspect. + /// The readable public instance properties. + private static PropertyInfo[] GetQueryProperties( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + Type type) + { + var properties = type.GetProperties(BindingFlags.Instance | BindingFlags.Public); + var count = 0; + for (var i = 0; i < properties.Length; i++) + { + if (IsReadablePublicProperty(properties[i])) + { + count++; + } } - var auth = request.Headers.Authorization; - if (auth is null || !string.IsNullOrWhiteSpace(auth.Parameter)) + if (count == properties.Length) { - return; + return properties; } - var token = await _settings.AuthorizationHeaderValueGetter(request, cancellationToken) - .ConfigureAwait(false); - request.Headers.Authorization = new AuthenticationHeaderValue(auth.Scheme, token); + var readableProperties = new PropertyInfo[count]; + var index = 0; + for (var i = 0; i < properties.Length; i++) + { + if (IsReadablePublicProperty(properties[i])) + { + readableProperties[index++] = properties[i]; + } + } + + return readableProperties; } + /// Gets cached query-map properties for the given type. + /// The object type to inspect. + /// The readable public instance properties. + [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] + private static PropertyInfo[] GetCachedQueryProperties(Type type) => + QueryPropertyCache.GetValue(type, GetQueryProperties); + + /// Populates the Authorization header from the configured token getter when present. + /// The request to add the header to. + /// A token to cancel the getter. + /// A task that completes when the header has been set. + private Task AddAuthorizationHeadersFromGetterAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + RequestExecutionHelpers.AddAuthorizationHeaderFromGetterAsync(request, _settings, cancellationToken); + /// Adds configured options/properties and Refit metadata to the request. /// The rest method being invoked. /// The request message to populate. @@ -299,7 +261,7 @@ private void AddPropertiesToRequest(RestMethodInfoInternal restMethod, HttpReque foreach (var p in _settings.HttpRequestMessageOptions) { #if NET6_0_OR_GREATER - ret.Options.Set(new HttpRequestOptionsKey(p.Key), p.Value); + ret.Options.Set(new(p.Key), p.Value); #else ret.Properties.Add(p); #endif @@ -312,7 +274,7 @@ private void AddPropertiesToRequest(RestMethodInfoInternal restMethod, HttpReque { #if NET6_0_OR_GREATER ret.Options.Set( - new HttpRequestOptionsKey(propertyKey), + new(propertyKey), paramList[i]); #else ret.Properties[propertyKey] = paramList[i]; @@ -323,10 +285,10 @@ private void AddPropertiesToRequest(RestMethodInfoInternal restMethod, HttpReque // Always add the top-level type of the interface to the properties #if NET6_0_OR_GREATER ret.Options.Set( - new HttpRequestOptionsKey(HttpRequestMessageOptions.InterfaceType), + new(HttpRequestMessageOptions.InterfaceType), TargetType); ret.Options.Set( - new HttpRequestOptionsKey( + new( HttpRequestMessageOptions.RestMethodInfo), restMethod.RestMethodInfo); #else @@ -352,12 +314,10 @@ private void AddVersionToRequest(HttpRequestMessage ret) /// Optional parameter info used to skip path-bound properties. /// The collection format for enumerable values. /// The query-string key/value pairs. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private List> BuildQueryMap( - object? @object, + object @object, string? delimiter = null, RestMethodParameterInfo? parameterInfo = null, CollectionFormat? collectionFormat = null) @@ -369,18 +329,10 @@ private void AddVersionToRequest(HttpRequestMessage ret) var kvps = new List>(); - if (@object is null) - { - return kvps; - } - - var props = @object - .GetType() - .GetProperties(BindingFlags.Instance | BindingFlags.Public) - .Where(p => p.CanRead && p.GetMethod?.IsPublic == true); - - foreach (var propertyInfo in props) + var props = GetCachedQueryProperties(@object.GetType()); + for (var i = 0; i < props.Length; i++) { + var propertyInfo = props[i]; AppendPropertyToQueryMap(@object, propertyInfo, kvps, delimiter, parameterInfo, collectionFormat); } @@ -392,10 +344,8 @@ private void AddVersionToRequest(HttpRequestMessage ret) /// The delimiter used between nested keys. /// The collection format for enumerable values. /// The query-string key/value pairs. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private List> BuildQueryMap( IDictionary dictionary, string? delimiter = null, @@ -421,14 +371,16 @@ private void AddVersionToRequest(HttpRequestMessage ret) if (DoNotConvertToQueryMap(obj)) { - kvps.Add(new KeyValuePair(formattedKey!, obj)); + kvps.Add(new(formattedKey!, obj)); } else { - foreach (var keyValuePair in BuildQueryMap(obj, delimiter, null, collectionFormat)) + var nestedQueryMap = BuildQueryMap(obj, delimiter, null, collectionFormat); + for (var i = 0; i < nestedQueryMap.Count; i++) { + var keyValuePair = nestedQueryMap[i]; kvps.Add( - new KeyValuePair( + new( formattedKey + delimiter + keyValuePair.Key, keyValuePair.Value)); } @@ -445,10 +397,8 @@ private void AddVersionToRequest(HttpRequestMessage ret) /// The delimiter used between nested property names. /// Optional parameter info used to skip path-bound properties. /// The collection format for enumerable values. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private void AppendPropertyToQueryMap( object @object, PropertyInfo propertyInfo, @@ -466,15 +416,14 @@ private void AppendPropertyToQueryMap( var aliasAttribute = propertyInfo.GetCustomAttribute(); var key = aliasAttribute?.Name ?? _settings.UrlParameterKeyFormatter.Format(propertyInfo.Name); - // Look to see if the property has a Query attribute, and if so, format it accordingly var queryAttribute = propertyInfo.GetCustomAttribute(); - if (queryAttribute is { Format: not null }) + if (!TryFormatQueryPropertyValue(queryAttribute, obj, out var formattedObj)) { - obj = _settings.FormUrlEncodedParameterFormatter.Format( - obj, - queryAttribute.Format); + return; } + obj = formattedObj; + // If obj is IEnumerable - format it accounting for Query attribute and CollectionFormat if (obj is not string and IEnumerable ienu and not IDictionary) { @@ -484,13 +433,45 @@ private void AppendPropertyToQueryMap( if (DoNotConvertToQueryMap(obj)) { - kvps.Add(new KeyValuePair(key, obj)); + kvps.Add(new(key, obj)); return; } AppendNestedQueryMap(obj, key, kvps, delimiter, collectionFormat); } + /// Applies a property-level query format, if present. + /// The value type. + /// The query attribute, if any. + /// The value to format. + /// Receives the formatted value. + /// when a non-null value remains. + [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] + [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] + private bool TryFormatQueryPropertyValue( + QueryAttribute? queryAttribute, + T value, + [NotNullWhen(true)] out object? formattedValue) + { + if (queryAttribute is not { Format: not null }) + { + formattedValue = value!; + return true; + } + + var formatted = _settings.FormUrlEncodedParameterFormatter.Format( + value, + queryAttribute.Format); + if (formatted is null) + { + formattedValue = null; + return false; + } + + formattedValue = formatted; + return true; + } + /// Appends each formatted element of an enumerable property to the query map under one key. /// The enumerable property value. /// The property being flattened. @@ -498,10 +479,8 @@ private void AppendPropertyToQueryMap( /// The accumulating list of query pairs. /// The property's query attribute, if any. /// The collection format for enumerable values. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private void AppendEnumerablePropertyValues( IEnumerable values, PropertyInfo propertyInfo, @@ -511,13 +490,13 @@ private void AppendEnumerablePropertyValues( CollectionFormat? collectionFormat) { foreach (var value in ParseEnumerableQueryParameterValue( - values, - propertyInfo, - propertyInfo.PropertyType, - queryAttribute, - collectionFormat)) + values, + propertyInfo, + propertyInfo.PropertyType, + queryAttribute, + collectionFormat)) { - kvps.Add(new KeyValuePair(key, value)); + kvps.Add(new(key, value)); } } @@ -527,12 +506,10 @@ private void AppendEnumerablePropertyValues( /// The accumulating list of query pairs. /// The delimiter used between nested keys. /// The collection format for enumerable values. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private void AppendNestedQueryMap( - object? obj, + object obj, string key, List> kvps, string? delimiter, @@ -542,10 +519,11 @@ private void AppendNestedQueryMap( ? BuildQueryMap(idict, delimiter, collectionFormat) : BuildQueryMap(obj, delimiter, null, collectionFormat); - foreach (var keyValuePair in nested) + for (var i = 0; i < nested.Count; i++) { + var keyValuePair = nested[i]; kvps.Add( - new KeyValuePair( + new( key + delimiter + keyValuePair.Key, keyValuePair.Value)); } diff --git a/src/Refit/RequestBuilderImplementation.RequestBuilding.cs b/src/Refit/RequestBuilderImplementation.RequestBuilding.cs index f25807ec4..ad7de4417 100644 --- a/src/Refit/RequestBuilderImplementation.RequestBuilding.cs +++ b/src/Refit/RequestBuilderImplementation.RequestBuilding.cs @@ -4,12 +4,10 @@ using System.Collections; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Net.Http.Headers; using System.Reflection; using System.Text; -#if NET5_0_OR_GREATER -using System.Diagnostics.CodeAnalysis; -#endif namespace Refit { @@ -17,20 +15,12 @@ namespace Refit internal partial class RequestBuilderImplementation { /// Cached reflection handle to the generic body-serialization method. - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Major Code Smell", - "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", - Justification = "Refit must invoke its own non-public generic serialization helper by reflection.")] -#if NET5_0_OR_GREATER - [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + [UnconditionalSuppressMessage( "Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' may break when trimming", Justification = "The reflective serialization path is only reached from public APIs already annotated with RequiresUnreferencedCode; the source generator is the trim-safe alternative.")] -#endif private static readonly MethodInfo SerializeBodyMethod = - typeof(RequestBuilderImplementation).GetMethod( - nameof(SerializeBodyGeneric), - BindingFlags.Static | BindingFlags.NonPublic)!; + FindDeclaredMethod(nameof(SerializeBodyGeneric)); /// Maps a single header, header-collection or authorization parameter into the pending headers. /// The rest method being invoked. @@ -91,10 +81,8 @@ private static void AddHeaderCollection(object? param, ref DictionaryThe rest method being invoked. /// The index of the parameter. /// when the parameter is property-only and should not also feed the query string. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private static bool IsPropertyOnlyParameter(RestMethodInfoInternal restMethod, int i) => restMethod.PropertyParameterMap.ContainsKey(i) && restMethod.ParameterInfoArray[i].GetCustomAttribute() is null; @@ -104,10 +92,8 @@ private static bool IsPropertyOnlyParameter(RestMethodInfoInternal restMethod, i /// The body value to serialize. /// The declared (static) type of the body. /// The serialized HTTP content. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private static HttpContent SerializeBody( IHttpContentSerializer serializer, object? body, @@ -122,27 +108,55 @@ private static HttpContent SerializeBody( /// The content serializer to use. /// The body value to serialize. /// The serialized HTTP content. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif - [System.Diagnostics.CodeAnalysis.SuppressMessage( + [SuppressMessage( "Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private static HttpContent SerializeBodyGeneric(IHttpContentSerializer serializer, object? body) => serializer.ToHttpContent((T)body!); + /// Returns a copy of an argument array with cancellation tokens removed. + /// The original argument values. + /// The argument values used for request mapping. + private static object[] RemoveCancellationTokens(object[] paramList) + { + var count = 0; + for (var i = 0; i < paramList.Length; i++) + { + if (paramList[i] is not CancellationToken) + { + count++; + } + } + + if (count == paramList.Length) + { + return paramList; + } + + var mappedParams = new object[count]; + var index = 0; + for (var i = 0; i < paramList.Length; i++) + { + if (paramList[i] is not CancellationToken) + { + mappedParams[index++] = paramList[i]; + } + } + + return mappedParams; + } + /// Builds the full request message for a method invocation, including body, headers and query. /// The rest method being invoked. /// The base path from the client's base address. /// Whether the argument list contains a cancellation token. /// The argument values for the call. /// The constructed request message. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private async Task BuildRequestMessageForMethodAsync( RestMethodInfoInternal restMethod, string basePath, @@ -153,8 +167,8 @@ private static HttpContent SerializeBodyGeneric(IHttpContentSerializer serial if (paramsContainsCancellationToken) { - cancellationToken = paramList.OfType().FirstOrDefault(); - paramList = [.. paramList.Where(o => o is not CancellationToken)]; + cancellationToken = GetCancellationToken(paramList); + paramList = RemoveCancellationTokens(paramList); } var ret = new HttpRequestMessage { Method = restMethod.HttpMethod }; @@ -163,7 +177,7 @@ private static HttpContent SerializeBodyGeneric(IHttpContentSerializer serial MultipartFormDataContent? multiPartContent = null; if (restMethod.IsMultipart) { - multiPartContent = new MultipartFormDataContent(restMethod.MultipartBoundary); + multiPartContent = new(restMethod.MultipartBoundary); ret.Content = multiPartContent; } @@ -199,10 +213,8 @@ await AddAuthorizationHeadersFromGetterAsync(ret, cancellationToken) /// The multipart content, when the method is multipart. /// The pending header collection, created as needed. /// The pending query parameter collection, created as needed. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private void MapParametersToRequest( RestMethodInfoInternal restMethod, object[] paramList, @@ -262,10 +274,8 @@ private void MapParametersToRequest( /// The request message being populated. /// The pending header collection, created as needed. /// when the parameter was fully mapped and needs no further handling. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private bool MapSingleParameterToRequest( RestMethodInfoInternal restMethod, int i, @@ -303,10 +313,8 @@ private bool MapSingleParameterToRequest( /// The base path from the client's base address. /// The argument values for the call. /// The query parameters collected for the request, if any. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private void AssignRequestUri( RestMethodInfoInternal restMethod, HttpRequestMessage ret, @@ -323,7 +331,7 @@ private void AssignRequestUri( ? CreateQueryString(queryParamsToAdd) : null; - ret.RequestUri = new Uri( + ret.RequestUri = new( uri.Uri.GetComponents(UriComponents.PathAndQuery, restMethod.QueryUriFormat), UriKind.Relative); } @@ -333,10 +341,8 @@ private void AssignRequestUri( /// The rest method being invoked. /// The argument values used to resolve dynamic fragments. /// The fully expanded relative path. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private string BuildRelativePath(string basePath, RestMethodInfoInternal restMethod, object[] paramList) { // Every path fragment is prefixed with '/', so trim a trailing slash from the @@ -358,8 +364,9 @@ private string BuildRelativePath(string basePath, RestMethodInfoInternal restMet var vsb = new ValueStringBuilder(stackalloc char[StackallocThreshold]); vsb.Append(basePath); - foreach (var fragment in pathFragments) + for (var i = 0; i < pathFragments.Count; i++) { + var fragment = pathFragments[i]; AppendPathFragmentValue(ref vsb, restMethod, paramList, fragment); } @@ -371,10 +378,8 @@ private string BuildRelativePath(string basePath, RestMethodInfoInternal restMet /// The rest method being invoked. /// The argument values used to resolve the fragment. /// The path fragment to append. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private void AppendPathFragmentValue( ref ValueStringBuilder vsb, RestMethodInfoInternal restMethod, @@ -413,10 +418,8 @@ private void AppendPathFragmentValue( /// The argument values used to resolve the fragment. /// The path fragment to append. /// The parameter info for the fragment. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private void AppendObjectPropertyFragment( ref ValueStringBuilder vsb, object[] paramList, @@ -439,10 +442,8 @@ private void AppendObjectPropertyFragment( /// The argument values used to resolve the fragment. /// The path fragment to append. /// The parameter info for the fragment. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private void AppendDynamicRouteFragment( ref ValueStringBuilder vsb, RestMethodInfoInternal restMethod, @@ -463,26 +464,30 @@ private void AppendDynamicRouteFragment( return; } - // If round tripping, split string up, format each segment and append to vsb. + // If round tripping, format each path segment independently. Debug.Assert(parameterMapValue.Type == ParameterType.RoundTripping, "Dynamic route fragments must be Normal or RoundTripping."); var paramValue = (string)param; - var split = paramValue.Split('/'); - - var firstSection = true; - foreach (var section in split) + var sectionStart = 0; + for (var i = 0; i <= paramValue.Length; i++) { - if (!firstSection) + if (i != paramValue.Length && paramValue[i] != '/') + { + continue; + } + + if (sectionStart > 0) { vsb.Append('/'); } + var section = paramValue.Substring(sectionStart, i - sectionStart); vsb.Append( Uri.EscapeDataString( _settings.UrlParameterFormatter.Format( section, parameterInfo, parameterInfo.ParameterType) ?? string.Empty)); - firstSection = false; + sectionStart = i + 1; } } @@ -490,10 +495,8 @@ private void AppendDynamicRouteFragment( /// The rest method being invoked. /// The body argument value. /// The request message to populate. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private void AddBodyToRequest(RestMethodInfoInternal restMethod, object param, HttpRequestMessage ret) { if (param is HttpContent httpContentParam) @@ -529,9 +532,12 @@ private void AddBodyToRequest(RestMethodInfoInternal restMethod, object param, H break; } -#pragma warning disable CS0618 // Type or member is obsolete + // BodySerializationMethod.Json is obsolete, but the reflection path must still + // accept legacy [Body(BodySerializationMethod.Json)] usage from compiled callers. + // Falling through to Default would incorrectly send string bodies as raw text. +#pragma warning disable CS0618 // Required for legacy BodySerializationMethod.Json compatibility. case BodySerializationMethod.Default or BodySerializationMethod.Json or BodySerializationMethod.Serialized: -#pragma warning restore CS0618 // Type or member is obsolete +#pragma warning restore CS0618 // Compatibility switch complete; re-enable obsolete warnings. { AddSerializedBodyToRequest(restMethod, param, ret); break; @@ -543,10 +549,8 @@ private void AddBodyToRequest(RestMethodInfoInternal restMethod, object param, H /// The rest method being invoked. /// The body argument value. /// The request message to populate. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private void AddSerializedBodyToRequest(RestMethodInfoInternal restMethod, object param, HttpRequestMessage ret) { var declaredBodyType = restMethod.ParameterInfoArray[ @@ -579,10 +583,8 @@ await content /// The list of query parameters being built. /// The index of the parameter. /// Optional parameter info for property-bound parameters. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private void AddQueryParameters( RestMethodInfoInternal restMethod, QueryAttribute? queryAttribute, @@ -594,40 +596,42 @@ private void AddQueryParameters( var attr = queryAttribute ?? DefaultQueryAttribute; if (attr.TreatAsString) { - queryParamsToAdd.AddRange( - ParseQueryParameter( - param.ToString(), - restMethod.ParameterInfoArray[i], - restMethod.QueryParameterMap[i], - attr)); + AppendQueryParameter( + queryParamsToAdd, + param.ToString(), + restMethod.ParameterInfoArray[i], + restMethod.QueryParameterMap[i], + attr); return; } if (DoNotConvertToQueryMap(param)) { - queryParamsToAdd.AddRange( - ParseQueryParameter( - param, - restMethod.ParameterInfoArray[i], - restMethod.QueryParameterMap[i], - attr)); + AppendQueryParameter( + queryParamsToAdd, + param, + restMethod.ParameterInfoArray[i], + restMethod.QueryParameterMap[i], + attr); return; } var parameterCollectionFormat = attr.IsCollectionFormatSpecified ? attr.CollectionFormat : (CollectionFormat?)null; - foreach (var kvp in BuildQueryMap(param, attr.Delimiter, parameterInfo, parameterCollectionFormat)) + var queryMap = BuildQueryMap(param, attr.Delimiter, parameterInfo, parameterCollectionFormat); + for (var queryMapIndex = 0; queryMapIndex < queryMap.Count; queryMapIndex++) { + var kvp = queryMap[queryMapIndex]; var path = !string.IsNullOrWhiteSpace(attr.Prefix) ? attr.Prefix + attr.Delimiter + kvp.Key : kvp.Key; - queryParamsToAdd.AddRange( - ParseQueryParameter( - kvp.Value, - restMethod.ParameterInfoArray[i], - path, - attr)); + AppendQueryParameter( + queryParamsToAdd, + kvp.Value, + restMethod.ParameterInfoArray[i], + path, + attr); } } @@ -636,10 +640,8 @@ private void AddQueryParameters( /// The index of the parameter. /// The argument value, which may be a single item or an enumerable. /// The multipart content to add to. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private void AddMultiPart( RestMethodInfoInternal restMethod, int i, @@ -681,10 +683,8 @@ private void AddMultiPart( /// The file name to use for file-like parts. /// The form field name for the part. /// The value to add. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private void AddMultipartItem( MultipartFormDataContent multiPartContent, string fileName, @@ -742,10 +742,8 @@ private void AddMultipartItem( /// The file name used in the error message. /// The form field name for the part. /// The value to serialize and add. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private void AddSerializedMultipartItem( MultipartFormDataContent multiPartContent, string fileName, @@ -774,17 +772,16 @@ private void AddSerializedMultipartItem( e); } - /// Produces query key/value pairs for a single parameter value. + /// Appends query key/value pairs for a single parameter value. + /// The list receiving query parameters. /// The parameter value. /// Reflection info for the parameter. /// The query key path for the parameter. /// The query attribute governing formatting. - /// The query key/value pairs. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif - private IEnumerable> ParseQueryParameter( + private void AppendQueryParameter( + List> queryParamsToAdd, object? param, ParameterInfo parameterInfo, string queryPath, @@ -793,23 +790,24 @@ private void AddSerializedMultipartItem( if (param is not string and IEnumerable paramValues) { foreach (var value in ParseEnumerableQueryParameterValue( - paramValues, - parameterInfo, - parameterInfo.ParameterType, - queryAttribute)) + paramValues, + parameterInfo, + parameterInfo.ParameterType, + queryAttribute)) { - yield return new KeyValuePair(queryPath, value); + queryParamsToAdd.Add(new(queryPath, value)); } + + return; } - else - { - yield return new KeyValuePair( + + queryParamsToAdd.Add( + new( queryPath, _settings.UrlParameterFormatter.Format( param, parameterInfo, - parameterInfo.ParameterType)); - } + parameterInfo.ParameterType))); } /// Formats an enumerable parameter value according to the effective collection format. @@ -819,10 +817,8 @@ private void AddSerializedMultipartItem( /// The query attribute governing the collection format, if any. /// The collection format to use when none is specified. /// The formatted query values. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private IEnumerable ParseEnumerableQueryParameterValue( IEnumerable paramValues, ICustomAttributeProvider customAttributeProvider, @@ -861,16 +857,58 @@ private void AddSerializedMultipartItem( }; // Missing a "default" clause was preventing the collection from serializing at all, as it was hitting "continue" thus causing an off-by-one error - var formattedValues = paramValues - .Cast() - .Select( - v => + yield return JoinFormattedQueryValues(paramValues, customAttributeProvider, type, delimiter); + } + + /// Formats and joins an enumerable query value without LINQ adapters. + /// The enumerable values to format. + /// The attribute provider for the parameter or property. + /// The element type used for formatting. + /// The delimiter between formatted values. + /// The joined formatted values. + [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] + [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] + [SuppressMessage( + "Major Code Smell", + "S2930:\"IDisposables\" should be disposed", + Justification = "ValueStringBuilder.ToString() disposes the builder and returns its pooled buffer; Dispose is idempotent.")] + private string JoinFormattedQueryValues( + IEnumerable paramValues, + ICustomAttributeProvider customAttributeProvider, + Type type, + string delimiter) + { + var enumerator = paramValues.GetEnumerator(); + try + { + if (!enumerator.MoveNext()) + { + return string.Empty; + } + + var builder = new ValueStringBuilder(stackalloc char[StackallocThreshold]); + builder.Append( + _settings.UrlParameterFormatter.Format( + enumerator.Current, + customAttributeProvider, + type)); + + while (enumerator.MoveNext()) + { + builder.Append(delimiter); + builder.Append( _settings.UrlParameterFormatter.Format( - v, + enumerator.Current, customAttributeProvider, type)); + } - yield return string.Join(delimiter, formattedValues); + return builder.ToString(); + } + finally + { + (enumerator as IDisposable)?.Dispose(); + } } } } diff --git a/src/Refit/RequestBuilderImplementation.cs b/src/Refit/RequestBuilderImplementation.cs index 6843bda50..def73e3af 100644 --- a/src/Refit/RequestBuilderImplementation.cs +++ b/src/Refit/RequestBuilderImplementation.cs @@ -3,10 +3,8 @@ // See the LICENSE file in the project root for full license information. using System.Collections.Concurrent; -using System.Reflection; -#if NET5_0_OR_GREATER using System.Diagnostics.CodeAnalysis; -#endif +using System.Reflection; namespace Refit { @@ -16,12 +14,6 @@ internal partial class RequestBuilderImplementation : IRequestBuilder /// Maximum stack-allocated buffer size, in characters, used when building paths and query strings. private const int StackallocThreshold = 512; - /// The message used when content cannot be deserialized into the requested type. - private const string DeserializationErrorMessage = "An error occured deserializing the response."; - - /// The error message used when the HTTP client has no base address configured. - private const string BaseAddressRequiredMessage = "BaseAddress must be set on the HttpClient instance"; - /// The default query attribute applied when a parameter has none. private static readonly QueryAttribute DefaultQueryAttribute = new(); @@ -45,23 +37,11 @@ internal partial class RequestBuilderImplementation : IRequestBuilder /// Initializes a new instance of the class for the given interface type. /// The Refit interface type to build requests for. /// The settings to use, or null for defaults. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Minor Code Smell", - "SST1114:Remove the blank line between the declaration and the first parameter", - Justification = "False positive: the #if-guarded parameter attribute is required for trim annotations but is unavailable on non-net5 targets.")] - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Minor Code Smell", - "SST1115:Remove the blank line before this parameter", - Justification = "False positive: the #if-guarded parameter attribute is required for trim annotations but is unavailable on non-net5 targets.")] public RequestBuilderImplementation( -#if NET5_0_OR_GREATER [DynamicallyAccessedMembers( DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] -#endif Type refitInterfaceType, RefitSettings? refitSettings = null) { @@ -75,7 +55,7 @@ public RequestBuilderImplementation( _settings = refitSettings ?? new RefitSettings(); _serializer = _settings.ContentSerializer; _interfaceGenericHttpMethods = - new ConcurrentDictionary(); + new(); TargetType = refitInterfaceType; @@ -94,21 +74,16 @@ public RequestBuilderImplementation( public Type TargetType { get; } /// -#if NET5_0_OR_GREATER + public RefitSettings Settings => _settings; + + /// [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif public Func BuildRestResultFuncForMethod( string methodName, Type[]? parameterTypes = null, Type[]? genericArgumentTypes = null) { - if (!_interfaceHttpMethods.ContainsKey(methodName)) - { - throw new ArgumentException( - "Method must be defined and have an HTTP Method attribute"); - } - var restMethod = FindMatchingRestMethodInfo( methodName, parameterTypes, @@ -138,15 +113,31 @@ public RequestBuilderImplementation( return BuildResultFuncForMethod(restMethod, nameof(BuildRxFuncForMethod)); } - var isExplicitInterfaceMember = restMethod.MethodInfo.Name.Contains('.'); - var isNonPublic = !restMethod.MethodInfo.IsPublic; - if (isExplicitInterfaceMember || isNonPublic) + return BuildGeneratedSyncFuncForMethod(restMethod); + } + + /// Finds a method declared on this implementation type by name. + /// The method name. + /// The declared method. + [UnconditionalSuppressMessage( + "Trimming", + "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' may break when trimming", + Justification = "This only resolves Refit's own generic delegate factories for the reflection-based request builder path, which is already annotated as not trim-safe.")] + [UnconditionalSuppressMessage( + "Trimming", + "IL2111:Reflection access to methods with DynamicallyAccessedMembersAttribute", + Justification = "This helper filters by known method names and does not invoke methods with dynamic-access requirements.")] + internal static MethodInfo FindDeclaredMethod(string name) + { + foreach (var method in typeof(RequestBuilderImplementation).GetTypeInfo().DeclaredMethods) { - return BuildGeneratedSyncFuncForMethod(restMethod); + if (method.Name == name) + { + return method; + } } - throw new ArgumentException( - $"Method \"{restMethod.MethodInfo.Name}\" is invalid. All REST Methods must return either Task or ValueTask or IObservable"); + throw new MissingMethodException(typeof(RequestBuilderImplementation).FullName, name); } /// Gets the lookup key for a method, stripping any explicit-interface prefix from the name. @@ -156,7 +147,7 @@ private static string GetLookupKeyForMethod(MethodInfo methodInfo) { var name = methodInfo.Name; var lastDot = name.LastIndexOf('.'); - return lastDot >= 0 ? name.Substring(lastDot + 1) : name; + return lastDot >= 0 ? name[(lastDot + 1)..] : name; } /// Determines whether the method's return type is a closed generic of the supplied open generic type. @@ -178,25 +169,39 @@ private static RestMethodInfoInternal[] FilterPossibleMethods( Type[]? genericArgumentTypes) { var isGeneric = genericArgumentTypes?.Length > 0; + List? possibleMethods = null; + + for (var i = 0; i < httpMethods.Count; i++) + { + var method = httpMethods[i]; + if (method.MethodInfo.GetParameters().Length != parameterTypes.Length) + { + continue; + } - var possibleMethodsCollection = httpMethods.Where( - method => method.MethodInfo.GetParameters().Length == parameterTypes.Length); + if (isGeneric) + { + if (!method.MethodInfo.IsGenericMethod + || method.MethodInfo.GetGenericArguments().Length != genericArgumentTypes!.Length) + { + continue; + } + } + else if (method.MethodInfo.IsGenericMethod) + { + continue; + } - possibleMethodsCollection = isGeneric - ? possibleMethodsCollection.Where( - method => - method.MethodInfo.IsGenericMethod - && method.MethodInfo.GetGenericArguments().Length - == genericArgumentTypes!.Length) - : possibleMethodsCollection.Where( - method => !method.MethodInfo.IsGenericMethod); + possibleMethods ??= []; + possibleMethods.Add(method); + } - return [.. possibleMethodsCollection]; + return possibleMethods is null ? [] : [.. possibleMethods]; } /// Runs an asynchronous task factory synchronously and waits for completion. /// The task factory to run. - [System.Diagnostics.CodeAnalysis.SuppressMessage( + [SuppressMessage( "Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Deliberate sync-over-async bridge for synchronous (void/non-Task) interface methods that have no async caller; the work is offloaded via Task.Run to avoid deadlocks.")] @@ -207,7 +212,7 @@ private static void RunSynchronous(Func taskFactory) => /// The result type. /// The task factory to run. /// The result produced by the task. - [System.Diagnostics.CodeAnalysis.SuppressMessage( + [SuppressMessage( "Usage", "VSTHRD002:Avoid problematic synchronous waits", Justification = "Deliberate sync-over-async bridge for synchronous (non-Task) interface methods that have no async caller; the work is offloaded via Task.Run to avoid deadlocks.")] @@ -219,7 +224,7 @@ private static void RunSynchronous(Func taskFactory) => /// The in-flight request task. /// The linked cancellation source to dispose when the task finishes. /// The result produced by . - [System.Diagnostics.CodeAnalysis.SuppressMessage( + [SuppressMessage( "Usage", "VSTHRD003:Avoid awaiting foreign Tasks", Justification = "The task is the request just launched by the caller; awaiting it here only scopes disposal of the linked cancellation source.")] @@ -235,52 +240,66 @@ private static void RunSynchronous(Func taskFactory) => } } + /// Determines whether reflected parameters exactly match the requested parameter types. + /// The reflected method parameters. + /// The requested parameter types. + /// when the parameter types match. + private static bool ParametersMatch(ParameterInfo[] parameters, Type[] parameterTypes) + { + for (var i = 0; i < parameters.Length; i++) + { + if (parameters[i].ParameterType != parameterTypes[i]) + { + return false; + } + } + + return true; + } + + /// Finds the first cancellation token in an argument array. + /// The argument values. + /// The first cancellation token, or . + private static CancellationToken GetCancellationToken(object[] paramList) + { + for (var i = 0; i < paramList.Length; i++) + { + if (paramList[i] is CancellationToken cancellationToken) + { + return cancellationToken; + } + } + + return CancellationToken.None; + } + /// Discovers the Refit HTTP methods on an interface and adds them to the lookup dictionary. /// The interface to scan for HTTP methods. /// The dictionary to populate with discovered methods. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] -#endif - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Major Code Smell", - "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", - Justification = "Refit must bind to non-public interface members to resolve explicit interface implementations.")] - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Minor Code Smell", - "SST1114:Remove the blank line between the declaration and the first parameter", - Justification = "False positive: the #if-guarded parameter attribute is required for trim annotations but is unavailable on non-net5 targets.")] - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Minor Code Smell", - "SST1115:Remove the blank line before this parameter", - Justification = "False positive: the #if-guarded parameter attribute is required for trim annotations but is unavailable on non-net5 targets.")] private void AddInterfaceHttpMethods( -#if NET5_0_OR_GREATER [DynamicallyAccessedMembers( DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] -#endif Type interfaceType, Dictionary> methods) { - var methodInfos = interfaceType - .GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) - .Where(i => i.IsAbstract); - - foreach (var methodInfo in methodInfos) + foreach (var methodInfo in interfaceType.GetTypeInfo().DeclaredMethods) { - var attrs = methodInfo.GetCustomAttributes(true); - var hasHttpMethod = attrs.OfType().Any(); - if (hasHttpMethod) + if (!methodInfo.IsAbstract + || methodInfo.GetCustomAttribute(true) is null) { - var key = GetLookupKeyForMethod(methodInfo); - if (!methods.TryGetValue(key, out var value)) - { - value = []; - methods.Add(key, value); - } + continue; + } - var restinfo = new RestMethodInfoInternal(interfaceType, methodInfo, _settings); - value.Add(restinfo); + var key = GetLookupKeyForMethod(methodInfo); + if (!methods.TryGetValue(key, out var value)) + { + value = []; + methods.Add(key, value); } + + var restinfo = new RestMethodInfoInternal(interfaceType, methodInfo, _settings); + value.Add(restinfo); } } @@ -289,10 +308,8 @@ private void AddInterfaceHttpMethods( /// The parameter types to match, or null to match a single overload. /// The generic argument types to close over, or null. /// The matching rest method info. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private RestMethodInfoInternal FindMatchingRestMethodInfo( string key, Type[]? parameterTypes, @@ -324,11 +341,7 @@ private RestMethodInfoInternal FindMatchingRestMethodInfo( foreach (var method in possibleMethods) { - var match = method - .MethodInfo.GetParameters() - .Select(p => p.ParameterType) - .SequenceEqual(parameterTypes); - if (match) + if (ParametersMatch(method.MethodInfo.GetParameters(), parameterTypes)) { return CloseGenericMethodIfNeeded(method, genericArgumentTypes); } @@ -341,10 +354,8 @@ private RestMethodInfoInternal FindMatchingRestMethodInfo( /// The (possibly generic) rest method. /// The generic argument types, or null if not generic. /// The closed rest method info, or the original when no generic arguments are supplied. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private RestMethodInfoInternal CloseGenericMethodIfNeeded( RestMethodInfoInternal restMethodInfo, Type[]? genericArgumentTypes) @@ -352,9 +363,9 @@ private RestMethodInfoInternal CloseGenericMethodIfNeeded( if (genericArgumentTypes is not null) { return _interfaceGenericHttpMethods.GetOrAdd( - new CloseGenericMethodKey(restMethodInfo.MethodInfo, genericArgumentTypes), + new(restMethodInfo.MethodInfo, genericArgumentTypes), _ => - new RestMethodInfoInternal( + new( restMethodInfo.Type, restMethodInfo.MethodInfo.MakeGenericMethod(genericArgumentTypes), restMethodInfo.RefitSettings)); @@ -367,21 +378,13 @@ private RestMethodInfoInternal CloseGenericMethodIfNeeded( /// The rest method to build a delegate for. /// The name of the private generic builder method. /// A delegate that invokes the method. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Major Code Smell", - "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", - Justification = "Refit must invoke its own non-public generic builder methods by reflection.")] private Func BuildResultFuncForMethod( RestMethodInfoInternal restMethod, string builderMethodName) { - var builderMethodInfo = typeof(RequestBuilderImplementation).GetMethod( - builderMethodName, - BindingFlags.NonPublic | BindingFlags.Instance); + var builderMethodInfo = FindDeclaredMethod(builderMethodName); var resultFunc = (MulticastDelegate?) builderMethodInfo!.MakeGenericMethod( restMethod.ReturnResultType, @@ -394,14 +397,8 @@ private RestMethodInfoInternal CloseGenericMethodIfNeeded( /// Builds a synchronous invocation delegate for a generated (sync) interface method. /// The rest method to build a delegate for. /// A delegate that invokes the method synchronously. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Major Code Smell", - "S3011:Reflection should not be used to increase accessibility of classes, methods, or fields", - Justification = "Refit must invoke its own non-public generic builder methods by reflection.")] private Func BuildGeneratedSyncFuncForMethod( RestMethodInfoInternal restMethod) { @@ -420,9 +417,7 @@ private RestMethodInfoInternal CloseGenericMethodIfNeeded( }; } - var syncFuncMi = typeof(RequestBuilderImplementation).GetMethod( - nameof(BuildGeneratedSyncFuncForMethodGeneric), - BindingFlags.NonPublic | BindingFlags.Instance); + var syncFuncMi = FindDeclaredMethod(nameof(BuildGeneratedSyncFuncForMethodGeneric)); var func = syncFuncMi! .MakeGenericMethod( @@ -437,11 +432,9 @@ private RestMethodInfoInternal CloseGenericMethodIfNeeded( /// The body type used for API responses. /// The rest method to build a delegate for. /// A delegate that invokes the method synchronously. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif - [System.Diagnostics.CodeAnalysis.SuppressMessage( + [SuppressMessage( "Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] @@ -463,11 +456,9 @@ private RestMethodInfoInternal CloseGenericMethodIfNeeded( /// The body type used for API responses. /// The rest method to build a delegate for. /// A delegate that returns an observable of the result. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif - [System.Diagnostics.CodeAnalysis.SuppressMessage( + [SuppressMessage( "Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] @@ -482,7 +473,7 @@ private RestMethodInfoInternal CloseGenericMethodIfNeeded( var methodCt = CancellationToken.None; if (restMethod.CancellationToken is not null) { - methodCt = paramList.OfType().FirstOrDefault(); + methodCt = GetCancellationToken(paramList); } // link the two @@ -500,11 +491,9 @@ private RestMethodInfoInternal CloseGenericMethodIfNeeded( /// The body type used for API responses. /// The rest method to build a delegate for. /// A delegate that returns a task of the result. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif - [System.Diagnostics.CodeAnalysis.SuppressMessage( + [SuppressMessage( "Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] @@ -519,7 +508,7 @@ private RestMethodInfoInternal CloseGenericMethodIfNeeded( { return ret( client, - paramList.OfType().FirstOrDefault(), + GetCancellationToken(paramList), paramList); } @@ -532,11 +521,9 @@ private RestMethodInfoInternal CloseGenericMethodIfNeeded( /// The body type used for API responses. /// The rest method to build a delegate for. /// A delegate that returns a value task of the result. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif - [System.Diagnostics.CodeAnalysis.SuppressMessage( + [SuppressMessage( "Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] @@ -545,16 +532,14 @@ private RestMethodInfoInternal CloseGenericMethodIfNeeded( { var ret = BuildTaskFuncForMethod(restMethod); - return (client, paramList) => new ValueTask(ret(client, paramList)); + return (client, paramList) => new(ret(client, paramList)); } /// Builds a task invocation delegate for a method with no response body. /// The rest method to build a delegate for. /// A delegate that returns a task with no result. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif private Func BuildVoidTaskFuncForMethod( RestMethodInfoInternal restMethod) { @@ -564,7 +549,7 @@ private Func BuildVoidTaskFuncForMethod( if (restMethod.CancellationToken is not null) { - ct = paramList.OfType().FirstOrDefault(); + ct = GetCancellationToken(paramList); } return ExecuteVoidRequestAsync( diff --git a/src/Refit/RequestBuilderImplementation{TApi}.cs b/src/Refit/RequestBuilderImplementation{TApi}.cs index 13bf99ab0..aa655becb 100644 --- a/src/Refit/RequestBuilderImplementation{TApi}.cs +++ b/src/Refit/RequestBuilderImplementation{TApi}.cs @@ -2,29 +2,21 @@ // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -#if NET5_0_OR_GREATER using System.Diagnostics.CodeAnalysis; -#endif namespace Refit { /// Typed request builder that targets a specific Refit interface. /// The Refit interface type requests are built for. -#if NET5_0_OR_GREATER internal class RequestBuilderImplementation< [ DynamicallyAccessedMembers( DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] TApi> : RequestBuilderImplementation, IRequestBuilder -#else - internal class RequestBuilderImplementation : RequestBuilderImplementation, IRequestBuilder -#endif { /// Initializes a new instance of the class. /// The settings to use, or null for defaults. -#if NET5_0_OR_GREATER [RequiresUnreferencedCode("Refit's reflection-based request building is not trim-safe; use the Refit source generator for trimmed/AOT apps.")] [RequiresDynamicCode("Refit's reflection-based request building requires runtime code generation; use the Refit source generator for AOT apps.")] -#endif public RequestBuilderImplementation(RefitSettings? refitSettings = null) : base(typeof(TApi), refitSettings) { diff --git a/src/Refit/RequestExecutionHelpers.cs b/src/Refit/RequestExecutionHelpers.cs new file mode 100644 index 000000000..bfa4b7d57 --- /dev/null +++ b/src/Refit/RequestExecutionHelpers.cs @@ -0,0 +1,507 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +namespace Refit; + +/// Shared send and response-processing helpers for generated and reflection-built requests. +internal static class RequestExecutionHelpers +{ + /// The message used when content cannot be deserialized into the requested type. + private const string DeserializationErrorMessage = "An error occured deserializing the response."; + + /// The error message used when the HTTP client has no base address configured. + private const string BaseAddressRequiredMessage = "BaseAddress must be set on the HttpClient instance"; + + /// Throws when a client cannot build relative generated requests. + /// The HTTP client to inspect. + /// Thrown when no base address is configured. + public static void ThrowIfBaseAddressMissing(HttpClient client) + { + if (client.BaseAddress is not null) + { + return; + } + + throw new InvalidOperationException(BaseAddressRequiredMessage); + } + + /// Sends a request with no response body, throwing on HTTP errors. + /// The HTTP client to send with. + /// The request message. + /// The Refit settings to use. + /// Whether request content should be buffered before sending. + /// Whether to apply the configured authorization getter before sending. + /// A token to cancel the request. + /// A task that completes when the request finishes. + public static async Task SendVoidAsync( + HttpClient client, + HttpRequestMessage request, + RefitSettings settings, + bool bufferBody, + bool applyAuthorizationHeader, + CancellationToken cancellationToken) + { + if (bufferBody && request.Content is not null) + { + await request.Content.LoadIntoBufferAsync(cancellationToken).ConfigureAwait(false); + } + + if (applyAuthorizationHeader) + { + await AddAuthorizationHeaderFromGetterAsync(request, settings, cancellationToken) + .ConfigureAwait(false); + } + + using var response = await client + .SendAsync(request, cancellationToken) + .ConfigureAwait(false); + + var exception = await settings.ExceptionFactory(response).ConfigureAwait(false); + if (exception is null) + { + return; + } + + throw exception; + } + + /// Buffers, sends, and processes the response for a request. + /// The result type returned to the caller. + /// The deserialized body type for API response wrappers. + /// The HTTP client to send with. + /// The request message. + /// The Refit settings to use. + /// The send and response-processing options. + /// A token to cancel the request. + /// The deserialized or wrapped response. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "CodeQuality", + "S1541:Methods and properties should not be too complex", + Justification = "This is Refit's shared response state machine; keeping it centralized avoids duplicated generated/reflection hot paths.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "TBody is intentionally passed explicitly by generated and reflection callers for ApiResponse body deserialization.")] + public static async Task SendAndProcessResponseAsync( + HttpClient client, + HttpRequestMessage request, + RefitSettings settings, + RequestExecutionOptions options, + CancellationToken cancellationToken) + { + HttpResponseMessage? response = null; + HttpContent? content = null; + var disposeResponse = true; + try + { + if (options.BufferBody && request.Content is not null) + { + await request.Content.LoadIntoBufferAsync(cancellationToken).ConfigureAwait(false); + } + + if (options.ApplyAuthorizationHeader) + { + await AddAuthorizationHeaderFromGetterAsync(request, settings, cancellationToken) + .ConfigureAwait(false); + } + + var sendResult = await SendOrCaptureExceptionAsync( + client, + request, + settings, + options.IsApiResponse, + cancellationToken) + .ConfigureAwait(false); + if (!sendResult.HasResponse) + { + return sendResult.FailureResult; + } + + response = sendResult.Response!; + content = response.Content ?? new StringContent(string.Empty); + disposeResponse = options.ShouldDisposeResponse; + + var exception = typeof(T) != typeof(HttpResponseMessage) + ? await settings.ExceptionFactory(response).ConfigureAwait(false) + : null; + + if (options.IsApiResponse) + { + return await BuildApiResponseAsync( + request, + response, + content, + settings, + exception, + cancellationToken) + .ConfigureAwait(false); + } + + if (exception is not null) + { + disposeResponse = false; + throw exception; + } + + return await DeserializeOrThrowAsync( + request, + response, + content, + settings, + cancellationToken) + .ConfigureAwait(false); + } + finally + { + if (disposeResponse) + { + response?.Dispose(); + content?.Dispose(); + } + } + } + + /// Populates an empty Authorization header through the configured token getter. + /// The request to modify. + /// The Refit settings to use. + /// A token to cancel the token getter. + /// A task that completes when the header has been updated. + public static async Task AddAuthorizationHeaderFromGetterAsync( + HttpRequestMessage request, + RefitSettings settings, + CancellationToken cancellationToken) + { + if (settings.AuthorizationHeaderValueGetter is null) + { + return; + } + + var auth = request.Headers.Authorization; + if (auth is null || !string.IsNullOrWhiteSpace(auth.Parameter)) + { + return; + } + + var token = await settings.AuthorizationHeaderValueGetter(request, cancellationToken) + .ConfigureAwait(false); + request.Headers.Authorization = new(auth.Scheme, token); + } + + /// Sends the request, capturing a transport failure as an API response when required. + /// The result type returned to the caller. + /// The deserialized body type for API response wrappers. + /// The HTTP client to send with. + /// The request message. + /// The Refit settings to use. + /// Whether the result type is an API response wrapper. + /// A token to cancel the request. + /// The send result. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "TBody is intentionally passed explicitly by callers for ApiResponse failure wrapping.")] + private static async Task> SendOrCaptureExceptionAsync( + HttpClient client, + HttpRequestMessage request, + RefitSettings settings, + bool isApiResponse, + CancellationToken cancellationToken) + { + try + { + var response = await client + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + return SendResult.FromResponse(response); + } + catch (Exception ex) + { + if (!isApiResponse) + { + throw new ApiRequestException(request, request.Method, settings, ex); + } + + var failure = ApiResponse.Create( + request, + null, + default, + settings, + new ApiRequestException(request, request.Method, settings, ex)); + return SendResult.FromFailure(failure); + } + } + + /// Builds an API response, deserializing content unless an earlier error exists. + /// The result type returned to the caller. + /// The deserialized body type. + /// The request message. + /// The response message. + /// The response content. + /// The Refit settings to use. + /// An exception already produced by the exception factory, if any. + /// A token to cancel the read. + /// The constructed API response. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "TBody is intentionally passed explicitly by callers for ApiResponse body deserialization.")] + private static async Task BuildApiResponseAsync( + HttpRequestMessage request, + HttpResponseMessage response, + HttpContent content, + RefitSettings settings, + Exception? existingException, + CancellationToken cancellationToken) + { + var exception = existingException; + var body = default(TBody); + + try + { + body = + exception is null + ? await DeserializeContentAsync(response, content, settings, cancellationToken) + .ConfigureAwait(false) + : default; + } + catch (Exception ex) + { + exception = await CreateDeserializationExceptionAsync(request, response, settings, ex) + .ConfigureAwait(false); + } + + return ApiResponse.Create( + request, + response, + body, + settings, + exception as ApiException); + } + + /// Deserializes the response content, throwing a wrapped exception on failure. + /// The result type returned to the caller. + /// The request message. + /// The response message. + /// The response content. + /// The Refit settings to use. + /// A token to cancel the read. + /// The deserialized result. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "Callers intentionally close the result type; type inference is not part of this helper contract.")] + private static async Task DeserializeOrThrowAsync( + HttpRequestMessage request, + HttpResponseMessage response, + HttpContent content, + RefitSettings settings, + CancellationToken cancellationToken) + { + try + { + return await DeserializeContentAsync(response, content, settings, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + if (settings.DeserializationExceptionFactory is not null) + { + var customEx = await settings + .DeserializationExceptionFactory(response, ex) + .ConfigureAwait(false); + if (customEx is not null) + { + throw customEx; + } + + return default; + } + + throw await ApiException.Create( + DeserializationErrorMessage, + request, + request.Method, + response, + settings, + ex).ConfigureAwait(false); + } + } + + /// Produces a wrapped deserialization exception using the configured factory or default behavior. + /// The request message. + /// The response message. + /// The Refit settings to use. + /// The original exception. + /// The wrapped exception, or null when a configured factory returns null. + private static async Task CreateDeserializationExceptionAsync( + HttpRequestMessage request, + HttpResponseMessage response, + RefitSettings settings, + Exception exception) + { + if (settings.DeserializationExceptionFactory is not null) + { + return await settings.DeserializationExceptionFactory(response, exception) + .ConfigureAwait(false); + } + + return await ApiException.Create( + DeserializationErrorMessage, + request, + request.Method, + response, + settings, + exception).ConfigureAwait(false); + } + + /// Deserializes the response content into the requested type. + /// The type to deserialize into. + /// The response message. + /// The response content. + /// The Refit settings to use. + /// A token to cancel the read. + /// The deserialized value, or default when there is no content. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "Callers intentionally close the result type; type inference is not part of this helper contract.")] + private static async Task DeserializeContentAsync( + HttpResponseMessage response, + HttpContent content, + RefitSettings settings, + CancellationToken cancellationToken) + { + if (typeof(T) == typeof(HttpResponseMessage)) + { + return (T)(object)response; + } + + if (typeof(T) == typeof(HttpContent)) + { + return (T)(object)content; + } + + if (typeof(T) == typeof(Stream)) + { + var stream = (object) + await content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + return (T)stream; + } + + if (typeof(T) == typeof(string)) + { + var stream = await content + .ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + using (stream) + { + using var reader = new StreamReader(stream); +#if NET8_0_OR_GREATER + var str = (object)await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); +#else + cancellationToken.ThrowIfCancellationRequested(); + var str = (object)await reader.ReadToEndAsync().ConfigureAwait(false); +#endif + return (T)str; + } + } + + return await DeserializeSerializedContentAsync( + response, + content, + settings, + cancellationToken) + .ConfigureAwait(false); + } + + /// Buffers and deserializes serialized content via the configured serializer. + /// The type to deserialize into. + /// The response message. + /// The response content. + /// The Refit settings to use. + /// A token to cancel the read. + /// The deserialized value, or default when there is no content. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "Callers intentionally close the result type; type inference is not part of this helper contract.")] + private static async Task DeserializeSerializedContentAsync( + HttpResponseMessage response, + HttpContent content, + RefitSettings settings, + CancellationToken cancellationToken) + { + if (response.StatusCode == System.Net.HttpStatusCode.NoContent + || content.Headers.ContentLength == 0) + { + return default; + } + + await TryBufferContentAsync(content, cancellationToken).ConfigureAwait(false); + + return await settings.ContentSerializer + .FromHttpContentAsync(content, cancellationToken) + .ConfigureAwait(false); + } + + /// Attempts to buffer content into memory, ignoring buffering failures. + /// The content to buffer. + /// A token to cancel buffering. + /// A task that completes once buffering has been attempted. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1031:Do not catch general exception types", + Justification = "Best-effort buffering matches the existing runtime response path.")] + private static async Task TryBufferContentAsync(HttpContent content, CancellationToken cancellationToken) + { + try + { + await content.LoadIntoBufferAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception bufferingException) + { + _ = bufferingException; + } + } + + /// The outcome of attempting to send a request. + /// The result type returned to the caller. + private readonly record struct SendResult + { + /// Initializes a new instance of the struct. + /// Whether the send produced a response. + /// The response, or null when the send failed. + /// The captured failure result, valid only when is false. + private SendResult(bool hasResponse, HttpResponseMessage? response, T? failureResult) + { + HasResponse = hasResponse; + Response = response; + FailureResult = failureResult; + } + + /// Gets a value indicating whether the send produced a response. + public bool HasResponse { get; } + + /// Gets the response, or null when the send failed. + public HttpResponseMessage? Response { get; } + + /// Gets the captured failure result, valid only when is false. + public T? FailureResult { get; } + + /// Creates a successful result wrapping the given response. + /// The response produced by the send. + /// A result indicating a response is present. + public static SendResult FromResponse(HttpResponseMessage response) => + new(true, response, default); + + /// Creates a failed result wrapping the captured failure value. + /// The captured failure result. + /// A result indicating the send failed. + public static SendResult FromFailure(T? failureResult) => + new(false, null, failureResult); + } +} diff --git a/src/Refit/RequestExecutionOptions.cs b/src/Refit/RequestExecutionOptions.cs new file mode 100644 index 000000000..2249d12b4 --- /dev/null +++ b/src/Refit/RequestExecutionOptions.cs @@ -0,0 +1,61 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Refit; + +/// Controls request sending and response handling for generated and reflection-built requests. +internal readonly struct RequestExecutionOptions : IEquatable +{ + /// Initializes a new instance of the struct. + /// Whether the caller expects an API response wrapper. + /// Whether the response should be disposed by the response processor. + /// Whether request content should be buffered before sending. + /// Whether to run the authorization-header value getter before sending. + public RequestExecutionOptions( + bool isApiResponse, + bool shouldDisposeResponse, + bool bufferBody, + bool applyAuthorizationHeader) + { + IsApiResponse = isApiResponse; + ShouldDisposeResponse = shouldDisposeResponse; + BufferBody = bufferBody; + ApplyAuthorizationHeader = applyAuthorizationHeader; + } + + /// Gets a value indicating whether the caller expects an API response wrapper. + public bool IsApiResponse { get; } + + /// Gets a value indicating whether the response should be disposed by the response processor. + public bool ShouldDisposeResponse { get; } + + /// Gets a value indicating whether request content should be buffered before sending. + public bool BufferBody { get; } + + /// Gets a value indicating whether the authorization-header getter should run before sending. + public bool ApplyAuthorizationHeader { get; } + + /// + public static bool operator ==(RequestExecutionOptions left, RequestExecutionOptions right) => + left.Equals(right); + + /// + public static bool operator !=(RequestExecutionOptions left, RequestExecutionOptions right) => + !left.Equals(right); + + /// + public bool Equals(RequestExecutionOptions other) => + IsApiResponse == other.IsApiResponse + && ShouldDisposeResponse == other.ShouldDisposeResponse + && BufferBody == other.BufferBody + && ApplyAuthorizationHeader == other.ApplyAuthorizationHeader; + + /// + public override bool Equals(object? obj) => + obj is RequestExecutionOptions other && Equals(other); + + /// + public override int GetHashCode() => + HashCode.Combine(IsApiResponse, ShouldDisposeResponse, BufferBody, ApplyAuthorizationHeader); +} diff --git a/src/Refit/RestMethodInfoInternal.cs b/src/Refit/RestMethodInfoInternal.cs index 04563b561..5df2ddf1b 100644 --- a/src/Refit/RestMethodInfoInternal.cs +++ b/src/Refit/RestMethodInfoInternal.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for full license information. using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.RegularExpressions; @@ -42,15 +43,18 @@ public RestMethodInfoInternal( RefitSettings? refitSettings = null) { RefitSettings = refitSettings ?? new RefitSettings(); - Type = targetInterface ?? throw new ArgumentNullException(nameof(targetInterface)); - MethodInfo = methodInfo ?? throw new ArgumentNullException(nameof(methodInfo)); + ArgumentExceptionHelper.ThrowIfNull(targetInterface); + ArgumentExceptionHelper.ThrowIfNull(methodInfo); + Type = targetInterface; + MethodInfo = methodInfo; - var hma = methodInfo.GetCustomAttributes(true).OfType().First(); + var hma = methodInfo.GetCustomAttribute(true) + ?? throw new InvalidOperationException("Sequence contains no elements"); HttpMethod = hma.Method; RelativePath = hma.Path; - IsMultipart = methodInfo.GetCustomAttributes(true).OfType().Any(); + IsMultipart = methodInfo.GetCustomAttribute(true) is not null; MultipartBoundary = GetMultipartBoundary(methodInfo, IsMultipart); @@ -63,12 +67,7 @@ public RestMethodInfoInternal( ShouldDisposeResponse = DetermineIfResponseMustBeDisposed(DeserializedResultType); // Exclude cancellation token parameters from this list - ParameterInfoArray = - [ - .. methodInfo - .GetParameters() - .Where(static p => p.ParameterType != typeof(CancellationToken) && - p.ParameterType != typeof(CancellationToken?))]; + ParameterInfoArray = GetNonCancellationTokenParameters(methodInfo.GetParameters()); (ParameterMap, FragmentPath) = BuildParameterMap(RelativePath, ParameterInfoArray); BodyParameterInfo = FindBodyParameter(ParameterInfoArray, IsMultipart, hma.Method); AuthorizeParameterInfo = FindAuthorizationParameter(ParameterInfoArray); @@ -178,6 +177,51 @@ .. methodInfo public bool HeaderCollectionAt(int index) => _headerCollectionParameterIndex >= 0 && _headerCollectionParameterIndex == index; + /// Removes cancellation-token parameters from a reflected parameter array. + /// The reflected method parameters. + /// The parameters used for request mapping. + private static ParameterInfo[] GetNonCancellationTokenParameters(ParameterInfo[] parameters) + { + var count = 0; + for (var i = 0; i < parameters.Length; i++) + { + if (!IsCancellationTokenParameter(parameters[i])) + { + count++; + } + } + + if (count == parameters.Length) + { + return parameters; + } + + var mappedParameters = new ParameterInfo[count]; + var index = 0; + for (var i = 0; i < parameters.Length; i++) + { + if (!IsCancellationTokenParameter(parameters[i])) + { + mappedParameters[index++] = parameters[i]; + } + } + + return mappedParameters; + } + + /// Determines whether a parameter is a cancellation token. + /// The parameter to inspect. + /// for cancellation-token parameters. + private static bool IsCancellationTokenParameter(ParameterInfo parameter) => + parameter.ParameterType == typeof(CancellationToken) + || parameter.ParameterType == typeof(CancellationToken?); + + /// Determines whether a property can be read through its public getter. + /// The property to inspect. + /// when the property is readable; otherwise . + private static bool IsReadablePublicProperty(PropertyInfo property) => + property.CanRead && property.GetMethod?.IsPublic == true; + /// Resolves the multipart boundary text for the method, defaulting when unspecified. /// The reflected method information. /// A value indicating whether the request is multipart. @@ -218,10 +262,7 @@ private static int GetHeaderCollectionParameterIndex(ParameterInfo[] parameterAr for (var i = 0; i < parameterArray.Length; i++) { var param = parameterArray[i]; - var headerCollection = param - .GetCustomAttributes(true) - .OfType() - .FirstOrDefault(); + var headerCollection = param.GetCustomAttribute(true); if (headerCollection is null) { @@ -258,10 +299,7 @@ private static Dictionary BuildRequestPropertyMap(ParameterInfo[] p for (var i = 0; i < parameterArray.Length; i++) { var param = parameterArray[i]; - var propertyAttribute = param - .GetCustomAttributes(true) - .OfType() - .FirstOrDefault(); + var propertyAttribute = param.GetCustomAttribute(true); if (propertyAttribute is not null) { @@ -281,10 +319,35 @@ private static Dictionary BuildRequestPropertyMap(ParameterInfo[] p /// The readable public instance properties. [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode( "Refit reflects over complex parameter types when expanding route-bound properties.")] - private static IEnumerable GetParameterProperties(ParameterInfo parameter) => - parameter - .ParameterType.GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Where(static p => p.CanRead && p.GetMethod?.IsPublic == true); + private static PropertyInfo[] GetParameterProperties(ParameterInfo parameter) + { + var properties = parameter.ParameterType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + var count = 0; + for (var i = 0; i < properties.Length; i++) + { + if (IsReadablePublicProperty(properties[i])) + { + count++; + } + } + + if (count == properties.Length) + { + return properties; + } + + var readableProperties = new PropertyInfo[count]; + var index = 0; + for (var i = 0; i < properties.Length; i++) + { + if (IsReadablePublicProperty(properties[i])) + { + readableProperties[index++] = properties[i]; + } + } + + return readableProperties; + } /// Verifies that the relative URL path is well formed and free of injection characters. /// The relative URL path to validate. @@ -317,6 +380,14 @@ private static void VerifyUrlPathIsSane(string relativePath) /// A tuple containing the parameter map and the ordered list of URL fragments. [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode( "Refit reflects over complex parameter types when building route parameter maps.")] + [SuppressMessage( + "Major Code Smell", + "S3776:Cognitive Complexity of methods should not be too high", + Justification = "Request route parsing has several validation branches that are clearer kept together.")] + [SuppressMessage( + "Major Code Smell", + "S1541:Methods and properties should not be too complex", + Justification = "Request route parsing has several validation branches that are clearer kept together.")] private static (Dictionary Map, List Fragments) BuildParameterMap( string relativePath, @@ -324,31 +395,49 @@ private static (Dictionary Map, List(); - var parameterizedParts = ParameterRegex().Matches(relativePath).Cast().ToArray(); + var parameterizedParts = ParameterRegex().Matches(relativePath); - if (parameterizedParts.Length == 0) + if (parameterizedParts.Count == 0) { return string.IsNullOrEmpty(relativePath) ? (ret, []) : (ret, [ParameterFragment.Constant(relativePath)]); } - var paramValidationDict = parameterInfo.ToDictionary( - k => GetUrlNameForParameter(k).ToLowerInvariant(), - v => v); + var paramValidationDict = new Dictionary(parameterInfo.Length); + for (var i = 0; i < parameterInfo.Length; i++) + { + paramValidationDict[GetUrlNameForParameter(parameterInfo[i]).ToLowerInvariant()] = parameterInfo[i]; + } // If the parameter is a class, build a dictionary for all of its potential bound properties. - var objectParamValidationDict = parameterInfo - .Where(x => x.ParameterType.GetTypeInfo().IsClass) - .SelectMany(x => GetParameterProperties(x).Select(p => Tuple.Create(x, p))) - .GroupBy(i => $"{i.Item1.Name}.{GetUrlNameForProperty(i.Item2)}".ToLowerInvariant()) - .ToDictionary(k => k.Key, v => v.First()); + var objectParamValidationDict = new Dictionary>(); + for (var i = 0; i < parameterInfo.Length; i++) + { + var parameter = parameterInfo[i]; + if (!parameter.ParameterType.GetTypeInfo().IsClass) + { + continue; + } + + var properties = GetParameterProperties(parameter); + for (var j = 0; j < properties.Length; j++) + { + var key = $"{parameter.Name}.{GetUrlNameForProperty(properties[j])}".ToLowerInvariant(); + if (!objectParamValidationDict.ContainsKey(key)) + { + objectParamValidationDict.Add(key, Tuple.Create(parameter, properties[j])); + } + } + } var fragmentList = new List(); var index = 0; - foreach (var match in parameterizedParts) + for (var i = 0; i < parameterizedParts.Count; i++) { + var match = parameterizedParts[i]; + // Add constant value from given http path if (match.Index != index) { @@ -359,7 +448,7 @@ private static (Dictionary Map, List Map, List Map, ListThe aliased or declared parameter name. private static string GetUrlNameForParameter(ParameterInfo paramInfo) { - var aliasAttr = paramInfo - .GetCustomAttributes(true) - .OfType() - .FirstOrDefault(); + var aliasAttr = paramInfo.GetCustomAttribute(true); return aliasAttr is not null ? aliasAttr.Name : paramInfo.Name!; } @@ -499,10 +585,7 @@ private static string GetUrlNameForParameter(ParameterInfo paramInfo) /// The aliased or declared property name. private static string GetUrlNameForProperty(PropertyInfo propInfo) { - var aliasAttr = propInfo - .GetCustomAttributes(true) - .OfType() - .FirstOrDefault(); + var aliasAttr = propInfo.GetCustomAttribute(true); return aliasAttr is not null ? aliasAttr.Name : propInfo.Name; } @@ -512,14 +595,11 @@ private static string GetUrlNameForProperty(PropertyInfo propInfo) private static string GetAttachmentNameForParameter(ParameterInfo paramInfo) { #pragma warning disable CS0618 // Type or member is obsolete - var nameAttr = paramInfo - .GetCustomAttributes(true) + var nameAttr = paramInfo.GetCustomAttribute(true); #pragma warning restore CS0618 // Type or member is obsolete - .FirstOrDefault(); // also check for AliasAs - return nameAttr?.Name - ?? paramInfo.GetCustomAttributes(true).FirstOrDefault()?.Name!; + return nameAttr?.Name ?? paramInfo.GetCustomAttribute(true)?.Name!; } /// Finds the parameter that carries the authorization value. @@ -527,30 +607,34 @@ private static string GetAttachmentNameForParameter(ParameterInfo paramInfo) /// The authorization parameter information, or null when there is no authorize parameter. private static Tuple? FindAuthorizationParameter(ParameterInfo[] parameterArray) { - var authorizeParamsEnumerable = parameterArray - .Select(x => - ( - Parameter: x, - AuthorizeAttribute: x.GetCustomAttributes(true) - .OfType() - .FirstOrDefault() - )) - .Where(x => x.AuthorizeAttribute is not null) - .TryGetSingle(out var authorizeParam); + ParameterInfo? authorizeParameter = null; + AuthorizeAttribute? authorizeAttribute = null; + var authorizeIndex = -1; - if (authorizeParamsEnumerable == EnumerablePeek.Many) + for (var i = 0; i < parameterArray.Length; i++) { - throw new ArgumentException("Only one parameter can be an Authorize parameter"); + var attribute = parameterArray[i].GetCustomAttribute(true); + if (attribute is null) + { + continue; + } + + if (authorizeParameter is not null) + { + throw new ArgumentException("Only one parameter can be an Authorize parameter"); + } + + authorizeParameter = parameterArray[i]; + authorizeAttribute = attribute; + authorizeIndex = i; } - if (authorizeParamsEnumerable != EnumerablePeek.Single) + if (authorizeParameter is null || authorizeAttribute is null) { return null; } - return Tuple.Create( - authorizeParam!.AuthorizeAttribute!.Scheme, - Array.IndexOf(parameterArray, authorizeParam.Parameter)); + return Tuple.Create(authorizeAttribute.Scheme, authorizeIndex); } /// Finds the single cancellation token parameter for the method. @@ -558,68 +642,98 @@ private static string GetAttachmentNameForParameter(ParameterInfo paramInfo) /// The cancellation token parameter, or null when none is present. private static ParameterInfo? FindCancellationTokenParameter(MethodInfo methodInfo) { - var cancellationTokenPeek = methodInfo - .GetParameters() - .Where(p => p.ParameterType == typeof(CancellationToken) || - p.ParameterType == typeof(CancellationToken?)) - .TryGetSingle(out var cancellationTokenParam); - - if (cancellationTokenPeek == EnumerablePeek.Many) + var parameters = methodInfo.GetParameters(); + ParameterInfo? cancellationTokenParam = null; + for (var i = 0; i < parameters.Length; i++) { - throw new ArgumentException( - $"Argument list to method \"{methodInfo.Name}\" can only contain a single CancellationToken"); + if (!IsCancellationTokenParameter(parameters[i])) + { + continue; + } + + if (cancellationTokenParam is not null) + { + throw new ArgumentException( + $"Argument list to method \"{methodInfo.Name}\" can only contain a single CancellationToken"); + } + + cancellationTokenParam = parameters[i]; } return cancellationTokenParam; } + /// Adds headers from a into the accumulated map. + /// The header attribute to process. + /// The accumulated map, created as needed. + private static void AddHeaders(HeadersAttribute headersAttribute, ref Dictionary? ret) + { + var headers = headersAttribute.Headers; + for (var i = 0; i < headers.Length; i++) + { + var header = headers[i]; + if (string.IsNullOrWhiteSpace(header)) + { + continue; + } + + ret ??= []; + var colonIndex = header.IndexOf(':'); + if (colonIndex < 0) + { + ret[header.Trim()] = null; + continue; + } + + ret[header[..colonIndex].Trim()] = header[(colonIndex + 1)..].Trim(); + } + } + /// Parses the static headers declared on the method and its declaring type. /// The reflected method information. /// A map of header names to header values. [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode( "Refit reflects over inherited interface metadata when composing header maps.")] + [SuppressMessage( + "Major Code Smell", + "S3776:Cognitive Complexity of methods should not be too high", + Justification = "Header precedence is order-sensitive and easier to audit in one method.")] private static Dictionary ParseHeaders(MethodInfo methodInfo) { - var inheritedAttributes = - methodInfo.DeclaringType is not null - ? methodInfo - .DeclaringType.GetInterfaces() - .SelectMany(i => i.GetTypeInfo().GetCustomAttributes(true)) - .Reverse() - : []; - - var declaringTypeAttributes = - methodInfo.DeclaringType is not null - ? methodInfo.DeclaringType.GetTypeInfo().GetCustomAttributes(true) - : []; - - // Headers set on the declaring type have to come first, - // so headers set on the method can replace them. Switching - // the order here will break stuff. - var headers = inheritedAttributes - .Concat(declaringTypeAttributes) - .Concat(methodInfo.GetCustomAttributes(true)) - .OfType() - .SelectMany(ha => ha.Headers); - Dictionary? ret = null; - foreach (var header in headers) + if (methodInfo.DeclaringType is not null) { - if (string.IsNullOrWhiteSpace(header)) + var interfaces = methodInfo.DeclaringType.GetInterfaces(); + for (var i = interfaces.Length - 1; i >= 0; i--) { - continue; + var attributes = interfaces[i].GetTypeInfo().GetCustomAttributes(true); + for (var j = attributes.Length - 1; j >= 0; j--) + { + if (attributes[j] is HeadersAttribute headersAttribute) + { + AddHeaders(headersAttribute, ref ret); + } + } } - ret ??= []; + var declaringAttributes = methodInfo.DeclaringType.GetTypeInfo().GetCustomAttributes(true); + for (var i = 0; i < declaringAttributes.Length; i++) + { + if (declaringAttributes[i] is HeadersAttribute headersAttribute) + { + AddHeaders(headersAttribute, ref ret); + } + } + } - // NB: Silverlight doesn't have an overload for String.Split() - // with a count parameter, but header values can contain - // ':' so we have to re-join all but the first part to get the - // value. - var parts = header.Split(':'); - ret[parts[0].Trim()] = - parts.Length > 1 ? string.Join(":", parts.Skip(1)).Trim() : null; + var methodAttributes = methodInfo.GetCustomAttributes(true); + for (var i = 0; i < methodAttributes.Length; i++) + { + if (methodAttributes[i] is HeadersAttribute headersAttribute) + { + AddHeaders(headersAttribute, ref ret); + } } return ret ?? EmptyDictionary.Get(); @@ -634,16 +748,12 @@ private static Dictionary BuildHeaderParameterMap(ParameterInfo[] p for (var i = 0; i < parameterArray.Length; i++) { - var header = parameterArray[i] - .GetCustomAttributes(true) - .OfType() - .Select(ha => ha.Header) - .FirstOrDefault(); + var header = parameterArray[i].GetCustomAttribute(true)?.Header; if (!string.IsNullOrWhiteSpace(header)) { ret ??= []; - ret[i] = header.Trim(); + ret[i] = header!.Trim(); } } @@ -808,6 +918,10 @@ private bool IsExcludedFromQueryMap(int index) => /// A value indicating whether the request is multipart. /// The HTTP method of the request. /// The body parameter information, or null when there is no body parameter. + [SuppressMessage( + "Major Code Smell", + "S1541:Methods and properties should not be too complex", + Justification = "Body parameter inference mirrors the documented precedence rules.")] private Tuple? FindBodyParameter( ParameterInfo[] parameterArray, bool isMultipart, @@ -817,21 +931,33 @@ private bool IsExcludedFromQueryMap(int index) => // 1) [Body] attribute // 2) POST/PUT/PATCH: Reference type other than string // 3) If there are two reference types other than string, without the body attribute, throw - var bodyParamEnumerable = parameterArray - .Select(x => - ( - Parameter: x, - BodyAttribute: x.GetCustomAttributes(true) - .OfType() - .FirstOrDefault() - )) - .Where(x => x.BodyAttribute is not null) - .TryGetSingle(out var bodyParam); + ParameterInfo? bodyParameter = null; + BodyAttribute? bodyAttribute = null; + var bodyParameterIndex = -1; + var hasMultipleBodyParameters = false; + for (var i = 0; i < parameterArray.Length; i++) + { + var attribute = parameterArray[i].GetCustomAttribute(true); + if (attribute is null) + { + continue; + } + + if (bodyParameter is not null) + { + hasMultipleBodyParameters = true; + break; + } + + bodyParameter = parameterArray[i]; + bodyAttribute = attribute; + bodyParameterIndex = i; + } // multipart requests may not contain a body, implicit or explicit if (isMultipart) { - if (bodyParamEnumerable != EnumerablePeek.Empty) + if (bodyParameter is not null) { throw new ArgumentException( "Multipart requests may not contain a Body parameter"); @@ -840,18 +966,18 @@ private bool IsExcludedFromQueryMap(int index) => return null; } - if (bodyParamEnumerable == EnumerablePeek.Many) + if (hasMultipleBodyParameters) { throw new ArgumentException("Only one parameter can be a Body parameter"); } // #1, body attribute wins - if (bodyParamEnumerable == EnumerablePeek.Single) + if (bodyParameter is not null && bodyAttribute is not null) { return Tuple.Create( - bodyParam!.BodyAttribute!.SerializationMethod, - bodyParam.BodyAttribute.Buffered ?? RefitSettings.Buffered, - Array.IndexOf(parameterArray, bodyParam.Parameter)); + bodyAttribute.SerializationMethod, + bodyAttribute.Buffered ?? RefitSettings.Buffered, + bodyParameterIndex); } // Not in post/put/patch? bail @@ -874,23 +1000,31 @@ private bool IsExcludedFromQueryMap(int index) => { // see if we're a post/put/patch // explicitly skip [Query], [HeaderCollection], and [Property]-denoted params - var refParamEnumerable = parameterArray - .Where(pi => - !pi.ParameterType.GetTypeInfo().IsValueType - && pi.ParameterType != typeof(string) - && pi.GetCustomAttribute() is null - && pi.GetCustomAttribute() is null - && pi.GetCustomAttribute() is null) - .TryGetSingle(out var refParam); - - // Check for rule #3 - if (refParamEnumerable == EnumerablePeek.Many) + ParameterInfo? refParam = null; + var refParamIndex = -1; + for (var i = 0; i < parameterArray.Length; i++) { - throw new ArgumentException( - "Multiple complex types found. Specify one parameter as the body using BodyAttribute"); + var parameter = parameterArray[i]; + if (parameter.ParameterType.GetTypeInfo().IsValueType + || parameter.ParameterType == typeof(string) + || parameter.GetCustomAttribute() is not null + || parameter.GetCustomAttribute() is not null + || parameter.GetCustomAttribute() is not null) + { + continue; + } + + if (refParam is not null) + { + throw new ArgumentException( + "Multiple complex types found. Specify one parameter as the body using BodyAttribute"); + } + + refParam = parameter; + refParamIndex = i; } - if (refParamEnumerable != EnumerablePeek.Single) + if (refParam is null) { return null; } @@ -898,7 +1032,7 @@ private bool IsExcludedFromQueryMap(int index) => return Tuple.Create( BodySerializationMethod.Serialized, RefitSettings.Buffered, - Array.IndexOf(parameterArray, refParam!)); + refParamIndex); } /// Holds the parsed forms of a route parameter name extracted from a URL template. diff --git a/src/Refit/RestService.cs b/src/Refit/RestService.cs index fcb7a6b4b..8b944e7ec 100644 --- a/src/Refit/RestService.cs +++ b/src/Refit/RestService.cs @@ -25,15 +25,9 @@ public static void RegisterGeneratedFactory( Type refitInterfaceType, Func factory) { - if (refitInterfaceType is null) - { - throw new ArgumentNullException(nameof(refitInterfaceType)); - } + ArgumentExceptionHelper.ThrowIfNull(refitInterfaceType); - if (factory is null) - { - throw new ArgumentNullException(nameof(factory)); - } + ArgumentExceptionHelper.ThrowIfNull(factory); _generatedFactories[refitInterfaceType] = factory; } diff --git a/src/Refit/SystemTextJsonContentSerializer.cs b/src/Refit/SystemTextJsonContentSerializer.cs index 5d21b9222..5b3f181c4 100644 --- a/src/Refit/SystemTextJsonContentSerializer.cs +++ b/src/Refit/SystemTextJsonContentSerializer.cs @@ -22,14 +22,12 @@ namespace Refit; public sealed class SystemTextJsonContentSerializer(JsonSerializerOptions jsonSerializerOptions) : IHttpContentSerializer { -#if NET8_0_OR_GREATER /// Justification shared by the reflection-fallback trim/AOT suppressions. private const string ReflectionFallbackJustification = "The reflection-based serialization fallback runs only when the supplied JsonSerializerOptions has no " + "TypeInfoResolver. Such options originate from the [RequiresUnreferencedCode] default options, so the " + "reflection requirement is already surfaced to callers; source-generated/AOT callers supply a resolver " + "and take the metadata path instead."; -#endif /// Initializes a new instance of the class. [RequiresUnreferencedCode("Default System.Text.Json serializer options include enum name reflection that trimming cannot statically preserve.")] @@ -101,14 +99,11 @@ public HttpContent ToHttpContent(T item) /// The calculated field name. /// /// propertyInfo. - public string? GetFieldNameForProperty(PropertyInfo propertyInfo) => propertyInfo switch + public string? GetFieldNameForProperty(PropertyInfo propertyInfo) { - null => throw new ArgumentNullException(nameof(propertyInfo)), - _ => propertyInfo - .GetCustomAttributes(true) - .Select(a => a.Name) - .FirstOrDefault() - }; + ArgumentExceptionHelper.ThrowIfNull(propertyInfo); + return propertyInfo.GetCustomAttribute(true)?.Name; + } /// Determines whether the declared type is configured for polymorphic serialization. /// The declared type to inspect. @@ -202,10 +197,8 @@ private JsonContent ToHttpContentRuntimeTyped(object item, Type runtimeType) /// The item to serialize. /// The runtime type to serialize the item as. /// The serialized HTTP content. -#if NET8_0_OR_GREATER [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = ReflectionFallbackJustification)] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = ReflectionFallbackJustification)] -#endif private JsonContent ToHttpContentRuntimeReflection(object item, Type runtimeType) => JsonContent.Create(item, runtimeType, options: jsonSerializerOptions); @@ -213,10 +206,8 @@ private JsonContent ToHttpContentRuntimeReflection(object item, Type runtimeType /// The type of the item being serialized. /// The item to serialize. /// The serialized HTTP content. -#if NET8_0_OR_GREATER [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = ReflectionFallbackJustification)] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = ReflectionFallbackJustification)] -#endif private JsonContent ToHttpContentReflection(T item) => JsonContent.Create(item, options: jsonSerializerOptions); @@ -229,10 +220,8 @@ private JsonContent ToHttpContentReflection(T item) => "Major Code Smell", "S4018:Generic methods should provide type parameter for inference", Justification = "Type parameter intentionally specified explicitly by callers.")] -#if NET8_0_OR_GREATER [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = ReflectionFallbackJustification)] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = ReflectionFallbackJustification)] -#endif private Task FromHttpContentReflectionAsync(HttpContent content, CancellationToken cancellationToken) => content.ReadFromJsonAsync(jsonSerializerOptions, cancellationToken); } diff --git a/src/Refit/ValueStringBuilder.cs b/src/Refit/ValueStringBuilder.cs index 4ae106982..7d681548a 100644 --- a/src/Refit/ValueStringBuilder.cs +++ b/src/Refit/ValueStringBuilder.cs @@ -111,7 +111,7 @@ public ref char GetPinnableReference(bool terminate) /// public override string ToString() { - var s = _chars.Slice(0, _pos).ToString(); + var s = _chars[.._pos].ToString(); Dispose(); return s; } @@ -127,12 +127,12 @@ public ReadOnlySpan AsSpan(bool terminate) _chars[Length] = '\0'; } - return _chars.Slice(0, _pos); + return _chars[.._pos]; } /// Returns a span over the current contents of the builder. /// A span over the contents of the builder. - public readonly ReadOnlySpan AsSpan() => _chars.Slice(0, _pos); + public readonly ReadOnlySpan AsSpan() => _chars[.._pos]; /// Returns a span over the contents from the given start index to the end. /// The start index. @@ -151,7 +151,7 @@ public ReadOnlySpan AsSpan(bool terminate) /// if the contents were copied; otherwise, . public bool TryCopyTo(Span destination, out int charsWritten) { - if (_chars.Slice(0, _pos).TryCopyTo(destination)) + if (_chars[.._pos].TryCopyTo(destination)) { charsWritten = _pos; Dispose(); @@ -175,7 +175,7 @@ public void Insert(int index, char value, int count) } var remaining = _pos - index; - _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count)); + _chars.Slice(index, remaining).CopyTo(_chars[(index + count)..]); _chars.Slice(index, count).Fill(value); _pos += count; } @@ -198,12 +198,12 @@ public void Insert(int index, string? s) } var remaining = _pos - index; - _chars.Slice(index, remaining).CopyTo(_chars.Slice(index + count)); + _chars.Slice(index, remaining).CopyTo(_chars[(index + count)..]); s #if !NETCOREAPP .AsSpan() #endif - .CopyTo(_chars.Slice(index)); + .CopyTo(_chars[index..]); _pos += count; } @@ -278,7 +278,7 @@ public void Append(ReadOnlySpan value) Grow(value.Length); } - value.CopyTo(_chars.Slice(_pos)); + value.CopyTo(_chars[_pos..]); _pos += value.Length; } @@ -326,7 +326,7 @@ private void AppendSlow(string s) #if !NETCOREAPP .AsSpan() #endif - .CopyTo(_chars.Slice(pos)); + .CopyTo(_chars[pos..]); _pos += s.Length; } @@ -363,7 +363,7 @@ private void Grow(int additionalCapacityBeyondPos) // This could also go negative if the actual required length wraps around. var poolArray = ArrayPool.Shared.Rent(newCapacity); - _chars.Slice(0, _pos).CopyTo(poolArray); + _chars[.._pos].CopyTo(poolArray); var toReturn = _arrayToReturnToPool; _arrayToReturnToPool = poolArray; diff --git a/src/Refit/targets/refit.props b/src/Refit/targets/refit.props index 1760cd61a..75cf8e6e6 100644 --- a/src/Refit/targets/refit.props +++ b/src/Refit/targets/refit.props @@ -1,7 +1,18 @@ + + + $(RootNamespace) + true + + + + diff --git a/src/Refit/targets/refit.targets b/src/Refit/targets/refit.targets deleted file mode 100644 index b84c550bb..000000000 --- a/src/Refit/targets/refit.targets +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - $(RootNamespace) - - <_RefitMSBuildMinVersion>16.8.0 - - - - - - <_RefitAnalyzer Include="@(Analyzer)" Condition="'%(Analyzer.NuGetPackageId)' == 'Refit'"/> - - - - - - - - - - - - - - - - - - - diff --git a/src/Shared/AuthenticatedHttpClientHandler.cs b/src/Shared/AuthenticatedHttpClientHandler.cs index 75be72b6c..715767948 100644 --- a/src/Shared/AuthenticatedHttpClientHandler.cs +++ b/src/Shared/AuthenticatedHttpClientHandler.cs @@ -23,8 +23,11 @@ internal sealed class AuthenticatedHttpClientHandler : DelegatingHandler public AuthenticatedHttpClientHandler( Func> getToken, HttpMessageHandler? innerHandler = null) - : base(innerHandler ?? new HttpClientHandler()) => - _getToken = getToken ?? throw new ArgumentNullException(nameof(getToken)); + : base(innerHandler ?? new HttpClientHandler()) + { + ArgumentExceptionHelper.ThrowIfNull(getToken); + _getToken = getToken; + } /// Initializes a new instance of the class. /// The optional inner handler. @@ -40,7 +43,8 @@ public AuthenticatedHttpClientHandler( HttpMessageHandler? innerHandler, Func> getToken) { - _getToken = getToken ?? throw new ArgumentNullException(nameof(getToken)); + ArgumentExceptionHelper.ThrowIfNull(getToken); + _getToken = getToken; if (innerHandler is null) { return; diff --git a/src/Shared/UniqueName.cs b/src/Shared/UniqueName.cs index 4fcd9dd1a..0f2a3b71b 100644 --- a/src/Shared/UniqueName.cs +++ b/src/Shared/UniqueName.cs @@ -45,7 +45,7 @@ public static string ForType(Type refitInterfaceType) var lastDot = interfaceTypeName.LastIndexOf('.', searchEnd - 1); if (lastDot > 0) { - interfaceTypeName = interfaceTypeName.Substring(lastDot + 1); + interfaceTypeName = interfaceTypeName[(lastDot + 1)..]; } // Now we have the interface name like IFooBar`1[[Some Generic Args]] @@ -55,10 +55,8 @@ public static string ForType(Type refitInterfaceType) // if there's any generics, split that if (refitInterfaceType.IsGenericType) { - genericArgs = interfaceTypeName.Substring(interfaceTypeName.IndexOf('[')); - interfaceTypeName = interfaceTypeName.Substring( - 0, - interfaceTypeName.Length - genericArgs.Length); + genericArgs = interfaceTypeName[interfaceTypeName.IndexOf('[')..]; + interfaceTypeName = interfaceTypeName[..^genericArgs.Length]; } // Remove any + from the type name portion diff --git a/src/benchmarks/Refit.Benchmarks/GeneratorBenchmarkHarness.cs b/src/benchmarks/Refit.Benchmarks/GeneratorBenchmarkHarness.cs index 68ca1d491..4a40f3f4e 100644 --- a/src/benchmarks/Refit.Benchmarks/GeneratorBenchmarkHarness.cs +++ b/src/benchmarks/Refit.Benchmarks/GeneratorBenchmarkHarness.cs @@ -76,12 +76,29 @@ public static (Compilation Compilation, CSharpGeneratorDriver Driver) CreatePrim /// Gets the distinct, non-dynamic assemblies to reference when compiling the benchmark source. /// The distinct, non-dynamic assemblies to reference. - private static Assembly[] GetAssemblyReferencesForCodegen() => - [ - .. AppDomain - .CurrentDomain.GetAssemblies() - .Concat(_importantAssemblies.Select(x => x.Assembly)) - .Distinct() - .Where(a => !a.IsDynamic) - ]; + private static Assembly[] GetAssemblyReferencesForCodegen() + { + var assemblies = new HashSet(); + var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); + for (var i = 0; i < loadedAssemblies.Length; i++) + { + if (!loadedAssemblies[i].IsDynamic) + { + assemblies.Add(loadedAssemblies[i]); + } + } + + for (var i = 0; i < _importantAssemblies.Length; i++) + { + var assembly = _importantAssemblies[i].Assembly; + if (!assembly.IsDynamic) + { + assemblies.Add(assembly); + } + } + + var references = new Assembly[assemblies.Count]; + assemblies.CopyTo(references); + return references; + } } diff --git a/src/benchmarks/Refit.Benchmarks/Refit.Benchmarks.csproj b/src/benchmarks/Refit.Benchmarks/Refit.Benchmarks.csproj index 64e92a0a2..91f2f334b 100644 --- a/src/benchmarks/Refit.Benchmarks/Refit.Benchmarks.csproj +++ b/src/benchmarks/Refit.Benchmarks/Refit.Benchmarks.csproj @@ -1,4 +1,4 @@ - + Exe $(RefitBenchmarkTargets) diff --git a/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.cs b/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.cs index 1443c4488..3715640d6 100644 --- a/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.cs +++ b/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.cs @@ -11,7 +11,6 @@ public static class SourceGeneratorBenchmarksProjects """ using System; using System.Collections.Generic; - using System.Linq; using System.Net.Http; using System.Text; using System.Threading; @@ -44,7 +43,6 @@ public interface IReallyExcitingCrudApi where T : class """ using System; using System.Collections.Generic; - using System.Linq; using System.Net.Http; using System.Text; using System.Threading; diff --git a/src/examples/BlazorWasmIssue2065/BlazorWasmIssue2065.csproj b/src/examples/BlazorWasmIssue2065/BlazorWasmIssue2065.csproj index 7135d1b7b..df8224195 100644 --- a/src/examples/BlazorWasmIssue2065/BlazorWasmIssue2065.csproj +++ b/src/examples/BlazorWasmIssue2065/BlazorWasmIssue2065.csproj @@ -1,4 +1,4 @@ - + net10.0 diff --git a/src/examples/Meow.Common/Meow.Common.csproj b/src/examples/Meow.Common/Meow.Common.csproj index dd3f3baeb..ca2082c41 100644 --- a/src/examples/Meow.Common/Meow.Common.csproj +++ b/src/examples/Meow.Common/Meow.Common.csproj @@ -1,4 +1,4 @@ - + net8.0 enable diff --git a/src/examples/Meow.Common/Services/Issue2056And2058Demo.cs b/src/examples/Meow.Common/Services/Issue2056And2058Demo.cs index 59e1a8f3d..d9ad87db5 100644 --- a/src/examples/Meow.Common/Services/Issue2056And2058Demo.cs +++ b/src/examples/Meow.Common/Services/Issue2056And2058Demo.cs @@ -31,23 +31,41 @@ public static async Task RunAsync() /// A task that completes when the validation has finished. private static async Task ValidateIssue2056Async(IIssueDemoApi api) { - var customerIds = Enumerable.Range(1000, 50).ToArray(); + const int firstCustomerId = 1000; + const int customerCount = 50; - var responses = await Task.WhenAll( - customerIds.Select(async customerId => + var requests = new Task<(int Expected, string? Actual)>[customerCount]; + for (var i = 0; i < requests.Length; i++) + { + var customerId = firstCustomerId + i; + requests[i] = EchoCustomerAsync(api, customerId); + } + + var responses = await Task.WhenAll(requests).ConfigureAwait(false); + var mismatchCount = 0; + for (var i = 0; i < responses.Length; i++) + { + if (responses[i].Expected.ToString() != responses[i].Actual) { - var echo = await api.EchoCustomerAsync(customerId).ConfigureAwait(false); - return (Expected: customerId, Actual: echo.CustomerIdHeader); - })).ConfigureAwait(false); + mismatchCount++; + } + } - var mismatches = responses.Where(x => x.Expected.ToString() != x.Actual).ToArray(); - if (mismatches.Length == 0) + if (mismatchCount == 0) { return; } throw new InvalidOperationException( - $"Issue #2056 check failed. Found {mismatches.Length} mismatched CustomerId headers."); + $"Issue #2056 check failed. Found {mismatchCount} mismatched CustomerId headers."); + + static async Task<(int Expected, string? Actual)> EchoCustomerAsync( + IIssueDemoApi api, + int customerId) + { + var echo = await api.EchoCustomerAsync(customerId).ConfigureAwait(false); + return (customerId, echo.CustomerIdHeader); + } } /// Validates that a large async-only payload is fully read and deserialized. diff --git a/src/examples/SampleUsingLocalApi/LibraryWithSDKandRefitService/LibraryWithSDKandRefitService.csproj b/src/examples/SampleUsingLocalApi/LibraryWithSDKandRefitService/LibraryWithSDKandRefitService.csproj index cd1b27a3c..21d74e766 100644 --- a/src/examples/SampleUsingLocalApi/LibraryWithSDKandRefitService/LibraryWithSDKandRefitService.csproj +++ b/src/examples/SampleUsingLocalApi/LibraryWithSDKandRefitService/LibraryWithSDKandRefitService.csproj @@ -1,4 +1,4 @@ - + net8.0 diff --git a/src/tests/Refit.GeneratorTests/AotSafeAssertionExtensions.cs b/src/tests/Refit.GeneratorTests/AotSafeAssertionExtensions.cs new file mode 100644 index 000000000..63396394c --- /dev/null +++ b/src/tests/Refit.GeneratorTests/AotSafeAssertionExtensions.cs @@ -0,0 +1,26 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +using TUnit.Assertions.Conditions; +using TUnit.Assertions.Core; + +namespace Refit.GeneratorTests; + +/// AOT-safe collection-equality helpers. +internal static class AotSafeAssertionExtensions +{ + /// Provides collection-equality assertions for assertion sources. + /// The assertion source. + /// The collection type being asserted. + /// The element type. + extension(IAssertionSource source) + where TCollection : IEnumerable + { + /// Asserts collection equivalence with the element type's default comparer. + /// The expected element sequence. + /// The chained collection-equivalency assertion. + public IsEquivalentToAssertion IsCollectionEqualTo( + IEnumerable expected) => + source.IsEquivalentTo(expected, EqualityComparer.Default); + } +} diff --git a/src/tests/Refit.GeneratorTests/CollectibleAssemblyLoadContext.cs b/src/tests/Refit.GeneratorTests/CollectibleAssemblyLoadContext.cs new file mode 100644 index 000000000..3434fb87a --- /dev/null +++ b/src/tests/Refit.GeneratorTests/CollectibleAssemblyLoadContext.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +using System.Reflection; +using System.Runtime.Loader; + +namespace Refit.GeneratorTests; + +/// Collectible load context used by live-compilation source-generator tests. +public sealed class CollectibleAssemblyLoadContext : AssemblyLoadContext, IDisposable +{ + /// Initializes a new instance of the class. + public CollectibleAssemblyLoadContext() + : base(isCollectible: true) + { + } + + /// + public void Dispose() => Unload(); + + /// + protected override Assembly? Load(AssemblyName assemblyName) + { + if (string.IsNullOrEmpty(assemblyName.Name)) + { + return null; + } + + foreach (var assembly in Default.Assemblies) + { + if (string.Equals( + assembly.GetName().Name, + assemblyName.Name, + StringComparison.OrdinalIgnoreCase)) + { + return assembly; + } + } + + return null; + } +} diff --git a/src/tests/Refit.GeneratorTests/Fixture.cs b/src/tests/Refit.GeneratorTests/Fixture.cs index e703b42f6..17209f42a 100644 --- a/src/tests/Refit.GeneratorTests/Fixture.cs +++ b/src/tests/Refit.GeneratorTests/Fixture.cs @@ -1,10 +1,12 @@ // Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Reflection; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; using Refit.Generator; namespace Refit.GeneratorTests; @@ -16,6 +18,9 @@ namespace Refit.GeneratorTests; Justification = "Compiles generator inputs against on-disk assemblies; never run as a single-file app.")] public static class Fixture { + /// The runtime data key containing framework assemblies available to the current test host. + private const string TrustedPlatformAssemblies = "TRUSTED_PLATFORM_ASSEMBLIES"; + /// The metadata reference for the Refit assembly with documentation. private static readonly MetadataReference _refitAssembly = MetadataReference.CreateFromFile( typeof(GetAttribute).Assembly.Location, @@ -68,6 +73,126 @@ public interface IGeneratedClient return VerifyGenerator(source, ignoreNonInterfaces); } + /// Generates output for an interface body snippet and returns the requested generated file. + /// The interface member body source. + /// The generated source hint name. + /// The generated source text. + public static string GenerateForBody(string body, string hintName) => + GenerateForBody(body, hintName, null); + + /// Generates output for an interface body snippet and returns the requested generated file. + /// The interface member body source. + /// The generated source hint name. + /// Whether generated request construction is explicitly configured. + /// The generated source text. + public static string GenerateForBody(string body, string hintName, bool? generatedRequestBuilding) + { + var source = BuildBodySource(body); + + return Generate(source, hintName, generatedRequestBuilding); + } + + /// Generates output for an interface body snippet, compiles it, and returns error diagnostics. + /// The interface member body source. + /// Whether generated request construction is explicitly configured. + /// The compiler and generator errors produced by the generated output. + public static ImmutableArray GenerateErrorsForBody( + string body, + bool? generatedRequestBuilding) + { + var result = RunGenerator(BuildBodySource(body), generatedRequestBuilding); + + return + [ + .. result.GeneratorDiagnostics + .Concat(result.OutputCompilation.GetDiagnostics()) + .Where(IsGeneratedOutputError) + ]; + } + + /// Runs the generator over an interface body snippet and returns the output compilation. + /// The interface member body source. + /// Whether generated request construction is explicitly configured. + /// The generator result. + public static GeneratorTestResult RunGeneratorForBody( + string body, + bool? generatedRequestBuilding) => + RunGeneratorForBody(body, generatedRequestBuilding, false); + + /// Runs the generator over an interface body snippet and returns the output compilation. + /// The interface member body source. + /// Whether generated request construction is explicitly configured. + /// Whether source generation is disabled. + /// The generator result. + public static GeneratorTestResult RunGeneratorForBody( + string body, + bool? generatedRequestBuilding, + bool disableSourceGenerator) => + RunGenerator(BuildBodySource(body), generatedRequestBuilding, disableSourceGenerator); + + /// Runs the generator over the source and returns the output compilation. + /// The source to compile and generate from. + /// Whether generated request construction is explicitly configured. + /// The generator result. + public static GeneratorTestResult RunGenerator( + string source, + bool? generatedRequestBuilding) => + RunGenerator(source, generatedRequestBuilding, false); + + /// Runs the generator over the source and returns the output compilation. + /// The source to compile and generate from. + /// Whether generated request construction is explicitly configured. + /// Whether source generation is disabled. + /// The generator result. + public static GeneratorTestResult RunGenerator( + string source, + bool? generatedRequestBuilding, + bool disableSourceGenerator) + { + var compilation = CreateLibrary(source); + var generator = new InterfaceStubGeneratorV2(); + var driver = CSharpGeneratorDriver.Create( + [generator.AsSourceGenerator()], + optionsProvider: new TestAnalyzerConfigOptionsProvider( + generatedRequestBuilding, + disableSourceGenerator)); + + driver.RunGeneratorsAndUpdateCompilation( + compilation, + out var outputCompilation, + out var generatorDiagnostics); + + return new(driver, outputCompilation, generatorDiagnostics); + } + + /// Emits a generated output compilation and loads it into a collectible assembly context. + /// The generator result to emit. + /// The loaded assembly and load context. + [RequiresUnreferencedCode("Live-compilation tests intentionally load generated assemblies from memory.")] + public static (Assembly Assembly, CollectibleAssemblyLoadContext Context) EmitAndLoad( + GeneratorTestResult result) + { + ArgumentNullException.ThrowIfNull(result); + + using var stream = new MemoryStream(); + var emitResult = result.OutputCompilation.Emit(stream); + if (!emitResult.Success) + { + var errors = string.Join( + Environment.NewLine, + emitResult.Diagnostics + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Select(diagnostic => $"{diagnostic.Id}: {diagnostic.GetMessage()}")); + throw new InvalidOperationException( + $"Failed to emit generated compilation:{Environment.NewLine}{errors}"); + } + + stream.Position = 0; + var context = new CollectibleAssemblyLoadContext(); + var assembly = context.LoadFromStream(stream); + return (assembly, context); + } + /// Verifies generator output for type declarations within a namespace. /// The type declarations source. /// A task representing the verification. @@ -114,26 +239,61 @@ public static Task VerifyForDeclaration(string declarations) return VerifyGenerator(source); } + /// Generates output for top-level declarations and returns the requested generated file. + /// The declarations source. + /// The generated source hint name. + /// Whether generated request construction is explicitly configured. + /// The generated source text. + public static string GenerateForDeclaration( + string declarations, + string hintName, + bool? generatedRequestBuilding) + { + var source = + $$""" + using System; + using System.Collections.Generic; + using System.Linq; + using System.Net.Http; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using Refit; + + {{declarations}} + """; + + return Generate(source, hintName, generatedRequestBuilding); + } + /// Creates a compilation from the given syntax trees with the required references. /// The syntax trees to compile. /// The created compilation. public static CSharpCompilation CreateLibrary(params SyntaxTree[] source) { - var references = new List(); + var referencePaths = new HashSet(StringComparer.Ordinal); + AddTrustedPlatformAssemblies(referencePaths); + foreach (var assembly in GetAssemblyReferencesForCodegen()) { - if (!assembly.IsDynamic) + if (!assembly.IsDynamic && !string.IsNullOrEmpty(assembly.Location)) { - references.Add(MetadataReference.CreateFromFile(assembly.Location)); + referencePaths.Add(assembly.Location); } } + var references = new List(referencePaths.Count + 1); + foreach (var referencePath in referencePaths) + { + references.Add(MetadataReference.CreateFromFile(referencePath)); + } + references.Add(_refitAssembly); return CSharpCompilation.Create( "compilation", source, references, - new(OutputKind.ConsoleApplication)); + new(OutputKind.DynamicallyLinkedLibrary)); } /// Gets the assemblies referenced when compiling generated code. @@ -144,7 +304,7 @@ .. AppDomain.CurrentDomain .GetAssemblies() .Concat(_importantAssemblies.Select(x => x.Assembly)) .Distinct() - .Where(a => !a.IsDynamic) + .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location)) ]; /// Creates a compilation by parsing the given source strings. @@ -153,6 +313,57 @@ .. AppDomain.CurrentDomain private static CSharpCompilation CreateLibrary(params string[] source) => CreateLibrary(source.Select(s => CSharpSyntaxTree.ParseText(s)).ToArray()); + /// Adds runtime framework references used by Roslyn in-memory compilations. + /// The reference path set to populate. + private static void AddTrustedPlatformAssemblies(HashSet referencePaths) + { + var trustedPlatformAssemblies = (string?)AppContext.GetData(TrustedPlatformAssemblies); + if (string.IsNullOrEmpty(trustedPlatformAssemblies)) + { + return; + } + + // CI hosts can type-forward BCL types, such as Uri, to implementation assemblies + // that are not loaded yet. The trusted platform assembly list gives Roslyn the + // complete runtime framework closure for generated-output compilation tests. + foreach (var referencePath in trustedPlatformAssemblies.Split(Path.PathSeparator)) + { + if (!string.IsNullOrEmpty(referencePath)) + { + referencePaths.Add(referencePath); + } + } + } + + /// Determines whether a diagnostic represents an error in generator output. + /// The diagnostic to inspect. + /// when the diagnostic should fail generated-output compilation tests. + private static bool IsGeneratedOutputError(Diagnostic diagnostic) => + diagnostic is { Severity: DiagnosticSeverity.Error } + && diagnostic.Id != "CS5001"; + + /// Builds a source file containing a generated-client interface body. + /// The interface member body source. + /// The complete source file. + private static string BuildBodySource(string body) => + $$""" + using System; + using System.Collections.Generic; + using System.Linq; + using System.Net.Http; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + {{body}} + } + """; + /// Runs the generator over the source and returns the verification result. /// The source to compile and generate from. /// Whether to ignore non-interface generated results. @@ -176,4 +387,83 @@ private static Task VerifyGenerator(string source, bool ignoreNonI var verify = Verify(ranDriver, settings); return verify.ToTask(); } + + /// Runs the generator and returns a generated source by hint name. + /// The source to compile and generate from. + /// The generated source hint name. + /// Whether generated request construction is explicitly configured. + /// The generated source text. + private static string Generate( + string source, + string hintName, + bool? generatedRequestBuilding) + { + var compilation = CreateLibrary(source); + var generator = new InterfaceStubGeneratorV2(); + var driver = CSharpGeneratorDriver.Create( + [generator.AsSourceGenerator()], + optionsProvider: new TestAnalyzerConfigOptionsProvider(generatedRequestBuilding)); + + var ranDriver = driver.RunGenerators(compilation); + foreach (var syntaxTree in ranDriver.GetRunResult().GeneratedTrees) + { + if (Path.GetFileName(syntaxTree.FilePath) == hintName) + { + return syntaxTree.GetText().ToString(); + } + } + + throw new InvalidOperationException($"Generated file '{hintName}' was not produced."); + } + + /// Analyzer-config options used by source-generator tests. + /// Whether generated request construction is explicitly configured. + /// Whether source generation is disabled. + private sealed class TestAnalyzerConfigOptionsProvider( + bool? generatedRequestBuilding, + bool disableSourceGenerator = false) + : AnalyzerConfigOptionsProvider + { + /// + public override AnalyzerConfigOptions GlobalOptions { get; } = + new TestAnalyzerConfigOptions(generatedRequestBuilding, disableSourceGenerator); + + /// + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => + TestAnalyzerConfigOptions.Empty; + + /// + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => + TestAnalyzerConfigOptions.Empty; + } + + /// Analyzer-config options used by source-generator tests. + /// Whether generated request construction is explicitly configured. + /// Whether source generation is disabled. + private sealed class TestAnalyzerConfigOptions( + bool? generatedRequestBuilding, + bool disableSourceGenerator) : AnalyzerConfigOptions + { + /// Gets empty analyzer-config options. + public static TestAnalyzerConfigOptions Empty { get; } = new(null, false); + + /// + public override bool TryGetValue(string key, out string value) + { + if (key == "build_property.RefitGeneratedRequestBuilding" && generatedRequestBuilding.HasValue) + { + value = generatedRequestBuilding.Value ? "true" : "false"; + return true; + } + + if (key == "build_property.DisableRefitSourceGenerator" && disableSourceGenerator) + { + value = "true"; + return true; + } + + value = string.Empty; + return false; + } + } } diff --git a/src/tests/Refit.GeneratorTests/GeneratedRequestBuildingTests.cs b/src/tests/Refit.GeneratorTests/GeneratedRequestBuildingTests.cs new file mode 100644 index 000000000..7e18d0cfd --- /dev/null +++ b/src/tests/Refit.GeneratorTests/GeneratedRequestBuildingTests.cs @@ -0,0 +1,736 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +namespace Refit.GeneratorTests; + +/// Generator tests for opt-in generated request construction. +public class GeneratedRequestBuildingTests +{ + /// The generated implementation source hint name used by these tests. + private const string GeneratedClientHintName = "IGeneratedClient.g.cs"; + + /// The reflective request-builder call emitted by fallback paths. + private const string ReflectiveRequestBuilderCall = "BuildRestResultFuncForMethod"; + + /// The generated request-runner send call emitted by inline request construction. + private const string GeneratedRequestRunnerSendAsync = "GeneratedRequestRunner.SendAsync"; + + /// The generated request-message construction emitted by inline request construction. + private const string NewHttpRequestMessage = "new global::System.Net.Http.HttpRequestMessage"; + + /// A simple eligible GET method used by switch tests. + private const string SimpleGetMethod = + """ + [Get("/users")] + Task Get(CancellationToken cancellationToken); + """; + + /// Verifies the default output uses generated request construction for eligible methods. + /// A task representing the asynchronous test. + [Test] + public async Task DefaultUsesGeneratedRequestConstruction() + { + var generated = Fixture.GenerateForBody( + SimpleGetMethod, + GeneratedClientHintName); + + await Assert.That(generated).Contains(GeneratedRequestRunnerSendAsync); + await Assert.That(generated).Contains(NewHttpRequestMessage); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies an explicit switch-off keeps using the reflective request builder. + /// A task representing the asynchronous test. + [Test] + public async Task ExplicitSwitchOffUsesReflectiveRequestBuilder() + { + var generated = Fixture.GenerateForBody( + SimpleGetMethod, + GeneratedClientHintName, + generatedRequestBuilding: false); + + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + await Assert.That(generated).DoesNotContain(GeneratedRequestRunnerSendAsync); + } + + /// Verifies the legacy source-generator disable switch prevents all generated output. + /// A task representing the asynchronous test. + [Test] + public async Task DisableSourceGeneratorProducesNoSources() + { + var result = Fixture.RunGeneratorForBody( + SimpleGetMethod, + generatedRequestBuilding: null, + disableSourceGenerator: true); + + await Assert.That(result.GeneratedSources).IsEmpty(); + } + + /// Verifies the explicit switch-on emits inline request construction for a simple eligible method. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnEmitsInlineRequestConstructionForSimpleMethod() + { + var generated = Fixture.GenerateForBody( + SimpleGetMethod, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains(GeneratedRequestRunnerSendAsync); + await Assert.That(generated).Contains(NewHttpRequestMessage); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies generated inline request construction compiles in a consumer compilation. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnGeneratedRequestConstructionCompiles() + { + var errors = Fixture.GenerateErrorsForBody( + """ + [Post("/users")] + Task> Create([Body] string name, CancellationToken cancellationToken); + + [Get("/users")] + Task HeaderValue([Header("X-Test")] int id); + + [Get("/users")] + Task HeaderReference([Header("X-Test")] string? id); + + [Get("/users")] + Task Headers([HeaderCollection] IDictionary headers); + + [Get("/users")] + Task Property([Property("tenant")] int tenantId); + + string Client { get; set; } + """, + generatedRequestBuilding: true); + + var errorMessages = string.Join(Environment.NewLine, errors.Select(diagnostic => diagnostic.ToString())); + await Assert.That(errorMessages).IsEqualTo(string.Empty); + } + + /// Verifies static headers are emitted into inline request construction. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnEmitsStaticHeaders() + { + var generated = Fixture.GenerateForBody( + """ + [Headers("X-Test: test")] + [Get("/users")] + Task Get(CancellationToken cancellationToken); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("GeneratedRequestRunner.SetHeader(______rq, \"X-Test\", \"test\")"); + await Assert.That(generated).Contains(GeneratedRequestRunnerSendAsync); + } + + /// Verifies constant inline paths strip URI fragments before request construction. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnStripsFragmentsFromInlineConstantPaths() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/foo?key=value#name")] + Task Get(); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("______basePath + \"/foo?key=value\""); + await Assert.That(generated).DoesNotContain("#name"); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies constant inline paths discard query text after a fragment marker. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnStripsQueryAfterFragmentFromInlineConstantPaths() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/foo#?key=value")] + Task Get(); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("______basePath + \"/foo\""); + await Assert.That(generated).DoesNotContain("?key=value"); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies constant inline paths remove empty query keys to match runtime builder semantics. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnRemovesEmptyQueryKeysFromInlineConstantPaths() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/foo?=drop&key=&two=2")] + Task Get(); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("______basePath + \"/foo?key=&two=2\""); + await Assert.That(generated).DoesNotContain("=drop"); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies legacy JSON body metadata emits the non-obsolete equivalent enum member. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnMapsLegacyJsonBodySerializationToSerialized() + { + var generated = Fixture.GenerateForBody( + """ + [Post("/users")] + Task Create([Body(BodySerializationMethod.Json)] string name); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("BodySerializationMethod.Serialized"); + await Assert.That(generated).DoesNotContain("BodySerializationMethod.Json"); + } + + /// Verifies dynamic header parameters are emitted into inline request construction. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnEmitsDynamicHeader() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/users")] + Task Get([Header("X-Test")] int id); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("GeneratedRequestRunner.SetHeader(______rq, \"X-Test\", @id.ToString())"); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies header collections are emitted into inline request construction. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnEmitsHeaderCollection() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/users")] + Task Get([HeaderCollection] IDictionary headers); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("GeneratedRequestRunner.AddHeaderCollection(______rq, @headers)"); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies all built-in HTTP method attributes can be emitted inline. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnEmitsAllBuiltInHttpMethodsInline() + { + var generated = Fixture.GenerateForBody( + """ + [Delete("/delete")] + Task Delete(); + + [Head("/head")] + Task Head(); + + [Options("/options")] + Task Options(); + + [Patch("/patch")] + Task Patch(); + + [Post("/post")] + Task Post(); + + [Put("/put")] + Task Put(); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("HttpMethod.Delete"); + await Assert.That(generated).Contains("HttpMethod.Head"); + await Assert.That(generated).Contains("HttpMethod.Options"); + await Assert.That(generated).Contains("new global::System.Net.Http.HttpMethod(\"PATCH\")"); + await Assert.That(generated).Contains("HttpMethod.Post"); + await Assert.That(generated).Contains("HttpMethod.Put"); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies string literal escaping in inline request paths and header values. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnEscapesInlineStringLiterals() + { + var generated = Fixture.GenerateForBody( + """ + [Headers("X-Quote: value\"with\\slashes\tand\nlines")] + [Get("/escaped?name=value\"with\\slashes\tand")] + Task Escaped(); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("\\\"with\\\\slashes\\tand"); + await Assert.That(generated).Contains("and\\nlines"); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies invalid header collection semantics fall back to the runtime builder. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnFallsBackForInvalidHeaderCollection() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/users")] + Task Get([HeaderCollection] IDictionary headers); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + await Assert.That(generated).DoesNotContain("GeneratedRequestRunner.AddHeaderCollection"); + } + + /// Verifies unsupported inline path forms and metadata fall back to the runtime builder. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnFallsBackForUnsupportedInlinePathAndMetadata() + { + var generated = Fixture.GenerateForBody( + """ + [Get("relative")] + Task Relative(); + + [Get("/users/{id}")] + Task Templated(int id); + + [Get("/bad\r\npath")] + Task ControlCharacters(); + + [Multipart] + [Post("/multipart")] + Task Multipart([Body] string body); + + [QueryUriFormat(UriFormat.Unescaped)] + [Get("/format")] + Task QueryFormat(); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + await Assert.That(generated).DoesNotContain("______basePath + \"relative\""); + await Assert.That(generated).DoesNotContain("______basePath + \"/users/{id}\""); + await Assert.That(generated).DoesNotContain("______basePath + \"/bad"); + } + + /// Verifies custom HTTP method attributes are discovered but fall back to the runtime builder. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnFallsBackForCustomHttpMethodAttributes() + { + var generated = Fixture.GenerateForDeclaration( + """ + public sealed class CustomAttribute(string path) : HttpMethodAttribute(path); + + public interface IGeneratedClient + { + [Custom("/custom")] + Task Custom(); + } + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + await Assert.That(generated).DoesNotContain(NewHttpRequestMessage); + } + + /// Verifies synchronous Refit methods use the reflective fallback emitter shapes. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnEmitsReflectiveFallbackForSynchronousReturnShapes() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/sync")] + string Sync(); + + [Post("/void")] + void SyncVoid(); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("return (string)______func(this.Client, ______arguments);"); + await Assert.That(generated).Contains("______func(this.Client, ______arguments);"); + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + } + + /// Verifies generic methods use reflective fallback arrays and emit constraints. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnEmitsReflectiveFallbackForGenericMethodsWithConstraints() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/generic")] + Task Generic( + T value, + List values, + TStruct structValue, + TUnmanaged unmanagedValue, + TNotNull notNullValue, + TNew newValue, + TConstraint constraintValue) + where T : class + where TStruct : struct + where TUnmanaged : unmanaged + where TNotNull : notnull + where TNew : new() + where TConstraint : IDisposable; + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("new global::System.Type[] { typeof(T), typeof(global::System.Collections.Generic.List)"); + await Assert.That(generated).Contains("new global::System.Type[] { typeof(T), typeof(TStruct), typeof(TUnmanaged), typeof(TNotNull), typeof(TNew), typeof(TConstraint) }"); + await Assert.That(generated).Contains("where T : class"); + await Assert.That(generated).Contains("where TStruct : struct"); + await Assert.That(generated).Contains("where TUnmanaged : unmanaged"); + await Assert.That(generated).Contains("where TNotNull : notnull"); + await Assert.That(generated).Contains("where TNew : new()"); + await Assert.That(generated).Contains("where TConstraint : global::System.IDisposable"); + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + } + + /// Verifies inline query normalization drops empty and whitespace query keys. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnRemovesWhitespaceAndEmptyQueryKeysFromInlineConstantPaths() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/foo?& \t =drop&one=1&&two& =also-drop")] + Task Get(); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("______basePath + \"/foo?one=1&two\""); + await Assert.That(generated).DoesNotContain("drop"); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies request property parameters use typed generated helper calls. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnEmitsTypedPropertyParameter() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/users")] + Task Get([Property("tenant")] int tenantId); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("GeneratedRequestRunner.AddRequestProperty(______rq, \"tenant\", @tenantId)"); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies property parameters without explicit keys use the parameter name. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnUsesParameterNameForPropertyWithoutExplicitKey() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/users")] + Task Get([Property] int tenantId); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("GeneratedRequestRunner.AddRequestProperty(______rq, \"tenantId\", @tenantId)"); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies inline header parameters handle nullable value and reference types. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnEmitsNullableHeaderValues() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/users")] + Task Get([Header("X-Value")] int? value, [Header("X-Name")] string? name); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("GeneratedRequestRunner.SetHeader(______rq, \"X-Value\", @value?.ToString())"); + await Assert.That(generated).Contains("GeneratedRequestRunner.SetHeader(______rq, \"X-Name\", @name?.ToString())"); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies unsupported header attributes and duplicate special parameters fall back to the runtime builder. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnFallsBackForUnsupportedParametersAndDuplicateSpecialParameters() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/headers")] + Task EmptyHeader([Header(" ")] string value); + + [Post("/bodies")] + Task MultipleBodies([Body] string first, [Body] string second); + + [Get("/tokens")] + Task MultipleTokens(CancellationToken first, CancellationToken second); + + [Get("/collections")] + Task MultipleCollections( + [HeaderCollection] IDictionary first, + [HeaderCollection] IDictionary second); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + await Assert.That(generated).DoesNotContain("GeneratedRequestRunner.SetHeader(______rq, \" \","); + await Assert.That(generated).DoesNotContain("MultipleBodies(" + Environment.NewLine + "var ______settings"); + } + + /// Verifies body buffering and serialization modes are emitted for supported inline bodies. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnEmitsBodyBufferModesAndSerializationModes() + { + var generated = Fixture.GenerateForBody( + """ + [Post("/default")] + Task DefaultBody([Body] string body); + + [Post("/buffered")] + Task BufferedBody([Body(true)] string body); + + [Post("/streaming")] + Task StreamingBody([Body(false)] string body); + + [Post("/serialized")] + Task SerializedBody([Body(BodySerializationMethod.Serialized, false)] string body); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("BodySerializationMethod.Default"); + await Assert.That(generated).Contains("______settings.Buffered"); + await Assert.That(generated).Contains("BodySerializationMethod.Serialized"); + await Assert.That(generated).Contains("!______settings.Buffered"); + await Assert.That(generated).Contains("true,"); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies unsupported body serialization values fall back to the runtime builder. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnFallsBackForUnsupportedInlineBodySerialization() + { + var generated = Fixture.GenerateForBody( + """ + [Post("/form")] + Task Form([Body(BodySerializationMethod.UrlEncoded)] Dictionary form); + + [Post("/unknown")] + Task Unknown([Body((BodySerializationMethod)123)] string body); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + await Assert.That(generated).DoesNotContain("GeneratedRequestRunner.CreateBodyContent>"); + } + + /// Verifies return-type metadata for API response wrappers and raw response body types. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnEmitsApiResponseAndRawBodyReturnShapes() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/api-response")] + Task> ApiResponse(); + + [Get("/iapi-response")] + Task> GenericIApiResponse(); + + [Get("/bare-iapi-response")] + Task BareIApiResponse(); + + [Get("/response")] + Task ResponseMessage(); + + [Get("/content")] + Task Content(); + + [Get("/stream")] + Task Stream(); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("SendAsync, string>"); + await Assert.That(generated).Contains("SendAsync, string>"); + await Assert.That(generated).Contains("SendAsync"); + await Assert.That(generated).Contains("SendAsync"); + await Assert.That(generated).Contains("SendAsync"); + await Assert.That(generated).Contains("SendAsync"); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies ValueTask return types wrap the generated runner task. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnWrapsValueTaskInlineReturns() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/users")] + ValueTask Get(CancellationToken? cancellationToken); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("return new global::System.Threading.Tasks.ValueTask("); + await Assert.That(generated).Contains("@cancellationToken.GetValueOrDefault()"); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies property attributes on interface properties are implemented and passed into generated requests. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnEmitsInterfacePropertyRequestProperty() + { + var generated = Fixture.GenerateForDeclaration( + """ + public interface IGeneratedClient + { + [Property("tenant")] + int TenantId { get; set; } + + [Get("/users")] + Task Get(); + } + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("public int TenantId { get; set; }"); + await Assert.That(generated).Contains("GeneratedRequestRunner.AddRequestProperty(______rq, \"tenant\", this.TenantId)"); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies a get-only HttpClient Client interface property is satisfied by the generated infrastructure property. + /// A task representing the asynchronous test. + [Test] + public async Task DoesNotReemitGeneratedClientProperty() + { + var generated = Fixture.GenerateForDeclaration( + """ + public interface IGeneratedClient + { + HttpClient Client { get; } + + [Get("/users")] + Task Get(); + } + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + var clientPropertyCount = generated.Split("public global::System.Net.Http.HttpClient Client").Length - 1; + await Assert.That(clientPropertyCount).IsEqualTo(1); + } + + /// Verifies inherited non-Refit interface properties are implemented explicitly. + /// A task representing the asynchronous test. + [Test] + public async Task ImplementsInheritedRegularInterfaceProperty() + { + var generated = Fixture.GenerateForDeclaration( + """ + public interface IBaseApi + { + string BaseUri { get; set; } + } + + public interface IGeneratedClient : IBaseApi + { + [Get("/users")] + Task Get(); + } + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("global::IBaseApi.BaseUri"); + await Assert.That(generated).DoesNotContain("Either this method has no Refit HTTP method attribute"); + } + + /// Verifies inherited generated-client properties and methods are emitted through explicit interfaces. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnEmitsInheritedPropertiesMethodsAndDispose() + { + var generated = Fixture.GenerateForDeclaration( + """ + public interface IBaseApi : IDisposable + { + [Property("base-tenant")] + int BaseTenant { get; } + + string Name { set; } + + [Get("/base")] + Task GetBase(); + + string Helper(T value) + where T : class, new(); + } + + public interface IGeneratedClient : IBaseApi + { + [Get("/users")] + Task Get(); + + string IBaseApi.Helper(T value) => string.Empty; + } + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains("global::IBaseApi.BaseTenant"); + await Assert.That(generated).Contains("global::IBaseApi.Name"); + await Assert.That(generated).Contains("global::IBaseApi.GetBase()"); + await Assert.That(generated).Contains("void global::System.IDisposable.Dispose()"); + await Assert.That(generated).DoesNotContain("global::IBaseApi.Helper"); + } +} diff --git a/src/tests/Refit.GeneratorTests/GeneratorComponentTests.cs b/src/tests/Refit.GeneratorTests/GeneratorComponentTests.cs new file mode 100644 index 000000000..58514dd74 --- /dev/null +++ b/src/tests/Refit.GeneratorTests/GeneratorComponentTests.cs @@ -0,0 +1,602 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +using Refit.Generator; + +namespace Refit.GeneratorTests; + +/// +/// Focused unit tests for the individual building blocks of the source generator, +/// exercised directly rather than through end-to-end snapshot generation. +/// +public static class GeneratorComponentTests +{ + /// Tests for . + public class UniqueNameBuilderTests + { + /// The member name used to test simple generated-name collisions. + private const string ClientName = "client"; + + /// The first generated collision suffix for . + private const string FirstClientCollisionName = "client0"; + + /// Verifies that an unused name is returned unchanged. + /// A task representing the asynchronous test. + [Test] + public async Task New_ReturnsOriginalName_WhenUnused() + { + var builder = new UniqueNameBuilder(); + + await Assert.That(builder.New(ClientName)).IsEqualTo(ClientName); + } + + /// Verifies that a numeric suffix is appended when a name collides. + /// A task representing the asynchronous test. + [Test] + public async Task New_AppendsSuffix_OnCollision() + { + var builder = new UniqueNameBuilder(); + + await Assert.That(builder.New(ClientName)).IsEqualTo(ClientName); + await Assert.That(builder.New(ClientName)).IsEqualTo(FirstClientCollisionName); + await Assert.That(builder.New(ClientName)).IsEqualTo("client1"); + } + + /// Verifies that a reserved name is never handed out directly. + /// A task representing the asynchronous test. + [Test] + public async Task Reserve_PreventsName_FromBeingHandedOut() + { + var builder = new UniqueNameBuilder(); + builder.Reserve([ClientName]); + + await Assert.That(builder.New(ClientName)).IsEqualTo(FirstClientCollisionName); + } + + /// Verifies that reserving an enumerable prevents every supplied name. + /// A task representing the asynchronous test. + [Test] + public async Task Reserve_Enumerable_PreventsAllNames() + { + var builder = new UniqueNameBuilder(); + builder.Reserve([ClientName, "requestBuilder"]); + + await Assert.That(builder.New(ClientName)).IsEqualTo(FirstClientCollisionName); + await Assert.That(builder.New("requestBuilder")).IsEqualTo("requestBuilder0"); + } + + /// Verifies that reserving a null enumerable does not throw. + /// A task representing the asynchronous test. + [Test] + public async Task Reserve_NullEnumerable_DoesNotThrow() + { + var builder = new UniqueNameBuilder(); + + builder.Reserve((IEnumerable)null!); + + await Assert.That(builder.New(ClientName)).IsEqualTo(ClientName); + } + + /// Verifies that independent builders do not share reservations. + /// A task representing the asynchronous test. + [Test] + public async Task IndependentBuilders_DoNotShareReservations() + { + var first = new UniqueNameBuilder(); + first.Reserve([ClientName]); + + var second = new UniqueNameBuilder(); + + await Assert.That(second.New(ClientName)).IsEqualTo(ClientName); + } + + /// Verifies that names handed out in one builder do not leak to another builder. + /// A task representing the asynchronous test. + [Test] + public async Task IndependentBuilders_DoNotShareGeneratedNames() + { + var first = new UniqueNameBuilder(); + first.New("local"); + + var second = new UniqueNameBuilder(); + + await Assert.That(second.New("local")).IsEqualTo("local"); + } + } + + /// Tests for . + public class SourceWriterTests + { + /// Verifies that the configured indentation is applied to written lines. + /// A task representing the asynchronous test. + [Test] + public async Task WriteLine_AppliesIndentation() + { + var writer = new SourceWriter { Indentation = 1 }; + writer.WriteLine("body"); + writer.Indentation = 0; + + await Assert.That(writer.ToSourceText().ToString()).StartsWith(" body"); + } + + /// Verifies that zero indentation produces no leading whitespace. + /// A task representing the asynchronous test. + [Test] + public async Task WriteLine_ZeroIndentation_HasNoLeadingWhitespace() + { + var writer = new SourceWriter(); + writer.WriteLine("body"); + + await Assert.That(writer.ToSourceText().ToString()).StartsWith("body"); + } + + /// Verifies that a negative indentation value throws. + /// A task representing the asynchronous test. + [Test] + public async Task Indentation_Negative_Throws() + { + var writer = new SourceWriter(); + + await Assert.That(() => writer.Indentation = -1).ThrowsExactly(); + } + + /// Verifies that resetting clears both buffered content and indentation. + /// A task representing the asynchronous test. + [Test] + public async Task Reset_ClearsContentAndIndentation() + { + var writer = new SourceWriter { Indentation = 2 }; + writer.WriteLine("first"); + + writer.Reset(); + await Assert.That(writer.Indentation).IsEqualTo(0); + + writer.WriteLine("second"); + var text = writer.ToSourceText().ToString(); + + await Assert.That(text).StartsWith("second"); + await Assert.That(text.Split('\n')).DoesNotContain("first"); + } + + /// Verifies CRLF line endings are normalized without preserving carriage returns. + /// A task representing the asynchronous test. + [Test] + public async Task Append_MultilineCrLf_TrimsCarriageReturn() + { + var writer = new SourceWriter { Indentation = 1 }; + + writer.WriteLine("first\r\nsecond"); + writer.Indentation = 0; + + var text = writer.ToSourceText().ToString(); + + await Assert.That(text).IsEqualTo($" first{Environment.NewLine} second{Environment.NewLine}"); + await Assert.That(text).DoesNotContain($"\r{Environment.NewLine}"); + } + } + + /// Tests for . + public class ImmutableEquatableArrayTests + { + /// Verifies that arrays with the same sequence are equal and share a hash code. + /// A task representing the asynchronous test. + [Test] + public async Task Equals_SameSequence_IsTrue() + { + var left = new ImmutableEquatableArray(["a", "b", "c"]); + var right = new ImmutableEquatableArray(["a", "b", "c"]); + + await Assert.That(right).IsEqualTo(left); + await Assert.That(right.GetHashCode()).IsEqualTo(left.GetHashCode()); + } + + /// Verifies that arrays with differing sequences are not equal. + /// A task representing the asynchronous test. + [Test] + public async Task Equals_DifferentSequence_IsFalse() + { + var left = new ImmutableEquatableArray(["a", "b", "c"]); + var right = new ImmutableEquatableArray(["a", "x", "c"]); + + await Assert.That(right).IsNotEqualTo(left); + } + + /// Verifies that the empty array has no elements. + /// A task representing the asynchronous test. + [Test] + public async Task Empty_HasNoElements() => await Assert.That(ImmutableEquatableArray.Empty.Count).IsEqualTo(0); + + /// Verifies that converting a null source yields an empty array. + /// A task representing the asynchronous test. + [Test] + public async Task ToImmutableEquatableArray_Null_ReturnsEmpty() + { + var result = ((List?)null).ToImmutableEquatableArray(); + + await Assert.That(result.Count).IsEqualTo(0); + } + + /// Verifies that enumeration yields all values in their original order. + /// A task representing the asynchronous test. + [Test] + public async Task Enumerator_YieldsAllValuesInOrder() + { + const int FirstValue = 10; + const int SecondValue = 20; + const int ThirdValue = 30; + const int ExpectedCount = 3; + var array = new ImmutableEquatableArray([FirstValue, SecondValue, ThirdValue]); + + var collected = new List(array.Count); + collected.AddRange(array); + + await Assert.That(collected).IsCollectionEqualTo([FirstValue, SecondValue, ThirdValue]); + await Assert.That(array.Count).IsEqualTo(ExpectedCount); + await Assert.That(array[1]).IsEqualTo(SecondValue); + await Assert.That(array.AsArray()).IsSameReferenceAs(array.AsArray()); + await Assert.That(array.Equals(NullIntArray())).IsFalse(); + } + + /// Returns a null immutable array reference without making the call site a constant condition. + /// A null array reference. + private static ImmutableEquatableArray? NullIntArray() => null; + } + + /// Tests for direct emitter formatting helpers. + public class EmitterHelperTests + { + /// The default body serialization method name. + private const string DefaultSerializationMethod = "Default"; + + /// The generated false literal. + private const string FalseLiteral = "false"; + + /// The generated true literal. + private const string TrueLiteral = "true"; + + /// Verifies escaping every special C# string-literal character. + /// A task representing the asynchronous test. + [Test] + public async Task AppendEscapedCharacter_HandlesSpecialCharacters() + { + var builder = new StringBuilder(); + + foreach (var value in new[] { '\\', '"', '\0', '\a', '\b', '\f', '\n', '\r', '\t', '\v', 'x' }) + { + Emitter.AppendEscapedCharacter(builder, value); + } + + await Assert.That(builder.ToString()).IsEqualTo(@"\\\""\0\a\b\f\n\r\t\vx"); + } + + /// Verifies body buffering and streaming expressions for all supported modes. + /// A task representing the asynchronous test. + [Test] + public async Task BodyExpressionHelpers_HandleBufferModes() + { + var settingsBody = CreateBody(DefaultSerializationMethod, BodyBufferMode.Settings); + var bufferedBody = CreateBody(DefaultSerializationMethod, BodyBufferMode.Buffered); + var streamingBody = CreateBody(DefaultSerializationMethod, BodyBufferMode.Streaming); + var noneBody = CreateBody(DefaultSerializationMethod, BodyBufferMode.None); + var urlEncodedBody = CreateBody("UrlEncoded", BodyBufferMode.Streaming); + + await Assert.That(Emitter.BuildBufferBodyExpression(null)).IsEqualTo(FalseLiteral); + await Assert.That(Emitter.BuildBufferBodyExpression(settingsBody)).IsEqualTo("______settings.Buffered"); + await Assert.That(Emitter.BuildBufferBodyExpression(bufferedBody)).IsEqualTo(TrueLiteral); + await Assert.That(Emitter.BuildBufferBodyExpression(streamingBody)).IsEqualTo(FalseLiteral); + await Assert.That(Emitter.BuildBufferBodyExpression(noneBody)).IsEqualTo(FalseLiteral); + await Assert.That(Emitter.BuildStreamBodyExpression(settingsBody)).IsEqualTo("!______settings.Buffered"); + await Assert.That(Emitter.BuildStreamBodyExpression(bufferedBody)).IsEqualTo(FalseLiteral); + await Assert.That(Emitter.BuildStreamBodyExpression(streamingBody)).IsEqualTo(TrueLiteral); + await Assert.That(Emitter.BuildStreamBodyExpression(noneBody)).IsEqualTo(FalseLiteral); + await Assert.That(Emitter.BuildStreamBodyExpression(urlEncodedBody)).IsEqualTo(FalseLiteral); + } + + /// Verifies property access and global-prefix helpers. + /// A task representing the asynchronous test. + [Test] + public async Task PropertyAccessHelpers_HandleGeneratedExplicitAndPublicProperties() + { + const string TenantInterface = "RefitGeneratorTest.ITenant"; + const string GlobalTenantInterface = "global::RefitGeneratorTest.ITenant"; + + var generatedProperty = CreateProperty("Client", "global::System.Net.Http.HttpClient", "RefitGeneratorTest.IClient", true, false); + var explicitProperty = CreateProperty("Tenant", "int", TenantInterface, false, true); + var prefixedExplicitProperty = CreateProperty("Tenant", "int", GlobalTenantInterface, false, true); + var publicProperty = CreateProperty("Tenant", "int", TenantInterface, false, false); + + await Assert.That(Emitter.BuildPropertyAccessExpression(generatedProperty)).IsEqualTo("this.Client"); + await Assert.That(Emitter.BuildPropertyAccessExpression(explicitProperty)) + .IsEqualTo($"(({GlobalTenantInterface})this).Tenant"); + await Assert.That(Emitter.BuildPropertyAccessExpression(prefixedExplicitProperty)) + .IsEqualTo($"(({GlobalTenantInterface})this).Tenant"); + await Assert.That(Emitter.BuildPropertyAccessExpression(publicProperty)).IsEqualTo("this.Tenant"); + await Assert.That(Emitter.EnsureGlobalPrefix(TenantInterface)).IsEqualTo(GlobalTenantInterface); + await Assert.That(Emitter.EnsureGlobalPrefix(GlobalTenantInterface)).IsEqualTo(GlobalTenantInterface); + } + + /// Verifies HTTP method, literal, and explicit-prefix formatting helpers. + /// A task representing the asynchronous test. + [Test] + public async Task LiteralAndHttpMethodHelpers_HandleKnownAndInvalidValues() + { + await Assert.That(Emitter.ToNullableCSharpStringLiteral(null)).IsEqualTo("null"); + await Assert.That(Emitter.ToNullableCSharpStringLiteral("value")).IsEqualTo("\"value\""); + await Assert.That(Emitter.ToHttpMethodExpression("DELETE")).IsEqualTo("global::System.Net.Http.HttpMethod.Delete"); + await Assert.That(Emitter.ToHttpMethodExpression("GET")).IsEqualTo("global::System.Net.Http.HttpMethod.Get"); + await Assert.That(Emitter.ToHttpMethodExpression("HEAD")).IsEqualTo("global::System.Net.Http.HttpMethod.Head"); + await Assert.That(Emitter.ToHttpMethodExpression("OPTIONS")).IsEqualTo("global::System.Net.Http.HttpMethod.Options"); + await Assert.That(Emitter.ToHttpMethodExpression("POST")).IsEqualTo("global::System.Net.Http.HttpMethod.Post"); + await Assert.That(Emitter.ToHttpMethodExpression("PUT")).IsEqualTo("global::System.Net.Http.HttpMethod.Put"); + await Assert.That(Emitter.ToHttpMethodExpression("PATCH")).IsEqualTo("new global::System.Net.Http.HttpMethod(\"PATCH\")"); + await Assert.That(Emitter.StripExplicitInterfacePrefix("IFoo.Bar")).IsEqualTo("Bar"); + await Assert.That(Emitter.StripExplicitInterfacePrefix("IFoo.")).IsEqualTo("IFoo."); + await Assert.That(Emitter.StripExplicitInterfacePrefix("Bar")).IsEqualTo("Bar"); + await Assert.That(() => Emitter.ToHttpMethodExpression("TRACE")).ThrowsExactly(); + } + + /// Verifies generated return invocation text for every return type shape. + /// A task representing the asynchronous test. + [Test] + public async Task ReturnInvocationParts_HandleKnownAndInvalidValues() + { + await Assert.That(Emitter.GetReturnInvocationParts(ReturnTypeInfo.AsyncVoid)) + .IsEqualTo((true, "await (", ").ConfigureAwait(false)")); + await Assert.That(Emitter.GetReturnInvocationParts(ReturnTypeInfo.AsyncResult)) + .IsEqualTo((true, "return await (", ").ConfigureAwait(false)")); + await Assert.That(Emitter.GetReturnInvocationParts(ReturnTypeInfo.Return)) + .IsEqualTo((false, "return ", string.Empty)); + await Assert.That(Emitter.GetReturnInvocationParts(ReturnTypeInfo.SyncVoid)) + .IsEqualTo((false, string.Empty, string.Empty)); + await Assert.That(() => Emitter.GetReturnInvocationParts((ReturnTypeInfo)int.MaxValue)) + .ThrowsExactly(); + } + + /// Verifies explicit method openings receive a global interface qualifier. + /// A task representing the asynchronous test. + [Test] + public async Task WriteMethodOpening_QualifiesExplicitInterfaceMethods() + { + var writer = new SourceWriter(); + var method = new MethodModel( + "Ping", + "global::System.Threading.Tasks.Task", + "RefitGeneratorTest.IBase", + "Ping", + ReturnTypeInfo.AsyncVoid, + RequestModel.Empty, + ImmutableEquatableArray.Empty, + ImmutableEquatableArray.Empty, + false); + + Emitter.WriteMethodOpening(writer, method, true, true, true); + + await Assert.That(writer.ToSourceText().ToString()) + .Contains("async global::System.Threading.Tasks.Task global::RefitGeneratorTest.IBase.Ping("); + } + + /// Creates a body request parameter model. + /// The serialization method name. + /// The body buffer mode. + /// The request parameter model. + private static RequestParameterModel CreateBody(string serializationMethod, BodyBufferMode bufferMode) => + new("body", "string", RequestParameterKind.Body, false, string.Empty, string.Empty, serializationMethod, bufferMode); + + /// Creates an interface property model. + /// The property name. + /// The property type. + /// The containing type display name. + /// Whether it is satisfied by a generated member. + /// Whether it is implemented explicitly. + /// The interface property model. + private static InterfacePropertyModel CreateProperty( + string name, + string type, + string containingType, + bool generated, + bool explicitInterface) => + new(name, type, false, containingType, string.Empty, true, true, generated, explicitInterface); + } + + /// Tests for direct parser request helpers. + public class ParserRequestHelperTests + { + /// The simple path used by parser helper assertions. + private const string SimplePath = "/path"; + + /// The number of characters checked in whitespace assertions. + private const int WhitespaceLength = 2; + + /// The enum value for URL encoded body serialization. + private const int UrlEncodedSerializationValue = 2; + + /// The enum value for serialized body serialization. + private const int SerializedSerializationValue = 3; + + /// An unsupported body serialization enum value. + private const int UnsupportedSerializationValue = 4; + + /// Verifies inline path normalization and constant path classification. + /// A task representing the asynchronous test. + [Test] + public async Task InlinePathHelpers_NormalizeAndClassifyPaths() + { + await Assert.That(Parser.NormalizeConstantPathForInline(SimplePath)).IsEqualTo(SimplePath); + await Assert.That(Parser.NormalizeConstantPathForInline("/path?")).IsEqualTo(SimplePath); + await Assert.That(Parser.NormalizeConstantPathForInline("/path?& \t =drop")).IsEqualTo(SimplePath); + await Assert.That(Parser.NormalizeConstantPathForInline("/path?one=1&&two=2#fragment")).IsEqualTo("/path?one=1&two=2"); + await Assert.That(Parser.IsConstantPathSupported(string.Empty)).IsTrue(); + await Assert.That(Parser.IsConstantPathSupported(SimplePath)).IsTrue(); + await Assert.That(Parser.IsConstantPathSupported("relative")).IsFalse(); + await Assert.That(Parser.IsConstantPathSupported("/{id}")).IsFalse(); + await Assert.That(Parser.IsConstantPathSupported("/id}")).IsFalse(); + await Assert.That(Parser.IsConstantPathSupported("/line\nbreak")).IsFalse(); + await Assert.That(Parser.IsConstantPathSupported("/line\rbreak")).IsFalse(); + await Assert.That(Parser.IsWhiteSpace(" \t", 0, WhitespaceLength)).IsTrue(); + await Assert.That(Parser.IsWhiteSpace(" a", 0, WhitespaceLength)).IsFalse(); + } + + /// Verifies static header merging behavior. + /// A task representing the asynchronous test. + [Test] + public async Task AddStaticHeader_SkipsBlankAndReplacesExistingValues() + { + const int ExpectedHeaderCount = 2; + var headers = new List(); + + Parser.AddStaticHeader(headers, " "); + Parser.AddStaticHeader(headers, "X-One"); + Parser.AddStaticHeader(headers, "X-Two: two"); + Parser.AddStaticHeader(headers, "X-One: replaced"); + + await Assert.That(headers.Count).IsEqualTo(ExpectedHeaderCount); + await Assert.That(headers[0].Name).IsEqualTo("X-One"); + await Assert.That(headers[0].Value).IsEqualTo("replaced"); + await Assert.That(headers[1].Name).IsEqualTo("X-Two"); + await Assert.That(headers[1].Value).IsEqualTo("two"); + } + + /// Verifies body serialization, inline-body eligibility, and response disposal helpers. + /// A task representing the asynchronous test. + [Test] + public async Task BodyAndDisposalHelpers_ClassifySupportedValues() + { + await Assert.That(Parser.GetBodySerializationMethodName(0)).IsEqualTo("Default"); + await Assert.That(Parser.GetBodySerializationMethodName(1)).IsEqualTo("Json"); + await Assert.That(Parser.GetBodySerializationMethodName(UrlEncodedSerializationValue)).IsEqualTo("UrlEncoded"); + await Assert.That(Parser.GetBodySerializationMethodName(SerializedSerializationValue)).IsEqualTo("Serialized"); + await Assert.That(Parser.GetBodySerializationMethodName(UnsupportedSerializationValue)).IsEqualTo(string.Empty); + await Assert.That(Parser.IsSupportedInlineBody(ImmutableEquatableArray.Empty)).IsTrue(); + await Assert.That(Parser.IsSupportedInlineBody(new ImmutableEquatableArray([CreateHeaderParameter()]))).IsTrue(); + await Assert.That(Parser.IsSupportedInlineBody(new ImmutableEquatableArray([CreateBody(string.Empty)]))).IsFalse(); + await Assert.That(Parser.IsSupportedInlineBody(new ImmutableEquatableArray([CreateBody("UrlEncoded")]))).IsFalse(); + await Assert.That(Parser.IsSupportedInlineBody(new ImmutableEquatableArray([CreateBody("Serialized")]))).IsTrue(); + await Assert.That(Parser.ShouldDisposeResponse("global::System.Net.Http.HttpResponseMessage")).IsFalse(); + await Assert.That(Parser.ShouldDisposeResponse("global::System.Net.Http.HttpContent")).IsFalse(); + await Assert.That(Parser.ShouldDisposeResponse("global::System.IO.Stream")).IsFalse(); + await Assert.That(Parser.ShouldDisposeResponse("global::System.String")).IsTrue(); + } + + /// Creates a non-body parameter model. + /// The request parameter model. + private static RequestParameterModel CreateHeaderParameter() => + new("query", "string", RequestParameterKind.Header, true, string.Empty, string.Empty, string.Empty, BodyBufferMode.None); + + /// Creates a body parameter model. + /// The serialization method name. + /// The request parameter model. + private static RequestParameterModel CreateBody(string serializationMethod) => + new("body", "string", RequestParameterKind.Body, false, string.Empty, string.Empty, serializationMethod, BodyBufferMode.Buffered); + } + + /// Tests for the ITypeSymbol generator extension helpers. + public class ITypeSymbolExtensionsTests + { + /// The derived test type name used by inheritance assertions. + private const string DerivedTypeName = "Derived"; + + /// Verifies that inheritance checks walk through the full base-type chain. + /// A task representing the asynchronous test. + [Test] + public async Task InheritsFromOrEquals_TransitiveBaseType_IsTrue() + { + var compilation = Compile(""" + public class Base { } + public class Middle : Base { } + public class Derived : Middle { } + """); + var derived = GetType(compilation, DerivedTypeName); + var baseType = GetType(compilation, "Base"); + + await Assert.That(derived.InheritsFromOrEquals(baseType)).IsTrue(); + } + + /// Verifies that a type inherits from or equals itself. + /// A task representing the asynchronous test. + [Test] + public async Task InheritsFromOrEquals_SameType_IsTrue() + { + var compilation = Compile("public class Derived { }"); + var derived = GetType(compilation, DerivedTypeName); + + await Assert.That(derived.InheritsFromOrEquals(derived)).IsTrue(); + } + + /// Verifies that a derived type inherits from its base type. + /// A task representing the asynchronous test. + [Test] + public async Task InheritsFromOrEquals_BaseType_IsTrue() + { + var compilation = Compile(""" + public class Base { } + public class Derived : Base { } + """); + + await Assert.That( + GetType(compilation, DerivedTypeName).InheritsFromOrEquals(GetType(compilation, "Base"))).IsTrue(); + } + + /// Verifies that unrelated types do not inherit from one another. + /// A task representing the asynchronous test. + [Test] + public async Task InheritsFromOrEquals_UnrelatedType_IsFalse() + { + var compilation = Compile(""" + public class Foo { } + public class Bar { } + """); + + await Assert.That( + GetType(compilation, "Foo").InheritsFromOrEquals(GetType(compilation, "Bar"))).IsFalse(); + } + + /// Verifies that interface inheritance is only considered when the include-interfaces flag is set. + /// A task representing the asynchronous test. + [Test] + public async Task InheritsFromOrEquals_Interface_HonorsIncludeInterfacesFlag() + { + var compilation = Compile(""" + public interface IThing { } + public class Thing : IThing { } + """); + var thing = GetType(compilation, "Thing"); + var thingInterface = GetType(compilation, "IThing"); + + await Assert.That(thing.InheritsFromOrEquals(thingInterface, includeInterfaces: false)).IsFalse(); + await Assert.That(thing.InheritsFromOrEquals(thingInterface, includeInterfaces: true)).IsTrue(); + } + + /// Compiles the supplied C# source into an in-memory compilation. + /// The C# source to compile. + /// The resulting compilation. + private static CSharpCompilation Compile(string source) + { + var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)); + + return CSharpCompilation.Create( + "TypeSymbolTests", + [CSharpSyntaxTree.ParseText(source)], + references, + new(OutputKind.DynamicallyLinkedLibrary)); + } + + /// Resolves a named type symbol from the compilation by metadata name. + /// The compilation to search. + /// The metadata name of the type to find. + /// The resolved type symbol. + private static INamedTypeSymbol GetType(Compilation compilation, string typeName) => + compilation.GetTypeByMetadataName(typeName) + ?? throw new InvalidOperationException($"Type '{typeName}' was not found."); + } +} diff --git a/src/tests/Refit.GeneratorTests/GeneratorTestResult.cs b/src/tests/Refit.GeneratorTests/GeneratorTestResult.cs new file mode 100644 index 000000000..647ccd8e1 --- /dev/null +++ b/src/tests/Refit.GeneratorTests/GeneratorTestResult.cs @@ -0,0 +1,74 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Refit.GeneratorTests; + +/// Wraps the result of running the Refit source generator against a test compilation. +public sealed class GeneratorTestResult +{ + /// Initializes a new instance of the class. + /// The generator driver after execution. + /// The output compilation containing generated source. + /// The diagnostics produced by the generator driver. + public GeneratorTestResult( + GeneratorDriver driver, + Compilation outputCompilation, + ImmutableArray generatorDiagnostics) + { + ArgumentNullException.ThrowIfNull(driver); + ArgumentNullException.ThrowIfNull(outputCompilation); + + Driver = driver; + OutputCompilation = outputCompilation; + GeneratorDiagnostics = generatorDiagnostics; + CompilationErrors = + [ + .. outputCompilation.GetDiagnostics() + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + ]; + GeneratedSources = BuildGeneratedSources(driver); + } + + /// Gets the generator driver after execution. + public GeneratorDriver Driver { get; } + + /// Gets the output compilation containing generated source. + public Compilation OutputCompilation { get; } + + /// Gets the diagnostics produced by the generator driver. + public ImmutableArray GeneratorDiagnostics { get; } + + /// Gets compilation diagnostics with error severity. + public ImmutableArray CompilationErrors { get; } + + /// Gets generated source text by hint name. + public IReadOnlyDictionary GeneratedSources { get; } + + /// Gets a value indicating whether the generated output compiles without errors. + public bool CompilesWithoutErrors => CompilationErrors.Length == 0; + + /// Builds generated source text by hint name. + /// The generator driver after execution. + /// The generated source map. + private static Dictionary BuildGeneratedSources(GeneratorDriver driver) + { + var generatedSources = new Dictionary(StringComparer.Ordinal); + foreach (var result in driver.GetRunResult().Results) + { + if (result.GeneratedSources.IsDefaultOrEmpty) + { + continue; + } + + foreach (var source in result.GeneratedSources) + { + generatedSources[source.HintName] = source.SourceText.ToString(); + } + } + + return generatedSources; + } +} diff --git a/src/tests/Refit.Tests/InterfaceStubGeneratorTests.cs b/src/tests/Refit.GeneratorTests/InterfaceStubGeneratorTests.cs similarity index 50% rename from src/tests/Refit.Tests/InterfaceStubGeneratorTests.cs rename to src/tests/Refit.GeneratorTests/InterfaceStubGeneratorTests.cs index eda57969a..52371d2a5 100644 --- a/src/tests/Refit.Tests/InterfaceStubGeneratorTests.cs +++ b/src/tests/Refit.GeneratorTests/InterfaceStubGeneratorTests.cs @@ -1,72 +1,30 @@ // Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; -using System.Reflection; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Testing; using Refit.Generator; using Task = System.Threading.Tasks.Task; -namespace Refit.Tests; +namespace Refit.GeneratorTests; /// Verifies the Refit interface stub source generator against known fixture interfaces. [RequiresUnreferencedCode("Refit's reflection-based serialization and request building are exercised by these tests.")] [RequiresDynamicCode("Refit's reflection-based serialization and request building are exercised by these tests.")] public class InterfaceStubGeneratorTests { - /// The Refit assembly metadata reference used when compiling generator inputs. - [UnconditionalSuppressMessage( - "SingleFile", - "IL3000:Avoid accessing Assembly file path when publishing as a single file", - Justification = "Test compiles generator inputs against the on-disk Refit assembly; never run as a single-file app.")] - private static readonly MetadataReference RefitAssembly = MetadataReference.CreateFromFile( - typeof(GetAttribute).Assembly.Location, - documentation: XmlDocumentationProvider.CreateFromFile( - Path.ChangeExtension(typeof(GetAttribute).Assembly.Location, ".xml"))); - - /// The reference assemblies for the target framework under test. - private static readonly ReferenceAssemblies ReferenceAssemblies; - - /// Initializes static members of the class. - static InterfaceStubGeneratorTests() - { -#if NET6_0 - ReferenceAssemblies = ReferenceAssemblies.Net.Net60; -#elif NET8_0 - ReferenceAssemblies = ReferenceAssemblies.Net.Net80; -#elif NET9_0 - ReferenceAssemblies = ReferenceAssemblies.Net.Net90; -#else - ReferenceAssemblies = ReferenceAssemblies.Default.AddPackages( - [new PackageIdentity("System.Text.Json", "7.0.2")]); -#endif - -#if NET48 - ReferenceAssemblies = ReferenceAssemblies - .AddAssemblies(ImmutableArray.Create("System.Web")) - .AddPackages(ImmutableArray.Create(new PackageIdentity("System.Net.Http", "4.3.4"))); -#endif - } - /// Runs the interface stub generator over the supplied source file and verifies the output. /// The path to the source file to feed to the generator. /// The snapshot verification result for the generated output. public static async Task VerifyGenerator(string input) { - var assemblies = await ReferenceAssemblies.ResolveAsync(null, default); - - string[] inputs = [input]; - var compilation = CSharpCompilation.Create( - "compilation", - inputs.Select(source => CSharpSyntaxTree.ParseText(File.ReadAllText(source))), - assemblies.Add(RefitAssembly), - new CSharpCompilationOptions(OutputKind.ConsoleApplication)); + var source = await File.ReadAllTextAsync(input); + var compilation = Fixture.CreateLibrary( + CSharpSyntaxTree.ParseText(source)); var generator = new InterfaceStubGeneratorV2(); var driver = CSharpGeneratorDriver.Create(generator); @@ -80,7 +38,7 @@ public static async Task VerifyGenerator(string input) [Test] public async Task NoRefitInterfacesSmokeTest() { - var path = IntegrationTestHelper.GetPath("IInterfaceWithoutRefit.cs"); + var path = GetFixturePath("IInterfaceWithoutRefit.cs"); await VerifyGenerator(path); } @@ -89,7 +47,7 @@ public async Task NoRefitInterfacesSmokeTest() [Test] public async Task FindInterfacesSmokeTest() { - var path = IntegrationTestHelper.GetPath("GitHubApi.cs"); + var path = GetFixturePath("GitHubApi.cs"); await VerifyGenerator(path); } @@ -98,7 +56,19 @@ public async Task FindInterfacesSmokeTest() [Test] public async Task GenerateInterfaceStubsWithoutNamespaceSmokeTest() { - var path = IntegrationTestHelper.GetPath("IServiceWithoutNamespace.cs"); + var path = GetFixturePath("IServiceWithoutNamespace.cs"); await VerifyGenerator(path); } + + /// Gets the path to a source fixture owned by the runtime test project. + /// The fixture path parts. + /// The absolute fixture path. + private static string GetFixturePath(params string[] paths) + { + var generatorTestProjectDirectory = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, "..", "..", "..")); + var runtimeTestProjectDirectory = Path.GetFullPath( + Path.Combine(generatorTestProjectDirectory, "..", "Refit.Tests")); + return Path.Combine([runtimeTestProjectDirectory, ..paths]); + } } diff --git a/src/tests/Refit.GeneratorTests/LiveCompilationTests.cs b/src/tests/Refit.GeneratorTests/LiveCompilationTests.cs new file mode 100644 index 000000000..02406fdaf --- /dev/null +++ b/src/tests/Refit.GeneratorTests/LiveCompilationTests.cs @@ -0,0 +1,124 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Text; + +namespace Refit.GeneratorTests; + +/// Live compilation tests for generated Refit implementations. +public sealed class LiveCompilationTests +{ + /// Compiles, loads, and invokes generated request-building code. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("The test deliberately reflects over a live-generated assembly.")] + [RequiresDynamicCode("The test deliberately emits and invokes a live-generated assembly.")] + public async Task GeneratedRequestBuilding_CanBeEmittedLoadedAndInvoked() + { + const int HeaderId = 42; + const int PropertyTenantId = 17; + const int ParameterTenantId = 23; + + var result = Fixture.RunGenerator( + """ + using System; + using System.Collections.Generic; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using Refit; + + namespace Refit.LiveCompilation; + + public interface ILiveGeneratedApi + { + [Property("property-tenant")] + int TenantId { get; set; } + + [Headers("X-Static: static")] + [Get("/users")] + Task Get( + [Header("X-Id")] int id, + [HeaderCollection] IDictionary headers, + [Property("parameter-tenant")] int tenantId, + CancellationToken cancellationToken); + } + """, + generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + + var (assembly, context) = Fixture.EmitAndLoad(result); + using (context) + { + var interfaceType = assembly.GetType( + "Refit.LiveCompilation.ILiveGeneratedApi", + throwOnError: true)!; + var generatedType = assembly + .GetTypes() + .Single(type => type.IsClass && interfaceType.IsAssignableFrom(type)); + + using var handler = new CapturingHandler(); + using var client = new HttpClient(handler) + { + BaseAddress = new("https://example.test/base/") + }; + var settings = new RefitSettings(); + var requestBuilder = RequestBuilder.ForType(interfaceType, settings); + var api = Activator.CreateInstance(generatedType, [client, requestBuilder])!; + + interfaceType.GetProperty("TenantId")!.SetValue(api, PropertyTenantId); + var task = (Task)interfaceType.GetMethod("Get")!.Invoke( + api, + [ + HeaderId, + new Dictionary(StringComparer.Ordinal) { ["X-Dynamic"] = "dynamic" }, + ParameterTenantId, + CancellationToken.None + ])!; + + await task.ConfigureAwait(false); + var response = (string?)task.GetType().GetProperty("Result")!.GetValue(task); + + await Assert.That(response).IsEqualTo("done"); + await Assert.That(handler.LastRequest).IsNotNull(); + var request = handler.LastRequest!; + + await Assert.That(request.Method).IsEqualTo(HttpMethod.Get); + await Assert.That(request.RequestUri).IsEqualTo(new("https://example.test/base/users")); + await Assert.That(request.Headers.GetValues("X-Static")).IsCollectionEqualTo(["static"]); + await Assert.That(request.Headers.GetValues("X-Id")).IsCollectionEqualTo(["42"]); + await Assert.That(request.Headers.GetValues("X-Dynamic")).IsCollectionEqualTo(["dynamic"]); + + var parameterTenantKey = new HttpRequestOptionsKey("parameter-tenant"); + await Assert.That(request.Options.TryGetValue(parameterTenantKey, out var parameterTenant)).IsTrue(); + await Assert.That(parameterTenant).IsEqualTo(ParameterTenantId); + + var propertyTenantKey = new HttpRequestOptionsKey("property-tenant"); + await Assert.That(request.Options.TryGetValue(propertyTenantKey, out var propertyTenant)).IsTrue(); + await Assert.That(propertyTenant).IsEqualTo(PropertyTenantId); + } + } + + /// Captures the outgoing request and returns a fixed JSON string response. + private sealed class CapturingHandler : HttpMessageHandler + { + /// Gets the last request sent through the handler. + public HttpRequestMessage? LastRequest { get; private set; } + + /// + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("done", Encoding.UTF8, "text/plain") + }); + } + } +} diff --git a/src/tests/Refit.GeneratorTests/ParserCoverageTests.cs b/src/tests/Refit.GeneratorTests/ParserCoverageTests.cs new file mode 100644 index 000000000..7fb1e18a9 --- /dev/null +++ b/src/tests/Refit.GeneratorTests/ParserCoverageTests.cs @@ -0,0 +1,230 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Refit.Generator; + +namespace Refit.GeneratorTests; + +/// Focused tests for parser helper paths that are awkward to reach through snapshot tests. +public sealed class ParserCoverageTests +{ + /// Verifies parser argument validation. + /// A task representing the asynchronous test. + [Test] + public async Task GenerateInterfaceStubsRejectsNullCompilation() + { + await Assert.That( + () => Parser.GenerateInterfaceStubs( + null!, + null, + generatedRequestBuilding: true, + [], + [], + CancellationToken.None)) + .ThrowsExactly(); + } + + /// Verifies parser diagnostics and namespace normalization when Refit is not referenced. + /// A task representing the asynchronous test. + [Test] + public async Task GenerateInterfaceStubsReportsMissingRefitReference() + { + var syntaxTree = CSharpSyntaxTree.ParseText("public interface IUnused { }"); + var compilation = CSharpCompilation.Create("no-refit", [syntaxTree]); + + var (diagnostics, model) = Parser.GenerateInterfaceStubs( + compilation, + "bad-name@thing-", + generatedRequestBuilding: true, + [], + [], + CancellationToken.None); + + await Assert.That(diagnostics.Count).IsEqualTo(1); + await Assert.That(model.RefitInternalNamespace).IsEqualTo("bad_name_thing_RefitInternalGenerated"); + await Assert.That(model.Interfaces).IsEmpty(); + } + + /// Verifies well-known type lookup caching and failure paths. + /// A task representing the asynchronous test. + [Test] + public async Task WellKnownTypesCachesResolvedAndMissingSymbols() + { + var compilation = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText("namespace Test { public sealed class Sample { } }")); + var wellKnownTypes = new WellKnownTypes(compilation); + + var first = wellKnownTypes.Get(typeof(string)); + var second = wellKnownTypes.TryGet("System.String"); + var missingFirst = wellKnownTypes.TryGet("Missing.Type"); + var missingSecond = wellKnownTypes.TryGet("Missing.Type"); + var openGenericParameter = typeof(List<>).GetGenericArguments()[0]; + + await Assert.That(second).IsSameReferenceAs(first); + await Assert.That(missingFirst).IsNull(); + await Assert.That(missingSecond).IsNull(); + await Assert.That(() => wellKnownTypes.Get(null!)).ThrowsExactly(); + await Assert.That(() => wellKnownTypes.Get(openGenericParameter)).ThrowsExactly(); + } + + /// Verifies parser and generator helper fallback paths that are easier to exercise directly. + /// A task representing the asynchronous test. + [Test] + public async Task InternalHelpersHandleFallbackPaths() + { + var emptyOptions = new DictionaryAnalyzerConfigOptions(new Dictionary()); + var buildOptions = new DictionaryAnalyzerConfigOptions( + new Dictionary + { + ["build_property.RefitOption"] = "build", + ["AnalyzerOption"] = "analyzer" + }); + + var defaultConstant = default(TypedConstant); + + await Assert.That(Parser.TryGetBodyBufferedValue(in defaultConstant, out var buffered)).IsFalse(); + await Assert.That(buffered).IsFalse(); + await Assert.That(InterfaceStubGeneratorV2.TryGetGlobalOption(emptyOptions, "Missing", out var missingValue)).IsFalse(); + await Assert.That(missingValue).IsNull(); + await Assert.That(InterfaceStubGeneratorV2.TryGetGlobalOption(buildOptions, "RefitOption", out var buildValue)).IsTrue(); + await Assert.That(buildValue).IsEqualTo("build"); + await Assert.That(InterfaceStubGeneratorV2.TryGetGlobalOption(buildOptions, "AnalyzerOption", out var analyzerValue)).IsTrue(); + await Assert.That(analyzerValue).IsEqualTo("analyzer"); + } + + /// Verifies type-symbol inheritance helpers with base classes and interfaces. + /// A task representing the asynchronous test. + [Test] + public async Task TypeSymbolInheritanceHelpersHandleBaseClassesAndInterfaces() + { + var syntaxTree = CSharpSyntaxTree.ParseText( + """ + namespace Symbols; + public interface IMarker { } + public class Base { } + public class Derived : Base, IMarker { } + public class Other { } + """); + var compilation = Fixture.CreateLibrary(syntaxTree); + var derived = compilation.GetTypeByMetadataName("Symbols.Derived")!; + var @base = compilation.GetTypeByMetadataName("Symbols.Base")!; + var marker = compilation.GetTypeByMetadataName("Symbols.IMarker")!; + var other = compilation.GetTypeByMetadataName("Symbols.Other")!; + + await Assert.That(derived.InheritsFromOrEquals(@base)).IsTrue(); + await Assert.That(derived.InheritsFromOrEquals(derived, includeInterfaces: true)).IsTrue(); + await Assert.That(derived.InheritsFromOrEquals(marker, includeInterfaces: true)).IsTrue(); + await Assert.That(derived.InheritsFromOrEquals(marker, includeInterfaces: false)).IsFalse(); + await Assert.That(derived.InheritsFromOrEquals(other, includeInterfaces: true)).IsFalse(); + } + + /// Verifies parser generation skips non-Refit methods while preserving Refit request metadata. + /// A task representing the asynchronous test. + [Test] + public async Task GenerateInterfaceStubsHandlesMixedRefitAndNonRefitMethods() + { + const int ExpectedMethodCount = 5; + const int GenericApiResponseMethodIndex = 2; + const int BodyMethodIndex = 3; + const int GenericMethodIndex = 4; + + var syntaxTree = CSharpSyntaxTree.ParseText( + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + Task NoAttribute(); + + [Get("/string")] + Task GetString(); + + [Get("/api")] + Task GetApiResponse(); + + [Get("/generic-api")] + Task> GetGenericApiResponse(); + + [Post("/body")] + Task Body([Body(BodySerializationMethod.Serialized, false)] string body); + + [Get("/generic")] + Task Generic(); + } + """); + var root = await syntaxTree.GetRootAsync(); + var candidateMethods = root.DescendantNodes().OfType().ToImmutableArray(); + var candidateInterfaces = root.DescendantNodes().OfType().ToImmutableArray(); + var compilation = Fixture.CreateLibrary(syntaxTree); + + var (_, model) = Parser.GenerateInterfaceStubs( + compilation, + "mixed", + generatedRequestBuilding: true, + candidateMethods, + candidateInterfaces, + CancellationToken.None); + + var methods = model.Interfaces.AsArray()[0].RefitMethods.AsArray(); + + await Assert.That(methods.Length).IsEqualTo(ExpectedMethodCount); + await Assert.That(methods[0].Name).IsEqualTo("GetString"); + await Assert.That(methods[1].Request.IsApiResponse).IsTrue(); + await Assert.That(methods[GenericApiResponseMethodIndex].Request.DeserializedResultType).IsEqualTo("int"); + await Assert.That(methods[BodyMethodIndex].Request.Parameters.AsArray()[0].BodyBufferMode) + .IsEqualTo(BodyBufferMode.Streaming); + await Assert.That(methods[GenericMethodIndex].DeclaredMethod).IsEqualTo("Generic"); + } + + /// Verifies parser generation under a language version that does not support nullable directives. + /// A task representing the asynchronous test. + [Test] + public async Task GenerateInterfaceStubsUsesNoNullabilityWhenLanguageVersionDoesNotSupportIt() + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp7_3); + var syntaxTree = CSharpSyntaxTree.ParseText( + """ + using System.Threading.Tasks; + using Refit; + + public interface ILegacyClient + { + [Get("/legacy")] + Task Get(); + } + """, + parseOptions); + var root = await syntaxTree.GetRootAsync(); + var candidateMethods = root.DescendantNodes().OfType().ToImmutableArray(); + var candidateInterfaces = root.DescendantNodes().OfType().ToImmutableArray(); + var compilation = Fixture.CreateLibrary(syntaxTree); + + var (_, model) = Parser.GenerateInterfaceStubs( + compilation, + "legacy", + generatedRequestBuilding: true, + candidateMethods, + candidateInterfaces, + CancellationToken.None); + + await Assert.That(model.Interfaces.AsArray()[0].Nullability).IsEqualTo(Nullability.None); + } + + /// Analyzer config options backed by a dictionary for direct helper tests. + /// The option values. + private sealed class DictionaryAnalyzerConfigOptions(IReadOnlyDictionary values) + : AnalyzerConfigOptions + { + /// + public override bool TryGetValue(string key, out string value) => values.TryGetValue(key, out value!); + } +} diff --git a/src/tests/Refit.GeneratorTests/Refit.GeneratorTests.csproj b/src/tests/Refit.GeneratorTests/Refit.GeneratorTests.csproj index 8b3e83ec0..0e1b75307 100644 --- a/src/tests/Refit.GeneratorTests/Refit.GeneratorTests.csproj +++ b/src/tests/Refit.GeneratorTests/Refit.GeneratorTests.csproj @@ -1,4 +1,4 @@ - + Exe $(RefitTestTargets) diff --git a/src/tests/Refit.GeneratorTests/_snapshots/GeneratedTest.ShouldEmitAllFiles#Generated.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/GeneratedTest.ShouldEmitAllFiles#Generated.g.verified.cs index 75dbf36af..e9abf62c6 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/GeneratedTest.ShouldEmitAllFiles#Generated.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/GeneratedTest.ShouldEmitAllFiles#Generated.g.verified.cs @@ -1,5 +1,7 @@ //HintName: Generated.g.cs +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -17,7 +19,9 @@ internal static partial class Generated [System.Diagnostics.CodeAnalysis.DynamicDependency(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Refit.Implementation.Generated))] public static void Initialize() { - global::Refit.RestService.RegisterGeneratedFactory(typeof(global::RefitGeneratorTest.IGeneratedClient), static (client, requestBuilder) => new global::Refit.Implementation.Generated.RefitGeneratorTestIGeneratedClient(client, requestBuilder)); + global::Refit.RestService.RegisterGeneratedFactory( + typeof(global::RefitGeneratorTest.IGeneratedClient), + static (client, requestBuilder) => new global::Refit.Implementation.Generated.RefitGeneratorTestIGeneratedClient(client, requestBuilder)); } #endif } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/GeneratedTest.ShouldEmitAllFiles#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/GeneratedTest.ShouldEmitAllFiles#IGeneratedClient.g.verified.cs index e86de6dd0..18d11625b 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/GeneratedTest.ShouldEmitAllFiles#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/GeneratedTest.ShouldEmitAllFiles#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IGeneratedClient)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/GeneratedTest.ShouldEmitAllFiles#PreserveAttribute.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/GeneratedTest.ShouldEmitAllFiles#PreserveAttribute.g.verified.cs index b924c2258..d892ea095 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/GeneratedTest.ShouldEmitAllFiles#PreserveAttribute.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/GeneratedTest.ShouldEmitAllFiles#PreserveAttribute.g.verified.cs @@ -1,5 +1,7 @@ //HintName: PreserveAttribute.g.cs +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace RefitInternalGenerated { diff --git a/src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#Generated.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#Generated.g.verified.cs similarity index 52% rename from src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#Generated.g.verified.cs rename to src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#Generated.g.verified.cs index 25939d3f3..ba2278a98 100644 --- a/src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#Generated.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#Generated.g.verified.cs @@ -1,5 +1,7 @@ -//HintName: Generated.g.cs +//HintName: Generated.g.cs +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -17,9 +19,15 @@ internal static partial class Generated [System.Diagnostics.CodeAnalysis.DynamicDependency(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Refit.Implementation.Generated))] public static void Initialize() { - global::Refit.RestService.RegisterGeneratedFactory(typeof(global::Refit.Tests.IGitHubApi), static (client, requestBuilder) => new global::Refit.Implementation.Generated.RefitTestsIGitHubApi(client, requestBuilder)); - global::Refit.RestService.RegisterGeneratedFactory(typeof(global::Refit.Tests.IGitHubApiDisposable), static (client, requestBuilder) => new global::Refit.Implementation.Generated.RefitTestsIGitHubApiDisposable(client, requestBuilder)); - global::Refit.RestService.RegisterGeneratedFactory(typeof(global::Refit.Tests.TestNested.INestedGitHubApi), static (client, requestBuilder) => new global::Refit.Implementation.Generated.RefitTestsTestNestedINestedGitHubApi(client, requestBuilder)); + global::Refit.RestService.RegisterGeneratedFactory( + typeof(global::Refit.Tests.IGitHubApi), + static (client, requestBuilder) => new global::Refit.Implementation.Generated.RefitTestsIGitHubApi(client, requestBuilder)); + global::Refit.RestService.RegisterGeneratedFactory( + typeof(global::Refit.Tests.IGitHubApiDisposable), + static (client, requestBuilder) => new global::Refit.Implementation.Generated.RefitTestsIGitHubApiDisposable(client, requestBuilder)); + global::Refit.RestService.RegisterGeneratedFactory( + typeof(global::Refit.Tests.TestNested.INestedGitHubApi), + static (client, requestBuilder) => new global::Refit.Implementation.Generated.RefitTestsTestNestedINestedGitHubApi(client, requestBuilder)); } #endif } diff --git a/src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApi.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApi.g.verified.cs similarity index 64% rename from src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApi.g.verified.cs rename to src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApi.g.verified.cs index bb2ae27b2..6287d5327 100644 --- a/src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApi.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApi.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGitHubApi.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -84,12 +86,26 @@ public RefitTestsIGitHubApi(global::System.Net.Http.HttpClient client, global::R } /// - public async global::System.Threading.Tasks.Task GetIndex() + public global::System.Threading.Tasks.Task GetIndex() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("GetIndex", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.SetHeader(______rq, "User-Agent", "Refit Integration Tests"); + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::Refit.Tests.IGitHubApi)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + false, + false, + global::System.Threading.CancellationToken.None); } /// @@ -102,21 +118,49 @@ public RefitTestsIGitHubApi(global::System.Net.Http.HttpClient client, global::R } /// - public async global::System.Threading.Tasks.Task NothingToSeeHere() + public global::System.Threading.Tasks.Task NothingToSeeHere() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("NothingToSeeHere", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/give-me-some-404-action", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.SetHeader(______rq, "User-Agent", "Refit Integration Tests"); + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::Refit.Tests.IGitHubApi)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } /// - public async global::System.Threading.Tasks.Task> NothingToSeeHereWithMetadata() + public global::System.Threading.Tasks.Task> NothingToSeeHereWithMetadata() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("NothingToSeeHereWithMetadata", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task>)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/give-me-some-404-action", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.SetHeader(______rq, "User-Agent", "Refit Integration Tests"); + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::Refit.Tests.IGitHubApi)); + return global::Refit.GeneratedRequestRunner.SendAsync, global::Refit.Tests.User>( + this.Client, + ______rq, + ______settings, + true, + true, + false, + global::System.Threading.CancellationToken.None); } private static readonly global::System.Type[] ______typeParameters4 = new global::System.Type[] {typeof(string), typeof(global::System.Threading.CancellationToken) }; diff --git a/src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApiDisposable.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApiDisposable.g.verified.cs similarity index 91% rename from src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApiDisposable.g.verified.cs rename to src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApiDisposable.g.verified.cs index a5675c968..8b11f85d7 100644 --- a/src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApiDisposable.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApiDisposable.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGitHubApiDisposable.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { diff --git a/src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#INestedGitHubApi.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#INestedGitHubApi.g.verified.cs similarity index 61% rename from src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#INestedGitHubApi.g.verified.cs rename to src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#INestedGitHubApi.g.verified.cs index 90e88743f..80939947e 100644 --- a/src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#INestedGitHubApi.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#INestedGitHubApi.g.verified.cs @@ -1,5 +1,7 @@ //HintName: INestedGitHubApi.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -84,12 +86,26 @@ public RefitTestsTestNestedINestedGitHubApi(global::System.Net.Http.HttpClient c } /// - public async global::System.Threading.Tasks.Task GetIndex() + public global::System.Threading.Tasks.Task GetIndex() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("GetIndex", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.SetHeader(______rq, "User-Agent", "Refit Integration Tests"); + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::Refit.Tests.TestNested.INestedGitHubApi)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + false, + false, + global::System.Threading.CancellationToken.None); } /// @@ -102,12 +118,24 @@ public RefitTestsTestNestedINestedGitHubApi(global::System.Net.Http.HttpClient c } /// - public async global::System.Threading.Tasks.Task NothingToSeeHere() + public global::System.Threading.Tasks.Task NothingToSeeHere() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("NothingToSeeHere", global::System.Array.Empty() ); - - await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/give-me-some-404-action", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.SetHeader(______rq, "User-Agent", "Refit Integration Tests"); + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::Refit.Tests.TestNested.INestedGitHubApi)); + return global::Refit.GeneratedRequestRunner.SendVoidAsync( + this.Client, + ______rq, + ______settings, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#PreserveAttribute.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#PreserveAttribute.g.verified.cs similarity index 86% rename from src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#PreserveAttribute.g.verified.cs rename to src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#PreserveAttribute.g.verified.cs index b924c2258..d892ea095 100644 --- a/src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#PreserveAttribute.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#PreserveAttribute.g.verified.cs @@ -1,5 +1,7 @@ //HintName: PreserveAttribute.g.cs +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace RefitInternalGenerated { diff --git a/src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#Generated.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#Generated.g.verified.cs similarity index 68% rename from src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#Generated.g.verified.cs rename to src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#Generated.g.verified.cs index 8ae835fad..7baf0d8fa 100644 --- a/src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#Generated.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#Generated.g.verified.cs @@ -1,5 +1,7 @@ -//HintName: Generated.g.cs +//HintName: Generated.g.cs +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -17,7 +19,9 @@ internal static partial class Generated [System.Diagnostics.CodeAnalysis.DynamicDependency(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, typeof(global::Refit.Implementation.Generated))] public static void Initialize() { - global::Refit.RestService.RegisterGeneratedFactory(typeof(global::IServiceWithoutNamespace), static (client, requestBuilder) => new global::Refit.Implementation.Generated.IServiceWithoutNamespace(client, requestBuilder)); + global::Refit.RestService.RegisterGeneratedFactory( + typeof(global::IServiceWithoutNamespace), + static (client, requestBuilder) => new global::Refit.Implementation.Generated.IServiceWithoutNamespace(client, requestBuilder)); } #endif } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#IServiceWithoutNamespace.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#IServiceWithoutNamespace.g.verified.cs new file mode 100644 index 000000000..35429e892 --- /dev/null +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#IServiceWithoutNamespace.g.verified.cs @@ -0,0 +1,76 @@ +//HintName: IServiceWithoutNamespace.g.cs +#nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. +#pragma warning disable +namespace Refit.Implementation +{ + + partial class Generated + { + + /// + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::System.Diagnostics.DebuggerNonUserCode] + [global::RefitInternalGenerated.PreserveAttribute] + [global::System.Reflection.Obfuscation(Exclude=true)] + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + partial class IServiceWithoutNamespace + : global::IServiceWithoutNamespace + { + /// + public global::System.Net.Http.HttpClient Client { get; } + readonly global::Refit.IRequestBuilder requestBuilder; + + /// + public IServiceWithoutNamespace(global::System.Net.Http.HttpClient client, global::Refit.IRequestBuilder requestBuilder) + { + Client = client; + this.requestBuilder = requestBuilder; + } + + + /// + public global::System.Threading.Tasks.Task GetRoot() + { + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::IServiceWithoutNamespace)); + return global::Refit.GeneratedRequestRunner.SendVoidAsync( + this.Client, + ______rq, + ______settings, + false, + global::System.Threading.CancellationToken.None); + } + + /// + public global::System.Threading.Tasks.Task PostRoot() + { + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Post, new global::System.Uri(______basePath + "/", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::IServiceWithoutNamespace)); + return global::Refit.GeneratedRequestRunner.SendVoidAsync( + this.Client, + ______rq, + ______settings, + false, + global::System.Threading.CancellationToken.None); + } + } + } +} + +#pragma warning restore diff --git a/src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#PreserveAttribute.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#PreserveAttribute.g.verified.cs similarity index 86% rename from src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#PreserveAttribute.g.verified.cs rename to src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#PreserveAttribute.g.verified.cs index b924c2258..d892ea095 100644 --- a/src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#PreserveAttribute.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#PreserveAttribute.g.verified.cs @@ -1,5 +1,7 @@ //HintName: PreserveAttribute.g.cs +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace RefitInternalGenerated { diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.ContainedInterfaceTest#IContainedInterface.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.ContainedInterfaceTest#IContainedInterface.g.verified.cs index 08f48afb2..93201807a 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.ContainedInterfaceTest#IContainedInterface.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.ContainedInterfaceTest#IContainedInterface.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IContainedInterface.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestContainerTypeIContainedInterface(global::System.Net.Htt /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.ContainerType.IContainedInterface)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.DefaultInterfaceMethod#IGeneratedInterface.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.DefaultInterfaceMethod#IGeneratedInterface.g.verified.cs index c5d4c12db..621a9fc69 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.DefaultInterfaceMethod#IGeneratedInterface.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.DefaultInterfaceMethod#IGeneratedInterface.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedInterface.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestIGeneratedInterface(global::System.Net.Http.HttpClient /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IGeneratedInterface)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.DerivedDefaultInterfaceMethod#IBaseInterface.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.DerivedDefaultInterfaceMethod#IBaseInterface.g.verified.cs index aae4c7635..e51a5ab49 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.DerivedDefaultInterfaceMethod#IBaseInterface.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.DerivedDefaultInterfaceMethod#IBaseInterface.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IBaseInterface.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestIBaseInterface(global::System.Net.Http.HttpClient clien /// - public async global::System.Threading.Tasks.Task GetPosts() + public global::System.Threading.Tasks.Task GetPosts() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("GetPosts", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/posts", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IBaseInterface)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.DerivedDefaultInterfaceMethod#IGeneratedInterface.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.DerivedDefaultInterfaceMethod#IGeneratedInterface.g.verified.cs index ecafd8481..80847f08b 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.DerivedDefaultInterfaceMethod#IGeneratedInterface.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.DerivedDefaultInterfaceMethod#IGeneratedInterface.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedInterface.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,21 +31,47 @@ public RefitGeneratorTestIGeneratedInterface(global::System.Net.Http.HttpClient /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IGeneratedInterface)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } /// - async global::System.Threading.Tasks.Task global::RefitGeneratorTest.IBaseInterface.GetPosts() + global::System.Threading.Tasks.Task global::RefitGeneratorTest.IBaseInterface.GetPosts() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("GetPosts", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/posts", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IGeneratedInterface)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.DisposableTest#IGeneratedInterface.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.DisposableTest#IGeneratedInterface.g.verified.cs index 36af992ca..8427cee0b 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.DisposableTest#IGeneratedInterface.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.DisposableTest#IGeneratedInterface.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedInterface.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public IGeneratedInterface(global::System.Net.Http.HttpClient client, global::Re /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::IGeneratedInterface)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } /// diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.GlobalNamespaceTest#IGeneratedInterface.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.GlobalNamespaceTest#IGeneratedInterface.g.verified.cs index dd845da9a..d054a2317 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.GlobalNamespaceTest#IGeneratedInterface.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.GlobalNamespaceTest#IGeneratedInterface.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedInterface.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public IGeneratedInterface(global::System.Net.Http.HttpClient client, global::Re /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::IGeneratedInterface)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfaceDerivedFromRefitBaseTest#IBaseInterface.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfaceDerivedFromRefitBaseTest#IBaseInterface.g.verified.cs index 18e20ca16..4d75414a7 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfaceDerivedFromRefitBaseTest#IBaseInterface.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfaceDerivedFromRefitBaseTest#IBaseInterface.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IBaseInterface.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestIBaseInterface(global::System.Net.Http.HttpClient clien /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IBaseInterface)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfaceDerivedFromRefitBaseTest#IDerivedInterface.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfaceDerivedFromRefitBaseTest#IDerivedInterface.g.verified.cs index 2a8bd4ff0..2fd5751ec 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfaceDerivedFromRefitBaseTest#IDerivedInterface.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfaceDerivedFromRefitBaseTest#IDerivedInterface.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IDerivedInterface.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestIDerivedInterface(global::System.Net.Http.HttpClient cl /// - async global::System.Threading.Tasks.Task global::RefitGeneratorTest.IBaseInterface.Get() + global::System.Threading.Tasks.Task global::RefitGeneratorTest.IBaseInterface.Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IDerivedInterface)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfaceWithGenericConstraint#IGeneratedInterface.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfaceWithGenericConstraint#IGeneratedInterface.g.verified.cs index 250afa82e..6386e3155 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfaceWithGenericConstraint#IGeneratedInterface.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfaceWithGenericConstraint#IGeneratedInterface.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedInterface.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -34,12 +36,25 @@ public IGeneratedInterface(global::System.Net.Http.HttpClient client, global::Re /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::IGeneratedInterface)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfacesWithDifferentCasing#IApi.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfacesWithDifferentCasing#IApi.g.verified.cs index abddefdbf..5da165979 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfacesWithDifferentCasing#IApi.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfacesWithDifferentCasing#IApi.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IApi.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestIApi(global::System.Net.Http.HttpClient client, global: /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IApi)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfacesWithDifferentCasing#Iapi1.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfacesWithDifferentCasing#Iapi1.g.verified.cs index a3cd7952b..fe341384d 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfacesWithDifferentCasing#Iapi1.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfacesWithDifferentCasing#Iapi1.g.verified.cs @@ -1,5 +1,7 @@ //HintName: Iapi1.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestIapi(global::System.Net.Http.HttpClient client, global: /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.Iapi)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfacesWithDifferentSignature#IApi.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfacesWithDifferentSignature#IApi.g.verified.cs index abddefdbf..5da165979 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfacesWithDifferentSignature#IApi.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfacesWithDifferentSignature#IApi.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IApi.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestIApi(global::System.Net.Http.HttpClient client, global: /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IApi)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfacesWithDifferentSignature#IApi1.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfacesWithDifferentSignature#IApi1.g.verified.cs index dc6658313..51bd45cc2 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfacesWithDifferentSignature#IApi1.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.InterfacesWithDifferentSignature#IApi1.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IApi1.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestIApi(global::System.Net.Http.HttpClient client, global: /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IApi)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.NestedNamespaceTest#IGeneratedInterface.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.NestedNamespaceTest#IGeneratedInterface.g.verified.cs index 0dfba6963..e82be4ce5 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.NestedNamespaceTest#IGeneratedInterface.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.NestedNamespaceTest#IGeneratedInterface.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedInterface.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public NestedRefitGeneratorTestIGeneratedInterface(global::System.Net.Http.HttpC /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::Nested.RefitGeneratorTest.IGeneratedInterface)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.NonRefitMethodShouldRaiseDiagnostic#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.NonRefitMethodShouldRaiseDiagnostic#IGeneratedClient.g.verified.cs index 410146bc8..d2498750d 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.NonRefitMethodShouldRaiseDiagnostic#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.NonRefitMethodShouldRaiseDiagnostic#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IGeneratedClient)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } /// diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.RefitInterfaceDerivedFromBaseTest#IGeneratedInterface.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.RefitInterfaceDerivedFromBaseTest#IGeneratedInterface.g.verified.cs index b8268b962..20ee31508 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.RefitInterfaceDerivedFromBaseTest#IGeneratedInterface.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.RefitInterfaceDerivedFromBaseTest#IGeneratedInterface.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedInterface.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestIGeneratedInterface(global::System.Net.Http.HttpClient /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IGeneratedInterface)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } /// diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.RefitInterfaceDerivedFromRefitBaseTest#IBaseInterface.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.RefitInterfaceDerivedFromRefitBaseTest#IBaseInterface.g.verified.cs index aae4c7635..e51a5ab49 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.RefitInterfaceDerivedFromRefitBaseTest#IBaseInterface.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.RefitInterfaceDerivedFromRefitBaseTest#IBaseInterface.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IBaseInterface.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestIBaseInterface(global::System.Net.Http.HttpClient clien /// - public async global::System.Threading.Tasks.Task GetPosts() + public global::System.Threading.Tasks.Task GetPosts() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("GetPosts", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/posts", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IBaseInterface)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.RefitInterfaceDerivedFromRefitBaseTest#IGeneratedInterface.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.RefitInterfaceDerivedFromRefitBaseTest#IGeneratedInterface.g.verified.cs index ecafd8481..80847f08b 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.RefitInterfaceDerivedFromRefitBaseTest#IGeneratedInterface.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceTests.RefitInterfaceDerivedFromRefitBaseTest#IGeneratedInterface.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedInterface.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,21 +31,47 @@ public RefitGeneratorTestIGeneratedInterface(global::System.Net.Http.HttpClient /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IGeneratedInterface)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } /// - async global::System.Threading.Tasks.Task global::RefitGeneratorTest.IBaseInterface.GetPosts() + global::System.Threading.Tasks.Task global::RefitGeneratorTest.IBaseInterface.GetPosts() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("GetPosts", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/posts", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IGeneratedInterface)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/MethodTests.MethodsWithGenericConstraints#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/MethodTests.MethodsWithGenericConstraints#IGeneratedClient.g.verified.cs index 1a2693ba2..b9af4f301 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/MethodTests.MethodsWithGenericConstraints#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/MethodTests.MethodsWithGenericConstraints#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.NullableRouteParameter#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.NullableRouteParameter#IGeneratedClient.g.verified.cs index c6522e2af..0357d54cf 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.NullableRouteParameter#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.NullableRouteParameter#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.NullableValueTypeRouteParameter#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.NullableValueTypeRouteParameter#IGeneratedClient.g.verified.cs index e8730b019..8e9d03119 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.NullableValueTypeRouteParameter#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.NullableValueTypeRouteParameter#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.RouteParameter#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.RouteParameter#IGeneratedClient.g.verified.cs index a5f385ed5..2665cca76 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.RouteParameter#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.RouteParameter#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.ValueTypeRouteParameter#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.ValueTypeRouteParameter#IGeneratedClient.g.verified.cs index 33529c624..a478ef5c2 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.ValueTypeRouteParameter#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.ValueTypeRouteParameter#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericConstraintReturnTask#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericConstraintReturnTask#IGeneratedClient.g.verified.cs index 114d7ef79..afa20d87d 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericConstraintReturnTask#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericConstraintReturnTask#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericStructConstraintReturnTask#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericStructConstraintReturnTask#IGeneratedClient.g.verified.cs index 45bf8d42b..567e6bc29 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericStructConstraintReturnTask#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericStructConstraintReturnTask#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericTaskShouldWork#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericTaskShouldWork#IGeneratedClient.g.verified.cs index e86de6dd0..18d11625b 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericTaskShouldWork#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericTaskShouldWork#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IGeneratedClient)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericUnmanagedConstraintReturnTask#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericUnmanagedConstraintReturnTask#IGeneratedClient.g.verified.cs index 404b3a93d..46d175216 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericUnmanagedConstraintReturnTask#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericUnmanagedConstraintReturnTask#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericValueTaskShouldWork#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericValueTaskShouldWork#IGeneratedClient.g.verified.cs index ee088dcd8..19f4c1e15 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericValueTaskShouldWork#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.GenericValueTaskShouldWork#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli /// - public async global::System.Threading.Tasks.ValueTask Get() + public global::System.Threading.Tasks.ValueTask Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.ValueTask)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IGeneratedClient)); + return new global::System.Threading.Tasks.ValueTask(global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None)); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ReturnIObservable#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ReturnIObservable#IGeneratedClient.g.verified.cs index 3c58f7e3b..22df40880 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ReturnIObservable#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ReturnIObservable#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ReturnNullableObject#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ReturnNullableObject#IGeneratedClient.g.verified.cs index e86de6dd0..18d11625b 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ReturnNullableObject#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ReturnNullableObject#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IGeneratedClient)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ReturnNullableValueType#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ReturnNullableValueType#IGeneratedClient.g.verified.cs index 5568cf299..f3286f8e2 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ReturnNullableValueType#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ReturnNullableValueType#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IGeneratedClient)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + ______rq, + ______settings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ReturnUnsupportedType#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ReturnUnsupportedType#IGeneratedClient.g.verified.cs index dbc30bb52..a9155f156 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ReturnUnsupportedType#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ReturnUnsupportedType#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ValueTaskApiResponseShouldWork#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ValueTaskApiResponseShouldWork#IGeneratedClient.g.verified.cs index 71eba988b..4611f3419 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ValueTaskApiResponseShouldWork#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.ValueTaskApiResponseShouldWork#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,25 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli /// - public async global::System.Threading.Tasks.ValueTask> Get() + public global::System.Threading.Tasks.ValueTask> Get() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty() ); - - return await ((global::System.Threading.Tasks.ValueTask>)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IGeneratedClient)); + return new global::System.Threading.Tasks.ValueTask>(global::Refit.GeneratedRequestRunner.SendAsync, string>( + this.Client, + ______rq, + ______settings, + true, + true, + false, + global::System.Threading.CancellationToken.None)); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.VoidTaskShouldWork#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.VoidTaskShouldWork#IGeneratedClient.g.verified.cs index c63f4652f..1e22ee06a 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.VoidTaskShouldWork#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ReturnTypeTests.VoidTaskShouldWork#IGeneratedClient.g.verified.cs @@ -1,5 +1,7 @@ //HintName: IGeneratedClient.g.cs #nullable disable +// This file is generated into consumer projects; suppress all analyzers so +// consumer analyzer policy does not report Refit implementation details. #pragma warning disable namespace Refit.Implementation { @@ -29,12 +31,23 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli /// - public async global::System.Threading.Tasks.Task Post() + public global::System.Threading.Tasks.Task Post() { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("Post", global::System.Array.Empty() ); - - await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); + var ______settings = requestBuilder.Settings; + var ______basePath = this.Client.BaseAddress?.AbsolutePath ?? throw new global::System.InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + ______basePath = ______basePath == "/" ? string.Empty : ______basePath.TrimEnd('/'); + var ______rq = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Post, new global::System.Uri(______basePath + "/users", global::System.UriKind.Relative)); + #if NET6_0_OR_GREATER + ______rq.Version = ______settings.Version; + ______rq.VersionPolicy = ______settings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(______rq, ______settings, typeof(global::RefitGeneratorTest.IGeneratedClient)); + return global::Refit.GeneratedRequestRunner.SendVoidAsync( + this.Client, + ______rq, + ______settings, + false, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.Tests/Address.cs b/src/tests/Refit.Tests/Address.cs index 97f283187..1b5a2a68b 100644 --- a/src/tests/Refit.Tests/Address.cs +++ b/src/tests/Refit.Tests/Address.cs @@ -1,25 +1,15 @@ // Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; - namespace Refit.Tests; /// Nested address query object used by the complex query parameter tests. public sealed record Address { - /// Gets or sets the postcode value, aliased to Zip. + /// Gets the postcode value, aliased to Zip. [AliasAs("Zip")] - [SuppressMessage( - "RoslynCommonAnalyzers", - "SST1802:Replace the set accessor with init", - Justification = "Tests mutate this property after construction via myParams.Address.Postcode, so a settable accessor is required.")] - public int Postcode { get; set; } + public int Postcode { get; init; } - /// Gets or sets the street value. - [SuppressMessage( - "RoslynCommonAnalyzers", - "SST1802:Replace the set accessor with init", - Justification = "Tests mutate this property after construction via myParams.Address.Street, so a settable accessor is required.")] - public string Street { get; set; } = string.Empty; + /// Gets the street value. + public string Street { get; init; } = string.Empty; } diff --git a/src/tests/Refit.Tests/ApiExceptionTests.cs b/src/tests/Refit.Tests/ApiExceptionTests.cs new file mode 100644 index 000000000..dd5f326a9 --- /dev/null +++ b/src/tests/Refit.Tests/ApiExceptionTests.cs @@ -0,0 +1,234 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using System.Net; + +namespace Refit.Tests; + +/// Tests for API exception factory and validation exception edge cases. +[RequiresUnreferencedCode("ValidationApiException.Create uses System.Text.Json reflection-based deserialization.")] +[RequiresDynamicCode("ValidationApiException.Create uses System.Text.Json reflection-based deserialization.")] +public sealed class ApiExceptionTests +{ + /// Verifies ApiException factory guard clauses. + /// A task representing the asynchronous test. + [Test] + public async Task CreateRejectsSuccessfulOrMissingResponses() + { + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.test"); + using var success = new HttpResponseMessage(HttpStatusCode.OK) { RequestMessage = request }; + + await Assert.That(() => (Task)ApiException.Create(request, HttpMethod.Get, success, new())) + .ThrowsExactly(); + await Assert.That(() => (Task)ApiException.Create("message", request, HttpMethod.Get, null!, new())) + .ThrowsExactly(); + } + + /// Verifies ApiException factory handles absent and unreadable content without hiding the original response error. + /// A task representing the asynchronous test. + [Test] + public async Task CreateHandlesMissingAndUnreadableContent() + { + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.test"); + using var noContentResponse = new HttpResponseMessage(HttpStatusCode.BadGateway) + { + RequestMessage = request, + Content = null + }; + using var throwingContentResponse = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + RequestMessage = request, + Content = new ThrowingReadContent() + }; + + var noContentException = await ApiException.Create(request, HttpMethod.Get, noContentResponse, new()); + var throwingContentException = await ApiException.Create( + "custom", + request, + HttpMethod.Get, + throwingContentResponse, + new(), + new InvalidOperationException("inner")); + + await Assert.That(noContentException.Content).IsEqualTo(string.Empty); + await Assert.That(noContentException.ContentHeaders).IsNotNull(); + await Assert.That(throwingContentException.Content).IsNull(); + await Assert.That(throwingContentException.InnerException).IsTypeOf(); + } + + /// Verifies protected constructors and content deserialization helpers. + /// A task representing the asynchronous test. + [Test] + public async Task ConstructorsAndContentHelpersPreserveContext() + { + using var response = CreateErrorResponse("{\"Value\":42}"); + var settings = new RefitSettings(); + var exception = await ApiException.Create( + response.RequestMessage!, + HttpMethod.Get, + response, + settings); + var derived = new DerivedApiException( + "custom", + response.RequestMessage!, + HttpMethod.Get, + "content", + HttpStatusCode.Conflict, + "Conflict", + response.Headers, + settings); + var emptyDerived = new DerivedApiException( + response.RequestMessage!, + HttpMethod.Get, + null, + HttpStatusCode.BadRequest, + "Bad Request", + response.Headers, + settings); + + var model = await exception.GetContentAsAsync(); + var missing = await emptyDerived.GetContentAsAsync(); + + await Assert.That(model!.Value).IsEqualTo(42); + await Assert.That(missing).IsNull(); + await Assert.That(derived.Message).IsEqualTo("custom"); + await Assert.That(derived.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + await Assert.That(emptyDerived.HasContent).IsFalse(); + } + + /// Verifies ValidationApiException constructors and creation guards. + /// A task representing the asynchronous test. + [Test] + public async Task ValidationApiExceptionConstructorsAndCreateGuards() + { + var inner = new InvalidOperationException("inner"); + var messageOnly = new ValidationApiException("message"); + var withInner = new ValidationApiException("message", inner); + + await Assert.That(messageOnly.Message).IsEqualTo("message"); + await Assert.That(withInner.InnerException).IsSameReferenceAs(inner); + await Assert.That(() => ValidationApiException.Create(null!)) + .ThrowsExactly(); + + using var emptyResponse = CreateErrorResponse(" "); + var emptyException = await ApiException.Create( + emptyResponse.RequestMessage!, + HttpMethod.Get, + emptyResponse, + new()); + await Assert.That(() => ValidationApiException.Create(emptyException)) + .ThrowsExactly(); + } + + /// Verifies the synchronous ValidationApiException factory deserializes problem details. + /// A task representing the asynchronous test. + [Test] + [SuppressMessage("Usage", "CA1849:Call async methods when in an async method", Justification = "This test intentionally covers the synchronous compatibility factory.")] + [SuppressMessage("Major Code Smell", "S6966:Awaitable method should be used", Justification = "This test intentionally covers the synchronous compatibility factory.")] + public async Task ValidationApiExceptionCreateDeserializesProblemDetails() + { + using var response = CreateErrorResponse( + "{\"title\":\"invalid\",\"status\":400,\"errors\":{\"Name\":[\"Required\"]}}", + "application/problem+json"); + var apiException = await ApiException.Create( + response.RequestMessage!, + HttpMethod.Get, + response, + new()); + + var validationException = ValidationApiException.Create(apiException); + + await Assert.That(validationException.Content).IsNotNull(); + await Assert.That(validationException.Content!.Title).IsEqualTo("invalid"); + await Assert.That(validationException.Content.Status).IsEqualTo(400); + await Assert.That(validationException.Content.Errors["Name"][0]).IsEqualTo("Required"); + } + + /// Creates an error response with an attached request. + /// The response content. + /// The optional media type. + /// The response message. + private static HttpResponseMessage CreateErrorResponse(string content, string? mediaType = null) + { + var response = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + RequestMessage = new(HttpMethod.Get, "https://example.test"), + Content = new StringContent(content) + }; + + if (mediaType is not null) + { + response.Content.Headers.ContentType = new(mediaType); + } + + return response; + } + + /// Content that throws when read as a string. + private sealed class ThrowingReadContent : HttpContent + { + /// + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + throw new InvalidOperationException("read failed"); + + /// + protected override bool TryComputeLength(out long length) + { + length = 1; + return true; + } + } + + /// Derived exception exposing protected constructors for coverage. + [SuppressMessage( + "Usage", + "CA1032:Implement standard exception constructors", + Justification = "This test fixture exposes only the protected ApiException constructors under test.")] + [SuppressMessage( + "Major Code Smell", + "S4027:Exceptions should provide standard constructors", + Justification = "This test fixture exposes only the protected ApiException constructors under test.")] + private sealed class DerivedApiException : ApiException + { + /// + public DerivedApiException( + string exceptionMessage, + HttpRequestMessage message, + HttpMethod httpMethod, + string? content, + HttpStatusCode statusCode, + string? reasonPhrase, + System.Net.Http.Headers.HttpResponseHeaders headers, + RefitSettings refitSettings) + : base( + exceptionMessage, + message, + httpMethod, + content, + statusCode, + reasonPhrase, + headers, + refitSettings) + { + } + + /// + public DerivedApiException( + HttpRequestMessage message, + HttpMethod httpMethod, + string? content, + HttpStatusCode statusCode, + string? reasonPhrase, + System.Net.Http.Headers.HttpResponseHeaders headers, + RefitSettings refitSettings) + : base(message, httpMethod, content, statusCode, reasonPhrase, headers, refitSettings) + { + } + } + + /// Response model used by content deserialization tests. + /// The deserialized value. + private sealed record ResponseModel(int Value); +} diff --git a/src/tests/Refit.Tests/ApiResponseTests.cs b/src/tests/Refit.Tests/ApiResponseTests.cs new file mode 100644 index 000000000..b141b029c --- /dev/null +++ b/src/tests/Refit.Tests/ApiResponseTests.cs @@ -0,0 +1,126 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using System.Net; + +namespace Refit.Tests; + +/// Tests for direct construction and error handling. +[RequiresUnreferencedCode("RefitSettings default serializer is exercised by these direct wrapper tests.")] +[RequiresDynamicCode("RefitSettings default serializer is exercised by these direct wrapper tests.")] +public sealed class ApiResponseTests +{ + /// Verifies public constructors reject missing response data needed by the wrapper. + /// A task representing the asynchronous test. + [Test] + public async Task ConstructorsRequireResponseAndRequestMessage() + { + await Assert.That(() => new ApiResponse(null!, "body", new())) + .ThrowsExactly(); + await Assert.That(() => new ApiResponse(new(HttpStatusCode.OK), "body", new())) + .ThrowsExactly(); + } + + /// Verifies a response without an HTTP response reports unavailable state and throws cleanly. + /// A task representing the asynchronous test. + [Test] + public async Task MissingResponseReportsUnavailableState() + { + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.test"); + using var response = new ApiResponse(request, null, null, new()); + + await Assert.That(response.Content).IsNull(); + await Assert.That(response.Headers).IsNull(); + await Assert.That(response.ContentHeaders).IsNull(); + await Assert.That(response.IsSuccessStatusCode).IsFalse(); + await Assert.That(response.IsSuccessful).IsFalse(); + await Assert.That(response.IsReceived).IsFalse(); + await Assert.That(response.ReasonPhrase).IsNull(); + await Assert.That(response.StatusCode).IsNull(); + await Assert.That(response.Version).IsNull(); + await Assert.That(response.RequestMessage).IsSameReferenceAs(request); + await Assert.That(() => (Task)response.EnsureSuccessStatusCodeAsync()) + .ThrowsExactly(); + } + + /// Verifies success wrappers expose response metadata and return themselves from ensure methods. + /// A task representing the asynchronous test. + [Test] + public async Task SuccessResponseExposesMetadataAndUsesFastEnsurePath() + { + using var responseMessage = CreateResponse(HttpStatusCode.OK, "payload"); + using var response = new ApiResponse(responseMessage, "payload", new()); + + var success = await response.EnsureSuccessStatusCodeAsync(); + var successful = await response.EnsureSuccessfulAsync(); + + await Assert.That(success).IsSameReferenceAs(response); + await Assert.That(successful).IsSameReferenceAs(response); + await Assert.That(response.Content).IsEqualTo("payload"); + await Assert.That(response.Headers).IsNotNull(); + await Assert.That(response.ContentHeaders).IsNotNull(); + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + await Assert.That(response.IsSuccessful).IsTrue(); + await Assert.That(response.IsReceived).IsTrue(); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(response.Version).IsEqualTo(responseMessage.Version); + } + + /// Verifies typed error helpers distinguish request and response errors. + /// A task representing the asynchronous test. + [Test] + public async Task ErrorHelpersDistinguishRequestAndResponseErrors() + { + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.test"); + var requestError = new ApiRequestException("failed", request, HttpMethod.Get, new()); + var innerRequestError = new ApiRequestException( + request, + HttpMethod.Post, + new(), + new InvalidOperationException("inner failure")); + var requestErrorWithInner = new ApiRequestException( + "failed with inner", + request, + HttpMethod.Put, + new(), + new InvalidOperationException("inner")); + using var requestErrorResponse = new ApiResponse(request, null, null, new(), requestError); + using var apiResponseMessage = CreateResponse(HttpStatusCode.BadRequest, "bad"); + var apiError = await ApiException.Create( + apiResponseMessage.RequestMessage!, + HttpMethod.Get, + apiResponseMessage, + new()); + using var responseErrorResponse = new ApiResponse( + apiResponseMessage.RequestMessage!, + apiResponseMessage, + null, + new(), + apiError); + + await Assert.That(requestErrorResponse.HasRequestError(out var typedRequestError)).IsTrue(); + await Assert.That(typedRequestError).IsSameReferenceAs(requestError); + await Assert.That(innerRequestError.Message).IsEqualTo("inner failure"); + await Assert.That(innerRequestError.Uri).IsEqualTo(request.RequestUri); + await Assert.That(requestErrorWithInner.InnerException).IsTypeOf(); + await Assert.That(requestErrorResponse.HasResponseError(out _)).IsFalse(); + await Assert.That(responseErrorResponse.HasResponseError(out var typedResponseError)).IsTrue(); + await Assert.That(typedResponseError).IsSameReferenceAs(apiError); + await Assert.That(responseErrorResponse.HasRequestError(out _)).IsFalse(); + await Assert.That(() => (Task)responseErrorResponse.EnsureSuccessfulAsync()) + .ThrowsExactly(); + } + + /// Creates an HTTP response message with an attached request. + /// The response status code. + /// The response content. + /// The response message. + private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, string content) => + new(statusCode) + { + RequestMessage = new(HttpMethod.Get, "https://example.test"), + Content = new StringContent(content) + }; +} diff --git a/src/tests/Refit.Tests/AttributeTests.cs b/src/tests/Refit.Tests/AttributeTests.cs new file mode 100644 index 000000000..028a5186f --- /dev/null +++ b/src/tests/Refit.Tests/AttributeTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Refit.Tests; + +/// Tests for simple public attribute types. +public class AttributeTests +{ + /// Verifies body attribute constructors preserve serialization method and buffering values. + /// A task representing the asynchronous test. + [Test] + public async Task BodyAttributeConstructorsSetExpectedProperties() + { + var defaultAttribute = new BodyAttribute(); + var bufferedAttribute = new BodyAttribute(true); + var methodAttribute = new BodyAttribute(BodySerializationMethod.UrlEncoded); + var methodAndBufferedAttribute = new BodyAttribute(BodySerializationMethod.Serialized, false); + + await Assert.That(defaultAttribute.SerializationMethod).IsEqualTo(BodySerializationMethod.Default); + await Assert.That(defaultAttribute.Buffered).IsNull(); + await Assert.That(bufferedAttribute.SerializationMethod).IsEqualTo(BodySerializationMethod.Default); + await Assert.That(bufferedAttribute.Buffered).IsTrue(); + await Assert.That(methodAttribute.SerializationMethod).IsEqualTo(BodySerializationMethod.UrlEncoded); + await Assert.That(methodAttribute.Buffered).IsNull(); + await Assert.That(methodAndBufferedAttribute.SerializationMethod).IsEqualTo(BodySerializationMethod.Serialized); + await Assert.That(methodAndBufferedAttribute.Buffered).IsFalse(); + } + + /// Verifies the legacy attachment-name attribute stores the supplied name. + /// A task representing the asynchronous test. + [Test] + public async Task AttachmentNameAttributeStoresName() + { +#pragma warning disable CS0618 // Public API retained for compatibility and covered intentionally. + var attribute = new AttachmentNameAttribute("payload"); +#pragma warning restore CS0618 + + await Assert.That(attribute.Name).IsEqualTo("payload"); + } +} diff --git a/src/tests/Refit.Tests/AuthenticatedClientHandlerTests.cs b/src/tests/Refit.Tests/AuthenticatedClientHandlerTests.cs index 29928a5c7..b031a8808 100644 --- a/src/tests/Refit.Tests/AuthenticatedClientHandlerTests.cs +++ b/src/tests/Refit.Tests/AuthenticatedClientHandlerTests.cs @@ -113,16 +113,25 @@ public async Task DefaultHandlerIsNull() await Assert.That(handler.InnerHandler).IsNull(); } - /// Verifies a null token getter throws an . + /// Verifies the constructor that takes an inner handler stores it when provided. /// A task that represents the asynchronous test operation. [Test] - public async Task NullTokenGetterThrows() + public async Task ExplicitInnerHandlerIsAssigned() { + using var innerHandler = new TestHttpMessageHandler(); + var handler = new AuthenticatedHttpClientHandler(innerHandler, (_, _) => Task.FromResult(string.Empty)); + + await Assert.That(handler.InnerHandler).IsSameReferenceAs(innerHandler); + } + + /// Verifies a null token getter throws an . + /// A task that represents the asynchronous test operation. + [Test] + public async Task NullTokenGetterThrows() => await Assert .That(() => new AuthenticatedHttpClientHandler( (Func>)null!)) .ThrowsExactly(); - } /// Verifies unauthenticated calls do not send an authorization header. /// A task that represents the asynchronous test operation. @@ -424,7 +433,7 @@ await Assert.That(async () => public async Task AuthorizationHeaderValueGetterIsUsedWhenSupplyingHttpClient() { var handler = new MockHttpMessageHandler(); - var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://api") }; + var httpClient = new HttpClient(handler) { BaseAddress = new("http://api") }; var settings = new RefitSettings { @@ -450,7 +459,7 @@ public async Task AuthorizationHeaderValueGetterIsUsedWhenSupplyingHttpClient() public async Task AuthorizationHeaderValueGetterCanAwaitWhenSupplyingHttpClient() { var handler = new MockHttpMessageHandler(); - var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://api") }; + var httpClient = new HttpClient(handler) { BaseAddress = new("http://api") }; var settings = new RefitSettings { @@ -480,7 +489,7 @@ public async Task AuthorizationHeaderValueGetterCanAwaitWhenSupplyingHttpClient( public async Task AuthorizationHeaderValueGetterDoesNotOverrideExplicitTokenWhenSupplyingHttpClient() { var handler = new MockHttpMessageHandler(); - var httpClient = new HttpClient(handler) { BaseAddress = new Uri("http://api") }; + var httpClient = new HttpClient(handler) { BaseAddress = new("http://api") }; var settings = new RefitSettings { diff --git a/src/tests/Refit.Tests/CachedRequestBuilderTests.cs b/src/tests/Refit.Tests/CachedRequestBuilderTests.cs index 28b22c77a..9339af01b 100644 --- a/src/tests/Refit.Tests/CachedRequestBuilderTests.cs +++ b/src/tests/Refit.Tests/CachedRequestBuilderTests.cs @@ -2,12 +2,9 @@ // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. using System.Diagnostics.CodeAnalysis; -using System.Net; using System.Net.Http; using System.Reflection; -using RichardSzalay.MockHttp; - namespace Refit.Tests; /// Tests for the cached request builder implementation. @@ -18,10 +15,7 @@ public class CachedRequestBuilderTests /// Verifies the cached builder throws when constructed with a null inner builder. /// A task that represents the asynchronous operation. [Test] - public async Task CachedBuilder_ThrowsForNullInnerBuilder() - { - await Assert.That(() => new CachedRequestBuilderImplementation(null!)).ThrowsExactly(); - } + public async Task CachedBuilder_ThrowsForNullInnerBuilder() => await Assert.That(() => new CachedRequestBuilderImplementation(null!)).ThrowsExactly(); /// Verifies method-table key equality, including object equality and generic-argument differences. /// A task that represents the asynchronous operation. @@ -44,46 +38,23 @@ public async Task MethodTableKey_ObjectEquals_And_GenericArgumentDifference_AreC [Test] public async Task CacheHasCorrectNumberOfElementsTest() { - var mockHttp = new MockHttpMessageHandler(); - var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp }; - - var fixture = RestService.For("http://bar", settings); - - // get internal dictionary to check count - var requestBuilderField = fixture.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).Single(x => x.Name == "requestBuilder"); - var requestBuilder = (CachedRequestBuilderImplementation)requestBuilderField.GetValue(fixture)!; + var innerBuilder = new CountingRequestBuilder(); + var requestBuilder = new CachedRequestBuilderImplementation(innerBuilder); - mockHttp - .Expect(HttpMethod.Post, "http://bar/foo") - .Respond(HttpStatusCode.OK); - await fixture.Empty(); + requestBuilder.BuildRestResultFuncForMethod(nameof(IGeneralRequests.SingleParameter), [typeof(string)]); await Assert.That(requestBuilder.MethodDictionary).HasSingleItem(); + await Assert.That(innerBuilder.BuildCount).IsEqualTo(1); - mockHttp - .Expect(HttpMethod.Post, "http://bar/foo") - .WithQueryString("id", "id") - .Respond(HttpStatusCode.OK); - await fixture.SingleParameter("id"); + requestBuilder.BuildRestResultFuncForMethod(nameof(IGeneralRequests.MultiParameter), [typeof(string), typeof(string)]); await Assert.That(requestBuilder.MethodDictionary.Count).IsEqualTo(2); + await Assert.That(innerBuilder.BuildCount).IsEqualTo(2); - mockHttp - .Expect(HttpMethod.Post, "http://bar/foo") - .WithQueryString("id", "id") - .WithQueryString("name", "name") - .Respond(HttpStatusCode.OK); - await fixture.MultiParameter("id", "name"); + requestBuilder.BuildRestResultFuncForMethod( + nameof(IGeneralRequests.SingleGenericMultiParameter), + [typeof(string), typeof(string), typeof(string)], + [typeof(string)]); await Assert.That(requestBuilder.MethodDictionary.Count).IsEqualTo(3); - - mockHttp - .Expect(HttpMethod.Post, "http://bar/foo") - .WithQueryString("id", "id") - .WithQueryString("name", "name") - .WithQueryString("generic", "generic") - .Respond(HttpStatusCode.OK); - await fixture.SingleGenericMultiParameter("id", "name", "generic"); - await Assert.That(requestBuilder.MethodDictionary.Count).IsEqualTo(4); - - mockHttp.VerifyNoOutstandingExpectation(); + await Assert.That(innerBuilder.BuildCount).IsEqualTo(3); } /// Verifies repeated identical requests do not create duplicate cache entries. @@ -91,38 +62,18 @@ public async Task CacheHasCorrectNumberOfElementsTest() [Test] public async Task NoDuplicateEntriesTest() { - var mockHttp = new MockHttpMessageHandler(); - var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp }; - - var fixture = RestService.For("http://bar", settings); - - // get internal dictionary to check count - var requestBuilderField = fixture.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).Single(x => x.Name == "requestBuilder"); - var requestBuilder = (CachedRequestBuilderImplementation)requestBuilderField.GetValue(fixture)!; + var innerBuilder = new CountingRequestBuilder(); + var requestBuilder = new CachedRequestBuilderImplementation(innerBuilder); - // send the same request repeatedly to ensure that multiple dictionary entries are not created - mockHttp - .Expect(HttpMethod.Post, "http://bar/foo") - .WithQueryString("id", "id") - .Respond(HttpStatusCode.OK); - await fixture.SingleParameter("id"); + requestBuilder.BuildRestResultFuncForMethod(nameof(IGeneralRequests.SingleParameter), [typeof(string)]); await Assert.That(requestBuilder.MethodDictionary).HasSingleItem(); - mockHttp - .Expect(HttpMethod.Post, "http://bar/foo") - .WithQueryString("id", "id") - .Respond(HttpStatusCode.OK); - await fixture.SingleParameter("id"); + requestBuilder.BuildRestResultFuncForMethod(nameof(IGeneralRequests.SingleParameter), [typeof(string)]); await Assert.That(requestBuilder.MethodDictionary).HasSingleItem(); - mockHttp - .Expect(HttpMethod.Post, "http://bar/foo") - .WithQueryString("id", "id") - .Respond(HttpStatusCode.OK); - await fixture.SingleParameter("id"); + requestBuilder.BuildRestResultFuncForMethod(nameof(IGeneralRequests.SingleParameter), [typeof(string)]); await Assert.That(requestBuilder.MethodDictionary).HasSingleItem(); - - mockHttp.VerifyNoOutstandingExpectation(); + await Assert.That(innerBuilder.BuildCount).IsEqualTo(1); } /// Verifies same-named overloads with different parameter types produce distinct cache entries. @@ -130,30 +81,55 @@ public async Task NoDuplicateEntriesTest() [Test] public async Task SameNameDuplicateEntriesTest() { - var mockHttp = new MockHttpMessageHandler(); - var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp }; - - var fixture = RestService.For("http://bar", settings); + var innerBuilder = new CountingRequestBuilder(); + var requestBuilder = new CachedRequestBuilderImplementation(innerBuilder); - // get internal dictionary to check count - var requestBuilderField = fixture.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).Single(x => x.Name == "requestBuilder"); - var requestBuilder = (CachedRequestBuilderImplementation)requestBuilderField.GetValue(fixture)!; - - // send the two different requests with the same name - mockHttp - .Expect(HttpMethod.Post, "http://bar/foo") - .WithQueryString("id", "id") - .Respond(HttpStatusCode.OK); - await fixture.SingleParameter("id"); + requestBuilder.BuildRestResultFuncForMethod(nameof(IDuplicateNames.SingleParameter), [typeof(string)]); await Assert.That(requestBuilder.MethodDictionary).HasSingleItem(); - mockHttp - .Expect(HttpMethod.Post, "http://bar/foo") - .WithQueryString("id", "10") - .Respond(HttpStatusCode.OK); - await fixture.SingleParameter(10); + requestBuilder.BuildRestResultFuncForMethod(nameof(IDuplicateNames.SingleParameter), [typeof(int)]); await Assert.That(requestBuilder.MethodDictionary.Count).IsEqualTo(2); + await Assert.That(innerBuilder.BuildCount).IsEqualTo(2); + } - mockHttp.VerifyNoOutstandingExpectation(); + /// Request-builder test double that records how many functions the cache asks it to build. + private sealed class CountingRequestBuilder : IRequestBuilder + { + /// Gets the number of build calls received. + public int BuildCount { get; private set; } + + /// + public RefitSettings Settings { get; } = new(new NullContentSerializer()); + + /// + [RequiresUnreferencedCode("Test double matches the IRequestBuilder contract.")] + [RequiresDynamicCode("Test double matches the IRequestBuilder contract.")] + public Func BuildRestResultFuncForMethod( + string methodName, + Type[]? parameterTypes = null, + Type[]? genericArgumentTypes = null) + { + BuildCount++; + return static (_, _) => null; + } + } + + /// Content serializer test double used only to satisfy construction. + private sealed class NullContentSerializer : IHttpContentSerializer + { + /// + public HttpContent ToHttpContent(T item) => new ByteArrayContent([]); + + /// + [SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "The method implements IHttpContentSerializer and must preserve the interface shape.")] + public Task FromHttpContentAsync( + HttpContent content, + CancellationToken cancellationToken = default) => Task.FromResult(default); + + /// + public string? GetFieldNameForProperty(PropertyInfo propertyInfo) => propertyInfo.Name; } } diff --git a/src/tests/Refit.Tests/CamelCaseUrlParameterKeyFormatterTests.cs b/src/tests/Refit.Tests/CamelCaseUrlParameterKeyFormatterTests.cs index cd9b3ccbf..edee312c8 100644 --- a/src/tests/Refit.Tests/CamelCaseUrlParameterKeyFormatterTests.cs +++ b/src/tests/Refit.Tests/CamelCaseUrlParameterKeyFormatterTests.cs @@ -22,6 +22,23 @@ public async Task Format_EmptyKey_ReturnsEmptyKey() await Assert.That(output).IsEqualTo(string.Empty); } + /// Verifies the acronym casing rules stop before the first non-leading lowercase character. + /// The key to format. + /// The expected formatted key. + /// A task that represents the asynchronous test operation. + [Test] + [Arguments("URLValue", "urlValue")] + [Arguments("UrlValue", "urlValue")] + [Arguments("URL", "url")] + public async Task Format_AcronymKeys_ReturnsExpectedValue(string key, string expected) + { + var urlParameterKeyFormatter = new CamelCaseUrlParameterKeyFormatter(); + + var output = urlParameterKeyFormatter.Format(key); + + await Assert.That(output).IsEqualTo(expected); + } + /// Verifies that query keys are camelCased when building a request. /// A task that represents the asynchronous test operation. [Test] @@ -45,7 +62,7 @@ public async Task FormatKey_Returns_ExpectedValue() var output = factory([complexQuery]); await Assert.That(output.RequestUri).IsNotNull(); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?alreadyCamelCased=value1¬camelCased=value2"); } diff --git a/src/tests/Refit.Tests/CollisionA/SomeType.cs b/src/tests/Refit.Tests/CollisionA/SomeType.cs index 37ed8f990..e5bc5c4e1 100644 --- a/src/tests/Refit.Tests/CollisionA/SomeType.cs +++ b/src/tests/Refit.Tests/CollisionA/SomeType.cs @@ -1,10 +1,11 @@ // Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; - namespace CollisionA; /// Empty response type used to verify Refit resolves same-named types across namespaces. -[SuppressMessage("Design", "SST1436:Add members to type or remove it", Justification = "Intentional empty fixture used to verify Refit type-name collision handling across namespaces.")] +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "RoslynCommonAnalyzers", + "SST1436:Add members to a type or remove it", + Justification = "Intentional empty response fixture used to verify same-name namespace collision handling.")] public class SomeType; diff --git a/src/tests/Refit.Tests/CollisionB/SomeType.cs b/src/tests/Refit.Tests/CollisionB/SomeType.cs index 826d2796d..c8c9aca75 100644 --- a/src/tests/Refit.Tests/CollisionB/SomeType.cs +++ b/src/tests/Refit.Tests/CollisionB/SomeType.cs @@ -1,10 +1,11 @@ // Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; - namespace CollisionB; /// Empty response type used to verify Refit resolves same-named types across namespaces. -[SuppressMessage("Design", "SST1436:Add members to type or remove it", Justification = "Intentional empty fixture used to verify Refit type-name collision handling across namespaces.")] +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "RoslynCommonAnalyzers", + "SST1436:Add members to a type or remove it", + Justification = "Intentional empty response fixture used to verify same-name namespace collision handling.")] public class SomeType; diff --git a/src/tests/Refit.Tests/CustomReferenceType.cs b/src/tests/Refit.Tests/CustomReferenceType.cs index 0c0d9ad08..fdb176223 100644 --- a/src/tests/Refit.Tests/CustomReferenceType.cs +++ b/src/tests/Refit.Tests/CustomReferenceType.cs @@ -2,10 +2,11 @@ // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; - namespace Refit.Tests; /// An empty custom reference type used to verify Refit's nullable reference handling. -[SuppressMessage("RoslynCommonAnalyzers", "SST1436", Justification = "Intentional empty fixture type used to verify nullable reference handling in Refit tests.")] +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "RoslynCommonAnalyzers", + "SST1436:Add members to a type or remove it", + Justification = "Intentional empty fixture type used to verify nullable custom-reference handling without changing the public shape.")] public sealed class CustomReferenceType; diff --git a/src/tests/Refit.Tests/CustomValueType.cs b/src/tests/Refit.Tests/CustomValueType.cs index cd943206c..8223efa27 100644 --- a/src/tests/Refit.Tests/CustomValueType.cs +++ b/src/tests/Refit.Tests/CustomValueType.cs @@ -2,10 +2,11 @@ // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; - namespace Refit.Tests; /// An empty custom type used to verify Refit's nullable handling of custom types. -[SuppressMessage("RoslynCommonAnalyzers", "SST1436", Justification = "Intentional empty fixture type used to verify nullable handling in Refit tests.")] +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "RoslynCommonAnalyzers", + "SST1436:Add members to a type or remove it", + Justification = "Intentional empty fixture type used to verify nullable custom-type handling without changing the public shape.")] public sealed class CustomValueType; diff --git a/src/tests/Refit.Tests/DefaultFormUrlEncodedParameterFormatterTests.cs b/src/tests/Refit.Tests/DefaultFormUrlEncodedParameterFormatterTests.cs new file mode 100644 index 000000000..1fffa37a6 --- /dev/null +++ b/src/tests/Refit.Tests/DefaultFormUrlEncodedParameterFormatterTests.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.Serialization; + +namespace Refit.Tests; + +/// Tests for . +[RequiresUnreferencedCode("Formatting enum values reflects over enum metadata in these tests.")] +public class DefaultFormUrlEncodedParameterFormatterTests +{ + /// Enum used to verify enum-member formatting and undefined enum fallback. + private enum FormEnum + { + /// The value with an explicit form name. + [EnumMember(Value = "custom-value")] + Custom = 1, + + /// A value without an explicit form name. + Plain = 2 + } + + /// Verifies null values format without allocating a string. + /// A task representing the asynchronous test. + [Test] + public async Task FormatReturnsNullForNullValue() + { + var formatter = new DefaultFormUrlEncodedParameterFormatter(); + + await Assert.That(formatter.Format(null, null)).IsNull(); + } + + /// Verifies enum-member names are honored and undefined values fall back to their numeric value. + /// A task representing the asynchronous test. + [Test] + public async Task FormatUsesEnumMemberWhenPresentAndFallsBackForUndefinedValues() + { + var formatter = new DefaultFormUrlEncodedParameterFormatter(); + + await Assert.That(formatter.Format(FormEnum.Custom, null)).IsEqualTo("custom-value"); + await Assert.That(formatter.Format(FormEnum.Plain, null)).IsEqualTo("Plain"); + await Assert.That(formatter.Format((FormEnum)999, null)).IsEqualTo("999"); + } +} diff --git a/src/tests/Refit.Tests/DefaultUrlParameterFormatterTests.cs b/src/tests/Refit.Tests/DefaultUrlParameterFormatterTests.cs index 75cca71a5..46d95c6cc 100644 --- a/src/tests/Refit.Tests/DefaultUrlParameterFormatterTests.cs +++ b/src/tests/Refit.Tests/DefaultUrlParameterFormatterTests.cs @@ -236,7 +236,7 @@ public async Task RequestWithPlainDateTimeQueryParameter_ProducesCorrectQueryStr }; var output = factory([parameters]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.Query).IsEqualTo( "?DateTime=2023"); @@ -259,13 +259,13 @@ public async Task RequestWithDateTimeCollectionQueryParameter_ProducesCorrectQue { DateTimeCollection = [ - new DateTime(2023, 8, 21, 0, 0, 0, DateTimeKind.Unspecified), - new DateTime(2024, 8, 21, 0, 0, 0, DateTimeKind.Unspecified) + new(2023, 8, 21, 0, 0, 0, DateTimeKind.Unspecified), + new(2024, 8, 21, 0, 0, 0, DateTimeKind.Unspecified) ], }; var output = factory([parameters]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.Query).IsEqualTo( "?DateTimeCollection=2023%2C2024"); @@ -288,13 +288,13 @@ public async Task RequestWithDateTimeDictionaryQueryParameter_ProducesCorrectQue { DateTimeDictionary = new Dictionary { - { 1, new DateTime(2023, 8, 21, 0, 0, 0, DateTimeKind.Unspecified) }, - { 2, new DateTime(2024, 8, 21, 0, 0, 0, DateTimeKind.Unspecified) }, + { 1, new(2023, 8, 21, 0, 0, 0, DateTimeKind.Unspecified) }, + { 2, new(2024, 8, 21, 0, 0, 0, DateTimeKind.Unspecified) }, }, }; var output = factory([parameters]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.Query).IsEqualTo( "?DateTimeDictionary.1=2023&DateTimeDictionary.2=2024"); @@ -317,13 +317,13 @@ public async Task RequestWithDateTimeKeyedDictionaryQueryParameter_ProducesCorre { DateTimeKeyedDictionary = new Dictionary { - { new DateTime(2023, 8, 21, 0, 0, 0, DateTimeKind.Unspecified), 1 }, - { new DateTime(2024, 8, 21, 0, 0, 0, DateTimeKind.Unspecified), 2 }, + { new(2023, 8, 21, 0, 0, 0, DateTimeKind.Unspecified), 1 }, + { new(2024, 8, 21, 0, 0, 0, DateTimeKind.Unspecified), 2 }, }, }; var output = factory([parameters]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.Query).IsEqualTo( "?DateTimeKeyedDictionary.2023=1&DateTimeKeyedDictionary.2024=2"); diff --git a/src/tests/Refit.Tests/DeliminatorSeparatedPropertyNamesContractResolver.cs b/src/tests/Refit.Tests/DeliminatorSeparatedPropertyNamesContractResolver.cs index 63179f5ad..da2e8fb14 100644 --- a/src/tests/Refit.Tests/DeliminatorSeparatedPropertyNamesContractResolver.cs +++ b/src/tests/Refit.Tests/DeliminatorSeparatedPropertyNamesContractResolver.cs @@ -20,10 +20,7 @@ public class DeliminatorSeparatedPropertyNamesContractResolver : DefaultContract /// Initializes a new instance of the class. /// The character placed between property name words. - protected DeliminatorSeparatedPropertyNamesContractResolver(char separator) - { - _separator = separator.ToString(CultureInfo.InvariantCulture); - } + protected DeliminatorSeparatedPropertyNamesContractResolver(char separator) => _separator = separator.ToString(CultureInfo.InvariantCulture); /// protected override string ResolvePropertyName(string propertyName) diff --git a/src/tests/Refit.Tests/Foo.cs b/src/tests/Refit.Tests/Foo.cs index b9b8dcc05..58a15ea7c 100644 --- a/src/tests/Refit.Tests/Foo.cs +++ b/src/tests/Refit.Tests/Foo.cs @@ -8,8 +8,5 @@ namespace Refit.Tests; public sealed record Foo { /// - public override string ToString() - { - return "foo"; - } + public override string ToString() => "foo"; } diff --git a/src/tests/Refit.Tests/FormValueMultimapTests.cs b/src/tests/Refit.Tests/FormValueMultimapTests.cs index 2dd35184a..9db18519a 100644 --- a/src/tests/Refit.Tests/FormValueMultimapTests.cs +++ b/src/tests/Refit.Tests/FormValueMultimapTests.cs @@ -1,6 +1,7 @@ // Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. +using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -38,6 +39,15 @@ public async Task EmptyIfNullPassedIn() await Assert.That(target).IsEmpty(); } + /// Verifies a null settings instance is rejected before source processing. + /// A task that represents the asynchronous operation. + [Test] + public async Task RejectsNullSettings() + { + await Assert.That(() => new FormValueMultimap(new object(), null!)) + .ThrowsExactly(); + } + /// Verifies the multimap loads entries from a dictionary source. /// A task that represents the asynchronous operation. [Test] @@ -46,8 +56,10 @@ public async Task LoadsFromDictionary() var source = new Dictionary { { "foo", "bar" }, { "xyz", "123" } }; var target = new FormValueMultimap(source, _settings); + var nonGenericEntries = ((IEnumerable)target).Cast>().ToArray(); await Assert.That(target).IsCollectionEqualTo(ToNullableKvps(source)); + await Assert.That(nonGenericEntries).IsCollectionEqualTo(ToNullableKvps(source)); } /// Verifies the multimap loads entries from an object's public properties. @@ -174,6 +186,38 @@ public async Task DefaultCollectionFormatCanBeSpecifiedInSettings( await Assert.That(actual).IsCollectionEqualTo(expected); } + /// Verifies an empty delimited collection is represented by an empty value. + /// A task that represents the asynchronous operation. + [Test] + public async Task EmptyDelimitedCollectionSerializesAsEmptyValue() + { + var source = new ObjectWithEmptyDelimitedCollection(); + var expected = new[] + { + new KeyValuePair("Values", string.Empty) + }; + + var actual = new FormValueMultimap(source, _settings); + + await Assert.That(actual).IsCollectionEqualTo(expected); + } + + /// Verifies unknown collection formats fall back to formatting the collection object itself. + /// A task that represents the asynchronous operation. + [Test] + public async Task UnknownCollectionFormatFallsBackToObjectFormatter() + { + var source = new ObjectWithUnknownCollectionFormat(); + var expected = new[] + { + new KeyValuePair("Values", source.Values.ToString()) + }; + + var actual = new FormValueMultimap(source, _settings); + + await Assert.That(actual).IsCollectionEqualTo(expected); + } + /// Verifies properties with non-public getters are excluded from the multimap. /// A task that represents the asynchronous operation. [Test] @@ -337,6 +381,22 @@ public class ObjectWithRepeatedFieldsTestClass public int[]? F { get; init; } } + /// Test fixture with an empty collection using a delimited collection format. + public class ObjectWithEmptyDelimitedCollection + { + /// Gets the empty values collection. + [Query(CollectionFormat.Csv)] + public ArrayList Values { get; } = []; + } + + /// Test fixture with an unsupported collection format value. + public class ObjectWithUnknownCollectionFormat + { + /// Gets the values collection. + [Query((CollectionFormat)123)] + public int[] Values { get; } = [1, 2]; + } + /// Test fixture whose properties have non-public getters to verify they are excluded. public class ClassWithInaccessibleGetters { diff --git a/src/tests/Refit.Tests/GeneratedRequestRunnerTests.cs b/src/tests/Refit.Tests/GeneratedRequestRunnerTests.cs new file mode 100644 index 000000000..4242ac58a --- /dev/null +++ b/src/tests/Refit.Tests/GeneratedRequestRunnerTests.cs @@ -0,0 +1,994 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Http.Headers; +using System.Reflection; +using System.Text; + +namespace Refit.Tests; + +/// Tests for the generated request runtime helper. +[RequiresUnreferencedCode("RefitSettings creates the default reflection-capable System.Text.Json serializer.")] +[RequiresDynamicCode("RefitSettings creates the default reflection-capable System.Text.Json serializer.")] +public class GeneratedRequestRunnerTests +{ + /// Verifies that already-created HTTP content is reused directly. + /// A task that represents the asynchronous operation. + [Test] + public async Task CreateBodyContentReusesHttpContent() + { + var content = new StringContent("body"); + var settings = CreateSettings(); + + var result = GeneratedRequestRunner.CreateBodyContent( + settings, + content, + BodySerializationMethod.Default, + streamBody: false); + + await Assert.That(result).IsSameReferenceAs(content); + } + + /// Verifies that stream bodies become stream content without serializer involvement. + /// A task that represents the asynchronous operation. + [Test] + public async Task CreateBodyContentUsesStreamContentForStreamBodies() + { + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes("stream-body")); + var settings = CreateSettings(); + + var result = GeneratedRequestRunner.CreateBodyContent( + settings, + stream, + BodySerializationMethod.Default, + streamBody: false); + + await Assert.That(result).IsTypeOf(); + await Assert.That(await result.ReadAsStringAsync()).IsEqualTo("stream-body"); + } + + /// Verifies that default string bodies are sent as literal string content. + /// A task that represents the asynchronous operation. + [Test] + public async Task CreateBodyContentUsesLiteralStringForDefaultStringBodies() + { + var serializer = new RecordingContentSerializer(); + var settings = CreateSettings(serializer); + + var result = GeneratedRequestRunner.CreateBodyContent( + settings, + "literal", + BodySerializationMethod.Default, + streamBody: false); + + await Assert.That(await result.ReadAsStringAsync()).IsEqualTo("literal"); + await Assert.That(serializer.SerializeCallCount).IsEqualTo(0); + } + + /// Verifies that serialized body modes use the configured content serializer. + /// A task that represents the asynchronous operation. + [Test] + public async Task CreateBodyContentUsesSerializerForSerializedBodyModes() + { + var serializer = new RecordingContentSerializer(); + var settings = CreateSettings(serializer); + + var defaultContent = GeneratedRequestRunner.CreateBodyContent( + settings, + 42, + BodySerializationMethod.Default, + streamBody: false); + var serializedContent = GeneratedRequestRunner.CreateBodyContent( + settings, + "serialized", + BodySerializationMethod.Serialized, + streamBody: false); +#pragma warning disable CS0618 // Generated request building must keep accepting legacy compiled BodySerializationMethod.Json callers. + var legacyJsonContent = GeneratedRequestRunner.CreateBodyContent( + settings, + "legacy-json", + BodySerializationMethod.Json, + streamBody: false); +#pragma warning restore CS0618 + + await Assert.That(await defaultContent.ReadAsStringAsync()).IsEqualTo("serialized:42"); + await Assert.That(await serializedContent.ReadAsStringAsync()).IsEqualTo("serialized:serialized"); + await Assert.That(await legacyJsonContent.ReadAsStringAsync()).IsEqualTo("serialized:legacy-json"); + await Assert.That(serializer.SerializeCallCount).IsEqualTo(3); + } + + /// Verifies that streaming serialized bodies are copied through push-stream content. + /// A task that represents the asynchronous operation. + [Test] + public async Task CreateBodyContentWrapsSerializedContentForStreamingBodies() + { + var settings = CreateSettings(new RecordingContentSerializer()); + + var result = GeneratedRequestRunner.CreateBodyContent( + settings, + "streamed", + BodySerializationMethod.Serialized, + streamBody: true); + + await Assert.That(result).IsTypeOf(); + await Assert.That(await result.ReadAsStringAsync()).IsEqualTo("serialized:streamed"); + } + + /// Verifies that unsupported body serialization modes are rejected. + /// A task that represents the asynchronous operation. + [Test] + public async Task CreateBodyContentRejectsUnsupportedBodySerializationMode() + { + var settings = CreateSettings(); + + await Assert + .That( + () => GeneratedRequestRunner.CreateBodyContent( + settings, + new { Value = 42 }, + BodySerializationMethod.UrlEncoded, + streamBody: false)) + .ThrowsExactly(); + } + + /// Verifies that generated header assignment replaces, removes, and sanitizes values. + /// A task that represents the asynchronous operation. + [Test] + public async Task SetHeaderReplacesRemovesAndSanitizesHeaders() + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + + GeneratedRequestRunner.SetHeader(request, "X-Test", "first"); + GeneratedRequestRunner.SetHeader(request, "X-Test", "second\r\nvalue"); + + await Assert.That(request.Headers.GetValues("X-Test")).IsCollectionEqualTo(["secondvalue"]); + + GeneratedRequestRunner.SetHeader(request, "X-Test", null); + + await Assert.That(request.Headers.Contains("X-Test")).IsFalse(); + } + + /// Verifies that content headers create placeholder content for methods that can carry bodies. + /// A task that represents the asynchronous operation. + [Test] + public async Task SetHeaderUsesContentHeadersForContentHeaderNames() + { + using var request = new HttpRequestMessage(HttpMethod.Post, "/resource"); + + GeneratedRequestRunner.SetHeader(request, "Content-Language", "en-US"); + + await Assert.That(request.Content).IsNotNull(); + await Assert.That(request.Content!.Headers.ContentLanguage).IsCollectionEqualTo(["en-US"]); + } + + /// Verifies that generated header assignment removes existing content headers before adding replacements. + /// A task that represents the asynchronous operation. + [Test] + public async Task SetHeaderReplacesExistingContentHeaders() + { + using var request = new HttpRequestMessage(HttpMethod.Post, "/resource") + { + Content = new StringContent("body") + }; + request.Content.Headers.ContentLanguage.Add("en-US"); + + GeneratedRequestRunner.SetHeader(request, "Content-Language", "fr-FR"); + + await Assert.That(request.Content.Headers.ContentLanguage).IsCollectionEqualTo(["fr-FR"]); + } + + /// Verifies that header collections are optional and replace earlier values by key. + /// A task that represents the asynchronous operation. + [Test] + public async Task AddHeaderCollectionIgnoresNullAndReplacesExistingHeaders() + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + var headers = new Dictionary + { + ["X-First"] = "one", + ["X-Second"] = "two" + }; + + GeneratedRequestRunner.SetHeader(request, "X-First", "original"); + GeneratedRequestRunner.AddHeaderCollection(request, null); + GeneratedRequestRunner.AddHeaderCollection(request, headers); + + await Assert.That(request.Headers.GetValues("X-First")).IsCollectionEqualTo(["one"]); + await Assert.That(request.Headers.GetValues("X-Second")).IsCollectionEqualTo(["two"]); + } + + /// Verifies that generated request properties use typed request options where available. + /// A task that represents the asynchronous operation. + [Test] + public async Task AddRequestPropertyStoresTypedRequestOption() + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + + GeneratedRequestRunner.AddRequestProperty(request, "number", 42); + +#if NET6_0_OR_GREATER + await Assert.That(request.Options.TryGetValue(new HttpRequestOptionsKey("number"), out var value)) + .IsTrue(); + await Assert.That(value).IsEqualTo(42); +#else + await Assert.That(request.Properties["number"]).IsEqualTo(42); +#endif + } + + /// Verifies that configured request options and interface type metadata are applied. + /// A task that represents the asynchronous operation. + [Test] + public async Task AddConfiguredRequestOptionsAddsConfiguredValuesAndInterfaceType() + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + var settings = new RefitSettings(new RecordingContentSerializer()) + { + HttpRequestMessageOptions = new() + { + ["configured"] = 42 + } + }; + + GeneratedRequestRunner.AddConfiguredRequestOptions( + request, + settings, + typeof(GeneratedRequestRunnerTests)); + +#if NET6_0_OR_GREATER + await Assert.That( + request.Options.TryGetValue( + new HttpRequestOptionsKey("configured"), + out var configuredValue)) + .IsTrue(); + await Assert.That(configuredValue).IsEqualTo(42); + await Assert.That( + request.Options.TryGetValue( + new HttpRequestOptionsKey(HttpRequestMessageOptions.InterfaceType), + out var interfaceType)) + .IsTrue(); + await Assert.That(interfaceType).IsEqualTo(typeof(GeneratedRequestRunnerTests)); +#else + await Assert.That(request.Properties["configured"]).IsEqualTo(42); + await Assert.That(request.Properties[HttpRequestMessageOptions.InterfaceType]) + .IsEqualTo(typeof(GeneratedRequestRunnerTests)); +#endif + } + + /// Verifies that void requests apply generated auth headers and honor the exception factory. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendVoidAsyncAppliesAuthorizationAndThrowsFactoryException() + { + var handler = new CapturingHandler( + (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.Accepted) + { + Content = new StringContent("accepted") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Post, "/resource") + { + Content = new StringContent("body") + }; + request.Headers.Authorization = new("Bearer"); + var exception = new InvalidOperationException("factory failure"); + var settings = CreateSettings(); + settings.AuthorizationHeaderValueGetter = (_, _) => Task.FromResult("token"); + settings.ExceptionFactory = _ => Task.FromResult(exception); + + var thrown = await Assert + .That( + () => GeneratedRequestRunner.SendVoidAsync( + client, + request, + settings, + bufferBody: true, + CancellationToken.None)) + .ThrowsExactly(); + + await Assert.That(thrown).IsSameReferenceAs(exception); + await Assert.That(handler.AuthorizationParameter).IsEqualTo("token"); + } + + /// Verifies that void requests require a base address when using generated relative URIs. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendVoidAsyncRequiresBaseAddress() + { + using var client = new HttpClient(new CapturingHandler()); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + + var exception = await Assert + .That( + () => GeneratedRequestRunner.SendVoidAsync( + client, + request, + CreateSettings(), + bufferBody: false, + CancellationToken.None)) + .ThrowsExactly(); + + await Assert.That(exception!.Message).IsEqualTo("BaseAddress must be set on the HttpClient instance"); + } + + /// Verifies that string response paths avoid the serializer. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncReturnsStringContentWithoutSerializer() + { + var serializer = new RecordingContentSerializer(); + var handler = new CapturingHandler( + (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("response") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + var settings = CreateSettings(serializer); + + var result = await GeneratedRequestRunner.SendAsync( + client, + request, + settings, + isApiResponse: false, + shouldDisposeResponse: true, + bufferBody: false, + CancellationToken.None); + + await Assert.That(result).IsEqualTo("response"); + await Assert.That(serializer.DeserializeCallCount).IsEqualTo(0); + } + + /// Verifies that generated response calls buffer request content when requested. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncBuffersRequestContentWhenRequested() + { + var handler = new CapturingHandler( + (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("buffered") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Post, "/resource") + { + Content = new StringContent("request-body") + }; + + var result = await GeneratedRequestRunner.SendAsync( + client, + request, + CreateSettings(), + isApiResponse: false, + shouldDisposeResponse: true, + bufferBody: true, + CancellationToken.None); + + await Assert.That(result).IsEqualTo("buffered"); + } + + /// Verifies that HTTP response messages can be returned without running the exception factory. + /// A task that represents the asynchronous operation. + [Test] + [SuppressMessage( + "Reliability", + "CA2025:Ensure tasks using IDisposable instances complete before the instances are disposed", + Justification = "The test awaits generated SendAsync before response disposal and verifies this exact response is returned.")] + public async Task SendAsyncReturnsHttpResponseMessageWithoutExceptionFactory() + { + var response = new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("server-error") + }; + var handler = new CapturingHandler((_, _) => Task.FromResult(response)); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + var settings = CreateSettings(); + var exceptionFactoryCalled = false; + settings.ExceptionFactory = _ => + { + exceptionFactoryCalled = true; + return Task.FromResult(new InvalidOperationException("should not run")); + }; + + var result = await GeneratedRequestRunner.SendAsync( + client, + request, + settings, + isApiResponse: false, + shouldDisposeResponse: false, + bufferBody: false, + CancellationToken.None); + + await Assert.That(result).IsSameReferenceAs(response); + await Assert.That(exceptionFactoryCalled).IsFalse(); + } + + /// Verifies that HTTP content can be returned directly. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncReturnsHttpContentDirectly() + { + var responseContent = new StringContent("content"); + var handler = new CapturingHandler( + (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = responseContent + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + + var result = await GeneratedRequestRunner.SendAsync( + client, + request, + CreateSettings(), + isApiResponse: false, + shouldDisposeResponse: false, + bufferBody: false, + CancellationToken.None); + + await Assert.That(result).IsSameReferenceAs(responseContent); + } + + /// Verifies that stream responses are read from response content. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncReturnsResponseStream() + { + var handler = new CapturingHandler( + (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("streamed-response") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + + var result = await GeneratedRequestRunner.SendAsync( + client, + request, + CreateSettings(), + isApiResponse: false, + shouldDisposeResponse: false, + bufferBody: false, + CancellationToken.None); + + using var reader = new StreamReader(result!); + await Assert.That(await reader.ReadToEndAsync()).IsEqualTo("streamed-response"); + } + + /// Verifies that serialized responses use the configured content serializer. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncDeserializesSerializedContent() + { + var serializer = new RecordingContentSerializer + { + DeserializedValue = new GeneratedResult(42) + }; + var handler = new CapturingHandler( + (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"value\":42}") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + + var result = await GeneratedRequestRunner.SendAsync( + client, + request, + CreateSettings(serializer), + isApiResponse: false, + shouldDisposeResponse: true, + bufferBody: false, + CancellationToken.None); + + await Assert.That(result).IsEqualTo(new(42)); + await Assert.That(serializer.DeserializeCallCount).IsEqualTo(1); + } + + /// Verifies that empty serialized responses return the default value without deserializing. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncReturnsDefaultForEmptySerializedContent() + { + var serializer = new RecordingContentSerializer + { + DeserializedValue = new GeneratedResult(42) + }; + var handler = new CapturingHandler( + (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent([]) + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + + var result = await GeneratedRequestRunner.SendAsync( + client, + request, + CreateSettings(serializer), + isApiResponse: false, + shouldDisposeResponse: true, + bufferBody: false, + CancellationToken.None); + + await Assert.That(result).IsNull(); + await Assert.That(serializer.DeserializeCallCount).IsEqualTo(0); + } + + /// Verifies that generated response handling uses the configured exception factory for non-wrapper results. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncThrowsExceptionFactoryExceptionForNonApiResponses() + { + var exception = new InvalidOperationException("factory failure"); + var handler = new CapturingHandler( + (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("bad") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + var settings = CreateSettings(); + settings.ExceptionFactory = _ => Task.FromResult(exception); + + var thrown = await Assert + .That( + () => GeneratedRequestRunner.SendAsync( + client, + request, + settings, + isApiResponse: false, + shouldDisposeResponse: true, + bufferBody: false, + CancellationToken.None)) + .ThrowsExactly(); + + await Assert.That(thrown).IsSameReferenceAs(exception); + } + + /// Verifies that generated response handling wraps transport failures for API response results. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncReturnsApiResponseForTransportFailure() + { + var handler = new CapturingHandler( + (_, _) => throw new HttpRequestException("network failure")); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + + var result = await GeneratedRequestRunner.SendAsync, string>( + client, + request, + CreateSettings(), + isApiResponse: true, + shouldDisposeResponse: false, + bufferBody: false, + CancellationToken.None); + + await Assert.That(result!.IsReceived).IsFalse(); + await Assert.That(result.HasRequestError(out var error)).IsTrue(); + await Assert.That(error!.InnerException).IsTypeOf(); + } + + /// Verifies that generated response handling throws transport failures for non-wrapper results. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncThrowsApiRequestExceptionForTransportFailure() + { + var handler = new CapturingHandler( + (_, _) => throw new HttpRequestException("network failure")); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + + var exception = await Assert + .That( + () => GeneratedRequestRunner.SendAsync( + client, + request, + CreateSettings(), + isApiResponse: false, + shouldDisposeResponse: true, + bufferBody: false, + CancellationToken.None)) + .ThrowsExactly(); + + await Assert.That(exception!.InnerException).IsTypeOf(); + } + + /// Verifies that API response results carry deserialized content on success. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncReturnsSuccessfulApiResponseWithDeserializedContent() + { + var serializer = new RecordingContentSerializer + { + DeserializedValue = new GeneratedResult(123) + }; + var handler = new CapturingHandler( + (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"value\":123}") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + + var result = await GeneratedRequestRunner.SendAsync, GeneratedResult>( + client, + request, + CreateSettings(serializer), + isApiResponse: true, + shouldDisposeResponse: false, + bufferBody: false, + CancellationToken.None); + + await Assert.That(result!.IsSuccessful).IsTrue(); + await Assert.That(result.Content).IsEqualTo(new(123)); + await Assert.That(serializer.DeserializeCallCount).IsEqualTo(1); + } + + /// Verifies that API response results carry response factory errors without deserializing. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncReturnsApiResponseWithResponseException() + { + var serializer = new RecordingContentSerializer + { + DeserializedValue = new GeneratedResult(123) + }; + var handler = new CapturingHandler( + (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("bad") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + var settings = CreateSettings(serializer); + + var result = await GeneratedRequestRunner.SendAsync, GeneratedResult>( + client, + request, + settings, + isApiResponse: true, + shouldDisposeResponse: false, + bufferBody: false, + CancellationToken.None); + + await Assert.That(result!.IsSuccessStatusCode).IsFalse(); + await Assert.That(result.HasResponseError(out var error)).IsTrue(); + await Assert.That(error!.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + await Assert.That(serializer.DeserializeCallCount).IsEqualTo(0); + } + + /// Verifies that API response deserialization exceptions can be suppressed by the configured factory. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncApiResponseSuppressesDeserializationExceptionWhenFactoryReturnsNull() + { + var serializer = new RecordingContentSerializer + { + DeserializeException = new FormatException("bad content") + }; + var handler = new CapturingHandler( + (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("bad") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + var settings = CreateSettings(serializer); + settings.DeserializationExceptionFactory = (_, _) => Task.FromResult(null); + + var result = await GeneratedRequestRunner.SendAsync, GeneratedResult>( + client, + request, + settings, + isApiResponse: true, + shouldDisposeResponse: false, + bufferBody: false, + CancellationToken.None); + + await Assert.That(result!.IsSuccessful).IsTrue(); + await Assert.That(result.Content).IsNull(); + await Assert.That(result.Error).IsNull(); + } + + /// Verifies that non-wrapper deserialization exceptions can be replaced by the configured factory. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncThrowsConfiguredDeserializationExceptionForNonApiResponses() + { + var serializer = new RecordingContentSerializer + { + DeserializeException = new FormatException("bad content") + }; + var handler = new CapturingHandler( + (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("bad") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + var replacement = new InvalidOperationException("replacement"); + var settings = CreateSettings(serializer); + settings.DeserializationExceptionFactory = (_, _) => Task.FromResult(replacement); + + var thrown = await Assert + .That( + () => GeneratedRequestRunner.SendAsync( + client, + request, + settings, + isApiResponse: false, + shouldDisposeResponse: true, + bufferBody: false, + CancellationToken.None)) + .ThrowsExactly(); + + await Assert.That(thrown).IsSameReferenceAs(replacement); + } + + /// Verifies that non-wrapper deserialization exceptions use Refit's default API exception wrapper. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncThrowsDefaultApiExceptionForNonApiResponseDeserializationFailures() + { + var serializer = new RecordingContentSerializer + { + DeserializeException = new FormatException("bad content") + }; + var handler = new CapturingHandler( + (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("bad") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + + var thrown = await Assert + .That( + () => GeneratedRequestRunner.SendAsync( + client, + request, + CreateSettings(serializer), + isApiResponse: false, + shouldDisposeResponse: true, + bufferBody: false, + CancellationToken.None)) + .ThrowsExactly(); + + await Assert.That(thrown!.Message).IsEqualTo("An error occured deserializing the response."); + await Assert.That(thrown.InnerException).IsTypeOf(); + } + + /// Verifies cancellation-triggered deserialization exceptions are rethrown directly. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncRethrowsCancellationRequestedDuringDeserialization() + { + var serializer = new RecordingContentSerializer + { + DeserializeException = new OperationCanceledException("cancelled") + }; + var handler = new CapturingHandler( + (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("cancelled") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + using var tokenSource = new CancellationTokenSource(); + await tokenSource.CancelAsync(); + + await Assert + .That( + () => GeneratedRequestRunner.SendAsync( + client, + request, + CreateSettings(serializer), + isApiResponse: false, + shouldDisposeResponse: true, + bufferBody: false, + tokenSource.Token)) + .ThrowsExactly(); + } + + /// Verifies that best-effort response buffering failures do not prevent serializer deserialization. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncIgnoresResponseBufferingFailuresBeforeDeserializing() + { + var serializer = new RecordingContentSerializer + { + DeserializedValue = new GeneratedResult(321) + }; + var handler = new CapturingHandler( + (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ThrowingLoadContent() + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + + var result = await GeneratedRequestRunner.SendAsync( + client, + request, + CreateSettings(serializer), + isApiResponse: false, + shouldDisposeResponse: true, + bufferBody: false, + CancellationToken.None); + + await Assert.That(result).IsEqualTo(new(321)); + await Assert.That(serializer.DeserializeCallCount).IsEqualTo(1); + } + + /// Verifies that generated response calls require a base address for relative requests. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncRequiresBaseAddress() + { + using var client = new HttpClient(new CapturingHandler()); + using var request = new HttpRequestMessage(HttpMethod.Get, "/resource"); + + var exception = await Assert + .That( + () => GeneratedRequestRunner.SendAsync( + client, + request, + CreateSettings(), + isApiResponse: false, + shouldDisposeResponse: true, + bufferBody: false, + CancellationToken.None)) + .ThrowsExactly(); + + await Assert.That(exception!.Message).IsEqualTo("BaseAddress must be set on the HttpClient instance"); + } + + /// Creates settings backed by the test serializer. + /// The serializer to assign, or null for a recording serializer. + /// The configured settings. + private static RefitSettings CreateSettings(IHttpContentSerializer? serializer = null) => + new(serializer ?? new RecordingContentSerializer()); + + /// Creates an HTTP client that can send generated relative request URIs. + /// The handler that will receive generated requests. + /// The configured client. + private static HttpClient CreateClient(HttpMessageHandler handler) => + new(handler) + { + BaseAddress = new("https://api.example") + }; + + /// Captures request details sent by generated response helpers. + private sealed class CapturingHandler : HttpMessageHandler + { + /// The send delegate used by this handler. + private readonly Func> _sendAsync; + + /// Initializes a new instance of the class. + public CapturingHandler() + : this( + (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(string.Empty) + })) + { + } + + /// Initializes a new instance of the class. + /// The send delegate to invoke. + public CapturingHandler( + Func> sendAsync) => + _sendAsync = sendAsync; + + /// Gets the authorization parameter captured from the sent request. + public string? AuthorizationParameter { get; private set; } + + /// + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + AuthorizationParameter = request.Headers.Authorization?.Parameter; + return SendAndAttachRequestAsync(request, cancellationToken); + } + + /// Runs the send delegate and mirrors HttpClientHandler by attaching the request to the response. + /// The sent request. + /// The cancellation token. + /// The response with a request message. + private async Task SendAndAttachRequestAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var response = await _sendAsync(request, cancellationToken).ConfigureAwait(false); + response.RequestMessage ??= request; + return response; + } + } + + /// Records serializer usage and returns configured test values. + private sealed class RecordingContentSerializer : IHttpContentSerializer + { + /// Gets the number of serialization calls. + public int SerializeCallCount { get; private set; } + + /// Gets the number of deserialization calls. + public int DeserializeCallCount { get; private set; } + + /// Gets the value returned from deserialization. + public object? DeserializedValue { get; init; } + + /// Gets the exception thrown from deserialization. + public Exception? DeserializeException { get; init; } + + /// + public HttpContent ToHttpContent(T item) + { + SerializeCallCount++; + return new StringContent($"serialized:{item}"); + } + + /// + [SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "The method implements Refit's published serializer interface.")] + public Task FromHttpContentAsync( + HttpContent content, + CancellationToken cancellationToken = default) + { + DeserializeCallCount++; + if (DeserializeException is not null) + { + throw DeserializeException; + } + + return Task.FromResult((T?)DeserializedValue); + } + + /// + public string? GetFieldNameForProperty(PropertyInfo propertyInfo) => + propertyInfo.Name; + } + + /// Content that fails when buffering attempts to serialize it into memory. + private sealed class ThrowingLoadContent : HttpContent + { + /// + protected override Task SerializeToStreamAsync( + Stream stream, + TransportContext? context) => + throw new InvalidOperationException("buffering failed"); + + /// + protected override bool TryComputeLength(out long length) + { + length = 1; + return true; + } + } + + /// Simple deserialized response model for generated runtime tests. + /// The model value. + private sealed record GeneratedResult(int Value); +} diff --git a/src/tests/Refit.Tests/GeneratorComponentTests.cs b/src/tests/Refit.Tests/GeneratorComponentTests.cs deleted file mode 100644 index 1192db120..000000000 --- a/src/tests/Refit.Tests/GeneratorComponentTests.cs +++ /dev/null @@ -1,324 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. -// ReactiveUI and Contributors licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; - -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; - -using Refit.Generator; - -namespace Refit.Tests; - -/// -/// Focused unit tests for the individual building blocks of the source generator, -/// exercised directly rather than through end-to-end snapshot generation. -/// -public static class GeneratorComponentTests -{ - /// Tests for . - public class UniqueNameBuilderTests - { - /// Verifies that an unused name is returned unchanged. - /// A task representing the asynchronous test. - [Test] - public async Task New_ReturnsOriginalName_WhenUnused() - { - var builder = new UniqueNameBuilder(); - - await Assert.That(builder.New("client")).IsEqualTo("client"); - } - - /// Verifies that a numeric suffix is appended when a name collides. - /// A task representing the asynchronous test. - [Test] - public async Task New_AppendsSuffix_OnCollision() - { - var builder = new UniqueNameBuilder(); - - await Assert.That(builder.New("client")).IsEqualTo("client"); - await Assert.That(builder.New("client")).IsEqualTo("client0"); - await Assert.That(builder.New("client")).IsEqualTo("client1"); - } - - /// Verifies that a reserved name is never handed out directly. - /// A task representing the asynchronous test. - [Test] - public async Task Reserve_PreventsName_FromBeingHandedOut() - { - var builder = new UniqueNameBuilder(); - builder.Reserve("client"); - - await Assert.That(builder.New("client")).IsEqualTo("client0"); - } - - /// Verifies that reserving an enumerable prevents every supplied name. - /// A task representing the asynchronous test. - [Test] - public async Task Reserve_Enumerable_PreventsAllNames() - { - var builder = new UniqueNameBuilder(); - builder.Reserve(["client", "requestBuilder"]); - - await Assert.That(builder.New("client")).IsEqualTo("client0"); - await Assert.That(builder.New("requestBuilder")).IsEqualTo("requestBuilder0"); - } - - /// Verifies that reserving a null enumerable does not throw. - /// A task representing the asynchronous test. - [Test] - public async Task Reserve_NullEnumerable_DoesNotThrow() - { - var builder = new UniqueNameBuilder(); - - builder.Reserve((IEnumerable)null!); - - await Assert.That(builder.New("client")).IsEqualTo("client"); - } - - /// Verifies that a new scope inherits the parent's reservations. - /// A task representing the asynchronous test. - [Test] - public async Task NewScope_InheritsParentReservations() - { - var parent = new UniqueNameBuilder(); - parent.Reserve("client"); - - var child = parent.NewScope(); - - await Assert.That(child.New("client")).IsEqualTo("client0"); - } - - /// Verifies that names handed out in a child scope do not leak to the parent. - /// A task representing the asynchronous test. - [Test] - public async Task NewScope_ParentDoesNotSeeChildNames() - { - var parent = new UniqueNameBuilder(); - var child = parent.NewScope(); - child.New("local"); - - // The child's name must not leak back into the parent scope. - await Assert.That(parent.New("local")).IsEqualTo("local"); - } - } - - /// Tests for . - public class SourceWriterTests - { - /// Verifies that the configured indentation is applied to written lines. - /// A task representing the asynchronous test. - [Test] - public async Task WriteLine_AppliesIndentation() - { - var writer = new SourceWriter { Indentation = 1 }; - writer.WriteLine("body"); - writer.Indentation = 0; - - await Assert.That(writer.ToSourceText().ToString()).StartsWith(" body"); - } - - /// Verifies that zero indentation produces no leading whitespace. - /// A task representing the asynchronous test. - [Test] - public async Task WriteLine_ZeroIndentation_HasNoLeadingWhitespace() - { - var writer = new SourceWriter(); - writer.WriteLine("body"); - - await Assert.That(writer.ToSourceText().ToString()).StartsWith("body"); - } - - /// Verifies that a negative indentation value throws. - /// A task representing the asynchronous test. - [Test] - public async Task Indentation_Negative_Throws() - { - var writer = new SourceWriter(); - - await Assert.That(() => writer.Indentation = -1).ThrowsExactly(); - } - - /// Verifies that resetting clears both buffered content and indentation. - /// A task representing the asynchronous test. - [Test] - public async Task Reset_ClearsContentAndIndentation() - { - var writer = new SourceWriter { Indentation = 2 }; - writer.WriteLine("first"); - - writer.Reset(); - await Assert.That(writer.Indentation).IsEqualTo(0); - - writer.WriteLine("second"); - var text = writer.ToSourceText().ToString(); - - await Assert.That(text).StartsWith("second"); - await Assert.That(text.Split('\n')).DoesNotContain("first"); - } - } - - /// Tests for . - public class ImmutableEquatableArrayTests - { - /// Verifies that arrays with the same sequence are equal and share a hash code. - /// A task representing the asynchronous test. - [Test] - public async Task Equals_SameSequence_IsTrue() - { - var left = new ImmutableEquatableArray(["a", "b", "c"]); - var right = new ImmutableEquatableArray(["a", "b", "c"]); - - await Assert.That(right).IsEqualTo(left); - await Assert.That(right.GetHashCode()).IsEqualTo(left.GetHashCode()); - } - - /// Verifies that arrays with differing sequences are not equal. - /// A task representing the asynchronous test. - [Test] - public async Task Equals_DifferentSequence_IsFalse() - { - var left = new ImmutableEquatableArray(["a", "b", "c"]); - var right = new ImmutableEquatableArray(["a", "x", "c"]); - - await Assert.That(right).IsNotEqualTo(left); - } - - /// Verifies that the empty array has no elements. - /// A task representing the asynchronous test. - [Test] - public async Task Empty_HasNoElements() - { - await Assert.That(ImmutableEquatableArray.Empty.Count).IsEqualTo(0); - } - - /// Verifies that converting a null source yields an empty array. - /// A task representing the asynchronous test. - [Test] - public async Task ToImmutableEquatableArray_Null_ReturnsEmpty() - { - IEnumerable? source = null; - - var result = source.ToImmutableEquatableArray(); - - await Assert.That(result.Count).IsEqualTo(0); - } - - /// Verifies that enumeration yields all values in their original order. - /// A task representing the asynchronous test. - [Test] - public async Task Enumerator_YieldsAllValuesInOrder() - { - var array = new ImmutableEquatableArray([10, 20, 30]); - - var collected = new List(array.Count); - collected.AddRange(array); - - await Assert.That(collected).IsCollectionEqualTo([10, 20, 30]); - await Assert.That(array[1]).IsEqualTo(20); - } - } - - /// Tests for the ITypeSymbol generator extension helpers. - public class ITypeSymbolExtensionsTests - { - /// Verifies that the base-type chain starts with the type itself and walks its bases. - /// A task representing the asynchronous test. - [Test] - public async Task GetBaseTypesAndThis_ReturnsSelfThenBaseChain() - { - var compilation = Compile(""" - public class Base { } - public class Middle : Base { } - public class Derived : Middle { } - """); - var derived = GetType(compilation, "Derived"); - - var chain = derived.GetBaseTypesAndThis().Select(t => t.Name).ToArray(); - - await Assert.That(chain).IsCollectionEqualTo(["Derived", "Middle", "Base", "Object"]); - } - - /// Verifies that a type inherits from or equals itself. - /// A task representing the asynchronous test. - [Test] - public async Task InheritsFromOrEquals_SameType_IsTrue() - { - var compilation = Compile("public class Derived { }"); - var derived = GetType(compilation, "Derived"); - - await Assert.That(derived.InheritsFromOrEquals(derived)).IsTrue(); - } - - /// Verifies that a derived type inherits from its base type. - /// A task representing the asynchronous test. - [Test] - public async Task InheritsFromOrEquals_BaseType_IsTrue() - { - var compilation = Compile(""" - public class Base { } - public class Derived : Base { } - """); - - await Assert.That( - GetType(compilation, "Derived").InheritsFromOrEquals(GetType(compilation, "Base"))).IsTrue(); - } - - /// Verifies that unrelated types do not inherit from one another. - /// A task representing the asynchronous test. - [Test] - public async Task InheritsFromOrEquals_UnrelatedType_IsFalse() - { - var compilation = Compile(""" - public class Foo { } - public class Bar { } - """); - - await Assert.That( - GetType(compilation, "Foo").InheritsFromOrEquals(GetType(compilation, "Bar"))).IsFalse(); - } - - /// Verifies that interface inheritance is only considered when the include-interfaces flag is set. - /// A task representing the asynchronous test. - [Test] - public async Task InheritsFromOrEquals_Interface_HonorsIncludeInterfacesFlag() - { - var compilation = Compile(""" - public interface IThing { } - public class Thing : IThing { } - """); - var thing = GetType(compilation, "Thing"); - var thingInterface = GetType(compilation, "IThing"); - - await Assert.That(thing.InheritsFromOrEquals(thingInterface, includeInterfaces: false)).IsFalse(); - await Assert.That(thing.InheritsFromOrEquals(thingInterface, includeInterfaces: true)).IsTrue(); - } - - /// Compiles the supplied C# source into an in-memory compilation. - /// The C# source to compile. - /// The resulting compilation. - private static CSharpCompilation Compile(string source) - { - var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) - .Split(Path.PathSeparator) - .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)); - - return CSharpCompilation.Create( - "TypeSymbolTests", - [CSharpSyntaxTree.ParseText(source)], - references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - } - - /// Resolves a named type symbol from the compilation by metadata name. - /// The compilation to search. - /// The metadata name of the type to find. - /// The resolved type symbol. - private static INamedTypeSymbol GetType(Compilation compilation, string typeName) => - compilation.GetTypeByMetadataName(typeName) - ?? throw new InvalidOperationException($"Type '{typeName}' was not found."); - } -} diff --git a/src/tests/Refit.Tests/Http/Client.cs b/src/tests/Refit.Tests/Http/Client.cs index 171ef724c..1ef558b20 100644 --- a/src/tests/Refit.Tests/Http/Client.cs +++ b/src/tests/Refit.Tests/Http/Client.cs @@ -1,19 +1,22 @@ // Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; - namespace Refit.Tests.Http; /// An HTTP client fixture used to verify unique-name generation across namespaces and nested types. -[SuppressMessage("Design", "SST1436:Add members to type or remove it", Justification = "Intentional empty type marker; used only as a generic type argument to UniqueName.ForType.")] public sealed class Client { /// A request fixture nested inside used to verify unique-name generation for nested types. - [SuppressMessage("Design", "SST1436:Add members to type or remove it", Justification = "Intentional empty type marker; used only as a generic type argument to UniqueName.ForType.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "RoslynCommonAnalyzers", + "SST1436:Add members to a type or remove it", + Justification = "Intentional empty nested fixture type used to verify unique-name generation.")] public sealed class Request; /// A response fixture nested inside used to verify unique-name generation for nested types. - [SuppressMessage("Design", "SST1436:Add members to type or remove it", Justification = "Intentional empty type marker; used only as a generic type argument to UniqueName.ForType.")] + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "RoslynCommonAnalyzers", + "SST1436:Add members to a type or remove it", + Justification = "Intentional empty nested fixture type used to verify unique-name generation.")] public sealed class Response; } diff --git a/src/tests/Refit.Tests/HttpClientFactoryExtensionsTests.cs b/src/tests/Refit.Tests/HttpClientFactoryExtensionsTests.cs index ec3ac6908..61e794d5b 100644 --- a/src/tests/Refit.Tests/HttpClientFactoryExtensionsTests.cs +++ b/src/tests/Refit.Tests/HttpClientFactoryExtensionsTests.cs @@ -2,17 +2,14 @@ // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. using System.Diagnostics.CodeAnalysis; -using System.Globalization; using System.Linq; +using System.Net; using System.Net.Http; using System.Reflection; -using System.Text.Json; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Refit.Implementation; - namespace Refit.Tests; /// Tests for the Refit HttpClientFactory dependency-injection extension methods. @@ -115,10 +112,10 @@ public async Task HttpClientReturnsClientGivenTypeArgument() public async Task HttpClientSettingsAreInjectableGivenGenericArgument() { var serviceCollection = new ServiceCollection().Configure( - o => o.Serializer = new SystemTextJsonContentSerializer(new JsonSerializerOptions())); + o => o.Serializer = new(new())); serviceCollection.AddRefitClient( _ => - new RefitSettings + new() { ContentSerializer = _.GetRequiredService< IOptions>().Value.Serializer! @@ -138,11 +135,11 @@ await Assert.That( public async Task HttpClientSettingsAreInjectableGivenTypeArgument() { var serviceCollection = new ServiceCollection().Configure( - o => o.Serializer = new SystemTextJsonContentSerializer(new JsonSerializerOptions())); + o => o.Serializer = new(new())); serviceCollection.AddRefitClient( typeof(IFooWithOtherAttribute), _ => - new RefitSettings + new() { ContentSerializer = _.GetRequiredService< IOptions>().Value.Serializer! @@ -160,7 +157,7 @@ await Assert.That( [Test] public async Task HttpClientSettingsCanBeProvidedStaticallyGivenGenericArgument() { - var contentSerializer = new SystemTextJsonContentSerializer(new JsonSerializerOptions()); + var contentSerializer = new SystemTextJsonContentSerializer(new()); var serviceCollection = new ServiceCollection(); serviceCollection.AddRefitClient( new RefitSettings { ContentSerializer = contentSerializer }); @@ -177,7 +174,7 @@ await Assert.That( [SuppressMessage("Usage", "CA2263:Prefer generic overload", Justification = "Test intentionally exercises the non-generic Type overload.")] public async Task HttpClientSettingsCanBeProvidedStaticallyGivenTypeArgument() { - var contentSerializer = new SystemTextJsonContentSerializer(new JsonSerializerOptions()); + var contentSerializer = new SystemTextJsonContentSerializer(new()); var serviceCollection = new ServiceCollection(); serviceCollection.AddRefitClient( typeof(IFooWithOtherAttribute), @@ -189,6 +186,669 @@ await Assert.That( .Settings!.ContentSerializer).IsSameReferenceAs(contentSerializer); } + /// Verifies keyed generic service-collection registrations can use static settings and a named HTTP client. + /// A task that represents the asynchronous operation. + [Test] + public async Task ServiceCollectionKeyedGenericSettingsOverloadUsesStaticSettingsAndClientName() + { + var contentSerializer = new SystemTextJsonContentSerializer(new()); + var settings = new RefitSettings { ContentSerializer = contentSerializer }; + var services = new ServiceCollection(); + + var builder = services.AddKeyedRefitClient( + "service-key", + settings, + "service-client"); + + await Assert.That(builder.Name).IsEqualTo("service-client"); + + var serviceProvider = services.BuildServiceProvider(); + await Assert.That( + serviceProvider.GetRequiredKeyedService>("service-key") + .Settings! + .ContentSerializer).IsSameReferenceAs(contentSerializer); + await Assert.That( + serviceProvider.GetRequiredKeyedService>("service-key")) + .IsNotNull(); + } + + /// Verifies keyed service-collection registrations can resolve settings from services. + /// A task that represents the asynchronous operation. + [Test] + [SuppressMessage("Usage", "CA2263:Prefer generic overload", Justification = "Test intentionally exercises the non-generic Type overload.")] + public async Task ServiceCollectionKeyedTypeSettingsFactoryOverloadUsesServiceProvider() + { + var services = new ServiceCollection().Configure( + o => o.Serializer = new(new())); + + var builder = services.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + "service-key", + serviceProvider => new() + { + ContentSerializer = serviceProvider.GetRequiredService>().Value.Serializer! + }, + "service-client"); + + await Assert.That(builder.Name).IsEqualTo("service-client"); + + var serviceProvider = services.BuildServiceProvider(); + await Assert.That( + serviceProvider.GetRequiredKeyedService>("service-key") + .Settings! + .ContentSerializer).IsSameReferenceAs( + serviceProvider.GetRequiredService>().Value.Serializer); + } + + /// Verifies keyed service-collection registrations can use static settings. + /// A task that represents the asynchronous operation. + [Test] + [SuppressMessage("Usage", "CA2263:Prefer generic overload", Justification = "Test intentionally exercises the non-generic Type overload.")] + public async Task ServiceCollectionKeyedTypeSettingsOverloadUsesStaticSettings() + { + var contentSerializer = new SystemTextJsonContentSerializer(new()); + var settings = new RefitSettings { ContentSerializer = contentSerializer }; + var services = new ServiceCollection(); + + services.AddKeyedRefitClient(typeof(IFooWithOtherAttribute), "service-key", settings); + + var serviceProvider = services.BuildServiceProvider(); + await Assert.That( + serviceProvider.GetRequiredKeyedService>("service-key") + .Settings! + .ContentSerializer).IsSameReferenceAs(contentSerializer); + } + + /// Verifies service-collection overloads reject missing required arguments. + /// A task that represents the asynchronous operation. + [Test] + [SuppressMessage("Usage", "CA2263:Prefer generic overload", Justification = "Test intentionally exercises the non-generic Type overload.")] + public async Task ServiceCollectionOverloadsValidateRequiredArguments() + { + IServiceCollection services = new ServiceCollection(); + + await Assert.That(() => services.AddRefitClient(null!)).ThrowsExactly(); + await Assert.That(() => services.AddKeyedRefitClient(typeof(IFooWithOtherAttribute), null!)) + .ThrowsExactly(); + await Assert.That(() => services.AddKeyedRefitClient(null!)) + .ThrowsExactly(); + await Assert.That(() => services.AddKeyedRefitClient(null!, new RefitSettings())) + .ThrowsExactly(); + await Assert.That( + () => services.AddKeyedRefitClient( + null!, + static _ => null)) + .ThrowsExactly(); + await Assert.That(() => services.AddKeyedRefitClient(null!, "service-key")) + .ThrowsExactly(); + await Assert.That(() => services.AddRefitClient(null!, new RefitSettings())) + .ThrowsExactly(); + await Assert.That(() => services.AddRefitClient(null!, new RefitSettings(), "service-client")) + .ThrowsExactly(); + await Assert.That( + () => services.AddRefitClient( + null!, + (Func?)null)) + .ThrowsExactly(); + await Assert.That( + () => services.AddRefitClient( + null!, + (Func?)null, + "service-client")) + .ThrowsExactly(); + await Assert.That( + () => services.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + null!, + new RefitSettings())) + .ThrowsExactly(); + await Assert.That( + () => services.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + null!, + new RefitSettings(), + "service-client")) + .ThrowsExactly(); + await Assert.That( + () => services.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + null!, + (Func?)null)) + .ThrowsExactly(); + await Assert.That( + () => services.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + null!, + (Func?)null, + "service-client")) + .ThrowsExactly(); + await Assert.That( + () => services.AddKeyedRefitClient( + null!, + "service-key", + new RefitSettings())) + .ThrowsExactly(); + await Assert.That( + () => services.AddKeyedRefitClient( + null!, + "service-key", + new RefitSettings(), + "service-client")) + .ThrowsExactly(); + await Assert.That( + () => services.AddKeyedRefitClient( + null!, + "service-key", + (Func?)null)) + .ThrowsExactly(); + await Assert.That( + () => services.AddKeyedRefitClient( + null!, + "service-key", + (Func?)null, + "service-client")) + .ThrowsExactly(); + var keyedTypeBuilder = services.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + "service-key", + (Func?)null); + await Assert.That(keyedTypeBuilder).IsNotNull(); + + services = null!; + await Assert.That(() => services.AddRefitClient(typeof(IFooWithOtherAttribute))) + .ThrowsExactly(); + await Assert.That(() => services.AddRefitClient()) + .ThrowsExactly(); + await Assert.That(() => services.AddRefitClient(typeof(IFooWithOtherAttribute), new RefitSettings())) + .ThrowsExactly(); + await Assert.That( + () => services.AddRefitClient( + typeof(IFooWithOtherAttribute), + new RefitSettings(), + "service-client")) + .ThrowsExactly(); + await Assert.That( + () => services.AddRefitClient( + typeof(IFooWithOtherAttribute), + (Func?)null)) + .ThrowsExactly(); + await Assert.That( + () => services.AddRefitClient( + typeof(IFooWithOtherAttribute), + (Func?)null, + "service-client")) + .ThrowsExactly(); + await Assert.That(() => services.AddRefitClient(new RefitSettings())) + .ThrowsExactly(); + await Assert.That(() => services.AddRefitClient(new RefitSettings(), "service-client")) + .ThrowsExactly(); + await Assert.That( + () => services.AddRefitClient( + (Func?)null)) + .ThrowsExactly(); + await Assert.That( + () => services.AddRefitClient( + (Func?)null, + "service-client")) + .ThrowsExactly(); + await Assert.That(() => services.AddKeyedRefitClient(typeof(IFooWithOtherAttribute), "service-key")) + .ThrowsExactly(); + await Assert.That( + () => services.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + "service-key", + new RefitSettings())) + .ThrowsExactly(); + await Assert.That( + () => services.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + "service-key", + new RefitSettings(), + "service-client")) + .ThrowsExactly(); + await Assert.That( + () => services.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + "service-key", + (Func?)null)) + .ThrowsExactly(); + await Assert.That( + () => services.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + "service-key", + (Func?)null, + "service-client")) + .ThrowsExactly(); + await Assert.That(() => services.AddKeyedRefitClient("service-key")) + .ThrowsExactly(); + await Assert.That(() => services.AddKeyedRefitClient("service-key", new RefitSettings())) + .ThrowsExactly(); + await Assert.That( + () => services.AddKeyedRefitClient( + "service-key", + new RefitSettings(), + "service-client")) + .ThrowsExactly(); + await Assert.That( + () => services.AddKeyedRefitClient( + "service-key", + (Func?)null)) + .ThrowsExactly(); + await Assert.That( + () => services.AddKeyedRefitClient( + "service-key", + (Func?)null, + "service-client")) + .ThrowsExactly(); + } + + /// Verifies service-collection overloads that accept client names pass those names through. + /// A task that represents the asynchronous operation. + [Test] + [SuppressMessage("Usage", "CA2263:Prefer generic overload", Justification = "Test intentionally exercises the non-generic Type overload.")] + public async Task ServiceCollectionNamedOverloadsUseProvidedClientNames() + { + var services = new ServiceCollection(); + var settings = new RefitSettings(); + + var genericSettings = services.AddRefitClient(settings, "generic-settings"); + var genericFactory = services.AddRefitClient( + static _ => new RefitSettings(), + "generic-factory"); + var typeSettings = services.AddRefitClient( + typeof(IFooWithOtherAttribute), + settings, + "type-settings"); + var typeFactory = services.AddRefitClient( + typeof(IFooWithOtherAttribute), + static _ => new RefitSettings(), + "type-factory"); + + await Assert.That(genericSettings.Name).IsEqualTo("generic-settings"); + await Assert.That(genericFactory.Name).IsEqualTo("generic-factory"); + await Assert.That(typeSettings.Name).IsEqualTo("type-settings"); + await Assert.That(typeFactory.Name).IsEqualTo("type-factory"); + } + + /// Verifies remaining keyed service-collection overloads register keyed services and settings. + /// A task that represents the asynchronous operation. + [Test] + [SuppressMessage("Usage", "CA2263:Prefer generic overload", Justification = "Test intentionally exercises the non-generic Type overload.")] + public async Task ServiceCollectionKeyedOverloadMatrixRegistersServices() + { + var genericSettingsSerializer = new SystemTextJsonContentSerializer(new()); + var genericFactorySerializer = new SystemTextJsonContentSerializer(new()); + var typeNamedSerializer = new SystemTextJsonContentSerializer(new()); + var typeFactorySerializer = new SystemTextJsonContentSerializer(new()); + var services = new ServiceCollection(); + + var genericNoSettings = services.AddKeyedRefitClient("generic-none"); + services.AddKeyedRefitClient( + "generic-settings", + new RefitSettings { ContentSerializer = genericSettingsSerializer }); + var genericNamedSettings = services.AddKeyedRefitClient( + "generic-settings-named", + new RefitSettings(), + "generic-settings-client"); + services.AddKeyedRefitClient( + "generic-factory", + static _ => new RefitSettings()); + var genericNamedFactory = services.AddKeyedRefitClient( + "generic-factory-named", + _ => new() { ContentSerializer = genericFactorySerializer }, + "generic-factory-client"); + services.AddKeyedRefitClient(typeof(IFooWithOtherAttribute), "type-none"); + var typeNamedSettings = services.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + "type-settings-named", + new RefitSettings { ContentSerializer = typeNamedSerializer }, + "type-settings-client"); + services.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + "type-factory", + _ => new() { ContentSerializer = typeFactorySerializer }); + + await Assert.That(genericNoSettings.Name).IsNotNull(); + await Assert.That(genericNamedSettings.Name).IsEqualTo("generic-settings-client"); + await Assert.That(genericNamedFactory.Name).IsEqualTo("generic-factory-client"); + await Assert.That(typeNamedSettings.Name).IsEqualTo("type-settings-client"); + + var serviceProvider = services.BuildServiceProvider(); + await Assert.That( + serviceProvider.GetRequiredKeyedService>("generic-settings") + .Settings! + .ContentSerializer).IsSameReferenceAs(genericSettingsSerializer); + await Assert.That( + serviceProvider.GetRequiredKeyedService>("generic-factory-named") + .Settings! + .ContentSerializer).IsSameReferenceAs(genericFactorySerializer); + await Assert.That( + serviceProvider.GetRequiredKeyedService>("type-settings-named") + .Settings! + .ContentSerializer).IsSameReferenceAs(typeNamedSerializer); + await Assert.That( + serviceProvider.GetRequiredKeyedService>("type-factory") + .Settings! + .ContentSerializer).IsSameReferenceAs(typeFactorySerializer); + await Assert.That( + serviceProvider.GetRequiredKeyedService>("type-none")) + .IsNotNull(); + await Assert.That(serviceProvider.GetRequiredKeyedService("type-none")).IsNotNull(); + } + + /// Verifies the generic overload keeps the existing named client and registers Refit services. + /// A task that represents the asynchronous operation. + [Test] + public async Task HttpClientBuilderGenericOverloadUsesExistingBuilderName() + { + var services = new ServiceCollection(); + var builder = services.AddHttpClient("builder-client", client => client.BaseAddress = new("https://builder.example")); + + var returnedBuilder = builder.AddRefitClient(); + + await Assert.That(returnedBuilder.Name).IsEqualTo("builder-client"); + + var serviceProvider = services.BuildServiceProvider(); + await Assert.That(serviceProvider.GetService()).IsNotNull(); + await Assert.That( + serviceProvider.GetRequiredService>().Settings).IsNull(); + } + + /// Verifies the generic settings overload stores the supplied settings. + /// A task that represents the asynchronous operation. + [Test] + public async Task HttpClientBuilderGenericSettingsOverloadUsesStaticSettings() + { + var contentSerializer = new SystemTextJsonContentSerializer(new()); + var settings = new RefitSettings { ContentSerializer = contentSerializer }; + var services = new ServiceCollection(); + var builder = services.AddHttpClient("builder-settings"); + + builder.AddRefitClient(settings); + + var serviceProvider = services.BuildServiceProvider(); + await Assert.That( + serviceProvider.GetRequiredService>() + .Settings! + .ContentSerializer).IsSameReferenceAs(contentSerializer); + } + + /// Verifies the generic settings factory overload resolves settings from services. + /// A task that represents the asynchronous operation. + [Test] + public async Task HttpClientBuilderGenericSettingsFactoryOverloadUsesServiceProvider() + { + var services = new ServiceCollection().Configure( + o => o.Serializer = new(new())); + var builder = services.AddHttpClient("builder-settings-factory"); + + builder.AddRefitClient( + serviceProvider => new() + { + ContentSerializer = serviceProvider.GetRequiredService>().Value.Serializer! + }); + + var serviceProvider = services.BuildServiceProvider(); + await Assert.That( + serviceProvider.GetRequiredService>() + .Settings! + .ContentSerializer).IsSameReferenceAs( + serviceProvider.GetRequiredService>().Value.Serializer); + } + + /// Verifies the settings overload stores the supplied settings. + /// A task that represents the asynchronous operation. + [Test] + [SuppressMessage("Usage", "CA2263:Prefer generic overload", Justification = "Test intentionally exercises the non-generic Type overload.")] + public async Task HttpClientBuilderTypeSettingsOverloadUsesStaticSettings() + { + var contentSerializer = new SystemTextJsonContentSerializer(new()); + var settings = new RefitSettings { ContentSerializer = contentSerializer }; + var services = new ServiceCollection(); + var builder = services.AddHttpClient("builder-type-settings"); + + builder.AddRefitClient(typeof(IFooWithOtherAttribute), settings); + + var serviceProvider = services.BuildServiceProvider(); + await Assert.That( + serviceProvider.GetRequiredService>() + .Settings! + .ContentSerializer).IsSameReferenceAs(contentSerializer); + } + + /// Verifies the settings factory overload resolves settings from services. + /// A task that represents the asynchronous operation. + [Test] + [SuppressMessage("Usage", "CA2263:Prefer generic overload", Justification = "Test intentionally exercises the non-generic Type overload.")] + public async Task HttpClientBuilderTypeSettingsFactoryOverloadUsesServiceProvider() + { + var services = new ServiceCollection().Configure( + o => o.Serializer = new(new())); + var builder = services.AddHttpClient("builder-type-settings-factory"); + + builder.AddRefitClient( + typeof(IFooWithOtherAttribute), + serviceProvider => new() + { + ContentSerializer = serviceProvider.GetRequiredService>().Value.Serializer! + }); + + var serviceProvider = services.BuildServiceProvider(); + await Assert.That( + serviceProvider.GetRequiredService>() + .Settings! + .ContentSerializer).IsSameReferenceAs( + serviceProvider.GetRequiredService>().Value.Serializer); + } + + /// Verifies the keyed generic overload registers keyed services. + /// A task that represents the asynchronous operation. + [Test] + public async Task HttpClientBuilderKeyedGenericOverloadRegistersKeyedServices() + { + var services = new ServiceCollection(); + var builder = services.AddHttpClient("builder-keyed"); + + var returnedBuilder = builder.AddKeyedRefitClient("builder-key"); + + await Assert.That(returnedBuilder.Name).IsEqualTo("builder-keyed"); + + var serviceProvider = services.BuildServiceProvider(); + await Assert.That( + serviceProvider.GetKeyedService("builder-key")).IsNotNull(); + await Assert.That( + serviceProvider.GetKeyedService>("builder-key")).IsNotNull(); + } + + /// Verifies the keyed generic settings overload stores the supplied settings. + /// A task that represents the asynchronous operation. + [Test] + public async Task HttpClientBuilderKeyedGenericSettingsOverloadUsesStaticSettings() + { + var contentSerializer = new SystemTextJsonContentSerializer(new()); + var settings = new RefitSettings { ContentSerializer = contentSerializer }; + var services = new ServiceCollection(); + var builder = services.AddHttpClient("builder-keyed-settings"); + + builder.AddKeyedRefitClient("builder-key", settings); + + var serviceProvider = services.BuildServiceProvider(); + await Assert.That( + serviceProvider.GetRequiredKeyedService>("builder-key") + .Settings! + .ContentSerializer).IsSameReferenceAs(contentSerializer); + } + + /// Verifies the keyed settings factory overload resolves settings from services. + /// A task that represents the asynchronous operation. + [Test] + [SuppressMessage("Usage", "CA2263:Prefer generic overload", Justification = "Test intentionally exercises the non-generic Type overload.")] + public async Task HttpClientBuilderKeyedTypeSettingsFactoryOverloadUsesServiceProvider() + { + var services = new ServiceCollection().Configure( + o => o.Serializer = new(new())); + var builder = services.AddHttpClient("builder-keyed-type-settings-factory"); + + builder.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + "builder-key", + serviceProvider => new() + { + ContentSerializer = serviceProvider.GetRequiredService>().Value.Serializer! + }); + + var serviceProvider = services.BuildServiceProvider(); + await Assert.That( + serviceProvider.GetRequiredKeyedService>("builder-key") + .Settings! + .ContentSerializer).IsSameReferenceAs( + serviceProvider.GetRequiredService>().Value.Serializer); + } + + /// Verifies the overloads reject missing required arguments. + /// A task that represents the asynchronous operation. + [Test] + [SuppressMessage("Usage", "CA2263:Prefer generic overload", Justification = "Test intentionally exercises the non-generic Type overload.")] + public async Task HttpClientBuilderOverloadsValidateRequiredArguments() + { + IHttpClientBuilder builder = new ServiceCollection().AddHttpClient("builder-validation"); + + await Assert.That(() => builder.AddRefitClient(null!)).ThrowsExactly(); + await Assert.That(() => builder.AddKeyedRefitClient(typeof(IFooWithOtherAttribute), null!)) + .ThrowsExactly(); + await Assert.That(() => builder.AddKeyedRefitClient(null!)) + .ThrowsExactly(); + await Assert.That(() => builder.AddKeyedRefitClient(null!, new RefitSettings())) + .ThrowsExactly(); + await Assert.That( + () => builder.AddKeyedRefitClient( + null!, + static _ => null)) + .ThrowsExactly(); + await Assert.That(() => builder.AddKeyedRefitClient(null!, "builder-key")) + .ThrowsExactly(); + await Assert.That(() => builder.AddRefitClient(null!, new RefitSettings())) + .ThrowsExactly(); + await Assert.That( + () => builder.AddRefitClient( + null!, + (Func?)null)) + .ThrowsExactly(); + await Assert.That( + () => builder.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + null!, + new RefitSettings())) + .ThrowsExactly(); + await Assert.That( + () => builder.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + null!, + (Func?)null)) + .ThrowsExactly(); + await Assert.That( + () => builder.AddKeyedRefitClient( + null!, + "builder-key", + new RefitSettings())) + .ThrowsExactly(); + await Assert.That( + () => builder.AddKeyedRefitClient( + null!, + "builder-key", + (Func?)null)) + .ThrowsExactly(); + + builder = null!; + await Assert.That(() => builder.AddRefitClient(typeof(IFooWithOtherAttribute))) + .ThrowsExactly(); + await Assert.That(() => builder.AddRefitClient()) + .ThrowsExactly(); + await Assert.That(() => builder.AddRefitClient(typeof(IFooWithOtherAttribute), new RefitSettings())) + .ThrowsExactly(); + await Assert.That( + () => builder.AddRefitClient( + typeof(IFooWithOtherAttribute), + (Func?)null)) + .ThrowsExactly(); + await Assert.That(() => builder.AddRefitClient(new RefitSettings())) + .ThrowsExactly(); + await Assert.That( + () => builder.AddRefitClient( + (Func?)null)) + .ThrowsExactly(); + await Assert.That(() => builder.AddKeyedRefitClient(typeof(IFooWithOtherAttribute), "builder-key")) + .ThrowsExactly(); + await Assert.That( + () => builder.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + "builder-key", + new RefitSettings())) + .ThrowsExactly(); + await Assert.That( + () => builder.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + "builder-key", + (Func?)null)) + .ThrowsExactly(); + await Assert.That(() => builder.AddKeyedRefitClient("builder-key")) + .ThrowsExactly(); + await Assert.That(() => builder.AddKeyedRefitClient("builder-key", new RefitSettings())) + .ThrowsExactly(); + await Assert.That( + () => builder.AddKeyedRefitClient( + "builder-key", + (Func?)null)) + .ThrowsExactly(); + } + + /// Verifies the remaining overloads register Refit services on the existing builder. + /// A task that represents the asynchronous operation. + [Test] + [SuppressMessage("Usage", "CA2263:Prefer generic overload", Justification = "Test intentionally exercises the non-generic Type overload.")] + public async Task HttpClientBuilderOverloadMatrixRegistersServices() + { + var typeSettingsSerializer = new SystemTextJsonContentSerializer(new()); + var keyedTypeSettingsSerializer = new SystemTextJsonContentSerializer(new()); + var keyedGenericFactorySerializer = new SystemTextJsonContentSerializer(new()); + var services = new ServiceCollection(); + var builder = services.AddHttpClient("builder-matrix"); + + var typeBuilder = builder.AddRefitClient(typeof(IFooWithOtherAttribute)); + builder.AddRefitClient(static _ => new RefitSettings()); + builder.AddRefitClient( + typeof(IFooWithOtherAttribute), + new RefitSettings { ContentSerializer = typeSettingsSerializer }); + var keyedTypeBuilder = builder.AddKeyedRefitClient(typeof(IFooWithOtherAttribute), "type-none"); + builder.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + "type-settings", + new RefitSettings { ContentSerializer = keyedTypeSettingsSerializer }); + builder.AddKeyedRefitClient( + "generic-factory", + _ => new() { ContentSerializer = keyedGenericFactorySerializer }); + + await Assert.That(typeBuilder.Name).IsEqualTo("builder-matrix"); + await Assert.That(keyedTypeBuilder.Name).IsEqualTo("builder-matrix"); + + var serviceProvider = services.BuildServiceProvider(); + await Assert.That( + serviceProvider.GetRequiredService>() + .Settings! + .ContentSerializer).IsSameReferenceAs(typeSettingsSerializer); + await Assert.That( + serviceProvider.GetRequiredKeyedService>("type-settings") + .Settings! + .ContentSerializer).IsSameReferenceAs(keyedTypeSettingsSerializer); + await Assert.That( + serviceProvider.GetRequiredKeyedService>("generic-factory") + .Settings! + .ContentSerializer).IsSameReferenceAs(keyedGenericFactorySerializer); + await Assert.That( + serviceProvider.GetRequiredKeyedService>("type-none")) + .IsNotNull(); + } + /// Verifies the generic (IServiceCollection, RefitSettings) overload still exists for binary compatibility. /// A task that represents the asynchronous operation. [Test] @@ -247,7 +907,7 @@ public async Task ProvidedHttpClientIsUsedAsNamedClient() client.BaseAddress = baseUri; client.DefaultRequestHeaders.Add("X-Powered-By", Environment.OSVersion.VersionString); }); - services.AddRefitClient(settingsAction: null, "MyHttpClient"); + var refitBuilder = services.AddRefitClient(settingsAction: null, "MyHttpClient"); var sp = services.BuildServiceProvider(); var httpClientFactory = sp.GetRequiredService(); @@ -255,25 +915,159 @@ public async Task ProvidedHttpClientIsUsedAsNamedClient() var gitHubApi = sp.GetRequiredService(); - var memberInfos = typeof(Generated).GetMember("RefitTestsIGitHubApi", BindingFlags.NonPublic); - var genApi = Convert.ChangeType(gitHubApi, (Type)memberInfos[0], CultureInfo.InvariantCulture); - var genApiProperty = genApi.GetType().GetProperty("Client")!; - var genApiClient = (HttpClient)genApiProperty.GetValue(genApi)!; - - await Assert.That(genApiClient).IsNotSameReferenceAs(httpClient); - await Assert.That(genApiClient.BaseAddress).IsEqualTo(httpClient.BaseAddress); - await Assert.That(genApiClient.BaseAddress).IsEqualTo(baseUri); - await Assert.That(genApiClient.DefaultRequestHeaders).Contains( + await Assert.That(refitBuilder.Name).IsEqualTo("MyHttpClient"); + await Assert.That(gitHubApi).IsNotNull(); + await Assert.That(httpClient.BaseAddress).IsEqualTo(baseUri); + await Assert.That(httpClient.DefaultRequestHeaders).Contains( h => h.Key == "X-Powered-By" && h.Value.Contains(Environment.OSVersion.VersionString)); } + /// Verifies the shared core registration methods validate direct null inputs. + /// A task that represents the asynchronous operation. + [Test] + [SuppressMessage("Usage", "CA2263:Prefer generic overload", Justification = "Test intentionally exercises non-generic core overloads.")] + public async Task CoreRegistrationsRejectNullInputs() + { + var services = new ServiceCollection(); + + await Assert.That(() => InvokeAddRefitClientCore(null!, typeof(IFooWithOtherAttribute), null, null)) + .ThrowsExactly(); + await Assert.That(() => InvokeAddRefitClientCore(services, null!, null, null)) + .ThrowsExactly(); + await Assert.That(() => InvokeAddRefitClientCoreGeneric(null!, null, null)) + .ThrowsExactly(); + await Assert.That(() => InvokeAddKeyedRefitClientCore(null!, typeof(IFooWithOtherAttribute), "key", null, null)) + .ThrowsExactly(); + await Assert.That(() => InvokeAddKeyedRefitClientCore(services, null!, "key", null, null)) + .ThrowsExactly(); + await Assert.That(() => InvokeAddKeyedRefitClientCore(services, typeof(IFooWithOtherAttribute), null, null, null)) + .ThrowsExactly(); + await Assert.That(() => InvokeAddKeyedRefitClientCoreGeneric(null!, "key", null, null)) + .ThrowsExactly(); + await Assert.That(() => InvokeAddKeyedRefitClientCoreGeneric(services, null, null, null)) + .ThrowsExactly(); + } + + /// Verifies configured handler factories and authorization getters are composed for named clients. + /// A task that represents the asynchronous operation. + [Test] + public async Task NonKeyedRegistrationComposesConfiguredHandlerAndAuthorizationGetter() + { + var recordingHandler = new RecordingHandler(); + var services = new ServiceCollection(); + var builder = services.AddRefitClient( + new RefitSettings + { + HttpMessageHandlerFactory = () => recordingHandler, + AuthorizationHeaderValueGetter = static (_, _) => Task.FromResult("token") + }); + var serviceProvider = services.BuildServiceProvider(); + var client = serviceProvider.GetRequiredService().CreateClient(builder.Name); + client.DefaultRequestHeaders.Authorization = new("Bearer", "placeholder"); + + using var response = await client.GetAsync(new Uri("https://example.test")); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(recordingHandler.AuthorizationParameter).IsEqualTo("token"); + } + + /// Verifies keyed registrations compose primary and additional handlers from keyed settings. + /// A task that represents the asynchronous operation. + [Test] + public async Task KeyedRegistrationComposesConfiguredHandlerAndAuthorizationGetter() + { + var recordingHandler = new RecordingHandler(); + var services = new ServiceCollection(); + var builder = services.AddKeyedRefitClient( + "keyed-handler", + new RefitSettings + { + HttpMessageHandlerFactory = () => recordingHandler, + AuthorizationHeaderValueGetter = static (_, _) => Task.FromResult("keyed-token") + }); + var serviceProvider = services.BuildServiceProvider(); + var client = serviceProvider.GetRequiredService().CreateClient(builder.Name); + client.DefaultRequestHeaders.Authorization = new("Bearer", "placeholder"); + + using var response = await client.GetAsync(new Uri("https://example.test")); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(recordingHandler.AuthorizationParameter).IsEqualTo("keyed-token"); + } + + /// Invokes the non-generic shared AddRefitClientCore method. + /// The services argument. + /// The interface type argument. + /// The settings factory argument. + /// The client name argument. + private static void InvokeAddRefitClientCore( + IServiceCollection services, + Type refitInterfaceType, + Func? settings, + string? httpClientName) => + services.AddRefitClient(refitInterfaceType, settings, httpClientName); + + /// Invokes the generic shared AddRefitClientCore method. + /// The interface type. + /// The services argument. + /// The settings factory argument. + /// The client name argument. + [SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "Type parameter is intentionally specified explicitly by the test caller.")] + private static void InvokeAddRefitClientCoreGeneric( + IServiceCollection services, + Func? settings, + string? httpClientName) + where T : class => + services.AddRefitClient(settings, httpClientName); + + /// Invokes the non-generic shared AddKeyedRefitClientCore method. + /// The services argument. + /// The interface type argument. + /// The service key argument. + /// The settings factory argument. + /// The client name argument. + private static void InvokeAddKeyedRefitClientCore( + IServiceCollection services, + Type refitInterfaceType, + object? serviceKey, + Func? settings, + string? httpClientName) => + services.AddKeyedRefitClient(refitInterfaceType, serviceKey, settings, httpClientName); + + /// Invokes the generic shared AddKeyedRefitClientCore method. + /// The interface type. + /// The services argument. + /// The service key argument. + /// The settings factory argument. + /// The client name argument. + [SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "Type parameter is intentionally specified explicitly by the test caller.")] + private static void InvokeAddKeyedRefitClientCoreGeneric( + IServiceCollection services, + object? serviceKey, + Func? settings, + string? httpClientName) + where T : class => + services.AddKeyedRefitClient(serviceKey, settings, httpClientName); + /// Marker type used as a generic argument to verify unique client naming. - [SuppressMessage("Design", "SST1436:Add members to type or remove it", Justification = "Intentional empty fixture used only as a generic type argument to exercise client naming for Refit tests.")] + [SuppressMessage( + "RoslynCommonAnalyzers", + "SST1436:Add members to a type or remove it", + Justification = "Intentional empty marker fixture used as a generic argument for client naming tests.")] private sealed class User; /// Marker type used as a generic argument to verify unique client naming. - [SuppressMessage("Design", "SST1436:Add members to type or remove it", Justification = "Intentional empty fixture used only as a generic type argument to exercise client naming for Refit tests.")] + [SuppressMessage( + "RoslynCommonAnalyzers", + "SST1436:Add members to a type or remove it", + Justification = "Intentional empty marker fixture used as a generic argument for client naming tests.")] private sealed class Role; /// Options carrying a content serializer used to verify settings injection. @@ -282,4 +1076,30 @@ private sealed class ClientOptions /// Gets or sets the content serializer injected into the Refit settings. public SystemTextJsonContentSerializer? Serializer { get; set; } } + + /// HTTP handler that records the final authorization header it receives. + private sealed class RecordingHandler : HttpMessageHandler + { + /// Gets the authorization parameter observed by the handler. + public string? AuthorizationParameter { get; private set; } + + /// Gets the request URI observed by the handler. + public Uri? RequestUri { get; private set; } + + /// Gets the powered-by header observed by the handler. + public string? PoweredByHeader { get; private set; } + + /// + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + AuthorizationParameter = request.Headers.Authorization?.Parameter; + RequestUri = request.RequestUri; + PoweredByHeader = request.Headers.TryGetValues("X-Powered-By", out var values) + ? values.FirstOrDefault() + : null; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); + } + } } diff --git a/src/tests/Refit.Tests/IAmARefitInterfaceButNobodyUsesMe.cs b/src/tests/Refit.Tests/IAmARefitInterfaceButNobodyUsesMe.cs index d23108db7..ddfa2684c 100644 --- a/src/tests/Refit.Tests/IAmARefitInterfaceButNobodyUsesMe.cs +++ b/src/tests/Refit.Tests/IAmARefitInterfaceButNobodyUsesMe.cs @@ -13,7 +13,7 @@ public interface IAmARefitInterfaceButNobodyUsesMe /// A GET method declared with the fully qualified attribute name. /// A task that represents the asynchronous operation. - [Refit.GetAttribute("something-else")] + [GetAttribute("something-else")] Task AnotherRefitMethod(); /// A GET method whose route comes from a referenced constant. diff --git a/src/tests/Refit.Tests/IGenericMethodKeyFixture.cs b/src/tests/Refit.Tests/IGenericMethodKeyFixture.cs new file mode 100644 index 000000000..dd4995dfa --- /dev/null +++ b/src/tests/Refit.Tests/IGenericMethodKeyFixture.cs @@ -0,0 +1,23 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Refit.Tests; + +/// API surface with public generic methods used for method-key equality tests. +public interface IGenericMethodKeyFixture +{ + /// Generic method fixture used to obtain an open method definition. + /// The first generic argument. + /// The second generic argument. + /// The first value. + /// The second value. + void GenericMethod(T1 first, T2 second); + + /// Second generic method fixture used to verify method mismatches. + /// The first generic argument. + /// The second generic argument. + /// The first value. + /// The second value. + void OtherGenericMethod(T1 first, T2 second); +} diff --git a/src/tests/Refit.Tests/IHaveDims.cs b/src/tests/Refit.Tests/IHaveDims.cs index d46fe7b57..405d15cf4 100644 --- a/src/tests/Refit.Tests/IHaveDims.cs +++ b/src/tests/Refit.Tests/IHaveDims.cs @@ -14,17 +14,11 @@ public interface IHaveDims /// Returns a constant identifying name via a static interface method. /// The name of the interface. [SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Fixture intentionally exercises a static interface method to verify default interface member support.")] - static string GetStatic() - { - return nameof(IHaveDims); - } + static string GetStatic() => nameof(IHaveDims); /// Performs a request through a default interface method. /// The response body text. - Task GetDim() - { - return GetPrivate(); - } + Task GetDim() => GetPrivate(); #endif /// Performs a GET request through an internal interface member. @@ -36,9 +30,6 @@ Task GetDim() #if NETCOREAPP3_1_OR_GREATER /// Performs a request through a private interface method. /// The response body text. - private Task GetPrivate() - { - return GetInternal(); - } + private Task GetPrivate() => GetInternal(); #endif } diff --git a/src/tests/Refit.Tests/IMissingHttpMethodApi.cs b/src/tests/Refit.Tests/IMissingHttpMethodApi.cs new file mode 100644 index 000000000..1645764f1 --- /dev/null +++ b/src/tests/Refit.Tests/IMissingHttpMethodApi.cs @@ -0,0 +1,12 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Refit.Tests; + +/// API surface with a method missing a Refit HTTP method attribute. +public interface IMissingHttpMethodApi +{ + /// Method fixture with no Refit HTTP method attribute. + void MethodWithoutHttpMethod(); +} diff --git a/src/tests/Refit.Tests/IOverloadedApi.cs b/src/tests/Refit.Tests/IOverloadedApi.cs new file mode 100644 index 000000000..5da5612ce --- /dev/null +++ b/src/tests/Refit.Tests/IOverloadedApi.cs @@ -0,0 +1,20 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Refit.Tests; + +/// API surface with overloaded methods used to verify overload diagnostics. +public interface IOverloadedApi +{ + /// Gets the default overloaded endpoint. + /// A task that represents the asynchronous operation. + [Get("/overloaded")] + Task Overloaded(); + + /// Gets the overloaded endpoint for an id. + /// The id path value. + /// A task that represents the asynchronous operation. + [Get("/overloaded/{id}")] + Task Overloaded(int id); +} diff --git a/src/tests/Refit.Tests/IRestMethodInfoTests.cs b/src/tests/Refit.Tests/IRestMethodInfoTests.cs index 1c01a96e2..e122f1d4a 100644 --- a/src/tests/Refit.Tests/IRestMethodInfoTests.cs +++ b/src/tests/Refit.Tests/IRestMethodInfoTests.cs @@ -10,6 +10,30 @@ namespace Refit.Tests; [Headers("User-Agent: RefitTestClient", "Api-Version: 1")] public interface IRestMethodInfoTests { + /// Defines a GET route with an invalid CR/LF path. + /// A task that represents the asynchronous operation. + [Get("/foo\nbar")] + Task NewlinePath(); + + /// Defines a route using a readable property from a complex parameter. + /// The complex route parameter. + /// A task that represents the asynchronous operation. + [Get("/foo/{route.Visible}")] + Task ObjectRouteWithUnreadableProperty(RouteObjectWithUnreadableProperty route); + + /// Defines a route that binds a complex parameter directly and through a property. + /// The conflicting complex route parameter. + /// A task that represents the asynchronous operation. + [Get("/foo/{route}/{route.Visible}")] + Task ConflictingObjectRoute(RouteObjectWithUnreadableProperty route); + + /// Defines a route with duplicate authorize parameters. + /// The first authorization value. + /// The second authorization value. + /// A task that represents the asynchronous operation. + [Get("/foo")] + Task DuplicateAuthorize([Authorize] string first, [Authorize] string second); + /// Defines a GET route with a deliberately malformed path to test error handling. /// A task that returns the response body. [Get("@)!@_!($_!@($\\\\|||::::")] diff --git a/src/tests/Refit.Tests/IStreamApi.cs b/src/tests/Refit.Tests/IStreamApi.cs index 06fcd7843..97e723d4a 100644 --- a/src/tests/Refit.Tests/IStreamApi.cs +++ b/src/tests/Refit.Tests/IStreamApi.cs @@ -10,6 +10,12 @@ namespace Refit.Tests; /// A Refit interface returning responses. public interface IStreamApi { + /// Posts a stream as the request body. + /// The stream to send. + /// A task that represents the asynchronous operation. + [Post("/stream")] + Task PostStream([Body] Stream stream); + /// Gets a remote file as a stream. /// The name of the file to retrieve. /// The file content stream. diff --git a/src/tests/Refit.Tests/ModelNamespace/SomeType.cs b/src/tests/Refit.Tests/ModelNamespace/SomeType.cs index 87603ae26..218a29421 100644 --- a/src/tests/Refit.Tests/ModelNamespace/SomeType.cs +++ b/src/tests/Refit.Tests/ModelNamespace/SomeType.cs @@ -1,10 +1,11 @@ // Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; - namespace Refit.Tests.ModelNamespace; /// Empty response type used to verify Refit handling of reduced usings inside a namespace. -[SuppressMessage("Design", "SST1436:Add members to type or remove it", Justification = "Intentional empty fixture response type used to verify Refit reduced-using handling.")] +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "RoslynCommonAnalyzers", + "SST1436:Add members to a type or remove it", + Justification = "Intentional empty response fixture used to verify reduced using handling inside a namespace.")] public class SomeType; diff --git a/src/tests/Refit.Tests/ModuleInitializer.cs b/src/tests/Refit.Tests/ModuleInitializer.cs deleted file mode 100644 index 0c5eae41d..000000000 --- a/src/tests/Refit.Tests/ModuleInitializer.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. -// ReactiveUI and Contributors licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using VerifyTests.DiffPlex; - -namespace Refit.Tests; - -/// Initializes Verify settings for the test assembly at module load. -internal static class ModuleInitializer -{ - /// Configures Verify snapshot paths and source generator support. - [ModuleInitializer] - [SuppressMessage("Usage", "CA2255:The 'ModuleInitializer' attribute should not be used in libraries", Justification = "Required to initialize Verify settings for the test assembly.")] - public static void Init() - { - DerivePathInfo((file, _, type, method) => new(Path.Combine(Path.GetDirectoryName(file) ?? string.Empty, "_snapshots"), type.Name, method.Name)); - - VerifySourceGenerators.Initialize(); - VerifyDiffPlex.Initialize(OutputType.Compact); - } -} diff --git a/src/tests/Refit.Tests/MultipartTests.cs b/src/tests/Refit.Tests/MultipartTests.cs index 1d3e7244c..8cd6be12f 100644 --- a/src/tests/Refit.Tests/MultipartTests.cs +++ b/src/tests/Refit.Tests/MultipartTests.cs @@ -9,6 +9,7 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -179,8 +180,8 @@ public async Task MultipartUploadShouldWorkWithFileInfo() var fixture = RestService.For(BaseAddress, settings); await fixture.UploadFileInfo( - [new FileInfo(fileName), new FileInfo(fileName)], - new FileInfo(fileName)); + [new(fileName), new(fileName)], + new(fileName)); } finally { @@ -297,7 +298,7 @@ public async Task MultipartUploadShouldWorkWithStreamPart() await using var stream = GetTestFileStream("Test Files/Test.pdf"); var fixture = RestService.For(BaseAddress, settings); await fixture.UploadStreamPart( - new StreamPart(stream, "test-streampart.pdf", "application/pdf")); + new(stream, "test-streampart.pdf", "application/pdf")); } /// Verifies a stream part with a named multipart uses the supplied name. @@ -328,7 +329,7 @@ public async Task MultipartUploadShouldWorkWithStreamPartWithNamedMultipart() await using var stream = GetTestFileStream("Test Files/Test.pdf"); var fixture = RestService.For(BaseAddress, settings); await fixture.UploadStreamPart( - new StreamPart(stream, "test-streampart.pdf", "application/pdf", "test-stream")); + new(stream, "test-streampart.pdf", "application/pdf", "test-stream")); } /// Verifies a stream part can be combined with query parameters. @@ -360,8 +361,8 @@ public async Task MultipartUploadShouldWorkWithStreamPartAndQuery() await using var stream = GetTestFileStream("Test Files/Test.pdf"); var fixture = RestService.For(BaseAddress, settings); await fixture.UploadStreamPart( - new ModelObject { Property1 = "test", Property2 = "test2" }, - new StreamPart(stream, "test-streampart.pdf", "application/pdf")); + new() { Property1 = "test", Property2 = "test2" }, + new(stream, "test-streampart.pdf", "application/pdf")); } /// Verifies a byte array part keeps its supplied alias, file name and content type. @@ -397,7 +398,7 @@ await Assert var fixture = RestService.For(BaseAddress, settings); await fixture.UploadBytesPart( - new ByteArrayPart(bytes, "test-bytearraypart.pdf", "application/pdf")); + new(bytes, "test-bytearraypart.pdf", "application/pdf")); } /// Verifies a collection of file parts plus an extra part keep their names and content types. @@ -459,17 +460,17 @@ await Assert var fixture = RestService.For(BaseAddress, settings); await fixture.UploadFileInfoPart( [ - new FileInfoPart( - new FileInfo(fileName), + new( + new(fileName), "test-fileinfopart.pdf", "application/pdf"), - new FileInfoPart( - new FileInfo(fileName), + new( + new(fileName), "test-fileinfopart2.pdf", contentType: null) ], - new FileInfoPart( - new FileInfo(fileName), + new( + new(fileName), fileName: "additionalfile.pdf", contentType: "application/pdf")); } @@ -528,6 +529,28 @@ public async Task MultipartUploadShouldWorkWithAnObject( await fixture.UploadJsonObject(model1); } + /// Verifies multipart object serialization failures are wrapped with a descriptive argument exception. + /// A task representing the asynchronous test. + [Test] + public async Task MultipartUploadWithUnserializableObjectThrowsArgumentException() + { + var fixture = RestService.For( + BaseAddress, + new() + { + ContentSerializer = new ThrowingContentSerializer() + }); + + Task UploadJsonObject() => fixture.UploadJsonObject(new ModelObject()); + + var exception = await Assert + .That(UploadJsonObject) + .ThrowsExactly(); + + await Assert.That(exception!.Message).Contains("Unexpected parameter type", StringComparison.Ordinal); + await Assert.That(exception.InnerException).IsTypeOf(); + } + /// Verifies multiple objects are serialized to separate multipart parts by each serializer. /// The serializer type to exercise. /// The expected media type produced by the serializer. @@ -680,7 +703,7 @@ public async Task MultipartUploadShouldWorkWithMixedTypes() await fixture.UploadMixedObjects( [model1, model2], anotherModel, - new FileInfo(fileName), + new(fileName), AnEnum.Val2, "frob", 42); @@ -697,7 +720,7 @@ await fixture.UploadMixedObjects( public async Task MultipartUploadShouldWorkWithHttpContent() { var httpContent = new StringContent("some text", Encoding.ASCII, "application/custom"); - httpContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment") + httpContent.Headers.ContentDisposition = new("attachment") { Name = "myName", FileName = "myFileName", @@ -728,22 +751,18 @@ public async Task MultipartUploadShouldWorkWithHttpContent() /// Verifies the constructor rejects a null file name. /// A task representing the asynchronous test. [Test] - public async Task MultiPartConstructorShouldThrowArgumentNullExceptionWhenNoFileName() - { + public async Task MultiPartConstructorShouldThrowArgumentNullExceptionWhenNoFileName() => await Assert .That(() => _ = new ByteArrayPart([], null!, "application/pdf")) .ThrowsExactly(); - } /// Verifies the constructor rejects a null file info. /// A task representing the asynchronous test. [Test] - public async Task FileInfoPartConstructorShouldThrowArgumentNullExceptionWhenNoFileInfo() - { + public async Task FileInfoPartConstructorShouldThrowArgumentNullExceptionWhenNoFileInfo() => await Assert .That(() => _ = new FileInfoPart(null!, "file.pdf", "application/pdf")) .ThrowsExactly(); - } /// Loads an embedded test resource as a stream. /// The relative path of the embedded resource. @@ -838,7 +857,29 @@ protected override async Task SendAsync( await Asserts!(content!); - return new HttpResponseMessage(HttpStatusCode.OK); + return new(HttpStatusCode.OK); } } + + /// An that rejects all serialization calls. + private sealed class ThrowingContentSerializer : IHttpContentSerializer + { + /// + public HttpContent ToHttpContent(T item) => + throw new InvalidOperationException("serialization failed"); + + /// + [SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "The method implements Refit's published serializer interface.")] + public Task FromHttpContentAsync( + HttpContent content, + CancellationToken cancellationToken = default) => + Task.FromResult(default(T)); + + /// + public string? GetFieldNameForProperty(PropertyInfo propertyInfo) => + null; + } } diff --git a/src/tests/Refit.Tests/MyComplexQueryParams.cs b/src/tests/Refit.Tests/MyComplexQueryParams.cs index 14bbe2bdf..bacda37a0 100644 --- a/src/tests/Refit.Tests/MyComplexQueryParams.cs +++ b/src/tests/Refit.Tests/MyComplexQueryParams.cs @@ -14,9 +14,9 @@ public class MyComplexQueryParams /// Gets or sets the last name query value. public required string LastName { get; init; } - /// Gets the nested address query object, aliased to Addr. + /// Gets or initializes the nested address query object, aliased to Addr. [AliasAs("Addr")] - public Address Address { get; } = new Address(); + public Address Address { get; init; } = new(); /// Gets the arbitrary metadata expanded into prefixed query values. public Dictionary MetaData { get; } = []; diff --git a/src/tests/Refit.Tests/NamespaceCollisionApi.cs b/src/tests/Refit.Tests/NamespaceCollisionApi.cs index b6ea75055..bfa8d840c 100644 --- a/src/tests/Refit.Tests/NamespaceCollisionApi.cs +++ b/src/tests/Refit.Tests/NamespaceCollisionApi.cs @@ -12,8 +12,5 @@ public static class NamespaceCollisionApi { /// Creates a Refit implementation of . /// A Refit-backed instance. - public static INamespaceCollisionApi Create() - { - return RestService.For("http://somewhere.com"); - } + public static INamespaceCollisionApi Create() => RestService.For("http://somewhere.com"); } diff --git a/src/tests/Refit.Tests/NamespaceOverlapApi.cs b/src/tests/Refit.Tests/NamespaceOverlapApi.cs index 3208d6f58..1fe17613f 100644 --- a/src/tests/Refit.Tests/NamespaceOverlapApi.cs +++ b/src/tests/Refit.Tests/NamespaceOverlapApi.cs @@ -13,8 +13,5 @@ public static class NamespaceOverlapApi { /// Creates a Refit client for . /// A configured instance. - public static INamespaceOverlapApi Create() - { - return RestService.For("http://somewhere.com"); - } + public static INamespaceOverlapApi Create() => RestService.For("http://somewhere.com"); } diff --git a/src/tests/Refit.Tests/PooledBufferWriterTests.cs b/src/tests/Refit.Tests/PooledBufferWriterTests.cs new file mode 100644 index 000000000..99ecb008d --- /dev/null +++ b/src/tests/Refit.Tests/PooledBufferWriterTests.cs @@ -0,0 +1,235 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. +using System.Diagnostics.CodeAnalysis; +using Refit.Buffers; + +namespace Refit.Tests; + +/// Tests for and its detached stream. +[SuppressMessage("Performance", "CA1849:Call async methods when in an async method", Justification = "These tests intentionally exercise the synchronous Stream overrides.")] +[SuppressMessage("Major Code Smell", "S6966:Awaitable method should be used", Justification = "These tests intentionally exercise the synchronous Stream overrides.")] +public class PooledBufferWriterTests +{ + /// Verifies written bytes are preserved when the writer grows beyond its initial rented buffer. + /// A task representing the asynchronous test. + [Test] + public async Task GetMemoryGrowsAndPreservesWrittenBytes() + { + using var writer = new PooledBufferWriter(); + var first = writer.GetSpan(PooledBufferWriter.DefaultSize); + first[0] = 1; + first[PooledBufferWriter.DefaultSize - 1] = 2; + writer.Advance(PooledBufferWriter.DefaultSize); + + var second = writer.GetSpan(1); + second[0] = 3; + writer.Advance(1); + + await using var stream = writer.DetachStream(); + var buffer = new byte[PooledBufferWriter.DefaultSize + 1]; + + var read = stream.Read(buffer, 0, buffer.Length); + + await Assert.That(read).IsEqualTo(buffer.Length); + await Assert.That(buffer[0]).IsEqualTo((byte)1); + await Assert.That(buffer[PooledBufferWriter.DefaultSize - 1]).IsEqualTo((byte)2); + await Assert.That(buffer[PooledBufferWriter.DefaultSize]).IsEqualTo((byte)3); + } + + /// Verifies zero-sized span and memory requests still reserve at least one byte. + /// A task representing the asynchronous test. + [Test] + public async Task GetMemoryAndSpanWithZeroSizeHintReturnWritableBuffers() + { + var (memoryLength, spanLength) = GetZeroSizeHintBufferLengths(); + + await Assert.That(memoryLength).IsGreaterThanOrEqualTo(1); + await Assert.That(spanLength).IsGreaterThanOrEqualTo(1); + } + + /// Verifies invalid advances throw the expected argument exceptions. + /// A task representing the asynchronous test. + [Test] + public async Task AdvanceValidatesCount() + { + using var writer = new PooledBufferWriter(); + + await Assert.That(() => writer.Advance(-1)).ThrowsExactly(); + await Assert.That(() => writer.Advance(PooledBufferWriter.DefaultSize + 1)) + .ThrowsExactly(); + await Assert.That(() => writer.GetMemory(-1)).ThrowsExactly(); + await Assert.That(() => writer.GetSpan(-1)).ThrowsExactly(); + } + + /// Verifies a detached stream reads only the bytes advanced into the writer. + /// A task representing the asynchronous test. + [Test] + public async Task DetachedStreamReadByteStopsAtUsedLength() + { + using var writer = new PooledBufferWriter(); + var span = writer.GetSpan(2); + span[0] = 10; + span[1] = 20; + writer.Advance(2); + + await using var stream = writer.DetachStream(); + + await Assert.That(stream.ReadByte()).IsEqualTo(10); + await Assert.That(stream.ReadByte()).IsEqualTo(20); + await Assert.That(stream.ReadByte()).IsEqualTo(-1); + } + + /// Verifies the detached stream validates read ranges before copying. + /// A task representing the asynchronous test. + [Test] + public async Task DetachedStreamReadValidatesArguments() + { + using var writer = CreateWriter(1, 2, 3); + await using var stream = writer.DetachStream(); + var buffer = new byte[2]; + + await Assert.That(() => stream.Read(buffer, -1, 1)).ThrowsExactly(); + await Assert.That(() => stream.Read(buffer, 0, -1)).ThrowsExactly(); + await Assert.That(() => stream.Read(buffer, 1, 2)).ThrowsExactly(); + } + + /// Verifies detached stream metadata and partial reads. + /// A task representing the asynchronous test. + [Test] + [SuppressMessage("Performance", "CA1835:Prefer the memory-based overloads", Justification = "This test intentionally covers the byte-array Stream override.")] + public async Task DetachedStreamReportsLengthPositionAndPartialReads() + { + using var writer = CreateWriter(1, 2, 3); + await using var stream = writer.DetachStream(); + var buffer = new byte[2]; + + stream.Flush(); + var firstRead = stream.Read(buffer, 0, buffer.Length); + var secondRead = await stream.ReadAsync(buffer, 0, buffer.Length); + var thirdRead = await stream.ReadAsync(buffer, 0, buffer.Length); + await stream.FlushAsync(); + + await Assert.That(stream.Length).IsEqualTo(3); + await Assert.That(firstRead).IsEqualTo(2); + await Assert.That(secondRead).IsEqualTo(1); + await Assert.That(thirdRead).IsEqualTo(0); + await Assert.That(stream.Position).IsEqualTo(3); + await Assert.That(buffer[0]).IsEqualTo((byte)3); + } + + /// Verifies unsupported stream operations throw . + /// A task representing the asynchronous test. + [Test] + public async Task DetachedStreamUnsupportedOperationsThrow() + { + using var writer = CreateWriter(1, 2, 3); + await using var stream = writer.DetachStream(); + + await Assert.That(stream.CanRead).IsTrue(); + await Assert.That(stream.CanSeek).IsFalse(); + await Assert.That(stream.CanWrite).IsFalse(); + await Assert.That(() => stream.Position = 1).ThrowsExactly(); + await Assert.That(() => stream.Seek(0, SeekOrigin.Begin)).ThrowsExactly(); + await Assert.That(() => stream.SetLength(0)).ThrowsExactly(); + await Assert.That(() => stream.Write([1], 0, 1)).ThrowsExactly(); + } + + /// Verifies detached stream async methods observe cancellation without reading. + /// A task representing the asynchronous test. + [Test] + public async Task DetachedStreamAsyncMethodsHonorCancellation() + { + using var writer = CreateWriter(1, 2, 3); + await using var stream = writer.DetachStream(); + using var cancellationTokenSource = new CancellationTokenSource(); + await cancellationTokenSource.CancelAsync(); + + await Assert.That(() => stream.FlushAsync(cancellationTokenSource.Token)) + .ThrowsExactly(); + await Assert.That(() => stream.ReadAsync(new byte[3], 0, 3, cancellationTokenSource.Token)) + .ThrowsExactly(); + await Assert.That(() => stream.CopyToAsync(Stream.Null, 81_920, cancellationTokenSource.Token)) + .ThrowsExactly(); + } + + /// Verifies a disposed detached stream rejects further reads. + /// A task representing the asynchronous test. + [Test] + public async Task DetachedStreamThrowsAfterDispose() + { + using var writer = CreateWriter(1); + var stream = writer.DetachStream(); + await stream.DisposeAsync(); + + await Assert.That(stream.ReadByte).ThrowsExactly(); + await Assert.That(() => stream.Read(new byte[1], 0, 1)).ThrowsExactly(); + } + + /// Verifies disposed detached stream async paths surface object-disposed failures. + /// A task representing the asynchronous test. + [Test] + public async Task DetachedStreamAsyncMethodsThrowAfterDispose() + { + using var writer = CreateWriter(1); + var stream = writer.DetachStream(); + await stream.DisposeAsync(); + + await Assert.That(() => stream.CopyToAsync(Stream.Null)).ThrowsExactly(); + await Assert.That(() => stream.ReadAsync(new byte[1], 0, 1)) + .ThrowsExactly(); +#if NET6_0_OR_GREATER + await Assert.That(() => stream.ReadAsync(new byte[1].AsMemory()).AsTask()) + .ThrowsExactly(); +#endif + } + +#if NET6_0_OR_GREATER + /// Verifies span-based detached stream reads consume only available bytes. + /// A task representing the asynchronous test. + [Test] + public async Task DetachedStreamSpanReadStopsAtLength() + { + using var writer = CreateWriter(1, 2, 3); + await using var stream = writer.DetachStream(); + var buffer = new byte[4]; + + var firstRead = stream.Read(buffer.AsSpan(0, 2)); + var secondRead = await stream.ReadAsync(buffer.AsMemory(2, 2)); + var thirdRead = stream.Read(buffer); + + await Assert.That(firstRead).IsEqualTo(2); + await Assert.That(secondRead).IsEqualTo(1); + await Assert.That(thirdRead).IsEqualTo(0); + await Assert.That(buffer[0]).IsEqualTo((byte)1); + await Assert.That(buffer[1]).IsEqualTo((byte)2); + await Assert.That(buffer[2]).IsEqualTo((byte)3); + await Assert.That(buffer[3]).IsEqualTo((byte)0); + } +#endif + + /// Creates a writer containing the provided bytes. + /// The bytes to write. + /// A writer advanced by the number of provided bytes. + private static PooledBufferWriter CreateWriter(params byte[] values) + { + var writer = new PooledBufferWriter(); + values.CopyTo(writer.GetSpan(values.Length)); + writer.Advance(values.Length); + return writer; + } + + /// Gets buffer lengths from zero-size requests without carrying spans across async state-machine boundaries. + /// The memory and span lengths. + private static (int MemoryLength, int SpanLength) GetZeroSizeHintBufferLengths() + { + using var writer = new PooledBufferWriter(); + + var memory = writer.GetMemory(); + memory.Span[0] = 1; + var span = writer.GetSpan(); + span[0] = 2; + + return (memory.Length, span.Length); + } +} diff --git a/src/tests/Refit.Tests/Refit.Tests.csproj b/src/tests/Refit.Tests/Refit.Tests.csproj index 6aee6095c..d765bca34 100644 --- a/src/tests/Refit.Tests/Refit.Tests.csproj +++ b/src/tests/Refit.Tests/Refit.Tests.csproj @@ -1,4 +1,4 @@ - + Exe @@ -18,7 +18,7 @@ - @@ -33,27 +33,11 @@ - - - - - - + - - - - %(RecursiveDir)\resources\%(Filename)%(Extension) - Always - - - - - - diff --git a/src/tests/Refit.Tests/ReflectionTests.cs b/src/tests/Refit.Tests/ReflectionTests.cs index 58d11b419..4aeec1abd 100644 --- a/src/tests/Refit.Tests/ReflectionTests.cs +++ b/src/tests/Refit.Tests/ReflectionTests.cs @@ -82,7 +82,7 @@ public async Task PropertyParameterShouldBeExpectedReflection() }; var service = RestService.For("https://foo", settings); - await service.GetPropertyParam(new MyParams("propVal")); + await service.GetPropertyParam(new("propVal")); await formatter.AssertNoOutstandingAssertions(); } @@ -119,7 +119,7 @@ public async Task QueryParameterShouldBeExpectedReflection() _mockHandler .Expect(HttpMethod.Get, "https://foo/") .WithExactQueryString( - [new KeyValuePair("queryKey", "queryValue")]) + [new("queryKey", "queryValue")]) .Respond("application/json", nameof(IBasicApi.GetQuery)); var methodInfo = typeof(IBasicApi).GetMethod(nameof(IBasicApi.GetQuery))!; @@ -144,7 +144,7 @@ public async Task QueryPropertyParameterShouldBeExpectedReflection() { _mockHandler .Expect(HttpMethod.Get, "https://foo/") - .WithExactQueryString([new KeyValuePair("Value", "queryVal")]) + .WithExactQueryString([new("Value", "queryVal")]) .Respond("application/json", nameof(IBasicApi.GetPropertyQuery)); var methodInfo = typeof(IBasicApi).GetMethod(nameof(IBasicApi.GetPropertyQuery))!; @@ -158,7 +158,7 @@ public async Task QueryPropertyParameterShouldBeExpectedReflection() }; var service = RestService.For("https://foo", settings); - await service.GetPropertyQuery(new BaseRecord("queryVal")); + await service.GetPropertyQuery(new("queryVal")); await formatter.AssertNoOutstandingAssertions(); } @@ -171,8 +171,8 @@ public async Task DerivedQueryPropertyParameterShouldBeExpectedReflection() .Expect(HttpMethod.Get, "https://foo/") .WithExactQueryString( [ - new KeyValuePair("Name", "queryName"), - new KeyValuePair("Value", "value"), + new("Name", "queryName"), + new("Value", "value"), ]) .Respond("application/json", nameof(IBasicApi.GetPropertyQuery)); @@ -201,7 +201,7 @@ public async Task GenericQueryParameterShouldBeExpectedReflection() _mockHandler .Expect(HttpMethod.Get, "https://foo/") .WithExactQueryString( - [new KeyValuePair("queryKey", "queryValue")]) + [new("queryKey", "queryValue")]) .Respond("application/json", nameof(IBasicApi.GetGenericQuery)); var methodInfo = typeof(IBasicApi).GetMethod(nameof(IBasicApi.GetGenericQuery))!; @@ -227,7 +227,7 @@ public async Task EnumerableQueryParameterShouldBeExpectedReflection() { _mockHandler .Expect(HttpMethod.Get, "https://foo/") - .WithExactQueryString([new KeyValuePair("enums", "k0,k1")]) + .WithExactQueryString([new("enums", "k0,k1")]) .Respond("application/json", nameof(IBasicApi.GetEnumerableQuery)); var methodInfo = typeof(IBasicApi).GetMethod(nameof(IBasicApi.GetEnumerableQuery))!; @@ -254,7 +254,7 @@ public async Task EnumerablePropertyQueryParameterShouldBeExpectedReflection() { _mockHandler .Expect(HttpMethod.Get, "https://foo/") - .WithExactQueryString([new KeyValuePair("Enumerable", "0,1")]) + .WithExactQueryString([new("Enumerable", "0,1")]) .Respond("application/json", nameof(IBasicApi.GetEnumerablePropertyQuery)); var methodInfo = typeof(IBasicApi).GetMethod(nameof(IBasicApi.GetEnumerablePropertyQuery))!; @@ -271,7 +271,7 @@ public async Task EnumerablePropertyQueryParameterShouldBeExpectedReflection() }; var service = RestService.For("https://foo", settings); - await service.GetEnumerablePropertyQuery(new MyEnumerableParams([0, 1])); + await service.GetEnumerablePropertyQuery(new([0, 1])); await formatter.AssertNoOutstandingAssertions(); } @@ -284,8 +284,8 @@ public async Task QueryDictionaryParameterShouldBeExpectedReflection() .Expect(HttpMethod.Get, "https://foo/") .WithExactQueryString( [ - new KeyValuePair("key0", "1"), - new KeyValuePair("key1", "2"), + new("key0", "1"), + new("key1", "2"), ]) .Respond("application/json", nameof(IBasicApi.GetDictionaryQuery)); diff --git a/src/tests/Refit.Tests/RequestBuilderTestExtensions.cs b/src/tests/Refit.Tests/RequestBuilderTestExtensions.cs index 07a98f3c1..0ee04609e 100644 --- a/src/tests/Refit.Tests/RequestBuilderTestExtensions.cs +++ b/src/tests/Refit.Tests/RequestBuilderTestExtensions.cs @@ -31,7 +31,7 @@ public Func BuildRequestFactoryForMethod( return paramList => { var task = (Task)factory( - new HttpClient(testHttpMessageHandler) { BaseAddress = new Uri(baseAddress) }, + new(testHttpMessageHandler) { BaseAddress = new(baseAddress) }, paramList)!; task.Wait(); return testHttpMessageHandler.RequestMessage!; @@ -60,7 +60,7 @@ public Func RunRequest( return paramList => { var task = (Task)factory( - new HttpClient(testHttpMessageHandler) { BaseAddress = new Uri(baseAddress) }, + new(testHttpMessageHandler) { BaseAddress = new(baseAddress) }, paramList)!; try { diff --git a/src/tests/Refit.Tests/RequestBuilderTests.Dictionaries.cs b/src/tests/Refit.Tests/RequestBuilderTests.Dictionaries.cs index 44bac3977..81c51664c 100644 --- a/src/tests/Refit.Tests/RequestBuilderTests.Dictionaries.cs +++ b/src/tests/Refit.Tests/RequestBuilderTests.Dictionaries.cs @@ -21,7 +21,7 @@ public async Task QueryStringWithArrayCanBeFormattedByAttribute() var factory = fixture.BuildRequestFactoryForMethod("UnescapedQueryParams"); var output = factory(["Select+Id,Name+From+Account"]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/query?q=Select+Id,Name+From+Account"); } @@ -35,7 +35,7 @@ public async Task QueryStringWithArrayCanBeFormattedByAttributeWithMultiple() var factory = fixture.BuildRequestFactoryForMethod("UnescapedQueryParamsWithFilter"); var output = factory(["Select+Id+From+Account", "*"]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/query?q=Select+Id+From+Account&filter=*"); } @@ -45,7 +45,7 @@ public async Task QueryStringWithArrayCanBeFormattedByAttributeWithMultiple() public async Task QueryStringWithArrayCanBeFormattedByDefaultSetting() { var fixture = new RequestBuilderImplementation( - new RefitSettings { CollectionFormat = CollectionFormat.Multi }); + new() { CollectionFormat = CollectionFormat.Multi }); var factory = fixture.BuildRequestFactoryForMethod("QueryWithArray"); var output = factory([_intArray123]); @@ -59,7 +59,7 @@ public async Task QueryStringWithArrayCanBeFormattedByDefaultSetting() public async Task DefaultCollectionFormatCanBeOverridenByQueryAttribute() { var fixture = new RequestBuilderImplementation( - new RefitSettings { CollectionFormat = CollectionFormat.Multi }); + new() { CollectionFormat = CollectionFormat.Multi }); var factory = fixture.BuildRequestFactoryForMethod("QueryWithArrayFormattedAsCsv"); var output = factory([_intArray123]); @@ -78,7 +78,7 @@ public async Task RequestWithParameterInMultiplePlaces() nameof(IDummyHttpApi.FetchSomeStuffWithTheSameId)); var output = factory(["theId"]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); var builder = new UriBuilder(uri); var qs = QueryHelpers.ParseQuery(uri.Query); @@ -98,7 +98,7 @@ public async Task RequestWithParameterInAQueryParameterMultipleTimes() nameof(IDummyHttpApi.FetchSomeStuffWithTheIdInAParameterMultipleTimes)); var output = factory(["theId"]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar?param=first%20theId%20and%20second%20theId"); } @@ -119,7 +119,7 @@ public async Task QueryStringWithArrayFormatted(string apiMethodName, string exp var factory = fixture.BuildRequestFactoryForMethod(apiMethodName); var output = factory([_intArray123]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo(expectedQuery); } @@ -137,7 +137,7 @@ public async Task QueryStringWithArrayFormattedAsSsvAndItemsFormattedIndividuall var factory = fixture.BuildRequestFactoryForMethod("QueryWithArrayFormattedAsSsv"); var output = factory([_intArray123]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/query?numbers=custom-parameter%20custom-parameter%20custom-parameter"); } @@ -158,7 +158,7 @@ public async Task QueryStringWithEnumerablesCanBeFormattedEnumerable() var output = factory([list]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/query?numbers=1%2C2%2C3"); } @@ -184,7 +184,7 @@ public async Task QueryStringWithEnumerableFormatted(string apiMethodName, strin var output = factory([lines]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo(expectedQuery); } @@ -201,7 +201,7 @@ public async Task QueryStringExcludesPropertiesWithPrivateGetters() var output = factory([person]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/query?FullName=Mickey%20Mouse"); } @@ -221,7 +221,7 @@ public async Task QueryStringUsesEnumMemberAttribute( var output = factory([queryParameter]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo(expectedQuery); } @@ -242,7 +242,7 @@ public async Task QueryStringUsesEnumMemberAttributeInTypeWithEnum( var output = factory( [new TypeFooWithEnumMember { Foo = queryParameter }]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo(expectedQuery); } @@ -257,7 +257,7 @@ public async Task TestNullableQueryStringParams(string expectedQuery) var factory = fixture.BuildRequestFactoryForMethod("QueryWithOptionalParameters"); var output = factory([123, "title", 999, new Foo(), _stringArrayAb]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo(expectedQuery); } @@ -272,7 +272,7 @@ public async Task TestNullableQueryStringParamsWithANull(string expectedQuery) var factory = fixture.BuildRequestFactoryForMethod("QueryWithOptionalParameters"); var output = factory([123, "title", null!, null!, _stringArrayAb]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo(expectedQuery); } @@ -294,7 +294,7 @@ public async Task TestNullableQueryStringParamsWithANullAndPathBoundObject(strin _stringArrayAb ]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo(expectedQuery); } @@ -319,7 +319,7 @@ public async Task DefaultParameterFormatterIsInvariant() var factory = fixture.BuildRequestFactoryForMethod("FetchSomeStuff"); var output = factory([5.4]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/5.4"); } finally @@ -353,7 +353,7 @@ public async Task DeleteWithQuery() var output = factory([1]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/api/v1/video?playerIndex=1"); } @@ -368,7 +368,7 @@ public async Task ClearWithQuery() var output = factory([FooWithEnumMember.B]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/api/bar?foo=b"); } @@ -387,7 +387,7 @@ public async Task MultipartPostWithAliasAndHeader() var output = factory([42, "aPath", sp, "theAuth", false, "theMeta"]); - var uri = new Uri(new Uri("http://api"), output.RequestMessage!.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestMessage!.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/companies/42/aPath"); await Assert.That(output.RequestMessage.Headers.Authorization!.ToString()).IsEqualTo("theAuth"); @@ -408,7 +408,7 @@ public async Task PostBlobByteWithAlias() var output = factory(["the/path", bap]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/blobstorage/the/path"); } @@ -431,7 +431,7 @@ public async Task QueryWithAliasAndHeadersWorks() var output = factory( [authHeader, langHeader, searchParam, controlIdParam, secretValue]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo( $"/api/someModule/deviceList?controlId={controlIdParam}&search={searchParam}&secret={secretValue}"); @@ -481,7 +481,7 @@ public async Task DictionaryQueryWithEnumKeyProducesCorrectQueryString() }; var output = factory([dict]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?A=value1&B=value2"); } @@ -502,7 +502,7 @@ public async Task DictionaryQueryWithPrefix() }; var output = factory([dict]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?dictionary.A=value1&dictionary.B=value2"); } @@ -519,7 +519,7 @@ public async Task DictionaryQueryWithNumericKeyProducesCorrectQueryString() var dict = new Dictionary { { 1, "value1" }, { 2, "value2" }, }; var output = factory([dict]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?1=value1&2=value2"); } @@ -543,7 +543,7 @@ public async Task DictionaryQueryWithCustomFormatterProducesCorrectQueryString() }; var output = factory([dict]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo( $"/foo?{(int)TestEnum.A}=value1{TestEnumUrlParameterFormatter.StringParameterSuffix}&{(int)TestEnum.B}=value2{TestEnumUrlParameterFormatter.StringParameterSuffix}"); @@ -561,7 +561,7 @@ public async Task ComplexQueryObjectWithDefaultKeyFormatterProducesCorrectQueryS var complexQuery = new ComplexQueryObject { TestAlias2 = "value1" }; var output = factory([complexQuery]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?TestAlias2=value1"); } @@ -584,7 +584,7 @@ public async Task ComplexQueryObjectWithCustomKeyFormatterProducesCorrectQuerySt var complexQuery = new ComplexQueryObject { TestAlias2 = "value1" }; var output = factory([complexQuery]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?testAlias2=value1"); } @@ -600,7 +600,7 @@ public async Task ComplexQueryObjectWithAliasedDictionaryProducesCorrectQueryStr var complexQuery = new ComplexQueryObject { - TestAliasedDictionary = new Dictionary + TestAliasedDictionary = new() { { TestEnum.A, "value1" }, { TestEnum.B, "value2" }, @@ -608,7 +608,7 @@ public async Task ComplexQueryObjectWithAliasedDictionaryProducesCorrectQueryStr }; var output = factory([complexQuery]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo( "/foo?test-dictionary-alias.A=value1&test-dictionary-alias.B=value2"); @@ -625,7 +625,7 @@ public async Task ComplexQueryObjectWithDictionaryProducesCorrectQueryString() var complexQuery = new ComplexQueryObject { - TestDictionary = new Dictionary + TestDictionary = new() { { TestEnum.A, "value1" }, { TestEnum.B, "value2" }, @@ -633,7 +633,7 @@ public async Task ComplexQueryObjectWithDictionaryProducesCorrectQueryString() }; var output = factory([complexQuery]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?TestDictionary.A=value1&TestDictionary.B=value2"); } @@ -651,7 +651,7 @@ public async Task ComplexQueryObjectWithDictionaryAndCustomFormatterProducesCorr var complexQuery = new ComplexQueryObject { - TestDictionary = new Dictionary + TestDictionary = new() { { TestEnum.A, "value1" }, { TestEnum.B, "value2" }, @@ -659,7 +659,7 @@ public async Task ComplexQueryObjectWithDictionaryAndCustomFormatterProducesCorr }; var output = factory([complexQuery]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); var suffix = TestEnumUrlParameterFormatter.StringParameterSuffix; var expectedQuery = @@ -675,6 +675,9 @@ private sealed class RequestBuilderMock : IRequestBuilder /// Gets the number of times the builder was invoked. public int CallCount { get; private set; } + /// + public RefitSettings Settings { get; } = new(); + /// Records the invocation and returns null. /// The name of the method being built. /// The parameter types of the method, if any. diff --git a/src/tests/Refit.Tests/RequestBuilderTests.Queries.cs b/src/tests/Refit.Tests/RequestBuilderTests.Queries.cs index f4a13c05a..17f8eca28 100644 --- a/src/tests/Refit.Tests/RequestBuilderTests.Queries.cs +++ b/src/tests/Refit.Tests/RequestBuilderTests.Queries.cs @@ -69,9 +69,9 @@ public async Task ReadStringContentWithMetadata() var task = (Task>) factory( - new HttpClient(testHttpMessageHandler) + new(testHttpMessageHandler) { - BaseAddress = new Uri("http://api/") + BaseAddress = new("http://api/") }, [42])!; var result = await task; @@ -122,7 +122,7 @@ public async Task ParameterWithPropertyAndQueryAttributesIsAddedToQuery() "FetchSomeStuffWithPropertyAndQuery"); var output = factory([6, "value1"]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/6?someValue=value1"); } @@ -262,7 +262,7 @@ public async Task LastWriteWinsWhenHeaderCollectionAndDynamicHeader() await Assert.That(output.Headers.Contains("Authorization")).IsTrue().Because("Headers include Authorization header"); await Assert.That(output.Headers.GetValues("Authorization").First()).IsEqualTo("OpenSesame"); - fixture = new RequestBuilderImplementation(); + fixture = new(); factory = fixture.BuildRequestFactoryForMethod( nameof( IDummyHttpApi.FetchSomeStuffWithDynamicHeaderCollectionAndDynamicHeaderOrderFlipped)); @@ -357,9 +357,9 @@ public async Task OptionsFromSettingsShouldBeInProperties() const string nameProp2 = "UnitTest.Property2"; object valueProp2 = new List { "123", "345" }; var fixture = new RequestBuilderImplementation( - new RefitSettings + new() { - HttpRequestMessageOptions = new Dictionary + HttpRequestMessageOptions = new() { [nameProp1] = valueProp1, [nameProp2] = valueProp2, @@ -494,9 +494,9 @@ public async Task HttpClientShouldPrefixedAbsolutePathToTheRequestUri() var testHttpMessageHandler = new TestHttpMessageHandler(); var task = (Task)factory( - new HttpClient(testHttpMessageHandler) + new(testHttpMessageHandler) { - BaseAddress = new Uri("http://api/foo/bar") + BaseAddress = new("http://api/foo/bar") }, [])!; await task; @@ -514,9 +514,9 @@ public async Task HttpClientForVoidMethodShouldPrefixedAbsolutePathToTheRequestU var testHttpMessageHandler = new TestHttpMessageHandler(); var task = (Task)factory( - new HttpClient(testHttpMessageHandler) + new(testHttpMessageHandler) { - BaseAddress = new Uri("http://api/foo/bar") + BaseAddress = new("http://api/foo/bar") }, [])!; await task; @@ -534,7 +534,7 @@ public async Task HttpClientShouldNotPrefixEmptyAbsolutePathToTheRequestUri() var testHttpMessageHandler = new TestHttpMessageHandler(); var task = (Task)factory( - new HttpClient(testHttpMessageHandler) { BaseAddress = new Uri("http://api/") }, + new(testHttpMessageHandler) { BaseAddress = new("http://api/") }, [42])!; await task; @@ -645,7 +645,7 @@ public async Task CustomParmeterFormatter() var factory = fixture.BuildRequestFactoryForMethod("FetchSomeStuff"); var output = factory([5]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/custom-parameter"); } @@ -663,7 +663,7 @@ public async Task QueryStringWithEnumerablesCanBeFormatted() var factory = fixture.BuildRequestFactoryForMethod("QueryWithEnumerable"); var output = factory([_intArray123]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/query?numbers=1%2C2%2C3"); } @@ -681,7 +681,7 @@ public async Task QueryStringWithArrayCanBeFormatted() var factory = fixture.BuildRequestFactoryForMethod("QueryWithArray"); var output = factory([_intArray123]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/query?numbers=1%2C2%2C3"); } } diff --git a/src/tests/Refit.Tests/RequestBuilderTests.cs b/src/tests/Refit.Tests/RequestBuilderTests.cs index 061827b92..2b7ad2866 100644 --- a/src/tests/Refit.Tests/RequestBuilderTests.cs +++ b/src/tests/Refit.Tests/RequestBuilderTests.cs @@ -4,8 +4,6 @@ using System.Diagnostics.CodeAnalysis; using System.Reactive.Linq; -using System.Reflection; -using System.Runtime.CompilerServices; namespace Refit.Tests; @@ -23,6 +21,50 @@ public partial class RequestBuilderTests /// The string array {"A", "B"} used as query test data. private static readonly string[] _stringArrayAb = ["A", "B"]; + /// Rejects non-interface request-builder targets. + /// A task that represents the asynchronous operation. + [Test] + public async Task ConstructorRejectsNonInterfaceTargets() + { + await Assert.That(() => new RequestBuilderImplementation(typeof(string))) + .ThrowsExactly(); + } + + /// Verifies the public request-builder factory entry points create usable builders. + /// A task that represents the asynchronous operation. + [Test] + [SuppressMessage("Usage", "CA2263:Prefer generic overloads", Justification = "This test intentionally covers the Type-based overloads.")] + public async Task RequestBuilderFactoryEntryPointsCreateBuilders() + { + var genericBuilder = RequestBuilder.ForType(); + var genericBuilderWithSettings = RequestBuilder.ForType(new()); + var typeBuilder = RequestBuilder.ForType(typeof(IDummyHttpApi)); + var typeBuilderWithSettings = RequestBuilder.ForType(typeof(IDummyHttpApi), new()); + var factory = new RequestBuilderFactory(); + var factoryGenericBuilder = factory.Create(new()); + var factoryTypeBuilder = factory.Create(typeof(IDummyHttpApi), new()); + + await Assert.That(genericBuilder).IsNotNull(); + await Assert.That(genericBuilderWithSettings).IsNotNull(); + await Assert.That(typeBuilder).IsNotNull(); + await Assert.That(typeBuilderWithSettings).IsNotNull(); + await Assert.That(factoryGenericBuilder).IsNotNull(); + await Assert.That(factoryTypeBuilder).IsNotNull(); + } + + /// Rejects methods that are missing or ambiguous without parameter metadata. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildRestResultFuncRejectsMissingAndAmbiguousMethods() + { + var fixture = new RequestBuilderImplementation(); + + await Assert.That(() => fixture.BuildRestResultFuncForMethod("Missing")) + .ThrowsExactly(); + await Assert.That(() => fixture.BuildRestResultFuncForMethod(nameof(IOverloadedApi.Overloaded))) + .ThrowsExactly(); + } + /// Builds a request when no cancellation token is supplied. /// A task that represents the asynchronous operation. [Test] @@ -32,7 +74,7 @@ public async Task MethodsShouldBeCancellableDefault() var factory = fixture.RunRequest("GetWithCancellation"); var output = factory([]); - var uri = new Uri(new Uri("http://api"), output.RequestMessage!.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestMessage!.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo"); await Assert.That(output.CancellationToken.IsCancellationRequested).IsFalse(); } @@ -48,7 +90,7 @@ public async Task MethodsWithNullableCancellationTokenShouldBuildRequest() using var cts = new CancellationTokenSource(); var output = factory([42, cts.Token]); - var uri = new Uri(new Uri("http://api"), output.RequestMessage!.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestMessage!.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/42"); await Assert.That(output.CancellationToken.IsCancellationRequested).IsFalse(); } @@ -81,7 +123,7 @@ public async Task MethodsShouldBeCancellableWithToken() var output = factory([cts.Token]); - var uri = new Uri(new Uri("http://api"), output.RequestMessage!.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestMessage!.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo"); await Assert.That(output.CancellationToken.IsCancellationRequested).IsFalse(); } @@ -142,9 +184,9 @@ public async Task HttpContentAsApiResponseTest() var task = (Task>) factory( - new HttpClient(testHttpMessageHandler) + new(testHttpMessageHandler) { - BaseAddress = new Uri("http://api/") + BaseAddress = new("http://api/") }, [mpc])!; var result = await task; @@ -175,9 +217,9 @@ public async Task HttpContentTest() var task = (Task) factory( - new HttpClient(testHttpMessageHandler) + new(testHttpMessageHandler) { - BaseAddress = new Uri("http://api/") + BaseAddress = new("http://api/") }, [mpc])!; var result = await task; @@ -186,6 +228,20 @@ public async Task HttpContentTest() await Assert.That(result).IsEqualTo(retContent); } + /// A stream body is wrapped directly in stream content. + /// A task that represents the asynchronous operation. + [Test] + public async Task StreamRequestBodyUsesStreamContent() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod(nameof(IStreamApi.PostStream)); + + await using var stream = new MemoryStream([1, 2, 3]); + var request = factory([stream]); + + await Assert.That(request.Content).IsTypeOf(); + } + /// A stream response is wrapped in an ApiResponse. /// A task that represents the asynchronous operation. [Test] @@ -206,9 +262,9 @@ public async Task StreamResponseAsApiResponseTest() var task = (Task>) factory( - new HttpClient(testHttpMessageHandler) + new(testHttpMessageHandler) { - BaseAddress = new Uri("http://api/") + BaseAddress = new("http://api/") }, ["test-file"])!; var result = await task; @@ -228,26 +284,17 @@ public async Task StreamResponseAsApiResponseTest() [Test] public async Task GeneratedSyncApiResponseShouldPreserveRequestMessage() { - var fixture = new RequestBuilderImplementation(); - var restMethod = new RestMethodInfoInternal( - typeof(IDummyHttpApi), - typeof(IDummyHttpApi) - .GetMethods() - .First(x => x.Name == nameof(IDummyHttpApi.FetchSomeStringWithMetadata))); - var buildGeneratedSyncFuncForMethod = typeof(RequestBuilderImplementation).GetMethod( - "BuildGeneratedSyncFuncForMethod", - BindingFlags.Instance | BindingFlags.NonPublic); - var factory = (Func) - buildGeneratedSyncFuncForMethod!.Invoke(fixture, [restMethod])!; + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRestResultFuncForMethod("GetApiResponse"); var testHttpMessageHandler = new TestHttpMessageHandler(); - var response = (ApiResponse) + var response = (IApiResponse) factory( - new HttpClient(testHttpMessageHandler) + new(testHttpMessageHandler) { - BaseAddress = new Uri("http://api/") + BaseAddress = new("http://api/") }, - [42])!; + [])!; await Assert.That(response.RequestMessage).IsSameReferenceAs(testHttpMessageHandler.RequestMessage); await Assert.That(response.RequestMessage!.RequestUri).IsEqualTo(testHttpMessageHandler.RequestMessage!.RequestUri); @@ -273,9 +320,9 @@ public async Task StreamResponseTest() var task = (Task) factory( - new HttpClient(testHttpMessageHandler) + new(testHttpMessageHandler) { - BaseAddress = new Uri("http://api/") + BaseAddress = new("http://api/") }, ["test-file"])!; var result = await task; @@ -295,9 +342,9 @@ public async Task ValueTaskMethodsShouldWork() var valueTask = (ValueTask) factory( - new HttpClient(testHttpMessageHandler) + new(testHttpMessageHandler) { - BaseAddress = new Uri("http://api/") + BaseAddress = new("http://api/") }, ["value"])!; @@ -318,9 +365,9 @@ public async Task ValueTaskApiResponseMethodsShouldWork() var valueTask = (ValueTask>) factory( - new HttpClient(testHttpMessageHandler) + new(testHttpMessageHandler) { - BaseAddress = new Uri("http://api/") + BaseAddress = new("http://api/") }, ["value"])!; @@ -346,9 +393,9 @@ public async Task ObservableMethodsWithCancellationTokenShouldCancelWhenRequeste var observable = (IObservable) factory( - new HttpClient(testHttpMessageHandler) + new(testHttpMessageHandler) { - BaseAddress = new Uri("http://api/") + BaseAddress = new("http://api/") }, ["value", cts.Token])!; @@ -357,42 +404,13 @@ public async Task ObservableMethodsWithCancellationTokenShouldCancelWhenRequeste await Assert.That(testHttpMessageHandler.CancellationToken.IsCancellationRequested).IsTrue(); } - /// Throws for an invalid public sync method built from injected metadata. + /// Throws while analyzing an invalid public synchronous method. /// A task that represents the asynchronous operation. [Test] - [SuppressMessage("Interoperability", "SYSLIB0050", Justification = "Test intentionally exercises the obsolete API.")] - public async Task BuildRestResultFuncForMethodThrowsForInvalidPublicSyncMethodFromInjectedMetadata() + public async Task ConstructorThrowsForInvalidPublicSyncMethod() { - var fixture = new RequestBuilderImplementation(); - var interfaceHttpMethodsField = typeof(RequestBuilderImplementation).GetField( - "_interfaceHttpMethods", - BindingFlags.Instance | BindingFlags.NonPublic); - var interfaceHttpMethods = - (Dictionary>)interfaceHttpMethodsField!.GetValue(fixture)!; - - var restMethod = (RestMethodInfoInternal)RuntimeHelpers.GetUninitializedObject( - typeof(RestMethodInfoInternal)); - typeof(RestMethodInfoInternal) - .GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)! - .SetValue( - restMethod, - typeof(IInvalidReturnTypeIApiResponse).GetMethod(nameof(IInvalidReturnTypeIApiResponse.GetValue))); - typeof(RestMethodInfoInternal) - .GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)! - .SetValue(restMethod, typeof(IApiResponse)); - typeof(RestMethodInfoInternal) - .GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)! - .SetValue(restMethod, typeof(IApiResponse)); - typeof(RestMethodInfoInternal) - .GetField( - "k__BackingField", - BindingFlags.Instance | BindingFlags.NonPublic)! - .SetValue(restMethod, typeof(HttpContent)); - - interfaceHttpMethods["GetValue"] = [restMethod]; - var exception = await Assert.That( - () => fixture.BuildRestResultFuncForMethod("GetValue")).ThrowsExactly(); + () => new RequestBuilderImplementation()).ThrowsExactly(); await Assert.That(exception!.Message).Contains( "All REST Methods must return either Task or ValueTask or IObservable"); @@ -452,7 +470,7 @@ public async Task HardcodedQueryParamShouldBeInUrl() "FetchSomeStuffWithHardcodedQueryParameter"); var output = factory([6]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/6?baz=bamf"); } @@ -466,7 +484,7 @@ public async Task ParameterizedQueryParamsShouldBeInUrl() "FetchSomeStuffWithHardcodedAndOtherQueryParameters"); var output = factory([6, "foo"]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/6?baz=bamf&search_for=foo"); } @@ -480,7 +498,7 @@ public async Task ParameterizedValuesShouldBeInUrlMoreThanOnce() nameof(IDummyHttpApi.SomeApiThatUsesParameterMoreThanOnceInTheUrl)); var output = factory([6]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/api/foo/6/file_6?query=6"); } @@ -503,7 +521,7 @@ public async Task RoundTrippingParameterizedQueryParamsShouldBeInUrl( "FetchSomeStuffWithRoundTrippingParam"); var output = factory([path, 1]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo(expectedQuery); } @@ -521,7 +539,7 @@ public async Task ParameterizedNullQueryParamsShouldBeBlankInUrl() var output = factory( [new FileInfo(typeof(RequestBuilderTests).Assembly.Location), null!]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?name="); } @@ -535,7 +553,7 @@ public async Task ParametersShouldBePutAsExplicitQueryString() nameof(IDummyHttpApi.QueryWithExplicitParameters)); var output = factory(["value1", "value2"]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/query?q1=value1&q2=value2"); } @@ -549,7 +567,7 @@ public async Task QueryParamShouldFormat() var factory = fixture.BuildRequestFactoryForMethod("FetchSomeStuffWithQueryFormat"); var output = factory([6]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/6.0"); } @@ -563,7 +581,7 @@ public async Task ParameterizedQueryParamsShouldBeInUrlAndValuesEncoded() "FetchSomeStuffWithHardcodedAndOtherQueryParameters"); var output = factory([6, "push!=pull&push"]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/6?baz=bamf&search_for=push%21%3Dpull%26push"); } @@ -578,7 +596,7 @@ public async Task ParameterizedQueryParamsShouldBeInUrlAndValuesEncodedWhenMixed "FetchSomeStuffWithVoidAndQueryAlias"); var output = factory(["6 & 7/8", "test@example.com", "push!=pull"]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/void/6%20%26%207%2F8/path?a=test%40example.com&b=push%21%3Dpull"); } @@ -593,7 +611,7 @@ public async Task QueryParamWithPathDelimiterShouldBeEncoded() "FetchSomeStuffWithVoidAndQueryAlias"); var output = factory(["6/6", "test@example.com", "push!=pull"]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/void/6%2F6/path?a=test%40example.com&b=push%21%3Dpull"); } @@ -608,7 +626,7 @@ public async Task QueryParamWhichEndsInDoubleQuotesShouldNotBeTruncated() "FetchSomeStuffWithDoubleQuotesInUrl"); var output = factory([42]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?q=app_metadata.id%3A%2242%22"); } @@ -628,7 +646,7 @@ public async Task ShouldCaptureLastCharacterWhenRouteEndsWithConstant(string met methodToTest); var output = factory(["1"]); - var uri = new Uri(new Uri("http://api/"), output.RequestUri!); + var uri = new Uri(new("http://api/"), output.RequestUri!); await Assert.That(uri.PathAndQuery).EndsWith(constantChar); await Assert.That(uri.PathAndQuery).Contains(contains); @@ -644,7 +662,7 @@ public async Task ParameterizedQueryParamsShouldBeInUrlAndValuesEncodedWhenMixed "FetchSomeStuffWithVoidAndQueryAlias"); var output = factory(["6", "test@example.com", "push!=pull"]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/void/6/path?a=test%40example.com&b=push%21%3Dpull"); } @@ -659,7 +677,7 @@ public async Task NonFormattableQueryParamsShouldBeIncluded() "FetchSomeStuffWithNonFormattableQueryParams"); var output = factory([true, 'x']); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?b=True&c=x"); } @@ -674,7 +692,7 @@ public async Task MultipleParametersInTheSameSegmentAreGeneratedProperly() "FetchSomethingWithMultipleParametersPerSegment"); var output = factory([6, 1024, 768]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/6/1024x768/foo"); } } diff --git a/src/tests/Refit.Tests/ResponseModel.cs b/src/tests/Refit.Tests/ResponseModel.cs index 3ddaba1ce..f213cb666 100644 --- a/src/tests/Refit.Tests/ResponseModel.cs +++ b/src/tests/Refit.Tests/ResponseModel.cs @@ -1,10 +1,11 @@ // Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; - namespace Refit.Tests.SeparateNamespaceWithModel; /// An intentionally empty response model in a separate namespace, used to verify the generator emits the required using directive. -[SuppressMessage("Design", "SST1436", Justification = "Intentional empty fixture model used to verify the generator emits the required using directive for a separate namespace.")] +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "RoslynCommonAnalyzers", + "SST1436:Add members to a type or remove it", + Justification = "Intentional empty response fixture used to verify generated using directives without changing the public shape.")] public class ResponseModel; diff --git a/src/tests/Refit.Tests/ResponseTests.cs b/src/tests/Refit.Tests/ResponseTests.cs index 40644aeaa..317519710 100644 --- a/src/tests/Refit.Tests/ResponseTests.cs +++ b/src/tests/Refit.Tests/ResponseTests.cs @@ -41,7 +41,7 @@ public sealed class ResponseTests : IDisposable /// Initializes a new instance of the class. public ResponseTests() { - _mockHandler = new MockHttpMessageHandler(); + _mockHandler = new(); var settings = new RefitSettings { HttpMessageHandlerFactory = () => _mockHandler }; @@ -130,7 +130,7 @@ public async Task ThrowsValidationException() Content = new StringContent(JsonConvert.SerializeObject(expectedContent)) }; expectedResponse.Content.Headers.ContentType = - new MediaTypeHeaderValue("application/problem+json"); + new("application/problem+json"); _mockHandler.Expect(HttpMethod.Get, "http://api/aliasTest").Respond(req => expectedResponse); var actualException = await Assert.That(_fixture.GetTestObject).ThrowsExactly(); @@ -157,7 +157,7 @@ public async Task ValidationApiExceptionPropagatesContentHeaders() { Content = new StringContent(JsonConvert.SerializeObject(expectedContent)) }; - expectedResponse.Content.Headers.ContentType = new MediaTypeHeaderValue( + expectedResponse.Content.Headers.ContentType = new( "application/problem+json"); _mockHandler.Expect(HttpMethod.Get, "http://api/aliasTest").Respond(req => expectedResponse); @@ -182,7 +182,7 @@ public async Task ValidationApiExceptionUsesConfiguredContentSerializer() var localHandler = new MockHttpMessageHandler(); var settings = new RefitSettings( new SystemTextJsonContentSerializer( - new JsonSerializerOptions { PropertyNameCaseInsensitive = false })) + new() { PropertyNameCaseInsensitive = false })) { HttpMessageHandlerFactory = () => localHandler }; @@ -191,7 +191,7 @@ public async Task ValidationApiExceptionUsesConfiguredContentSerializer() { Content = new StringContent("{\"Title\":\"mapped\",\"detail\":\"unmapped\"}") }; - expectedResponse.Content.Headers.ContentType = new MediaTypeHeaderValue( + expectedResponse.Content.Headers.ContentType = new( "application/problem+json"); localHandler .Expect(HttpMethod.Get, "http://api/aliasTest") @@ -231,7 +231,7 @@ public async Task When_BadRequest_EnsureSuccessStatusCodeAsync_ThrowsValidationE }; expectedResponse.Content.Headers.ContentType = - new MediaTypeHeaderValue("application/problem+json"); + new("application/problem+json"); _mockHandler .Expect(HttpMethod.Get, "http://api/GetApiResponseTestObject") .Respond(req => expectedResponse); @@ -340,7 +340,7 @@ public async Task WhenProblemDetailsResponseContainsExtensions_ShouldHydrateExte }; expectedResponse.Content.Headers.ContentType = - new MediaTypeHeaderValue("application/problem+json"); + new("application/problem+json"); _mockHandler.Expect(HttpMethod.Get, "http://api/aliasTest").Respond(req => expectedResponse); _mockHandler.Expect(HttpMethod.Get, "http://api/soloyolo").Respond(req => expectedResponse); @@ -356,9 +356,9 @@ public async Task WhenProblemDetailsResponseContainsExtensions_ShouldHydrateExte await Assert.That(actualException.Content.Extensions).Count().IsEqualTo(2); var items = actualException.Content.Extensions.ToList(); await Assert.That(items[0]).IsEqualTo( - new KeyValuePair(nameof(expectedContent.Foo), expectedContent.Foo)); + new(nameof(expectedContent.Foo), expectedContent.Foo)); await Assert.That(items[1]).IsEqualTo( - new KeyValuePair(nameof(expectedContent.Baz), expectedContent.Baz)); + new(nameof(expectedContent.Baz), expectedContent.Baz)); } /// Verifies that a non-seekable stream is handled by the System.Text.Json content serializer. @@ -399,7 +399,7 @@ public async Task WithNonSeekableStream_UsingSystemTextJsonContentSerializer() { Headers = { - ContentType = new MediaTypeHeaderValue("application/json") + ContentType = new("application/json") { CharSet = Encoding.UTF8.WebName } @@ -408,7 +408,7 @@ public async Task WithNonSeekableStream_UsingSystemTextJsonContentSerializer() var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK) { Content = httpContent }; - expectedResponse.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + expectedResponse.Content.Headers.ContentType = new("application/json"); expectedResponse.StatusCode = HttpStatusCode.OK; localHandler @@ -449,7 +449,7 @@ public async Task DeserializationFailureWithNonSeekableStream_PopulatesApiExcept contentStream.CanGetLength = false; var httpContent = new StreamContent(contentStream); - httpContent.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + httpContent.Headers.ContentType = new("application/json"); var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK) { Content = httpContent }; @@ -479,7 +479,7 @@ public async Task XmlDeserializationWithNonSeekableStream_DoesNotThrowStreamCons "abc-123"; var localHandler = new MockHttpMessageHandler(); - var settings = new RefitSettings(new Refit.XmlContentSerializer()) + var settings = new RefitSettings(new XmlContentSerializer()) { HttpMessageHandlerFactory = () => localHandler }; @@ -491,7 +491,7 @@ public async Task XmlDeserializationWithNonSeekableStream_DoesNotThrowStreamCons contentStream.CanGetLength = false; var httpContent = new StreamContent(contentStream); - httpContent.Headers.ContentType = new MediaTypeHeaderValue("application/xml"); + httpContent.Headers.ContentType = new("application/xml"); var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK) { Content = httpContent }; @@ -616,7 +616,7 @@ public async Task ValidationApiException_HydratesBaseContent() { Content = new StringContent(expectedContent) }; - expectedResponse.Content.Headers.ContentType = new MediaTypeHeaderValue( + expectedResponse.Content.Headers.ContentType = new( "application/problem+json"); _mockHandler.Expect(HttpMethod.Get, "http://api/aliasTest").Respond(req => expectedResponse); diff --git a/src/tests/Refit.Tests/RestMethodInfoTests.HeaderCollection.cs b/src/tests/Refit.Tests/RestMethodInfoTests.HeaderCollection.cs index ab05cdebc..7629371a0 100644 --- a/src/tests/Refit.Tests/RestMethodInfoTests.HeaderCollection.cs +++ b/src/tests/Refit.Tests/RestMethodInfoTests.HeaderCollection.cs @@ -170,7 +170,7 @@ public async Task DynamicHeaderCollectionShouldWorkWithDynamicHeader(string inte await Assert.That(fixture.HeaderCollectionAt(2)).IsTrue(); input = typeof(IRestMethodInfoTests); - fixture = new RestMethodInfoInternal( + fixture = new( input, input .GetMethods() @@ -680,7 +680,8 @@ public async Task InternalSyncGenericReturnTypeSetsDeserializedTypeToReturnType( var fixture = new RestMethodInfoInternal( input, input - .GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) + .GetTypeInfo() + .DeclaredMethods .First(x => x.Name == nameof(IInternalSyncGenericReturnTypeApi.GetValues))); await Assert.That(fixture.ReturnType).IsEqualTo(typeof(List)); @@ -697,7 +698,8 @@ public async Task InternalSyncIApiResponseGenericReturnTypeSetsDeserializedTypeT var fixture = new RestMethodInfoInternal( input, input - .GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) + .GetTypeInfo() + .DeclaredMethods .First(x => x.Name == nameof(IInternalSyncGenericApiResponseReturnTypeApi.GetResponse))); await Assert.That(fixture.ReturnType).IsEqualTo(typeof(IApiResponse)); diff --git a/src/tests/Refit.Tests/RestMethodInfoTests.cs b/src/tests/Refit.Tests/RestMethodInfoTests.cs index faf682380..625de1cbb 100644 --- a/src/tests/Refit.Tests/RestMethodInfoTests.cs +++ b/src/tests/Refit.Tests/RestMethodInfoTests.cs @@ -127,7 +127,7 @@ public async Task CultureInfoQueryParameterDoesNotStackOverflow() var output = factory([new System.Globalization.CultureInfo("en-US")]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?culture=en-US"); } @@ -145,7 +145,7 @@ public async Task BaseAddressWithTrailingSlashDoesNotProduceDoubleSlash() var output = factory([42]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.AbsolutePath).IsEqualTo("/v1/foo/bar/42"); } @@ -168,7 +168,7 @@ public async Task DerivedPathBoundObjectDoesNotDuplicatePathPropertyAsQuery(stri _filterValues ]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo(expectedQuery); } @@ -186,7 +186,7 @@ public async Task PostWithObjectQueryParameterHasCorrectQuerystring() var output = factory([param]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?test-query-alias=one&TestAlias2=two"); } @@ -306,7 +306,7 @@ public async Task ObjectQueryParameterWithInnerCollectionHasCorrectQuerystring() var param = new ComplexQueryObject { TestCollection = [1, 2, 3] }; var output = factory([param]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?TestCollection=1%2C2%2C3"); } @@ -325,7 +325,7 @@ public async Task ObjectQueryParameterWithoutQueryAttributeHonorsMultiCollection EnumCollectionMulti = [TestEnum.A, TestEnum.B] }; var output = factory([param]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?listOfEnumMulti=A&listOfEnumMulti=B"); } @@ -341,7 +341,7 @@ public async Task ParameterLevelCollectionFormatAppliesToInnerCollectionsWithout var param = new ComplexQueryObject { TestCollection = [1, 2, 3] }; var output = factory([param]); - var uri = new Uri(new Uri("http://api"), output.RequestUri!); + var uri = new Uri(new("http://api"), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?TestCollection=1&TestCollection=2&TestCollection=3"); } @@ -690,4 +690,75 @@ public async Task DynamicHeadersShouldWork() await Assert.That(fixture.Headers["User-Agent"]).IsEqualTo("RefitTestClient"); await Assert.That(fixture.Headers.Count).IsEqualTo(2); } + + /// Verifies constructor guard and missing HTTP method branches. + /// A task that represents the asynchronous operation. + [Test] + public async Task ConstructorGuardsAndMissingHttpMethodThrow() + { + var input = typeof(IRestMethodInfoTests); + var method = input.GetMethods().First(x => x.Name == nameof(IRestMethodInfoTests.FetchSomeStuff)); + var missingInput = typeof(IMissingHttpMethodApi); + var missingHttpMethod = missingInput.GetMethod(nameof(IMissingHttpMethodApi.MethodWithoutHttpMethod))!; + + await Assert.That(() => new RestMethodInfoInternal(null!, method)) + .ThrowsExactly(); + await Assert.That(() => new RestMethodInfoInternal(input, null!)) + .ThrowsExactly(); + await Assert.That(() => new RestMethodInfoInternal(missingInput, missingHttpMethod)) + .ThrowsExactly(); + } + + /// Verifies CR/LF paths are rejected. + /// A task that represents the asynchronous operation. + [Test] + public async Task PathContainingNewlineThrows() + { + var input = typeof(IRestMethodInfoTests); + var method = input.GetMethods().First(x => x.Name == nameof(IRestMethodInfoTests.NewlinePath)); + + await Assert.That(() => new RestMethodInfoInternal(input, method)) + .ThrowsExactly(); + } + + /// Verifies object route binding ignores unreadable public properties. + /// A task that represents the asynchronous operation. + [Test] + public async Task ObjectRouteBindingIgnoresUnreadableProperties() + { + var input = typeof(IRestMethodInfoTests); + var fixture = new RestMethodInfoInternal( + input, + input.GetMethods().First(x => x.Name == nameof(IRestMethodInfoTests.ObjectRouteWithUnreadableProperty))); + + await Assert.That(fixture.ParameterMap).HasSingleItem(); + await Assert.That(fixture.ParameterMap[0].IsObjectPropertyParameter).IsTrue(); + await Assert.That(fixture.ParameterMap[0].ParameterProperties).HasSingleItem(); + await Assert.That(fixture.ParameterMap[0].ParameterProperties[0].PropertyInfo.Name) + .IsEqualTo(nameof(RouteObjectWithUnreadableProperty.Visible)); + } + + /// Verifies a path cannot bind a parameter both directly and through one of its properties. + /// A task that represents the asynchronous operation. + [Test] + public async Task ObjectRouteBindingConflictingDirectAndPropertyMatchThrows() + { + var input = typeof(IRestMethodInfoTests); + var method = input.GetMethods().First(x => x.Name == nameof(IRestMethodInfoTests.ConflictingObjectRoute)); + + await Assert.That(() => new RestMethodInfoInternal(input, method)) + .ThrowsExactly(); + } + + /// Verifies only one authorization parameter may be declared. + /// A task that represents the asynchronous operation. + [Test] + public async Task DuplicateAuthorizeParametersThrow() + { + var input = typeof(IRestMethodInfoTests); + var method = input.GetMethods().First(x => x.Name == nameof(IRestMethodInfoTests.DuplicateAuthorize)); + + await Assert.That(() => new RestMethodInfoInternal(input, method)) + .ThrowsExactly(); + } } diff --git a/src/tests/Refit.Tests/RestServiceExceptions.cs b/src/tests/Refit.Tests/RestServiceExceptions.cs index 25b0c3b81..3926b6ece 100644 --- a/src/tests/Refit.Tests/RestServiceExceptions.cs +++ b/src/tests/Refit.Tests/RestServiceExceptions.cs @@ -169,8 +169,5 @@ public async Task InvalidRawApiResponseReturnTypeShouldThrow() /// The substring expected within the exception message. /// The exception whose message is inspected. /// A task that completes when the assertion has run. - private static async Task AssertExceptionContains(string expectedSubstring, Exception exception) - { - await Assert.That(exception.Message!).Contains(expectedSubstring, StringComparison.Ordinal); - } + private static async Task AssertExceptionContains(string expectedSubstring, Exception exception) => await Assert.That(exception.Message!).Contains(expectedSubstring, StringComparison.Ordinal); } diff --git a/src/tests/Refit.Tests/RestServiceIntegrationTests.GitHub.cs b/src/tests/Refit.Tests/RestServiceIntegrationTests.GitHub.cs index d29ed3f34..ec47901bd 100644 --- a/src/tests/Refit.Tests/RestServiceIntegrationTests.GitHub.cs +++ b/src/tests/Refit.Tests/RestServiceIntegrationTests.GitHub.cs @@ -29,7 +29,7 @@ public async Task HitTheGitHubUserApiAsApiResponse() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) @@ -76,7 +76,7 @@ public async Task HitTheNonExistentApiAsApiResponse() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) @@ -110,7 +110,7 @@ public async Task HitTheNonExistentApi() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) @@ -145,7 +145,7 @@ public async Task HitTheGitHubUserApiAsObservableApiResponse() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) @@ -194,7 +194,7 @@ public async Task HitTheGitHubUserApiAsObservableIApiResponse() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) @@ -243,7 +243,7 @@ public async Task HitTheGitHubUserApi() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) @@ -274,7 +274,7 @@ public async Task HitWithCamelCaseParameter() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) @@ -305,7 +305,7 @@ public async Task HitTheGitHubOrgMembersApi() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) @@ -338,7 +338,7 @@ public async Task HitTheGitHubOrgMembersApiInParallel() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) @@ -385,7 +385,7 @@ public async Task RequestCanceledBeforeResponseRead() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) @@ -400,7 +400,7 @@ public async Task RequestCanceledBeforeResponseRead() // Cancel the request cts.Cancel(); - return new HttpResponseMessage(HttpStatusCode.OK) + return new(HttpStatusCode.OK) { Content = new StringContent( "[{ 'login':'octocat', 'avatar_url':'http://foo/bar', 'type':'User'}]", @@ -440,7 +440,7 @@ public async Task RequestCanceledBeforeResponseReadWithIApiResponse() // Cancel the request cts.Cancel(); - return new HttpResponseMessage(HttpStatusCode.OK) + return new(HttpStatusCode.OK) { Content = new StringContent( "[{ 'login':'octocat', 'avatar_url':'http://foo/bar', 'type':'User'}]", @@ -471,7 +471,7 @@ public async Task HitTheGitHubUserSearchApi() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) @@ -504,7 +504,7 @@ public async Task HitTheGitHubUserApiAsObservable() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) @@ -535,7 +535,7 @@ public async Task HitTheGitHubUserApiAsObservableAndSubscribeAfterTheFact() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) @@ -568,7 +568,7 @@ public async Task TwoSubscriptionsResultInTwoRequests() ContentFactory = () => new StringContent("test") }; - var client = new HttpClient(input) { BaseAddress = new Uri("http://foo") }; + var client = new HttpClient(input) { BaseAddress = new("http://foo") }; var fixture = RestService.For(client); await Assert.That(input.MessagesSent).IsEqualTo(0); @@ -597,7 +597,7 @@ public async Task ShouldRetHttpResponseMessage() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) @@ -623,7 +623,7 @@ public async Task ShouldRetHttpResponseMessageWithNestedInterface() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) diff --git a/src/tests/Refit.Tests/RestServiceIntegrationTests.Inheritance.cs b/src/tests/Refit.Tests/RestServiceIntegrationTests.Inheritance.cs index a5282f23c..b69e1f8df 100644 --- a/src/tests/Refit.Tests/RestServiceIntegrationTests.Inheritance.cs +++ b/src/tests/Refit.Tests/RestServiceIntegrationTests.Inheritance.cs @@ -243,9 +243,12 @@ public async Task ComplexDynamicQueryparametersTestWithIncludeParameterName() "args": {"search.Addr.Street": "HomeStreet 99","search.Addr.Zip": "9999","search.FirstName": "John","search.LastName": "Rambo"}} """); - var myParams = new MyComplexQueryParams { FirstName = "John", LastName = "Rambo" }; - myParams.Address.Postcode = 9999; - myParams.Address.Street = "HomeStreet 99"; + var myParams = new MyComplexQueryParams + { + FirstName = "John", + LastName = "Rambo", + Address = new() { Postcode = 9999, Street = "HomeStreet 99" }, + }; var fixture = RestService.For>( "https://httpbin.org/get", @@ -317,7 +320,7 @@ public async Task CanSerializeContentAsXml() .WithHeaders("Content-Type:application/xml; charset=utf-8") .Respond( req => - new HttpResponseMessage(HttpStatusCode.OK) + new(HttpStatusCode.OK) { Content = new StringContent( "Created", @@ -327,7 +330,7 @@ public async Task CanSerializeContentAsXml() var fixture = RestService.For("https://api.github.com", settings); - var result = await fixture.CreateUser(new User()).ConfigureAwait(false); + var result = await fixture.CreateUser(new()).ConfigureAwait(false); await Assert.That(result.Name).IsEqualTo("Created"); diff --git a/src/tests/Refit.Tests/RestServiceIntegrationTests.RequestBody.cs b/src/tests/Refit.Tests/RestServiceIntegrationTests.RequestBody.cs index e1eb69b61..e892f5fc2 100644 --- a/src/tests/Refit.Tests/RestServiceIntegrationTests.RequestBody.cs +++ b/src/tests/Refit.Tests/RestServiceIntegrationTests.RequestBody.cs @@ -229,7 +229,7 @@ public async Task CanGetDataOutOfErrorResponses() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) @@ -309,7 +309,7 @@ public async Task ErrorsFromApiReturnErrorContent() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) @@ -325,7 +325,7 @@ public async Task ErrorsFromApiReturnErrorContent() var fixture = RestService.For("https://api.github.com", settings); var result = await Assert.That( - () => (Task)fixture.CreateUser(new User { Name = "foo" })).ThrowsExactly(); + () => (Task)fixture.CreateUser(new() { Name = "foo" })).ThrowsExactly(); await AssertStackTraceContains(nameof(IGitHubApi.CreateUser), result!.StackTrace); @@ -348,7 +348,7 @@ public async Task ErrorsFromApiReturnErrorContentWhenApiResponse() { HttpMessageHandlerFactory = () => mockHttp, ContentSerializer = new NewtonsoftJsonContentSerializer( - new JsonSerializerSettings + new() { ContractResolver = new SnakeCasePropertyNamesContractResolver() }) @@ -363,7 +363,7 @@ public async Task ErrorsFromApiReturnErrorContentWhenApiResponse() var fixture = RestService.For("https://api.github.com", settings); - using var response = await fixture.CreateUserWithMetadata(new User { Name = "foo" }); + using var response = await fixture.CreateUserWithMetadata(new() { Name = "foo" }); await Assert.That(response.IsSuccessStatusCode).IsFalse(); await Assert.That(response.Error).IsNotNull(); @@ -448,7 +448,7 @@ public async Task ValueTypesArentValidButTheyWorkAnyway() var handler = new TestHttpMessageHandler("true"); var fixture = RestService.For( - new HttpClient(handler) { BaseAddress = new Uri("http://nowhere.com") }); + new HttpClient(handler) { BaseAddress = new("http://nowhere.com") }); var result = await fixture.PostAValue("Does this work?"); @@ -522,9 +522,12 @@ public async Task ComplexDynamicQueryparametersTest() + "\"Other\": [\"12345\",\"10/31/2017 4:32:59 PM\",\"60282dd2-f79a-4400-be01-bcb0e86e7bc6\"], " + "\"hardcoded\": \"true\"}}"); - var myParams = new MyComplexQueryParams { FirstName = "John", LastName = "Rambo" }; - myParams.Address.Postcode = 9999; - myParams.Address.Street = "HomeStreet 99"; + var myParams = new MyComplexQueryParams + { + FirstName = "John", + LastName = "Rambo", + Address = new() { Postcode = 9999, Street = "HomeStreet 99" }, + }; myParams.MetaData.Add("Age", 99); myParams.MetaData.Add("Initials", "JR"); @@ -568,9 +571,12 @@ public async Task ComplexPostDynamicQueryparametersTest() + "\"Other\": [\"12345\",\"10/31/2017 4:32:59 PM\",\"60282dd2-f79a-4400-be01-bcb0e86e7bc6\"], " + "\"hardcoded\": \"true\"}}"); - var myParams = new MyComplexQueryParams { FirstName = "John", LastName = "Rambo" }; - myParams.Address.Postcode = 9999; - myParams.Address.Street = "HomeStreet 99"; + var myParams = new MyComplexQueryParams + { + FirstName = "John", + LastName = "Rambo", + Address = new() { Postcode = 9999, Street = "HomeStreet 99" }, + }; myParams.MetaData.Add("Age", 99); myParams.MetaData.Add("Initials", "JR"); diff --git a/src/tests/Refit.Tests/RestServiceIntegrationTests.cs b/src/tests/Refit.Tests/RestServiceIntegrationTests.cs index 6315e8c93..56b3ca603 100644 --- a/src/tests/Refit.Tests/RestServiceIntegrationTests.cs +++ b/src/tests/Refit.Tests/RestServiceIntegrationTests.cs @@ -45,7 +45,7 @@ public async Task UsesRegisteredGeneratedFactory() typeof(IGeneratedFactoryApi), static (client, builder) => new GeneratedFactoryApiClient(client, builder)); - using var client = new HttpClient { BaseAddress = new Uri("http://foo") }; + using var client = new HttpClient { BaseAddress = new("http://foo") }; var instance = RestService.For(client); var generated = await Assert.That(instance).IsTypeOf(); @@ -185,7 +185,7 @@ public async Task BaseAddressFromHttpClientMatchesTest() .WithExactQueryString(string.Empty) .Respond("application/json", "Ok"); - var client = new HttpClient(mockHttp) { BaseAddress = new Uri("http://foo") }; + var client = new HttpClient(mockHttp) { BaseAddress = new("http://foo") }; var fixture = RestService.For(client); @@ -204,7 +204,7 @@ public async Task BaseAddressWithTrailingSlashFromHttpClientMatchesTest() .WithExactQueryString(string.Empty) .Respond("application/json", "Ok"); - var client = new HttpClient(mockHttp) { BaseAddress = new Uri("http://foo/") }; + var client = new HttpClient(mockHttp) { BaseAddress = new("http://foo/") }; var fixture = RestService.For(client); @@ -227,7 +227,7 @@ public async Task BaseAddressWithTrailingSlashCalledBeforeFromHttpClientMatchesT .WithExactQueryString(string.Empty) .Respond("application/json", "Ok"); - var client = new HttpClient(mockHttp) { BaseAddress = new Uri("http://foo/") }; + var client = new HttpClient(mockHttp) { BaseAddress = new("http://foo/") }; _ = await client.GetAsync(new Uri("/firstRequest", UriKind.RelativeOrAbsolute)); @@ -309,7 +309,7 @@ public async Task GetWithPathBoundObjectDifferentCasing() var fixture = RestService.For("http://foo", settings); await fixture.GetFooBarsWithDifferentCasing( - new PathBoundObject { SomeProperty = 1, SomeProperty2 = "barNone" }); + new() { SomeProperty = 1, SomeProperty2 = "barNone" }); mockHttp.VerifyNoOutstandingExpectation(); } @@ -329,7 +329,7 @@ public async Task GetWithPathBoundObjectAndParameter() await fixture.GetBarsByFoo( "myId", - new PathBoundObject { SomeProperty = 22, SomeProperty2 = "bart" }); + new() { SomeProperty = 22, SomeProperty2 = "bart" }); mockHttp.VerifyNoOutstandingExpectation(); } @@ -341,14 +341,14 @@ public async Task GetWithPathBoundObjectAndParameterParameterPrecedence() var mockHttp = new MockHttpMessageHandler(); mockHttp .Expect(HttpMethod.Get, "http://foo/foos/chooseMe/bar/barNone") - .WithExactQueryString([new KeyValuePair("SomeProperty", "1")]) + .WithExactQueryString([new("SomeProperty", "1")]) .Respond("application/json", "Ok"); var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp }; var fixture = RestService.For("http://foo", settings); await fixture.GetFooBars( - new PathBoundObject { SomeProperty = 1, SomeProperty2 = "barNone" }, + new() { SomeProperty = 1, SomeProperty2 = "barNone" }, "chooseMe"); mockHttp.VerifyNoOutstandingExpectation(); } @@ -362,14 +362,14 @@ public async Task GetWithPathBoundDerivedObject() mockHttp .Expect(HttpMethod.Get, "http://foo/foos/1/bar/test") .WithExactQueryString( - [new KeyValuePair("SomeProperty2", "barNone")]) + [new("SomeProperty2", "barNone")]) .Respond("application/json", "Ok"); var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp }; var fixture = RestService.For("http://foo", settings); await fixture.GetFooBarsDerived( - new PathBoundDerivedObject + new() { SomeProperty = 1, SomeProperty2 = "barNone", @@ -391,8 +391,8 @@ public async Task GetWithDerivedObjectAsBaseType() .Expect(HttpMethod.Get, "http://foo/foos/1/bar") .WithExactQueryString( [ - new KeyValuePair("SomeProperty3", "test"), - new KeyValuePair("SomeProperty2", "barNone") + new("SomeProperty3", "test"), + new("SomeProperty2", "barNone") ]) .Respond("application/json", "Ok"); @@ -418,14 +418,14 @@ public async Task GetWithPathBoundObjectAndQueryParameter() mockHttp .Expect(HttpMethod.Get, "http://foo/foos/22/bar") .WithExactQueryString( - [new KeyValuePair("SomeProperty2", "bart")]) + [new("SomeProperty2", "bart")]) .Respond("application/json", "Ok"); var settings = new RefitSettings { HttpMessageHandlerFactory = () => mockHttp }; var fixture = RestService.For("http://foo", settings); await fixture.GetBarsByFoo( - new PathBoundObject { SomeProperty = 22, SomeProperty2 = "bart" }); + new() { SomeProperty = 22, SomeProperty2 = "bart" }); mockHttp.VerifyNoOutstandingExpectation(); } @@ -443,7 +443,7 @@ public async Task PostFooBarPathBoundObject() var fixture = RestService.For("http://foo", settings); await fixture.PostFooBar( - new PathBoundObject { SomeProperty = 22, SomeProperty2 = "bart" }, + new() { SomeProperty = 22, SomeProperty2 = "bart" }, new { }); mockHttp.VerifyNoOutstandingExpectation(); } @@ -466,7 +466,7 @@ public async Task PathBoundObjectsRespectFormatter() var fixture = RestService.For("http://foo", settings); await fixture.GetFoos( - new PathBoundList + new() { Values = [22, 23] }); @@ -512,7 +512,7 @@ public async Task GetWithPathBoundObjectAndQueryWithFormat() var fixture = RestService.For("http://foo", settings); await fixture.GetBarsWithCustomQueryFormat( - new PathBoundObjectWithQueryFormat + new() { SomeQueryWithFormat = new DateTimeOffset(2020, 03, 05, 13, 55, 00, TimeSpan.Zero).UtcDateTime }); @@ -535,8 +535,8 @@ public async Task GetWithPathBoundObjectAndQueryObject() var fixture = RestService.For("http://foo", settings); await fixture.PostFooBar( - new PathBoundObject { SomeProperty = 1, SomeProperty2 = "barNone" }, - new ModelObject { Property1 = "test", Property2 = "test2" }); + new() { SomeProperty = 1, SomeProperty2 = "barNone" }, + new() { Property1 = "test", Property2 = "test2" }); mockHttp.VerifyNoOutstandingExpectation(); } @@ -557,7 +557,7 @@ public async Task PostFooBarPathMultipart() await using var stream = GetTestFileStream("Test Files/Test.pdf"); await fixture.PostFooBarStreamPart( new PathBoundObject { SomeProperty = 22, SomeProperty2 = "bar" }, - new StreamPart(stream, "Test.pdf", "application/pdf")); + new(stream, "Test.pdf", "application/pdf")); mockHttp.VerifyNoOutstandingExpectation(); } @@ -583,7 +583,7 @@ await fixture.PostFooBarStreamPart( SomeProperty2 = "bar", SomeQuery = "test" }, - new StreamPart(stream, "Test.pdf", "application/pdf")); + new(stream, "Test.pdf", "application/pdf")); mockHttp.VerifyNoOutstandingExpectation(); } @@ -603,9 +603,9 @@ public async Task PostFooBarPathQueryObjectMultipart() await using var stream = GetTestFileStream("Test Files/Test.pdf"); await fixture.PostFooBarStreamPart( - new PathBoundObject { SomeProperty = 22, SomeProperty2 = "bar" }, - new ModelObject { Property1 = "test", Property2 = "test2" }, - new StreamPart(stream, "Test.pdf", "application/pdf")); + new() { SomeProperty = 22, SomeProperty2 = "bar" }, + new() { Property1 = "test", Property2 = "test2" }, + new(stream, "Test.pdf", "application/pdf")); mockHttp.VerifyNoOutstandingExpectation(); } @@ -667,7 +667,7 @@ public async Task GetWithDecimal() mockHttp .Expect(HttpMethod.Get, "http://foo/withDecimal") - .WithExactQueryString([new KeyValuePair("value", "3.456")]) + .WithExactQueryString([new("value", "3.456")]) .Respond("application/json", "Ok"); var fixture = RestService.For("http://foo", settings); @@ -713,8 +713,5 @@ internal static Stream GetTestFileStream(string relativeFilePath) /// The substring expected to appear in the stack trace. /// The actual stack trace string to inspect. /// A task that represents the asynchronous operation. - private static async Task AssertStackTraceContains(string expectedSubstring, string? actualString) - { - await Assert.That(actualString).Contains(expectedSubstring); - } + private static async Task AssertStackTraceContains(string expectedSubstring, string? actualString) => await Assert.That(actualString).Contains(expectedSubstring); } diff --git a/src/tests/Refit.Tests/RouteObjectWithUnreadableProperty.cs b/src/tests/Refit.Tests/RouteObjectWithUnreadableProperty.cs new file mode 100644 index 000000000..c88e34cd4 --- /dev/null +++ b/src/tests/Refit.Tests/RouteObjectWithUnreadableProperty.cs @@ -0,0 +1,21 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; + +namespace Refit.Tests; + +/// Route object with one readable and one non-public-getter property. +[SuppressMessage( + "Design", + "CA1044:Properties should not be write only", + Justification = "This fixture intentionally exposes a property that Refit must not read as a route value.")] +public sealed class RouteObjectWithUnreadableProperty +{ + /// Gets or sets the visible route value. + public string Visible { get; set; } = string.Empty; + + /// Gets or sets a route value that cannot be read publicly. + public string Hidden { private get; set; } = string.Empty; +} diff --git a/src/tests/Refit.Tests/SerializedContentTests.cs b/src/tests/Refit.Tests/SerializedContentTests.cs index fcd8a6767..1b5d7b3bd 100644 --- a/src/tests/Refit.Tests/SerializedContentTests.cs +++ b/src/tests/Refit.Tests/SerializedContentTests.cs @@ -116,6 +116,16 @@ public interface IAbstractRequestApi Task CreateWeapon([Body] AbstractCreateWeaponRequest request); } + /// Refit API used to verify resolver-provided polymorphic body metadata. + public interface IResolverPolymorphicRequestApi + { + /// Sends a weapon creation request. + /// The weapon request to serialize. + /// A task representing the asynchronous operation. + [Post("/weapons")] + Task CreateWeapon([Body] ResolverPolymorphicRequest request); + } + #if NET9_0_OR_GREATER /// Refit API used to verify JsonStringEnumMemberName handling on responses. public interface IIssue2067StatusApi @@ -163,7 +173,7 @@ public async Task WhenARequestRequiresABodyThenItDoesNotDeadlock(Type contentSer var fixture = RestService.For(BaseAddress, settings); - var fixtureTask = await RunTaskWithATimeLimit(fixture.CreateUser(new User())) + var fixtureTask = await RunTaskWithATimeLimit(fixture.CreateUser(new())) .ConfigureAwait(false); await Assert.That(fixtureTask.IsCompleted).IsTrue(); await Assert.That(fixtureTask.Status).IsEqualTo(TaskStatus.RanToCompletion); @@ -231,7 +241,7 @@ public async Task VerityDefaultSerializer() await Assert.That(settings.ContentSerializer).IsNotNull(); await Assert.That(settings.ContentSerializer).IsTypeOf(); - settings = new RefitSettings(new NewtonsoftJsonContentSerializer()); + settings = new(new NewtonsoftJsonContentSerializer()); await Assert.That(settings.ContentSerializer).IsNotNull(); await Assert.That(settings.ContentSerializer).IsTypeOf(); @@ -345,6 +355,37 @@ public async Task NewtonsoftJsonContentSerializer_GetFieldNameForProperty_Throws await Assert.That(exception!.ParamName).IsEqualTo("propertyInfo"); } + /// Verifies quoted charsets are unwrapped before Newtonsoft deserialization resolves the encoding. + /// A task that represents the asynchronous test operation. + [Test] + public async Task NewtonsoftJsonContentSerializer_FromHttpContentAsync_UnwrapsQuotedCharset() + { + var serializer = new NewtonsoftJsonContentSerializer(); + var content = new StringContent( + """{"Name":"Utf16 User"}""", + Encoding.Unicode, + "application/json"); + content.Headers.ContentType!.CharSet = "\"utf-16\""; + + var result = await serializer.FromHttpContentAsync(content); + + await Assert.That(result).IsNotNull(); + await Assert.That(result!.Name).IsEqualTo("Utf16 User"); + } + + /// Verifies invalid Newtonsoft response charsets fail with a clear operation exception. + /// A task that represents the asynchronous test operation. + [Test] + public async Task NewtonsoftJsonContentSerializer_FromHttpContentAsync_ThrowsForInvalidCharset() + { + var serializer = new NewtonsoftJsonContentSerializer(); + var content = new StringContent("""{"Name":"Invalid"}""", Encoding.UTF8, "application/json"); + content.Headers.ContentType!.CharSet = "not-a-real-charset"; + + await Assert.That(() => serializer.FromHttpContentAsync(content)) + .ThrowsExactly(); + } + /// Verifies that the System.Text.Json content serializer returns the JsonPropertyName for a property. /// A task that represents the asynchronous test operation. [Test] @@ -397,6 +438,18 @@ public async Task SystemTextJsonContentSerializer_DefaultOptions_InferBooleanObj await Assert.That(await Assert.That(result!.Value).IsTypeOf()).IsTrue(); } + /// Verifies false JSON object values are inferred as . + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_InferFalseObjectValues() + { + var result = SystemTextJsonSerializer.Deserialize( + """{"value":false}""", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(await Assert.That(result!.Value).IsTypeOf()).IsFalse(); + } + /// Verifies that integral JSON object values are inferred as . /// A task that represents the asynchronous test operation. [Test] @@ -435,7 +488,7 @@ public async Task SystemTextJsonContentSerializer_DefaultOptions_InferDateObject SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); await Assert.That(await Assert.That(result!.Value).IsTypeOf()) - .IsEqualTo(new DateTime(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc)); + .IsEqualTo(new(2024, 1, 2, 3, 4, 5, DateTimeKind.Utc)); } /// Verifies that string JSON object values are inferred as . @@ -586,50 +639,42 @@ public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializeEmpt /// Verifies that JSON null throws for a non-nullable enum. /// A task that represents the asynchronous test operation. [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_ThrowForNullNonNullableEnumValues() - { + public async Task SystemTextJsonContentSerializer_DefaultOptions_ThrowForNullNonNullableEnumValues() => await Assert.That( - () => SystemTextJsonSerializer.Deserialize( - "null", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions())) + () => SystemTextJsonSerializer.Deserialize( + "null", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions())) .ThrowsExactly(); - } /// Verifies that an empty string throws for a non-nullable enum. /// A task that represents the asynchronous test operation. [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_ThrowForEmptyNonNullableEnumValues() - { + public async Task SystemTextJsonContentSerializer_DefaultOptions_ThrowForEmptyNonNullableEnumValues() => await Assert.That( - () => SystemTextJsonSerializer.Deserialize( - "\"\"", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions())) + () => SystemTextJsonSerializer.Deserialize( + "\"\"", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions())) .ThrowsExactly(); - } /// Verifies that an unknown enum name throws. /// A task that represents the asynchronous test operation. [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_ThrowForInvalidEnumValues() - { + public async Task SystemTextJsonContentSerializer_DefaultOptions_ThrowForInvalidEnumValues() => await Assert.That( - () => SystemTextJsonSerializer.Deserialize( - "\"notAValue\"", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions())) + () => SystemTextJsonSerializer.Deserialize( + "\"notAValue\"", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions())) .ThrowsExactly(); - } /// Verifies that unexpected JSON tokens throw when parsing enums. /// A task that represents the asynchronous test operation. [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_ThrowForUnexpectedTokensWhenParsingEnums() - { + public async Task SystemTextJsonContentSerializer_DefaultOptions_ThrowForUnexpectedTokensWhenParsingEnums() => await Assert.That( - () => SystemTextJsonSerializer.Deserialize( - "true", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions())) + () => SystemTextJsonSerializer.Deserialize( + "true", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions())) .ThrowsExactly(); - } /// Verifies that undefined enum values are serialized as numbers. /// A task that represents the asynchronous test operation. @@ -643,6 +688,21 @@ public async Task SystemTextJsonContentSerializer_DefaultOptions_SerializeUndefi await Assert.That(json).IsEqualTo("999"); } + /// Verifies undefined enum dictionary keys are serialized with numeric property names. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_SerializeUndefinedEnumDictionaryKeysAsNumbers() + { + var json = SystemTextJsonSerializer.Serialize( + new Dictionary + { + [(CamelCaseEnum)999] = "unknown" + }, + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(json).IsEqualTo("""{"999":"unknown"}"""); + } + /// Verifies that lowercase enum names are serialized unchanged. /// A task that represents the asynchronous test operation. [Test] @@ -738,7 +798,7 @@ public async Task RestService_CanUseSourceGeneratedSystemTextJsonMetadata() var resolver = new TrackingTypeInfoResolver(SerializedContentJsonSerializerContext.Default); var settings = new RefitSettings( new SystemTextJsonContentSerializer( - new JsonSerializerOptions(JsonSerializerDefaults.Web) + new(JsonSerializerDefaults.Web) { TypeInfoResolver = resolver })) @@ -772,7 +832,7 @@ public async Task RestService_SerializesBodyUsingDeclaredPolymorphicBaseType() string? serializedBody = null; var settings = new RefitSettings( new SystemTextJsonContentSerializer( - new JsonSerializerOptions(JsonSerializerDefaults.Web) + new(JsonSerializerDefaults.Web) { TypeInfoResolver = PolymorphicRequestJsonSerializerContext.Default })) @@ -780,7 +840,7 @@ public async Task RestService_SerializesBodyUsingDeclaredPolymorphicBaseType() HttpMessageHandlerFactory = () => new StubHttpMessageHandler(async request => { serializedBody = await request.Content!.ReadAsStringAsync(); - return new HttpResponseMessage(HttpStatusCode.OK) + return new(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; @@ -795,6 +855,55 @@ public async Task RestService_SerializesBodyUsingDeclaredPolymorphicBaseType() await Assert.That(serializedBody).Contains("\"name\":\"Photon\""); } + /// Verifies resolver-provided polymorphism metadata is honored for declared abstract body types. + /// A task that represents the asynchronous test operation. + [Test] + public async Task RestService_SerializesBodyUsingResolverPolymorphismMetadata() + { + string? serializedBody = null; + var resolver = new DefaultJsonTypeInfoResolver(); + resolver.Modifiers.Add( + typeInfo => + { + if (typeInfo.Type != typeof(ResolverPolymorphicRequest)) + { + return; + } + + typeInfo.PolymorphismOptions = new() + { + TypeDiscriminatorPropertyName = "$type", + DerivedTypes = + { + new(typeof(ResolverLaserWeaponRequest), "laser") + } + }; + }); + var settings = new RefitSettings( + new SystemTextJsonContentSerializer( + new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = resolver + })) + { + HttpMessageHandlerFactory = () => new StubHttpMessageHandler(async request => + { + serializedBody = await request.Content!.ReadAsStringAsync(); + return new(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + }) + }; + + var api = RestService.For(BaseAddress, settings); + await api.CreateWeapon(new ResolverLaserWeaponRequest { Name = "Photon" }); + + await Assert.That(serializedBody).IsNotNull(); + await Assert.That(serializedBody).Contains("\"$type\":\"laser\""); + await Assert.That(serializedBody).Contains("\"name\":\"Photon\""); + } + /// Verifies that a request body uses the runtime type when the declared type is an interface. /// A task that represents the asynchronous test operation. [Test] @@ -806,7 +915,37 @@ public async Task RestService_SerializesBodyUsingRuntimeTypeWhenDeclaredTypeIsIn HttpMessageHandlerFactory = () => new StubHttpMessageHandler(async request => { serializedBody = await request.Content!.ReadAsStringAsync(); - return new HttpResponseMessage(HttpStatusCode.OK) + return new(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + }) + }; + + var api = RestService.For(BaseAddress, settings); + await api.CreateWeapon(new InterfaceLaserWeaponRequest { Name = "Photon" }); + + await Assert.That(serializedBody).IsNotNull(); + await Assert.That(serializedBody).IsEqualTo("""{"name":"Photon"}"""); + } + + /// Verifies resolver-backed options use runtime metadata when an interface body has no polymorphism metadata. + /// A task that represents the asynchronous test operation. + [Test] + public async Task RestService_SerializesInterfaceBodyUsingRuntimeTypeWithResolver() + { + string? serializedBody = null; + var settings = new RefitSettings( + new SystemTextJsonContentSerializer( + new(JsonSerializerDefaults.Web) + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver() + })) + { + HttpMessageHandlerFactory = () => new StubHttpMessageHandler(async request => + { + serializedBody = await request.Content!.ReadAsStringAsync(); + return new(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; @@ -831,7 +970,7 @@ public async Task RestService_SerializesBodyUsingRuntimeTypeWhenDeclaredTypeIsAb HttpMessageHandlerFactory = () => new StubHttpMessageHandler(async request => { serializedBody = await request.Content!.ReadAsStringAsync(); - return new HttpResponseMessage(HttpStatusCode.OK) + return new(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; @@ -882,6 +1021,28 @@ public async Task SystemTextJsonContentSerializer_RoundTripsEnumDictionaryKeys() await Assert.That(roundTrip[CamelCaseEnum.alreadyLowercase]).IsEqualTo("second"); } + /// Verifies nullable enum dictionary key conversion handles empty and non-empty property names. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_NullableEnumPropertyNamesRoundTripThroughConverter() + { + var (emptyNameValue, namedValue, json) = ReadAndWriteNullableEnumPropertyNames(); + + await Assert.That(emptyNameValue).IsNull(); + await Assert.That(namedValue).IsEqualTo(CamelCaseEnum.ValueOne); + await Assert.That(json).IsEqualTo("""{"":"empty","valueOne":"first"}"""); + } + + /// Verifies nullable enum values write null and concrete enum names through the custom converter. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_NullableEnumValuesWriteThroughConverter() + { + var json = WriteNullableEnumValues(); + + await Assert.That(json).IsEqualTo("""[null,"valueOne"]"""); + } + #if NET9_0_OR_GREATER /// Verifies that JsonStringEnumMemberName is honored when serializing and deserializing. /// A task that represents the asynchronous test operation. @@ -939,7 +1100,7 @@ public async Task RestService_DefaultSystemTextJsonSerializerHonorsJsonStringEnu HttpMessageHandlerFactory = () => new StubHttpMessageHandler(async request => { serializedBody = await request.Content!.ReadAsStringAsync(); - return new HttpResponseMessage(HttpStatusCode.OK) + return new(HttpStatusCode.OK) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; @@ -947,7 +1108,7 @@ public async Task RestService_DefaultSystemTextJsonSerializerHonorsJsonStringEnu }; var api = RestService.For(BaseAddress, settings); - await api.PostColorAsync(new EnumMemberNameColorEnvelope { Color = EnumMemberNameColor.Green }); + await api.PostColorAsync(new() { Color = EnumMemberNameColor.Green }); await Assert.That(serializedBody).IsEqualTo("""{"color":"GREEN"}"""); } @@ -963,6 +1124,57 @@ private static async Task> RunTaskWithATimeLimit(Task fixtureTa return fixtureTask; } + /// Exercises the nullable enum converter's property-name read and write paths. + /// The values read from property names and the JSON written through the converter. + private static (CamelCaseEnum? EmptyNameValue, CamelCaseEnum? NamedValue, string Json) ReadAndWriteNullableEnumPropertyNames() + { + var options = SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions(); + var converter = (System.Text.Json.Serialization.JsonConverter)options.GetConverter( + typeof(CamelCaseEnum?)); + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes("""{"":"empty","valueOne":"first"}""")); + reader.Read(); + reader.Read(); + var emptyNameValue = converter.ReadAsPropertyName(ref reader, typeof(CamelCaseEnum?), options); + reader.Read(); + reader.Read(); + var namedValue = converter.ReadAsPropertyName(ref reader, typeof(CamelCaseEnum?), options); + + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); +#pragma warning disable CS8607 // The nullable converter intentionally supports null keys by writing an empty property name. + converter.WriteAsPropertyName(writer, null, options); +#pragma warning restore CS8607 + writer.WriteStringValue("empty"); + converter.WriteAsPropertyName(writer, CamelCaseEnum.ValueOne, options); + writer.WriteStringValue("first"); + writer.WriteEndObject(); + } + + return (emptyNameValue, namedValue, Encoding.UTF8.GetString(stream.ToArray())); + } + + /// Writes nullable enum values through the converter directly. + /// The JSON written by the converter. + private static string WriteNullableEnumValues() + { + var options = SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions(); + var converter = (System.Text.Json.Serialization.JsonConverter)options.GetConverter( + typeof(CamelCaseEnum?)); + + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartArray(); + converter.Write(writer, null, options); + converter.Write(writer, CamelCaseEnum.ValueOne, options); + writer.WriteEndArray(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + /// Base request type used to verify polymorphic body serialization. [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] [JsonDerivedType(typeof(LaserWeaponRequest), "laser")] @@ -982,11 +1194,21 @@ public sealed class InterfaceLaserWeaponRequest : InterfaceCreateWeaponRequest public string? Name { get; set; } } + /// Base request whose polymorphism metadata is supplied by a resolver in tests. + public abstract class ResolverPolymorphicRequest + { + /// Gets or sets the weapon name. + public string? Name { get; set; } + } + + /// Concrete request used with resolver-provided polymorphism metadata. + public sealed class ResolverLaserWeaponRequest : ResolverPolymorphicRequest; + /// Marker abstract request used to verify serialization when the declared type is abstract. [SuppressMessage( - "Design", - "SST1436:Add members to type or remove it", - Justification = "Intentional empty fixture type used to verify Refit serialization when the declared type is abstract.")] + "RoslynCommonAnalyzers", + "SST1436:Add members to a type or remove it", + Justification = "Intentional empty abstract fixture used to verify declared-type serialization behavior.")] public abstract class AbstractCreateWeaponRequest; /// Concrete request derived from . @@ -1049,7 +1271,7 @@ protected override async Task SendAsync( var responseContent = await Asserts(content!).ConfigureAwait(false); - return new HttpResponseMessage(HttpStatusCode.OK) { Content = responseContent }; + return new(HttpStatusCode.OK) { Content = responseContent }; } } diff --git a/src/tests/Refit.Tests/SomeNamespace/SomeType.cs b/src/tests/Refit.Tests/SomeNamespace/SomeType.cs index 321ab61d9..11157f190 100644 --- a/src/tests/Refit.Tests/SomeNamespace/SomeType.cs +++ b/src/tests/Refit.Tests/SomeNamespace/SomeType.cs @@ -1,10 +1,11 @@ // Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; - namespace Refit.Tests.SomeNamespace; /// Empty response type used to verify Refit handling of global-aliased usings. -[SuppressMessage("Design", "SST1436:Add members to type or remove it", Justification = "Intentional empty fixture response type used to verify Refit global-alias using handling.")] +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "RoslynCommonAnalyzers", + "SST1436:Add members to a type or remove it", + Justification = "Intentional empty response fixture used to verify global-aliased using handling.")] public class SomeType; diff --git a/src/tests/Refit.Tests/SomeOtherType.cs b/src/tests/Refit.Tests/SomeOtherType.cs index e3b2d99bf..dc3ba6a1a 100644 --- a/src/tests/Refit.Tests/SomeOtherType.cs +++ b/src/tests/Refit.Tests/SomeOtherType.cs @@ -1,10 +1,11 @@ // Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; - namespace Refit.Tests.Common; /// Empty response type used to verify namespace overlap deserialization in Refit. -[SuppressMessage("Design", "SST1436:Add members to type or remove it", Justification = "Intentional empty fixture response type used to verify Refit namespace overlap handling.")] +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "RoslynCommonAnalyzers", + "SST1436:Add members to a type or remove it", + Justification = "Intentional empty response fixture used to verify namespace overlap handling without changing the public shape.")] public class SomeOtherType; diff --git a/src/tests/Refit.Tests/Tcp/Client.cs b/src/tests/Refit.Tests/Tcp/Client.cs index 62f69e9d3..01e2027aa 100644 --- a/src/tests/Refit.Tests/Tcp/Client.cs +++ b/src/tests/Refit.Tests/Tcp/Client.cs @@ -1,13 +1,14 @@ // Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. // ReactiveUI and Contributors licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -using System.Diagnostics.CodeAnalysis; - namespace Refit.Tests.Tcp; /// /// A TCP client fixture used to verify that produces a name /// distinct from the identically named client in the namespace. /// -[SuppressMessage("Design", "SST1436:Add members to type or remove it", Justification = "Intentional empty type marker; used only as a generic type argument to UniqueName.ForType.")] +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "RoslynCommonAnalyzers", + "SST1436:Add members to a type or remove it", + Justification = "Intentional empty fixture type used to verify unique-name generation across namespaces.")] public sealed class Client; diff --git a/src/tests/Refit.Tests/TestHttpMessageHandler.cs b/src/tests/Refit.Tests/TestHttpMessageHandler.cs index 89dbecd93..e9c046c9c 100644 --- a/src/tests/Refit.Tests/TestHttpMessageHandler.cs +++ b/src/tests/Refit.Tests/TestHttpMessageHandler.cs @@ -56,6 +56,6 @@ protected override async Task SendAsync( CancellationToken = cancellationToken; MessagesSent++; - return new HttpResponseMessage(HttpStatusCode.OK) { Content = ContentFactory() }; + return new(HttpStatusCode.OK) { Content = ContentFactory() }; } } diff --git a/src/tests/Refit.Tests/TestUrlParameterFormatter.cs b/src/tests/Refit.Tests/TestUrlParameterFormatter.cs index 2e076873d..b9b0ae345 100644 --- a/src/tests/Refit.Tests/TestUrlParameterFormatter.cs +++ b/src/tests/Refit.Tests/TestUrlParameterFormatter.cs @@ -17,18 +17,12 @@ public class TestUrlParameterFormatter : IUrlParameterFormatter /// Initializes a new instance of the class. /// The constant value to return for every parameter. - public TestUrlParameterFormatter(string constantOutput) - { - _constantParameterOutput = constantOutput; - } + public TestUrlParameterFormatter(string constantOutput) => _constantParameterOutput = constantOutput; /// Returns the configured constant value. /// The parameter value to format. /// The attribute provider for the parameter. /// The declared type of the parameter. /// The configured constant value. - public string Format(object? value, ICustomAttributeProvider attributeProvider, Type type) - { - return _constantParameterOutput; - } + public string Format(object? value, ICustomAttributeProvider attributeProvider, Type type) => _constantParameterOutput; } diff --git a/src/tests/Refit.Tests/TypeCollisionApiA.cs b/src/tests/Refit.Tests/TypeCollisionApiA.cs index 0f57b79d4..a0f7c2fa1 100644 --- a/src/tests/Refit.Tests/TypeCollisionApiA.cs +++ b/src/tests/Refit.Tests/TypeCollisionApiA.cs @@ -13,8 +13,5 @@ public static class TypeCollisionApiA { /// Creates a Refit client for . /// A configured instance. - public static ITypeCollisionApiA Create() - { - return RestService.For("http://somewhere.com"); - } + public static ITypeCollisionApiA Create() => RestService.For("http://somewhere.com"); } diff --git a/src/tests/Refit.Tests/TypeCollisionApiB.cs b/src/tests/Refit.Tests/TypeCollisionApiB.cs index 78a8316fc..6904cba8d 100644 --- a/src/tests/Refit.Tests/TypeCollisionApiB.cs +++ b/src/tests/Refit.Tests/TypeCollisionApiB.cs @@ -12,8 +12,5 @@ public static class TypeCollisionApiB { /// Creates a Refit client for . /// A configured instance. - public static ITypeCollisionApiB Create() - { - return RestService.For("http://somewhere.com"); - } + public static ITypeCollisionApiB Create() => RestService.For("http://somewhere.com"); } diff --git a/src/tests/Refit.Tests/UniqueNameTests.cs b/src/tests/Refit.Tests/UniqueNameTests.cs index 159f57f26..0ab137c3a 100644 --- a/src/tests/Refit.Tests/UniqueNameTests.cs +++ b/src/tests/Refit.Tests/UniqueNameTests.cs @@ -65,4 +65,17 @@ public async Task NestedClassesHaveUniqueNames() await Assert.That(name2).IsNotEqualTo(name1); } + + /// Verifies service keys are included only when present. + /// A task that represents the asynchronous test operation. + [Test] + public async Task ServiceKeysAreIncludedOnlyWhenPresent() + { + var withoutKey = UniqueName.ForType(null); + var emptyKey = UniqueName.ForType(string.Empty); + var withKey = UniqueName.ForType("primary"); + + await Assert.That(emptyKey).IsEqualTo(withoutKey); + await Assert.That(withKey).Contains("ServiceKey=primary"); + } } diff --git a/src/tests/Refit.Tests/ValueStringBuilderTests.cs b/src/tests/Refit.Tests/ValueStringBuilderTests.cs new file mode 100644 index 000000000..fba5cf73f --- /dev/null +++ b/src/tests/Refit.Tests/ValueStringBuilderTests.cs @@ -0,0 +1,411 @@ +// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. +// ReactiveUI and Contributors licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace Refit.Tests; + +/// Tests for allocation-conscious internal helper types. +public sealed class ValueStringBuilderTests +{ + /// Verifies common append and insert operations on a stack-backed builder. + /// A task representing the asynchronous test. + [Test] + public async Task StackBackedBuilderSupportsAppendInsertAndIndexer() + { + var result = BuildInsertedString(); + + await Assert.That(result).IsEqualTo("abBXXcde!!!"); + } + + /// Verifies span APIs and growth from the initial stack buffer. + /// A task representing the asynchronous test. + [Test] + public async Task BuilderGrowsAndExposesRequestedSpans() + { + var (text, length, capacity, suffix, middle, terminator) = BuildSpanSummary(); + + await Assert.That(text).IsEqualTo("abcdef"); + await Assert.That(length).IsEqualTo(6); + await Assert.That(capacity).IsGreaterThanOrEqualTo(6); + await Assert.That(suffix).IsEqualTo("cdef"); + await Assert.That(middle).IsEqualTo("bcd"); + await Assert.That(terminator).IsEqualTo('\0'); + } + + /// Verifies copying succeeds when the destination has enough room. + /// A task representing the asynchronous test. + [Test] + public async Task TryCopyToCopiesWhenDestinationHasEnoughRoom() + { + var (success, charsWritten, text) = TryCopyToLargeDestination(); + + await Assert.That(success).IsTrue(); + await Assert.That(charsWritten).IsEqualTo(4); + await Assert.That(text).IsEqualTo("copy"); + } + + /// Verifies copying reports failure when the destination is too small. + /// A task representing the asynchronous test. + [Test] + public async Task TryCopyToFailsWhenDestinationIsTooSmall() + { + var (success, charsWritten) = TryCopyToSmallDestination(); + + await Assert.That(success).IsFalse(); + await Assert.That(charsWritten).IsEqualTo(0); + } + + /// Verifies pooled construction, explicit capacity, and no-op null appends. + /// A task representing the asynchronous test. + [Test] + public async Task PooledBuilderSupportsCapacityAndNullNoOps() + { + var (text, capacity) = BuildWithPooledInitialCapacity(); + + await Assert.That(text).IsEqualTo("z"); + await Assert.That(capacity).IsGreaterThanOrEqualTo(2); + } + + /// Verifies less common growth and span termination paths. + /// A task representing the asynchronous test. + [Test] + public async Task BuilderCoversGrowthAndTerminationBranches() + { + var (text, length, terminated, first) = BuildThroughGrowthBranches(); + + await Assert.That(text).IsEqualTo("yyxbcdefghijj"); + await Assert.That(length).IsEqualTo(13); + await Assert.That(terminated).IsEqualTo('\0'); + await Assert.That(first).IsEqualTo('y'); + } + + /// Verifies each append and insert overload grows when its current buffer is full. + /// A task representing the asynchronous test. + [Test] + public async Task BuilderGrowthBranchesAreCoveredPerOverload() + { + var text = BuildWithEveryGrowthOverload(); + + await Assert.That(text).IsEqualTo("bba|abc|ab|abb|abc|ab"); + } + + /// Verifies the enumerable peek helper distinguishes empty, single, and multi-item sequences. + /// A task representing the asynchronous test. + [Test] + public async Task TryGetSingleReportsEmptySingleAndMany() + { + var emptyState = Array.Empty().TryGetSingle(out var emptyValue); + var singleState = new[] { 42 }.TryGetSingle(out var singleValue); + var manyState = new[] { 1, 2 }.TryGetSingle(out var manyValue); + + await Assert.That(emptyState).IsEqualTo(EnumerablePeek.Empty); + await Assert.That(emptyValue).IsEqualTo(0); + await Assert.That(singleState).IsEqualTo(EnumerablePeek.Single); + await Assert.That(singleValue).IsEqualTo(42); + await Assert.That(manyState).IsEqualTo(EnumerablePeek.Many); + await Assert.That(manyValue).IsEqualTo(0); + } + + /// Verifies request-execution option value equality. + /// A task representing the asynchronous test. + [Test] + public async Task RequestExecutionOptionsCompareAllFields() + { + var value = new RequestExecutionOptions(true, false, true, false); + var same = new RequestExecutionOptions(true, false, true, false); + var differentApiResponse = new RequestExecutionOptions(false, false, true, false); + var differentDispose = new RequestExecutionOptions(true, true, true, false); + var differentBuffer = new RequestExecutionOptions(true, false, false, false); + var differentAuthorization = new RequestExecutionOptions(true, false, true, true); + + await Assert.That(value == same).IsTrue(); + await Assert.That(value != same).IsFalse(); + await Assert.That(value.Equals((object)same)).IsTrue(); + await Assert.That(value.Equals("not-options")).IsFalse(); + await Assert.That(value.GetHashCode()).IsEqualTo(same.GetHashCode()); + await Assert.That(value.Equals(differentApiResponse)).IsFalse(); + await Assert.That(value.Equals(differentDispose)).IsFalse(); + await Assert.That(value.Equals(differentBuffer)).IsFalse(); + await Assert.That(value.Equals(differentAuthorization)).IsFalse(); + } + + /// Verifies closed generic method keys compare method definitions and type arguments. + /// A task representing the asynchronous test. + [Test] + public async Task CloseGenericMethodKeyComparesMethodAndTypes() + { + var openMethod = typeof(IGenericMethodKeyFixture).GetMethod(nameof(IGenericMethodKeyFixture.GenericMethod))!; + var otherOpenMethod = typeof(IGenericMethodKeyFixture).GetMethod(nameof(IGenericMethodKeyFixture.OtherGenericMethod))!; + var value = new CloseGenericMethodKey(openMethod, [typeof(string), typeof(int)]); + var same = new CloseGenericMethodKey(openMethod, [typeof(string), typeof(int)]); + var differentMethod = new CloseGenericMethodKey(otherOpenMethod, [typeof(string), typeof(int)]); + var differentLength = new CloseGenericMethodKey(openMethod, [typeof(string)]); + var differentType = new CloseGenericMethodKey(openMethod, [typeof(string), typeof(long)]); + + await Assert.That(value.Equals(same)).IsTrue(); + await Assert.That(value.Equals((object)same)).IsTrue(); + await Assert.That(value.Equals("not-key")).IsFalse(); + await Assert.That(value.GetHashCode()).IsEqualTo(same.GetHashCode()); + await Assert.That(value.Equals(differentMethod)).IsFalse(); + await Assert.That(value.Equals(differentLength)).IsFalse(); + await Assert.That(value.Equals(differentType)).IsFalse(); + } + + /// Builds a string using append, insert, span, and indexer operations. + /// The built string. + private static string BuildInsertedString() + { + var builder = new ValueStringBuilder(stackalloc char[4]); + try + { + builder.Append('a'); + builder.Append("b"); + builder.Append("cd".AsSpan()); + builder.AppendSpan(1)[0] = 'e'; + builder[1] = 'B'; + builder.Insert(2, 'X', 2); + builder.Insert(1, "b"); + builder.Append('!', 3); + return builder.ToString(); + } + finally + { + builder.Dispose(); + } + } + + /// Builds a string and captures span slices plus the null terminator. + /// A summary of the builder state before disposal. + private static (string Text, int Length, int Capacity, string Suffix, string Middle, char Terminator) BuildSpanSummary() + { + var builder = new ValueStringBuilder(stackalloc char[2]); + try + { + builder.EnsureCapacity(1); + builder.Append("ab"); + builder.Append("cdef"); + builder.GetPinnableReference(terminate: true); + + var text = builder.AsSpan().ToString(); + var suffix = builder.AsSpan(2).ToString(); + var middle = builder.AsSpan(1, 3).ToString(); + var terminator = builder.RawChars[builder.Length]; + var length = builder.Length; + var capacity = builder.Capacity; + + return (text, length, capacity, suffix, middle, terminator); + } + finally + { + builder.Dispose(); + } + } + + /// Copies builder contents into a destination large enough to hold them. + /// The copy result and copied text. + private static (bool Success, int CharsWritten, string Text) TryCopyToLargeDestination() + { + var builder = new ValueStringBuilder(stackalloc char[4]); + try + { + builder.Append("copy"); + Span destination = stackalloc char[8]; + + var success = builder.TryCopyTo(destination, out var charsWritten); + + return (success, charsWritten, destination[..charsWritten].ToString()); + } + finally + { + builder.Dispose(); + } + } + + /// Attempts to copy builder contents into a destination that is too small. + /// The copy result. + private static (bool Success, int CharsWritten) TryCopyToSmallDestination() + { + var builder = new ValueStringBuilder(stackalloc char[4]); + try + { + builder.Append("copy"); + Span destination = stackalloc char[2]; + + var success = builder.TryCopyTo(destination, out var charsWritten); + + return (success, charsWritten); + } + finally + { + builder.Dispose(); + } + } + + /// Builds a short string using a pooled initial capacity. + /// The built text and capacity. + private static (string Text, int Capacity) BuildWithPooledInitialCapacity() + { + var builder = new ValueStringBuilder(2); + try + { + builder.Append(null); + builder.Insert(0, null); + builder.Append('z'); + var capacity = builder.Capacity; + var text = builder.ToString(); + + return (text, capacity); + } + finally + { + builder.Dispose(); + } + } + + /// Builds a string through growth paths that are uncommon in request formatting. + /// The built text, length, terminator, and first character. + private static (string Text, int Length, char Terminator, char First) BuildThroughGrowthBranches() + { + var builder = new ValueStringBuilder(stackalloc char[1]); + try + { + builder.Length = 0; + builder.EnsureCapacity(4); + builder.Append('x'); + builder.Insert(0, 'y', 2); + builder.Insert(3, "bcdef"); + builder.Append("ghij".AsSpan()); + builder.Append('k'); + builder.Length--; + builder.AppendSpan(1)[0] = 'j'; + + var first = builder.GetPinnableReference(); + builder.AsSpan(terminate: true); + var length = builder.Length; + var terminated = builder.RawChars[length]; + var text = builder.AsSpan().ToString(); + + return (text, length, terminated, first); + } + finally + { + builder.Dispose(); + } + } + + /// Builds strings through the grow branch of each mutating overload. + /// The combined text. + private static string BuildWithEveryGrowthOverload() + { + var insertChars = BuildInsertCharsGrowth(); + var insertString = BuildInsertStringGrowth(); + var appendChar = BuildAppendCharGrowth(); + var appendRepeatedChar = BuildAppendRepeatedCharGrowth(); + var appendSpan = BuildAppendSpanGrowth(); + var pooled = BuildPooledGrowth(); + + return string.Join('|', insertChars, insertString, appendChar, appendRepeatedChar, appendSpan, pooled); + } + + /// Builds through repeated-character insert growth. + /// The built text. + private static string BuildInsertCharsGrowth() + { + var builder = new ValueStringBuilder(stackalloc char[1]); + try + { + builder.Append('a'); + builder.Insert(0, 'b', 2); + return builder.ToString(); + } + finally + { + builder.Dispose(); + } + } + + /// Builds through string insert growth. + /// The built text. + private static string BuildInsertStringGrowth() + { + var builder = new ValueStringBuilder(stackalloc char[1]); + try + { + builder.Append('a'); + builder.Insert(1, "bc"); + return builder.ToString(); + } + finally + { + builder.Dispose(); + } + } + + /// Builds through single-character append growth. + /// The built text. + private static string BuildAppendCharGrowth() + { + var builder = new ValueStringBuilder(stackalloc char[1]); + try + { + builder.Append('a'); + builder.Append('b'); + return builder.ToString(); + } + finally + { + builder.Dispose(); + } + } + + /// Builds through repeated-character append growth. + /// The built text. + private static string BuildAppendRepeatedCharGrowth() + { + var builder = new ValueStringBuilder(stackalloc char[1]); + try + { + builder.Append('a'); + builder.Append('b', 2); + return builder.ToString(); + } + finally + { + builder.Dispose(); + } + } + + /// Builds through span append growth. + /// The built text. + private static string BuildAppendSpanGrowth() + { + var builder = new ValueStringBuilder(stackalloc char[1]); + try + { + builder.Append('a'); + builder.Append("bc".AsSpan()); + return builder.ToString(); + } + finally + { + builder.Dispose(); + } + } + + /// Builds through pooled-buffer growth to return an old rented array. + /// The built text. + private static string BuildPooledGrowth() + { + var builder = new ValueStringBuilder(1); + try + { + builder.Append('a'); + builder.Append('b'); + return builder.ToString(); + } + finally + { + builder.Dispose(); + } + } +} diff --git a/src/tests/Refit.Tests/Verifiers/CSharpIncrementalSourceGeneratorVerifier`1.cs b/src/tests/Refit.Tests/Verifiers/CSharpIncrementalSourceGeneratorVerifier`1.cs deleted file mode 100644 index 16f39f777..000000000 --- a/src/tests/Refit.Tests/Verifiers/CSharpIncrementalSourceGeneratorVerifier`1.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. -// ReactiveUI and Contributors licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. -using System.Collections.Generic; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Testing; -using Microsoft.CodeAnalysis.Testing; - -namespace Refit.Tests; - -/// Verifier for incremental source generator tests. -/// The incremental generator under test. -public static class CSharpIncrementalSourceGeneratorVerifier - where TIncrementalGenerator : IIncrementalGenerator, new() -{ - /// Test harness for the incremental source generator. - public class Test : CSharpSourceGeneratorTest - { - /// Initializes a new instance of the class. - public Test() - { - SolutionTransforms.Add( - (solution, projectId) => - { - var compilationOptions = solution.GetProject(projectId)!.CompilationOptions; - compilationOptions = compilationOptions!.WithSpecificDiagnosticOptions( - compilationOptions.SpecificDiagnosticOptions.SetItems( - CSharpVerifierHelper.NullableWarnings)); - return solution.WithProjectCompilationOptions( - projectId, - compilationOptions); - }); - } - - /// Gets the source generators. - /// The source generators to run. - protected override IEnumerable GetSourceGenerators() - { - yield return new TIncrementalGenerator().AsSourceGenerator().GetGeneratorType(); - } - - /// Creates the parse options. - /// The parse options configured with the preview language version. - protected override ParseOptions CreateParseOptions() - { - var parseOptions = (CSharpParseOptions)base.CreateParseOptions(); - return parseOptions.WithLanguageVersion(LanguageVersion.Preview); - } - } -} diff --git a/src/tests/Refit.Tests/Verifiers/CSharpSourceGeneratorVerifier`1.cs b/src/tests/Refit.Tests/Verifiers/CSharpSourceGeneratorVerifier`1.cs deleted file mode 100644 index 009b36c3e..000000000 --- a/src/tests/Refit.Tests/Verifiers/CSharpSourceGeneratorVerifier`1.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. -// ReactiveUI and Contributors licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Testing; -using Microsoft.CodeAnalysis.Testing; - -namespace Refit.Tests; - -/// Verifier for source generator tests. -/// The source generator under test. -public static class CSharpSourceGeneratorVerifier - where TSourceGenerator : ISourceGenerator, new() -{ - /// Test harness for the source generator. - public class Test : CSharpSourceGeneratorTest - { - /// Initializes a new instance of the class. - public Test() - { - SolutionTransforms.Add( - (solution, projectId) => - { - var compilationOptions = solution.GetProject(projectId)!.CompilationOptions; - compilationOptions = compilationOptions!.WithSpecificDiagnosticOptions( - compilationOptions.SpecificDiagnosticOptions.SetItems( - CSharpVerifierHelper.NullableWarnings)); - return solution.WithProjectCompilationOptions( - projectId, - compilationOptions); - }); - } - } -} diff --git a/src/tests/Refit.Tests/Verifiers/CSharpVerifierHelper.cs b/src/tests/Refit.Tests/Verifiers/CSharpVerifierHelper.cs deleted file mode 100644 index 0ca8e5c02..000000000 --- a/src/tests/Refit.Tests/Verifiers/CSharpVerifierHelper.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved. -// ReactiveUI and Contributors licenses this file to you under the MIT license. -// See the LICENSE file in the project root for full license information. -using System; -using System.Collections.Immutable; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; - -namespace Refit.Tests; - -/// Helpers for configuring analyzer and source-generator test verifiers. -internal static class CSharpVerifierHelper -{ - /// - /// Gets the compiler nullable diagnostic IDs mapped to . - /// By default, the compiler reports diagnostics for nullable reference types at - /// , and the analyzer test framework defaults to only validating - /// diagnostics at . This map enables all nullability warnings for - /// default validation during analyzer and code fix tests. - /// - internal static ImmutableDictionary NullableWarnings { get; } = - GetNullableWarningsFromCompiler(); - - /// Parses the compiler nullable diagnostic options and maps them to errors. - /// A map of nullability diagnostic IDs to . - private static ImmutableDictionary GetNullableWarningsFromCompiler() - { - string[] args = ["/warnaserror:nullable"]; - var commandLineArguments = CSharpCommandLineParser.Default.Parse( - args, - baseDirectory: Environment.CurrentDirectory, - sdkDirectory: Environment.CurrentDirectory); - return commandLineArguments.CompilationOptions.SpecificDiagnosticOptions; - } -} diff --git a/src/tests/Refit.Tests/XmlContentSerializerTests.cs b/src/tests/Refit.Tests/XmlContentSerializerTests.cs index b743cfa41..3112229ae 100644 --- a/src/tests/Refit.Tests/XmlContentSerializerTests.cs +++ b/src/tests/Refit.Tests/XmlContentSerializerTests.cs @@ -60,7 +60,7 @@ public async Task ShouldSerializeToXmlUsingAttributeOverrides() var serializerSettings = new XmlContentSerializerSettings(); var attributes = new XmlAttributes { - XmlRoot = new XmlRootAttribute(overridenRootElementName) + XmlRoot = new(overridenRootElementName) }; serializerSettings.XmlAttributeOverrides.Add(dto.GetType(), attributes); var sut = new XmlContentSerializer(serializerSettings); @@ -82,7 +82,7 @@ public async Task ShouldSerializeToXmlUsingNamespaceOverrides() var dto = BuildDto(); var serializerSettings = new XmlContentSerializerSettings { - XmlNamespaces = new XmlSerializerNamespaces() + XmlNamespaces = new() }; serializerSettings.XmlNamespaces.Add(prefix, "https://google.com"); var sut = new XmlContentSerializer(serializerSettings); @@ -101,7 +101,7 @@ public async Task ShouldDeserializeFromXmlAsync() { var serializerSettings = new XmlContentSerializerSettings { - XmlNamespaces = new XmlSerializerNamespaces() + XmlNamespaces = new() }; var sut = new XmlContentSerializer(serializerSettings); @@ -119,9 +119,9 @@ public async Task XmlEncodingShouldMatchWriterSettingAsync() var encoding = Encoding.UTF32; var serializerSettings = new XmlContentSerializerSettings { - XmlReaderWriterSettings = new XmlReaderWriterSettings + XmlReaderWriterSettings = new() { - WriterSettings = new XmlWriterSettings { Encoding = encoding } + WriterSettings = new() { Encoding = encoding } } }; var sut = new XmlContentSerializer(serializerSettings); @@ -133,21 +133,59 @@ public async Task XmlEncodingShouldMatchWriterSettingAsync() await Assert.That(documentEncoding).IsEqualTo(encoding.WebName); } + /// Verifies constructor, serialization, and field-name guard branches. + /// A task representing the asynchronous test. + [Test] + public async Task GuardsAndXmlFieldNamesShouldWork() + { + var sut = new XmlContentSerializer(); + + await Assert.That(() => new XmlContentSerializer(null!)).ThrowsExactly(); + await Assert.That(() => sut.ToHttpContent(null!)).ThrowsExactly(); + await Assert.That(() => sut.GetFieldNameForProperty(null!)).ThrowsExactly(); + await Assert.That(sut.GetFieldNameForProperty(typeof(XmlFieldNameDto).GetProperty(nameof(XmlFieldNameDto.Element))!)) + .IsEqualTo("element-name"); + await Assert.That(sut.GetFieldNameForProperty(typeof(XmlFieldNameDto).GetProperty(nameof(XmlFieldNameDto.Attribute))!)) + .IsEqualTo("attribute-name"); + await Assert.That(sut.GetFieldNameForProperty(typeof(XmlFieldNameDto).GetProperty(nameof(XmlFieldNameDto.Unannotated))!)) + .IsNull(); + } + + /// Verifies XML reader/writer settings constructors apply async overrides and null guards. + /// A task representing the asynchronous test. + [Test] + public async Task XmlReaderWriterSettingsConstructorsAndGuardsShouldWork() + { + var readerSettings = new XmlReaderSettings(); + var writerSettings = new XmlWriterSettings(); + + var readerOnly = new XmlReaderWriterSettings(readerSettings); + var writerOnly = new XmlReaderWriterSettings(writerSettings); + var both = new XmlReaderWriterSettings(new XmlReaderSettings(), new XmlWriterSettings()); + + await Assert.That(readerOnly.ReaderSettings).IsSameReferenceAs(readerSettings); + await Assert.That(readerOnly.ReaderSettings.Async).IsTrue(); + await Assert.That(writerOnly.WriterSettings).IsSameReferenceAs(writerSettings); + await Assert.That(writerOnly.WriterSettings.Async).IsTrue(); + await Assert.That(both.ReaderSettings.Async).IsTrue(); + await Assert.That(both.WriterSettings.Async).IsTrue(); + await Assert.That(() => both.ReaderSettings = null!).ThrowsExactly(); + await Assert.That(() => both.WriterSettings = null!).ThrowsExactly(); + } + /// Builds a populated instance for the tests. /// A new . [System.Diagnostics.CodeAnalysis.SuppressMessage( "Major Code Smell", "S6566:Prefer using \"DateTimeOffset\" instead of \"DateTime\"", Justification = "Test intentionally exercises DateTime XML round-trip via XmlConvert.ToDateTime.")] - private static Dto BuildDto() - { - return new Dto + private static Dto BuildDto() => + new() { CreatedOn = DateTime.UtcNow, Identifier = Guid.NewGuid().ToString(), Name = "Test Dto Object" }; - } /// A simple data transfer object used to exercise XML serialization. public class Dto @@ -162,4 +200,19 @@ public class Dto [XmlElement(Namespace = "https://google.com")] public string? Name { get; set; } } + + /// DTO used to verify XML field-name lookup. + public class XmlFieldNameDto + { + /// Gets or sets an XML element-backed value. + [XmlElement("element-name")] + public string? Element { get; set; } + + /// Gets or sets an XML attribute-backed value. + [XmlAttribute("attribute-name")] + public string? Attribute { get; set; } + + /// Gets or sets an unannotated value. + public string? Unannotated { get; set; } + } } diff --git a/src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#IServiceWithoutNamespace.g.verified.cs b/src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#IServiceWithoutNamespace.g.verified.cs deleted file mode 100644 index c9c94c1f8..000000000 --- a/src/tests/Refit.Tests/_snapshots/InterfaceStubGeneratorTests.GenerateInterfaceStubsWithoutNamespaceSmokeTest#IServiceWithoutNamespace.g.verified.cs +++ /dev/null @@ -1,52 +0,0 @@ -//HintName: IServiceWithoutNamespace.g.cs -#nullable disable -#pragma warning disable -namespace Refit.Implementation -{ - - partial class Generated - { - - /// - [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - [global::System.Diagnostics.DebuggerNonUserCode] - [global::RefitInternalGenerated.PreserveAttribute] - [global::System.Reflection.Obfuscation(Exclude=true)] - [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - partial class IServiceWithoutNamespace - : global::IServiceWithoutNamespace - { - /// - public global::System.Net.Http.HttpClient Client { get; } - readonly global::Refit.IRequestBuilder requestBuilder; - - /// - public IServiceWithoutNamespace(global::System.Net.Http.HttpClient client, global::Refit.IRequestBuilder requestBuilder) - { - Client = client; - this.requestBuilder = requestBuilder; - } - - - /// - public async global::System.Threading.Tasks.Task GetRoot() - { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("GetRoot", global::System.Array.Empty() ); - - await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); - } - - /// - public async global::System.Threading.Tasks.Task PostRoot() - { - var ______arguments = global::System.Array.Empty(); - var ______func = requestBuilder.BuildRestResultFuncForMethod("PostRoot", global::System.Array.Empty() ); - - await ((global::System.Threading.Tasks.Task)______func(this.Client, ______arguments)).ConfigureAwait(false); - } - } - } -} - -#pragma warning restore