diff --git a/.editorconfig b/.editorconfig index 3e1467250..979c5aa2f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -203,18 +203,18 @@ dotnet_diagnostic.CA1027.severity = error # Mark enums with FlagsAttribute dotnet_diagnostic.CA1028.severity = error # Enum storage should be Int32 dotnet_diagnostic.CA1030.severity = none # Use events where appropriate — we use Rx observables instead of CLR events dotnet_diagnostic.CA1031.severity = none # Do not catch general exception types — required at logging/dispose/IO boundaries -dotnet_diagnostic.CA1032.severity = error # Implement standard exception constructors +dotnet_diagnostic.CA1032.severity = none # Implement standard exception constructors — covered by SST1488 dotnet_diagnostic.CA1033.severity = none # Interface methods should be callable by child types — explicit interface implementations are a deliberate design choice dotnet_diagnostic.CA1034.severity = none # Nested types should not be visible — public nested types are sometimes the cleanest API (e.g. interface-scoped exception helpers) dotnet_diagnostic.CA1036.severity = none # Override methods on comparable types — relational operators rarely meaningful for our types dotnet_diagnostic.CA1040.severity = none # Avoid empty interfaces — duplicate of SST1437 (canonical); marker interfaces (IActivatableView etc.) are deliberate public API -dotnet_diagnostic.CA1041.severity = error # Provide ObsoleteAttribute message +dotnet_diagnostic.CA1041.severity = none # Provide ObsoleteAttribute message — covered by SST2308 dotnet_diagnostic.CA1043.severity = error # Use integral or string argument for indexers -dotnet_diagnostic.CA1044.severity = error # Properties should not be write only +dotnet_diagnostic.CA1044.severity = none # Properties should not be write only — covered by SST1421 dotnet_diagnostic.CA1045.severity = none # Do not pass types by reference — we deliberately use ref-passing static helpers so they carry only the data they touch; data-oriented layout is the default here dotnet_diagnostic.CA1046.severity = error # Do not overload operator equals on reference types -dotnet_diagnostic.CA1047.severity = error # Do not declare protected member in sealed type -dotnet_diagnostic.CA1048.severity = error # Do not declare virtual members in sealed types +dotnet_diagnostic.CA1047.severity = none # Do not declare protected member in sealed type — covered by SST1427 +dotnet_diagnostic.CA1048.severity = none # Do not declare virtual members in sealed types — covered by SST1491 dotnet_diagnostic.CA1050.severity = error # Declare types in namespaces dotnet_diagnostic.CA1051.severity = none # Duplicate of SST1401 (canonical) — do not declare visible instance fields dotnet_diagnostic.CA1052.severity = error # Static holder types should be sealed @@ -227,9 +227,9 @@ dotnet_diagnostic.CA1059.severity = error # Members should not expose certain co dotnet_diagnostic.CA1060.severity = error # Move P/Invokes to NativeMethods class dotnet_diagnostic.CA1061.severity = error # Do not hide base class methods dotnet_diagnostic.CA1062.severity = none # Validate arguments of public methods - Nullable=enable + we own every consumer, so the compiler already guarantees non-null params -dotnet_diagnostic.CA1063.severity = error # Implement IDisposable correctly +dotnet_diagnostic.CA1063.severity = none # Implement IDisposable correctly — covered by SST2300 dotnet_diagnostic.CA1064.severity = error # Exceptions should be public -dotnet_diagnostic.CA1065.severity = error # Do not raise exceptions in unexpected locations +dotnet_diagnostic.CA1065.severity = none # Do not raise exceptions in unexpected locations — covered by SST1485 dotnet_diagnostic.CA1066.severity = error # Implement IEquatable when overriding Equals dotnet_diagnostic.CA1067.severity = error # Override Equals when implementing IEquatable dotnet_diagnostic.CA1068.severity = error # CancellationToken parameters must come last @@ -250,12 +250,12 @@ dotnet_diagnostic.CA1401.severity = error # P/Invokes should not be visible ################### # Microsoft .NET Analyzers (CA) - Maintainability Rules ################### -dotnet_diagnostic.CA1500.severity = error # Variable names should not match field names +dotnet_diagnostic.CA1500.severity = none # Variable names should not match field names — covered by SST1484 dotnet_diagnostic.CA1501.severity = none # Avoid excessive inheritance — disabled because the analyzer noticeably slows down the build dotnet_diagnostic.CA1502.severity = none # Avoid excessive complexity — disabled because the analyzer noticeably slows down the build dotnet_diagnostic.CA1505.severity = error # Avoid unmaintainable code dotnet_diagnostic.CA1506.severity = none # Avoid excessive class coupling — adds little signal here, mostly trips on legitimate orchestration code -dotnet_diagnostic.CA1507.severity = error # Use nameof in place of string +dotnet_diagnostic.CA1507.severity = none # Use nameof in place of string — covered by SST1463 dotnet_diagnostic.CA1508.severity = error # Avoid dead conditional code dotnet_diagnostic.CA1509.severity = error # Invalid entry in code metrics configuration file dotnet_diagnostic.CA1510.severity = none # Use ArgumentNullException throw helper — disabled because we target older TFMs and use ArgumentExceptionHelper for cross-platform parity @@ -275,72 +275,72 @@ dotnet_diagnostic.CA1724.severity = none # Type Names Should Not Match Namespace ################### # Microsoft .NET Analyzers (CA) - Performance Rules ################### -dotnet_diagnostic.CA1802.severity = error # Use literals where appropriate -dotnet_diagnostic.CA1805.severity = error # Do not initialize unnecessarily +dotnet_diagnostic.CA1802.severity = none # Use literals where appropriate — covered by PSH1402 +dotnet_diagnostic.CA1805.severity = none # Do not initialize unnecessarily — covered by PSH1403 dotnet_diagnostic.CA1806.severity = error # Do not ignore method results dotnet_diagnostic.CA1810.severity = none # Initialize reference type static fields inline — explicit static constructors are deliberate in some types dotnet_diagnostic.CA1812.severity = error # Avoid uninstantiated internal classes -dotnet_diagnostic.CA1813.severity = error # Avoid unsealed attributes -dotnet_diagnostic.CA1814.severity = error # Prefer jagged arrays over multidimensional -dotnet_diagnostic.CA1815.severity = error # Override equals and operator equals on value types +dotnet_diagnostic.CA1813.severity = none # Avoid unsealed attributes — covered by PSH1401 +dotnet_diagnostic.CA1814.severity = none # Prefer jagged arrays over multidimensional — covered by PSH1020 +dotnet_diagnostic.CA1815.severity = none # Override equals and operator equals on value types — covered by PSH1005 dotnet_diagnostic.CA1819.severity = none # Properties should not return arrays — incompatible with the RxUI/sqlite-net mapping style we use throughout the codebase -dotnet_diagnostic.CA1820.severity = error # Test for empty strings using string length -dotnet_diagnostic.CA1821.severity = error # Remove empty finalizers +dotnet_diagnostic.CA1820.severity = none # Test for empty strings using string length — covered by PSH1204 +dotnet_diagnostic.CA1821.severity = none # Remove empty finalizers — covered by PSH1002 dotnet_diagnostic.CA1822.severity = error # Mark members as static -dotnet_diagnostic.CA1823.severity = error # Avoid unused private fields +dotnet_diagnostic.CA1823.severity = none # Avoid unused private fields — covered by SST1441 dotnet_diagnostic.CA1824.severity = error # Mark assemblies with NeutralResourcesLanguageAttribute -dotnet_diagnostic.CA1825.severity = error # Avoid zero-length array allocations -dotnet_diagnostic.CA1826.severity = error # Use property instead of Linq Enumerable method -dotnet_diagnostic.CA1827.severity = error # Do not use Count/LongCount when Any can be used +dotnet_diagnostic.CA1825.severity = none # Avoid zero-length array allocations — covered by PSH1001 +dotnet_diagnostic.CA1826.severity = none # Use property instead of Linq Enumerable method — covered by PSH1103 +dotnet_diagnostic.CA1827.severity = none # Do not use Count/LongCount when Any can be used — covered by PSH1119 dotnet_diagnostic.CA1828.severity = error # Do not use CountAsync/LongCountAsync when AnyAsync can be used -dotnet_diagnostic.CA1829.severity = error # Use Length/Count property instead of Enumerable.Count method -dotnet_diagnostic.CA1830.severity = error # Prefer strongly-typed Append and Insert method overloads on StringBuilder -dotnet_diagnostic.CA1831.severity = error # Use AsSpan instead of Range-based indexers for string when appropriate -dotnet_diagnostic.CA1832.severity = error # Use AsSpan or AsMemory instead of Range-based indexers for getting ReadOnlySpan or ReadOnlyMemory portion of an array +dotnet_diagnostic.CA1829.severity = none # Use Length/Count property instead of Enumerable.Count — covered by PSH1103 +dotnet_diagnostic.CA1830.severity = none # Prefer strongly-typed Append/Insert overloads on StringBuilder — covered by PSH1202 +dotnet_diagnostic.CA1831.severity = none # Use AsSpan instead of Range-based indexers for string — covered by PSH1212 +dotnet_diagnostic.CA1832.severity = none # Use AsSpan or AsMemory instead of Range-based indexers for getting ReadOnlySpan or ReadOnlyMemory portion of an array — covered by PSH1019 dotnet_diagnostic.CA1833.severity = error # Use AsSpan or AsMemory instead of Range-based indexers for getting Span or Memory portion of an array -dotnet_diagnostic.CA1834.severity = error # Use StringBuilder.Append(char) for single character strings -dotnet_diagnostic.CA1835.severity = error # Prefer the memory-based overloads of ReadAsync/WriteAsync methods in stream-based classes -dotnet_diagnostic.CA1836.severity = error # Prefer IsEmpty over Count when available -dotnet_diagnostic.CA1837.severity = error # Use Environment.ProcessId instead of Process.GetCurrentProcess().Id +dotnet_diagnostic.CA1834.severity = none # Use StringBuilder.Append(char) for single character strings — covered by PSH1202 +dotnet_diagnostic.CA1835.severity = none # Prefer the memory-based overloads of ReadAsync/WriteAsync methods in stream-based classes — covered by PSH1314 +dotnet_diagnostic.CA1836.severity = none # Prefer IsEmpty over Count when available — covered by PSH1117 +dotnet_diagnostic.CA1837.severity = none # Use Environment.ProcessId — covered by PSH1405 dotnet_diagnostic.CA1838.severity = error # Avoid StringBuilder parameters for P/Invokes -dotnet_diagnostic.CA1839.severity = error # Use Environment.ProcessPath instead of Process.GetCurrentProcess().MainModule.FileName -dotnet_diagnostic.CA1840.severity = error # Use Environment.CurrentManagedThreadId instead of Thread.CurrentThread.ManagedThreadId -dotnet_diagnostic.CA1841.severity = error # Prefer Dictionary.Contains methods -dotnet_diagnostic.CA1842.severity = error # Do not use 'WhenAll' with a single task -dotnet_diagnostic.CA1843.severity = error # Do not use 'WaitAll' with a single task +dotnet_diagnostic.CA1839.severity = none # Use Environment.ProcessPath — covered by PSH1405 +dotnet_diagnostic.CA1840.severity = none # Use Environment.CurrentManagedThreadId — covered by PSH1405 +dotnet_diagnostic.CA1841.severity = none # Prefer Dictionary.Contains methods — covered by PSH1407 +dotnet_diagnostic.CA1842.severity = none # Do not use 'WhenAll' with a single task — covered by PSH1301 +dotnet_diagnostic.CA1843.severity = none # Do not use 'WaitAll' with a single task — covered by PSH1301 dotnet_diagnostic.CA1844.severity = error # Provide memory-based overrides of async methods when subclassing 'Stream' dotnet_diagnostic.CA1845.severity = error # Use span-based 'string.Concat' -dotnet_diagnostic.CA1846.severity = error # Prefer AsSpan over Substring +dotnet_diagnostic.CA1846.severity = none # Prefer AsSpan over Substring — covered by PSH1212 dotnet_diagnostic.CA1847.severity = none # Use char literal for a single character lookup — disabled because the string.Contains(char) overload doesn't exist on .NET Framework / netstandard2.0 and we target both dotnet_diagnostic.CA1848.severity = error # Use the LoggerMessage delegates -dotnet_diagnostic.CA1849.severity = error # Call async methods when in an async method -dotnet_diagnostic.CA1850.severity = error # Prefer static HashData method over ComputeHash +dotnet_diagnostic.CA1849.severity = none # Call async methods when in an async method — covered by PSH1313 +dotnet_diagnostic.CA1850.severity = none # Prefer static HashData method over ComputeHash — covered by PSH1400 dotnet_diagnostic.CA1851.severity = error # Possible multiple enumerations of IEnumerable collection -dotnet_diagnostic.CA1852.severity = error # Seal internal types +dotnet_diagnostic.CA1852.severity = none # Seal internal types — covered by PSH1411 dotnet_code_quality.CA1852.api_surface = private, internal # only flag non-public classes; public classes stay open for inheritance -dotnet_diagnostic.CA1853.severity = error # Unnecessary call to 'Dictionary.ContainsKey(key)' -dotnet_diagnostic.CA1854.severity = error # Prefer the IDictionary.TryGetValue(TKey, out TValue) method +dotnet_diagnostic.CA1853.severity = none # Unnecessary call to 'Dictionary.ContainsKey(key)' — covered by PSH1105 +dotnet_diagnostic.CA1854.severity = none # Prefer the IDictionary.TryGetValue method — covered by PSH1104 dotnet_diagnostic.CA1855.severity = error # Prefer 'Clear' over 'Fill' dotnet_diagnostic.CA1856.severity = error # Incorrect usage of ConstantExpected attribute dotnet_diagnostic.CA1857.severity = error # A constant is expected for the parameter dotnet_diagnostic.CA1858.severity = error # Use 'StartsWith' instead of 'IndexOf' dotnet_diagnostic.CA1859.severity = error # Use concrete types when possible for improved performance -dotnet_diagnostic.CA1860.severity = error # Avoid using 'Enumerable.Any()' extension method -dotnet_diagnostic.CA1861.severity = error # Avoid constant arrays as arguments -dotnet_diagnostic.CA1862.severity = error # Use the 'StringComparison' method overloads to perform case-insensitive string comparisons +dotnet_diagnostic.CA1860.severity = none # Avoid using 'Enumerable.Any()' extension method — covered by PSH1103 +dotnet_diagnostic.CA1861.severity = none # Avoid constant arrays as arguments — covered by PSH1004 +dotnet_diagnostic.CA1862.severity = none # Use the 'StringComparison' overloads for case-insensitive comparisons — covered by PSH1200 dotnet_diagnostic.CA1863.severity = error # Use 'CompositeFormat' -dotnet_diagnostic.CA1864.severity = error # Prefer the 'IDictionary.TryAdd(TKey, TValue)' method +dotnet_diagnostic.CA1864.severity = none # Prefer the 'IDictionary.TryAdd' method — covered by PSH1115 dotnet_diagnostic.CA1865.severity = none # Use char overload (string.StartsWith) — disabled because the string.StartsWith(char) overload doesn't exist on .NET Framework / netstandard2.0 and we target both dotnet_diagnostic.CA1866.severity = none # Use char overload (string.EndsWith) — disabled because the string.EndsWith(char) overload doesn't exist on .NET Framework / netstandard2.0 and we target both dotnet_diagnostic.CA1867.severity = none # Use char overload (string.IndexOf / string.LastIndexOf) — disabled because the char overloads don't exist on .NET Framework / netstandard2.0 and we target both -dotnet_diagnostic.CA1868.severity = error # Unnecessary call to 'Contains' for sets -dotnet_diagnostic.CA1869.severity = error # Cache and reuse 'JsonSerializerOptions' instances -dotnet_diagnostic.CA1870.severity = error # Use a cached 'SearchValues' instance +dotnet_diagnostic.CA1868.severity = none # Unnecessary call to 'Contains' for sets — covered by PSH1105 +dotnet_diagnostic.CA1869.severity = none # Cache and reuse 'JsonSerializerOptions' instances — covered by PSH1416 +dotnet_diagnostic.CA1870.severity = none # Use a cached 'SearchValues' instance — covered by PSH1213 dotnet_diagnostic.CA1871.severity = error # Do not pass a nullable struct to 'ArgumentNullException.ThrowIfNull' -dotnet_diagnostic.CA1872.severity = error # Prefer 'Convert.ToHexString' and 'Convert.ToHexStringLower' over call chains based on 'BitConverter.ToString' +dotnet_diagnostic.CA1872.severity = none # Prefer 'Convert.ToHexString' and 'Convert.ToHexStringLower' over call chains based on 'BitConverter.ToString' — covered by PSH1224 dotnet_diagnostic.CA1873.severity = error # Avoid potentially expensive evaluation of arguments to 'Debug.Assert' -dotnet_diagnostic.CA1874.severity = error # Use 'Regex.IsMatch' -dotnet_diagnostic.CA1875.severity = error # Use 'Regex.Count' +dotnet_diagnostic.CA1874.severity = none # Use 'Regex.IsMatch' — covered by PSH1406 +dotnet_diagnostic.CA1875.severity = none # Use 'Regex.Count' — covered by PSH1406 dotnet_diagnostic.CA1877.severity = error # Use 'Encoding.GetString' instead of 'Encoding.GetChars' ################### @@ -378,12 +378,12 @@ dotnet_diagnostic.CA2200.severity = error # Rethrow to preserve stack details dotnet_diagnostic.CA2201.severity = error # Do not raise reserved exception types dotnet_diagnostic.CA2207.severity = error # Initialize value type static fields inline dotnet_diagnostic.CA2208.severity = none # Instantiate argument exceptions correctly — too sensitive: flags valid context-forwarding nameof(arg.Property) patterns -dotnet_diagnostic.CA2211.severity = error # Non-constant fields should not be visible +dotnet_diagnostic.CA2211.severity = none # Non-constant fields should not be visible — covered by SST1499 dotnet_diagnostic.CA2213.severity = error # Disposable fields should be disposed dotnet_diagnostic.CA2214.severity = error # Do not call overridable methods in constructors dotnet_diagnostic.CA2215.severity = error # Dispose methods should call base class dispose dotnet_diagnostic.CA2216.severity = error # Disposable types should declare finalizer -dotnet_diagnostic.CA2217.severity = error # Do not mark enums with FlagsAttribute +dotnet_diagnostic.CA2217.severity = none # Do not mark enums with FlagsAttribute — covered by SST2303 dotnet_diagnostic.CA2218.severity = error # Override GetHashCode on overriding Equals dotnet_diagnostic.CA2219.severity = error # Do not raise exceptions in finally clauses dotnet_diagnostic.CA2224.severity = error # Override Equals on overloading operator equals @@ -718,12 +718,12 @@ dotnet_diagnostic.SYSLIB1104.severity = error # Configuration binding source gen # etc.) so we don't double-report. # Bug catchers — always error. -dotnet_diagnostic.IDE0035.severity = error # Remove unreachable code -dotnet_diagnostic.IDE0043.severity = error # Format string contains invalid placeholder +dotnet_diagnostic.IDE0035.severity = none # Remove unreachable code — covered by SST1453 +dotnet_diagnostic.IDE0043.severity = none # Format string contains invalid placeholder — covered by SST1454 dotnet_diagnostic.IDE0051.severity = none # Remove unused private member — covered by SST1440 dotnet_diagnostic.IDE0052.severity = none # Remove unread private member — covered by SST1441 -dotnet_diagnostic.IDE0076.severity = error # Remove invalid global SuppressMessage attribute -dotnet_diagnostic.IDE0077.severity = error # Avoid legacy format target in global SuppressMessage attribute +dotnet_diagnostic.IDE0076.severity = none # Remove invalid global SuppressMessage attribute — covered by SST1457 +dotnet_diagnostic.IDE0077.severity = none # Avoid legacy format target in global SuppressMessage attribute — covered by SST1458 dotnet_diagnostic.IDE0080.severity = none # Remove unnecessary suppression operator — covered by SST2209 # Simplification / cleanup. @@ -749,14 +749,14 @@ dotnet_diagnostic.IDE0041.severity = none # Use 'is null' check — covered by S dotnet_diagnostic.IDE0042.severity = none # Deconstruct variable declaration — covered by SST2214 dotnet_diagnostic.IDE0044.severity = none # Add readonly modifier — covered by SST1424 dotnet_diagnostic.IDE0046.severity = none # Use conditional expression for return — covered by SST1197 -dotnet_diagnostic.IDE0047.severity = error # Remove unnecessary parentheses +dotnet_diagnostic.IDE0047.severity = none # Remove unnecessary parentheses — covered by SST1459 dotnet_diagnostic.IDE0049.severity = none # Use language keywords instead of framework type names for type references — covered by SST1121 dotnet_diagnostic.IDE0054.severity = none # Use compound assignment — covered by SST1185 dotnet_diagnostic.IDE0056.severity = none # Use index operator — covered by SST2203 dotnet_diagnostic.IDE0057.severity = none # Use range operator — covered by SST2204 dotnet_diagnostic.IDE0059.severity = none # Remove unnecessary value assignment — covered by SST2222 -dotnet_diagnostic.IDE0062.severity = error # Make local function static -dotnet_diagnostic.IDE0063.severity = error # Use simple 'using' statement +dotnet_diagnostic.IDE0062.severity = none # Make local function static — covered by SST2235 +dotnet_diagnostic.IDE0063.severity = none # Use simple 'using' statement — covered by SST2236 dotnet_diagnostic.IDE0064.severity = error # Make struct fields writable — flag readonly-but-mutable struct fields where intent diverges from declaration dotnet_diagnostic.IDE0066.severity = none # Use switch expression — covered by SST2201 dotnet_diagnostic.IDE0070.severity = none # Use 'System.HashCode.Combine' — covered by SST2217 @@ -771,18 +771,18 @@ dotnet_diagnostic.IDE0084.severity = error # Use pattern matching ('IsNot' opera dotnet_diagnostic.IDE0090.severity = none # Simplify 'new' expression — covered by SST2202 dotnet_diagnostic.IDE0100.severity = none # Remove unnecessary equality operator — covered by SST1143 dotnet_diagnostic.IDE0110.severity = none # Remove unnecessary discard — covered by SST2213 -dotnet_diagnostic.IDE0120.severity = none # Simplify LINQ expression — covered by SST2229/SST2233 +dotnet_diagnostic.IDE0120.severity = none # Simplify LINQ expression — covered by PSH1100/PSH1101 dotnet_diagnostic.IDE0150.severity = none # Prefer 'null' check over type check — covered by SST2231 -dotnet_diagnostic.IDE0161.severity = error # Use file-scoped namespace declaration -dotnet_diagnostic.IDE0170.severity = error # Simplify property pattern +dotnet_diagnostic.IDE0161.severity = none # Use file-scoped namespace declaration — covered by SST2237 +dotnet_diagnostic.IDE0170.severity = none # Simplify property pattern — covered by SST2238 dotnet_diagnostic.IDE0180.severity = none # Use tuple to swap values — covered by SST2215 -dotnet_diagnostic.IDE0200.severity = error # Remove unnecessary lambda expression +dotnet_diagnostic.IDE0200.severity = none # Remove unnecessary lambda expression — covered by SST2239 dotnet_diagnostic.IDE0220.severity = none # Add explicit cast in foreach loop — covered by SST2225 dotnet_diagnostic.IDE0221.severity = none # Add explicit cast — covered by SST2226 dotnet_diagnostic.IDE0240.severity = none # Remove redundant nullable directive — covered by SST2210 dotnet_diagnostic.IDE0241.severity = none # Remove unnecessary nullable directive — covered by SST2211 -dotnet_diagnostic.IDE0250.severity = error # Make struct 'readonly' -dotnet_diagnostic.IDE0251.severity = error # Make member 'readonly' +dotnet_diagnostic.IDE0250.severity = none # Make struct 'readonly' — covered by PSH1014 +dotnet_diagnostic.IDE0251.severity = none # Make member 'readonly' — covered by SST1460 dotnet_diagnostic.IDE0260.severity = none # Use pattern matching — covered by SST2006/SST2231 dotnet_diagnostic.IDE0270.severity = none # Use coalesce expression — covered by SST2227 @@ -826,7 +826,7 @@ dotnet_diagnostic.IDE0303.severity = none # Use collection expression for Create dotnet_diagnostic.IDE0304.severity = none # Use collection expression for builder — covered by SST2104 dotnet_diagnostic.IDE0305.severity = none # Use collection expression for fluent — covered by SST2105 dotnet_diagnostic.IDE0306.severity = none # Use collection expression for new — covered by SST2101 -dotnet_diagnostic.IDE0320.severity = error # Make anonymous function static — eliminates the closure-capture display class allocation +dotnet_diagnostic.IDE0320.severity = none # Make anonymous function static — covered by PSH1000 dotnet_diagnostic.IDE0050.severity = none # Convert anonymous type to tuple — covered by SST2224 dotnet_diagnostic.IDE0230.severity = none # Use UTF-8 string literal — covered by SST2212 dotnet_diagnostic.IDE0310.severity = none # Convert lambda expression to method group — handled by RCS1207 @@ -834,8 +834,8 @@ dotnet_diagnostic.IDE0340.severity = none # Use unbound generic type — covered dotnet_diagnostic.IDE0350.severity = none # Use implicitly typed lambda — covered by SST2218 dotnet_diagnostic.IDE0360.severity = none # Simplify property accessor — covered by SST2219 dotnet_diagnostic.IDE0370.severity = none # Remove unnecessary suppression (null-forgiving operator) — disabled for the same reason as RCS1249: multi-TFM nullability annotations can differ per platform -dotnet_diagnostic.IDE0380.severity = error # Remove unnecessary unsafe modifier -dotnet_diagnostic.IDE1005.severity = error # Use conditional delegate call +dotnet_diagnostic.IDE0380.severity = none # Remove unnecessary unsafe modifier — covered by SST1455 +dotnet_diagnostic.IDE1005.severity = none # Use conditional delegate call — covered by SST2240 ################### # Microsoft .NET Style Rules (IDE1xxx / IDE3xxx) - Naming & Miscellaneous @@ -864,21 +864,23 @@ dotnet_diagnostic.RCS1042.severity = none # Remove enum default underlying type dotnet_diagnostic.RCS1043.severity = error # Remove 'partial' modifier from type with a single part dotnet_diagnostic.RCS1049.severity = error # Simplify boolean comparison dotnet_diagnostic.RCS1058.severity = none # Use compound assignment — covered by SST1185 -dotnet_diagnostic.RCS1061.severity = error # Merge 'if' with nested 'if' +dotnet_diagnostic.RCS1061.severity = none # Merge 'if' with nested 'if' — covered by SST2013 dotnet_diagnostic.RCS1068.severity = none # Simplify logical negation — covered by SST1172/SST2006 dotnet_diagnostic.RCS1069.severity = error # Remove unnecessary case label dotnet_diagnostic.RCS1070.severity = none # Remove redundant default switch section — covered by SST1179 dotnet_diagnostic.RCS1071.severity = none # Remove redundant base constructor call — covered by SST1178 +dotnet_diagnostic.RCS1072.severity = none # Remove empty namespace declaration — covered by SST1435 dotnet_diagnostic.RCS1073.severity = error # Convert 'if' to 'return' statement dotnet_diagnostic.RCS1074.severity = none # Remove redundant constructor — covered by SST1433 dotnet_diagnostic.RCS1078.severity = error # Use "" or 'string.Empty' dotnet_diagnostic.RCS1084.severity = error # Use coalesce expression instead of conditional expression -dotnet_diagnostic.RCS1085.severity = error # Use auto-implemented property +dotnet_diagnostic.RCS1085.severity = none # Use auto-implemented property — covered by SST1420 dotnet_diagnostic.RCS1089.severity = error # Use --/++ operator instead of assignment dotnet_diagnostic.RCS1097.severity = error # Remove redundant 'ToString' call dotnet_diagnostic.RCS1103.severity = error # Convert 'if' to assignment dotnet_diagnostic.RCS1104.severity = none # Simplify conditional expression — covered by SST1182 dotnet_diagnostic.RCS1105.severity = error # Unnecessary interpolation +dotnet_diagnostic.RCS1106.severity = none # Remove empty destructor — covered by PSH1002 dotnet_diagnostic.RCS1107.severity = error # Remove redundant 'ToCharArray' call dotnet_diagnostic.RCS1114.severity = error # Remove redundant delegate creation dotnet_diagnostic.RCS1124.severity = error # Inline local variable @@ -956,7 +958,7 @@ dotnet_diagnostic.RCS1136.severity = error # Merge switch sections with equivale dotnet_diagnostic.RCS1154.severity = error # Sort enum members dotnet_diagnostic.RCS1155.severity = error # Use StringComparison when comparing strings dotnet_diagnostic.RCS1156.severity = error # Use string.Length instead of comparison with empty string -dotnet_diagnostic.RCS1157.severity = error # Composite enum value contains undefined flag +dotnet_diagnostic.RCS1157.severity = none # Composite enum value contains undefined flag — covered by SST2303 dotnet_diagnostic.RCS1159.severity = error # Use EventHandler dotnet_diagnostic.RCS1160.severity = none # Abstract type should not have public constructors — covered by SST1428 dotnet_diagnostic.RCS1161.severity = none # Enum should declare explicit values - do not need explicit values @@ -983,7 +985,7 @@ dotnet_diagnostic.RCS1222.severity = error # Merge preprocessor directives dotnet_diagnostic.RCS1223.severity = suggestion # Mark publicly visible type with DebuggerDisplay attribute — only data types benefit; the rule is too broad to be an error dotnet_diagnostic.RCS1224.severity = error # Make method an extension method dotnet_diagnostic.RCS1225.severity = error # Make class sealed -dotnet_diagnostic.RCS1227.severity = error # Validate arguments correctly +dotnet_diagnostic.RCS1227.severity = none # Validate arguments correctly — covered by SST2404 dotnet_diagnostic.RCS1229.severity = error # Use async/await when necessary dotnet_diagnostic.RCS1231.severity = suggestion # Make parameter ref read-only dotnet_diagnostic.RCS1233.severity = error # Use short-circuiting operator @@ -1125,6 +1127,11 @@ dotnet_diagnostic.RCS0063.severity = none # Remove unnecessary blank line — co file_header_template = Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.\nReactiveUI and Contributors licenses this file to you under the MIT license.\nSee the LICENSE file in the project root for full license information. stylesharp.summary_single_line_max_length = 120 +# SonarAnalyzer's S4022 only ever flagged storage narrower than int; uint, long and ulong +# passed silently. Match that, so retiring S4022 for SST2313 does not fail a build on an +# enum that was deliberately widened. +stylesharp.SST2313.allowed_enum_storage = int, uint, long, ulong + # Documentation coverage scope (SST1600/SST1601/SST1602/SST1654). # Require documentation on every element, including private members. stylesharp.document_exposed_elements = true @@ -1134,10 +1141,22 @@ stylesharp.document_private_fields = true stylesharp.document_interfaces = all stylesharp.SST1305.allowed_hungarian_prefixes = rx stylesharp.instance_member_qualification = omit_this -stylesharp.avoid_linq_on_hot_path = true stylesharp.max_cyclomatic_complexity = 10 stylesharp.max_cognitive_complexity = 15 stylesharp.max_property_cognitive_complexity = 3 +# SST1484 also reports a field that shadows one inherited from a base type. +stylesharp.SST1484.check_base_types = true +stylesharp.max_line_length = 200 # SST1521 (characters; keeps the limit this repo has always enforced) +# stylesharp.max_file_lines = 500 # SST1522 (code lines; blank lines and comments do not count) +# stylesharp.max_member_lines = 60 # SST1523 (code lines) +# stylesharp.max_switch_section_lines = 20 # SST1524 (code lines) +# stylesharp.include_internal = true # SST1499 (set false to report only fields visible outside the assembly) +# stylesharp.require_parameterless = true # SST1488 (set false where every exception must carry a message) +# stylesharp.include_non_public_types = true # SST1488 (set false to check only externally visible exceptions) +performancesharp.avoid_linq_on_hot_path = true # PSH1100 (product code bans LINQ outright; tests relax this) +# performancesharp.empty_string_style = pattern # PSH1204 (pattern | length | is_null_or_empty; the last two are only offered where the string is provably not null) +# performancesharp.include_public = false # PSH1411 (set true in an app to seal public types too; a break in a library) +# performancesharp.excluded_properties = Items, Keys # PSH1017 (comma-separated; properties allowed to copy on read) # Spacing dotnet_diagnostic.SST1000.severity = error # A control-flow keyword is not followed by a space @@ -1172,7 +1191,6 @@ dotnet_diagnostic.SST1028.severity = error # A line ends with trailing whitespac # Readability and maintainability dotnet_diagnostic.SST1100.severity = error # A base. prefix is used where the type does not override the member -dotnet_diagnostic.SST1101.severity = none # An instance member is accessed without a this. prefix — we don't require this. prefixing dotnet_diagnostic.SST1102.severity = error # A query clause is separated from the previous clause by a blank line dotnet_diagnostic.SST1103.severity = error # Query clauses mix single-line and multi-line layout dotnet_diagnostic.SST1104.severity = error # A query clause shares the last line of a multi-line previous clause @@ -1286,6 +1304,7 @@ dotnet_diagnostic.SST1214.severity = error # Static readonly fields should appea dotnet_diagnostic.SST1215.severity = error # Instance readonly fields should appear before instance non-readonly fields dotnet_diagnostic.SST1216.severity = error # Using static directives should be placed after regular usings and before aliases dotnet_diagnostic.SST1217.severity = error # Using static directives should be ordered alphabetically +dotnet_diagnostic.SST1218.severity = error # Other members separate a method's overloads # Naming dotnet_diagnostic.SST1300.severity = none # Types and members should be PascalCase — naming duplicates existing analyzers @@ -1306,6 +1325,7 @@ dotnet_diagnostic.SST1315.severity = error # Union member names should match the dotnet_diagnostic.SST1316.severity = none # Tuple element names should use the configured casing — tuple naming is not enforced here dotnet_diagnostic.SST1317.severity = none # Asynchronous method names should end with 'Async' — conflicts with this project's Rx-compatibility and naming mechanism dotnet_diagnostic.SST1318.severity = error # Overriding parameter names should match the base declaration +dotnet_diagnostic.SST1319.severity = error # An enumeration's type name is not PascalCase # Maintainability dotnet_diagnostic.SST1400.severity = error # An element does not declare an access modifier @@ -1341,7 +1361,6 @@ dotnet_diagnostic.SST1430.severity = error # Rethrow with 'throw;' to preserve t dotnet_diagnostic.SST1431.severity = error # Static members of a generic type should use a type parameter dotnet_diagnostic.SST1432.severity = error # Classes with only static members should be static dotnet_diagnostic.SST1433.severity = error # Redundant constructors should be removed -dotnet_diagnostic.SST1434.severity = error # Empty finalizers should be removed dotnet_diagnostic.SST1435.severity = error # Empty namespace declarations should be removed dotnet_diagnostic.SST1436.severity = error # Empty types should not be declared dotnet_diagnostic.SST1437.severity = error # Empty interfaces should not be declared @@ -1352,7 +1371,61 @@ dotnet_diagnostic.SST1441.severity = error # Private fields assigned but never r dotnet_diagnostic.SST1442.severity = error # A function has too many direct branch points dotnet_diagnostic.SST1443.severity = error # A function has too much nested control flow dotnet_diagnostic.SST1444.severity = error # A loop cannot naturally reach a second iteration +dotnet_diagnostic.SST1445.severity = error # A using directive is unnecessary +dotnet_diagnostic.SST1446.severity = error # An inheritance chain is deeper than the configured maximum +dotnet_diagnostic.SST1447.severity = error # An equality override delegates to object's reference semantics +dotnet_diagnostic.SST1448.severity = error # An argument is passed explicitly to a caller-info parameter +dotnet_diagnostic.SST1449.severity = error # Code writes directly to the console dotnet_diagnostic.SST1450.severity = error # Store files as UTF-8 without a byte order mark +dotnet_diagnostic.SST1451.severity = error # A DateTime is created without a DateTimeKind +dotnet_diagnostic.SST1452.severity = error # A generic type parameter is never used +dotnet_diagnostic.SST1453.severity = error # Statements after an unconditional exit should be removed +dotnet_diagnostic.SST1454.severity = error # Composite format placeholders should match the supplied arguments +dotnet_diagnostic.SST1455.severity = error # Unsafe modifiers should be used only when unsafe syntax is present +dotnet_diagnostic.SST1456.severity = error # Readonly fields should not store mutable source-defined structs +dotnet_diagnostic.SST1457.severity = error # Global suppressions should point at real declarations +dotnet_diagnostic.SST1458.severity = error # Global suppression targets should use declaration ids directly +dotnet_diagnostic.SST1459.severity = error # Grouping parentheses should be removed when the parent syntax already isolates the expression +dotnet_diagnostic.SST1460.severity = error # Non-mutating struct members should be readonly +dotnet_diagnostic.SST1461.severity = error # Private parameters that are never read should be removed +dotnet_diagnostic.SST1462.severity = error # Suppressions for diagnostics already disabled by config should be removed +dotnet_diagnostic.SST1463.severity = error # Symbol-name strings should use nameof +dotnet_diagnostic.SST1464.severity = error # An else follows a branch that never falls through +dotnet_diagnostic.SST1465.severity = error # An else block only wraps an if and should collapse to else if +dotnet_diagnostic.SST1466.severity = error # A case label shares its section with default and adds nothing +dotnet_diagnostic.SST1467.severity = error # An enumerator is driven by hand where foreach would do +dotnet_diagnostic.SST1468.severity = error # Boolean logic uses a non-short-circuiting operator +dotnet_diagnostic.SST1469.severity = error # A value type is compared to null +dotnet_diagnostic.SST1470.severity = error # A catch clause only rethrows +dotnet_diagnostic.SST1471.severity = error # Magic numbers should be named constants +dotnet_diagnostic.SST1472.severity = error # Signatures should not declare too many parameters +dotnet_diagnostic.SST1473.severity = error # A floating-point value is compared for exact equality (zero comparison allowed by default) +dotnet_diagnostic.SST1474.severity = error # Both sides of an operator are the same expression +dotnet_diagnostic.SST1475.severity = error # A condition repeats an earlier one in the chain, so its branch cannot run +dotnet_diagnostic.SST1476.severity = error # Every branch of a conditional has the same body +dotnet_diagnostic.SST1477.severity = error # An integer division is widened to floating point after it has already truncated +dotnet_diagnostic.SST1478.severity = error # A shift count is zero, negative, or at least the operand's width +dotnet_diagnostic.SST1479.severity = error # A count or length is compared against a value it can never take +dotnet_diagnostic.SST1480.severity = error # An exception is constructed and then discarded +dotnet_diagnostic.SST1481.severity = error # A bitwise operation has a constant operand that makes it pointless +dotnet_diagnostic.SST1482.severity = error # GetHashCode reads mutable state +dotnet_diagnostic.SST1483.severity = error # A constructor calls an overridable member +dotnet_diagnostic.SST1484.severity = error # A declaration shadows an outer field or property (inherited fields included) +dotnet_diagnostic.SST1485.severity = error # A member that must not throw throws +dotnet_diagnostic.SST1486.severity = error # The same string literal is repeated instead of being named +dotnet_diagnostic.SST1487.severity = error # A collection element is assigned twice with nothing reading it in between +dotnet_diagnostic.SST1488.severity = error # An exception type does not declare the standard constructors (parameterless waivable) +dotnet_diagnostic.SST1489.severity = error # An exception type carries serialization members the target framework has obsoleted +dotnet_diagnostic.SST1490.severity = error # A base list names an interface the rest of the list already implies +dotnet_diagnostic.SST1491.severity = error # A modifier restates the declaration's default +dotnet_diagnostic.SST1492.severity = error # A value is tested against what it is then assigned, so the guard decides nothing +dotnet_diagnostic.SST1493.severity = error # A method's whole body is a constant +dotnet_diagnostic.SST1494.severity = error # A trailing argument repeats the parameter's default +dotnet_diagnostic.SST1495.severity = error # '==' compares references on a type that overrides Equals, so the two disagree +dotnet_diagnostic.SST1496.severity = error # An abstract type declares nothing abstract +dotnet_diagnostic.SST1497.severity = error # A local is declared and never read +dotnet_diagnostic.SST1498.severity = error # Only a nested type uses a private member +dotnet_diagnostic.SST1499.severity = error # A static field visible outside its type can still be changed (internal fields included by default) # Layout dotnet_diagnostic.SST1500.severity = error # A brace in a multi-line construct shares its line with other code @@ -1376,6 +1449,10 @@ dotnet_diagnostic.SST1517.severity = error # The file begins with one or more bl dotnet_diagnostic.SST1518.severity = error # The file does not end with exactly one newline dotnet_diagnostic.SST1519.severity = error # A multi-line child statement of a control-flow keyword omits its braces dotnet_diagnostic.SST1520.severity = error # The clauses of an if/else chain use braces inconsistently +dotnet_diagnostic.SST1521.severity = error # A line is longer than the configured maximum (default 120 characters) +dotnet_diagnostic.SST1522.severity = error # A file has more code lines than the configured maximum (default 500) +dotnet_diagnostic.SST1523.severity = error # A member has more code lines than the configured maximum (default 60) +dotnet_diagnostic.SST1524.severity = error # A switch section has more code lines than the configured maximum (default 20) # Documentation dotnet_diagnostic.SST1600.severity = error # Externally visible members should be documented @@ -1423,9 +1500,10 @@ dotnet_diagnostic.SST1654.severity = error # Extension blocks should be document dotnet_diagnostic.SST1655.severity = error # Extension block parameters should be documented dotnet_diagnostic.SST1656.severity = error # Extension block type parameters should be documented dotnet_diagnostic.SST1657.severity = error # Extension block documentation should reference a real parameter or type parameter +dotnet_diagnostic.SST1658.severity = error # Documentation repeats a word +dotnet_diagnostic.SST1659.severity = error # A comment has no text at all # Concurrency and modernization -dotnet_diagnostic.SST1900.severity = error # A dedicated object lock field should be a System.Threading.Lock dotnet_diagnostic.SST1901.severity = error # A lock targets a field or property reachable from outside the declaring type dotnet_diagnostic.SST1902.severity = error # Do not lock on 'this', a Type, or a string dotnet_diagnostic.SST1903.severity = error # Do not lock on a newly-created object @@ -1437,6 +1515,16 @@ dotnet_diagnostic.SST2004.severity = suggestion # A range check should use an Ar dotnet_diagnostic.SST2005.severity = error # Use the 'is' type pattern instead of comparing an 'as' cast to null dotnet_diagnostic.SST2006.severity = error # Use the 'is not' pattern instead of negating an 'is' check dotnet_diagnostic.SST2007.severity = error # Use declaration patterns instead of an is check followed by a cast local +dotnet_diagnostic.SST2008.severity = error # Negated pattern tests should use is-not patterns +dotnet_diagnostic.SST2009.severity = error # A catch that rethrows unless a condition holds should use a when filter +dotnet_diagnostic.SST2010.severity = error # A type reads the machine clock directly instead of through a TimeProvider +dotnet_diagnostic.SST2011.severity = error # An instant is recorded from the local clock rather than in UTC +dotnet_diagnostic.SST2012.severity = error # A GUID is constructed with the parameterless constructor instead of Guid.Empty +dotnet_diagnostic.SST2013.severity = error # An if whose entire body is another if should be merged +dotnet_diagnostic.SST2014.severity = error # A goto jumps to a label +dotnet_diagnostic.SST2015.severity = error # A ++ or -- is buried inside a larger expression +dotnet_diagnostic.SST2016.severity = error # A DateTime on a visible signature loses its offset at the boundary +dotnet_diagnostic.SST2017.severity = error # A .Date or .TimeOfDay read proves the value is only a date, or only a time of day # Modern language and library usage dotnet_diagnostic.SST1700.severity = error # An extension block declares no members @@ -1486,13 +1574,171 @@ dotnet_diagnostic.SST2225.severity = error # A foreach loop hides an explicit el dotnet_diagnostic.SST2226.severity = error # A cast hides an inner explicit conversion dotnet_diagnostic.SST2227.severity = error # A post-assignment null fallback can be folded into the assigned expression dotnet_diagnostic.SST2228.severity = error # A delegate local used only as a call target can be a local function -dotnet_diagnostic.SST2229.severity = none # LINQ terminal predicate simplification is reserved for test code; production code should avoid LINQ on hot paths -dotnet_diagnostic.SST2230.severity = none # LINQ type-filter simplification is reserved for test code; production code should avoid LINQ on hot paths dotnet_diagnostic.SST2231.severity = error # A broad object pattern can use a direct null pattern dotnet_diagnostic.SST2232.severity = error # nameof does not need concrete generic type arguments -dotnet_diagnostic.SST2233.severity = error # Hot-path code should avoid System.Linq.Enumerable calls +dotnet_diagnostic.SST2234.severity = error # Nullable should use the T? shorthand +dotnet_diagnostic.SST2235.severity = error # Capture-free local functions should be static +dotnet_diagnostic.SST2236.severity = error # Tail-position using blocks can use using declarations +dotnet_diagnostic.SST2237.severity = error # Single block-scoped namespaces can use file-scoped syntax +dotnet_diagnostic.SST2238.severity = error # Nested property patterns can use extended property syntax +dotnet_diagnostic.SST2239.severity = error # Forwarding lambdas can use method groups +dotnet_diagnostic.SST2240.severity = error # Delegate null checks can use conditional invocation +dotnet_diagnostic.SST2241.severity = error # Constructors that only store parameters can use primary-constructor storage +dotnet_diagnostic.SST2242.severity = error # Enum switch statement mappings should name every enum value or include a catch-all +dotnet_diagnostic.SST2243.severity = error # A string literal escaping quotes or backslashes should be a raw string literal +dotnet_diagnostic.SST2244.severity = error # A numeric literal's suffix is lower case +dotnet_diagnostic.SST2245.severity = error # A for loop with only a condition should be a while loop dotnet_diagnostic.RCS1040.severity = none # covered by SST1180 +# Design +dotnet_diagnostic.SST2300.severity = error # A class implements IDisposable but builds only half of the disposal pattern +dotnet_diagnostic.SST2301.severity = error # A class implementing IEquatable for itself can still be derived from +dotnet_diagnostic.SST2302.severity = error # A type overloads an operator without the rest of the set it belongs to +dotnet_diagnostic.SST2303.severity = error # A [Flags] enum's members are not distinct bit values +dotnet_diagnostic.SST2304.severity = error # An event's delegate does not have the standard (object sender, TEventArgs e) shape +dotnet_diagnostic.SST2305.severity = error # A mutable collection property declares a caller-visible setter +dotnet_diagnostic.SST2306.severity = error # A collection-returning member hands back null +dotnet_diagnostic.SST2307.severity = error # A generic method has a type parameter that cannot be inferred from its parameters +dotnet_diagnostic.SST2308.severity = error # An [Obsolete] attribute carries no message +dotnet_diagnostic.SST2309.severity = error # An externally visible member declares an optional parameter, so callers bake in the default +dotnet_diagnostic.SST2310.severity = error # Deprecated code is still here; remove it once its last caller is gone +dotnet_diagnostic.SST2311.severity = error # A visible const is copied into every assembly that reads it +dotnet_diagnostic.SST2312.severity = error # A type is declared outside any namespace +dotnet_diagnostic.SST2313.severity = error # An enum is stored as a type the project does not allow +dotnet_diagnostic.SST2314.severity = none # An [Obsolete] has a message but no DiagnosticId — unusable here: ObsoleteAttribute.DiagnosticId is .NET 5+, and this source is shared with net462 + +# Correctness +dotnet_diagnostic.SST2400.severity = error # Two arguments name each other's parameters and have been transposed +dotnet_diagnostic.SST2401.severity = error # A catch targets NullReferenceException +dotnet_diagnostic.SST2402.severity = error # An instance constructor assigns a static field of its own type +dotnet_diagnostic.SST2403.severity = error # 'this' escapes from a constructor before the object is fully built +dotnet_diagnostic.SST2404.severity = error # An iterator's argument guard does not run until the first MoveNext +dotnet_diagnostic.SST2405.severity = error # A [DebuggerDisplay] string names a member the type does not have +dotnet_diagnostic.SST2406.severity = error # A loop's stop condition reads only variables the loop never writes +dotnet_diagnostic.SST2407.severity = error # A declared event is never raised +dotnet_diagnostic.SST2408.severity = error # A StringBuilder is filled and never read +dotnet_diagnostic.SST2409.severity = error # A throw constructs a general exception type (Exception/SystemException/ApplicationException) +dotnet_diagnostic.SST2410.severity = error # A created disposable is never disposed and never leaves the method + +################### +# PerformanceSharp Analyzers (PSH) +################### +# Allocations +dotnet_diagnostic.PSH1000.severity = error # Anonymous functions without captures should be static +dotnet_diagnostic.PSH1001.severity = error # Avoid allocating zero-length arrays (fix prefers [] on C# 12+, else Array.Empty()) +dotnet_diagnostic.PSH1002.severity = error # Empty finalizers should be removed +dotnet_diagnostic.PSH1003.severity = error # 'in' parameters should use readonly structs +dotnet_diagnostic.PSH1004.severity = error # Constant arrays passed as arguments should be hoisted +dotnet_diagnostic.PSH1005.severity = error # Structs should define equality members to avoid boxing comparisons +dotnet_diagnostic.PSH1006.severity = error # ConcurrentDictionary factories should use the lambda argument +dotnet_diagnostic.PSH1007.severity = error # Pass large readonly structs by 'in' reference (size threshold + exclusions configurable) +dotnet_diagnostic.PSH1008.severity = error # GC.SuppressFinalize does nothing for sealed finalizer-free types +dotnet_diagnostic.PSH1009.severity = error # Bound variable-length stackalloc with a constant guard +dotnet_diagnostic.PSH1010.severity = error # Clear reference-typed arrays when returning them to the pool +dotnet_diagnostic.PSH1011.severity = error # Pass state to callbacks through the state-taking overload +dotnet_diagnostic.PSH1012.severity = error # Compare type parameter values with EqualityComparer.Default +dotnet_diagnostic.PSH1013.severity = error # Expose constant UTF-8 data as a ReadOnlySpan property +dotnet_diagnostic.PSH1014.severity = error # Declare immutable structs as readonly +dotnet_diagnostic.PSH1015.severity = error # Avoid casting value types through object +dotnet_diagnostic.PSH1016.severity = error # Test enum flags with bitwise operators instead of boxing +dotnet_diagnostic.PSH1017.severity = error # A property allocates a copy of a collection on every read (excludable via performancesharp.PSH1017.excluded_properties) +dotnet_diagnostic.PSH1018.severity = error # A hand-written array is passed to a params parameter +dotnet_diagnostic.PSH1019.severity = error # The range indexer on an array allocates a copy; slice with AsSpan/AsMemory +dotnet_diagnostic.PSH1020.severity = error # Prefer a jagged array over a multidimensional one + +# Collections +dotnet_diagnostic.PSH1100.severity = error # Hot-path code should avoid System.Linq.Enumerable calls +dotnet_diagnostic.PSH1101.severity = none # LINQ terminal predicate simplification is reserved for test code; production code should avoid LINQ on hot paths +dotnet_diagnostic.PSH1102.severity = none # LINQ type-filter simplification is reserved for test code; production code should avoid LINQ on hot paths +dotnet_diagnostic.PSH1103.severity = error # Prefer the collection's own count over enumerating +dotnet_diagnostic.PSH1104.severity = error # Use TryGetValue instead of ContainsKey followed by an indexer read +dotnet_diagnostic.PSH1105.severity = error # Avoid double lookups on dictionaries and sets +dotnet_diagnostic.PSH1106.severity = error # Index collections directly instead of using LINQ element access +dotnet_diagnostic.PSH1107.severity = error # Filter sequences before sorting them +dotnet_diagnostic.PSH1108.severity = error # Chain secondary sorts with ThenBy +dotnet_diagnostic.PSH1109.severity = error # Merge consecutive Where calls +dotnet_diagnostic.PSH1110.severity = error # Use the collection's own predicate methods over LINQ +dotnet_diagnostic.PSH1111.severity = error # Use Contains for membership tests +dotnet_diagnostic.PSH1112.severity = error # Seed the collection through its constructor (fix honors performancesharp.prefer_collection_expressions) +dotnet_diagnostic.PSH1113.severity = error # Sort naturally instead of ordering by the element itself +# dotnet_diagnostic.PSH1114.severity = error # Freeze static lookup collections (opt-in; freezing trades slower construction for faster reads) +dotnet_diagnostic.PSH1115.severity = error # Insert-if-absent should probe the dictionary once +dotnet_diagnostic.PSH1116.severity = error # Probe string-keyed collections with a span through GetAlternateLookup +dotnet_diagnostic.PSH1117.severity = error # Ask the collection whether it is empty +dotnet_diagnostic.PSH1118.severity = error # Take the extreme element without sorting the whole sequence +dotnet_diagnostic.PSH1119.severity = error # Check for elements without counting them all +dotnet_diagnostic.PSH1120.severity = error # Do not materialize a sequence just to enumerate it +dotnet_diagnostic.PSH1122.severity = error # Read a sorted set's extreme through its Min/Max property, not the LINQ extension +dotnet_diagnostic.PSH1124.severity = error # Read a linked list's end through its First/Last property, not the LINQ extension +dotnet_diagnostic.PSH1125.severity = error # Do not enumerate the same lazy sequence twice +dotnet_diagnostic.PSH1126.severity = error # Ask whether an async sequence has elements instead of counting them +dotnet_diagnostic.PSH1127.severity = error # Clear an array instead of filling it with its default + +# Strings +dotnet_diagnostic.PSH1200.severity = error # Compare strings without allocating case-converted copies +dotnet_diagnostic.PSH1201.severity = error # Use the char overload for single-character strings +dotnet_diagnostic.PSH1202.severity = error # Append characters as char, not single-character strings +dotnet_diagnostic.PSH1203.severity = error # Let StringBuilder do the formatting work +dotnet_diagnostic.PSH1204.severity = error # Test for empty strings by length +dotnet_diagnostic.PSH1205.severity = error # Remove interpolation that does no work +dotnet_diagnostic.PSH1206.severity = error # Do not build strings by concatenation in loops +dotnet_diagnostic.PSH1207.severity = error # Specify StringComparison for culture-sensitive string operations +dotnet_diagnostic.PSH1208.severity = error # Encode constant strings with u8 literals +dotnet_diagnostic.PSH1209.severity = error # Build transformed strings with string.Create +dotnet_diagnostic.PSH1210.severity = error # Compare UTF-8 bytes without decoding them +dotnet_diagnostic.PSH1211.severity = error # Pass values directly instead of ToString results +dotnet_diagnostic.PSH1212.severity = error # Slice with AsSpan when the call accepts a span +dotnet_diagnostic.PSH1213.severity = error # Probe repeated character sets through SearchValues +dotnet_diagnostic.PSH1214.severity = error # Append the parts, not a concatenated whole +dotnet_diagnostic.PSH1215.severity = error # Concatenate when there is no separator to join on +dotnet_diagnostic.PSH1216.severity = error # Ask for equality, not ordering, when only equality matters +dotnet_diagnostic.PSH1217.severity = error # A sequence is copied to an array just to be read straight back +dotnet_diagnostic.PSH1218.severity = error # A substring is allocated only to search it; slice with AsSpan instead +dotnet_diagnostic.PSH1219.severity = error # Ask whether a string is blank without trimming it +dotnet_diagnostic.PSH1220.severity = error # A length argument spells out the run that reaches the end anyway +dotnet_diagnostic.PSH1221.severity = error # An IndexOf compared to 0 scans the whole string to answer a prefix test +dotnet_diagnostic.PSH1222.severity = error # Concatenate slices without materializing them +dotnet_diagnostic.PSH1223.severity = error # A reused composite format string is re-parsed on every call +dotnet_diagnostic.PSH1224.severity = error # Convert bytes to hex in one call, not by building the string twice +dotnet_diagnostic.PSH1225.severity = error # Decode bytes to a string in one call, without a throwaway char[] + +# Concurrency +dotnet_diagnostic.PSH1300.severity = error # Use System.Threading.Lock for a dedicated lock object +dotnet_diagnostic.PSH1301.severity = error # Do not wrap a single task in WhenAll or WaitAll +dotnet_diagnostic.PSH1302.severity = error # TaskCompletionSource should run continuations asynchronously +dotnet_diagnostic.PSH1303.severity = error # Do not block an async method with Thread.Sleep +dotnet_diagnostic.PSH1304.severity = error # Use PeriodicTimer instead of pacing a loop with Task.Delay +dotnet_diagnostic.PSH1305.severity = error # Enumerate a ConcurrentDictionary directly, not its Keys/Values snapshots +# dotnet_diagnostic.PSH1306.severity = error # Guard one-time execution with an interlocked latch (opt-in; thread-safety needs are contextual) +dotnet_diagnostic.PSH1307.severity = error # Access interlocked fields with Volatile +dotnet_diagnostic.PSH1308.severity = error # Return the completed task instead of Task.FromResult +# dotnet_diagnostic.PSH1309.severity = error # Register cancellation callbacks without flowing the execution context (opt-in; changes AsyncLocal visibility) +dotnet_diagnostic.PSH1310.severity = error # Dispose asynchronously in async code +dotnet_diagnostic.PSH1311.severity = error # Remove a pass-through async state machine +dotnet_diagnostic.PSH1312.severity = error # Return a completed task, never null +dotnet_diagnostic.PSH1313.severity = error # Call the async overload from an async method instead of blocking +dotnet_diagnostic.PSH1314.severity = error # Read and write streams through the memory-based overloads + +# API selection +dotnet_diagnostic.PSH1400.severity = error # Use the static HashData method for one-shot hashing +dotnet_diagnostic.PSH1401.severity = error # Attribute types should be sealed +dotnet_diagnostic.PSH1402.severity = error # Use const for compile-time constants +dotnet_diagnostic.PSH1403.severity = error # Do not initialize fields to their default value +dotnet_diagnostic.PSH1404.severity = error # Get the assembly from typeof instead of a stack walk +dotnet_diagnostic.PSH1405.severity = error # Use the direct Environment APIs +dotnet_diagnostic.PSH1406.severity = error # Ask Regex for the answer directly +dotnet_diagnostic.PSH1407.severity = error # Query the dictionary, not its Keys view +dotnet_diagnostic.PSH1408.severity = error # Measure elapsed time with Stopwatch timestamps +dotnet_diagnostic.PSH1409.severity = error # Use the built-in throw helpers for argument guards +# dotnet_diagnostic.PSH1410.severity = error # Mark trivial forwarders for aggressive inlining (opt-in; opinionated convention) +dotnet_diagnostic.PSH1411.severity = error # Seal non-public types nothing derives from so the JIT can devirtualize +dotnet_diagnostic.PSH1412.severity = error # Use Random.Shared instead of allocating a Random +dotnet_diagnostic.PSH1413.severity = error # Read the Unix epoch from the framework, not a hand-built DateTime +dotnet_diagnostic.PSH1414.severity = error # Mark members that do not touch instance state as static +dotnet_diagnostic.PSH1415.severity = error # Hold the concrete type when the concrete type is what you have +dotnet_diagnostic.PSH1416.severity = error # Cache the serializer options instead of building them per call +dotnet_diagnostic.PSH1417.severity = error # Do not compute an expensive argument for an assertion + ################### # PublicApiAnalyzers (RSxxxx) - public API surface tracking ################### @@ -1631,7 +1877,6 @@ dotnet_diagnostic.IL3055.severity = error # MakeGenericType on non-supported typ dotnet_diagnostic.IL3056.severity = error # MakeGenericMethod on non-supported method requires dynamic code dotnet_diagnostic.IL3057.severity = error # Reflection access to generic parameter requires dynamic code - ################### # SonarAnalyzer (Sxxxx) - Blocker Bug ################### @@ -1639,7 +1884,7 @@ dotnet_diagnostic.S1048.severity = error # Finalizers should not throw exception dotnet_diagnostic.S2190.severity = none # Loops and recursions should not be infinite dotnet_diagnostic.S2275.severity = none # Composite format strings should not lead to unexpected behavior at runtime - DUPLICATE CA2241 dotnet_diagnostic.S2857.severity = error # SQL keywords should be delimited by whitespace -dotnet_diagnostic.S2930.severity = error # "IDisposables" should be disposed +dotnet_diagnostic.S2930.severity = none # "IDisposables" should be disposed — covered by SST2410 dotnet_diagnostic.S2931.severity = error # Classes with "IDisposable" members should implement "IDisposable" dotnet_diagnostic.S3464.severity = error # Type inheritance should not be recursive dotnet_diagnostic.S3869.severity = error # "SafeHandle.DangerousGetHandle" should not be called @@ -1662,12 +1907,12 @@ dotnet_diagnostic.S6674.severity = error # Log message template should be syntac ################### # SonarAnalyzer (Sxxxx) - Major Bug ################### -dotnet_diagnostic.S1244.severity = error # Floating point numbers should not be tested for equality +dotnet_diagnostic.S1244.severity = none # Floating point numbers should not be tested for equality — covered by SST1473 dotnet_diagnostic.S1656.severity = none # Variables should not be self-assigned — covered by SST1189 dotnet_diagnostic.S1751.severity = none # Loops with at most one iteration should be refactored - covered by SST1444 -dotnet_diagnostic.S1764.severity = error # Identical expressions should not be used on both sides of operators +dotnet_diagnostic.S1764.severity = none # Identical expressions should not be used on both sides of operators — covered by SST1474 dotnet_diagnostic.S1848.severity = error # Objects should not be created to be dropped immediately without being used -dotnet_diagnostic.S1862.severity = error # Related "if/else if" statements should not have the same condition +dotnet_diagnostic.S1862.severity = none # Related "if/else if" statements should not have the same condition — covered by SST1475 dotnet_diagnostic.S2114.severity = error # Collections should not be passed as arguments to their own methods dotnet_diagnostic.S2123.severity = error # Values should not be uselessly incremented dotnet_diagnostic.S2201.severity = error # Methods without side effects should not have their return values ignored @@ -1675,7 +1920,7 @@ dotnet_diagnostic.S2225.severity = error # "ToString()" method should not return dotnet_diagnostic.S2251.severity = error # A "for" loop update clause should move the counter in the right direction dotnet_diagnostic.S2252.severity = error # For-loop conditions should be true at least once dotnet_diagnostic.S2445.severity = error # Blocks should be synchronized on read-only fields -dotnet_diagnostic.S2688.severity = error # "NaN" should not be used in comparisons +dotnet_diagnostic.S2688.severity = none # "NaN" should not be used in comparisons — covered by SST1473 dotnet_diagnostic.S2757.severity = error # Non-existent operators like "=+" should not be used dotnet_diagnostic.S2761.severity = none # Doubled prefix operators "!!" and "~~" should not be used — covered by SST1190 dotnet_diagnostic.S2995.severity = error # "Object.ReferenceEquals" should not be used for value types @@ -1694,13 +1939,13 @@ dotnet_diagnostic.S3466.severity = error # Optional parameters should be passed dotnet_diagnostic.S3598.severity = error # One-way "OperationContract" methods should have "void" return type dotnet_diagnostic.S3603.severity = error # Methods with "Pure" attribute should return a value dotnet_diagnostic.S3610.severity = error # Nullable type comparison should not be redundant -dotnet_diagnostic.S3903.severity = error # Types should be defined in named namespaces -dotnet_diagnostic.S3923.severity = error # All branches in a conditional structure should not have exactly the same implementation +dotnet_diagnostic.S3903.severity = none # Types should be defined in named namespaces — covered by SST2312 +dotnet_diagnostic.S3923.severity = none # All branches in a conditional structure should not have exactly the same implementation — covered by SST1476 dotnet_diagnostic.S3926.severity = error # Deserialization methods should be provided for "OptionalField" members dotnet_diagnostic.S3927.severity = error # Serialization event handlers should be implemented correctly -dotnet_diagnostic.S3981.severity = error # Collection sizes and array length comparisons should make sense -dotnet_diagnostic.S3984.severity = error # Exceptions should not be created without being thrown -dotnet_diagnostic.S4143.severity = error # Collection elements should not be replaced unconditionally +dotnet_diagnostic.S3981.severity = none # Collection sizes and array length comparisons should make sense — covered by SST1479 +dotnet_diagnostic.S3984.severity = none # Exceptions should not be created without being thrown — covered by SST1480 +dotnet_diagnostic.S4143.severity = none # Collection elements should not be replaced unconditionally — covered by SST1487 dotnet_diagnostic.S4210.severity = error # Windows Forms entry points should be marked with STAThread dotnet_diagnostic.S4260.severity = error # "ConstructorArgument" parameters should exist in constructors dotnet_diagnostic.S4428.severity = error # "PartCreationPolicyAttribute" should be used with "ExportAttribute" @@ -1716,16 +1961,16 @@ dotnet_diagnostic.S6930.severity = error # Backslash should be avoided in route ################### dotnet_diagnostic.S1206.severity = none # "Equals(Object)" and "GetHashCode()" should be overridden in pairs - DUPLICATE CA2218 dotnet_diagnostic.S1226.severity = none # Method parameters, caught exceptions and foreach variables' initial values should not be ignored -dotnet_diagnostic.S2183.severity = error # Integral numbers should not be shifted by zero or more than their number of bits-1 -dotnet_diagnostic.S2184.severity = error # Results of integer division should not be assigned to floating point variables -dotnet_diagnostic.S2328.severity = error # "GetHashCode" should not reference mutable fields -dotnet_diagnostic.S2345.severity = error # Flags enumerations should explicitly initialize all their members +dotnet_diagnostic.S2183.severity = none # Integral numbers should not be shifted by zero or more than their number of bits-1 — covered by SST1478 +dotnet_diagnostic.S2184.severity = none # Results of integer division should not be assigned to floating point variables — covered by SST1477 +dotnet_diagnostic.S2328.severity = none # "GetHashCode" should not reference mutable fields — covered by SST1482 +dotnet_diagnostic.S2345.severity = none # Flags enumerations should explicitly initialize all their members — covered by SST2303 dotnet_diagnostic.S2674.severity = error # The length returned from a stream read should be checked dotnet_diagnostic.S2934.severity = error # Property assignments should not be made for "readonly" fields not constrained to reference types dotnet_diagnostic.S2955.severity = error # Generic parameters not constrained to reference types should not be compared to "null" dotnet_diagnostic.S3363.severity = error # Date and time should not be used as a type for primary keys dotnet_diagnostic.S3397.severity = error # "base.Equals" should not be used to check for reference equality in "Equals" if "base" is not "object" -dotnet_diagnostic.S3456.severity = error # "string.ToCharArray()" and "ReadOnlySpan.ToArray()" should not be called redundantly +dotnet_diagnostic.S3456.severity = none # "string.ToCharArray()" and "ReadOnlySpan.ToArray()" should not be called redundantly — covered by PSH1217 dotnet_diagnostic.S3887.severity = error # Mutable, non-private fields should not be "readonly" ################### @@ -1768,8 +2013,8 @@ dotnet_diagnostic.S2178.severity = error # Short-circuit logic should be used in dotnet_diagnostic.S2187.severity = error # Test classes should contain at least one test case dotnet_diagnostic.S2306.severity = error # "async" and "await" should not be used as identifiers dotnet_diagnostic.S2368.severity = none # Public methods should not have multidimensional array parameters — jagged arrays are the chosen layout for hot-path lookup tables -dotnet_diagnostic.S2387.severity = error # Child class fields should not shadow parent class fields -dotnet_diagnostic.S2437.severity = error # Unnecessary bit operations should not be performed +dotnet_diagnostic.S2387.severity = none # Child class fields should not shadow parent class fields — covered by SST1484 +dotnet_diagnostic.S2437.severity = none # Unnecessary bit operations should not be performed — covered by SST1481 dotnet_diagnostic.S2699.severity = error # Tests should include assertions dotnet_diagnostic.S2953.severity = error # Methods named "Dispose" should implement "IDisposable.Dispose" dotnet_diagnostic.S2970.severity = error # Assertions should be complete @@ -1779,7 +2024,7 @@ dotnet_diagnostic.S3427.severity = error # Method overloads with default paramet dotnet_diagnostic.S3433.severity = error # Test method signatures should be correct dotnet_diagnostic.S3443.severity = error # Type should not be examined on "System.Type" instances dotnet_diagnostic.S3875.severity = error # "operator==" should not be overloaded on reference types -dotnet_diagnostic.S3877.severity = error # Exceptions should not be thrown from unexpected methods +dotnet_diagnostic.S3877.severity = none # Exceptions should not be thrown from unexpected methods — covered by SST1485 dotnet_diagnostic.S4462.severity = error # Calls to "async" methods should not be blocking dotnet_diagnostic.S6422.severity = error # Calls to "async" methods should not be blocking in Azure Functions dotnet_diagnostic.S6424.severity = error # Interfaces for durable entities should satisfy the restrictions @@ -1797,7 +2042,7 @@ dotnet_diagnostic.S126.severity = none # "if ... else if" constructs should end dotnet_diagnostic.S131.severity = none # "switch/Select" statements should contain a "default/Case Else" clauses dotnet_diagnostic.S134.severity = none # Control flow statements "if", "switch", "for", "foreach", "while", "do" and "try" should not be nested too deeply dotnet_diagnostic.S1541.severity = none # Methods and properties should not be too complex - covered by SST1442 -dotnet_diagnostic.S1699.severity = error # Constructors should only call non-overridable methods +dotnet_diagnostic.S1699.severity = none # Constructors should only call non-overridable methods — covered by SST1483 dotnet_diagnostic.S1821.severity = error # "switch" statements should not be nested dotnet_diagnostic.S1944.severity = error # Invalid casts should be avoided dotnet_diagnostic.S1994.severity = error # "for" loop increment clauses should modify the loops' counters @@ -1808,10 +2053,10 @@ dotnet_diagnostic.S2290.severity = error # Field-like events should not be virtu dotnet_diagnostic.S2291.severity = error # Overflow checking should not be disabled for "Enumerable.Sum" dotnet_diagnostic.S2302.severity = error # "nameof" should be used dotnet_diagnostic.S2330.severity = error # Array covariance should not be used -dotnet_diagnostic.S2339.severity = error # Public constant members should not be used +dotnet_diagnostic.S2339.severity = none # Public constant members should not be used — covered by SST2311 dotnet_diagnostic.S2346.severity = none # Flags enumerations zero-value members should be named "None" - DUPLICATE CA1008 -dotnet_diagnostic.S2360.severity = error # Optional parameters should not be used -dotnet_diagnostic.S2365.severity = error # Properties should not make collection or array copies +dotnet_diagnostic.S2360.severity = none # Optional parameters should not be used — covered by SST2309 +dotnet_diagnostic.S2365.severity = none # Properties should not make collection or array copies — covered by PSH1017 dotnet_diagnostic.S2479.severity = none # Whitespace and control characters in string literals should be explicit — covered by SST1192 dotnet_diagnostic.S2692.severity = error # "IndexOf" checks should not be for positive numbers dotnet_diagnostic.S2696.severity = error # Instance members should not write to "static" fields @@ -1840,7 +2085,7 @@ dotnet_diagnostic.S4025.severity = error # Child class fields should not differ dotnet_diagnostic.S4039.severity = none # Interface methods should be callable by derived types - DUPLICATE CA1033 dotnet_diagnostic.S4487.severity = none # Unread "private" fields should be removed dotnet_diagnostic.S4524.severity = error # "default" clauses should be first or last -dotnet_diagnostic.S4635.severity = error # Start index should be used instead of calling Substring +dotnet_diagnostic.S4635.severity = none # Start index should be used instead of calling Substring — covered by PSH1218 dotnet_diagnostic.S5034.severity = error # "ValueTask" should be consumed correctly dotnet_diagnostic.S6967.severity = error # ModelState.IsValid should be called in controller actions dotnet_diagnostic.S8367.severity = error # Identifiers should not conflict with the C# 14 "field" contextual keyword @@ -1852,70 +2097,70 @@ dotnet_diagnostic.S927.severity = none # Parameter names should match base decla ################### # SonarAnalyzer (Sxxxx) - Major Code Smell ################### -dotnet_diagnostic.S103.severity = error # Lines should not be too long -dotnet_diagnostic.S104.severity = error # Files should not have too many lines of code -dotnet_diagnostic.S106.severity = error # Standard outputs should not be used directly to log anything -dotnet_diagnostic.S1066.severity = error # Mergeable "if" statements should be combined -dotnet_diagnostic.S107.severity = error # Methods should not have too many parameters +dotnet_diagnostic.S103.severity = none # Lines should not be too long — covered by SST1521 +dotnet_diagnostic.S104.severity = none # Files should not have too many lines of code — covered by SST1522 +dotnet_diagnostic.S106.severity = none # Standard outputs should not be used directly to log anything — covered by SST1449 +dotnet_diagnostic.S1066.severity = none # Mergeable "if" statements should be combined — covered by SST2013 +dotnet_diagnostic.S107.severity = none # Methods should not have too many parameters — covered by SST1472 dotnet_diagnostic.S108.severity = none # Nested blocks of code should not be left empty — covered by SST1439 -dotnet_diagnostic.S109.severity = error # Magic numbers should not be used -dotnet_diagnostic.S110.severity = error # Inheritance tree of classes should not be too deep -dotnet_diagnostic.S1110.severity = error # Redundant pairs of parentheses should be removed -dotnet_diagnostic.S1117.severity = error # Local variables should not shadow class fields or properties +dotnet_diagnostic.S109.severity = none # Magic numbers should not be used — covered by SST1471 +dotnet_diagnostic.S110.severity = none # Inheritance tree of classes should not be too deep — covered by SST1446 +dotnet_diagnostic.S1110.severity = none # Redundant pairs of parentheses should be removed — covered by SST1459 +dotnet_diagnostic.S1117.severity = none # Local variables should not shadow class fields or properties — covered by SST1484 dotnet_diagnostic.S1118.severity = none # Utility classes should not have public constructors - DUPLICATE CA1052 dotnet_diagnostic.S112.severity = error # General or reserved exceptions should never be thrown dotnet_diagnostic.S1121.severity = none # Assignments should not be made from within sub-expressions — covered by SST1187 -dotnet_diagnostic.S1123.severity = error # "Obsolete" attributes should include explanations +dotnet_diagnostic.S1123.severity = none # "Obsolete" attributes should include explanations — covered by SST2308 dotnet_diagnostic.S1134.severity = error # Track uses of "FIXME" tags dotnet_diagnostic.S1144.severity = none # Unused private types or members should be removed - DUPLICATE IDE0051 -dotnet_diagnostic.S1151.severity = error # "switch case" clauses should not have too many lines of code +dotnet_diagnostic.S1151.severity = none # "switch case" clauses should not have too many lines of code — covered by SST1524 dotnet_diagnostic.S1168.severity = error # Empty arrays and collections should be returned instead of null -dotnet_diagnostic.S1172.severity = error # Unused method parameters should be removed +dotnet_diagnostic.S1172.severity = none # Unused method parameters should be removed — covered by SST1461 dotnet_diagnostic.S1200.severity = none # Classes should not be coupled to too many other classes -dotnet_diagnostic.S122.severity = error # Statements should be on separate lines +dotnet_diagnostic.S122.severity = none # Statements should be on separate lines — covered by SST1107 dotnet_diagnostic.S125.severity = none # Sections of code should not be commented out — covered by SST1148 dotnet_diagnostic.S127.severity = error # "for" loop stop conditions should be invariant -dotnet_diagnostic.S138.severity = error # Functions should not have too many lines of code -dotnet_diagnostic.S1479.severity = error # "switch" statements with many "case" clauses should have only one statement +dotnet_diagnostic.S138.severity = none # Functions should not have too many lines of code — covered by SST1523 +dotnet_diagnostic.S1479.severity = none # "switch" statements with many "case" clauses — covered by SST1423 dotnet_diagnostic.S1607.severity = error # Tests should not be ignored -dotnet_diagnostic.S1696.severity = error # NullReferenceException should not be caught +dotnet_diagnostic.S1696.severity = none # NullReferenceException should not be caught — covered by SST2401 dotnet_diagnostic.S1854.severity = none # Unused assignments should be removed - DUPLICATE IDE0059 dotnet_diagnostic.S1871.severity = error # Two branches in a conditional structure should not have exactly the same implementation dotnet_diagnostic.S2139.severity = error # Exceptions should be either logged or rethrown but not both dotnet_diagnostic.S2166.severity = none # Classes named like "Exception" should extend "Exception" or a subclass - DUPLICATE CA1710 dotnet_diagnostic.S2234.severity = error # Arguments should be passed in the same order as the method parameters -dotnet_diagnostic.S2326.severity = error # Unused type parameters should be removed +dotnet_diagnostic.S2326.severity = none # Unused type parameters should be removed — covered by SST1452 dotnet_diagnostic.S2327.severity = error # "try" statements with identical "catch" and/or "finally" blocks should be merged dotnet_diagnostic.S2357.severity = none # Fields should be private — duplicate of SST1401 (canonical); fields intentionally exposed (e.g. public test fields for reflection) already carry per-site SST1401 suppressions -dotnet_diagnostic.S2372.severity = error # Exceptions should not be thrown from property getters +dotnet_diagnostic.S2372.severity = none # Exceptions should not be thrown from property getters — covered by SST1485 dotnet_diagnostic.S2376.severity = error # Write-only properties should not be used dotnet_diagnostic.S2629.severity = error # Logging templates should be constant -dotnet_diagnostic.S2681.severity = error # Multiline blocks should be enclosed in curly braces +dotnet_diagnostic.S2681.severity = none # Multiline blocks should be enclosed in curly braces — covered by SST1503 dotnet_diagnostic.S2743.severity = none # Static fields should not be used in generic types - DUPLICATE CA1000 — covered by SST1431 dotnet_diagnostic.S2925.severity = error # "Thread.Sleep" should not be used in tests dotnet_diagnostic.S2933.severity = none # Fields that are only assigned in the constructor should be "readonly" - DUPLICATE IDE0044 -dotnet_diagnostic.S2971.severity = error # LINQ expressions should be simplified -dotnet_diagnostic.S3010.severity = error # Static fields should not be updated in constructors +dotnet_diagnostic.S2971.severity = none # LINQ expressions should be simplified — covered by PSH1101/PSH1102 +dotnet_diagnostic.S3010.severity = none # Static fields should not be updated in constructors — covered by SST2402 dotnet_diagnostic.S3011.severity = error # Reflection should not be used to increase accessibility of classes, methods, or fields dotnet_diagnostic.S3059.severity = none # Types should not have members with visibility set higher than the type's visibility -dotnet_diagnostic.S3063.severity = error # "StringBuilder" data should be used -dotnet_diagnostic.S3169.severity = error # Multiple "OrderBy" calls should not be used +dotnet_diagnostic.S3063.severity = none # "StringBuilder" data should be used — covered by SST2408 +dotnet_diagnostic.S3169.severity = none # Multiple "OrderBy" calls should not be used — covered by PSH1108 dotnet_diagnostic.S3246.severity = error # Generic type parameters should be co/contravariant when possible dotnet_diagnostic.S3262.severity = error # "params" should be used on overrides -dotnet_diagnostic.S3264.severity = error # Events should be invoked +dotnet_diagnostic.S3264.severity = none # Events should be invoked — covered by SST2407 dotnet_diagnostic.S3358.severity = none # Ternary operators should not be nested — covered by SST1147 -dotnet_diagnostic.S3366.severity = error # "this" should not be exposed from constructors +dotnet_diagnostic.S3366.severity = none # "this" should not be exposed from constructors — covered by SST2403 dotnet_diagnostic.S3415.severity = error # Assertion arguments should be passed in the correct order dotnet_diagnostic.S3431.severity = error # "[ExpectedException]" should not be used dotnet_diagnostic.S3442.severity = none # "abstract" classes should not have "public" constructors — covered by SST1428 dotnet_diagnostic.S3445.severity = none # Exceptions should not be explicitly rethrown — covered by SST1430 -dotnet_diagnostic.S3457.severity = error # Composite format strings should be used correctly +dotnet_diagnostic.S3457.severity = none # Composite format strings should be used correctly — covered by SST1454 dotnet_diagnostic.S3597.severity = error # "ServiceContract" and "OperationContract" attributes should be used together -dotnet_diagnostic.S3880.severity = none # Finalizers should not be empty — covered by SST1434 +dotnet_diagnostic.S3880.severity = none # Finalizers should not be empty — covered by PSH1002 dotnet_diagnostic.S3881.severity = error # "IDisposable" should be implemented correctly dotnet_diagnostic.S3885.severity = error # "Assembly.Load" should be used -dotnet_diagnostic.S3898.severity = error # Value types should implement "IEquatable" -dotnet_diagnostic.S3902.severity = error # "Assembly.GetExecutingAssembly" should not be called +dotnet_diagnostic.S3898.severity = none # Value types should implement "IEquatable" — covered by PSH1005 +dotnet_diagnostic.S3902.severity = none # "Assembly.GetExecutingAssembly" should not be called — covered by PSH1404 dotnet_diagnostic.S3906.severity = error # Event Handlers should have the correct signature dotnet_diagnostic.S3908.severity = error # Generic event handlers should be used dotnet_diagnostic.S3909.severity = error # Collections should implement the generic interface @@ -1935,12 +2180,12 @@ dotnet_diagnostic.S4004.severity = error # Collection properties should be reado dotnet_diagnostic.S4005.severity = none # "System.Uri" arguments should be used instead of strings dotnet_diagnostic.S4016.severity = error # Enumeration members should not be named "Reserved" dotnet_diagnostic.S4017.severity = none # Method signatures should not contain nested generic types -dotnet_diagnostic.S4035.severity = error # Classes implementing "IEquatable" should be sealed +dotnet_diagnostic.S4035.severity = none # Classes implementing "IEquatable" should be sealed — covered by SST2301 dotnet_diagnostic.S4050.severity = error # Operators should be overloaded consistently dotnet_diagnostic.S4055.severity = none # Literals should not be passed as localized parameters dotnet_diagnostic.S4057.severity = error # Locales should be set for data types dotnet_diagnostic.S4059.severity = none # Property names should not match get methods - DUPLICATE CA1721 -dotnet_diagnostic.S4070.severity = error # Non-flags enums should not be marked with "FlagsAttribute" +dotnet_diagnostic.S4070.severity = none # Non-flags enums should not be marked with "FlagsAttribute" — covered by SST2303 dotnet_diagnostic.S4144.severity = error # Methods should not have identical implementations dotnet_diagnostic.S4200.severity = error # Native methods should be wrapped dotnet_diagnostic.S4214.severity = none # "P/Invoke" methods should not be visible - DUPLICATE CA1401 @@ -1948,16 +2193,16 @@ dotnet_diagnostic.S4220.severity = error # Events should have proper arguments dotnet_diagnostic.S4456.severity = error # Parameter validation in yielding methods should be wrapped dotnet_diagnostic.S4457.severity = none # Parameter validation in "async"/"await" methods should be wrapped dotnet_diagnostic.S4545.severity = error # "DebuggerDisplayAttribute" strings should reference existing members -dotnet_diagnostic.S4581.severity = error # "new Guid()" should not be used -dotnet_diagnostic.S6354.severity = error # Use a testable date/time provider +dotnet_diagnostic.S4581.severity = none # "new Guid()" should not be used — covered by SST2012 +dotnet_diagnostic.S6354.severity = none # Use a testable date/time provider — covered by SST2010 dotnet_diagnostic.S6419.severity = error # Azure Functions should be stateless dotnet_diagnostic.S6420.severity = error # Client instances should not be recreated on each Azure Function invocation dotnet_diagnostic.S6421.severity = error # Azure Functions should use Structured Error Handling dotnet_diagnostic.S6423.severity = error # Azure Functions should log all failures -dotnet_diagnostic.S6561.severity = error # Avoid using "DateTime.Now" for benchmarking or timing operations -dotnet_diagnostic.S6562.severity = error # Always set the "DateTimeKind" when creating new "DateTime" instances +dotnet_diagnostic.S6561.severity = none # Avoid using "DateTime.Now" for benchmarking or timing operations — covered by PSH1408 +dotnet_diagnostic.S6562.severity = none # Always set the "DateTimeKind" when creating new "DateTime" instances — covered by SST1451 dotnet_diagnostic.S6563.severity = error # Use UTC when recording DateTime instants -dotnet_diagnostic.S6566.severity = error # Use "DateTimeOffset" instead of "DateTime" +dotnet_diagnostic.S6566.severity = none # Use "DateTimeOffset" instead of "DateTime" — covered by SST2016 dotnet_diagnostic.S6575.severity = error # Use "TimeZoneInfo.FindSystemTimeZoneById" without converting the timezones with "TimezoneConverter" dotnet_diagnostic.S6580.severity = none # Use a format provider when parsing date and time - DUPLICATE CA1305 dotnet_diagnostic.S6673.severity = error # Log message template placeholders should be in the right order @@ -1971,86 +2216,86 @@ dotnet_diagnostic.S6961.severity = error # API Controllers should derive from Co dotnet_diagnostic.S6962.severity = error # You should pool HTTP connections with HttpClientFactory dotnet_diagnostic.S6964.severity = error # Value type property used as input in a controller action should be nullable, required or annotated with the JsonRequiredAttribute to avoid under-posting. dotnet_diagnostic.S6965.severity = error # REST API actions should be annotated with an HTTP verb attribute -dotnet_diagnostic.S6966.severity = error # Awaitable method should be used +dotnet_diagnostic.S6966.severity = none # Awaitable method should be used — covered by PSH1313 dotnet_diagnostic.S6968.severity = error # Actions that return a value should be annotated with ProducesResponseTypeAttribute containing the return type -dotnet_diagnostic.S881.severity = error # Increment (++) and decrement (--) operators should not be used in a method call or mixed with other operators in an expression +dotnet_diagnostic.S881.severity = none # Increment (++) and decrement (--) operators should not be used in a method call or mixed with other operators in an expression — covered by SST2015 dotnet_diagnostic.S907.severity = error # "goto" statement should not be used ################### # SonarAnalyzer (Sxxxx) - Minor Code Smell ################### -dotnet_diagnostic.S100.severity = error # Methods and properties should be named in PascalCase (kept over SA1300 — S100+S101 47ms vs SA1300 101ms) -dotnet_diagnostic.S101.severity = error # Types should be named in PascalCase (kept over SA1300 — see S100) -dotnet_diagnostic.S105.severity = error # Tabulation characters should not be used +dotnet_diagnostic.S100.severity = none # Methods and properties should be named in PascalCase — covered by SST1300 +dotnet_diagnostic.S101.severity = none # Types should be named in PascalCase — covered by SST1300 +dotnet_diagnostic.S105.severity = none # Tabulation characters should not be used — covered by SST1027 dotnet_diagnostic.S1104.severity = none # Fields should not have public accessibility - DUPLICATE CA1051 -dotnet_diagnostic.S1109.severity = error # A close curly brace should be located at the beginning of a line +dotnet_diagnostic.S1109.severity = none # A close curly brace should be located at the beginning of a line — covered by SST1500 dotnet_diagnostic.S1116.severity = none # Empty statements should be removed - DUPLICATE SA1106 dotnet_diagnostic.S1125.severity = none # Boolean literals should not be redundant — covered by SST1182 -dotnet_diagnostic.S1128.severity = error # Unnecessary "using" should be removed (kept over IDE0005 — Sonar 551ms vs IDE0005 2.364s) +dotnet_diagnostic.S1128.severity = none # Unnecessary "using" should be removed — covered by SST1445 dotnet_diagnostic.S113.severity = none # Files should end with a newline -dotnet_diagnostic.S1155.severity = error # "Any()" should be used to test for emptiness +dotnet_diagnostic.S1155.severity = none # "Any()" should be used to test for emptiness — covered by PSH1119 dotnet_diagnostic.S1185.severity = none # Overriding members should do more than simply call the same member in the base class - DUPLICATE RCS1132 -dotnet_diagnostic.S1192.severity = error # String literals should not be duplicated +dotnet_diagnostic.S1192.severity = none # String literals should not be duplicated — covered by SST1486 dotnet_diagnostic.S1199.severity = error # Nested code blocks should not be used dotnet_diagnostic.S1210.severity = none # "Equals" and the comparison operators should be overridden when implementing "IComparable" - DUPLICATE CA1036 dotnet_diagnostic.S1227.severity = none # break statements should not be used except for switch cases dotnet_diagnostic.S1264.severity = error # A "while" loop should be used instead of a "for" loop dotnet_diagnostic.S1301.severity = none # "switch" statements should have at least 3 "case" clauses dotnet_diagnostic.S1312.severity = none # Logger fields should be "private static readonly" -dotnet_diagnostic.S1449.severity = error # Culture should be specified for "string" operations -dotnet_diagnostic.S1450.severity = error # Private fields only used as local variables in methods should become local variables +dotnet_diagnostic.S1449.severity = none # Culture should be specified for "string" operations — covered by PSH1207 +dotnet_diagnostic.S1450.severity = none # Private fields only used as local variables in methods should become local variables — covered by SST1422 dotnet_diagnostic.S1481.severity = error # Unused local variables should be removed -dotnet_diagnostic.S1643.severity = error # Strings should not be concatenated using '+' in a loop +dotnet_diagnostic.S1643.severity = none # Strings should not be concatenated using '+' in a loop — covered by PSH1206 dotnet_diagnostic.S1659.severity = error # Multiple variables should not be declared on the same line dotnet_diagnostic.S1694.severity = error # An abstract class should have both abstract and concrete methods dotnet_diagnostic.S1698.severity = error # "==" should not be used when "Equals" is overridden -dotnet_diagnostic.S1858.severity = error # "ToString()" calls should not be redundant +dotnet_diagnostic.S1858.severity = none # "ToString()" calls should not be redundant — covered by PSH1211 dotnet_diagnostic.S1905.severity = none # Redundant casts should not be used - DUPLICATE IDE0004 — covered by SST1175 dotnet_diagnostic.S1939.severity = error # Inheritance list should not be redundant -dotnet_diagnostic.S1940.severity = error # Boolean checks should not be inverted +dotnet_diagnostic.S1940.severity = none # Boolean checks should not be inverted — covered by SST1172 dotnet_diagnostic.S2094.severity = none # Classes should not be empty — covered by SST1436 dotnet_diagnostic.S2148.severity = none # Underscores should be used to make large numbers readable — covered by SST1191 -dotnet_diagnostic.S2156.severity = error # "sealed" classes should not have "protected" members -dotnet_diagnostic.S2219.severity = error # Runtime type checking should be simplified +dotnet_diagnostic.S2156.severity = none # "sealed" classes should not have "protected" members — covered by SST1427 +dotnet_diagnostic.S2219.severity = none # Runtime type checking should be simplified — covered by SST2007 dotnet_diagnostic.S2221.severity = none # "Exception" should not be caught -dotnet_diagnostic.S2292.severity = error # Trivial properties should be auto-implemented +dotnet_diagnostic.S2292.severity = none # Trivial properties should be auto-implemented — covered by SST1420 dotnet_diagnostic.S2325.severity = none # Methods and properties that don't access instance data should be static - DUPLICATE CA1822 dotnet_diagnostic.S2333.severity = error # Redundant modifiers should not be used -dotnet_diagnostic.S2342.severity = error # Enumeration types should comply with a naming convention +dotnet_diagnostic.S2342.severity = none # Enumeration types should comply with a naming convention — covered by SST1319 dotnet_diagnostic.S2344.severity = none # Enumeration type names should not have "Flags" or "Enum" suffixes - DUPLICATE CA1711 dotnet_diagnostic.S2386.severity = error # Mutable fields should not be "public static" dotnet_diagnostic.S2486.severity = none # Generic exceptions should not be ignored — covered by SST1429 -dotnet_diagnostic.S2737.severity = error # "catch" clauses should do more than rethrow +dotnet_diagnostic.S2737.severity = none # "catch" clauses should do more than rethrow — covered by SST1470 dotnet_diagnostic.S2760.severity = error # Sequential tests should not check the same condition -dotnet_diagnostic.S3052.severity = error # Members should not be initialized to default values +dotnet_diagnostic.S3052.severity = none # Members should not be initialized to default values — covered by PSH1403 dotnet_diagnostic.S3220.severity = error # Method calls should not resolve ambiguously to overloads with "params" -dotnet_diagnostic.S3234.severity = error # "GC.SuppressFinalize" should not be invoked for types without destructors -dotnet_diagnostic.S3235.severity = error # Redundant parentheses should not be used -dotnet_diagnostic.S3236.severity = error # Caller information arguments should not be provided explicitly +dotnet_diagnostic.S3234.severity = none # "GC.SuppressFinalize" should not be invoked for types without destructors — covered by PSH1008 +dotnet_diagnostic.S3235.severity = none # Redundant parentheses should not be used — covered by SST1459 +dotnet_diagnostic.S3236.severity = none # Caller information arguments should not be provided explicitly — covered by SST1448 dotnet_diagnostic.S3240.severity = none # The simplest possible condition syntax should be used - duplicate of SST1198 dotnet_diagnostic.S3241.severity = error # Methods should not return values that are never used dotnet_diagnostic.S3242.severity = none # Method parameters should be declared with base types -dotnet_diagnostic.S3247.severity = error # Duplicate casts should not be made +dotnet_diagnostic.S3247.severity = none # Duplicate casts should not be made — covered by SST1175 dotnet_diagnostic.S3251.severity = error # Implementations should be provided for "partial" methods dotnet_diagnostic.S3253.severity = none # Constructor and destructor declarations should not be redundant — covered by SST1433 -dotnet_diagnostic.S3254.severity = error # Default parameter values should not be passed as arguments -dotnet_diagnostic.S3256.severity = error # "string.IsNullOrEmpty" should be used +dotnet_diagnostic.S3254.severity = none # Default parameter values should not be passed as arguments — covered by SST1494 +dotnet_diagnostic.S3256.severity = none # "string.IsNullOrEmpty" should be used — covered by PSH1204 (style configurable) dotnet_diagnostic.S3257.severity = error # Declarations and initializations should be as concise as possible -dotnet_diagnostic.S3260.severity = error # Non-derived "private" classes and records should be "sealed" +dotnet_diagnostic.S3260.severity = none # Non-derived "private" classes and records should be "sealed" — covered by PSH1411 dotnet_diagnostic.S3261.severity = none # Namespaces should not be empty — covered by SST1435 dotnet_diagnostic.S3267.severity = none # Loops should be simplified with "LINQ" expressions dotnet_diagnostic.S3376.severity = none # Attribute, EventArgs, and Exception type names should end with the type being extended - DUPLICATE CA1710 -dotnet_diagnostic.S3398.severity = error # "private" methods called only by inner classes should be moved to those classes -dotnet_diagnostic.S3400.severity = error # Methods should not return constants +dotnet_diagnostic.S3398.severity = none # "private" methods called only by inner classes should be moved to those classes — covered by SST1498 +dotnet_diagnostic.S3400.severity = none # Methods should not return constants — covered by SST1493 dotnet_diagnostic.S3416.severity = error # Loggers should be named for their enclosing types -dotnet_diagnostic.S3440.severity = error # Variables should not be checked against the values they're about to be assigned -dotnet_diagnostic.S3441.severity = error # Redundant property names should be omitted in anonymous classes +dotnet_diagnostic.S3440.severity = none # Variables should not be checked against the values they're about to be assigned — covered by SST1492 +dotnet_diagnostic.S3441.severity = none # Redundant property names should be omitted in anonymous classes — covered by SST1173 dotnet_diagnostic.S3444.severity = error # Interfaces should not simply inherit from base interfaces with colliding members dotnet_diagnostic.S3450.severity = error # Parameters with "[DefaultParameterValue]" attributes should also be marked "[Optional]" -dotnet_diagnostic.S3458.severity = error # Empty "case" clauses that fall through to the "default" should be omitted +dotnet_diagnostic.S3458.severity = none # Empty "case" clauses that fall through to the "default" should be omitted — covered by SST1466 dotnet_diagnostic.S3459.severity = error # Unassigned members should be removed -dotnet_diagnostic.S3532.severity = error # Empty "default" clauses should be removed -dotnet_diagnostic.S3604.severity = error # Member initializer values should not be redundant +dotnet_diagnostic.S3532.severity = none # Empty "default" clauses should be removed — covered by SST1179 +dotnet_diagnostic.S3604.severity = none # Member initializer values should not be redundant — covered by PSH1403 dotnet_diagnostic.S3626.severity = none # Jump statements should not be redundant — covered by SST1174 dotnet_diagnostic.S3717.severity = error # Track use of "NotImplementedException" dotnet_diagnostic.S3872.severity = error # Parameter names should not duplicate the names of their methods @@ -2060,41 +2305,41 @@ dotnet_diagnostic.S3897.severity = error # Classes that provide "Equals()" sh dotnet_diagnostic.S3962.severity = none # "static readonly" constants should be "const" instead - DUPLICATE CA1802 dotnet_diagnostic.S3963.severity = none # "static" fields should be initialized inline - DUPLICATE CA1810 dotnet_diagnostic.S3967.severity = none # Multidimensional arrays should not be used - DUPLICATE CA1814 -dotnet_diagnostic.S4018.severity = error # All type parameters should be used in the parameter list to enable type inference -dotnet_diagnostic.S4022.severity = error # Enumerations should have "Int32" storage +dotnet_diagnostic.S4018.severity = none # All type parameters should be used in the parameter list to enable type inference — covered by SST2307 +dotnet_diagnostic.S4022.severity = none # Enumerations should have "Int32" storage — covered by SST2313 dotnet_diagnostic.S4023.severity = none # Interfaces should not be empty — covered by SST1437 dotnet_diagnostic.S4026.severity = error # Assemblies should be marked with "NeutralResourcesLanguageAttribute" -dotnet_diagnostic.S4027.severity = error # Exceptions should provide standard constructors +dotnet_diagnostic.S4027.severity = none # Exceptions should provide standard constructors - DUPLICATE CA1032 dotnet_diagnostic.S4040.severity = none # Strings should be normalized to uppercase - DUPLICATE CA1308 dotnet_diagnostic.S4041.severity = none # Type names should not match namespaces - DUPLICATE CA1724 dotnet_diagnostic.S4047.severity = error # Generics should be used when appropriate dotnet_diagnostic.S4049.severity = none # Properties should be preferred - DUPLICATE CA1024 dotnet_diagnostic.S4052.severity = error # Types should not extend outdated base types dotnet_diagnostic.S4056.severity = none # Overloads with a "CultureInfo" or an "IFormatProvider" parameter should be used - DUPLICATE CA1305 -dotnet_diagnostic.S4058.severity = error # Overloads with a "StringComparison" parameter should be used +dotnet_diagnostic.S4058.severity = none # Overloads with a "StringComparison" parameter should be used — covered by PSH1207 dotnet_diagnostic.S4060.severity = none # Non-abstract attributes should be sealed - DUPLICATE CA1813 dotnet_diagnostic.S4061.severity = error # "params" should be used instead of "varargs" dotnet_diagnostic.S4069.severity = none # Operator overloads should have named alternatives - DUPLICATE CA2225 -dotnet_diagnostic.S4136.severity = error # Method overloads should be grouped together +dotnet_diagnostic.S4136.severity = none # Method overloads should be grouped together — covered by SST1218 dotnet_diagnostic.S4201.severity = error # Null checks should not be combined with "is" operator checks dotnet_diagnostic.S4225.severity = error # Extension methods should not extend "object" dotnet_diagnostic.S4226.severity = none # Extensions should be in separate namespaces dotnet_diagnostic.S4261.severity = none # Methods should be named according to their synchronicities - Async suffix not used -dotnet_diagnostic.S4663.severity = error # Comments should not be empty +dotnet_diagnostic.S4663.severity = none # Comments should not be empty — covered by SST1659 dotnet_diagnostic.S6513.severity = none # "ExcludeFromCodeCoverage" attributes should include a justification - not available on net462 and older TFMs dotnet_diagnostic.S6585.severity = error # Don't hardcode the format when turning dates and times to strings -dotnet_diagnostic.S6588.severity = error # Use the "UnixEpoch" field instead of creating "DateTime" instances that point to the beginning of the Unix epoch +dotnet_diagnostic.S6588.severity = none # Use the "UnixEpoch" field instead of creating "DateTime" instances that point to the beginning of the Unix epoch — covered by PSH1413 dotnet_diagnostic.S6602.severity = none # "Find" method should be used instead of the "FirstOrDefault" extension - DUPLICATE CA1826 -dotnet_diagnostic.S6603.severity = error # The collection-specific "TrueForAll" method should be used instead of the "All" extension -dotnet_diagnostic.S6605.severity = error # Collection-specific "Exists" method should be used instead of the "Any" extension -dotnet_diagnostic.S6607.severity = error # The collection should be filtered before sorting by using "Where" before "OrderBy" +dotnet_diagnostic.S6603.severity = none # The collection-specific "TrueForAll" method should be used instead of the "All" extension — covered by PSH1110 +dotnet_diagnostic.S6605.severity = none # Collection-specific "Exists" method should be used instead of the "Any" extension — covered by PSH1110 +dotnet_diagnostic.S6607.severity = none # The collection should be filtered before sorting — covered by PSH1107 dotnet_diagnostic.S6608.severity = none # Prefer indexing instead of "Enumerable" methods on types implementing "IList" - DUPLICATE CA1826 -dotnet_diagnostic.S6609.severity = error # "Min/Max" properties of "Set" types should be used instead of the "Enumerable" extension methods -dotnet_diagnostic.S6610.severity = error # "StartsWith" and "EndsWith" overloads that take a "char" should be used instead of the ones that take a "string" -dotnet_diagnostic.S6612.severity = error # The lambda parameter should be used instead of capturing arguments in "ConcurrentDictionary" methods -dotnet_diagnostic.S6613.severity = error # "First" and "Last" properties of "LinkedList" should be used instead of the "First()" and "Last()" extension methods -dotnet_diagnostic.S6617.severity = error # "Contains" should be used instead of "Any" for simple equality checks -dotnet_diagnostic.S6618.severity = error # "string.Create" should be used instead of "FormattableString" +dotnet_diagnostic.S6609.severity = none # "Min/Max" properties of "Set" types should be used instead of the "Enumerable" extension methods — covered by PSH1122 +dotnet_diagnostic.S6610.severity = none # "StartsWith"/"EndsWith" char overloads should be used — covered by PSH1201 +dotnet_diagnostic.S6612.severity = none # The lambda parameter should be used instead of capturing arguments in "ConcurrentDictionary" methods — covered by PSH1006 +dotnet_diagnostic.S6613.severity = none # "First" and "Last" properties of "LinkedList" should be used instead of the "First()" and "Last()" extension methods — covered by PSH1124 +dotnet_diagnostic.S6617.severity = none # "Contains" should be used instead of "Any" for simple equality checks — covered by PSH1111 +dotnet_diagnostic.S6618.severity = none # "string.Create" should be used instead of "FormattableString" — covered by PSH1209 dotnet_diagnostic.S6664.severity = error # The code block contains too many logging calls dotnet_diagnostic.S6667.severity = error # Logging in a catch clause should pass the caught exception as a parameter. dotnet_diagnostic.S6668.severity = error # Logging arguments should be passed to the correct parameter @@ -2103,12 +2348,12 @@ dotnet_diagnostic.S6670.severity = error # "Trace.Write" and "Trace.WriteLine" s dotnet_diagnostic.S6672.severity = error # Generic logger injection should match enclosing type dotnet_diagnostic.S6675.severity = error # "Trace.WriteLineIf" should not be used with "TraceSwitch" levels dotnet_diagnostic.S6678.severity = error # Use PascalCase for named placeholders -dotnet_diagnostic.S818.severity = error # Literal suffixes should be upper case +dotnet_diagnostic.S818.severity = none # Literal suffixes should be upper case — covered by SST2244 ################### # SonarAnalyzer (Sxxxx) - Info Code Smell ################### -dotnet_diagnostic.S1133.severity = error # Deprecated code should be removed +dotnet_diagnostic.S1133.severity = none # Deprecated code should be removed — covered by SST2310 dotnet_diagnostic.S1135.severity = error # Track uses of "TODO" tags dotnet_diagnostic.S1309.severity = none # Track uses of in-source issue suppressions @@ -2338,19 +2583,3 @@ end_of_line = lf [*.{cmd, bat}] end_of_line = crlf - - -############################################# -# Test projects (TUnit) -############################################# -# TUnit instantiates test classes per test, so they must remain instance classes and -# cannot be marked static — even when a partial declaration happens to hold only static -# members (the instance [Test] methods live in sibling partial files). -[**/tests/**/*.cs] -stylesharp.avoid_linq_on_hot_path = false -dotnet_diagnostic.SST2229.severity = error # In tests, simplify LINQ Where plus terminal calls instead of banning LINQ -dotnet_diagnostic.SST2230.severity = error # In tests, simplify LINQ type filters instead of banning LINQ -dotnet_diagnostic.SST2233.severity = none # Tests can use LINQ for clarity -dotnet_diagnostic.SST1432.severity = none -dotnet_diagnostic.RCS1072.severity = none # covered by SST1435 -dotnet_diagnostic.RCS1106.severity = none # covered by SST1434 diff --git a/.gitignore b/.gitignore index c27e24da2..e18e0137a 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,6 @@ Refit-Tests/test-results /InterfaceStubGenerator.Core/Properties/launchSettings.json *.svclog **/BenchmarkDotNet.Artifacts + +# Local agent worktrees and scratch +.claude/ diff --git a/README.md b/README.md index a6d2e1925..aa71c56dd 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,7 @@ lets you stub responses and verify requests with a declarative route table — s * [Sponsors](#sponsors) * [Where does this work?](#where-does-this-work) - * [Breaking changes in V13.x](#breaking-changes-in-v13x) - * [Breaking changes in V12.x](#breaking-changes-in-v12x) - * [Breaking changes in 11.x](#breaking-changes-in-11x) - * [Breaking changes in 6.x](#breaking-changes-in-6x) +* [Breaking changes and release notes](docs/breaking-changes.md) * [Source generation](#source-generation) * [Generated-only client creation](#generated-only-client-creation) * [Generated request building](#generated-request-building) @@ -56,6 +53,7 @@ lets you stub responses and verify requests with a declarative route table — s * [Dynamic Querystring Parameters](#dynamic-querystring-parameters) * [Collections as Querystring parameters](#collections-as-querystring-parameters) * [Unescape Querystring parameters](#unescape-querystring-parameters) + * [Valueless query flags](#valueless-query-flags) * [Custom Querystring Parameter formatting](#custom-querystring-parameter-formatting) * [Body content](#body-content) * [Buffering and the Content-Length header](#buffering-and-the-content-length-header) @@ -110,139 +108,20 @@ Refit currently supports the following platforms and modern .NET targets: ### SDK Requirements -### V13.x.x +### Breaking changes and release notes -#### Breaking changes in V13.x - -Refit 13 hardens the default security posture from a security audit. The new behavior is safe by default; the changes -below only affect code that previously relied on the less-secure defaults. - -* **XML responses no longer process DTDs.** `XmlContentSerializer` now forces `DtdProcessing.Prohibit` and clears the - `XmlResolver` on every read, blocking XML External Entity (XXE) and entity-expansion ("billion laughs") attacks. If - you were deserializing trusted XML that depends on a DTD or external entities, that content will now throw. We - **strongly recommend against re-enabling DTD processing**, but if your XML comes from a fully trusted source you can - opt out via the obsolete `XmlReaderWriterSettings.AllowDtdProcessing` flag (marked `[Obsolete]` deliberately, so it - surfaces a compiler warning) - you must also configure `DtdProcessing`/`XmlResolver` yourself on `ReaderSettings`. - Prefer pre-processing untrusted documents instead. -* **Newtonsoft.Json no longer inherits an unsafe global `TypeNameHandling`.** When you do not pass explicit - `JsonSerializerSettings`, `NewtonsoftJsonContentSerializer` now forces `TypeNameHandling.None` even if - `JsonConvert.DefaultSettings` configured a different value, closing a known remote-code-execution gadget vector on - response bodies. If you genuinely need polymorphic (`$type`) deserialization, opt in explicitly by passing your own - `JsonSerializerSettings` (ideally constrained with a `SerializationBinder`). - -The release also adds two opt-in, non-breaking knobs on `RefitSettings` for hardening exception handling: - -* `RefitSettings.ExceptionRedactor` — a hook invoked before an `ApiException` propagates, so you can scrub the - `Authorization` header, request/response bodies, and `Set-Cookie` before they reach logging or telemetry pipelines - that serialize exceptions. -* `RefitSettings.MaxExceptionContentLength` — caps how many characters of an error response body are read into - `ApiException.Content`, bounding memory use against hostile or oversized error responses. Defaults to unbounded. - -#### New in V13.x - -* **`Refit.Testing`** — a new first-party package for testing Refit clients without a mocking library or a live - server. You describe expected calls as a route table (`Route` → `Reply`) and point a real client at it via - `StubHttp.CreateClient(...)`. Route templates mirror your interface attributes, typed replies (`Reply.With`) - are serialized with the client's own serializer, and the sent request body can be read back as a typed object with - `LastRequestBodyAsync()`. It also includes `NetworkBehavior` for seeded latency/fault injection and - `StubApiResponse` for unit-testing code that consumes `IApiResponse`. See - [Testing your Refit clients](#testing-your-refit-clients). - -### V12.x.x - -#### Breaking changes in V12.x - -Refit 12.0 is a large release centered on a near-complete rewrite of request building. The source generator now builds -eligible HTTP requests inline at compile time instead of going through the reflection request-builder pipeline, with the -reflection path kept as a fallback for shapes that cannot be generated inline. - -Two breaking changes are called out for migration: - -* `IApiResponse` no longer shadows base interface members. The `new`-shadowed `Error`, `ContentHeaders`, - `IsSuccessStatusCode`, and `IsSuccessful` members were removed from the generic interface. Source that reads these - members still binds to the inherited base members, but assemblies compiled against v8-v11 should be recompiled. - If you used `IsSuccessful` to narrow `Content` to non-null on an `IApiResponse` value, use `HasContent` or - `IsSuccessfulWithContent` instead. -* The default `System.Text.Json` serializer now reads numbers from JSON strings by setting - `JsonNumberHandling.AllowReadingFromString`. To opt back out, set `NumberHandling = JsonNumberHandling.Strict` on - your `JsonSerializerOptions`. - -See the [Refit 12.0.0 release notes](https://github.com/reactiveui/refit/releases/tag/v12.0.0) for the full release -details. - -### V11.x.x - -#### Breaking changes in 11.x - -Refit 11 introduces `ApiRequestException` to represent requests that fail before receiving a response from the server. -This exception will now wrap previous exceptions such as `HttpRequestException` and `TaskCanceledException` when they -occur during request execution. - -* If you were not wrapping responses with `IApiResponse` and were catching these exceptions directly, you will need to - update your code to catch `ApiRequestException` instead. -* If you were wrapping responses with `IApiResponse`, these exceptions will no longer be thrown and will instead be - captured in the `IApiResponse.Error` property. - You can use the new `IApiResponse.HasRequestError(out var apiRequestException)` method to safely check and retrieve - the `ApiRequestException` instance. - -The `IApiResponse.Error` property's type has also changed to `ApiExceptionBase`, which is the new base class for -`ApiException` and `ApiRequestException`. -If your code accessed members specific to `ApiException` (i.e. anything related to the response from the server), you -can use the new `IApiResponse.HasResponseError(out var apiException)` method to safely check and retrieve the -`ApiException` instance. - -All response-related properties of `IApiResponse` are now nullable. -The new `IApiResponse.IsReceived` property can be used to check if a response was received from the server, and will -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. - -### Updates in 8.0.x - -Fixes for some issues experienced, this led to some breaking changes. -See [Releases](https://github.com/reactiveui/refit/releases) for full details. - -### V6.x.x - -Refit 6 requires Visual Studio 16.8 or higher, or the .NET SDK 5.0.100 or higher. It can target any .NET Standard 2.0 -platform. - -Refit 6 does not support the old `packages.config` format for NuGet references (as they do not support analyzers/source -generators). You must -[migrate to PackageReference](https://devblogs.microsoft.com/nuget/migrate-packages-config-to-package-reference/) to use -Refit v6 and later. - -#### Breaking changes in 6.x - -Refit 6 -makes [System.Text.Json](https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-overview) the -default JSON serializer. If you'd like to continue to use `Newtonsoft.Json`, add the `Refit.Newtonsoft.Json` NuGet -package and set your `ContentSerializer` to `NewtonsoftJsonContentSerializer` on your `RefitSettings` instance. -`System.Text.Json` is faster and uses less memory, though not all features are supported. -The [migration guide](https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-migrate-from-newtonsoft-how-to?pivots=dotnet-5-0) -contains more details. - -`IContentSerializer` was renamed to `IHttpContentSerializer` to better reflect its purpose. Additionally, two of its -methods were renamed, `SerializeAsync` -> `ToHttpContent` and `DeserializeAsync` -> `FromHttpContentAsync`. -Any existing implementations of these will need to be updated, though the changes should be minor. - -##### Updates in 6.3 - -Refit 6.3 splits out the XML serialization via `XmlContentSerializer` into a separate package, `Refit.Xml`. This -is to reduce the dependency size when using Refit with Web Assembly (WASM) applications. If you require XML, add a -reference -to `Refit.Xml`. +Breaking changes and the notable additions for each major version — including the V14 move of the reflection +request builder into the opt-in [`Refit.Reflection`](https://www.nuget.org/packages/Refit.Reflection) package — +are documented in [Breaking changes and release notes](docs/breaking-changes.md). ### 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 `Refit` package ships Roslyn source generators. A `PackageReference` to Refit gets you generated clients at build +time — no extra package. -Generated Refit client source is compatible with C# 7.3 as the baseline. When the consuming project uses C# 8 or newer -and nullable reference types are available, the generator also emits nullable directives and nullable annotations for -the generated client. +The generated code targets C# 7.3. On C# 8 or newer, the generator also emits nullable directives and annotations. -The generated clients are still created with the normal APIs: +You create generated clients with the normal APIs: ```csharp var api = RestService.For("https://api.github.com"); @@ -256,8 +135,8 @@ services .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: +Generated clients use the same `RefitSettings` you pass to `RestService.For` or `AddRefitClient`, and honor settings +such as: * `ContentSerializer` * `UrlParameterFormatter` @@ -270,10 +149,29 @@ clients continue to honor settings such as: * `HttpRequestMessageOptions` * `Version` and `VersionPolicy` +#### Automatic client registration + +On **.NET 5 and newer** the generator emits a [module initializer](https://learn.microsoft.com/dotnet/csharp/language-reference/proposals/csharp-9.0/module-initializers) +that registers every generated client factory at assembly load. `RestService.ForGenerated` and +`AddRefitGeneratedClient` then resolve the client with no runtime reflection. `RestService.For` skips the reflection +request builder for fully generated interfaces. That keeps trimmed and Native AOT apps reflection-free. + +**.NET Framework (net462–net481)** gets no automatic registration. `ModuleInitializerAttribute` arrived in .NET 5 and +isn't in the .NET Framework BCL, so Refit emits the initializer only for .NET 5+ targets. Raising a project's +`` to 9 doesn't change that — the missing type is the blocker, not the language version. (It does switch on +modern generated syntax like nullable annotations.) + +The generated inline code still runs. But `RestService.For` finds the client by runtime type lookup and builds the +reflection request builder, so you must reference the opt-in [`Refit.Reflection`](https://www.nuget.org/packages/Refit.Reflection) +package. .NET Framework has no trimming or AOT, so this costs nothing there. + +`ForGenerated` and `AddRefitGeneratedClient` need that registration. On .NET Framework they throw unless you register +the factory yourself with `RegisterGeneratedFactory` / `RegisterGeneratedSettingsFactory` at startup. + #### Generated-only client creation -For Native AoT or trimming-sensitive applications, `RestService.ForGenerated` creates a client only when Refit's -source generator registered an implementation for that interface: +For Native AoT or trimmed apps, `RestService.ForGenerated` creates a client only when the generator registered an +implementation for that interface: ```csharp using var client = new HttpClient @@ -284,35 +182,38 @@ using var client = new HttpClient var api = RestService.ForGenerated(client); ``` -`ForGenerated` does not fall back to the runtime reflection client. When the generated client can build all requests -directly, Refit also avoids creating the reflection request builder for that client. If the generated implementation is -not available at runtime, Refit throws an `InvalidOperationException` that points back to source generation setup. +`ForGenerated` never falls back to the reflection client. When the generated client builds every request directly, +Refit skips the reflection request builder too. If no generated implementation is registered, it throws an +`InvalidOperationException` pointing back to source generation setup. #### 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. +By default the generator builds requests directly. Instead of a method body that calls the reflective pipeline through +`BuildRestResultFuncForMethod`, the generated client creates the `HttpRequestMessage`, applies headers, properties, and +body, then dispatches it through Refit's 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: +This cuts runtime reflection, metadata lookup, argument boxing, and delegate construction. It covers most request shapes: * parameters that appear in the path template +* query parameters: auto-appended, `[AliasAs]`, `[Query]` (including `Format` and `CollectionFormat`), scalar + collections, `[QueryName]` flags and `[Encoded]` values +* implicit `[Body]` detection on POST/PUT/PATCH * static `[Headers]` * dynamic `[Header]` parameters * `[HeaderCollection]` dictionaries * `[Body]` content +* `[Multipart]` form uploads whose parts are `StreamPart`/`ByteArrayPart`/`FileInfoPart` (or a `MultipartItem` subclass), + `Stream`, `string`, `FileInfo`, `byte[]`, `HttpContent`, a date/time or `Guid` value, or an enumerable of these (an + object part serialized through the content serializer still falls back to the runtime builder) * `[Property]` parameters * `[Property]` interface properties * cancellation tokens * `Task`, `Task`, `Task>`, and related response wrappers * `IAsyncEnumerable` for streamed responses -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. +For a shape the generator can't emit yet, that method falls back to the runtime request builder. -You can explicitly turn generated request building off in a project file: +Turn generated request building off in a project file: ```xml @@ -320,10 +221,9 @@ You can explicitly turn generated request building off in a project file: ``` -That keeps the source-generated interface implementation but uses the legacy reflective request-building call path -inside generated methods. +That keeps the generated interface implementation but routes its methods through the reflective request builder. -If you need to disable Refit source generation entirely, set: +Disable source generation entirely: ```xml @@ -335,15 +235,15 @@ Most applications should leave both settings unset. #### Analyzer diagnostics and code fixes -Refit also ships analyzers with the main package. They report common interface issues at compile time, including: +The main package also ships analyzers. They flag common interface issues at compile time: * methods or properties on Refit interfaces that cannot be generated or called by Refit * route templates that use backslashes instead of forward slashes * methods with more than one `CancellationToken` parameter * `[HeaderCollection]` parameters that are not `IDictionary` -Where the fix is mechanical, Refit includes a code fix. Current fixes can replace route backslashes with forward -slashes and change invalid `[HeaderCollection]` parameter types to `IDictionary`. +Mechanical fixes ship as code fixes: replacing route backslashes with forward slashes, and changing an invalid +`[HeaderCollection]` parameter to `IDictionary`. ### API Attributes @@ -451,6 +351,10 @@ significant, exactly as with `HttpClient`: [Get("/values")] // -> https://api.example.com/values (leading slash replaces the base path) ``` +> **Note:** with generated request building (the default), a leading-slash-less route under the default legacy +> resolution is validated when the request is built — so the `ArgumentException` surfaces on the first call rather than +> from `RestService.For(...)`. Under `UrlResolutionMode.Rfc3986` the route is valid and no exception is raised. + ### Querystrings #### Dynamic Querystring Parameters @@ -559,6 +463,42 @@ Query("Select+Id,Name+From+Account") >>> "/query?q=Select+Id,Name+From+Account" ``` +For a single pre-encoded value, mark the parameter with `[Encoded]` (the equivalent of Retrofit's `encoded = true`) +and Refit passes it through verbatim while the rest of the request encodes normally. It applies to query values and +to path segments, including round-tripping `{**param}` segments — the caller becomes responsible for producing valid +encoded output: + +```csharp +[Get("/calendars/{calId}/events/{**eventId}")] +Task GetEvent(string calId, [Encoded] string eventId); + +GetEvent("work", "3bf0000488fda0ec154ee%40zoho.com") +>>> "/calendars/work/events/3bf0000488fda0ec154ee%40zoho.com" +``` + +`[Encoded]` (and `[QueryName]` below) are handled by generated request building only; using them on a method that +cannot generate inline is a compile-time error (RF007). + +#### Valueless query flags + +Some APIs use bare presence-style query switches with no value. Mark a parameter with `[QueryName]` (the equivalent +of Retrofit's `@QueryName`) and its value becomes the query segment itself; collections render one flag per element +and `null` values are omitted: + +```csharp +[Get("/items")] +Task> List([QueryName] string flag); + +List("archived") +>>> "/items?archived" + +[Get("/items")] +Task> List([QueryName] string[] flags); + +List(["a", "b", "c"]) +>>> "/items?a&b&c" +``` + #### Custom Querystring parameter formatting **Formatting Keys** @@ -1104,11 +1044,14 @@ public class Measurement ##### Reflection-free form serialization -When generated request building is enabled (the default), Refit's source generator emits the form-field flattening -for strongly-typed bodies at compile time, so no reflection is used at request time. This applies when the body is a -concrete class or struct serialized with the built-in `SystemTextJsonContentSerializer`. Bodies typed as `object`, -an `IDictionary`, a collection, or serialized with a custom `IHttpContentSerializer` fall back to the reflection-based -path, which remains fully supported and produces identical output. +With generated request building on (the default), Refit flattens strongly-typed form bodies at compile time, so no +reflection runs at request time. This covers a concrete class or struct serialized with the built-in +`SystemTextJsonContentSerializer`. An `object`, an `IDictionary`, a collection, or a custom `IHttpContentSerializer` +falls back to the reflection path, which produces identical output. + +A body whose properties are all simple scalars (strings, numbers, enums, other `IFormattable` values) takes a faster +straight-line path: the generator unrolls each field directly, with no descriptor array, getter delegates, or boxing. A +body with a collection or complex property keeps the descriptor path. All three paths produce identical output. ### Setting request headers @@ -1719,6 +1662,41 @@ await using var stream = response.Content; // released when the response is disp The rule is decided in `RestMethodInfoInternal.DetermineIfResponseMustBeDisposed`: those three types (and their `ApiResponse<>` wrappers) are the only ones Refit does not auto-dispose for you. +### Custom return types (`IReturnTypeAdapter`) + +Beyond the built-in shapes, you can teach Refit to surface **any** return type by implementing +`IReturnTypeAdapter`. `TReturn` is the type your interface method returns; `TResult` is what the HTTP +call materializes (the deserialized body). The single `Adapt` method receives a deferred call and returns the wrapper: + +```csharp +// A cold, deferred call that runs only when invoked. +public sealed class DeferredCall(Func> invoke) +{ + public Task InvokeAsync(CancellationToken ct = default) => invoke(ct); +} + +public sealed class DeferredCallAdapter : IReturnTypeAdapter, T> +{ + public DeferredCall Adapt(Func> invoke) => new(invoke); +} + +public interface IUserApi +{ + [Get("/users/{id}")] + DeferredCall GetUser(int id); // surfaced by the adapter above +} +``` + +* **Generated (Native AOT / trimming friendly):** the source generator discovers adapters declared in your project at + compile time and emits a direct `Adapt` call — no reflection or registration needed. +* **Reflection (`RestService.For` without the generator, or adapters from another assembly):** register the adapter type + on the settings — `settings.ReturnTypeAdapters.Add(typeof(DeferredCallAdapter<>))`. + +A generic adapter's single type parameter is treated as the wrapped result type (`Adapter : IReturnTypeAdapter, T>`), +so `Wrapper` closes it over `User`. Adapters must have a public parameterless constructor. The generated path builds +the request eagerly and captures it, so a generated deferred call is **single-use**; the reflection path rebuilds the +request on each invocation, so it can re-run. + ### Using generic interfaces When using something like ASP.NET Web API, it's a fairly common pattern to have a whole stack of CRUD REST services. @@ -1990,6 +1968,9 @@ Refit's recommended **source-generator-first** setup for Native AoT and trimmed 2. Use `RestService.ForGenerated(...)` when the application should require a source-generated client at runtime. Otherwise, prefer `RestService.For(...)` over reflection-heavy manual patterns around `Type` where possible. 3. Supply source-generated `System.Text.Json` metadata for your DTOs. +4. Do not reference the [`Refit.Reflection`](https://www.nuget.org/packages/Refit.Reflection) package. The reflection + request builder is opt-in, so leaving it out keeps that pipeline out of your application entirely. Build with + warnings as errors and the RF006 diagnostic will point at any method that still needs it. For the default `SystemTextJsonContentSerializer` on .NET 8+, Refit prefers `JsonTypeInfo` metadata from your configured `JsonSerializerOptions` when it is available. That means Native AoT apps can improve compatibility by supplying diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md new file mode 100644 index 000000000..eb5cc5fea --- /dev/null +++ b/docs/breaking-changes.md @@ -0,0 +1,273 @@ +# Breaking changes and release notes + +Every breaking change and notable addition, newest first. Each major version links back to the feature documentation +in the [main README](../README.md). + +* [V14.x.x](#v14xx) +* [V13.x.x](#v13xx) +* [V12.x.x](#v12xx) +* [V11.x.x](#v11xx) +* [Updates in 8.0.x](#updates-in-80x) +* [V6.x.x](#v6xx) + +## V14.x.x + +### Breaking changes in V14.x + +Refit 14 finishes the move to generated request building. Interfaces whose methods all generate inline no longer touch +the reflection request builder at any point, which is what makes them trim and Native AOT clean — and that shift is +visible in three places. + +* **Some hot-path async members now return `ValueTask` instead of `Task`** (allocation reductions for the breaking + release). `ApiResponse.EnsureSuccessStatusCodeAsync()`/`EnsureSuccessfulAsync()` (and the matching + `IApiResponse` extension methods) and `DefaultApiExceptionFactory.CreateAsync` now return `ValueTask<...>`, making + their common success path allocation-free. The `RefitSettings` async delegates `ExceptionFactory`, + `DeserializationExceptionFactory`, and `AuthorizationHeaderValueGetter` now use `ValueTask`-returning `Func` shapes, + removing a per-request `Task` allocation from the default (synchronously-completing) exception factory. `async` + lambdas assigned to these delegates need no change; a delegate that returned `Task.FromResult(x)` should return + `new ValueTask(x)`, and a caller that stored an `EnsureSuccess...Async()` result as a `Task` should `await` it or + call `.AsTask()`. `TransportExceptionFactory` is unaffected (it was already synchronous). +* **The reflection request builder is now an opt-in package.** The `Refit` package no longer carries it. If any of + your interface methods cannot generate inline — the RF006 diagnostic reports exactly which — add a reference to the + [`Refit.Reflection`](https://www.nuget.org/packages/Refit.Reflection) package and everything works as before, with + no code changes. Without it, `RestService.For` and `AddRefitClient` throw a `NotSupportedException` naming the + package when they reach a method that needs reflection. Applications whose interfaces all generate inline should not + install it: that is what keeps a client trimmable and Native AOT clean. +* **Interface validation for fully generated interfaces happens when a request is built, not when the client is + created.** `RestService.For` still validates reflection-backed interfaces eagerly, but an interface whose methods + all generate inline never touches the reflection request builder — an invalid route template (for example a + placeholder with no matching parameter) now throws the same `ArgumentException` on the first call instead of inside + `RestService.For`. +* **`ApiException` stack traces no longer contain the generated method frame** for methods that construct requests + inline (they return the runner task directly instead of awaiting inside an async wrapper). Use + `ApiException.HttpMethod`/`ApiException.Uri` to identify the failing request. +* **Query objects are flattened by their declared type, not their runtime type.** A complex parameter whose public + properties become query pairs is now flattened at compile time, the same way the `System.Text.Json` source generator + treats a declared type. The reflection request builder called `value.GetType()`, so passing a *derived* instance + through a base-typed parameter also contributed the derived type's extra properties. Generated request building emits + only the properties declared on the parameter's type: + + ```csharp + public record BaseRecord(string Value); + public sealed record DerivedRecord(string Name) : BaseRecord("value"); + + [Get("/search")] + Task Search(BaseRecord query); + + Search(new DerivedRecord("ada")) + >>> "/search?Value=value" // V14, generated: declared type only + >>> "/search?Name=ada&Value=value" // V13, reflection: runtime type + ``` + + Only polymorphic query objects are affected; a parameter whose declared type is the type you actually pass is + unchanged. If you rely on the old behavior, declare the parameter as the derived type. + +* **Flattened query-object keys honor the content serializer's property names.** A query object's property keys now + resolve with the same precedence form-encoded field names already used: `[AliasAs]` first, then the configured + content serializer's field name (`[JsonPropertyName]` for the default `System.Text.Json` serializer, `[JsonProperty]` + for `Refit.Newtonsoft.Json`), then the URL parameter key formatter. Previously query keys consulted only `[AliasAs]` + and the key formatter, so a `[JsonPropertyName]` on a flattened property had no effect on the query string. + + ```csharp + public sealed class Filter + { + [JsonPropertyName("created_after")] + public DateTimeOffset? CreatedAfter { get; set; } + } + + [Get("/search")] + Task Search([Query] Filter filter); + + Search(new Filter { CreatedAfter = ... }) + >>> "/search?created_after=..." // V14: serializer field name + >>> "/search?CreatedAfter=..." // V13: CLR name + ``` + + Both request builders apply this. The generated path bakes the `[JsonPropertyName]` name at compile time (matching + the generated form path); the reflection path resolves it through the configured serializer, so it also picks up + `[JsonProperty]` when `Refit.Newtonsoft.Json` is installed. To keep the pre-V14 behavior, set + `RefitSettings.HonorContentSerializerPropertyNamesInQuery = false`; `[AliasAs]` still takes precedence in either mode. + +### New in V14.x + +* **Inline query-string generation.** Query parameters — auto-appended parameters, `[AliasAs]`, `[Query(Format = ...)]`, + and scalar collections with every `CollectionFormat` — now generate reflection-free request construction, so the most + common Refit method shapes work with generated-only clients (`AddRefitGeneratedClient`, `RestService.ForGenerated`) + and under Native AOT. Implicit body detection (a complex parameter on POST/PUT/PATCH without `[Body]`) generates + inline too, and value formatting for statically-known types (numbers, dates, GUIDs, enums with `[EnumMember]`) is + resolved at compile time while still honoring a custom `IUrlParameterFormatter` when one is configured. +* **Reflection-free query-object flattening.** A complex query parameter's public readable properties are now walked at + compile time and emitted as straight-line code — no reflection, no delegates, and no per-request descriptor array — + honoring `[AliasAs]`, the content serializer's property name (`[JsonPropertyName]`), `[Query]` + prefix/delimiter/format/`SerializeNull`, and the `IgnoreDataMember`/`JsonIgnore` attributes. Value-typed properties + are formatted without boxing. A property that is itself a collection of simple elements (for example `int[]`, + `List`, `IReadOnlyList`) flattens too, honoring its `CollectionFormat`; a customized + `IUrlParameterFormatter` still gets the reflection builder's two formatting passes. A property that is itself a + concrete nested object flattens recursively under a dotted key (`Address.City=...`), composing each level through the + key formatter and honoring per-level `[Query(Prefix)]`; a self-referential (cyclic) type keeps using the reflection + builder. See the declared-type and property-name notes above. +* **Reflection-free dictionary query parameters.** `IDictionary` and `Dictionary` with + simple keys and values expand inline, one query pair per entry, with the same semantics as before: entries with a + null value are omitted, blank keys drop the pair, enum keys render through `[EnumMember]`, and a parameter-level + `[Query(Prefix)]` is prepended to every key. Dictionaries with `object` values still use the reflection request + builder, because the value's runtime type decides whether it is recursed into — unless you attach an + `[QueryConverter]` (below). +* **`[QueryConverter]` for shapes only known at runtime.** Attach `[QueryConverter(typeof(MyConverter))]` to a query + parameter whose shape the generator cannot flatten from the declared type — an `object` value, a polymorphic base + type, a `Dictionary`. The converter (`IQueryConverter`) writes query pairs directly into the + pooled `GeneratedQueryStringBuilder`, so the parameter generates inline and stays reflection- and allocation-free. + It is a source-generation-only feature (like `[QueryName]`/`[Encoded]`): a method that carries it but cannot generate + inline for another reason reports `RF007`. The reflection request builder is unaffected; it keeps walking the runtime + type. `Refit` ships `SystemTextJsonQueryConverter`, which flattens any type registered in your configured + `System.Text.Json` context by walking its `JsonTypeInfo`. +* **`[QueryName]` valueless query flags.** The equivalent of Retrofit's `@QueryName`: the parameter's value becomes a + bare `?flag` segment with no `=value`; collections render one flag per element. See + [Valueless query flags](../README.md#valueless-query-flags). +* **`[Encoded]` per-parameter encoding opt-out.** The equivalent of Retrofit's `encoded = true`: a query or path value + (including round-tripping `{**param}` segments) passes through verbatim so pre-encoded values are not double-encoded. + See [Unescape Querystring parameters](../README.md#unescape-querystring-parameters). +* **RF006 fallback analyzer.** Methods that must use the reflection request builder are reported at compile time, so + generated-only and Native AOT clients fail the build instead of throwing at runtime. RF007 reports the use of + source-generation-only attributes (`[QueryName]`, `[Encoded]`) on methods that cannot generate inline. +* **Generated fallback methods no longer leak IL2026/IL3050.** Projects with `EnableTrimAnalyzer` and warnings as + errors build cleanly; the compile-time RF006 diagnostic is the signal that a method still needs reflection. +* **Hot-path allocation reductions.** The response pipeline avoids a per-stream linked `CancellationTokenSource` when a + token can't cancel, uses `HttpHeaders.NonValidated` for generated header checks (no value re-parsing), and returns + `ValueTask` from the internal deserialization chain so empty/`204` responses complete without a `Task` allocation — + alongside the public `ValueTask` moves noted in the breaking changes above. +* **Generic interface methods generate inline.** A generic Refit method (for example `Task Get(string id)` or + `Task Post([Body] TRequest body)`) no longer falls back to the reflection request + builder — the type parameter flows straight through to the generated runner (`SendAsync`), so generic + methods are reflection-free and Native AOT clean, with their generic constraints preserved. The narrow exceptions that + still require the reflection builder are the ones an *open* type parameter genuinely can't generate: a complex query + object (its query pairs are only known per value) and a form-url-encoded `[Body]` (its `[DynamicallyAccessedMembers]` + contract can't be satisfied by an open type parameter). RF006 continues to report exactly which methods still fall + back. +* **Pluggable return-type adapters (`IReturnTypeAdapter`).** Surface any custom return type (for + example `IObservable` or a `Result` wrapper) from an interface method by implementing the interface. The source + generator discovers adapters declared in your project at compile time and emits a direct `Adapt` call — no reflection, + so adapter-backed methods stay trim and Native AOT clean; the reflection request builder resolves adapters registered + in `RefitSettings.ReturnTypeAdapters`. See [Custom return types](../README.md#custom-return-types-ireturntypeadapter). + +## V13.x.x + +### Breaking changes in V13.x + +Refit 13 hardens the default security posture from a security audit. The new behavior is safe by default; the changes +below only affect code that previously relied on the less-secure defaults. + +* **XML responses no longer process DTDs.** `XmlContentSerializer` now forces `DtdProcessing.Prohibit` and clears the + `XmlResolver` on every read, blocking XML External Entity (XXE) and entity-expansion ("billion laughs") attacks. If + you were deserializing trusted XML that depends on a DTD or external entities, that content will now throw. We + **strongly recommend against re-enabling DTD processing**, but if your XML comes from a fully trusted source you can + opt out via the obsolete `XmlReaderWriterSettings.AllowDtdProcessing` flag (marked `[Obsolete]` deliberately, so it + surfaces a compiler warning) - you must also configure `DtdProcessing`/`XmlResolver` yourself on `ReaderSettings`. + Prefer pre-processing untrusted documents instead. +* **Newtonsoft.Json no longer inherits an unsafe global `TypeNameHandling`.** When you do not pass explicit + `JsonSerializerSettings`, `NewtonsoftJsonContentSerializer` now forces `TypeNameHandling.None` even if + `JsonConvert.DefaultSettings` configured a different value, closing a known remote-code-execution gadget vector on + response bodies. If you genuinely need polymorphic (`$type`) deserialization, opt in explicitly by passing your own + `JsonSerializerSettings` (ideally constrained with a `SerializationBinder`). + +The release also adds two opt-in, non-breaking knobs on `RefitSettings` for hardening exception handling: + +* `RefitSettings.ExceptionRedactor` — a hook invoked before an `ApiException` propagates, so you can scrub the + `Authorization` header, request/response bodies, and `Set-Cookie` before they reach logging or telemetry pipelines + that serialize exceptions. +* `RefitSettings.MaxExceptionContentLength` — caps how many characters of an error response body are read into + `ApiException.Content`, bounding memory use against hostile or oversized error responses. Defaults to unbounded. + +### New in V13.x + +* **`Refit.Testing`** — a new first-party package for testing Refit clients without a mocking library or a live + server. You describe expected calls as a route table (`Route` → `Reply`) and point a real client at it via + `StubHttp.CreateClient(...)`. Route templates mirror your interface attributes, typed replies (`Reply.With`) + are serialized with the client's own serializer, and the sent request body can be read back as a typed object with + `LastRequestBodyAsync()`. It also includes `NetworkBehavior` for seeded latency/fault injection and + `StubApiResponse` for unit-testing code that consumes `IApiResponse`. See + [Testing your Refit clients](../README.md#testing-your-refit-clients). +## V12.x.x + +### Breaking changes in V12.x + +Refit 12.0 is a large release centered on a near-complete rewrite of request building. The source generator now builds +eligible HTTP requests inline at compile time instead of going through the reflection request-builder pipeline, with the +reflection path kept as a fallback for shapes that cannot be generated inline. + +Two breaking changes are called out for migration: + +* `IApiResponse` no longer shadows base interface members. The `new`-shadowed `Error`, `ContentHeaders`, + `IsSuccessStatusCode`, and `IsSuccessful` members were removed from the generic interface. Source that reads these + members still binds to the inherited base members, but assemblies compiled against v8-v11 should be recompiled. + If you used `IsSuccessful` to narrow `Content` to non-null on an `IApiResponse` value, use `HasContent` or + `IsSuccessfulWithContent` instead. +* The default `System.Text.Json` serializer now reads numbers from JSON strings by setting + `JsonNumberHandling.AllowReadingFromString`. To opt back out, set `NumberHandling = JsonNumberHandling.Strict` on + your `JsonSerializerOptions`. + +See the [Refit 12.0.0 release notes](https://github.com/reactiveui/refit/releases/tag/v12.0.0) for the full release +details. + +## V11.x.x + +### Breaking changes in 11.x + +Refit 11 introduces `ApiRequestException` to represent requests that fail before receiving a response from the server. +This exception will now wrap previous exceptions such as `HttpRequestException` and `TaskCanceledException` when they +occur during request execution. + +* If you were not wrapping responses with `IApiResponse` and were catching these exceptions directly, you will need to + update your code to catch `ApiRequestException` instead. +* If you were wrapping responses with `IApiResponse`, these exceptions will no longer be thrown and will instead be + captured in the `IApiResponse.Error` property. + You can use the new `IApiResponse.HasRequestError(out var apiRequestException)` method to safely check and retrieve + the `ApiRequestException` instance. + +The `IApiResponse.Error` property's type has also changed to `ApiExceptionBase`, which is the new base class for +`ApiException` and `ApiRequestException`. +If your code accessed members specific to `ApiException` (i.e. anything related to the response from the server), you +can use the new `IApiResponse.HasResponseError(out var apiException)` method to safely check and retrieve the +`ApiException` instance. + +All response-related properties of `IApiResponse` are now nullable. +The new `IApiResponse.IsReceived` property can be used to check if a response was received from the server, and will +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. + +## Updates in 8.0.x + +Fixes for some issues experienced, this led to some breaking changes. +See [Releases](https://github.com/reactiveui/refit/releases) for full details. + +## V6.x.x + +Refit 6 requires Visual Studio 16.8 or higher, or the .NET SDK 5.0.100 or higher. It can target any .NET Standard 2.0 +platform. + +Refit 6 does not support the old `packages.config` format for NuGet references (as they do not support analyzers/source +generators). You must +[migrate to PackageReference](https://devblogs.microsoft.com/nuget/migrate-packages-config-to-package-reference/) to use +Refit v6 and later. + +### Breaking changes in 6.x + +Refit 6 +makes [System.Text.Json](https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-overview) the +default JSON serializer. If you'd like to continue to use `Newtonsoft.Json`, add the `Refit.Newtonsoft.Json` NuGet +package and set your `ContentSerializer` to `NewtonsoftJsonContentSerializer` on your `RefitSettings` instance. +`System.Text.Json` is faster and uses less memory, though not all features are supported. +The [migration guide](https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-migrate-from-newtonsoft-how-to?pivots=dotnet-5-0) +contains more details. + +`IContentSerializer` was renamed to `IHttpContentSerializer` to better reflect its purpose. Additionally, two of its +methods were renamed, `SerializeAsync` -> `ToHttpContent` and `DeserializeAsync` -> `FromHttpContentAsync`. +Any existing implementations of these will need to be updated, though the changes should be minor. + +##### Updates in 6.3 + +Refit 6.3 splits out the XML serialization via `XmlContentSerializer` into a separate package, `Refit.Xml`. This +is to reduce the dependency size when using Refit with Web Assembly (WASM) applications. If you require XML, add a +reference +to `Refit.Xml`. + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 71e0e048f..a0d3b97bf 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -27,8 +27,7 @@ true latest true - true - true + false true enable @@ -158,7 +157,7 @@ minor alpha.0 v - 10.2 + 14.0 diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 0ba8b41ff..da49508fe 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -6,10 +6,10 @@ 6.0.0 - 1.58.0 + 1.59.0 - 3.17.7 + 3.25.2 @@ -47,7 +47,7 @@ - + @@ -69,7 +69,7 @@ - + @@ -83,7 +83,7 @@ - + diff --git a/src/InterfaceStubGenerator.Shared/DiagnosticDescriptors.cs b/src/InterfaceStubGenerator.Shared/DiagnosticDescriptors.cs index 6595682a5..b301d732c 100644 --- a/src/InterfaceStubGenerator.Shared/DiagnosticDescriptors.cs +++ b/src/InterfaceStubGenerator.Shared/DiagnosticDescriptors.cs @@ -19,6 +19,18 @@ internal static class DiagnosticDescriptors DiagnosticSeverity.Error, true); + /// Diagnostic reported when a source-generation-only attribute is used on a method that cannot generate inline. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "RS2008", Justification = "Diagnostic IDs are stable and intentionally not tracked in an analyzer release-tracking file.")] + public static readonly DiagnosticDescriptor SourceGenOnlyAttributeRequiresInlineRequest = + new( + "RF007", + "Attribute requires generated request building", + "Method '{0}' uses [{1}], which is only honored by generated request building, but its request cannot be " + + "generated inline. Make the method compatible with inline generation or remove the attribute.", + Category, + DiagnosticSeverity.Error, + true); + /// The diagnostic category for Refit generator diagnostics. private const string Category = "Refit"; } diff --git a/src/InterfaceStubGenerator.Shared/Emitter.Buffers.cs b/src/InterfaceStubGenerator.Shared/Emitter.Buffers.cs new file mode 100644 index 000000000..2243a7c1f --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Emitter.Buffers.cs @@ -0,0 +1,271 @@ +// 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; + +/// Low-level primitives that assemble generated source into pre-sized character buffers. +internal static partial class Emitter +{ + /// The cached indentation strings for the levels the emitter uses in its hot paths. + private static readonly string[] IndentCache = BuildIndentCache(); + +#if NETSTANDARD2_0 + /// Delegate used to fill a generated string buffer. + /// The state type. + /// The target character buffer. + /// The caller supplied state. + private delegate void GeneratedStringAction(char[] destination, TState state); +#else + /// Delegate used to fill a generated string buffer. + /// The state type. + /// The target character buffer. + /// The caller supplied state. + private delegate void GeneratedStringAction(Span destination, TState state); +#endif + + /// Concatenates populated source fragments without allocating a trimmed array. + /// The source fragments. + /// The populated fragment count. + /// The concatenated source. + private static string ConcatParts(string[] parts, int count) + { + var length = 0; + for (var i = 0; i < count; i++) + { + length += parts[i].Length; + } + + return CreateGeneratedString( + length, + (Parts: parts, Count: count), + static (destination, state) => + { + var position = 0; + for (var i = 0; i < state.Count; i++) + { + AppendText(destination, state.Parts[i], ref position); + } + }); + } + + /// Joins populated source fragments without allocating a trimmed array. + /// The source fragments. + /// The populated fragment count. + /// The separator text. + /// The joined source. + private static string JoinParts(string[] parts, int count, string separator) + { + if (count == 0) + { + return string.Empty; + } + + var length = separator.Length * (count - 1); + for (var i = 0; i < count; i++) + { + length += parts[i].Length; + } + + return CreateGeneratedString( + length, + (Parts: parts, Count: count, Separator: separator), + static (destination, state) => + { + var position = 0; + for (var i = 0; i < state.Count; i++) + { + if (i > 0) + { + AppendText(destination, state.Separator, ref position); + } + + AppendText(destination, state.Parts[i], ref position); + } + }); + } + + /// Builds a generated indentation string. + /// The indentation level. + /// The generated indentation. + /// Indentation levels are compile-time constants, so the common levels are cached once and shared + /// instead of allocating an identical fresh string at every per-method and per-parameter call site. Levels beyond + /// the cache allocate a fresh string, a fallback reached only by nesting deeper than the shared test fixtures. + [ExcludeFromCodeCoverage] + private static string Indent(int level) => + (uint)level < (uint)IndentCache.Length + ? IndentCache[level] + : new string(' ', level * CharsPerIndentation); + + /// Precomputes the shared indentation strings for levels 0 through . + /// The cached indentation strings, indexed by level. + private static string[] BuildIndentCache() + { + var cache = new string[MaxCachedIndentLevel + 1]; + for (var level = 0; level <= MaxCachedIndentLevel; level++) + { + cache[level] = new(' ', level * CharsPerIndentation); + } + + return cache; + } + +#if NETSTANDARD2_0 + /// Writes a C# string literal or the null keyword into a generated string buffer. + /// The target character buffer. + /// The value to quote, or . + /// The current write position. + private static void AppendLiteralOrNull(char[] destination, string? value, ref int position) + { + if (value is null) + { + AppendText(destination, NullLiteral, ref position); + return; + } + + destination[position] = '"'; + position++; + foreach (var character in value) + { + var escape = EscapeSequence(character); + if (escape is null) + { + destination[position] = character; + position++; + } + else + { + AppendText(destination, escape, ref position); + } + } + + destination[position] = '"'; + position++; + } + + /// Writes the decimal rendering of a non-negative 32-bit integer into a generated string buffer. + /// The target character buffer. + /// The non-negative value to render (callers only pass CollectionFormat values). + /// The current write position. + private static void AppendInt32(char[] destination, int value, ref int position) + { + var end = position + Int32Length(value); + var write = end; + do + { + --write; + destination[write] = (char)('0' + (value % DecimalRadix)); + value /= DecimalRadix; + } + while (value > 0); + + position = end; + } +#else + /// Writes a C# string literal or the null keyword into a generated string buffer. + /// The target character span. + /// The value to quote, or . + /// The current write position. + private static void AppendLiteralOrNull(Span destination, string? value, ref int position) + { + if (value is null) + { + AppendText(destination, NullLiteral, ref position); + return; + } + + destination[position] = '"'; + position++; + foreach (var character in value) + { + var escape = EscapeSequence(character); + if (escape is null) + { + destination[position] = character; + position++; + } + else + { + AppendText(destination, escape, ref position); + } + } + + destination[position] = '"'; + position++; + } + + /// Writes the decimal rendering of a non-negative 32-bit integer into a generated string buffer. + /// The target character span. + /// The non-negative value to render (callers only pass CollectionFormat values). + /// The current write position. + private static void AppendInt32(Span destination, int value, ref int position) + { + var end = position + Int32Length(value); + var write = end; + do + { + --write; + destination[write] = (char)('0' + (value % DecimalRadix)); + value /= DecimalRadix; + } + while (value > 0); + + position = end; + } +#endif + +#if NETSTANDARD2_0 + /// Creates a generated string using a pre-sized buffer. + /// The state type. + /// The string length. + /// The caller supplied state. + /// The buffer fill callback. + /// The generated string. + private static string CreateGeneratedString( + int length, + TState state, + GeneratedStringAction action) + { + var destination = new char[length]; + action(destination, state); + return new(destination); + } + + /// Appends text into a generated string buffer. + /// The target character buffer. + /// The text to append. + /// The current write position. + private static void AppendText(char[] destination, string text, ref int position) + { + text.CopyTo(0, destination, position, text.Length); + position += text.Length; + } +#else + /// Creates a generated string using . + /// The state type. + /// The string length. + /// The caller supplied state. + /// The buffer fill callback. + /// The generated string. + private static string CreateGeneratedString( + int length, + TState state, + GeneratedStringAction action) => + string.Create( + length, + (State: state, Action: action), + static (destination, context) => context.Action(destination, context.State)); + + /// Appends text into a generated string buffer. + /// The target character buffer. + /// The text to append. + /// The current write position. + private static void AppendText(Span destination, string text, ref int position) + { + text.AsSpan().CopyTo(destination[position..]); + position += text.Length; + } +#endif +} diff --git a/src/InterfaceStubGenerator.Shared/Emitter.Constraints.cs b/src/InterfaceStubGenerator.Shared/Emitter.Constraints.cs index c9225c582..577d03427 100644 --- a/src/InterfaceStubGenerator.Shared/Emitter.Constraints.cs +++ b/src/InterfaceStubGenerator.Shared/Emitter.Constraints.cs @@ -7,6 +7,12 @@ namespace Refit.Generator; /// Emits generic type-parameter constraint clauses for generated Refit method implementations. internal static partial class Emitter { + /// + /// The number of keyword constraints (class, unmanaged, struct, notnull, new()) + /// that can be emitted alongside a type parameter's declared type constraints. + /// + private const int KeywordConstraintCount = 5; + /// Builds the generic type constraint clauses for the given type parameters. /// The type parameter constraints to emit. /// True if emitting for an override or explicit implementation. @@ -17,6 +23,12 @@ private static string BuildConstraints( bool isOverrideOrExplicitImplementation, int indentationLevel) { + // The overwhelmingly common case is a non-generic method: skip the array allocation entirely. + if (typeParameters.Count == 0) + { + return string.Empty; + } + var parts = new string[typeParameters.Count]; var count = 0; for (var i = 0; i < typeParameters.Count; i++) @@ -27,7 +39,8 @@ private static string BuildConstraints( indentationLevel); if (source.Length != 0) { - parts[count++] = source; + parts[count] = source; + count++; } } @@ -77,7 +90,7 @@ private static string BuildConstraintList( in TypeConstraint typeParameter, bool isOverrideOrExplicitImplementation) { - var parts = new string[typeParameter.Constraints.Count + 5]; + var parts = new string[typeParameter.Constraints.Count + KeywordConstraintCount]; var count = 0; var knownConstraints = typeParameter.KnownTypeConstraint; AddConstraint(parts, "class", (knownConstraints & KnownTypeConstraint.Class) != 0, ref count); @@ -125,6 +138,7 @@ private static void AddConstraint( return; } - parts[count++] = keyword; + parts[count] = keyword; + count++; } } diff --git a/src/InterfaceStubGenerator.Shared/Emitter.Helpers.cs b/src/InterfaceStubGenerator.Shared/Emitter.Helpers.cs index 82fa8c659..a984a03c8 100644 --- a/src/InterfaceStubGenerator.Shared/Emitter.Helpers.cs +++ b/src/InterfaceStubGenerator.Shared/Emitter.Helpers.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for full license information. using System.Diagnostics.CodeAnalysis; -using System.Text; namespace Refit.Generator; @@ -16,6 +15,9 @@ internal static partial class Emitter /// The text length added to a non-nullable generated parameter around its type and name. private const int ParameterExtraLength = 2; + /// The statement prefix that returns a generated expression to the caller. + private const string ReturnStatementPrefix = "return "; + /// Builds the request-body buffering expression for an inline generated method. /// The parsed body parameter, if any. /// The generated settings local name. @@ -60,18 +62,21 @@ internal static string BuildPropertyAccessExpression(InterfacePropertyModel prop : $"this.{property.Name}"; } - /// Ensures a type display name is prefixed with global::. + /// Ensures a type display name carries an alias qualifier, adding global:: when it has none. /// The type display name. - /// The globally qualified type display name. + /// The alias-qualified type display name. + /// A name that already carries an alias qualifier (either global:: or an extern alias) contains + /// the :: separator, whereas a bare name never does, so only bare names receive the global:: prefix. internal static string EnsureGlobalPrefix(string typeName) => - typeName.StartsWith(GlobalPrefix, StringComparison.Ordinal) + typeName.IndexOf("::", StringComparison.Ordinal) >= 0 ? 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] + /// Known verbs reuse the cached singletons; a custom verb (read from a derived + /// attribute's new HttpMethod("VERB") getter) constructs one, matching what the reflection builder does. internal static string ToHttpMethodExpression(string httpMethod) => httpMethod switch { @@ -81,8 +86,7 @@ internal static string ToHttpMethodExpression(string httpMethod) => "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.") + _ => $"new global::System.Net.Http.HttpMethod({ToCSharpStringLiteral(httpMethod)})" }; /// Gets the invocation text used for a generated method return type. @@ -95,8 +99,9 @@ internal static (bool IsAsync, string ReturnPrefix, string ConfigureAwaitSuffix) { ReturnTypeInfo.AsyncVoid => (true, "await (", ").ConfigureAwait(false)"), ReturnTypeInfo.AsyncResult => (true, "return await (", ").ConfigureAwait(false)"), - ReturnTypeInfo.AsyncEnumerable => (false, "return ", string.Empty), - ReturnTypeInfo.Return => (false, "return ", string.Empty), + ReturnTypeInfo.AsyncEnumerable => (false, ReturnStatementPrefix, string.Empty), + ReturnTypeInfo.Observable => (false, ReturnStatementPrefix, string.Empty), + ReturnTypeInfo.Return => (false, ReturnStatementPrefix, string.Empty), ReturnTypeInfo.SyncVoid => (false, string.Empty, string.Empty), _ => throw new ArgumentOutOfRangeException( nameof(returnTypeInfo), @@ -114,10 +119,10 @@ internal static string ToNullableCSharpStringLiteral(string? value) => /// The target builder. /// The character to append. [SuppressMessage( - "CodeQuality", - "S1541:Methods and properties should not be too complex", + "Maintainability", + "SST1442:A function has too many direct branch points", Justification = "A compact switch avoids a dictionary or repeated helper calls on the generator hot path.")] - internal static void AppendEscapedCharacter(StringBuilder builder, char character) => + internal static void AppendEscapedCharacter(PooledStringBuilder builder, char character) => _ = character switch { '\\' => builder.Append(@"\\"), @@ -214,13 +219,15 @@ internal static string StripExplicitInterfacePrefix(string name) /// True if the method is an explicit interface implementation. /// Whether the consumer compilation supports nullable reference type syntax. /// True if the method should be emitted as async. + /// Attribute lines emitted between the documentation and the signature. /// The generated method opening. internal static string BuildMethodOpening( MethodModel methodModel, bool isDerivedExplicitImpl, bool isExplicitInterface, bool supportsNullable, - bool isAsync = false) + bool isAsync = false, + string methodAttributes = "") { var visibility = !isExplicitInterface ? "public " : string.Empty; var asyncKeyword = isAsync ? "async " : string.Empty; @@ -232,7 +239,7 @@ internal static string BuildMethodOpening( return $$""" {{methodIndent}}/// - {{methodIndent}}{{visibility}}{{asyncKeyword}}{{methodModel.ReturnType}} {{explicitInterface}}{{methodModel.DeclaredMethod}}({{parameters}}) + {{methodAttributes}}{{methodIndent}}{{visibility}}{{asyncKeyword}}{{methodModel.ReturnType}} {{explicitInterface}}{{methodModel.DeclaredMethod}}({{parameters}}) """ + constraints @@ -240,6 +247,49 @@ internal static string BuildMethodOpening( + "{\n"; } + /// Builds the trim/AOT annotations emitted onto methods that use the reflection request builder. + /// Whether the interface method declares [RequiresUnreferencedCode]. + /// Whether the interface method declares [RequiresDynamicCode]. + /// + /// The reflection fallback intentionally trades trim safety for coverage of method shapes the inline emitter does + /// not support; RF006/RF007 report those shapes at compile time. When the interface member is unannotated the + /// generated call site suppresses the per-interface IL2026/IL3050 noise consumers cannot act on + /// (see reactiveui/refit#2200). When the interface member declares the matching [RequiresUnreferencedCode] or + /// [RequiresDynamicCode], the implementation mirrors it instead: that both honours the caller-visible contract + /// and satisfies the trim/AOT annotation-matching rule (IL2046/IL3051) between the interface and its implementation. + /// + /// The generated attribute lines, terminated by a newline. + internal static string BuildReflectionFallbackSuppressions( + bool requiresUnreferencedCode, + bool requiresDynamicCode) + { + const string justification = + "\"This method's shape is not supported by generated request building and intentionally uses the " + + "reflection request builder; trimmed and Native AOT applications must use inline-eligible method " + + "shapes instead (Refit reports this at compile time).\""; + const string requiresMessage = + "\"This generated Refit method's shape is not supported by generated request building and uses the " + + "reflection request builder, which requires unreferenced code and runtime code generation.\""; + var methodIndent = Indent(MethodMemberIndentation); + + // A [RequiresDynamicCode] attribute only exists on net7.0+, but it is only emitted when the interface member + // declares it, which itself can only compile on net7.0+ — so it is always inside the net5.0 guard safely. + var unreferencedCodeAttribute = requiresUnreferencedCode + ? $"[global::System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode({requiresMessage})]" + : $"[global::System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(\"Trimming\", \"IL2026\", Justification = {justification})]"; + var dynamicCodeAttribute = requiresDynamicCode + ? $"[global::System.Diagnostics.CodeAnalysis.RequiresDynamicCode({requiresMessage})]" + : $"[global::System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(\"AOT\", \"IL3050\", Justification = {justification})]"; + + return $$""" + {{methodIndent}}#if NET5_0_OR_GREATER + {{methodIndent}}{{unreferencedCodeAttribute}} + {{methodIndent}}{{dynamicCodeAttribute}} + {{methodIndent}}#endif + + """; + } + /// Builds the explicit interface qualifier for a method signature. /// The method model being emitted. /// Whether the method is emitted explicitly. @@ -271,7 +321,7 @@ private static string BuildParameterList( return string.Empty; } - var length = (parameterModels.Length - 1) * 2; + var length = (parameterModels.Length - 1) * ListSeparatorLength; for (var i = 0; i < parameterModels.Length; i++) { var (metadataName, type, annotation, _) = parameterModels[i]; @@ -298,7 +348,8 @@ private static string BuildParameterList( AppendText(destination, type, ref position); if (emitNullableAnnotations && annotation) { - destination[position++] = '?'; + destination[position] = '?'; + position++; } AppendText(destination, " @", ref position); diff --git a/src/InterfaceStubGenerator.Shared/Emitter.Inline.Content.cs b/src/InterfaceStubGenerator.Shared/Emitter.Inline.Content.cs new file mode 100644 index 000000000..d1b0e9ad7 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Emitter.Inline.Content.cs @@ -0,0 +1,252 @@ +// 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; + +/// Emits the inline request content assignment, including the form-body unroll fast path. +internal static partial class Emitter +{ + /// Builds request content assignment for an inline generated method. + /// The body parameter model. + /// The generated request message local name. + /// The generated settings local name. + /// The cached form field descriptor array name, or null to use the reflection path. + /// Whether the consumer compilation supports nullable reference type syntax. + /// The shared emission locals and helper state. + /// The method-scope unique local name builder. + /// The generated content assignment. + private static string BuildInlineContent( + RequestParameterModel bodyParameter, + string requestLocal, + string settingsLocal, + string? formFieldsFieldName, + bool supportsNullable, + in InlineValueEmission emission, + UniqueNameBuilder locals) + { + var bodyIndent = Indent(MethodBodyIndentation); + if (bodyParameter.BodySerializationMethod == "UrlEncoded") + { + if (IsUnrollableFormBody(bodyParameter)) + { + return BuildInlineFormUnroll(bodyParameter, requestLocal, supportsNullable, emission, locals); + } + + return formFieldsFieldName is not null + ? $$""" + {{bodyIndent}}{{requestLocal}}.Content = global::Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent<{{bodyParameter.Type}}>( + {{bodyIndent}} {{settingsLocal}}, + {{bodyIndent}} @{{bodyParameter.Name}}, + {{bodyIndent}} {{formFieldsFieldName}}); + + """ + : $$""" + {{bodyIndent}}{{requestLocal}}.Content = global::Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent<{{bodyParameter.Type}}>( + {{bodyIndent}} {{settingsLocal}}, + {{bodyIndent}} @{{bodyParameter.Name}}); + + """; + } + + if (bodyParameter.BodySerializationMethod == "JsonLines") + { + return $$""" + {{bodyIndent}}{{requestLocal}}.Content = global::Refit.GeneratedRequestRunner.CreateJsonLinesBodyContent<{{bodyParameter.Type}}>( + {{bodyIndent}} {{settingsLocal}}, + {{bodyIndent}} @{{bodyParameter.Name}}); + + """; + } + + var streamBodyExpression = BuildStreamBodyExpression(bodyParameter, settingsLocal); + var serializationMethodExpression = BuildBodySerializationMethodExpression(bodyParameter); + + return $$""" + {{bodyIndent}}{{requestLocal}}.Content = global::Refit.GeneratedRequestRunner.CreateBodyContent<{{bodyParameter.Type}}>( + {{bodyIndent}} {{settingsLocal}}, + {{bodyIndent}} @{{bodyParameter.Name}}, + {{bodyIndent}} {{serializationMethodExpression}}, + {{bodyIndent}} {{streamBodyExpression}}); + + """; + } + + /// Emits straight-line form-url-encoded body serialization for an all-scalar body, mirroring the descriptor + /// path's wire output without the descriptor array, getter delegates, or value boxing on the fast path. + /// The URL-encoded body parameter model. + /// The generated request message local name. + /// Whether the consumer compilation supports nullable reference type syntax. + /// The shared emission locals and helper state. + /// The method-scope unique local name builder. + /// The generated content assignment. + private static string BuildInlineFormUnroll( + RequestParameterModel bodyParameter, + string requestLocal, + bool supportsNullable, + in InlineValueEmission emission, + UniqueNameBuilder locals) + { + var settingsLocal = emission.SettingsLocal; + var bodyIndent = Indent(MethodBodyIndentation); + var inner = bodyIndent + " "; + var fields = bodyParameter.FormFields!.AsArray(); + var bodyExpr = "@" + bodyParameter.Name; + var entriesLocal = locals.New("______formEntries"); + + // Nullable reference annotations are a C# 8 feature; older consumers get the unannotated types, which also match + // the .NET Framework/netstandard FormUrlEncodedContent constructor signature. The generated code stays + // compilable down to the C# 7.3 floor (explicit KeyValuePair construction, != null guards - no C# 9 syntax). + var nullable = supportsNullable ? "?" : string.Empty; + var kvpType = "global::System.Collections.Generic.KeyValuePair"; + var site = new FormUnrollSite(bodyExpr, entriesLocal, inner, "new " + kvpType, locals); + + var adds = new PooledStringBuilder(); + foreach (var field in fields) + { + AppendFormFieldUnroll(adds, field, in site, emission); + } + + // CanUnrollForm rejects the null, HttpContent, Stream, string, and dictionary bodies the reflection path + // special-cases; a non-System.Text.Json serializer resolves field names differently, so it falls back too. + return $$""" + {{bodyIndent}}if ({{settingsLocal}}.ContentSerializer is global::Refit.SystemTextJsonContentSerializer + {{inner}} && global::Refit.GeneratedRequestRunner.CanUnrollForm({{bodyExpr}})) + {{bodyIndent}}{ + {{inner}}var {{entriesLocal}} = new global::System.Collections.Generic.List<{{kvpType}}>({{fields.Length}}); + {{adds}}{{inner}}{{requestLocal}}.Content = new global::System.Net.Http.FormUrlEncodedContent({{entriesLocal}}); + {{bodyIndent}}} + {{bodyIndent}}else + {{bodyIndent}}{ + {{inner}}{{requestLocal}}.Content = global::Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent<{{bodyParameter.Type}}>({{settingsLocal}}, {{bodyExpr}}); + {{bodyIndent}}} + + """; + } + + /// Appends the statements adding one scalar field to the unrolled form entry list. + /// The statement builder. + /// The form field descriptor. + /// The shared locals and rendered fragments for the enclosing body. + /// The shared emission locals and helper state. + private static void AppendFormFieldUnroll( + PooledStringBuilder sb, + FormFieldModel field, + in FormUnrollSite site, + in InlineValueEmission emission) + { + var indent = site.Indentation; + var valueLocal = site.Locals.New("______formValue"); + var keyExpr = "global::Refit.GeneratedRequestRunner.BuildQueryKey(" + + emission.SettingsLocal + ", " + + ToCSharpStringLiteral(field.PropertyName) + ", " + + ToNullableCSharpStringLiteral(field.ExplicitName) + ", " + + ToNullableCSharpStringLiteral(field.PrefixSegment) + ")"; + + _ = sb.Append(indent).Append("var ").Append(valueLocal).Append(" = ").Append(site.BodyExpr).Append(".@").Append(field.PropertyName).AppendLine(";"); + + var valueExpr = BuildFormFieldValueExpression(field, valueLocal, emission); + + // A non-nullable value type is always present, so it is added unconditionally. + if (!field.CanBeNull) + { + AppendFormEntryAdd(sb, in site, indent, keyExpr, valueExpr); + return; + } + + // "!= null" (not the C# 9 "is not null" pattern) keeps the emitted null guard compilable down to C# 7.3. + var childIndent = indent + " "; + _ = sb.Append(indent).Append("if (").Append(valueLocal).AppendLine(" != null)") + .Append(indent).AppendLine("{"); + AppendFormEntryAdd(sb, in site, childIndent, keyExpr, valueExpr); + _ = sb.Append(indent).AppendLine("}"); + + // A null value is omitted unless the field opts in via [Query(SerializeNull = true)], which emits an empty value. + if (!field.SerializeNull) + { + return; + } + + _ = sb.Append(indent).AppendLine("else") + .Append(indent).AppendLine("{"); + AppendFormEntryAdd(sb, in site, childIndent, keyExpr, "string.Empty"); + _ = sb.Append(indent).AppendLine("}"); + } + + /// Appends one entry-list Add using an explicit KeyValuePair construction (no C# 9 target-typed new). + /// The statement builder. + /// The shared locals and rendered fragments for the enclosing body. + /// The statement indentation. + /// The field key expression. + /// The field value expression. + private static void AppendFormEntryAdd(PooledStringBuilder sb, in FormUnrollSite site, string indent, string keyExpr, string valueExpr) => + _ = sb.Append(indent).Append(site.EntriesLocal).Append(".Add(").Append(site.KvpNew) + .Append('(').Append(keyExpr).Append(", ").Append(valueExpr).AppendLine("));"); + + /// Builds the value expression for one scalar form field, matching the configured form formatter. + /// The form field descriptor. + /// The non-null value local name. + /// The shared emission locals and helper state. + /// The rendering expression, branching to the formatter when it is customized. + private static string BuildFormFieldValueExpression( + FormFieldModel field, + string valueLocal, + in InlineValueEmission emission) + { + var formatterExpression = + $"{emission.SettingsLocal}.FormUrlEncodedParameterFormatter.Format({valueLocal}, {ToNullableCSharpStringLiteral(field.Format)})"; + var fastExpression = field.ValueFormat!.Kind == InlineFormatKind.FormatterOnly + ? null + : BuildFastFormatExpression(valueLocal, field.ValueFormat, emission); + + return fastExpression is null + ? formatterExpression + : $"{emission.UseDefaultFormFormattingLocal} ? ({fastExpression}) : {formatterExpression}"; + } + + /// Determines whether a URL-encoded body can be serialized by the straight-line unrolled fast path. + /// The body parameter model, or null when the method has no body. + /// when every field is a simple scalar carrying a compile-time rendering strategy, + /// so the body needs neither the descriptor array nor reflection on the common System.Text.Json path. + private static bool IsUnrollableFormBody(RequestParameterModel? bodyParameter) + { + if (bodyParameter is not { BodySerializationMethod: "UrlEncoded", FormFields: { Count: > 0 } formFields }) + { + return false; + } + + // A collection or complex field leaves ValueFormat null; it needs the descriptor path's collection-format and + // nested handling, so the whole body falls back rather than the generator guessing the wire format. + foreach (var field in formFields) + { + if (field.ValueFormat is null) + { + return false; + } + } + + return true; + } + + /// Determines whether an unrollable form body has at least one field with a reflection-free fast path. + /// The body parameter model, or null when the method has no body. + /// when a field renders through the default-form-formatting branch, so the generated + /// method must declare that branch local. + private static bool FormBodyHasFastPath(RequestParameterModel? bodyParameter) + { + if (!IsUnrollableFormBody(bodyParameter)) + { + return false; + } + + foreach (var field in bodyParameter!.FormFields!) + { + if (field.ValueFormat!.Kind != InlineFormatKind.FormatterOnly) + { + return true; + } + } + + return false; + } +} diff --git a/src/InterfaceStubGenerator.Shared/Emitter.Inline.FormFields.cs b/src/InterfaceStubGenerator.Shared/Emitter.Inline.FormFields.cs new file mode 100644 index 000000000..9d5b7e724 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Emitter.Inline.FormFields.cs @@ -0,0 +1,165 @@ +// 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; + +/// Emits the cached HTTP method field and form-field descriptor array for the inline path. +internal static partial class Emitter +{ + /// Resolves the HTTP method expression, caching a custom verb in a static field. + /// The parsed request model. + /// The interface-scope unique name builder. + /// The static field source (empty for a known verb) and the method expression to use. + /// A known verb resolves to a framework-cached singleton. A custom + /// verb otherwise constructs a new instance on every call; caching it in a static field matches the reflection + /// builder, which reads the verb from the attribute once per method. + private static (string Source, string Expression) BuildHttpMethodField(RequestModel request, UniqueNameBuilder uniqueNames) + { + var expression = ToHttpMethodExpression(request.HttpMethod); + if (!expression.StartsWith("new ", StringComparison.Ordinal)) + { + return (string.Empty, expression); + } + + var fieldName = uniqueNames.New("______httpMethod"); + var memberIndent = Indent(MethodMemberIndentation); + var source = $$""" + + + {{memberIndent}}/// Cached custom HTTP method, allocated once instead of per request. + {{memberIndent}}private static readonly global::System.Net.Http.HttpMethod {{fieldName}} = {{expression}}; + """; + return (source, fieldName); + } + + /// Builds the cached form field descriptor array declaration for a URL-encoded body, if eligible. + /// The body parameter model, or null when the method has no body. + /// Contains the unique member names in the interface scope. + /// Whether the consumer compilation supports nullable reference type syntax. + /// Whether the consumer compilation supports static lambda syntax. + /// The generated field declaration and its name, or empty values when the reflection path is used. + private static (string Source, string? FieldName) BuildFormFieldsField( + RequestParameterModel? bodyParameter, + UniqueNameBuilder uniqueNames, + bool supportsNullable, + bool supportsStaticLambdas) + { + // An all-scalar body is serialized straight-line by BuildInlineFormUnroll and needs no descriptor array. + if (IsUnrollableFormBody(bodyParameter)) + { + return (string.Empty, null); + } + + if (bodyParameter?.FormFields is not { Count: > 0 } formFields) + { + return (string.Empty, null); + } + + var fields = formFields.AsArray(); + var bodyType = bodyParameter.Type; + var fieldName = uniqueNames.New(FormFieldsVariableName); + var elementIndent = Indent(MethodBodyIndentation); + + // The getter lambda degrades to the consumer's language version: 'static' is C# 9 and the 'object?' cast + // annotation is C# 8, so both are omitted below those versions to keep generation compilable at the C# 7.3 floor. + var getterOpen = ">(" + (supportsStaticLambdas ? "static " : string.Empty) + + "body => (" + (supportsNullable ? "object?" : "object") + ")body.@"; + + var elements = BuildFormFieldElements(fields, bodyType, elementIndent, getterOpen); + + var memberIndent = Indent(MethodMemberIndentation); + var source = $$""" + + + {{memberIndent}}/// Cached form field descriptors used to serialize the URL-encoded request body without reflection. + {{memberIndent}}private static readonly global::Refit.FormField<{{bodyType}}>[] {{fieldName}} = new global::Refit.FormField<{{bodyType}}>[] + {{memberIndent}}{ + {{elements}}{{memberIndent}}}; + """; + return (source, fieldName); + } + + /// Builds the generated elements of the cached form field descriptor array. + /// The form field descriptors. + /// The fully-qualified body type. + /// The element indentation. + /// The language-version-specific getter lambda opening. + /// The rendered descriptor array element source. + private static string BuildFormFieldElements(FormFieldModel[] fields, string bodyType, string elementIndent, string getterOpen) + { + var elementsLength = 0; + for (var i = 0; i < fields.Length; i++) + { + elementsLength += MeasureFormFieldElement(fields[i], bodyType, getterOpen.Length, elementIndent.Length); + } + + return CreateGeneratedString( + elementsLength, + (fields, bodyType, elementIndent, getterOpen), + static (destination, state) => + { + var position = 0; + var (elementFields, type, indent, getter) = state; + for (var i = 0; i < elementFields.Length; i++) + { + var field = elementFields[i]; + AppendText(destination, indent, ref position); + AppendText(destination, FormFieldNew, ref position); + AppendText(destination, type, ref position); + AppendText(destination, getter, ref position); + AppendText(destination, field.PropertyName, ref position); + AppendText(destination, FormFieldNameOpen, ref position); + AppendText(destination, field.PropertyName, ref position); + AppendText(destination, FormFieldNameClose, ref position); + AppendLiteralOrNull(destination, field.ExplicitName, ref position); + AppendText(destination, ArgumentSeparator, ref position); + AppendLiteralOrNull(destination, field.PrefixSegment, ref position); + AppendText(destination, ArgumentSeparator, ref position); + AppendLiteralOrNull(destination, field.Format, ref position); + AppendText(destination, ArgumentSeparator, ref position); + if (field.CollectionFormatValue is { } collectionFormatValue) + { + AppendText(destination, CollectionFormatCast, ref position); + AppendInt32(destination, collectionFormatValue, ref position); + } + else + { + AppendText(destination, NullLiteral, ref position); + } + + AppendText(destination, ArgumentSeparator, ref position); + AppendText(destination, field.SerializeNull ? TrueLiteral : FalseLiteral, ref position); + AppendText(destination, FormFieldClose, ref position); + } + }); + } + + /// Measures the rendered length of one generated form field element line. + /// The form field descriptor. + /// The fully-qualified body type. + /// The length of the language-version-specific getter lambda opening. + /// The element indentation length. + /// The number of characters the rendered element occupies. + private static int MeasureFormFieldElement(FormFieldModel field, string bodyType, int getterOpenLength, int indentLength) => + indentLength + + FormFieldNew.Length + + bodyType.Length + + getterOpenLength + + field.PropertyName.Length + + FormFieldNameOpen.Length + + field.PropertyName.Length + + FormFieldNameClose.Length + + LiteralOrNullLength(field.ExplicitName) + + ArgumentSeparator.Length + + LiteralOrNullLength(field.PrefixSegment) + + ArgumentSeparator.Length + + LiteralOrNullLength(field.Format) + + ArgumentSeparator.Length + + (field.CollectionFormatValue is { } collectionFormatValue + ? CollectionFormatCast.Length + Int32Length(collectionFormatValue) + : NullLiteral.Length) + + ArgumentSeparator.Length + + (field.SerializeNull ? TrueLiteral.Length : FalseLiteral.Length) + + FormFieldClose.Length; +} diff --git a/src/InterfaceStubGenerator.Shared/Emitter.Inline.Multipart.cs b/src/InterfaceStubGenerator.Shared/Emitter.Inline.Multipart.cs new file mode 100644 index 000000000..e71ff9659 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Emitter.Inline.Multipart.cs @@ -0,0 +1,222 @@ +// 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; + +/// Emits inline request-construction source for generated Refit method implementations. +/// +/// Multipart form content emission for the inline path. Builds a MultipartFormDataContent and adds one part per +/// part parameter (or one per element for a reference-typed enumerable), reproducing the reflection request builder's +/// AddMultipartItem dispatch and its part name/file-name selection exactly for statically-resolvable part types. +/// +internal static partial class Emitter +{ + /// The fully-qualified MultipartFormDataContent constructor prefix. + private const string MultipartContentNew = "new global::System.Net.Http.MultipartFormDataContent("; + + /// The fully-qualified StreamContent constructor prefix. + private const string StreamContentNew = "new global::System.Net.Http.StreamContent("; + + /// The fully-qualified StringContent constructor prefix. + private const string StringContentNew = "new global::System.Net.Http.StringContent("; + + /// The fully-qualified ByteArrayContent constructor prefix. + private const string ByteArrayContentNew = "new global::System.Net.Http.ByteArrayContent("; + + /// Builds the multipart form content assignment for an inline generated method. + /// The parsed request model. + /// The generated request message local name. + /// The generated settings local name. + /// The method-scope unique local name builder. + /// The generated multipart content statements, ending with the request-content assignment. + private static string BuildInlineMultipartContent( + RequestModel request, + string requestLocal, + string settingsLocal, + UniqueNameBuilder locals) + { + var bodyIndent = Indent(MethodBodyIndentation); + var contentLocal = locals.New("refitMultipart"); + + var sb = new PooledStringBuilder(); + _ = sb.Append(bodyIndent).Append("var ").Append(contentLocal).Append(" = ").Append(MultipartContentNew) + .Append(ToCSharpStringLiteral(request.MultipartBoundary)).AppendLine(");"); + + foreach (var parameter in request.Parameters) + { + if (parameter is { Kind: RequestParameterKind.MultipartPart, MultipartPart: { } part }) + { + AppendMultipartPart(sb, parameter, part, settingsLocal, contentLocal, locals); + } + } + + // A blank line separates the content assignment from the following header statements, matching the body path. + _ = sb.Append(bodyIndent).Append(requestLocal).Append(".Content = ").Append(contentLocal).AppendLine(";") + .AppendLine(); + return sb.ToString(); + } + + /// Appends the statements adding one part parameter (guarding null values as the reflection builder does). + /// The statement builder. + /// The part parameter model. + /// The multipart part descriptor. + /// The generated settings local name. + /// The generated multipart content local name. + /// The method-scope unique local name builder. + private static void AppendMultipartPart( + PooledStringBuilder sb, + RequestParameterModel parameter, + MultipartPartModel part, + string settingsLocal, + string contentLocal, + UniqueNameBuilder locals) + { + var bodyIndent = Indent(MethodBodyIndentation); + var valueExpression = "@" + parameter.Name; + + // A reference-typed enumerable adds one part per element; a null collection contributes no parts, matching the + // reflection builder's skip of a null parameter value. + if (part.IsEnumerable) + { + var elementLocal = locals.New("refitPart"); + _ = sb.Append(bodyIndent).Append("if (").Append(valueExpression).AppendLine(" != null)") + .Append(bodyIndent).AppendLine("{") + .Append(bodyIndent).Append(" foreach (var ").Append(elementLocal).Append(" in ").Append(valueExpression).AppendLine(")") + .Append(bodyIndent).AppendLine(" {"); + AppendMultipartAdd(sb, part, settingsLocal, contentLocal, elementLocal, bodyIndent + " "); + _ = sb.Append(bodyIndent).AppendLine(" }") + .Append(bodyIndent).AppendLine("}"); + return; + } + + // A null single value contributes no part, matching the reflection builder's null-parameter skip. + if (parameter.CanBeNull) + { + _ = sb.Append(bodyIndent).Append("if (").Append(valueExpression).AppendLine(" != null)") + .Append(bodyIndent).AppendLine("{"); + AppendMultipartAdd(sb, part, settingsLocal, contentLocal, valueExpression, bodyIndent + " "); + _ = sb.Append(bodyIndent).AppendLine("}"); + return; + } + + AppendMultipartAdd(sb, part, settingsLocal, contentLocal, valueExpression, bodyIndent); + } + + /// Appends the single MultipartFormDataContent.Add call for one value, per the part's dispatch arm. + /// The statement builder. + /// The multipart part descriptor. + /// The generated settings local name. + /// The generated multipart content local name. + /// The value expression (a parameter accessor or a foreach element local). + /// The statement indentation. + private static void AppendMultipartAdd( + PooledStringBuilder sb, + MultipartPartModel part, + string settingsLocal, + string contentLocal, + string value, + string indent) + { + var fieldName = ToCSharpStringLiteral(part.FieldName); + var fileName = ToCSharpStringLiteral(part.FileName); + _ = sb.Append(indent).Append(contentLocal).Append(".Add("); + AppendMultipartAddArguments(sb, part, settingsLocal, value, fieldName, fileName); + } + + /// Appends the .Add(...) argument list for one multipart part, per its dispatch arm. + /// The statement builder. + /// The multipart part descriptor. + /// The generated settings local name. + /// The value expression (a parameter accessor or a foreach element local). + /// The C# string literal for the part's field name. + /// The C# string literal for the part's file name. + /// The arms are exhaustive over statically-dispatchable part kinds; the + /// compiler-required default arm handling the formattable kind cannot be selected for every kind by tests. + [ExcludeFromCodeCoverage] + private static void AppendMultipartAddArguments( + PooledStringBuilder sb, + MultipartPartModel part, + string settingsLocal, + string value, + string fieldName, + string fileName) + { + switch (part.Kind) + { + case MultipartPartKind.HttpContent: + { + _ = sb.Append(value).AppendLine(");"); + break; + } + + case MultipartPartKind.MultipartItem: + { + _ = sb.Append(value).Append(".ToContent(), ").Append(value).Append(".Name ?? ").Append(fieldName) + .Append(", string.IsNullOrEmpty(").Append(value).Append(".FileName) ? ").Append(fileName) + .Append(" : ").Append(value).AppendLine(".FileName);"); + break; + } + + case MultipartPartKind.Stream: + { + _ = sb.Append(StreamContentNew).Append(value).Append("), ").Append(fieldName).Append(", ") + .Append(fileName).AppendLine(");"); + break; + } + + case MultipartPartKind.String: + { + _ = sb.Append(StringContentNew).Append(value).Append("), ").Append(fieldName).AppendLine(");"); + break; + } + + case MultipartPartKind.FileInfo: + { + _ = sb.Append(StreamContentNew).Append(value).Append(".OpenRead()), ").Append(fieldName).Append(", ") + .Append(value).AppendLine(".Name);"); + break; + } + + case MultipartPartKind.ByteArray: + { + _ = sb.Append(ByteArrayContentNew).Append(value).Append("), ").Append(fieldName).Append(", ") + .Append(fileName).AppendLine(");"); + break; + } + + case MultipartPartKind.Serialized: + { + AppendSerializedMultipartArgument(sb, settingsLocal, value, fieldName); + break; + } + + default: + { + // Formattable: Guid/DateTime/etc. render through the form URL-encoded formatter, exactly as the + // reflection builder's AddSerializedMultipartItem special case does. + _ = sb.Append(StringContentNew).Append(settingsLocal) + .Append(".FormUrlEncodedParameterFormatter.Format(").Append(value).Append(", null) ?? string.Empty), ") + .Append(fieldName).AppendLine(");"); + break; + } + } + } + + /// Appends the .Add(...) arguments for a JSON-serialized multipart part. + /// The statement builder. + /// The generated settings local name. + /// The value expression (a parameter accessor or a foreach element local). + /// The C# string literal for the part's field name. + private static void AppendSerializedMultipartArgument(PooledStringBuilder sb, string settingsLocal, string value, string fieldName) + { + // A sealed/value part is JSON-serialized under its field name, matching AddSerializedMultipartItem's + // serializer fallback. The declared type drives ToHttpContent, so the serialized form matches; a + // serialization failure is wrapped in the same descriptive ArgumentException the reflection builder raises. + _ = sb.Append("global::Refit.GeneratedRequestRunner.SerializeMultipartPart(").Append(settingsLocal) + .Append(", ").Append(value).Append(", ").Append(fieldName).Append("), ") + .Append(fieldName).AppendLine(");"); + } +} diff --git a/src/InterfaceStubGenerator.Shared/Emitter.Inline.ParameterInfo.cs b/src/InterfaceStubGenerator.Shared/Emitter.Inline.ParameterInfo.cs new file mode 100644 index 000000000..09ddf20d6 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Emitter.Inline.ParameterInfo.cs @@ -0,0 +1,260 @@ +// 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; + +/// Emits the cached parameter attribute-provider fields and the parameters argument for the inline path. +internal static partial class Emitter +{ + /// Builds the unique cached attribute-provider field name for a path parameter. + /// The source parameter name. + /// The unique member name builder for the interface scope. + /// The unique generated field name. + private static string GetParameterInfoFieldName(string parameterName, UniqueNameBuilder uniqueNames) => + uniqueNames.New($"______{parameterName}AttributeProvider"); + + /// Appends a separator before all but the first element. + /// The zero-based element index. + /// The target builder. + /// The separator to append. + /// The same builder for chaining. + private static PooledStringBuilder AppendSeparator(int i, PooledStringBuilder sb, string separator = ", ") + { + return i <= 0 ? sb : sb.Append(separator); + } + + /// Appends a value, prefixed by a separator for all but the first element. + /// The value to append. + /// The zero-based element index. + /// The target builder. + /// The separator to append before the value. + /// The same builder for chaining. + private static PooledStringBuilder AppendJoining(string value, int i, PooledStringBuilder sb, string separator = ", ") + { + return AppendSeparator(i, sb, separator).Append(value); + } + + /// Appends a C# attribute construction expression to the builder. + /// The attribute model to render. + /// The target builder. + private static void AppendAttributeValue(ParameterAttributeModel attribute, PooledStringBuilder sb0) + { + _ = sb0.Append("new ").Append(attribute.TypeExpression).Append('('); + var i = 0; + + foreach (var argument in attribute.ConstructorArguments) + { + _ = AppendJoining(argument, i, sb0); + i++; + } + + _ = sb0.Append(')'); + if (attribute.NamedArguments.Count < 1) + { + return; + } + + i = 0; + _ = sb0.Append("{ "); + foreach (var named in attribute.NamedArguments) + { + _ = AppendSeparator(i, sb0); + i++; + _ = sb0.Append(named.Name).Append(" = ").Append(named.ValueExpression); + } + + _ = sb0.Append(" }"); + } + + /// Emits the cached attribute-provider field for a single path parameter. + /// The path parameter model. + /// The declaring method name, used for the generated documentation. + /// The unique generated field name. + /// The target builder. + private static void BuildParameterInfoField(RequestParameterModel parameter, string method, string paramInfoFieldName, PooledStringBuilder sb) + { + // Build the initializer. + var memberIndent = Indent(MethodMemberIndentation); + Dictionary> grouped = new(); + + foreach (var attribute in parameter.Attributes) + { + var key = $"typeof({attribute.TypeExpression})"; + if (grouped.TryGetValue(key, out var groupedAttributes)) + { + groupedAttributes.Add(attribute); + } + else + { + grouped.Add(key, [attribute]); + } + } + + _ = sb.AppendLine().Append(memberIndent).Append("/// Cached attribute provider for the generated ") + .Append(ToXmlDocumentationText(method)).Append(" method's ").Append(ToXmlDocumentationText(parameter.Name)).AppendLine(" parameter.") + .Append(memberIndent).Append("private static readonly global::Refit.GeneratedParameterAttributeProvider ").Append(paramInfoFieldName).Append(" = "); + + // A parameter with no attributes shares the singleton empty provider instead of allocating an empty dictionary. + if (grouped.Count == 0) + { + _ = sb.AppendLine("global::Refit.GeneratedParameterAttributeProvider.Empty;"); + return; + } + + const string dictType = "global::System.Collections.Generic.Dictionary"; + _ = sb.Append("new global::Refit.GeneratedParameterAttributeProvider(new ").Append(dictType).Append("() {"); + var i = 0; + foreach (var kv in grouped) + { + _ = AppendJoining("{ ", i, sb).Append(kv.Key).Append(", new object[] { "); + i++; + var argIndex = 0; + foreach (var arg in kv.Value) + { + // Multiple attributes of the same type must be comma-separated inside the array. + _ = AppendSeparator(argIndex, sb); + argIndex++; + AppendAttributeValue(arg, sb); + } + + _ = sb.Append("} }"); + } + + _ = sb.Append('}').AppendLine(");"); + } + + /// Assigns the unique cached field name for each attribute-provider parameter and emits its field. + /// The parsed request model. + /// The unique member name builder for the interface scope. + /// The declared method name the emitted fields are scoped to. + /// The builder receiving the emitted attribute-provider fields. + /// A map of parameter name to its cached attribute-provider field name. + private static Dictionary BuildParameterInfoFields( + RequestModel request, + UniqueNameBuilder uniqueNames, + string declaredMethod, + PooledStringBuilder paramInfoSb) + { + var dict = new Dictionary(); + foreach (var parameter in request.Parameters) + { + if (!NeedsAttributeProvider(parameter)) + { + continue; + } + + var parameterInfoFieldName = GetParameterInfoFieldName(parameter.Name, uniqueNames); + dict.Add(parameter.Name, parameterInfoFieldName); + BuildParameterInfoField(parameter, declaredMethod, parameterInfoFieldName, paramInfoSb); + } + + return dict; + } + + /// Builds the additional arguments passed to the generated request path builder. + /// The parsed request model. + /// The map of parameter name to cached attribute-provider field name. + /// The shared emission locals and helper state. + /// The generated argument list fragment. + private static string GetParametersArg( + RequestModel request, + Dictionary uniqueNameLookup, + in InlineValueEmission emission) + { + // A single pre-encoded path parameter switches every replacement to the overload carrying the + // per-value encoding flag, because a params call cannot mix tuple arities. + var anyPreEncoded = HasPreEncodedPathParameter(request); + + var replacements = CollectPathReplacements(request, uniqueNameLookup, emission); + if (replacements.Count == 0) + { + return string.Empty; + } + + // BuildRequestPath fills the template left-to-right and slices between consecutive replacements, so they must + // be ordered by template position. Parameter order does not match template order when an object binding (or a + // later parameter) fills an earlier placeholder, so sort here rather than relying on declaration order. + replacements.Sort(static (left, right) => left.Start.CompareTo(right.Start)); + + var parametersSb = new PooledStringBuilder(); + var first = true; + foreach (var replacement in replacements) + { + if (!first) + { + _ = parametersSb.Append(", "); + } + + first = false; + AppendPathTuple( + parametersSb, + replacement.Start, + replacement.End, + replacement.Value, + anyPreEncoded, + replacement.PreEncoded); + } + + return WrapPathReplacements(parametersSb.ToString(), emission.SupportsCollectionExpressions); + } + + /// Collects the path-template replacements contributed by every path parameter. + /// The parsed request model. + /// The map of parameter name to cached attribute-provider field name. + /// The shared emission locals and helper state. + /// The unordered path-template replacements. + private static List CollectPathReplacements( + RequestModel request, + Dictionary uniqueNameLookup, + in InlineValueEmission emission) + { + var pathLength = request.Path.Length; + var replacements = new List(); + foreach (var parameter in request.Parameters) + { + if (parameter.Kind is not RequestParameterKind.Path) + { + continue; + } + + var providerField = uniqueNameLookup[parameter.Name]; + + // A dotted {param.Prop} object parameter fills each placeholder with a formatted property value. + if (parameter.PathObjectBindings is { } bindings) + { + foreach (var binding in bindings) + { + var bindingValue = BuildPathValueExpressionCore( + "@" + parameter.Name + "." + binding.PropertyClrName, + binding.PropertyType, + binding.ValueFormat, + binding.PropertyCanBeNull, + providerField, + emission); + replacements.Add(new( + binding.Location.Start.GetOffset(pathLength), + binding.Location.End.GetOffset(pathLength), + bindingValue, + PreEncoded: false)); + } + + continue; + } + + // Every remaining Path parameter is a direct placeholder built with locations (a dotted object binding is + // handled above), so its locations are always present. + var valueExpression = BuildPathValueExpression(parameter, providerField, emission); + foreach (var location in parameter.Locations!) + { + replacements.Add(new( + location.Start.GetOffset(pathLength), + location.End.GetOffset(pathLength), + valueExpression, + parameter.PreEncoded)); + } + } + + return replacements; + } +} diff --git a/src/InterfaceStubGenerator.Shared/Emitter.Inline.Path.cs b/src/InterfaceStubGenerator.Shared/Emitter.Inline.Path.cs new file mode 100644 index 000000000..04a9fd640 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Emitter.Inline.Path.cs @@ -0,0 +1,141 @@ +// 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; + +/// Emits the inline URL path expression and its runtime replacement tuples. +internal static partial class Emitter +{ + /// Wraps the path replacement tuples as the collection argument passed to BuildRequestPath. + /// The comma-separated tuple expressions. + /// Whether the consumer supports C# 12 collection expressions. + /// The , <collection> argument fragment. + /// A C# 12 consumer receives a [...] collection expression, which the compiler materializes on the + /// stack (net8.0+ inline arrays) so no array is allocated; an older consumer receives an inferred array that the same + /// ReadOnlySpan overload accepts via the array-to-span conversion. The array element type is inferred from the + /// tuple values rather than stated, so no nullable reference annotation is emitted into a pre-C# 8 consumer. + private static string WrapPathReplacements(string tuples, bool supportsCollectionExpressions) => + supportsCollectionExpressions ? ", [" + tuples + "]" : ", new[] { " + tuples + " }"; + + /// Determines whether any path parameter passes its value through pre-encoded. + /// The parsed request model. + /// when a path parameter carries [Encoded]. + private static bool HasPreEncodedPathParameter(RequestModel request) + { + foreach (var parameter in request.Parameters) + { + if (parameter.Kind == RequestParameterKind.Path && parameter.PreEncoded) + { + return true; + } + } + + return false; + } + + /// Appends one ((start, end), value[, preEncoded]) tuple to the path replacement argument list. + /// The argument list builder. + /// The placeholder start offset. + /// The placeholder end offset. + /// The replacement value expression. + /// Whether the tuple carries the per-value pre-encoded flag. + /// The pre-encoded flag value, emitted only when is set. + private static void AppendPathTuple( + PooledStringBuilder sb, + int start, + int end, + string valueExpression, + bool includePreEncoded, + bool preEncoded) + { + _ = sb.Append("((").Append(start).Append(", ").Append(end).Append("), ").Append(valueExpression); + if (includePreEncoded) + { + _ = sb.Append(", ").Append(ToLowerInvariantString(preEncoded)); + } + + _ = sb.Append(')'); + } + + /// Builds the request path expression, preferring the span-formattable fast path when it applies. + /// The parsed request model. + /// The map of parameter name to cached attribute-provider field name. + /// The shared emission locals and helper state. + /// The generated settings local name. + /// The default path builder argument fragment. + /// The generated path expression. + private static string BuildInlinePathExpression( + RequestModel request, + Dictionary parameterInfoNames, + in InlineValueEmission emission, + string settingsLocal, + string parameters) + { + // A template with placeholders but no bound path parameters still runs the unmatched-placeholder + // check so AllowUnmatchedRouteParameters keeps its reflection-path semantics. + return TryBuildInlinePathFastExpression(request, parameterInfoNames, emission) + ?? (parameters.Length > 0 || request.Path.IndexOf('{') >= 0 + ? $"global::Refit.GeneratedRequestRunner.BuildRequestPath({ToCSharpStringLiteral(request.Path)}, {settingsLocal}.AllowUnmatchedRouteParameters{parameters})" + : ToCSharpStringLiteral(request.Path)); + } + + /// Builds the allocation-free path expression for a single span-formattable path parameter, or null. + /// The parsed request model. + /// The map of parameter name to cached attribute-provider field name. + /// The shared emission locals and helper state. + /// The path expression using the span-formattable fast overload, or null to use the default path building. + /// The default-formatting branch formats the value straight into the path buffer (net6+ integers with no + /// escaping, net10+ span-escaped values); a customized IUrlParameterFormatter falls back to the string overload. + private static string? TryBuildInlinePathFastExpression( + RequestModel request, + Dictionary parameterInfoNames, + in InlineValueEmission emission) + { + RequestParameterModel? pathParameter = null; + foreach (var parameter in request.Parameters) + { + if (parameter.Kind != RequestParameterKind.Path) + { + continue; + } + + // The single-placeholder fast overloads model one path parameter with one location; anything else falls back. + if (pathParameter is not null) + { + return null; + } + + pathParameter = parameter; + } + + if (pathParameter is not { Locations: { Count: 1 } locations, PreEncoded: false, ValueFormat: { } valueFormat } + || valueFormat.IsNullableValueType + || (!valueFormat.IsUrlSafeSpanFormattable && !valueFormat.IsSpanFormattableEscapable)) + { + // A nullable value type keeps the string-formatting path, which null-guards and unwraps .Value itself. + return null; + } + + var pathLength = request.Path.Length; + var location = locations.AsArray()[0]; + var start = location.Start.GetOffset(pathLength); + var end = location.End.GetOffset(pathLength); + var template = ToCSharpStringLiteral(request.Path); + var settingsLocal = emission.SettingsLocal; + var allowUnmatched = $"{settingsLocal}.AllowUnmatchedRouteParameters"; + var valueExpression = "@" + pathParameter.Name; + _ = parameterInfoNames.TryGetValue(pathParameter.Name, out var providerField); + const string runner = "global::Refit.GeneratedRequestRunner.BuildRequestPath"; + + var fastExpression = valueFormat.IsUrlSafeSpanFormattable + ? $"{runner}({template}, {allowUnmatched}, ({start}, {end}), {valueExpression})" + : $"{runner}({template}, {allowUnmatched}, ({start}, {end}), {valueExpression}, {ToNullableCSharpStringLiteral(valueFormat.Format)})"; + var customTuple = + $"(({start}, {end}), {settingsLocal}.UrlParameterFormatter.Format({valueExpression}, {providerField}, typeof({pathParameter.Type})))"; + var customReplacements = WrapPathReplacements(customTuple, emission.SupportsCollectionExpressions); + var customExpression = $"{runner}({template}, {allowUnmatched}{customReplacements})"; + + return $"({emission.UseDefaultFormattingLocal} ? {fastExpression} : {customExpression})"; + } +} diff --git a/src/InterfaceStubGenerator.Shared/Emitter.Inline.Query.Dictionary.cs b/src/InterfaceStubGenerator.Shared/Emitter.Inline.Query.Dictionary.cs new file mode 100644 index 000000000..ac76ead6a --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Emitter.Inline.Query.Dictionary.cs @@ -0,0 +1,169 @@ +// 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; + +/// Emits inline request-construction source for generated Refit method implementations. +/// Flattens a dictionary-shaped query parameter into query-string statements. +internal static partial class Emitter +{ + /// Appends the statements turning one dictionary parameter's entries into query pairs. + /// The statement builder. + /// The parameter model. + /// The query-binding metadata. + /// The cached attribute-provider field name. + /// The shared emission locals and helper state. + /// + /// Mirrors the reflection builder's BuildQueryMap(IDictionary): entries with a null value are skipped, the + /// key is rendered by the URL parameter formatter using the key's own as both the + /// attribute provider and the declared type, a blank key drops the pair, and the value is rendered using the + /// enclosing parameter's attributes and declared type. + /// + private static void AppendDictionaryQueryStatements( + PooledStringBuilder sb, + RequestParameterModel parameter, + QueryParameterModel query, + string providerField, + in InlineValueEmission emission) + { + var dictionary = query.Dictionary!; + var bodyIndent = Indent(MethodBodyIndentation); + var guarded = parameter.CanBeNull; + var indent = guarded ? bodyIndent + " " : bodyIndent; + var entryLocal = emission.QueryValueLocal + "_entry"; + var keyLocal = emission.QueryValueLocal + "_key"; + var valueLocal = emission.QueryValueLocal + ValueLocalSuffix; + + if (guarded) + { + _ = sb.Append(bodyIndent).Append(IfParameterPrefix).Append(parameter.Name).AppendLine(NotNullCheckSuffix) + .Append(bodyIndent).AppendLine("{"); + } + + _ = sb.Append(indent).Append(ForeachVarKeyword).Append(entryLocal).Append(" in @").Append(parameter.Name).AppendLine(")") + .Append(indent).AppendLine("{"); + + var entryIndent = indent + " "; + _ = sb.Append(entryIndent).Append("var ").Append(valueLocal).Append(" = ").Append(entryLocal).AppendLine(".Value;"); + + var valueIndent = entryIndent; + if (dictionary.ValueCanBeNull) + { + // The reflection builder skips an entry whose value is null before it ever formats the key. + _ = sb.Append(entryIndent).Append("if (").Append(valueLocal).AppendLine(NotNullCheckSuffix) + .Append(entryIndent).AppendLine("{"); + valueIndent = entryIndent + " "; + } + + var entry = new DictionaryEntrySite(entryLocal, keyLocal, valueLocal, valueIndent); + AppendDictionaryEntryStatements(sb, parameter, query, providerField, emission, entry); + + if (dictionary.ValueCanBeNull) + { + _ = sb.Append(entryIndent).AppendLine("}"); + } + + _ = sb.Append(indent).AppendLine("}"); + + if (!guarded) + { + return; + } + + _ = sb.Append(bodyIndent).AppendLine("}"); + } + + /// Appends the statements emitting one dictionary entry's query pair. + /// The statement builder. + /// The parameter model. + /// The query-binding metadata. + /// The cached attribute-provider field name. + /// The shared emission locals and helper state. + /// The generated locals and indentation for the current entry. + private static void AppendDictionaryEntryStatements( + PooledStringBuilder sb, + RequestParameterModel parameter, + QueryParameterModel query, + string providerField, + in InlineValueEmission emission, + in DictionaryEntrySite entry) + { + var dictionary = query.Dictionary!; + var (entryLocal, keyLocal, valueLocal, indent) = entry; + var keyTypeOf = $"typeof({dictionary.KeyTypeName})"; + var customKey = + $"{emission.SettingsLocal}.UrlParameterFormatter.Format({entryLocal}.Key, {keyTypeOf}, {keyTypeOf})"; + var fastKey = BuildFastFormatExpression(entryLocal + ".Key", dictionary.KeyFormat, emission); + var keyExpression = fastKey is null + ? customKey + : $"{emission.UseDefaultFormattingLocal} ? ({fastKey}) : {customKey}"; + + _ = sb.Append(indent).Append("var ").Append(keyLocal).Append(" = ").Append(keyExpression).AppendLine(";") + .Append(indent).Append("if (!string.IsNullOrWhiteSpace(").Append(keyLocal).AppendLine("))") + .Append(indent).AppendLine("{"); + + var innerIndent = indent + " "; + if (dictionary.ValueProperties is { } valueProperties) + { + AppendDictionaryValueFlatten(sb, parameter, query, providerField, emission, entry, valueProperties); + } + else + { + var customValue = BuildUrlFormatterCall(valueLocal, parameter.Type, providerField, emission); + var fastValue = BuildFastFormatExpression(valueLocal, query.ValueFormat, emission); + var valueExpression = fastValue is null + ? customValue + : $"{emission.UseDefaultFormattingLocal} ? ({fastValue}) : {customValue}"; + var keyArgument = dictionary.PrefixSegment is { } prefix + ? $"{ToCSharpStringLiteral(prefix)} + {keyLocal}" + : keyLocal; + _ = sb.Append(innerIndent).Append(emission.QueryBuilderLocal) + .Append(AddQueryPairCall).Append(keyArgument).Append(", ").Append(valueExpression).Append(", ") + .Append(ToLowerInvariantString(query.PreEncoded)).AppendLine(");"); + } + + _ = sb.Append(indent).AppendLine("}"); + } + + /// Appends the statements flattening a sealed complex dictionary value under the entry's key. + /// The statement builder. + /// The parameter model. + /// The query-binding metadata. + /// The cached attribute-provider field name. + /// The shared emission locals and helper state. + /// The generated locals and indentation for the current entry. + /// The flattened property descriptors of the value type. + /// Reuses the query-object flattening walk with the entry key as the runtime parent key, so each value + /// property emits an entryKey.property=value pair, matching the reflection builder's nested BuildQueryMap. + private static void AppendDictionaryValueFlatten( + PooledStringBuilder sb, + RequestParameterModel parameter, + QueryParameterModel query, + string providerField, + in InlineValueEmission emission, + in DictionaryEntrySite entry, + ImmutableEquatableArray valueProperties) + { + var dictionary = query.Dictionary!; + var (_, keyLocal, valueLocal, entryIndent) = entry; + var indent = entryIndent + " "; + + var parentKeyExpression = keyLocal; + if (dictionary.PrefixSegment is { } prefix) + { + var parentKeyLocal = keyLocal + "_prefixed"; + _ = sb.Append(indent).Append("var ").Append(parentKeyLocal).Append(" = ") + .Append(ToCSharpStringLiteral(prefix)).Append(" + ").Append(keyLocal).AppendLine(";"); + parentKeyExpression = parentKeyLocal; + } + + var context = new QueryObjectContext( + parameter, + providerField, + query.CollectionFormatValue, + ToLowerInvariantString(query.PreEncoded)); + var scope = new ObjectFlattenScope(valueLocal, parentKeyExpression, query.NestingDelimiter, ValueLocalSuffix, indent); + AppendObjectPropertyList(sb, context, valueProperties, scope, emission); + } +} diff --git a/src/InterfaceStubGenerator.Shared/Emitter.Inline.Query.Formatters.cs b/src/InterfaceStubGenerator.Shared/Emitter.Inline.Query.Formatters.cs new file mode 100644 index 000000000..85a10a095 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Emitter.Inline.Query.Formatters.cs @@ -0,0 +1,114 @@ +// 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; + +/// Emits inline request-construction source for generated Refit method implementations. +/// Generated enum-formatter and query-converter helper emission for the inline path. +internal static partial class Emitter +{ + /// Gets the generated enum formatting helper for an enum type and format, emitting it on first use. + /// The enum rendering strategy. + /// The shared emission locals and helper state. + /// The helper method name. + private static string GetOrAddEnumFormatter( + InlineValueFormatModel valueFormat, + in InlineValueEmission emission) + { + var scope = emission.Scope; + var key = (valueFormat.TypeName, valueFormat.Format); + if (scope.Formatters.TryGetValue(key, out var existing)) + { + return existing; + } + + var helperName = scope.UniqueNames.New(EnumFormatterBaseName); + scope.Formatters.Add(key, helperName); + AppendEnumFormatterSource(valueFormat, helperName, emission.MemberSource); + return helperName; + } + + /// Appends the source of one generated enum formatting helper. + /// The enum rendering strategy. + /// The unique helper method name. + /// The builder receiving emitted helper members. + private static void AppendEnumFormatterSource( + InlineValueFormatModel valueFormat, + string helperName, + PooledStringBuilder memberSb) + { + var memberIndent = Indent(MethodMemberIndentation); + var bodyIndent = Indent(MethodBodyIndentation); + var caseIndent = bodyIndent + " "; + var format = valueFormat.Format; + + _ = memberSb.AppendLine() + .Append(memberIndent).Append("/// Formats ").Append(ToXmlDocumentationText(valueFormat.TypeName)) + .AppendLine(" values for generated requests without reflection.") + .Append(memberIndent).Append("private static string ").Append(helperName) + .Append('(').Append(valueFormat.TypeName).AppendLine(" value)") + .Append(memberIndent).AppendLine("{") + .Append(bodyIndent).AppendLine("switch (value)") + .Append(bodyIndent).AppendLine("{"); + + foreach (var member in valueFormat.EnumMembers!) + { + // With a compile-time format only [EnumMember] overrides beat the formatted numeric rendering, + // matching string.Format's precedence in the default URL parameter formatter. + var resolved = format is null + ? member.EnumMemberValue ?? member.MemberName + : member.EnumMemberValue; + if (resolved is null) + { + continue; + } + + _ = memberSb.Append(caseIndent).Append("case ").Append(valueFormat.TypeName).Append(".@").Append(member.MemberName).AppendLine(":") + .Append(caseIndent).Append(" return ").Append(ToCSharpStringLiteral(resolved)).AppendLine(";"); + } + + var defaultExpression = format is null + ? "value.ToString()" + : $"value.ToString({ToCSharpStringLiteral(format)})"; + _ = memberSb.Append(caseIndent).AppendLine("default:") + .Append(caseIndent).Append(" return ").Append(defaultExpression).AppendLine(";") + .Append(bodyIndent).AppendLine("}") + .Append(memberIndent).AppendLine("}"); + } + + /// Gets the cached converter field for a converter type, emitting the field on first use. + /// The fully-qualified converter type. + /// The shared emission locals and helper state. + /// The cached field name. + private static string GetOrAddConverterField(string converterTypeName, in InlineValueEmission emission) + { + var scope = emission.Scope; + if (scope.Converters.TryGetValue(converterTypeName, out var existing)) + { + return existing; + } + + var fieldName = scope.UniqueNames.New(ConverterFieldBaseName); + scope.Converters.Add(converterTypeName, fieldName); + AppendConverterFieldSource(converterTypeName, fieldName, emission.MemberSource); + return fieldName; + } + + /// Appends the source of one cached converter field. + /// The fully-qualified converter type. + /// The unique field name. + /// The builder receiving emitted helper members. + private static void AppendConverterFieldSource( + string converterTypeName, + string fieldName, + PooledStringBuilder memberSb) + { + var memberIndent = Indent(MethodMemberIndentation); + _ = memberSb.AppendLine() + .Append(memberIndent).Append("/// Cached query converter of type ") + .Append(ToXmlDocumentationText(converterTypeName)).AppendLine(".") + .Append(memberIndent).Append("private static readonly ").Append(converterTypeName).Append(' ').Append(fieldName) + .Append(" = new ").Append(converterTypeName).AppendLine("();"); + } +} diff --git a/src/InterfaceStubGenerator.Shared/Emitter.Inline.Query.Object.cs b/src/InterfaceStubGenerator.Shared/Emitter.Inline.Query.Object.cs new file mode 100644 index 000000000..10c712eea --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Emitter.Inline.Query.Object.cs @@ -0,0 +1,628 @@ +// 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; + +/// Emits inline request-construction source for generated Refit method implementations. +/// Flattens a complex query object's properties into query-string statements. +internal static partial class Emitter +{ + /// Appends the statements flattening one query object's properties into query pairs. + /// The statement builder. + /// The parameter model. + /// The query-binding metadata. + /// The cached attribute-provider field name. + /// The shared emission locals and helper state. + /// + /// Mirrors the reflection builder's per-property contract: a null value is omitted unless + /// [Query(SerializeNull = true)] emits a bare key=; a property-level format runs through the + /// form-url-encoded formatter first and omits the pair when it yields null; and the surviving value is rendered by + /// the URL parameter formatter, which receives the enclosing parameter's attributes and declared type. + /// + private static void AppendObjectQueryStatements( + PooledStringBuilder sb, + RequestParameterModel parameter, + QueryParameterModel query, + string providerField, + in InlineValueEmission emission) + { + var bodyIndent = Indent(MethodBodyIndentation); + var guarded = parameter.CanBeNull; + var indent = guarded ? bodyIndent + " " : bodyIndent; + + if (guarded) + { + _ = sb.Append(bodyIndent).Append(IfParameterPrefix).Append(parameter.Name).AppendLine(NotNullCheckSuffix) + .Append(bodyIndent).AppendLine("{"); + } + + var context = new QueryObjectContext( + parameter, + providerField, + query.CollectionFormatValue, + ToLowerInvariantString(query.PreEncoded)); + var scope = new ObjectFlattenScope("@" + parameter.Name, null, query.NestingDelimiter, string.Empty, indent); + AppendObjectPropertyList(sb, context, query.ObjectProperties!, scope, emission); + + if (!guarded) + { + return; + } + + _ = sb.Append(bodyIndent).AppendLine("}"); + } + + /// Appends the statements for a list of flattened properties, recursing into nested objects. + /// The statement builder. + /// The enclosing parameter, provider field, and collection-format context. + /// The flattened property descriptors at this nesting level. + /// The access expression, parent key, delimiter, local suffix and indentation. + /// The shared emission locals and helper state. + private static void AppendObjectPropertyList( + PooledStringBuilder sb, + in QueryObjectContext context, + ImmutableEquatableArray properties, + in ObjectFlattenScope scope, + in InlineValueEmission emission) + { + foreach (var property in properties) + { + var valueLocal = emission.QueryValueLocal + scope.LocalSuffix + "_" + property.ClrName; + var keyExpression = scope.ParentKeyExpr is { } parentKey + ? BuildNestedKeyExpression(property, parentKey, scope.Delimiter, emission) + : BuildQueryObjectKeyExpression(property, emission); + var site = new QueryPropertySite(valueLocal, keyExpression, context.PreEncoded, scope.Indentation + " "); + + if (property.Nested is { } children) + { + AppendNestedObjectProperty(sb, context, property, children, site, scope, emission); + } + else + { + AppendObjectLeafProperty(sb, context, property, site, scope, emission); + } + } + } + + /// Appends the statements emitting one non-nested flattened query-object property. + /// The statement builder. + /// The enclosing parameter, provider field, and collection-format context. + /// The flattened property descriptor. + /// The generated locals and indentation for this property. + /// The access expression and indentation for this nesting level. + /// The shared emission locals and helper state. + private static void AppendObjectLeafProperty( + PooledStringBuilder sb, + in QueryObjectContext context, + QueryObjectPropertyModel property, + in QueryPropertySite site, + in ObjectFlattenScope scope, + in InlineValueEmission emission) + { + _ = sb.Append(scope.Indentation).AppendLine("{") + .Append(site.Indentation).Append("var ").Append(site.ValueLocal).Append(" = ").Append(scope.AccessExpr) + .Append('.').Append(property.ClrName).AppendLine(";"); + + if (property.Dictionary is { } dictionary) + { + AppendObjectDictionaryProperty(sb, context, property, dictionary, site, scope, emission); + } + else if (property.Collection is { } collection) + { + AppendObjectQueryCollectionProperty(sb, context, property, collection, site, emission); + } + else if (property.CanBeNull) + { + AppendNullableObjectQueryProperty(sb, context.Parameter, property, site, context.ProviderField, emission); + } + else + { + AppendObjectQueryPropertyValue(sb, context.Parameter, property, site, context.ProviderField, emission); + } + + _ = sb.Append(scope.Indentation).AppendLine("}"); + } + + /// Appends the statements expanding a dictionary property's entries under this property's key. + /// The statement builder. + /// The enclosing parameter, provider field, and pre-encoded context. + /// The dictionary property descriptor. + /// The dictionary key metadata. + /// The generated value local, composed key expression, and indentation for this property. + /// The nesting scope, supplying the key delimiter. + /// The shared emission locals and helper state. + private static void AppendObjectDictionaryProperty( + PooledStringBuilder sb, + in QueryObjectContext context, + QueryObjectPropertyModel property, + QueryDictionaryModel dictionary, + in QueryPropertySite site, + in ObjectFlattenScope scope, + in InlineValueEmission emission) + { + var indent = site.Indentation; + var entryLocal = site.ValueLocal + "_entry"; + var entryValueLocal = site.ValueLocal + ValueLocalSuffix; + var entryKeyLocal = site.ValueLocal + "_entrykey"; + + var loopIndent = indent; + if (property.CanBeNull) + { + _ = sb.Append(indent).Append("if (").Append(site.ValueLocal).AppendLine(NotNullCheckSuffix) + .Append(indent).AppendLine("{"); + loopIndent = indent + " "; + } + + _ = sb.Append(loopIndent).Append(ForeachVarKeyword).Append(entryLocal).Append(" in ").Append(site.ValueLocal).AppendLine(")") + .Append(loopIndent).AppendLine("{"); + + var entryIndent = loopIndent + " "; + _ = sb.Append(entryIndent).Append("var ").Append(entryValueLocal).Append(" = ").Append(entryLocal).AppendLine(".Value;"); + + var valueIndent = entryIndent; + if (dictionary.ValueCanBeNull) + { + _ = sb.Append(entryIndent).Append("if (").Append(entryValueLocal).AppendLine(NotNullCheckSuffix) + .Append(entryIndent).AppendLine("{"); + valueIndent = entryIndent + " "; + } + + var (entryKeyExpression, valueExpression) = BuildDictionaryEntryExpressions(entryLocal, entryValueLocal, dictionary, property, context, emission); + + // The entry key composes under this property's key: "propertyKey" + delimiter + entryKey, matching the + // reflection builder's nested BuildQueryMap. A blank entry key drops the pair, exactly as reflection does. + _ = sb.Append(valueIndent).Append("var ").Append(entryKeyLocal).Append(" = ").Append(entryKeyExpression).AppendLine(";") + .Append(valueIndent).Append("if (!string.IsNullOrWhiteSpace(").Append(entryKeyLocal).AppendLine("))") + .Append(valueIndent).AppendLine("{") + .Append(valueIndent).Append(" ").Append(emission.QueryBuilderLocal) + .Append(AddQueryPairCall).Append(site.KeyExpression).Append(" + ").Append(ToCSharpStringLiteral(scope.Delimiter)) + .Append(" + ").Append(entryKeyLocal).Append(", ").Append(valueExpression).Append(", ") + .Append(context.PreEncoded).AppendLine(");") + .Append(valueIndent).AppendLine("}"); + + if (dictionary.ValueCanBeNull) + { + _ = sb.Append(entryIndent).AppendLine("}"); + } + + _ = sb.Append(loopIndent).AppendLine("}"); + + if (!property.CanBeNull) + { + return; + } + + _ = sb.Append(indent).AppendLine("}"); + } + + /// Builds the formatted entry-key and value expressions for one dictionary property entry. + /// The local holding the current key/value pair. + /// The local holding the entry value. + /// The dictionary key metadata. + /// The dictionary property descriptor. + /// The enclosing parameter and provider-field context. + /// The shared emission locals and helper state. + /// The generated entry-key and value formatting expressions. + private static (string EntryKeyExpression, string ValueExpression) BuildDictionaryEntryExpressions( + string entryLocal, + string entryValueLocal, + QueryDictionaryModel dictionary, + QueryObjectPropertyModel property, + in QueryObjectContext context, + in InlineValueEmission emission) + { + var keyTypeOf = $"typeof({dictionary.KeyTypeName})"; + var customKey = $"{emission.SettingsLocal}.UrlParameterFormatter.Format({entryLocal}.Key, {keyTypeOf}, {keyTypeOf})"; + var fastKey = BuildFastFormatExpression(entryLocal + ".Key", dictionary.KeyFormat, emission); + var entryKeyExpression = fastKey is null + ? customKey + : $"{emission.UseDefaultFormattingLocal} ? ({fastKey}) : {customKey}"; + + var customValue = BuildUrlFormatterCall(entryValueLocal, property.ValueFormat.TypeName, context.ProviderField, emission); + var fastValue = BuildFastFormatExpression(entryValueLocal, property.ValueFormat, emission); + var valueExpression = fastValue is null + ? customValue + : $"{emission.UseDefaultFormattingLocal} ? ({fastValue}) : {customValue}"; + return (entryKeyExpression, valueExpression); + } + + /// Appends the statements flattening one nested-object property, recursing into its children. + /// The statement builder. + /// The enclosing parameter, provider field, and collection-format context. + /// The nested property descriptor. + /// The nested property's own flattened properties. + /// The generated value local and composed key expression for this property. + /// The access expression, delimiter, local suffix and indentation for this level. + /// The shared emission locals and helper state. + private static void AppendNestedObjectProperty( + PooledStringBuilder sb, + in QueryObjectContext context, + QueryObjectPropertyModel property, + ImmutableEquatableArray children, + in QueryPropertySite site, + in ObjectFlattenScope scope, + in InlineValueEmission emission) + { + var indent = scope.Indentation; + var innerIndent = indent + " "; + var keyLocal = site.ValueLocal + "_key"; + var childSuffix = scope.LocalSuffix + "_" + property.ClrName; + + _ = sb.Append(indent).AppendLine("{") + .Append(innerIndent).Append("var ").Append(site.ValueLocal).Append(" = ").Append(scope.AccessExpr) + .Append('.').Append(property.ClrName).AppendLine(";") + .Append(innerIndent).Append("var ").Append(keyLocal).Append(" = ").Append(site.KeyExpression).AppendLine(";"); + + // A nullable value-type nested object holds its underlying struct behind .Value; a reference type flattens off + // the value directly. The null check above still runs against the value itself. + var childAccess = property.NestedThroughValue ? site.ValueLocal + NullableValueAccess : site.ValueLocal; + + if (!property.CanBeNull) + { + AppendObjectPropertyList(sb, context, children, new(childAccess, keyLocal, scope.Delimiter, childSuffix, innerIndent), emission); + _ = sb.Append(indent).AppendLine("}"); + return; + } + + // A null nested object is omitted, unless [Query(SerializeNull = true)] emits a bare key=. + if (property.SerializeNull) + { + _ = sb.Append(innerIndent).Append("if (").Append(site.ValueLocal).AppendLine(NullEqualityCheckSuffix) + .Append(innerIndent).AppendLine("{") + .Append(innerIndent).Append(" ").Append(emission.QueryBuilderLocal) + .Append(AddQueryPairCall).Append(keyLocal).Append(EmptyValueArgument).Append(site.PreEncoded).AppendLine(");") + .Append(innerIndent).AppendLine("}") + .Append(innerIndent).AppendLine("else") + .Append(innerIndent).AppendLine("{"); + AppendObjectPropertyList(sb, context, children, new(childAccess, keyLocal, scope.Delimiter, childSuffix, innerIndent + " "), emission); + _ = sb.Append(innerIndent).AppendLine("}").Append(indent).AppendLine("}"); + return; + } + + _ = sb.Append(innerIndent).Append("if (").Append(site.ValueLocal).AppendLine(NotNullCheckSuffix) + .Append(innerIndent).AppendLine("{"); + AppendObjectPropertyList(sb, context, children, new(childAccess, keyLocal, scope.Delimiter, childSuffix, innerIndent + " "), emission); + _ = sb.Append(innerIndent).AppendLine("}").Append(indent).AppendLine("}"); + } + + /// Builds the composed key expression for a nested property under a parent key. + /// The nested-level property. + /// The runtime key expression (a local) of the enclosing object. + /// The nesting delimiter. + /// The shared emission locals and helper state. + /// The composed key expression: parent key, delimiter, this property's own prefix, then its name. + private static string BuildNestedKeyExpression( + QueryObjectPropertyModel property, + string parentKeyExpr, + string delimiter, + in InlineValueEmission emission) + { + var prefixExpr = $"{parentKeyExpr} + {ToCSharpStringLiteral(delimiter + (property.PrefixSegment ?? string.Empty))}"; + + // An [AliasAs] name always wins and bypasses the key formatter. + if (property.ExplicitName is { } alias) + { + return $"{prefixExpr} + {ToCSharpStringLiteral(alias)}"; + } + + var formatterCall = + $"global::Refit.GeneratedRequestRunner.BuildQueryKey({emission.SettingsLocal}, {ToCSharpStringLiteral(property.ClrName)}, null, {prefixExpr})"; + + // A [JsonPropertyName] name is honored only when the runtime setting is enabled. + return property.SerializerName is { } serializerName + ? $"{emission.SettingsLocal}.{HonorSerializerNamesFlag} ? ({prefixExpr} + {ToCSharpStringLiteral(serializerName)}) : ({formatterCall})" + : formatterCall; + } + + /// Appends the statements flattening one collection-valued query-object property. + /// The statement builder. + /// The enclosing parameter, provider field, and collection-format context. + /// The flattened property descriptor. + /// The collection descriptor. + /// The generated locals and indentation for this property. + /// The shared emission locals and helper state. + /// + /// The pristine default formatter renders elements inline through the query builder's collection API, where the + /// reflection builder's second formatting pass is a no-op. A customized formatter takes the runtime slow path, + /// which reproduces both passes. A null collection is omitted unless [Query(SerializeNull = true)] emits a + /// bare key=, matching the reflection builder. + /// + private static void AppendObjectQueryCollectionProperty( + PooledStringBuilder sb, + in QueryObjectContext context, + QueryObjectPropertyModel property, + QueryObjectCollectionModel collection, + in QueryPropertySite site, + in InlineValueEmission emission) + { + var indent = site.Indentation; + var keyLocal = site.ValueLocal + "_key"; + _ = sb.Append(indent).Append("var ").Append(keyLocal).Append(" = ").Append(site.KeyExpression).AppendLine(";"); + + var bodySite = site with { KeyExpression = keyLocal }; + if (!property.CanBeNull) + { + AppendCollectionPropertyBody(sb, context, property, collection, bodySite, emission); + return; + } + + if (property.SerializeNull) + { + _ = sb.Append(indent).Append("if (").Append(site.ValueLocal).AppendLine(NullEqualityCheckSuffix) + .Append(indent).AppendLine("{") + .Append(indent).Append(" ").Append(emission.QueryBuilderLocal) + .Append(AddQueryPairCall).Append(keyLocal).Append(EmptyValueArgument).Append(site.PreEncoded).AppendLine(");") + .Append(indent).AppendLine("}") + .Append(indent).AppendLine("else") + .Append(indent).AppendLine("{"); + AppendCollectionPropertyBody(sb, context, property, collection, bodySite with { Indentation = indent + " " }, emission); + _ = sb.Append(indent).AppendLine("}"); + return; + } + + _ = sb.Append(indent).Append("if (").Append(site.ValueLocal).AppendLine(NotNullCheckSuffix) + .Append(indent).AppendLine("{"); + AppendCollectionPropertyBody(sb, context, property, collection, bodySite with { Indentation = indent + " " }, emission); + _ = sb.Append(indent).AppendLine("}"); + } + + /// Appends the fast/slow collection-append body for a non-null collection property. + /// The statement builder. + /// The enclosing parameter, provider field, and collection-format context. + /// The flattened property descriptor. + /// The collection descriptor. + /// The generated locals and indentation, with KeyExpression set to the key local and + /// Indentation set to the body indentation. + /// The shared emission locals and helper state. + private static void AppendCollectionPropertyBody( + PooledStringBuilder sb, + in QueryObjectContext context, + QueryObjectPropertyModel property, + QueryObjectCollectionModel collection, + in QueryPropertySite site, + in InlineValueEmission emission) + { + var indent = site.Indentation; + var formatExpression = BuildCollectionFormatExpression(collection.CollectionFormatValue, context.ParameterCollectionFormat, emission); + var elementLocal = site.ValueLocal + "_e"; + var elementExpression = BuildFastCollectionElementExpression(elementLocal, property.ValueFormat, collection, emission); + var innerIndent = indent + " "; + + _ = sb.Append(indent).Append("if (").Append(emission.UseDefaultFormattingLocal).AppendLine(")") + .Append(indent).AppendLine("{") + .Append(innerIndent).Append(emission.QueryBuilderLocal).Append(".BeginCollection(").Append(site.KeyExpression) + .Append(", ").Append(formatExpression).Append(", ").Append(site.PreEncoded).AppendLine(");") + .Append(innerIndent).Append(ForeachVarKeyword).Append(elementLocal).Append(" in ").Append(site.ValueLocal).AppendLine(")") + .Append(innerIndent).AppendLine("{") + .Append(innerIndent).Append(" ").Append(emission.QueryBuilderLocal).Append(AddCollectionValueCall).Append(elementExpression).AppendLine(");") + .Append(innerIndent).AppendLine("}") + .Append(innerIndent).Append(emission.QueryBuilderLocal).AppendLine(".EndCollection();") + .Append(indent).AppendLine("}") + .Append(indent).AppendLine("else") + .Append(indent).AppendLine("{") + .Append(innerIndent).Append("global::Refit.GeneratedRequestRunner.AddFormattedCollectionProperty(ref ") + .Append(emission.QueryBuilderLocal).Append(", ").Append(emission.SettingsLocal).Append(", ").Append(site.ValueLocal) + .Append(", ").Append(site.KeyExpression).Append(", ").Append(formatExpression).Append(", ").Append(site.PreEncoded) + .Append(", (typeof(").Append(collection.PropertyTypeName).Append("), ").Append(context.ProviderField) + .Append(", typeof(").Append(context.Parameter.Type).AppendLine(")));") + .Append(indent).AppendLine("}"); + } + + /// Builds the resolved CollectionFormat expression for a collection property. + /// The property's own [Query(CollectionFormat)], or null. + /// The enclosing parameter's [Query(CollectionFormat)], or null. + /// The shared emission locals and helper state. + /// A compile-time cast literal, or the runtime settings default. + private static string BuildCollectionFormatExpression( + int? propertyCollectionFormat, + int? parameterCollectionFormat, + in InlineValueEmission emission) => + (propertyCollectionFormat ?? parameterCollectionFormat) is { } value + ? $"{CollectionFormatCast}{value}" + : $"{emission.SettingsLocal}.CollectionFormat"; + + /// Builds the inline expression rendering one collection element on the default-formatting fast path. + /// The foreach element local. + /// The element rendering strategy. + /// The collection descriptor. + /// The shared emission locals and helper state. + /// The reflection-free element expression, or a formatter call for an element with no inline rendering. + private static string BuildFastCollectionElementExpression( + string elementLocal, + InlineValueFormatModel elementFormat, + QueryObjectCollectionModel collection, + in InlineValueEmission emission) + { + var fast = BuildFastFormatExpression(elementLocal, elementFormat, emission); + if (fast is null) + { + // An element with no reflection-free rendering (e.g. an enum with duplicate constants) still uses the + // formatter, which under the pristine default renders it correctly; the type doubles as the provider. + return $"{emission.SettingsLocal}.UrlParameterFormatter.Format({elementLocal}, typeof({collection.PropertyTypeName}), typeof({collection.PropertyTypeName}))"; + } + + // A string element renders itself, so a null element already renders as null; other formats guard first. + return collection.ElementCanBeNull && fast != elementLocal + ? $"{elementLocal} == null ? null : {fast}" + : fast; + } + + /// Appends the null-guarded statements for a flattened property whose value may be null. + /// The statement builder. + /// The enclosing parameter model. + /// The flattened property descriptor. + /// The generated locals and indentation for this property. + /// The cached attribute-provider field name. + /// The shared emission locals and helper state. + private static void AppendNullableObjectQueryProperty( + PooledStringBuilder sb, + RequestParameterModel parameter, + QueryObjectPropertyModel property, + in QueryPropertySite site, + string providerField, + in InlineValueEmission emission) + { + var indent = site.Indentation; + + // A null property is omitted entirely unless it opts in via [Query(SerializeNull = true)], which emits "key=". + if (!property.SerializeNull) + { + _ = sb.Append(indent).Append("if (").Append(site.ValueLocal).AppendLine(NotNullCheckSuffix) + .Append(indent).AppendLine("{"); + AppendObjectQueryPropertyValue(sb, parameter, property, site with { Indentation = indent + " " }, providerField, emission); + _ = sb.Append(indent).AppendLine("}"); + return; + } + + _ = sb.Append(indent).Append("if (").Append(site.ValueLocal).AppendLine(NullEqualityCheckSuffix) + .Append(indent).AppendLine("{") + .Append(indent).Append(" ").Append(emission.QueryBuilderLocal) + .Append(AddQueryPairCall).Append(site.KeyExpression).Append(EmptyValueArgument) + .Append(site.PreEncoded).AppendLine(");") + .Append(indent).AppendLine("}") + .Append(indent).AppendLine("else") + .Append(indent).AppendLine("{"); + + AppendObjectQueryPropertyValue(sb, parameter, property, site with { Indentation = indent + " " }, providerField, emission); + + _ = sb.Append(indent).AppendLine("}"); + } + + /// Appends the statements rendering one non-null flattened property value. + /// The statement builder. + /// The enclosing parameter model. + /// The flattened property descriptor. + /// The generated locals and indentation for this property. + /// The cached attribute-provider field name. + /// The shared emission locals and helper state. + private static void AppendObjectQueryPropertyValue( + PooledStringBuilder sb, + RequestParameterModel parameter, + QueryObjectPropertyModel property, + in QueryPropertySite site, + string providerField, + in InlineValueEmission emission) + { + var fastExpression = BuildFastFormatExpression(site.ValueLocal, property.ValueFormat, emission); + if (property.PropertyFormat is null) + { + AppendUnformattedObjectQueryProperty(sb, parameter, site, providerField, emission, fastExpression); + return; + } + + AppendFormattedObjectQueryProperty(sb, parameter, property, site, providerField, emission, fastExpression); + } + + /// Appends the append call for a flattened property with no property-level format. + /// The statement builder. + /// The enclosing parameter model. + /// The generated locals and indentation for this property. + /// The cached attribute-provider field name. + /// The shared emission locals and helper state. + /// The reflection-free expression, or null when the formatter must always run. + private static void AppendUnformattedObjectQueryProperty( + PooledStringBuilder sb, + RequestParameterModel parameter, + in QueryPropertySite site, + string providerField, + in InlineValueEmission emission, + string? fastExpression) + { + var customExpression = BuildUrlFormatterCall(site.ValueLocal, parameter.Type, providerField, emission); + var valueExpression = fastExpression is null + ? customExpression + : $"{emission.UseDefaultFormattingLocal} ? ({fastExpression}) : {customExpression}"; + + _ = sb.Append(site.Indentation).Append(emission.QueryBuilderLocal) + .Append(AddQueryPairCall).Append(site.KeyExpression).Append(", ").Append(valueExpression).Append(", ") + .Append(site.PreEncoded).AppendLine(");"); + } + + /// Appends the statements for a flattened property carrying a [Query(Format)]. + /// The statement builder. + /// The enclosing parameter model. + /// The flattened property descriptor. + /// The generated locals and indentation for this property. + /// The cached attribute-provider field name. + /// The shared emission locals and helper state. + /// The reflection-free expression, or null when the formatter must always run. + private static void AppendFormattedObjectQueryProperty( + PooledStringBuilder sb, + RequestParameterModel parameter, + QueryObjectPropertyModel property, + in QueryPropertySite site, + string providerField, + in InlineValueEmission emission, + string? fastExpression) + { + // The form-url-encoded formatter applies the property format; a null result omits the pair entirely, and only + // the surviving string reaches the URL parameter formatter. + var indent = site.Indentation; + var formattedLocal = site.ValueLocal + "_formatted"; + var formatLiteral = ToNullableCSharpStringLiteral(property.PropertyFormat); + var formExpression = + $"{emission.SettingsLocal}.FormUrlEncodedParameterFormatter.Format({site.ValueLocal}, {formatLiteral})"; + var formattedExpression = fastExpression is null + ? formExpression + : $"{emission.UseDefaultFormFormattingLocal} ? ({fastExpression}) : {formExpression}"; + var customExpression = BuildUrlFormatterCall(formattedLocal, parameter.Type, providerField, emission); + + _ = sb.Append(indent).Append("var ").Append(formattedLocal).Append(" = ").Append(formattedExpression).AppendLine(";") + .Append(indent).Append("if (").Append(formattedLocal).AppendLine(NotNullCheckSuffix) + .Append(indent).AppendLine("{") + .Append(indent).Append(" ").Append(emission.QueryBuilderLocal) + .Append(AddQueryPairCall).Append(site.KeyExpression).Append(", ") + .Append(emission.UseDefaultFormattingLocal).Append(" ? ").Append(formattedLocal) + .Append(" : ").Append(customExpression) + .Append(", ").Append(site.PreEncoded).AppendLine(");") + .Append(indent).AppendLine("}"); + } + + /// Builds a call to the configured URL parameter formatter for a flattened property value. + /// The value expression to format. + /// The enclosing parameter's declared type. + /// The cached attribute-provider field name. + /// The shared emission locals and helper state. + /// The formatter call expression. + private static string BuildUrlFormatterCall( + string valueExpression, + string parameterTypeName, + string providerField, + in InlineValueEmission emission) => + $"{emission.SettingsLocal}.UrlParameterFormatter.Format({valueExpression}, {providerField}, typeof({parameterTypeName}))"; + + /// Builds the query key expression for one flattened property. + /// The flattened property descriptor. + /// The shared emission locals and helper state. + /// A constant literal when the key is fully known, otherwise a helper call or a settings-gated choice. + private static string BuildQueryObjectKeyExpression(QueryObjectPropertyModel property, in InlineValueEmission emission) + { + // An [AliasAs] name always wins and bypasses the key formatter, so prefix + alias is fully known at compile time. + if (property.ExplicitName is { } explicitName) + { + return ToCSharpStringLiteral(property.PrefixSegment + explicitName); + } + + // A [JsonPropertyName] name is honored only when the runtime setting is enabled; otherwise the CLR name goes + // through the key formatter, so both keys are emitted and the setting selects between them at runtime. + if (property.SerializerName is { } serializerName) + { + var serializerLiteral = ToCSharpStringLiteral(property.PrefixSegment + serializerName); + return $"{emission.SettingsLocal}.{HonorSerializerNamesFlag} ? {serializerLiteral} : {BuildQueryKeyHelperCall(property, emission)}"; + } + + return BuildQueryKeyHelperCall(property, emission); + } + + /// Builds the runtime key-composition call for a flattened property with no alias. + /// The flattened property descriptor. + /// The shared emission locals and helper state. + /// The BuildQueryKey call expression. + private static string BuildQueryKeyHelperCall(QueryObjectPropertyModel property, in InlineValueEmission emission) + { + var clrName = ToCSharpStringLiteral(property.ClrName); + var prefix = ToNullableCSharpStringLiteral(property.PrefixSegment); + return $"global::Refit.GeneratedRequestRunner.BuildQueryKey({emission.SettingsLocal}, {clrName}, null, {prefix})"; + } +} diff --git a/src/InterfaceStubGenerator.Shared/Emitter.Inline.Query.Values.cs b/src/InterfaceStubGenerator.Shared/Emitter.Inline.Query.Values.cs new file mode 100644 index 000000000..18eec1e27 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Emitter.Inline.Query.Values.cs @@ -0,0 +1,180 @@ +// 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; + +/// Emits inline request-construction source for generated Refit method implementations. +/// Emits the value-formatting expressions for inline query and path parameters. +internal static partial class Emitter +{ + /// Builds the expression that formats one bound value. + /// The value expression. + /// Whether the value may still be null when this expression runs. The + /// fast path renders null as null (omitting the value) while the custom formatter always receives the value, + /// matching the reflection builder's contract for null collection elements and path values. + /// The declared parameter type passed to the custom formatter. + /// The query-binding metadata. + /// The cached attribute-provider field name. + /// The shared emission locals and helper state. + /// The generated formatting expression. + private static string BuildFormattedValueExpression( + string valueExpression, + bool canBeNullAtEvaluation, + string parameterTypeName, + QueryParameterModel query, + string providerField, + in InlineValueEmission emission) + { + // TreatAsString stringifies the raw value before the formatter runs, mirroring the reflection builder. + var customValue = query.TreatAsString ? valueExpression + ToStringCall : valueExpression; + var customExpression = + $"{emission.SettingsLocal}.UrlParameterFormatter.Format({customValue}, {providerField}, typeof({parameterTypeName}))"; + + var fastExpression = query.TreatAsString + ? valueExpression + ToStringCall + : BuildFastFormatExpression(valueExpression, query.ValueFormat, emission); + if (fastExpression is null) + { + return customExpression; + } + + // When the fast path is the value itself (strings), a null value already renders as null. + if (canBeNullAtEvaluation && fastExpression != valueExpression) + { + fastExpression = $"{valueExpression} == null ? null : {fastExpression}"; + } + + return $"{emission.UseDefaultFormattingLocal} ? ({fastExpression}) : {customExpression}"; + } + + /// Builds the expression that formats one path parameter value. + /// The path parameter model. + /// The cached attribute-provider field name. + /// The shared emission locals and helper state. + /// The generated formatting expression. + private static string BuildPathValueExpression( + RequestParameterModel parameter, + string providerField, + in InlineValueEmission emission) + { + if (parameter.IsRoundTrip) + { + // A {**param} catch-all: split the value on '/', format and escape each segment, keep the separators. + var roundTripValue = parameter.CanBeNull ? $"@{parameter.Name}?.ToString()" : $"@{parameter.Name}.ToString()"; + return $"global::Refit.GeneratedRequestRunner.RoundTripEscapePath({roundTripValue}, {emission.SettingsLocal}.UrlParameterFormatter, {providerField}, typeof({parameter.Type}))"; + } + + return BuildPathValueExpressionCore( + "@" + parameter.Name, + parameter.Type, + parameter.ValueFormat, + parameter.CanBeNull, + providerField, + emission); + } + + /// Builds the formatted path value expression for a value accessor, choosing the fast or formatter path. + /// The C# expression yielding the value (for example @param or @param.Prop). + /// The value's declared type, passed to the URL parameter formatter. + /// The reflection-free rendering strategy, or null to always use the formatter. + /// Whether the value requires a null guard before the fast path formats it. + /// The cached attribute-provider field name. + /// The shared emission locals and helper state. + /// The path value expression. + private static string BuildPathValueExpressionCore( + string valueAccessor, + string typeName, + InlineValueFormatModel? valueFormat, + bool canBeNull, + string providerField, + in InlineValueEmission emission) + { + var customExpression = + $"{emission.SettingsLocal}.UrlParameterFormatter.Format({valueAccessor}, {providerField}, typeof({typeName}))"; + var fastExpression = ComputeFastPathExpression(valueAccessor, valueFormat, emission); + if (fastExpression is null) + { + return customExpression; + } + + // When the fast path is the value itself (strings), a null value already renders as null. + if (canBeNull && fastExpression != valueAccessor) + { + fastExpression = $"{valueAccessor} == null ? null : {fastExpression}"; + } + + return $"{emission.UseDefaultFormattingLocal} ? ({fastExpression}) : {customExpression}"; + } + + /// Builds the reflection-free fast-path expression for a value, or null to always use the formatter. + /// The C# expression yielding the value. + /// The reflection-free rendering strategy, or null to always use the formatter. + /// The shared emission locals and helper state. + /// The fast-path expression, or null when no fast path applies. + /// Path bindings always carry a rendering strategy, so the absent-format arm is only reachable for a path + /// parameter without one, which the shared fixtures never present. + [ExcludeFromCodeCoverage] + private static string? ComputeFastPathExpression( + string valueAccessor, + InlineValueFormatModel? valueFormat, + in InlineValueEmission emission) => + valueFormat is null ? null : BuildFastFormatExpression(valueAccessor, valueFormat, emission); + + /// Determines whether a scalar query value renders straight into the query builder as an + /// ISpanFormattable, skipping the per-value intermediate string. + /// The query-binding metadata. + /// The compile-time format passed to AddFormatted, or null. + /// when the value has a span-formattable fast write on the consumer target. + /// Reuses the shared span-formattable tiers computed by the parser: an unformatted URL-safe integer (net6+) + /// or any span-escapable value (net9+). A TreatAsString value stringifies first and stays on the string path. + private static bool IsSpanFormattableFast(QueryParameterModel query, out string? format) + { + var valueFormat = query.ValueFormat; + format = valueFormat.Format; + return !query.TreatAsString + && valueFormat.Kind == InlineFormatKind.Formattable + && (valueFormat.IsUrlSafeSpanFormattable || valueFormat.IsSpanFormattableEscapable); + } + + /// Determines whether a collection element renders straight into the query builder as an + /// ISpanFormattable, skipping the per-element intermediate string. + /// The query-binding metadata. + /// when each element has an unformatted span-formattable fast write on the target. + /// AddCollectionValueFormatted takes no format, so a per-element [Query(Format)] keeps the + /// string-formatted path; only unformatted span-formattable elements qualify. + private static bool IsCollectionSpanFormattableFast(QueryParameterModel query) + { + var valueFormat = query.ValueFormat; + return !query.TreatAsString + && valueFormat.Kind == InlineFormatKind.Formattable + && valueFormat.Format is null + && !valueFormat.IsNullableValueType + && (valueFormat.IsUrlSafeSpanFormattable || valueFormat.IsSpanFormattableEscapable); + } + + /// Builds the reflection-free fast-path expression for one non-null value. + /// The value expression, evaluated only when non-null. + /// The rendering strategy. + /// The shared emission locals and helper state. + /// The fast-path expression, or when the formatter must always run. + private static string? BuildFastFormatExpression( + string valueExpression, + InlineValueFormatModel valueFormat, + in InlineValueEmission emission) + { + var unwrapped = valueFormat.IsNullableValueType ? valueExpression + NullableValueAccess : valueExpression; + return valueFormat.Kind switch + { + InlineFormatKind.String => unwrapped, + InlineFormatKind.ToStringOnly => unwrapped + ToStringCall, + InlineFormatKind.Formattable => + $"global::Refit.GeneratedRequestRunner.FormatInvariant({unwrapped}, {ToNullableCSharpStringLiteral(valueFormat.Format)})", + InlineFormatKind.Enum => + $"{GetOrAddEnumFormatter(valueFormat, emission)}({unwrapped})", + _ => null + }; + } +} diff --git a/src/InterfaceStubGenerator.Shared/Emitter.Inline.Query.cs b/src/InterfaceStubGenerator.Shared/Emitter.Inline.Query.cs new file mode 100644 index 000000000..07827c2d0 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Emitter.Inline.Query.cs @@ -0,0 +1,633 @@ +// 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; + +/// Emits inline request-construction source for generated Refit method implementations. +/// Query string and value-formatting emission for the inline path. +internal static partial class Emitter +{ + /// The base member name for a generated enum formatting helper. + private const string EnumFormatterBaseName = "______FormatEnumValue"; + + /// The base member name for a cached query-converter instance field. + private const string ConverterFieldBaseName = "______queryConverter"; + + /// The tail of a generated non-null guard, closing the if ( opened by the caller. + private const string NotNullCheckSuffix = " != null)"; + + /// The head of a generated guard on a method parameter, whose name is escaped with @. + private const string IfParameterPrefix = "if (@"; + + /// The generated call appending one query pair to the query-string builder. + private const string AddQueryPairCall = ".Add("; + + /// The suffix appended to a generated local holding a dictionary entry or nested property value. + private const string ValueLocalSuffix = "_value"; + + /// The member access unwrapping a nullable value type to its underlying value after a null guard. + private const string NullableValueAccess = ".Value"; + + /// The generated call appending one collection element to the query-string builder. + private const string AddCollectionValueCall = ".AddCollectionValue("; + + /// The start of a generated foreach (var …) loop over a collection or dictionary. + private const string ForeachVarKeyword = "foreach (var "; + + /// The generated ToString() call appended to a value expression. + private const string ToStringCall = ".ToString()"; + + /// The tail of a generated if (value == null) guard. + private const string NullEqualityCheckSuffix = " == null)"; + + /// The generated argument list fragment that adds an empty serialized value. + private const string EmptyValueArgument = ", string.Empty, "; + + /// The RefitSettings property gating serializer-aware query key naming. + private const string HonorSerializerNamesFlag = "HonorContentSerializerPropertyNamesInQuery"; + + /// Determines whether any parameter binds to the query string. + /// The parsed request model. + /// when query emission is required. + private static bool HasQueryBindings(RequestModel request) + { + foreach (var parameter in request.Parameters) + { + if (parameter.Query is not null) + { + return true; + } + } + + return false; + } + + /// Determines whether the generated method needs the default-formatting branch local. + /// The parsed request model. + /// when at least one bound value has a reflection-free fast path. + private static bool NeedsFormattingLocal(RequestModel request) + { + foreach (var parameter in request.Parameters) + { + if (parameter.Kind == RequestParameterKind.Path && PathParameterNeedsFormattingLocal(parameter)) + { + return true; + } + + if (parameter.Query is not { } query) + { + continue; + } + + // A flattened object's own ValueFormat is unused; each property carries its own rendering strategy. + var needsLocal = query switch + { + // A converter formats its own values, so the generated method needs no default-formatting branch. + { Converter: not null } => false, + { ObjectProperties: { } properties } => ObjectPropertiesNeedFormattingLocal(properties), + { Dictionary: { } dictionary } => + dictionary.KeyFormat.Kind != InlineFormatKind.FormatterOnly + || query.ValueFormat.Kind != InlineFormatKind.FormatterOnly, + _ => query.TreatAsString || query.ValueFormat.Kind != InlineFormatKind.FormatterOnly + }; + if (needsLocal) + { + return true; + } + } + + return false; + } + + /// Determines whether any flattened property branches on the default-formatting local. + /// The flattened property descriptors. + /// when at least one property has a reflection-free fast path or a format. + private static bool ObjectPropertiesNeedFormattingLocal(ImmutableEquatableArray properties) + { + foreach (var property in properties) + { + if (property.Nested is { } nested) + { + if (ObjectPropertiesNeedFormattingLocal(nested)) + { + return true; + } + + continue; + } + + // A collection property branches on the local to pick the inline or double-pass rendering; a formatted or + // inline-renderable scalar (or a dictionary with an inline-renderable key) branches on the URL formatter. + if (property.Collection is not null + || property.PropertyFormat is not null + || property.ValueFormat.Kind != InlineFormatKind.FormatterOnly + || property.Dictionary is { KeyFormat.Kind: not InlineFormatKind.FormatterOnly }) + { + return true; + } + } + + return false; + } + + /// Determines whether a path parameter's value or any of its object bindings uses the fast-format branch. + /// The path parameter model. + /// when the default-formatting local is referenced when formatting the value. + private static bool PathParameterNeedsFormattingLocal(RequestParameterModel parameter) + { + if (parameter.ValueFormat is { Kind: not InlineFormatKind.FormatterOnly }) + { + return true; + } + + if (parameter.PathObjectBindings is not { } bindings) + { + return false; + } + + foreach (var binding in bindings) + { + if (binding.ValueFormat.Kind != InlineFormatKind.FormatterOnly) + { + return true; + } + } + + return false; + } + + /// Determines whether a parameter needs a generated attribute-provider field. + /// The parameter model. + /// when formatting may consult the parameter's attributes at runtime. + private static bool NeedsAttributeProvider(RequestParameterModel parameter) => + parameter.Kind == RequestParameterKind.Path + || parameter.Query is { Shape: not QueryParameterShape.Converter }; + + /// Determines whether the generated method needs the default-form-formatting branch local. + /// The parsed request model. + /// when a flattened query-object property carries a compile-time format. + private static bool NeedsFormFormattingLocal(RequestModel request) + { + foreach (var parameter in request.Parameters) + { + if (parameter.Query?.ObjectProperties is { } properties && ObjectPropertiesHaveFormat(properties)) + { + return true; + } + + // An unrolled form body reads its default-form-formatting branch when any field has a fast path. + if (parameter.Kind == RequestParameterKind.Body && FormBodyHasFastPath(parameter)) + { + return true; + } + } + + return false; + } + + /// Determines whether any flattened property (recursing into nested objects) carries a compile-time format. + /// The flattened property descriptors. + /// when at least one property carries a [Query(Format)]. + private static bool ObjectPropertiesHaveFormat(ImmutableEquatableArray properties) + { + foreach (var property in properties) + { + if (property.Nested is { } nested) + { + if (ObjectPropertiesHaveFormat(nested)) + { + return true; + } + + continue; + } + + if (property.PropertyFormat is not null) + { + return true; + } + } + + return false; + } + + /// Builds the query-appending statements for an inline generated method. + /// The parsed request model. + /// The map of parameter name to cached attribute-provider field name. + /// The shared emission locals and helper state. + /// The generated query statements. + private static string BuildInlineQueryStatements( + RequestModel request, + Dictionary parameterInfoNames, + in InlineValueEmission emission) + { + var sb = new PooledStringBuilder(); + foreach (var parameter in request.Parameters) + { + if (parameter.Query is not { } query) + { + continue; + } + + // A converter parameter needs no attribute provider (it formats its own values), so it may be absent. + _ = parameterInfoNames.TryGetValue(parameter.Name, out var providerField); + AppendInlineQueryStatement(sb, parameter, query, providerField, emission); + } + + return sb.ToString(); + } + + /// Appends the query-building statements for one parameter, dispatched on its query shape. + /// The statement builder. + /// The parsed request parameter. + /// The parameter's query-binding metadata. + /// The cached attribute-provider field name. + /// The shared emission locals and helper state. + /// The arms are exhaustive over the shapes the parser produces; the + /// compiler-required collection default arm cannot be reached for every shape value by tests. + [ExcludeFromCodeCoverage] + private static void AppendInlineQueryStatement( + PooledStringBuilder sb, + RequestParameterModel parameter, + QueryParameterModel query, + string providerField, + in InlineValueEmission emission) + { + switch (query.Shape) + { + case QueryParameterShape.Scalar or QueryParameterShape.Flag: + { + AppendScalarQueryStatement(sb, parameter, query, providerField, emission); + break; + } + + case QueryParameterShape.Object: + { + AppendObjectQueryStatements(sb, parameter, query, providerField, emission); + break; + } + + case QueryParameterShape.Dictionary: + { + AppendDictionaryQueryStatements(sb, parameter, query, providerField, emission); + break; + } + + case QueryParameterShape.Converter: + { + AppendConverterQueryStatements(sb, parameter, query, emission); + break; + } + + default: + { + AppendCollectionQueryStatement(sb, parameter, query, providerField, emission); + break; + } + } + } + + /// Appends the statement emitting one scalar query value or flag. + /// The statement builder. + /// The parameter model. + /// The query-binding metadata. + /// The cached attribute-provider field name. + /// The shared emission locals and helper state. + private static void AppendScalarQueryStatement( + PooledStringBuilder sb, + RequestParameterModel parameter, + QueryParameterModel query, + string providerField, + in InlineValueEmission emission) + { + var bodyIndent = Indent(MethodBodyIndentation); + + if (parameter.CanBeNull) + { + _ = sb.Append(bodyIndent).Append(IfParameterPrefix).Append(parameter.Name).AppendLine(NotNullCheckSuffix) + .Append(bodyIndent).AppendLine("{"); + AppendScalarAddCall(sb, parameter, query, providerField, bodyIndent + " ", emission); + _ = sb.Append(bodyIndent).AppendLine("}"); + return; + } + + AppendScalarAddCall(sb, parameter, query, providerField, bodyIndent, emission); + } + + /// Appends the query-builder add/addflag call for a scalar value, writing each piece straight into the + /// builder instead of composing an intermediate interpolated statement string. + /// The statement builder. + /// The parameter model. + /// The query-binding metadata. + /// The cached attribute-provider field name. + /// The indentation of the emitted statement. + /// The shared emission locals and helper state. + private static void AppendScalarAddCall( + PooledStringBuilder sb, + RequestParameterModel parameter, + QueryParameterModel query, + string providerField, + string indent, + in InlineValueEmission emission) + { + var preEncoded = ToLowerInvariantString(query.PreEncoded); + if (query.Shape == QueryParameterShape.Flag) + { + // Scalar values are guarded by the outer null check, so the value is never null when formatted. + var flagValue = BuildFormattedValueExpression("@" + parameter.Name, false, parameter.Type, query, providerField, emission); + _ = sb.Append(indent).Append(emission.QueryBuilderLocal).Append(".AddFlag(").Append(flagValue).Append(", ") + .Append(preEncoded).AppendLine(");"); + return; + } + + // The key is a compile-time constant, so it is escaped once here rather than on every call; the value keeps its + // per-call escaping, and the pre-escaped-key builder overloads append the key verbatim. + var key = BuildPreEscapedQueryKeyLiteral(query.Key, query.PreEncoded); + + // On the default-formatting branch a span-formattable value is rendered straight into the builder, skipping the + // per-value intermediate string; a customized formatter keeps the string-formatted Add. + if (IsSpanFormattableFast(query, out var format)) + { + // A nullable value type writes the unwrapped .Value (span-formattable) on the fast path - the outer null + // guard already ran - while the customized-formatter branch keeps the original value and declared type so + // it matches the reflection builder's UrlParameterFormatter.Format call exactly. + var accessor = "@" + parameter.Name; + var fastAccessor = query.ValueFormat.IsNullableValueType ? accessor + NullableValueAccess : accessor; + var customExpression = BuildUrlFormatterCall(accessor, parameter.Type, providerField, emission); + var innerIndent = indent + " "; + _ = sb.Append(indent).Append("if (").Append(emission.UseDefaultFormattingLocal).AppendLine(")") + .Append(indent).AppendLine("{") + .Append(innerIndent).Append(emission.QueryBuilderLocal).Append(".AddFormattedPreEscapedKey(").Append(key).Append(", ") + .Append(fastAccessor).Append(", ").Append(ToNullableCSharpStringLiteral(format)).Append(", ").Append(preEncoded).AppendLine(");") + .Append(indent).AppendLine("}") + .Append(indent).AppendLine("else") + .Append(indent).AppendLine("{") + .Append(innerIndent).Append(emission.QueryBuilderLocal).Append(".AddPreEscapedKey(").Append(key).Append(", ") + .Append(customExpression).Append(", ").Append(preEncoded).AppendLine(");") + .Append(indent).AppendLine("}"); + return; + } + + var valueExpression = BuildFormattedValueExpression("@" + parameter.Name, false, parameter.Type, query, providerField, emission); + _ = sb.Append(indent).Append(emission.QueryBuilderLocal).Append(".AddPreEscapedKey(").Append(key).Append(", ").Append(valueExpression) + .Append(", ").Append(preEncoded).AppendLine(");"); + } + + /// Builds the C# literal for a compile-time-constant query key, escaping it at generation time. + /// The constant query key. + /// Whether the key is caller-encoded and must be emitted verbatim. + /// The C# string literal, URI-data-escaped unless . + /// Escaping here rather than on every request matches the reflection builder's output because + /// Uri.EscapeDataString follows RFC 3986 consistently across the supported target frameworks. + private static string BuildPreEscapedQueryKeyLiteral(string key, bool preEncoded) => + ToCSharpStringLiteral(preEncoded ? key : System.Uri.EscapeDataString(key)); + + /// Appends the statements emitting one collection-valued query parameter or flag set. + /// The statement builder. + /// The parameter model. + /// The query-binding metadata. + /// The cached attribute-provider field name. + /// The shared emission locals and helper state. + private static void AppendCollectionQueryStatement( + PooledStringBuilder sb, + RequestParameterModel parameter, + QueryParameterModel query, + string providerField, + in InlineValueEmission emission) + { + var bodyIndent = Indent(MethodBodyIndentation); + var isFlag = query.Shape == QueryParameterShape.FlagCollection; + var preEncoded = ToLowerInvariantString(query.PreEncoded); + + // An unformatted span-formattable element renders straight into the builder on the default-formatting branch, + // skipping the per-element intermediate string; a per-element format keeps the string-formatted path because + // AddCollectionValueFormatted renders with the default format only. + var fast = !isFlag && IsCollectionSpanFormattableFast(query); + var guarded = parameter.CanBeNull; + var loopIndent = guarded ? bodyIndent + " " : bodyIndent; + + if (guarded) + { + _ = sb.Append(bodyIndent).Append(IfParameterPrefix).Append(parameter.Name).AppendLine(NotNullCheckSuffix) + .Append(bodyIndent).AppendLine("{"); + } + + if (!isFlag) + { + AppendBeginCollection(sb, query, emission, preEncoded, loopIndent); + } + + _ = sb.Append(loopIndent).Append(ForeachVarKeyword).Append(emission.QueryValueLocal).Append(" in @").Append(parameter.Name).AppendLine(")") + .Append(loopIndent).AppendLine("{"); + var itemIndent = loopIndent + " "; + if (fast) + { + AppendFastCollectionElement(sb, parameter, providerField, emission, itemIndent); + } + else + { + var elementExpression = BuildFormattedValueExpression( + emission.QueryValueLocal, + query.ElementCanBeNull, + parameter.Type, + query, + providerField, + emission); + _ = sb.Append(itemIndent).Append(emission.QueryBuilderLocal); + if (isFlag) + { + _ = sb.Append(".AddFlag(").Append(elementExpression).Append(", ").Append(preEncoded).AppendLine(");"); + } + else + { + _ = sb.Append(AddCollectionValueCall).Append(elementExpression).AppendLine(");"); + } + } + + _ = sb.Append(loopIndent).AppendLine("}"); + + if (!isFlag) + { + _ = sb.Append(loopIndent).Append(emission.QueryBuilderLocal).AppendLine(".EndCollection();"); + } + + if (!guarded) + { + return; + } + + _ = sb.Append(bodyIndent).AppendLine("}"); + } + + /// Appends the BeginCollection call opening a query collection. + /// The statement builder. + /// The query-binding metadata. + /// The shared emission locals and helper state. + /// The rendered preEncoded boolean literal. + /// The indentation of the emitted call. + private static void AppendBeginCollection( + PooledStringBuilder sb, + QueryParameterModel query, + in InlineValueEmission emission, + string preEncoded, + string loopIndent) + { + // Write the BeginCollection call straight into the builder instead of composing interpolated fragments. + _ = sb.Append(loopIndent).Append(emission.QueryBuilderLocal).Append(".BeginCollection(") + .Append(ToCSharpStringLiteral(query.Key)).Append(", "); + if (query.CollectionFormatValue is { } collectionFormatValue) + { + _ = sb.Append(CollectionFormatCast).Append(collectionFormatValue); + } + else + { + _ = sb.Append(emission.SettingsLocal).Append(".CollectionFormat"); + } + + _ = sb.Append(", ").Append(preEncoded).AppendLine(");"); + } + + /// Appends the default-formatting-guarded add for one span-formattable collection element. + /// The statement builder. + /// The parameter model. + /// The cached attribute-provider field name. + /// The shared emission locals and helper state. + /// The indentation of the emitted element statements. + private static void AppendFastCollectionElement( + PooledStringBuilder sb, + RequestParameterModel parameter, + string providerField, + in InlineValueEmission emission, + string itemIndent) + { + var customExpression = BuildUrlFormatterCall(emission.QueryValueLocal, parameter.Type, providerField, emission); + var innerIndent = itemIndent + " "; + _ = sb.Append(itemIndent).Append("if (").Append(emission.UseDefaultFormattingLocal).AppendLine(")") + .Append(itemIndent).AppendLine("{") + .Append(innerIndent).Append(emission.QueryBuilderLocal).Append(".AddCollectionValueFormatted(").Append(emission.QueryValueLocal).AppendLine(");") + .Append(itemIndent).AppendLine("}") + .Append(itemIndent).AppendLine("else") + .Append(itemIndent).AppendLine("{") + .Append(innerIndent).Append(emission.QueryBuilderLocal).Append(AddCollectionValueCall).Append(customExpression).AppendLine(");") + .Append(itemIndent).AppendLine("}"); + } + + /// Appends the statement delegating one converter-bound parameter to its IQueryConverter<T>. + /// The statement builder. + /// The parameter model. + /// The query-binding metadata. + /// The shared emission locals and helper state. + private static void AppendConverterQueryStatements( + PooledStringBuilder sb, + RequestParameterModel parameter, + QueryParameterModel query, + in InlineValueEmission emission) + { + var converter = query.Converter!; + var bodyIndent = Indent(MethodBodyIndentation); + var guarded = parameter.CanBeNull; + var indent = guarded ? bodyIndent + " " : bodyIndent; + var converterField = GetOrAddConverterField(converter.ConverterTypeName, emission); + + if (guarded) + { + _ = sb.Append(bodyIndent).Append(IfParameterPrefix).Append(parameter.Name).AppendLine(NotNullCheckSuffix) + .Append(bodyIndent).AppendLine("{"); + } + + _ = sb.Append(indent).Append(converterField).Append(".Flatten(@").Append(parameter.Name).Append(", ") + .Append(ToCSharpStringLiteral(converter.KeyPrefix)).Append(", ref ").Append(emission.QueryBuilderLocal) + .Append(", ").Append(emission.SettingsLocal).AppendLine(");"); + + if (!guarded) + { + return; + } + + _ = sb.Append(bodyIndent).AppendLine("}"); + } + + /// Bundles the locals and helper state shared by inline value-formatting emission. + /// The generated query builder local name. + /// The generated foreach element local name. + /// The generated settings local name. + /// The generated default-formatting branch local name. + /// The generated default-form-formatting branch local name, + /// guarding the fast path for a flattened property's [Query(Format)]. + /// The enum formatter scope for the interface. + /// The builder receiving emitted helper members. + /// Whether the consumer supports C# 12 collection expressions, so path + /// replacements are emitted as a stack-allocatable [...] span instead of an explicitly-typed array. + private readonly record struct InlineValueEmission( + string QueryBuilderLocal, + string QueryValueLocal, + string SettingsLocal, + string UseDefaultFormattingLocal, + string UseDefaultFormFormattingLocal, + EnumFormatterScope Scope, + PooledStringBuilder MemberSource, + bool SupportsCollectionExpressions); + + /// The generated locals and indentation used to emit one flattened query-object property. + /// The local holding the property value. + /// The query key expression, constant or key-formatter call. + /// The rendered preEncoded boolean literal. + /// The indentation of the statements emitting this property. + private readonly record struct QueryPropertySite( + string ValueLocal, + string KeyExpression, + string PreEncoded, + string Indentation); + + /// The enclosing-parameter context shared by every flattened property of one query object. + /// The enclosing query-object parameter. + /// The cached attribute-provider field name for the parameter. + /// The parameter's [Query(CollectionFormat)], or null. + /// The rendered preEncoded boolean literal for the parameter. + private readonly record struct QueryObjectContext( + RequestParameterModel Parameter, + string ProviderField, + int? ParameterCollectionFormat, + string PreEncoded); + + /// The per-nesting-level state threaded through recursive query-object flattening. + /// The C# expression accessing the current object (e.g. @filter or a nested local). + /// The runtime key expression (a local) of the enclosing object, or null at the top level. + /// The delimiter joining nested keys. + /// The suffix appended to generated local names to keep nested locals unique. + /// The indentation of the statements emitted at this level. + private readonly record struct ObjectFlattenScope( + string AccessExpr, + string? ParentKeyExpr, + string Delimiter, + string LocalSuffix, + string Indentation); + + /// The generated locals and indentation used to emit one dictionary entry. + /// The local holding the current key/value pair. + /// The local receiving the formatted key. + /// The local holding the entry value. + /// The indentation of the statements emitting this entry. + private readonly record struct DictionaryEntrySite( + string EntryLocal, + string KeyLocal, + string ValueLocal, + string Indentation); + + /// Tracks the enum formatting helpers emitted for one generated interface implementation. + /// The unique member name builder for the interface scope. + private sealed class EnumFormatterScope(UniqueNameBuilder uniqueNames) + { + /// Gets the emitted helper names keyed by enum type and compile-time format. + public Dictionary<(string TypeName, string? Format), string> Formatters { get; } = new(); + + /// Gets the emitted cached converter field names keyed by converter type. + public Dictionary Converters { get; } = new(StringComparer.Ordinal); + + /// Gets the unique member name builder for the interface scope. + public UniqueNameBuilder UniqueNames { get; } = uniqueNames; + } +} diff --git a/src/InterfaceStubGenerator.Shared/Emitter.Inline.cs b/src/InterfaceStubGenerator.Shared/Emitter.Inline.cs index 8aff2b122..4dd818e69 100644 --- a/src/InterfaceStubGenerator.Shared/Emitter.Inline.cs +++ b/src/InterfaceStubGenerator.Shared/Emitter.Inline.cs @@ -1,8 +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 System.Text; namespace Refit.Generator; @@ -15,9 +13,6 @@ internal static partial class Emitter /// The opening of a generated FormField construction. private const string FormFieldNew = "new global::Refit.FormField<"; - /// The getter lambda opening between the body type and the property name. - private const string FormFieldGetterOpen = ">(static body => (object?)body.@"; - /// The separator before the quoted CLR property name argument. private const string FormFieldNameOpen = ", \""; @@ -40,23 +35,20 @@ internal static partial class Emitter /// Contains the unique member names in the interface scope. /// The unique generated field name that stores the request builder. /// The unique generated field name that stores Refit settings. + /// The enum formatter scope for the interface. /// The generated method implementation. - [SuppressMessage( - "Usage", - "CA2208:Instantiate argument exceptions correctly", - Justification = - "The ArgumentOutOfRangeException intentionally reports the offending model property (ReturnTypeMetadata) rather than a method parameter.")] private static string BuildRefitMethod( MethodModel methodModel, bool isTopLevel, InterfaceModel interfaceModel, UniqueNameBuilder uniqueNames, string requestBuilderFieldName, - string settingsFieldName) + string settingsFieldName, + EnumFormatterScope enumFormatterScope) { if (interfaceModel.GeneratedRequestBuilding && methodModel.Request.CanGenerateInline) { - return BuildInlineRefitMethod(methodModel, interfaceModel, isTopLevel, settingsFieldName, uniqueNames); + return BuildInlineRefitMethod(methodModel, interfaceModel, isTopLevel, settingsFieldName, uniqueNames, enumFormatterScope); } var locals = CreateMethodLocalNameBuilder(methodModel.Parameters); @@ -80,7 +72,15 @@ private static string BuildRefitMethod( $"{requestBuilderFieldName} ?? throw new global::System.InvalidOperationException(\"This generated Refit method requires a request builder.\")"; return typeParameterFieldSource - + BuildMethodOpening(methodModel, isExplicit, isExplicit, interfaceModel.SupportsNullable, isAsync) + + BuildMethodOpening( + methodModel, + isExplicit, + isExplicit, + interfaceModel.SupportsNullable, + isAsync, + BuildReflectionFallbackSuppressions( + methodModel.RequiresUnreferencedCode, + methodModel.RequiresDynamicCode)) + $$""" {{bodyIndent}}var {{argumentsLocal}} = {{BuildArgumentsArrayLiteral(methodModel)}}; {{bodyIndent}}var {{requestBuilderLocal}} = {{requestBuilderInit}}; @@ -97,386 +97,262 @@ private static string BuildRefitMethod( /// True if directly from the type we're generating for, false for methods found on base interfaces. /// The unique generated field name that stores Refit settings. /// Contains the unique member names in the interface scope. + /// The enum formatter scope for the interface. /// The generated inline method implementation. private static string BuildInlineRefitMethod( MethodModel methodModel, InterfaceModel interfaceModel, bool isTopLevel, string settingsFieldName, - UniqueNameBuilder uniqueNames) + UniqueNameBuilder uniqueNames, + EnumFormatterScope enumFormatterScope) { var isExplicit = methodModel.IsExplicitInterface || !isTopLevel; var request = methodModel.Request; var locals = CreateMethodLocalNameBuilder(methodModel.Parameters); var settingsLocal = locals.New("refitSettings"); var requestLocal = locals.New("refitRequest"); + var adapterTokenLocal = locals.New("refitAdapterToken"); var bodyParameter = FindRequestParameter(request, RequestParameterKind.Body); - var cancellationTokenExpression = BuildCancellationTokenExpression(request); - var bufferBodyExpression = BuildBufferBodyExpression(bodyParameter, settingsLocal); - // Build path - var parameterInfoNames = GetParameterInfoUniqueNames(request, uniqueNames); - var parameters = GetParametersArg(request, parameterInfoNames); - var paramInfoSb = new StringBuilder(); + // Build path. Assigning the unique field names and emitting the attribute-provider fields is a single pass + // over the parameters (the two were previously separate loops sharing the same NeedsAttributeProvider filter). + var paramInfoSb = new PooledStringBuilder(); + var parameterInfoNames = BuildParameterInfoFields(request, uniqueNames, methodModel.DeclaredMethod, paramInfoSb); + + var emission = new InlineValueEmission( + locals.New("refitQueryBuilder"), + locals.New("refitQueryValue"), + settingsLocal, + locals.New("refitUseDefaultFormatting"), + locals.New("refitUseDefaultFormFormatting"), + enumFormatterScope, + paramInfoSb, + interfaceModel.SupportsCollectionExpressions); + var parameters = GetParametersArg(request, parameterInfoNames, emission); + var pathExpression = BuildInlinePathExpression(request, parameterInfoNames, emission, settingsLocal, parameters); + + var plan = new InlineMethodPlan( + bodyParameter, + settingsLocal, + requestLocal, + adapterTokenLocal, + BuildCancellationTokenExpression(request), + BuildBufferBodyExpression(bodyParameter, settingsLocal), + pathExpression, + parameterInfoNames, + paramInfoSb, + emission, + locals); + return BuildInlineRefitMethodBody(methodModel, interfaceModel, isExplicit, settingsFieldName, uniqueNames, plan); + } - foreach (var parameter in request.Parameters) - { - if (parameter.Kind is not RequestParameterKind.Path) - { - continue; - } + /// Assembles the generated inline method from its request-message construction and return statement. + /// The method model being emitted. + /// The interface model being emitted. + /// Whether the method is emitted as an explicit interface implementation. + /// The unique generated field name that stores Refit settings. + /// Contains the unique member names in the interface scope. + /// The method-scope locals and pre-built request fragments. + /// The generated inline method implementation. + private static string BuildInlineRefitMethodBody( + MethodModel methodModel, + InterfaceModel interfaceModel, + bool isExplicit, + string settingsFieldName, + UniqueNameBuilder uniqueNames, + in InlineMethodPlan plan) + { + var request = methodModel.Request; + var settingsLocal = plan.SettingsLocal; + var requestLocal = plan.RequestLocal; + var emission = plan.Emission; + var bodyIndent = Indent(MethodBodyIndentation); - var parameterName = parameterInfoNames[parameter.Name]; - BuildParameterInfoField(parameter, methodModel.DeclaredMethod, parameterName, paramInfoSb); + var requestPrologueSource = BuildInlineRequestPrologue(request, plan, bodyIndent, out var requestPathExpression); + + var (httpMethodFieldSource, httpMethodExpression) = BuildHttpMethodField(request, uniqueNames); + + // A [QueryUriFormat] method re-encodes the whole path and query with the attribute's UriFormat, matching the + // reflection builder's final GetComponents pass; every other method uses the direct relative URI. + var requestUriExpression = request.QueryUriFormat is { } queryUriFormat + ? $"global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, {requestPathExpression}, {settingsLocal}.UrlResolution, (global::System.UriFormat){queryUriFormat})" + : $"global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, {requestPathExpression}, {settingsLocal}.UrlResolution)"; + var (formFieldsSource, formFieldsFieldName) = BuildFormFieldsField( + plan.BodyParameter, + uniqueNames, + interfaceModel.SupportsNullable, + interfaceModel.SupportsStaticLambdas); + + // A multipart method builds a MultipartFormDataContent from its parts; every other method has at most one body + // parameter (a multipart method never carries one), so the two paths never both apply. + var contentSource = plan.BodyParameter is null + ? string.Empty + : BuildInlineContent(plan.BodyParameter, requestLocal, settingsLocal, formFieldsFieldName, interfaceModel.SupportsNullable, emission, plan.Locals); + if (request.IsMultipart) + { + contentSource = BuildInlineMultipartContent(request, requestLocal, settingsLocal, plan.Locals); } - var pathExpression = parameters.Length > 0 - ? $"global::Refit.GeneratedRequestRunner.BuildRequestPath({ToCSharpStringLiteral(request.Path)}, {settingsLocal}.AllowUnmatchedRouteParameters{parameters})" - : ToCSharpStringLiteral(request.Path); - var requestUriExpression = - $"global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, {pathExpression}, {settingsLocal}.UrlResolution)"; - var (formFieldsSource, formFieldsFieldName) = BuildFormFieldsField(bodyParameter, uniqueNames); - var contentSource = bodyParameter is null ? string.Empty : BuildInlineContent(bodyParameter, requestLocal, settingsLocal, formFieldsFieldName); var headerSource = BuildInlineHeaders(request, requestLocal); var requestPropertySource = BuildInlineRequestProperties(request, interfaceModel, requestLocal, settingsLocal); - var returnSource = BuildInlineReturn(methodModel, request, bufferBodyExpression, cancellationTokenExpression, requestLocal, settingsLocal); var methodIndent = Indent(MethodMemberIndentation); - var bodyIndent = Indent(MethodBodyIndentation); - - return $$""" - {{paramInfoSb}}{{formFieldsSource}}{{BuildMethodOpening(methodModel, isExplicit, isExplicit, interfaceModel.SupportsNullable)}}{{bodyIndent}}var {{settingsLocal}} = {{settingsFieldName}}; - {{bodyIndent}}var {{requestLocal}} = new global::System.Net.Http.HttpRequestMessage({{ToHttpMethodExpression(request.HttpMethod)}}, {{requestUriExpression}}); + var opening = BuildMethodOpening(methodModel, isExplicit, isExplicit, interfaceModel.SupportsNullable); + var methodPrefix = $"{plan.ParamInfoBuilder}{formFieldsSource}{httpMethodFieldSource}{opening}{bodyIndent}var {settingsLocal} = {settingsFieldName};\n"; + + // The request construction shared by every shape: prologue locals, the message, the version, content, headers, + // and request properties. A cold IObservable wraps this in a per-subscription local function so a second + // subscription rebuilds and re-sends instead of reusing a disposed request. + var requestConstruction = $$""" + {{requestPrologueSource}}{{bodyIndent}}var {{requestLocal}} = new global::System.Net.Http.HttpRequestMessage({{httpMethodExpression}}, {{requestUriExpression}}); {{bodyIndent}}#if NET6_0_OR_GREATER {{bodyIndent}}{{requestLocal}}.Version = {{settingsLocal}}.Version; {{bodyIndent}}{{requestLocal}}.VersionPolicy = {{settingsLocal}}.VersionPolicy; {{bodyIndent}}#endif - {{contentSource}}{{headerSource}}{{requestPropertySource}}{{returnSource}}{{methodIndent}}} - + {{contentSource}}{{headerSource}}{{requestPropertySource}} """; - } - - /// Builds the unique cached attribute-provider field name for a path parameter. - /// The source parameter name. - /// The unique member name builder for the interface scope. - /// The unique generated field name. - private static string GetParameterInfoFieldName(string parameterName, UniqueNameBuilder uniqueNames) => - uniqueNames.New($"______{parameterName}AttributeProvider"); - - /// Appends a separator before all but the first element. - /// The zero-based element index. - /// The target builder. - /// The separator to append. - /// The same builder for chaining. - private static StringBuilder AppendSeparator(int i, StringBuilder sb, string separator = ", ") - { - return i <= 0 ? sb : sb.Append(separator); - } - - /// Appends a value, prefixed by a separator for all but the first element. - /// The value to append. - /// The zero-based element index. - /// The target builder. - /// The separator to append before the value. - /// The same builder for chaining. - private static StringBuilder AppendJoining(string value, int i, StringBuilder sb, string separator = ", ") - { - return AppendSeparator(i, sb, separator).Append(value); - } - - /// Appends a C# attribute construction expression to the builder. - /// The attribute model to render. - /// The target builder. - private static void AppendAttributeValue(ParameterAttributeModel attribute, StringBuilder sb0) - { - _ = sb0.Append("new ").Append(attribute.TypeExpression).Append('('); - var i = 0; - foreach (var argument in attribute.ConstructorArguments) + if (methodModel.ReturnTypeMetadata == ReturnTypeInfo.Observable) { - _ = AppendJoining(argument, i++, sb0); + var buildRequestLocal = plan.Locals.New("BuildRefitRequest"); + var observableReturn = BuildInlineObservableReturn(request, plan.BufferBodyExpression, plan.CancellationTokenExpression, buildRequestLocal, settingsLocal); + return BuildInlineObservableMethodSource(methodPrefix, requestConstruction, buildRequestLocal, requestLocal, observableReturn, bodyIndent, methodIndent); } - _ = sb0.Append(')'); - if (attribute.NamedArguments.Count < 1) - { - return; - } - - i = 0; - _ = sb0.Append("{ "); - foreach (var named in attribute.NamedArguments) - { - _ = AppendSeparator(i++, sb0); - _ = sb0.Append(named.Name).Append(" = ").Append(named.ValueExpression); - } + var returnSource = BuildInlineReturn(methodModel, request, plan.BufferBodyExpression, plan.CancellationTokenExpression, requestLocal, settingsLocal, plan.AdapterTokenLocal); + return $$""" + {{methodPrefix}}{{requestConstruction}}{{returnSource}}{{methodIndent}}} - _ = sb0.Append(" }"); + """; } - /// Emits the cached attribute-provider field for a single path parameter. - /// The path parameter model. - /// The declaring method name, used for the generated documentation. - /// The unique generated field name. - /// The target builder. - private static void BuildParameterInfoField(RequestParameterModel parameter, string method, string paramInfoFieldName, StringBuilder sb) + /// Emits the request-prologue formatting locals and resolves the request-path expression to use. + /// The parsed request model. + /// The method-scope locals and pre-built request fragments. + /// The method-body indentation. + /// The request-path expression: the query builder's Build() call, or the path when no query binds. + /// The generated request-prologue source. + private static string BuildInlineRequestPrologue( + RequestModel request, + in InlineMethodPlan plan, + string bodyIndent, + out string requestPathExpression) { - // Build the initializer. - var memberIndent = Indent(MethodMemberIndentation); - Dictionary> grouped = new(); + var emission = plan.Emission; + var settingsLocal = plan.SettingsLocal; - foreach (var attribute in parameter.Attributes) + // Accumulate the request prologue through a pooled buffer rather than reallocating the whole string on each + // '+=' branch below. + var prologue = new PooledStringBuilder(); + if (NeedsFormattingLocal(request)) { - var key = $"typeof({attribute.TypeExpression})"; - if (grouped.TryGetValue(key, out var groupedAttributes)) - { - groupedAttributes.Add(attribute); - } - else - { - grouped.Add(key, [attribute]); - } + _ = prologue.Append(bodyIndent).Append("var ").Append(emission.UseDefaultFormattingLocal) + .Append(" = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(") + .Append(settingsLocal).AppendLine(");"); } - const string dictType = "global::System.Collections.Generic.Dictionary"; - _ = sb.AppendLine().Append(memberIndent).Append("/// Cached attribute provider for the generated ") - .Append(ToXmlDocumentationText(method)).Append(" method's ").Append(ToXmlDocumentationText(parameter.Name)).AppendLine(" parameter.") - .Append(memberIndent).Append("private static readonly global::Refit.GeneratedParameterAttributeProvider ").Append(paramInfoFieldName).Append(" = ") - .Append("new global::Refit.GeneratedParameterAttributeProvider(new ").Append(dictType).Append("()"); - var i = 0; - if (grouped.Count > 0) + if (NeedsFormFormattingLocal(request)) { - _ = sb.Append(" {"); - foreach (var kv in grouped) - { - _ = AppendJoining("{ ", i++, sb).Append(kv.Key).Append(", new object[] { "); - var argIndex = 0; - foreach (var arg in kv.Value) - { - // Multiple attributes of the same type must be comma-separated inside the array. - _ = AppendSeparator(argIndex++, sb); - AppendAttributeValue(arg, sb); - } - - _ = sb.Append("} }"); - } - - _ = sb.Append('}'); + _ = prologue.Append(bodyIndent).Append("var ").Append(emission.UseDefaultFormFormattingLocal) + .Append(" = global::Refit.GeneratedRequestRunner.UsesDefaultFormUrlEncodedParameterFormatting(") + .Append(settingsLocal).AppendLine(");"); } - _ = sb.AppendLine(");"); + requestPathExpression = AppendInlineQueryPrologue(prologue, request, plan.ParameterInfoNames, emission, plan.PathExpression, bodyIndent); + return prologue.ToString(); } - /// Builds the unique cached field names for each path parameter attribute provider. + /// Appends the query-string-builder prologue and returns the request-path expression to use. + /// The request-prologue buffer to append to. /// The parsed request model. - /// The unique member name builder for the interface scope. - /// A map of parameter name to its cached attribute-provider field name. - private static Dictionary GetParameterInfoUniqueNames( + /// The per-parameter attribute-provider field names. + /// The inline value-emission context. + /// The generated relative-path expression. + /// The method-body indentation. + /// The request-path expression: the query builder's Build() call, or the path when no query binds. + private static string AppendInlineQueryPrologue( + PooledStringBuilder prologue, RequestModel request, - UniqueNameBuilder uniqueNames) + Dictionary parameterInfoNames, + in InlineValueEmission emission, + string pathExpression, + string bodyIndent) { - var dict = new Dictionary(); - foreach (var parameter in request.Parameters) + if (!HasQueryBindings(request)) { - if (parameter.Kind is not RequestParameterKind.Path) - { - continue; - } - - var parameterInfoFieldName = GetParameterInfoFieldName(parameter.Name, uniqueNames); - dict.Add(parameter.Name, parameterInfoFieldName); + return pathExpression; } - return dict; - } + _ = prologue.Append(bodyIndent).Append("var ").Append(emission.QueryBuilderLocal) + .Append(" = new global::Refit.GeneratedQueryStringBuilder(").Append(pathExpression); - /// Builds the additional arguments passed to the generated request path builder. - /// The parsed request model. - /// The map of parameter name to cached attribute-provider field name. - /// The generated argument list fragment. - private static string GetParametersArg(RequestModel request, Dictionary uniqueNameLookup) - { - var parametersSb = new StringBuilder(); - var pathLength = request.Path.Length; - foreach (var parameter in request.Parameters) + // The query separator (? vs &) depends on whether the built path already carries a query. Path values are + // escaped, so only a pre-encoded ([Encoded]) segment could inject a ? the template lacks; without one the + // answer is the compile-time presence of ? in the template, passed in so the path is not rescanned per call. + if (!HasPreEncodedPathParameter(request)) { - if (parameter.Kind is not RequestParameterKind.Path || parameter.Locations is null) - { - continue; - } - - foreach (var location in parameter.Locations) - { - var start = location.Start.GetOffset(pathLength); - var end = location.End.GetOffset(pathLength); - _ = parametersSb.Append(", ").Append("((").Append(start).Append(", ").Append(end).Append("), "); - var parameterInfoFieldName = uniqueNameLookup[parameter.Name]; - _ = parametersSb.Append("_settings.UrlParameterFormatter.Format(") - .Append(parameter.Name) - .Append(", ") - .Append(parameterInfoFieldName) - .Append(", ") - .Append("typeof(").Append(parameter.Type).Append(')') - .Append(')'); - _ = parametersSb.Append(')'); - } + _ = prologue.Append(", ").Append(ToLowerInvariantString(request.Path.IndexOf('?') >= 0)); } - return parametersSb.ToString(); + _ = prologue.AppendLine(");") + .Append(BuildInlineQueryStatements(request, parameterInfoNames, emission)); + return emission.QueryBuilderLocal + ".Build()"; } - /// Builds request content assignment for an inline generated method. - /// The body parameter model. - /// The generated request message local name. + /// Assembles a cold-observable inline method: a per-subscription request factory and its send. + /// The method signature, settings assignment, and any field prologue. + /// The shared request-construction block. + /// The per-subscription request-building local function name. + /// The request-message local name. + /// The generated return SendObservable(...) statement. + /// The method-body indentation. + /// The method-member indentation. + /// The generated method source. + private static string BuildInlineObservableMethodSource( + string methodPrefix, + string requestConstruction, + string buildRequestLocal, + string requestLocal, + string observableReturn, + string bodyIndent, + string methodIndent) => + $$""" + {{methodPrefix}}{{bodyIndent}}global::System.Net.Http.HttpRequestMessage {{buildRequestLocal}}() + {{bodyIndent}}{ + {{requestConstruction}}{{bodyIndent}} return {{requestLocal}}; + {{bodyIndent}}} + {{observableReturn}}{{methodIndent}}} + + """; + + /// Builds the cold-observable return statement: a per-subscription send over the request factory. + /// The parsed request model. + /// The generated buffer-body expression. + /// The generated method cancellation-token expression. + /// The per-subscription request-building local function name. /// The generated settings local name. - /// The cached form field descriptor array name, or null to use the reflection path. - /// The generated content assignment. - private static string BuildInlineContent(RequestParameterModel bodyParameter, string requestLocal, string settingsLocal, string? formFieldsFieldName) + /// The generated return SendObservable(...) statement. + private static string BuildInlineObservableReturn( + RequestModel request, + string bufferBodyExpression, + string cancellationTokenExpression, + string buildRequestLocal, + string settingsLocal) { var bodyIndent = Indent(MethodBodyIndentation); - if (bodyParameter.BodySerializationMethod == "UrlEncoded") - { - return formFieldsFieldName is not null - ? $$""" - {{bodyIndent}}{{requestLocal}}.Content = global::Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent<{{bodyParameter.Type}}>( - {{bodyIndent}} {{settingsLocal}}, - {{bodyIndent}} @{{bodyParameter.Name}}, - {{bodyIndent}} {{formFieldsFieldName}}); - - """ - : $$""" - {{bodyIndent}}{{requestLocal}}.Content = global::Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent<{{bodyParameter.Type}}>( - {{bodyIndent}} {{settingsLocal}}, - {{bodyIndent}} @{{bodyParameter.Name}}); - - """; - } - - if (bodyParameter.BodySerializationMethod == "JsonLines") - { - return $$""" - {{bodyIndent}}{{requestLocal}}.Content = global::Refit.GeneratedRequestRunner.CreateJsonLinesBodyContent<{{bodyParameter.Type}}>( - {{bodyIndent}} {{settingsLocal}}, - {{bodyIndent}} @{{bodyParameter.Name}}); - - """; - } - - var streamBodyExpression = BuildStreamBodyExpression(bodyParameter, settingsLocal); - var serializationMethodExpression = BuildBodySerializationMethodExpression(bodyParameter); - return $$""" - {{bodyIndent}}{{requestLocal}}.Content = global::Refit.GeneratedRequestRunner.CreateBodyContent<{{bodyParameter.Type}}>( + {{bodyIndent}}return global::Refit.GeneratedRequestRunner.SendObservable<{{request.ResultType}}, {{request.DeserializedResultType}}>( + {{bodyIndent}} this.Client, + {{bodyIndent}} {{buildRequestLocal}}, {{bodyIndent}} {{settingsLocal}}, - {{bodyIndent}} @{{bodyParameter.Name}}, - {{bodyIndent}} {{serializationMethodExpression}}, - {{bodyIndent}} {{streamBodyExpression}}); - - """; - } - - /// Builds the cached form field descriptor array declaration for a URL-encoded body, if eligible. - /// The body parameter model, or null when the method has no body. - /// Contains the unique member names in the interface scope. - /// The generated field declaration and its name, or empty values when the reflection path is used. - private static (string Source, string? FieldName) BuildFormFieldsField( - RequestParameterModel? bodyParameter, - UniqueNameBuilder uniqueNames) - { - if (bodyParameter?.FormFields is not { Count: > 0 } formFields) - { - return (string.Empty, null); - } - - var fields = formFields.AsArray(); - var bodyType = bodyParameter.Type; - var fieldName = uniqueNames.New(FormFieldsVariableName); - var elementIndent = Indent(MethodBodyIndentation); - - var elementsLength = 0; - for (var i = 0; i < fields.Length; i++) - { - elementsLength += MeasureFormFieldElement(fields[i], bodyType, elementIndent.Length); - } - - var elements = CreateGeneratedString( - elementsLength, - (fields, bodyType, elementIndent), - static (destination, state) => - { - var position = 0; - var (elementFields, type, indent) = state; - for (var i = 0; i < elementFields.Length; i++) - { - var field = elementFields[i]; - AppendText(destination, indent, ref position); - AppendText(destination, FormFieldNew, ref position); - AppendText(destination, type, ref position); - AppendText(destination, FormFieldGetterOpen, ref position); - AppendText(destination, field.PropertyName, ref position); - AppendText(destination, FormFieldNameOpen, ref position); - AppendText(destination, field.PropertyName, ref position); - AppendText(destination, FormFieldNameClose, ref position); - AppendLiteralOrNull(destination, field.ExplicitName, ref position); - AppendText(destination, ArgumentSeparator, ref position); - AppendLiteralOrNull(destination, field.PrefixSegment, ref position); - AppendText(destination, ArgumentSeparator, ref position); - AppendLiteralOrNull(destination, field.Format, ref position); - AppendText(destination, ArgumentSeparator, ref position); - if (field.CollectionFormatValue is { } collectionFormatValue) - { - AppendText(destination, CollectionFormatCast, ref position); - AppendInt32(destination, collectionFormatValue, ref position); - } - else - { - AppendText(destination, NullLiteral, ref position); - } - - AppendText(destination, ArgumentSeparator, ref position); - AppendText(destination, field.SerializeNull ? TrueLiteral : FalseLiteral, ref position); - AppendText(destination, FormFieldClose, ref position); - } - }); - - var memberIndent = Indent(MethodMemberIndentation); - var source = $$""" - + {{bodyIndent}} {{ToLowerInvariantString(request.IsApiResponse)}}, + {{bodyIndent}} {{ToLowerInvariantString(request.ShouldDisposeResponse)}}, + {{bodyIndent}} {{bufferBodyExpression}}, + {{bodyIndent}} {{cancellationTokenExpression}}); - {{memberIndent}}/// Cached form field descriptors used to serialize the URL-encoded request body without reflection. - {{memberIndent}}private static readonly global::Refit.FormField<{{bodyType}}>[] {{fieldName}} = new global::Refit.FormField<{{bodyType}}>[] - {{memberIndent}}{ - {{elements}}{{memberIndent}}}; """; - return (source, fieldName); } - /// Measures the rendered length of one generated form field element line. - /// The form field descriptor. - /// The fully-qualified body type. - /// The element indentation length. - /// The number of characters the rendered element occupies. - private static int MeasureFormFieldElement(FormFieldModel field, string bodyType, int indentLength) => - indentLength - + FormFieldNew.Length - + bodyType.Length - + FormFieldGetterOpen.Length - + field.PropertyName.Length - + FormFieldNameOpen.Length - + field.PropertyName.Length - + FormFieldNameClose.Length - + LiteralOrNullLength(field.ExplicitName) - + ArgumentSeparator.Length - + LiteralOrNullLength(field.PrefixSegment) - + ArgumentSeparator.Length - + LiteralOrNullLength(field.Format) - + ArgumentSeparator.Length - + (field.CollectionFormatValue is { } collectionFormatValue - ? CollectionFormatCast.Length + Int32Length(collectionFormatValue) - : NullLiteral.Length) - + ArgumentSeparator.Length - + (field.SerializeNull ? TrueLiteral.Length : FalseLiteral.Length) - + FormFieldClose.Length; - /// Builds the return statement for an inline generated Refit method. /// The method model being emitted. /// The parsed request model. @@ -484,6 +360,7 @@ private static int MeasureFormFieldElement(FormFieldModel field, string bodyType /// The cancellation token expression. /// The generated request message local name. /// The generated settings local name. + /// The lambda parameter name for the return-type adapter's cancellation token. /// The generated return statement. private static string BuildInlineReturn( MethodModel methodModel, @@ -491,35 +368,70 @@ private static string BuildInlineReturn( string bufferBodyExpression, string cancellationTokenExpression, string requestLocal, - string settingsLocal) + string settingsLocal, + string adapterTokenLocal) { var bodyIndent = Indent(MethodBodyIndentation); - if (methodModel.ReturnTypeMetadata == ReturnTypeInfo.AsyncVoid) + if (request.AdapterTypeExpression is { } adapterType) { + // The adapter surfaces the call synchronously and receives a deferred send. The request is built eagerly + // and captured, so the deferred call is single-use (a second invocation sends a disposed request). return $$""" - {{bodyIndent}}return global::Refit.GeneratedRequestRunner.SendVoidAsync( + {{bodyIndent}}return new {{adapterType}}().Adapt(({{adapterTokenLocal}}) => global::Refit.GeneratedRequestRunner.SendAsync<{{request.ResultType}}, {{request.DeserializedResultType}}>( {{bodyIndent}} this.Client, {{bodyIndent}} {{requestLocal}}, {{bodyIndent}} {{settingsLocal}}, + {{bodyIndent}} {{ToLowerInvariantString(request.IsApiResponse)}}, + {{bodyIndent}} {{ToLowerInvariantString(request.ShouldDisposeResponse)}}, {{bodyIndent}} {{bufferBodyExpression}}, - {{bodyIndent}} {{cancellationTokenExpression}}); + {{bodyIndent}} {{adapterTokenLocal}})); """; } - if (methodModel.ReturnTypeMetadata == ReturnTypeInfo.AsyncEnumerable) + if (methodModel.ReturnTypeMetadata == ReturnTypeInfo.AsyncVoid) { return $$""" - {{bodyIndent}}return global::Refit.GeneratedRequestRunner.StreamAsync<{{request.ResultType}}>( + {{bodyIndent}}return global::Refit.GeneratedRequestRunner.SendVoidAsync( {{bodyIndent}} this.Client, {{bodyIndent}} {{requestLocal}}, {{bodyIndent}} {{settingsLocal}}, + {{bodyIndent}} {{bufferBodyExpression}}, {{bodyIndent}} {{cancellationTokenExpression}}); """; } - return methodModel.ReturnType.StartsWith("global::System.Threading.Tasks.ValueTask<", StringComparison.Ordinal) + return methodModel.ReturnTypeMetadata == ReturnTypeInfo.AsyncEnumerable + ? $$""" + {{bodyIndent}}return global::Refit.GeneratedRequestRunner.StreamAsync<{{request.ResultType}}>( + {{bodyIndent}} this.Client, + {{bodyIndent}} {{requestLocal}}, + {{bodyIndent}} {{settingsLocal}}, + {{bodyIndent}} {{cancellationTokenExpression}}); + + """ + : BuildInlineSendAsyncReturn(methodModel, request, bufferBodyExpression, cancellationTokenExpression, requestLocal, settingsLocal, bodyIndent); + } + + /// Builds the awaited SendAsync return statement for the standard async-result inline path. + /// The method model being emitted. + /// The parsed request model. + /// The request-body buffering expression. + /// The cancellation token expression. + /// The generated request message local name. + /// The generated settings local name. + /// The method-body indentation. + /// The generated return statement. + private static string BuildInlineSendAsyncReturn( + MethodModel methodModel, + RequestModel request, + string bufferBodyExpression, + string cancellationTokenExpression, + string requestLocal, + string settingsLocal, + string bodyIndent) => + methodModel.ReturnType.StartsWith("global::System.Threading.Tasks.ValueTask<", StringComparison.Ordinal) ? $$""" {{bodyIndent}}return new {{methodModel.ReturnType}}(global::Refit.GeneratedRequestRunner.SendAsync<{{request.ResultType}}, {{request.DeserializedResultType}}>( {{bodyIndent}} this.Client, @@ -542,7 +454,6 @@ private static string BuildInlineReturn( {{bodyIndent}} {{cancellationTokenExpression}}); """; - } /// Builds static and dynamic header application for an inline generated method. /// The parsed request model. @@ -555,8 +466,9 @@ private static string BuildInlineHeaders(RequestModel request, string requestLoc var bodyIndent = Indent(MethodBodyIndentation); foreach (var header in request.StaticHeaders) { - parts[count++] = + parts[count] = $"{bodyIndent}global::Refit.GeneratedRequestRunner.SetHeader({requestLocal}, {ToCSharpStringLiteral(header.Name)}, {ToNullableCSharpStringLiteral(header.Value)});\n"; + count++; } foreach (var parameter in request.Parameters) @@ -565,15 +477,21 @@ private static string BuildInlineHeaders(RequestModel request, string requestLoc { case RequestParameterKind.Header: { - parts[count++] = - $"{bodyIndent}global::Refit.GeneratedRequestRunner.SetHeader({requestLocal}, {ToCSharpStringLiteral(parameter.HeaderName)}, {BuildHeaderValueExpression(parameter)});\n"; + // An [Authorize] parameter carries a "{scheme} " prefix; a plain [Header] has none. + var headerValueExpression = parameter.HeaderValuePrefix is { } valuePrefix + ? $"{ToCSharpStringLiteral(valuePrefix)} + {BuildHeaderValueExpression(parameter)}" + : BuildHeaderValueExpression(parameter); + parts[count] = + $"{bodyIndent}global::Refit.GeneratedRequestRunner.SetHeader({requestLocal}, {ToCSharpStringLiteral(parameter.HeaderName)}, {headerValueExpression});\n"; + count++; continue; } case RequestParameterKind.HeaderCollection: { - parts[count++] = + parts[count] = $"{bodyIndent}global::Refit.GeneratedRequestRunner.AddHeaderCollection({requestLocal}, @{parameter.Name});\n"; + count++; break; } @@ -615,8 +533,9 @@ private static string BuildInlineRequestProperties( var parts = new string[1 + interfaceModel.Properties.Count + request.Parameters.Count]; var count = 0; var bodyIndent = Indent(MethodBodyIndentation); - parts[count++] = + parts[count] = $"{bodyIndent}global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions({requestLocal}, {settingsLocal}, typeof({interfaceModel.InterfaceDisplayName}));\n"; + count++; foreach (var property in interfaceModel.Properties) { @@ -625,18 +544,20 @@ private static string BuildInlineRequestProperties( continue; } - parts[count++] = + parts[count] = $"{bodyIndent}global::Refit.GeneratedRequestRunner.AddRequestProperty<{property.Type}>" + $"({requestLocal}, {ToCSharpStringLiteral(property.RequestPropertyKey)}, " + $"{BuildPropertyAccessExpression(property)});\n"; + count++; } foreach (var parameter in request.Parameters) { if (parameter.Kind == RequestParameterKind.Property) { - parts[count++] = + parts[count] = $"{bodyIndent}global::Refit.GeneratedRequestRunner.AddRequestProperty<{parameter.Type}>({requestLocal}, {ToCSharpStringLiteral(parameter.PropertyKey)}, @{parameter.Name});\n"; + count++; } } @@ -649,13 +570,11 @@ private static string BuildInlineRequestProperties( private static UniqueNameBuilder CreateMethodLocalNameBuilder(ImmutableEquatableArray parameters) { var builder = new UniqueNameBuilder(); - var names = new List(parameters.Count); foreach (var parameter in parameters) { - names.Add(parameter.MetadataName); + builder.Reserve(parameter.MetadataName); } - builder.Reserve(names); return builder; } @@ -702,4 +621,49 @@ private static string BuildBodySerializationMethodExpression(RequestParameterMod : bodyParameter.BodySerializationMethod; return $"global::Refit.BodySerializationMethod.{serializationMethod}"; } + + /// The shared locals and rendered fragments used to emit one unrolled form body. + /// The body value expression. + /// The form entry list local name. + /// The statement indentation. + /// The new KeyValuePair<...> constructor prefix, nullable-annotated per language version. + /// The method-scope unique local name builder. + private readonly record struct FormUnrollSite( + string BodyExpr, + string EntriesLocal, + string Indentation, + string KvpNew, + UniqueNameBuilder Locals); + + /// One placeholder replacement in the request path template, ordered by its start offset. + /// The placeholder start offset in the template. + /// The placeholder end offset in the template. + /// The generated replacement value expression. + /// Whether the value passes through pre-encoded. + private readonly record struct PathReplacement(int Start, int End, string Value, bool PreEncoded); + + /// The method-scope locals and pre-built request fragments threaded through inline method assembly. + /// The request body parameter, or null. + /// The generated settings local name. + /// The generated request message local name. + /// The lambda parameter name for the return-type adapter's cancellation token. + /// The cancellation token expression. + /// The request-body buffering expression. + /// The generated relative-path expression. + /// The per-parameter attribute-provider field names. + /// The builder holding the emitted attribute-provider fields. + /// The inline value-emission context. + /// The method-scope unique local name builder. + private readonly record struct InlineMethodPlan( + RequestParameterModel? BodyParameter, + string SettingsLocal, + string RequestLocal, + string AdapterTokenLocal, + string CancellationTokenExpression, + string BufferBodyExpression, + string PathExpression, + Dictionary ParameterInfoNames, + PooledStringBuilder ParamInfoBuilder, + InlineValueEmission Emission, + UniqueNameBuilder Locals); } diff --git a/src/InterfaceStubGenerator.Shared/Emitter.ReflectionArguments.cs b/src/InterfaceStubGenerator.Shared/Emitter.ReflectionArguments.cs new file mode 100644 index 000000000..191fae120 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Emitter.ReflectionArguments.cs @@ -0,0 +1,205 @@ +// 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; + +/// Builds the argument, type, and return expressions for the reflection-backed request builder call. +internal static partial class Emitter +{ + /// Builds the object[] literal that holds the method's argument values. + /// The method model being emitted. + /// The generated argument array literal. + private static string BuildArgumentsArrayLiteral(MethodModel methodModel) + { + var parameters = methodModel.Parameters.AsArray(); + if (parameters.Length == 0) + { + return "global::System.Array.Empty()"; + } + + const string prefix = "new object[] { "; + const string suffix = " }"; + var length = prefix.Length + suffix.Length + ((parameters.Length - 1) * ListSeparatorLength); + for (var i = 0; i < parameters.Length; i++) + { + length += 1 + parameters[i].MetadataName.Length; + } + + return CreateGeneratedString( + length, + parameters, + static (destination, values) => + { + var position = 0; + AppendText(destination, prefix, ref position); + for (var i = 0; i < values.Length; i++) + { + if (i > 0) + { + AppendText(destination, ", ", ref position); + } + + destination[position] = '@'; + position++; + AppendText(destination, values[i].MetadataName, ref position); + } + + AppendText(destination, suffix, ref position); + }); + } + + /// Builds the optional generic Type[] argument for the request builder call. + /// The method model being emitted. + /// The generated generic type argument, or an empty string. + private static string BuildGenericTypesArgument(MethodModel methodModel) + { + var constraints = methodModel.Constraints.AsArray(); + if (constraints.Length == 0) + { + return string.Empty; + } + + const string prefix = ", new global::System.Type[] { "; + const string suffix = " }"; + var length = prefix.Length + suffix.Length + ((constraints.Length - 1) * ListSeparatorLength); + for (var i = 0; i < constraints.Length; i++) + { + length += TypeOfPrefix.Length + constraints[i].DeclaredName.Length + 1; + } + + return CreateGeneratedString( + length, + constraints, + static (destination, values) => + { + var position = 0; + AppendText(destination, prefix, ref position); + for (var i = 0; i < values.Length; i++) + { + if (i > 0) + { + AppendText(destination, ", ", ref position); + } + + AppendText(destination, TypeOfPrefix, ref position); + AppendText(destination, values[i].DeclaredName, ref position); + destination[position] = ')'; + position++; + } + + AppendText(destination, suffix, ref position); + }); + } + + /// Builds a cached field for non-generic method parameter types, when possible. + /// The method model being emitted. + /// Contains the unique member names in the interface scope. + /// The generated field source and field name, if one was generated. + private static (string Source, string? FieldName) BuildTypeParameterField( + MethodModel methodModel, + UniqueNameBuilder uniqueNames) + { + if (methodModel.Parameters.Count == 0 || ContainsGenericParameter(methodModel.Parameters)) + { + return (string.Empty, null); + } + + var typeParameterFieldName = uniqueNames.New(TypeParameterVariableName); + var typeList = BuildParameterTypeList(methodModel.Parameters); + var memberIndent = Indent(MethodMemberIndentation); + var source = $$""" + + + {{memberIndent}}/// Cached parameter type array for the generated {{ToXmlDocumentationText(methodModel.DeclaredMethod)}} method. + {{memberIndent}}private static readonly global::System.Type[] {{typeParameterFieldName}} = new global::System.Type[] { {{typeList}} }; + """; + return (source, typeParameterFieldName); + } + + /// 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) + { + for (var i = 0; i < parameters.Count; i++) + { + if (parameters[i].IsGeneric) + { + return true; + } + } + + return false; + } + + /// Builds the expression used to pass the method's parameter types to the request builder. + /// The parameter models to emit. + /// The cached field name, if one was generated. + /// The generated type parameter expression. + private static string BuildTypeParameterExpression( + ImmutableEquatableArray parameters, + string? cachedTypeParameterFieldName) => + parameters.Count == 0 + ? "global::System.Array.Empty()" + : cachedTypeParameterFieldName ?? $"new global::System.Type[] {{ {BuildParameterTypeList(parameters)} }}"; + + /// Builds the generated typeof(...) argument list for method parameters. + /// The parameter models to emit. + /// The generated parameter type list. + private static string BuildParameterTypeList(ImmutableEquatableArray parameters) + { + if (parameters.Count == 0) + { + return string.Empty; + } + + var length = (parameters.Count - 1) * ListSeparatorLength; + for (var i = 0; i < parameters.Count; i++) + { + length += TypeOfPrefix.Length + parameters[i].Type.Length + 1; + } + + return CreateGeneratedString( + length, + parameters.AsArray(), + static (destination, values) => + { + var position = 0; + for (var i = 0; i < values.Length; i++) + { + if (i > 0) + { + AppendText(destination, ", ", ref position); + } + + AppendText(destination, TypeOfPrefix, ref position); + AppendText(destination, values[i].Type, ref position); + destination[position] = ')'; + position++; + } + }); + } + + /// Builds the generated return statement for the reflection-backed Refit method path. + /// The method model being emitted. + /// The return statement prefix. + /// The generated return type. + /// The generated configure-await suffix. + /// The generated request-func local name. + /// The generated arguments-array local name. + /// The generated return statement. + private static string BuildRefitReturnStatement( + MethodModel methodModel, + string returnPrefix, + string returnType, + string configureAwait, + string funcLocal, + string argumentsLocal) + { + var bodyIndent = Indent(MethodBodyIndentation); + return methodModel.ReturnTypeMetadata == ReturnTypeInfo.SyncVoid + ? $"{bodyIndent}{funcLocal}(this.Client, {argumentsLocal});\n" + : $"{bodyIndent}{returnPrefix}({returnType}){funcLocal}(this.Client, {argumentsLocal}){configureAwait};\n"; + } +} diff --git a/src/InterfaceStubGenerator.Shared/Emitter.SharedCode.cs b/src/InterfaceStubGenerator.Shared/Emitter.SharedCode.cs new file mode 100644 index 000000000..f02dad8fb --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Emitter.SharedCode.cs @@ -0,0 +1,101 @@ +// 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.Text; + +namespace Refit.Generator; + +/// Emits the shared preserve attribute and factory-registration module for a compilation. +internal static partial class Emitter +{ + /// Emits the generated PreserveAttribute source into the consumer compilation. + /// The context generation model describing the interfaces. + /// The shared generated file header. + /// Callback used to add generated source files. + private static void EmitPreserveAttribute( + ContextGenerationModel model, + string generatedFileHeader, + Action addSource) + { + const string attributeUsageLine = + "[global::System.AttributeUsage (global::System.AttributeTargets.Class | " + + "global::System.AttributeTargets.Struct | global::System.AttributeTargets.Enum | " + + "global::System.AttributeTargets.Constructor | global::System.AttributeTargets.Method | " + + "global::System.AttributeTargets.Property | global::System.AttributeTargets.Field | " + + "global::System.AttributeTargets.Event | global::System.AttributeTargets.Interface | " + + "global::System.AttributeTargets.Delegate)]"; + + var generatedCodeAttribute = GeneratedCodeAttribute; + var attributeText = $$""" + {{generatedFileHeader}} + namespace {{model.RefitInternalNamespace}} + { + /// Identifies generated members that should be preserved by tools that honor this attribute. + {{generatedCodeAttribute}} + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] + {{attributeUsageLine}} + internal sealed class PreserveAttribute : global::System.Attribute + { + /// Gets or sets a value indicating whether all members should be preserved. + public bool AllMembers { get; set; } + + /// Gets or sets a value indicating whether preservation should be conditional. + public bool Conditional { get; set; } + } + } + + """; + + // add the attribute text + addSource("PreserveAttribute.g.cs", SourceText.From(attributeText, Encoding.UTF8)); + } + + /// Emits the generated factory-registration module initializer into the consumer compilation. + /// The context generation model describing the interfaces. + /// The shared generated file header. + /// Callback used to add generated source files. + private static void EmitGeneratedFactoryModule( + ContextGenerationModel model, + string generatedFileHeader, + Action addSource) + { + const string dynamicDependencyLine = + "[System.Diagnostics.CodeAnalysis.DynamicDependency(" + + "System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, " + + "typeof(global::Refit.Implementation.Generated))]"; + + var generatedCodeAttribute = GeneratedCodeAttribute; + var generatedSource = $$""" + {{generatedFileHeader}} + namespace Refit.Implementation + { + /// Registers generated Refit factories for interfaces discovered at compile time. + {{generatedCodeAttribute}} + [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 + /// Registers generated Refit factories when the assembly is loaded. + [global::System.Diagnostics.CodeAnalysis.SuppressMessage( + "Usage", + "CA2255:The ModuleInitializer attribute should not be used in libraries", + Justification = "ModuleInitializer is used intentionally so generated Refit factories are registered when the assembly loads.")] + [System.Runtime.CompilerServices.ModuleInitializer] + {{dynamicDependencyLine}} + internal static void Initialize() + { + {{BuildGeneratedFactoryRegistrations(model.Interfaces)}} } + #endif + } + } + + """; + addSource("Generated.g.cs", ToSourceText(generatedSource)); + } +} diff --git a/src/InterfaceStubGenerator.Shared/Emitter.Testing.cs b/src/InterfaceStubGenerator.Shared/Emitter.Testing.cs index 4aeb81c18..0734a65c8 100644 --- a/src/InterfaceStubGenerator.Shared/Emitter.Testing.cs +++ b/src/InterfaceStubGenerator.Shared/Emitter.Testing.cs @@ -61,5 +61,12 @@ internal static string BuildRefitMethodForTesting( UniqueNameBuilder uniqueNames, string requestBuilderFieldName, string settingsFieldName) => - BuildRefitMethod(methodModel, isTopLevel, interfaceModel, uniqueNames, requestBuilderFieldName, settingsFieldName); + BuildRefitMethod( + methodModel, + isTopLevel, + interfaceModel, + uniqueNames, + requestBuilderFieldName, + settingsFieldName, + new(uniqueNames)); } diff --git a/src/InterfaceStubGenerator.Shared/Emitter.cs b/src/InterfaceStubGenerator.Shared/Emitter.cs index b2572b1fc..07b97aa2f 100644 --- a/src/InterfaceStubGenerator.Shared/Emitter.cs +++ b/src/InterfaceStubGenerator.Shared/Emitter.cs @@ -18,6 +18,12 @@ internal static partial class Emitter /// The number of quote characters wrapping a C# string literal. private const int StringLiteralQuoteLength = 2; + /// The rendered length of the ", " separator emitted between joined items. + private const int ListSeparatorLength = 2; + + /// The number of generated factory registrations emitted per non-generic interface. + private const int RegistrationsPerInterface = 2; + /// The radix used when rendering decimal integers. private const int DecimalRadix = 10; @@ -40,6 +46,9 @@ internal static partial class Emitter /// The number of spaces in one generated indentation level. private const int CharsPerIndentation = 4; + /// The highest indentation level kept in the shared ; deeper levels allocate. + private const int MaxCachedIndentLevel = 8; + /// Indentation level for generated nested implementation classes. private const int ClassMemberIndentation = 2; @@ -52,19 +61,8 @@ internal static partial class Emitter /// The generated attribute that identifies source produced by this generator. private static readonly string GeneratedCodeAttribute = BuildGeneratedCodeAttribute(); -#if NETSTANDARD2_0 - /// Delegate used to fill a generated string buffer. - /// The state type. - /// The target character buffer. - /// The caller supplied state. - private delegate void GeneratedStringAction(char[] destination, TState state); -#else - /// Delegate used to fill a generated string buffer. - /// The state type. - /// The target character buffer. - /// The caller supplied state. - private delegate void GeneratedStringAction(Span destination, TState state); -#endif + /// The XML metacharacters that force onto its escaping path. + private static readonly char[] XmlEscapeChars = ['&', '<', '>']; /// Emits the shared preserve attribute and factory registration code. /// The context generation model describing the interfaces. @@ -78,75 +76,9 @@ public static void EmitSharedCode( return; } - const string attributeUsageLine = - "[global::System.AttributeUsage (global::System.AttributeTargets.Class | " - + "global::System.AttributeTargets.Struct | global::System.AttributeTargets.Enum | " - + "global::System.AttributeTargets.Constructor | global::System.AttributeTargets.Method | " - + "global::System.AttributeTargets.Property | global::System.AttributeTargets.Field | " - + "global::System.AttributeTargets.Event | global::System.AttributeTargets.Interface | " - + "global::System.AttributeTargets.Delegate)]"; - var generatedFileHeader = BuildSharedGeneratedFileHeader(model.Interfaces, model.EmitGeneratedCodeMarkers); - var generatedCodeAttribute = GeneratedCodeAttribute; - var attributeText = $$""" - {{generatedFileHeader}} - namespace {{model.RefitInternalNamespace}} - { - /// Identifies generated members that should be preserved by tools that honor this attribute. - {{generatedCodeAttribute}} - [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] - [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] - {{attributeUsageLine}} - internal sealed class PreserveAttribute : global::System.Attribute - { - /// Gets or sets a value indicating whether all members should be preserved. - public bool AllMembers { get; set; } - - /// Gets or sets a value indicating whether preservation should be conditional. - public bool Conditional { get; set; } - } - } - - """; - - // add the attribute text - addSource("PreserveAttribute.g.cs", SourceText.From(attributeText, Encoding.UTF8)); - - const string dynamicDependencyLine = - "[System.Diagnostics.CodeAnalysis.DynamicDependency(" - + "System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.All, " - + "typeof(global::Refit.Implementation.Generated))]"; - - var generatedSource = $$""" - {{generatedFileHeader}} - namespace Refit.Implementation - { - /// Registers generated Refit factories for interfaces discovered at compile time. - {{generatedCodeAttribute}} - [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 - /// Registers generated Refit factories when the assembly is loaded. - [global::System.Diagnostics.CodeAnalysis.SuppressMessage( - "Usage", - "CA2255:The ModuleInitializer attribute should not be used in libraries", - Justification = "ModuleInitializer is used intentionally so generated Refit factories are registered when the assembly loads.")] - [System.Runtime.CompilerServices.ModuleInitializer] - {{dynamicDependencyLine}} - internal static void Initialize() - { - {{BuildGeneratedFactoryRegistrations(model.Interfaces)}} } - #endif - } - } - - """; - addSource("Generated.g.cs", ToSourceText(generatedSource)); + EmitPreserveAttribute(model, generatedFileHeader, addSource); + EmitGeneratedFactoryModule(model, generatedFileHeader, addSource); } /// Emits the generated implementation source for a single interface. @@ -158,24 +90,7 @@ public static SourceText EmitInterface(InterfaceModel model) uniqueNames.Reserve(model.MemberNames); var requestBuilderFieldName = uniqueNames.New("_requestBuilder"); var settingsFieldName = uniqueNames.New("_settings"); - var propertySource = BuildInterfaceProperties(model.Properties, model.SupportsNullable); - var refitMethodSource = BuildRefitMethods( - model.RefitMethods, - true, - model, - uniqueNames, - requestBuilderFieldName, - settingsFieldName); - var derivedRefitMethodSource = BuildRefitMethods( - model.DerivedRefitMethods, - false, - model, - uniqueNames, - requestBuilderFieldName, - settingsFieldName); - var nonRefitMethodSource = BuildNonRefitMethods(model.NonRefitMethods, model.SupportsNullable); - var disposableSource = BuildDisposableMethod(model.DisposeMethod); - var memberSource = propertySource + refitMethodSource + derivedRefitMethodSource + nonRefitMethodSource + disposableSource; + var memberSource = BuildInterfaceMemberSource(model, uniqueNames, requestBuilderFieldName, settingsFieldName); var typeParameterDocs = BuildTypeParameterDocumentation(model.Constraints, ClassMemberIndentation); var generatedCodeAttribute = GeneratedCodeAttribute; var settingsConstructorSource = BuildSettingsConstructor(model, settingsFieldName); @@ -183,7 +98,7 @@ public static SourceText EmitInterface(InterfaceModel model) ? "global::Refit.IRequestBuilder?" : "global::Refit.IRequestBuilder"; var source = $$""" - {{BuildGeneratedFileHeader(model.Nullability, model.EmitGeneratedCodeMarkers)}} + {{BuildGeneratedFileHeader(model.Nullability, model.EmitGeneratedCodeMarkers)}}{{BuildExternAliasDirectives(model.ExternAliases)}} namespace Refit.Implementation { /// Contains generated Refit implementation types. @@ -225,6 +140,52 @@ private sealed class {{model.Ns}}{{model.ClassDeclaration}} return ToSourceText(source); } + /// Concatenates the generated property, method, and dispose member blocks for an interface. + /// The interface model being emitted. + /// Contains the unique member names in the interface scope. + /// The unique generated field name that stores the request builder. + /// The unique generated field name that stores Refit settings. + /// The concatenated member source. + private static string BuildInterfaceMemberSource( + InterfaceModel model, + UniqueNameBuilder uniqueNames, + string requestBuilderFieldName, + string settingsFieldName) + { + var enumFormatterScope = new EnumFormatterScope(uniqueNames); + var propertySource = BuildInterfaceProperties(model.Properties, model.SupportsNullable); + var refitMethodSource = BuildRefitMethods( + model.RefitMethods, + true, + model, + uniqueNames, + requestBuilderFieldName, + settingsFieldName, + enumFormatterScope); + var derivedRefitMethodSource = BuildRefitMethods( + model.DerivedRefitMethods, + false, + model, + uniqueNames, + requestBuilderFieldName, + settingsFieldName, + enumFormatterScope); + var nonRefitMethodSource = BuildNonRefitMethods(model.NonRefitMethods, model.SupportsNullable); + var disposableSource = BuildDisposableMethod(model.DisposeMethod); + + // Concatenate the five member blocks through a pooled buffer instead of a five-operand '+', which would + // allocate a params string[] for the String.Concat overload. + return new PooledStringBuilder( + propertySource.Length + refitMethodSource.Length + derivedRefitMethodSource.Length + + nonRefitMethodSource.Length + disposableSource.Length) + .Append(propertySource) + .Append(refitMethodSource) + .Append(derivedRefitMethodSource) + .Append(nonRefitMethodSource) + .Append(disposableSource) + .ToString(); + } + /// Creates source text from generated source. /// The generated source. /// The generated source text. @@ -234,12 +195,13 @@ private sealed class {{model.Ns}}{{model.ClassDeclaration}} /// The generated attribute source. private static string BuildGeneratedCodeAttribute() { + // This generator assembly always carries a name and version, so no placeholder fallback is reachable. var assemblyName = typeof(Emitter).Assembly.GetName(); return "[global::System.CodeDom.Compiler.GeneratedCodeAttribute(" - + ToCSharpStringLiteral(assemblyName.Name ?? "Refit.Generator") + + ToCSharpStringLiteral(assemblyName.Name!) + ", " - + ToCSharpStringLiteral(assemblyName.Version?.ToString() ?? "0.0.0.0") + + ToCSharpStringLiteral(assemblyName.Version!.ToString()) + ")]"; } @@ -248,7 +210,14 @@ private static string BuildGeneratedCodeAttribute() /// The escaped XML documentation text. private static string ToXmlDocumentationText(string value) { - var builder = new StringBuilder(value.Length); + // Most identifiers and type names contain none of the XML metacharacters, so return them untouched + // instead of allocating a builder and copying character by character. + if (value.IndexOfAny(XmlEscapeChars) < 0) + { + return value; + } + + var builder = new PooledStringBuilder(value.Length); foreach (var c in value) { switch (c) @@ -282,6 +251,25 @@ private static string ToXmlDocumentationText(string value) return builder.ToString(); } + /// Builds the extern alias directives an interface's types require, if any. + /// The extern aliases the interface's types reference. + /// The directives, or an empty string when none are needed. + private static string BuildExternAliasDirectives(ImmutableEquatableArray aliases) + { + if (aliases.Count == 0) + { + return string.Empty; + } + + var builder = new PooledStringBuilder(); + foreach (var alias in aliases) + { + _ = builder.Append("extern alias ").Append(alias).AppendLine(";"); + } + + return builder.ToString(); + } + /// Builds the generated file header for an interface implementation. /// The nullable context for the generated source. /// Whether generated-code markers should be emitted. @@ -343,7 +331,7 @@ private static string BuildSharedGeneratedFileHeader( /// The generated factory registrations. private static string BuildGeneratedFactoryRegistrations(ImmutableEquatableArray interfaces) { - var registrations = new string[interfaces.Count * 2]; + var registrations = new string[interfaces.Count * RegistrationsPerInterface]; var count = 0; for (var i = 0; i < interfaces.Count; i++) { @@ -357,19 +345,21 @@ private static string BuildGeneratedFactoryRegistrations(ImmutableEquatableArray // The static modifier on a lambda is C# 9; older consumers must not see it. var lambdaModifier = interfaceModel.SupportsStaticLambdas ? "static " : string.Empty; - registrations[count++] = $$""" + registrations[count] = $$""" global::Refit.RestService.RegisterGeneratedFactory<{{interfaceModel.InterfaceDisplayName}}>( {{lambdaModifier}}(client, requestBuilder) => new {{generatedType}}(client, requestBuilder)); """; + count++; if (CanUseGeneratedSettingsFactory(interfaceModel)) { - registrations[count++] = $$""" + registrations[count] = $$""" global::Refit.RestService.RegisterGeneratedSettingsFactory<{{interfaceModel.InterfaceDisplayName}}>( {{lambdaModifier}}(client, settings) => new {{generatedType}}(client, settings)); """; + count++; } } @@ -457,7 +447,8 @@ private static string BuildInterfaceProperties( var source = BuildInterfaceProperty(properties[i], supportsNullable); if (source.Length != 0) { - parts[count++] = source; + parts[count] = source; + count++; } } @@ -471,6 +462,7 @@ private static string BuildInterfaceProperties( /// Contains the unique member names in the interface scope. /// The unique generated field name that stores the request builder. /// The unique generated field name that stores Refit settings. + /// The enum formatter scope for the interface. /// The generated method implementations. private static string BuildRefitMethods( ImmutableEquatableArray methods, @@ -478,7 +470,8 @@ private static string BuildRefitMethods( InterfaceModel interfaceModel, UniqueNameBuilder uniqueNames, string requestBuilderFieldName, - string settingsFieldName) + string settingsFieldName, + EnumFormatterScope enumFormatterScope) { if (methods.Count == 0) { @@ -494,7 +487,8 @@ private static string BuildRefitMethods( interfaceModel, uniqueNames, requestBuilderFieldName, - settingsFieldName); + settingsFieldName, + enumFormatterScope); } return ConcatParts(parts, parts.Length); @@ -532,7 +526,14 @@ private static string BuildNonRefitMethods( /// The escaped C# string literal. private static string ToCSharpStringLiteral(string value) { - var builder = new StringBuilder(value.Length + 2); + // The vast majority of emitted literals (query keys, header names, paths) need no escaping, so wrap them in + // quotes with a single concat instead of allocating a builder and appending each character. + if (!NeedsCSharpEscaping(value)) + { + return "\"" + value + "\""; + } + + var builder = new PooledStringBuilder(value.Length + StringLiteralQuoteLength); _ = builder.Append('"'); foreach (var c in value) { @@ -543,87 +544,20 @@ private static string ToCSharpStringLiteral(string value) return builder.ToString(); } - /// Builds the object[] literal that holds the method's argument values. - /// The method model being emitted. - /// The generated argument array literal. - private static string BuildArgumentsArrayLiteral(MethodModel methodModel) + /// Determines whether any character in a value requires C# string-literal escaping. + /// The value to inspect. + /// when at least one character needs an escape sequence. + private static bool NeedsCSharpEscaping(string value) { - var parameters = methodModel.Parameters.AsArray(); - if (parameters.Length == 0) - { - return "global::System.Array.Empty()"; - } - - const string prefix = "new object[] { "; - const string suffix = " }"; - var length = prefix.Length + suffix.Length + ((parameters.Length - 1) * 2); - for (var i = 0; i < parameters.Length; i++) + foreach (var c in value) { - length += 1 + parameters[i].MetadataName.Length; - } - - return CreateGeneratedString( - length, - parameters, - static (destination, values) => + if (EscapeSequence(c) is not null) { - var position = 0; - AppendText(destination, prefix, ref position); - for (var i = 0; i < values.Length; i++) - { - if (i > 0) - { - AppendText(destination, ", ", ref position); - } - - destination[position++] = '@'; - AppendText(destination, values[i].MetadataName, ref position); - } - - AppendText(destination, suffix, ref position); - }); - } - - /// Builds the optional generic Type[] argument for the request builder call. - /// The method model being emitted. - /// The generated generic type argument, or an empty string. - private static string BuildGenericTypesArgument(MethodModel methodModel) - { - var constraints = methodModel.Constraints.AsArray(); - if (constraints.Length == 0) - { - return string.Empty; - } - - const string prefix = ", new global::System.Type[] { "; - const string suffix = " }"; - var length = prefix.Length + suffix.Length + ((constraints.Length - 1) * 2); - for (var i = 0; i < constraints.Length; i++) - { - length += TypeOfPrefix.Length + constraints[i].DeclaredName.Length + 1; + return true; + } } - return CreateGeneratedString( - length, - constraints, - static (destination, values) => - { - var position = 0; - AppendText(destination, prefix, ref position); - for (var i = 0; i < values.Length; i++) - { - if (i > 0) - { - AppendText(destination, ", ", ref position); - } - - AppendText(destination, TypeOfPrefix, ref position); - AppendText(destination, values[i].DeclaredName, ref position); - destination[position++] = ')'; - } - - AppendText(destination, suffix, ref position); - }); + return false; } /// Builds a stub body for a non-Refit method that throws at runtime. @@ -696,329 +630,4 @@ private static string BuildDisposableMethod(bool shouldEmit) """; } - - /// Builds a cached field for non-generic method parameter types, when possible. - /// The method model being emitted. - /// Contains the unique member names in the interface scope. - /// The generated field source and field name, if one was generated. - private static (string Source, string? FieldName) BuildTypeParameterField( - MethodModel methodModel, - UniqueNameBuilder uniqueNames) - { - if (methodModel.Parameters.Count == 0 || ContainsGenericParameter(methodModel.Parameters)) - { - return (string.Empty, null); - } - - var typeParameterFieldName = uniqueNames.New(TypeParameterVariableName); - var typeList = BuildParameterTypeList(methodModel.Parameters); - var memberIndent = Indent(MethodMemberIndentation); - var source = $$""" - - - {{memberIndent}}/// Cached parameter type array for the generated {{ToXmlDocumentationText(methodModel.DeclaredMethod)}} method. - {{memberIndent}}private static readonly global::System.Type[] {{typeParameterFieldName}} = new global::System.Type[] { {{typeList}} }; - """; - return (source, typeParameterFieldName); - } - - /// 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) - { - for (var i = 0; i < parameters.Count; i++) - { - if (parameters[i].IsGeneric) - { - return true; - } - } - - return false; - } - - /// Builds the expression used to pass the method's parameter types to the request builder. - /// The parameter models to emit. - /// The cached field name, if one was generated. - /// The generated type parameter expression. - private static string BuildTypeParameterExpression( - ImmutableEquatableArray parameters, - string? cachedTypeParameterFieldName) => - parameters.Count == 0 - ? "global::System.Array.Empty()" - : cachedTypeParameterFieldName ?? $"new global::System.Type[] {{ {BuildParameterTypeList(parameters)} }}"; - - /// Builds the generated typeof(...) argument list for method parameters. - /// The parameter models to emit. - /// The generated parameter type list. - private static string BuildParameterTypeList(ImmutableEquatableArray parameters) - { - if (parameters.Count == 0) - { - return string.Empty; - } - - var length = (parameters.Count - 1) * 2; - for (var i = 0; i < parameters.Count; i++) - { - length += TypeOfPrefix.Length + parameters[i].Type.Length + 1; - } - - return CreateGeneratedString( - length, - parameters.AsArray(), - static (destination, values) => - { - var position = 0; - for (var i = 0; i < values.Length; i++) - { - if (i > 0) - { - AppendText(destination, ", ", ref position); - } - - AppendText(destination, TypeOfPrefix, ref position); - AppendText(destination, values[i].Type, ref position); - destination[position++] = ')'; - } - }); - } - - /// Builds the generated return statement for the reflection-backed Refit method path. - /// The method model being emitted. - /// The return statement prefix. - /// The generated return type. - /// The generated configure-await suffix. - /// The generated request-func local name. - /// The generated arguments-array local name. - /// The generated return statement. - private static string BuildRefitReturnStatement( - MethodModel methodModel, - string returnPrefix, - string returnType, - string configureAwait, - string funcLocal, - string argumentsLocal) - { - var bodyIndent = Indent(MethodBodyIndentation); - return methodModel.ReturnTypeMetadata == ReturnTypeInfo.SyncVoid - ? $"{bodyIndent}{funcLocal}(this.Client, {argumentsLocal});\n" - : $"{bodyIndent}{returnPrefix}({returnType}){funcLocal}(this.Client, {argumentsLocal}){configureAwait};\n"; - } - - /// Concatenates populated source fragments without allocating a trimmed array. - /// The source fragments. - /// The populated fragment count. - /// The concatenated source. - private static string ConcatParts(string[] parts, int count) - { - var length = 0; - for (var i = 0; i < count; i++) - { - length += parts[i].Length; - } - - return CreateGeneratedString( - length, - (Parts: parts, Count: count), - static (destination, state) => - { - var position = 0; - for (var i = 0; i < state.Count; i++) - { - AppendText(destination, state.Parts[i], ref position); - } - }); - } - - /// Joins populated source fragments without allocating a trimmed array. - /// The source fragments. - /// The populated fragment count. - /// The separator text. - /// The joined source. - private static string JoinParts(string[] parts, int count, string separator) - { - if (count == 0) - { - return string.Empty; - } - - var length = separator.Length * (count - 1); - for (var i = 0; i < count; i++) - { - length += parts[i].Length; - } - - return CreateGeneratedString( - length, - (Parts: parts, Count: count, Separator: separator), - static (destination, state) => - { - var position = 0; - for (var i = 0; i < state.Count; i++) - { - if (i > 0) - { - AppendText(destination, state.Separator, ref position); - } - - AppendText(destination, state.Parts[i], ref position); - } - }); - } - - /// Builds a generated indentation string. - /// The indentation level. - /// The generated indentation. - private static string Indent(int level) => new(' ', level * CharsPerIndentation); - -#if NETSTANDARD2_0 - /// Writes a C# string literal or the null keyword into a generated string buffer. - /// The target character buffer. - /// The value to quote, or . - /// The current write position. - private static void AppendLiteralOrNull(char[] destination, string? value, ref int position) - { - if (value is null) - { - AppendText(destination, NullLiteral, ref position); - return; - } - - destination[position++] = '"'; - foreach (var character in value) - { - var escape = EscapeSequence(character); - if (escape is null) - { - destination[position++] = character; - } - else - { - AppendText(destination, escape, ref position); - } - } - - destination[position++] = '"'; - } - - /// Writes the decimal rendering of a non-negative 32-bit integer into a generated string buffer. - /// The target character buffer. - /// The non-negative value to render (callers only pass CollectionFormat values). - /// The current write position. - private static void AppendInt32(char[] destination, int value, ref int position) - { - var end = position + Int32Length(value); - var write = end; - do - { - destination[--write] = (char)('0' + (value % DecimalRadix)); - value /= DecimalRadix; - } - while (value > 0); - - position = end; - } -#else - /// Writes a C# string literal or the null keyword into a generated string buffer. - /// The target character span. - /// The value to quote, or . - /// The current write position. - private static void AppendLiteralOrNull(Span destination, string? value, ref int position) - { - if (value is null) - { - AppendText(destination, NullLiteral, ref position); - return; - } - - destination[position++] = '"'; - foreach (var character in value) - { - var escape = EscapeSequence(character); - if (escape is null) - { - destination[position++] = character; - } - else - { - AppendText(destination, escape, ref position); - } - } - - destination[position++] = '"'; - } - - /// Writes the decimal rendering of a non-negative 32-bit integer into a generated string buffer. - /// The target character span. - /// The non-negative value to render (callers only pass CollectionFormat values). - /// The current write position. - private static void AppendInt32(Span destination, int value, ref int position) - { - var end = position + Int32Length(value); - var write = end; - do - { - destination[--write] = (char)('0' + (value % DecimalRadix)); - value /= DecimalRadix; - } - while (value > 0); - - position = end; - } -#endif - -#if NETSTANDARD2_0 - /// Creates a generated string using a pre-sized buffer. - /// The state type. - /// The string length. - /// The caller supplied state. - /// The buffer fill callback. - /// The generated string. - private static string CreateGeneratedString( - int length, - TState state, - GeneratedStringAction action) - { - var destination = new char[length]; - action(destination, state); - return new(destination); - } - - /// Appends text into a generated string buffer. - /// The target character buffer. - /// The text to append. - /// The current write position. - private static void AppendText(char[] destination, string text, ref int position) - { - text.CopyTo(0, destination, position, text.Length); - position += text.Length; - } -#else - /// Creates a generated string using . - /// The state type. - /// The string length. - /// The caller supplied state. - /// The buffer fill callback. - /// The generated string. - private static string CreateGeneratedString( - int length, - TState state, - GeneratedStringAction action) => - string.Create( - length, - (State: state, Action: action), - static (destination, context) => context.Action(destination, context.State)); - - /// Appends text into a generated string buffer. - /// The target character buffer. - /// The text to append. - /// The current write position. - private static void AppendText(Span destination, string text, ref int position) - { - text.AsSpan().CopyTo(destination[position..]); - position += text.Length; - } -#endif } diff --git a/src/InterfaceStubGenerator.Shared/ImmutableEquatableArrayFactory.cs b/src/InterfaceStubGenerator.Shared/ImmutableEquatableArrayFactory.cs index b79f5bad8..489422b8d 100644 --- a/src/InterfaceStubGenerator.Shared/ImmutableEquatableArrayFactory.cs +++ b/src/InterfaceStubGenerator.Shared/ImmutableEquatableArrayFactory.cs @@ -11,7 +11,10 @@ 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.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Type parameter is intentionally specified explicitly by callers.")] public static ImmutableEquatableArray Empty() where T : IEquatable => ImmutableEquatableArray.Empty; diff --git a/src/InterfaceStubGenerator.Shared/InterfaceStubGeneratorV2.cs b/src/InterfaceStubGenerator.Shared/InterfaceStubGeneratorV2.cs index 36d692de8..651ec5bd9 100644 --- a/src/InterfaceStubGenerator.Shared/InterfaceStubGeneratorV2.cs +++ b/src/InterfaceStubGenerator.Shared/InterfaceStubGeneratorV2.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; @@ -157,7 +158,7 @@ internal static bool TryGetGlobalOption( /// The generator initialization context. /// The collected candidate methods. private static IncrementalValueProvider> - CreateStandardHttpMethodCandidateProvider(IncrementalGeneratorInitializationContext context) + CreateStandardHttpMethodCandidateProvider(in IncrementalGeneratorInitializationContext context) { var deleteMethods = CreateHttpMethodCandidateProvider(context, DeleteAttributeMetadataName).Collect(); var getMethods = CreateHttpMethodCandidateProvider(context, GetAttributeMetadataName).Collect(); @@ -194,18 +195,25 @@ private static IncrementalValueProvider> /// The fully qualified metadata name. /// The matching method declarations. private static IncrementalValuesProvider CreateHttpMethodCandidateProvider( - IncrementalGeneratorInitializationContext context, + in IncrementalGeneratorInitializationContext context, string metadataName) => context.SyntaxProvider.ForAttributeWithMetadataName( metadataName, - static (syntax, _) => syntax is MethodDeclarationSyntax { Parent: InterfaceDeclarationSyntax }, + static (syntax, _) => IsInterfaceMethodDeclaration(syntax), static (generatorContext, _) => (MethodDeclarationSyntax)generatorContext.TargetNode); + /// Determines whether an attributed syntax node is a method declared directly on an interface. + /// The candidate syntax node carrying a Refit HTTP method attribute. + /// when the node is an interface method declaration. + [ExcludeFromCodeCoverage] + private static bool IsInterfaceMethodDeclaration(SyntaxNode syntax) => + syntax is MethodDeclarationSyntax { Parent: InterfaceDeclarationSyntax }; + /// Combines standard HTTP method candidate arrays into one array. /// The candidate arrays. /// The combined candidates. private static ImmutableArray CombineStandardHttpMethodCandidates( - StandardHttpMethodCandidates candidates) + in StandardHttpMethodCandidates candidates) { #if ROSLYN_5 return @@ -302,12 +310,12 @@ private static bool IsStandardHttpMethodAttributeName(NameSyntax name) { var identifier = GetRightmostIdentifier(name); const string attributeSuffix = "Attribute"; - if (identifier.EndsWith(attributeSuffix, StringComparison.Ordinal)) + if (!identifier.EndsWith(attributeSuffix, StringComparison.Ordinal)) { - identifier = identifier[..^attributeSuffix.Length]; + identifier += attributeSuffix; } - return identifier is "Delete" or "Get" or "Head" or "Options" or "Patch" or "Post" or "Put"; + return Parser.MapKnownHttpVerb(identifier) is not null; } /// Gets the rightmost identifier text from an attribute name. diff --git a/src/InterfaceStubGenerator.Shared/Models/EnumFormatMemberModel.cs b/src/InterfaceStubGenerator.Shared/Models/EnumFormatMemberModel.cs new file mode 100644 index 000000000..db2ce9231 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/EnumFormatMemberModel.cs @@ -0,0 +1,9 @@ +// 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; + +/// One enum member resolved at compile time for generated value formatting. +/// The declared enum member name. +/// The [EnumMember(Value = ...)] override honored by the default formatter, or null. +internal sealed record EnumFormatMemberModel(string MemberName, string? EnumMemberValue); diff --git a/src/InterfaceStubGenerator.Shared/Models/FormFieldModel.cs b/src/InterfaceStubGenerator.Shared/Models/FormFieldModel.cs index 1621ed2e6..8ee83a87f 100644 --- a/src/InterfaceStubGenerator.Shared/Models/FormFieldModel.cs +++ b/src/InterfaceStubGenerator.Shared/Models/FormFieldModel.cs @@ -10,10 +10,15 @@ namespace Refit.Generator; /// The [Query] value format, or . /// The explicit collection format underlying value, or to use the settings default. /// Whether a value should be serialized as an empty field. +/// Whether the field value can be and therefore needs a null guard. +/// The reflection-free scalar value fast-path descriptor, or when the +/// field is not a simple scalar (a collection or complex property) and the body must keep using the descriptor path. internal sealed record FormFieldModel( string PropertyName, string? ExplicitName, string? PrefixSegment, string? Format, int? CollectionFormatValue, - bool SerializeNull); + bool SerializeNull, + bool CanBeNull, + InlineValueFormatModel? ValueFormat); diff --git a/src/InterfaceStubGenerator.Shared/Models/InlineFormatKind.cs b/src/InterfaceStubGenerator.Shared/Models/InlineFormatKind.cs new file mode 100644 index 000000000..0edd30156 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/InlineFormatKind.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; + +/// Classifies how generated code formats a statically-known value without reflection. +internal enum InlineFormatKind +{ + /// No reflection-free rendering is provable; always call the configured URL parameter formatter. + FormatterOnly, + + /// The value is a string and is used as-is. + String, + + /// The value renders via ToString() (bool, char, and non-formattable values), ignoring any format. + ToStringOnly, + + /// The value renders via GeneratedRequestRunner.FormatInvariant with the compile-time format. + Formattable, + + /// The value renders through a generated per-enum switch that resolves member names at compile time. + Enum +} diff --git a/src/InterfaceStubGenerator.Shared/Models/InlineValueFormatModel.cs b/src/InterfaceStubGenerator.Shared/Models/InlineValueFormatModel.cs new file mode 100644 index 000000000..5253ad077 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/InlineValueFormatModel.cs @@ -0,0 +1,28 @@ +// 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; + +/// Compile-time metadata describing how generated code formats one URL value without reflection. +/// The reflection-free rendering strategy for the value. +/// The compile-time format from [Query(Format = ...)], or null when none applies. +/// The fully-qualified non-nullable value type, used to emit enum formatting helpers. +/// Whether the value is a nullable value type requiring a .Value unwrap. +/// The compile-time-resolved enum members for , or null. +internal sealed record InlineValueFormatModel( + InlineFormatKind Kind, + string? Format, + string TypeName, + bool IsNullableValueType, + ImmutableEquatableArray? EnumMembers) +{ + /// Gets a value indicating whether the value is a non-nullable, unformatted integer whose + /// ISpanFormattable output is inherently URL-safe (digits and -), so generated path code can format it + /// straight into a stack buffer with no intermediate string and no escaping. Only set for net6+ consumers. + public bool IsUrlSafeSpanFormattable { get; init; } + + /// Gets a value indicating whether the value is a non-nullable ISpanFormattable that generated path + /// code can format into a stack buffer and escape span-to-string without the intermediate formatted string. Only set + /// for net10+ consumers, where Uri.EscapeDataString(ReadOnlySpan<char>) exists. + public bool IsSpanFormattableEscapable { get; init; } +} diff --git a/src/InterfaceStubGenerator.Shared/Models/InterfaceGenerationContext.cs b/src/InterfaceStubGenerator.Shared/Models/InterfaceGenerationContext.cs index be8ed8410..b4365bdbd 100644 --- a/src/InterfaceStubGenerator.Shared/Models/InterfaceGenerationContext.cs +++ b/src/InterfaceStubGenerator.Shared/Models/InterfaceGenerationContext.cs @@ -1,27 +1,51 @@ // 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; namespace Refit.Generator; -/// Bundles the generation context shared by every interface processed in a single pass. +/// Transient scaffolding shared by every interface processed in a single generation pass. +/// This carries the compilation and well-known symbols used only while a pass runs. It is created inside the +/// parse transform and discarded before any model is produced, so it must never flow through the incremental pipeline. +/// It is a reference type (passed by reference, not copied) and its collections are plain concrete types - the emitted +/// models are the equatable, cache-safe values, never this. /// The list that collects diagnostics produced during processing. /// The display name of the generated preserve attribute. /// The IDisposable symbol, if available. /// The Refit HTTP method attribute symbol. -/// The System.IFormattable symbol used to classify inline-eligible path parameter types, if available. +/// The System.IFormattable symbol used to classify inline-eligible value types, if available. +/// The System.ISpanFormattable symbol (net6+ consumers only) enabling the path fast path, or null. +/// Whether the target exposes Uri.EscapeDataString(ReadOnlySpan<char>) (net9+), enabling the escaping span fast path. /// Whether generated request construction is enabled. /// Whether generated files include generated-code analyzer skip markers. /// Whether the compilation supports nullable reference types. /// Whether the compilation supports the static lambda modifier (C# 9). -internal readonly record struct InterfaceGenerationContext( +/// Whether the compilation supports collection expressions (C# 12), letting the +/// path builder receive its replacements as a stack-allocatable [...] span instead of an array. +/// The compilation, used to resolve types behind an extern alias, or null. +/// The Refit.IReturnTypeAdapter`2 symbol, or null when Refit is unavailable. +/// The types implementing IReturnTypeAdapter discovered in the compilation. +/// The per-interface collector recording the extern aliases used while qualifying its types. +/// A pass-wide cache mapping an assembly symbol to its resolved extern alias (or null), +/// shared across every interface so the extern-alias metadata-reference lookup runs once per assembly, not per type node. +internal sealed record InterfaceGenerationContext( List Diagnostics, string PreserveAttributeDisplayName, ISymbol? DisposableInterfaceSymbol, INamedTypeSymbol HttpMethodBaseAttributeSymbol, INamedTypeSymbol? FormattableSymbol, + INamedTypeSymbol? SpanFormattableSymbol, + bool SupportsSpanEscape, bool GeneratedRequestBuilding, bool EmitGeneratedCodeMarkers, bool SupportsNullable, - bool SupportsStaticLambdas); + bool SupportsStaticLambdas, + bool SupportsCollectionExpressions, + CSharpCompilation? Compilation, + INamedTypeSymbol? ReturnTypeAdapterInterface, + INamedTypeSymbol[] ReturnTypeAdapters, + HashSet ExternAliases, + Dictionary AssemblyAliasCache); diff --git a/src/InterfaceStubGenerator.Shared/Models/InterfaceModel.cs b/src/InterfaceStubGenerator.Shared/Models/InterfaceModel.cs index ba3fe47e8..546bf5caf 100644 --- a/src/InterfaceStubGenerator.Shared/Models/InterfaceModel.cs +++ b/src/InterfaceStubGenerator.Shared/Models/InterfaceModel.cs @@ -15,6 +15,8 @@ namespace Refit.Generator; /// Whether generated files include generated-code analyzer skip markers. /// Whether the consumer compilation supports nullable reference type syntax. /// Whether the consumer compilation supports the static lambda modifier (C# 9). +/// Whether the consumer compilation supports collection expressions (C# 12), +/// so path replacements can be passed as a stack-allocatable [...] span instead of an array. /// The generic type constraints of the interface. /// The names of the interface members. /// The interface properties implemented by the generated stub. @@ -23,6 +25,7 @@ namespace Refit.Generator; /// The Refit methods inherited from base interfaces. /// The nullable reference type context of the interface. /// A value indicating whether the interface declares a dispose method. +/// The extern aliases the interface's types require, emitted as extern alias directives. internal sealed record InterfaceModel( string PreserveAttributeDisplayName, string FileName, @@ -35,6 +38,7 @@ internal sealed record InterfaceModel( bool EmitGeneratedCodeMarkers, bool SupportsNullable, bool SupportsStaticLambdas, + bool SupportsCollectionExpressions, ImmutableEquatableArray Constraints, ImmutableEquatableArray MemberNames, ImmutableEquatableArray Properties, @@ -42,4 +46,5 @@ internal sealed record InterfaceModel( ImmutableEquatableArray RefitMethods, ImmutableEquatableArray DerivedRefitMethods, Nullability Nullability, - bool DisposeMethod); + bool DisposeMethod, + ImmutableEquatableArray ExternAliases); diff --git a/src/InterfaceStubGenerator.Shared/Models/KnownTypeConstraint.cs b/src/InterfaceStubGenerator.Shared/Models/KnownTypeConstraint.cs index ed35f4c3e..d9ce4ec0f 100644 --- a/src/InterfaceStubGenerator.Shared/Models/KnownTypeConstraint.cs +++ b/src/InterfaceStubGenerator.Shared/Models/KnownTypeConstraint.cs @@ -8,8 +8,8 @@ namespace Refit.Generator; /// The set of well-known constraints that can be applied to a generic type parameter. [Flags] [SuppressMessage( - "Minor Code Smell", - "S2342:Enumeration types should comply with a naming convention", + "Naming", + "SST1319:Enumeration types should be PascalCase", Justification = "Name retained; renaming the type to a plural would require changes to Emitter.cs and Parser.cs which are outside the scope of this change.")] internal enum KnownTypeConstraint { @@ -17,7 +17,7 @@ internal enum KnownTypeConstraint None = 0, /// The reference type (class) constraint. - Class = 1 << 0, + Class = 1, /// The unmanaged constraint. Unmanaged = 1 << 1, diff --git a/src/InterfaceStubGenerator.Shared/Models/MethodModel.cs b/src/InterfaceStubGenerator.Shared/Models/MethodModel.cs index f6b1f048e..7863be786 100644 --- a/src/InterfaceStubGenerator.Shared/Models/MethodModel.cs +++ b/src/InterfaceStubGenerator.Shared/Models/MethodModel.cs @@ -13,6 +13,10 @@ namespace Refit.Generator; /// The method parameters. /// The generic type constraints for the method. /// A value indicating whether the method is an explicit interface implementation. +/// Whether the interface method declares [RequiresUnreferencedCode], which a +/// reflection-fallback implementation must mirror instead of suppressing IL2026. +/// Whether the interface method declares [RequiresDynamicCode], which a +/// reflection-fallback implementation must mirror instead of suppressing IL3050. internal sealed record MethodModel( string Name, string ReturnType, @@ -22,4 +26,6 @@ internal sealed record MethodModel( RequestModel Request, ImmutableEquatableArray Parameters, ImmutableEquatableArray Constraints, - bool IsExplicitInterface); + bool IsExplicitInterface, + bool RequiresUnreferencedCode, + bool RequiresDynamicCode); diff --git a/src/InterfaceStubGenerator.Shared/Models/MultipartPartKind.cs b/src/InterfaceStubGenerator.Shared/Models/MultipartPartKind.cs new file mode 100644 index 000000000..5255bd9d9 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/MultipartPartKind.cs @@ -0,0 +1,40 @@ +// 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 one multipart part is added to the generated MultipartFormDataContent. +/// +/// Each value maps to one arm of the reflection request builder's AddMultipartItem dispatch, resolved statically +/// from the part's declared type. There is deliberately no serialize arm: a part whose declared type would fall through +/// to the content serializer is not statically dispatchable with byte parity, so the whole method keeps using the +/// reflection request builder instead. +/// +internal enum MultipartPartKind +{ + /// The value is already an and is added verbatim. + HttpContent, + + /// The value is a Refit.MultipartItem (or subclass) added via its ToContent(). + MultipartItem, + + /// The value is a wrapped in a StreamContent. + Stream, + + /// The value is a wrapped in a StringContent. + String, + + /// The value is a opened into a StreamContent. + FileInfo, + + /// The value is a array wrapped in a ByteArrayContent. + ByteArray, + + /// The value is a date/time or rendered by the form URL-encoded formatter. + Formattable, + + /// The value is a sealed or value type (a bool, enum, or sealed DTO) written through the content serializer, + /// matching the reflection builder's AddSerializedMultipartItem serializer fallback. + Serialized +} diff --git a/src/InterfaceStubGenerator.Shared/Models/MultipartPartModel.cs b/src/InterfaceStubGenerator.Shared/Models/MultipartPartModel.cs new file mode 100644 index 000000000..e212b206c --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/MultipartPartModel.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; + +/// Reflection-free metadata describing how one method parameter becomes a multipart form part. +/// +/// All Roslyn symbols are pre-resolved to display strings and names so the incremental generator cache holds only +/// value-equatable data, matching and . +/// +/// The per-declared-type dispatch arm the part is added through. +/// The multipart form field name (the reflection builder's parameterName): the +/// [AliasAs] name or the declared parameter name. +/// The multipart file name (the reflection builder's fileName): the obsolete +/// [AttachmentName] name when present, otherwise the same as . +/// Whether the declared type is an IEnumerable<T> of a reference-typed element +/// (so it is added as one part per element, mirroring the reflection builder's IEnumerable<object> check). +internal sealed record MultipartPartModel( + MultipartPartKind Kind, + string FieldName, + string FileName, + bool IsEnumerable); diff --git a/src/InterfaceStubGenerator.Shared/Models/PathObjectBindingModel.cs b/src/InterfaceStubGenerator.Shared/Models/PathObjectBindingModel.cs new file mode 100644 index 000000000..b7f9da74d --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/PathObjectBindingModel.cs @@ -0,0 +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. + +namespace Refit.Generator; + +/// One dotted {param.Prop} path placeholder bound to a declared property of the enclosing parameter. +/// The placeholder's range within the path template. +/// The member access off the parameter (@param.PropertyClrName); a nested +/// {a.b.c} binding is a null-conditional chain such as b?.c. +/// The first (top-level) CLR property in the access, excluded from the residual query so a +/// nested binding marks the whole top-level property consumed. +/// The fully-qualified property type, passed to the URL parameter formatter. +/// The reflection-free rendering strategy for the property value. +/// Whether the property value requires a null check before formatting. +internal sealed record PathObjectBindingModel( + Range Location, + string PropertyClrName, + string TopLevelClrName, + string PropertyType, + InlineValueFormatModel ValueFormat, + bool PropertyCanBeNull); diff --git a/src/InterfaceStubGenerator.Shared/Models/QueryConverterModel.cs b/src/InterfaceStubGenerator.Shared/Models/QueryConverterModel.cs new file mode 100644 index 000000000..3e4d5a981 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/QueryConverterModel.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.Generator; + +/// Describes a query parameter flattened by a user-supplied IQueryConverter<T>. +/// The fully-qualified converter type, cached and invoked by the generated method. +/// The compile-time key prefix from the parameter's [Query(Prefix)], or the empty +/// string, passed to the converter to prepend to every key it writes. +internal sealed record QueryConverterModel( + string ConverterTypeName, + string KeyPrefix); diff --git a/src/InterfaceStubGenerator.Shared/Models/QueryDictionaryModel.cs b/src/InterfaceStubGenerator.Shared/Models/QueryDictionaryModel.cs new file mode 100644 index 000000000..08a5709f2 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/QueryDictionaryModel.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; + +/// Parsed metadata for a query parameter whose entries come from an IDictionary<TKey, TValue>. +/// The fully-qualified key type, which is also the attribute provider the reflection +/// request builder passes when formatting a key. +/// The reflection-free rendering strategy for a key. +/// Whether a value requires a null check; the reflection builder omits null values. +/// The enclosing parameter's compile-time prefix + delimiter, or null. +/// The flattened property descriptors of a sealed or value complex value type, so each +/// entry recurses into its value under the entryKey.property key exactly as the reflection builder's nested +/// BuildQueryMap does; null for a simple value, which renders as a single entryKey=value pair. +internal sealed record QueryDictionaryModel( + string KeyTypeName, + InlineValueFormatModel KeyFormat, + bool ValueCanBeNull, + string? PrefixSegment, + ImmutableEquatableArray? ValueProperties = null); diff --git a/src/InterfaceStubGenerator.Shared/Models/QueryObjectCollectionModel.cs b/src/InterfaceStubGenerator.Shared/Models/QueryObjectCollectionModel.cs new file mode 100644 index 000000000..4b797b073 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/QueryObjectCollectionModel.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. +namespace Refit.Generator; + +/// Describes a flattened query-object property whose value is a collection of simple elements. +/// The property's own [Query(CollectionFormat)] value, or null when it +/// inherits the enclosing parameter's collection format and then RefitSettings.CollectionFormat. +/// Whether an element requires a null check before formatting. +/// The fully-qualified declared collection type, used as the attribute provider and +/// type when the custom formatter renders each element. Mirrors the reflection builder, which passes +/// propertyInfo.PropertyType for the element pass. +/// +/// The element rendering strategy is carried by the enclosing . +/// A collection property carrying [Query(Format)] is not represented here: the reflection builder stringifies +/// the whole collection through the form formatter instead, so those keep using the reflection request builder. +/// +internal sealed record QueryObjectCollectionModel( + int? CollectionFormatValue, + bool ElementCanBeNull, + string PropertyTypeName); diff --git a/src/InterfaceStubGenerator.Shared/Models/QueryObjectPropertyModel.cs b/src/InterfaceStubGenerator.Shared/Models/QueryObjectPropertyModel.cs new file mode 100644 index 000000000..63a1b45f7 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/QueryObjectPropertyModel.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. +namespace Refit.Generator; + +/// One public readable property flattened out of a query object into a query pair. +/// The declared CLR property name, formatted by the key formatter unless aliased. +/// The [AliasAs] name, which always wins and bypasses the key formatter, or null. +/// The System.Text.Json [JsonPropertyName], honored only when +/// RefitSettings.HonorContentSerializerPropertyNamesInQuery is set (mirroring the reflection builder, which +/// resolves the same name through the configured content serializer), or null. +/// The compile-time prefix + delimiter prepended to the key, or null. +/// This combines the enclosing parameter's [Query(Prefix)] with the property's own. +/// Whether a null value emits a bare key= instead of being omitted. +/// Whether the property value requires a null check before formatting. +/// The property's [Query(Format)], applied by the form-url-encoded +/// parameter formatter before the URL parameter formatter runs, or null. +/// The reflection-free rendering strategy for the value, or for each element when +/// is set. +/// The collection descriptor when the property is a collection of simple elements, or null +/// for a scalar property. When set, describes each element and +/// describes the collection reference. +/// The flattened properties of a nested concrete class/struct property, or null. When set, this +/// property contributes no value of its own; its children compose their keys under this property's key. Its own +/// holds only the property-level [Query(Prefix)], without any parameter prefix. +/// Whether a nested property is a nullable value type (Nullable<T>), so its +/// children are accessed through .Value after the null check rather than off the value directly. +/// The dictionary descriptor when the property is an IDictionary<simple, simple>, or +/// null. When set the property's entries expand under this property's key, one key.entryKey=value pair each. +internal sealed record QueryObjectPropertyModel( + string ClrName, + string? ExplicitName, + string? SerializerName, + string? PrefixSegment, + bool SerializeNull, + bool CanBeNull, + string? PropertyFormat, + InlineValueFormatModel ValueFormat, + QueryObjectCollectionModel? Collection = null, + ImmutableEquatableArray? Nested = null, + bool NestedThroughValue = false, + QueryDictionaryModel? Dictionary = null); diff --git a/src/InterfaceStubGenerator.Shared/Models/QueryParameterModel.cs b/src/InterfaceStubGenerator.Shared/Models/QueryParameterModel.cs new file mode 100644 index 000000000..67dd47af0 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/QueryParameterModel.cs @@ -0,0 +1,34 @@ +// 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 query-binding metadata for one query-bound method parameter. +/// The query key: the [AliasAs] name or the declared parameter name, verbatim. +/// How the parameter renders into the query string. +/// Whether the raw value is stringified via ToString() before formatting, +/// mirroring [Query(TreatAsString = true)] and an explicitly empty Format. +/// Whether values pass through verbatim because the parameter carries [Encoded]. +/// The explicit CollectionFormat underlying value, or null to use the settings default. +/// Whether collection elements require a null check before formatting. +/// The reflection-free rendering strategy for the value or collection element. +/// The flattened properties when is +/// ; otherwise null. +/// The key metadata when is +/// ; otherwise null. renders the values. +/// The converter metadata when is +/// ; otherwise null. +/// The delimiter joining nested object keys (the parameter's [Query] delimiter, +/// default "."), used when contains nested properties. +internal sealed record QueryParameterModel( + string Key, + QueryParameterShape Shape, + bool TreatAsString, + bool PreEncoded, + int? CollectionFormatValue, + bool ElementCanBeNull, + InlineValueFormatModel ValueFormat, + ImmutableEquatableArray? ObjectProperties = null, + QueryDictionaryModel? Dictionary = null, + QueryConverterModel? Converter = null, + string NestingDelimiter = "."); diff --git a/src/InterfaceStubGenerator.Shared/Models/QueryParameterShape.cs b/src/InterfaceStubGenerator.Shared/Models/QueryParameterShape.cs new file mode 100644 index 000000000..a6aecfef0 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Models/QueryParameterShape.cs @@ -0,0 +1,29 @@ +// 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 query-bound parameter renders into the query string. +internal enum QueryParameterShape +{ + /// A single key=value pair. + Scalar, + + /// A collection expanded per the effective CollectionFormat. + Collection, + + /// A single valueless flag (?name) from [QueryName]. + Flag, + + /// A collection of valueless flags, one per element. + FlagCollection, + + /// An object whose public readable properties are flattened into individual query pairs. + Object, + + /// A dictionary whose entries become one query pair each, keyed by the formatted dictionary key. + Dictionary, + + /// A value flattened by a user-supplied IQueryConverter<T> named with [QueryConverter]. + Converter +} diff --git a/src/InterfaceStubGenerator.Shared/Models/RequestModel.cs b/src/InterfaceStubGenerator.Shared/Models/RequestModel.cs index ccc100e6c..5c0200d90 100644 --- a/src/InterfaceStubGenerator.Shared/Models/RequestModel.cs +++ b/src/InterfaceStubGenerator.Shared/Models/RequestModel.cs @@ -11,6 +11,9 @@ namespace Refit.Generator; /// 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 closed IReturnTypeAdapter type expression when the return type is +/// surfaced by an adapter, so the generated method emits an Adapt call; otherwise . The +/// result-type fields already carry the adapter's wrapped result type. /// The static headers parsed from inherited interfaces, the declaring interface, and the method. /// The parsed request parameter bindings. internal sealed record RequestModel( @@ -21,6 +24,7 @@ internal sealed record RequestModel( bool IsApiResponse, bool ShouldDisposeResponse, bool CanGenerateInline, + string? AdapterTypeExpression, ImmutableEquatableArray StaticHeaders, ImmutableEquatableArray Parameters) { @@ -33,6 +37,21 @@ internal sealed record RequestModel( false, true, false, + null, ImmutableEquatableArray.Empty, ImmutableEquatableArray.Empty); + + /// Gets a value indicating whether the method is a [Multipart] request whose form parts are + /// constructed inline. When set, the generated method builds a MultipartFormDataContent as the request body + /// instead of following the normal single-body path. + public bool IsMultipart { get; init; } + + /// Gets the multipart boundary text, used only when is set. Mirrors the + /// reflection builder's boundary selection: the [Multipart(boundary)] argument, or the attribute default. + public string MultipartBoundary { get; init; } = string.Empty; + + /// Gets the System.UriFormat value from the method's [QueryUriFormat] attribute, or null when + /// absent. When set, the built path and query are re-encoded with this mode, matching the reflection builder's final + /// Uri.GetComponents(PathAndQuery, QueryUriFormat) pass. + public int? QueryUriFormat { get; init; } } diff --git a/src/InterfaceStubGenerator.Shared/Models/RequestParameterKind.cs b/src/InterfaceStubGenerator.Shared/Models/RequestParameterKind.cs index 286d460d2..37d175db9 100644 --- a/src/InterfaceStubGenerator.Shared/Models/RequestParameterKind.cs +++ b/src/InterfaceStubGenerator.Shared/Models/RequestParameterKind.cs @@ -25,5 +25,11 @@ internal enum RequestParameterKind CancellationToken, /// The parameter supplies a value for a placeholder in the path. - Path + Path, + + /// The parameter supplies one or more query string values or flags. + Query, + + /// The parameter supplies one (or, for an enumerable, each) part of a multipart form body. + MultipartPart } diff --git a/src/InterfaceStubGenerator.Shared/Models/RequestParameterModel.cs b/src/InterfaceStubGenerator.Shared/Models/RequestParameterModel.cs index 7655217f8..24ef93de8 100644 --- a/src/InterfaceStubGenerator.Shared/Models/RequestParameterModel.cs +++ b/src/InterfaceStubGenerator.Shared/Models/RequestParameterModel.cs @@ -32,4 +32,34 @@ internal sealed record RequestParameterModel( /// body type is not eligible and the reflection-based form path must be used. /// public ImmutableEquatableArray? FormFields { get; init; } + + /// + /// Gets the query-binding metadata when this parameter feeds the query string — set for + /// parameters and for + /// parameters that also carry [Query]. + /// + public QueryParameterModel? Query { get; init; } + + /// Gets the reflection-free rendering strategy for a path parameter value, or for non-path parameters. + public InlineValueFormatModel? ValueFormat { get; init; } + + /// Gets a value indicating whether a path parameter value passes through verbatim because the parameter carries [Encoded]. + public bool PreEncoded { get; init; } + + /// Gets the literal prefix prepended to a header value, or . Set for an + /// [Authorize] parameter, whose Authorization header value is "{scheme} " + value. + public string? HeaderValuePrefix { get; init; } + + /// Gets a value indicating whether a path parameter binds a round-trip {**param} catch-all whose + /// value is split on / with each segment formatted and escaped, preserving the separators. + public bool IsRoundTrip { get; init; } + + /// Gets the dotted {param.Prop} path placeholder bindings for an object path parameter, or + /// . When set, the parameter contributes no direct path value; each binding fills its own + /// placeholder with a formatted property value. + public ImmutableEquatableArray? PathObjectBindings { get; init; } + + /// Gets the multipart part descriptor when this parameter contributes a [Multipart] form part — + /// set for parameters and otherwise. + public MultipartPartModel? MultipartPart { get; init; } } diff --git a/src/InterfaceStubGenerator.Shared/Models/ReturnTypeInfo.cs b/src/InterfaceStubGenerator.Shared/Models/ReturnTypeInfo.cs index 82d558b44..7eb3860cb 100644 --- a/src/InterfaceStubGenerator.Shared/Models/ReturnTypeInfo.cs +++ b/src/InterfaceStubGenerator.Shared/Models/ReturnTypeInfo.cs @@ -18,6 +18,9 @@ internal enum ReturnTypeInfo /// The method returns an IAsyncEnumerable stream. AsyncEnumerable, + /// The method returns an IObservable<T> (a cold observable that sends per subscription). + Observable, + /// The method returns void synchronously. SyncVoid } diff --git a/src/InterfaceStubGenerator.Shared/Parser.Adapters.cs b/src/InterfaceStubGenerator.Shared/Parser.Adapters.cs new file mode 100644 index 000000000..2c5a1270d --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Parser.Adapters.cs @@ -0,0 +1,281 @@ +// 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 System.Threading; +using Microsoft.CodeAnalysis; + +namespace Refit.Generator; + +/// Parses candidate interfaces and methods into the models used to generate Refit stubs. +/// Discovery and matching for IReturnTypeAdapter implementations, so generated methods surface custom +/// return types (for example IObservable<T> or Result<T>) with a direct Adapt call. +internal static partial class Parser +{ + /// The metadata name of the Refit.IReturnTypeAdapter`2 interface. + private const string ReturnTypeAdapterMetadataName = "Refit.IReturnTypeAdapter`2"; + + /// Resolves the Refit.IReturnTypeAdapter`2 interface symbol, or null when Refit is unavailable. + /// The compilation to resolve against. + /// The interface symbol, or . + internal static INamedTypeSymbol? ResolveReturnTypeAdapterInterface(Compilation compilation) => + compilation.GetTypeByMetadataName(ReturnTypeAdapterMetadataName); + + /// Discovers the source-declared types that implement IReturnTypeAdapter in the compilation. + /// The compilation whose source assembly is scanned. + /// The resolved IReturnTypeAdapter`2 symbol, or null. + /// A token to observe while scanning. + /// The adapter type definitions, or an empty array when none exist. + internal static INamedTypeSymbol[] DiscoverReturnTypeAdapters( + Compilation compilation, + INamedTypeSymbol? adapterInterface, + CancellationToken cancellationToken) + { + if (adapterInterface is null) + { + return []; + } + + var adapters = new List(); + CollectReturnTypeAdapters(compilation.Assembly.GlobalNamespace, adapterInterface, adapters, cancellationToken); + return [.. adapters]; + } + + /// Recursively collects types implementing IReturnTypeAdapter from a namespace. + /// The namespace to scan. + /// The resolved IReturnTypeAdapter`2 symbol. + /// The accumulator for discovered adapter types. + /// A token to observe while scanning. + private static void CollectReturnTypeAdapters( + INamespaceSymbol namespaceSymbol, + INamedTypeSymbol adapterInterface, + List adapters, + CancellationToken cancellationToken) + { + foreach (var member in namespaceSymbol.GetMembers()) + { + cancellationToken.ThrowIfCancellationRequested(); + + // A namespace's members are either nested namespaces or named types; anything that is not a nested + // namespace is a named type. + if (member is INamespaceSymbol nestedNamespace) + { + CollectReturnTypeAdapters(nestedNamespace, adapterInterface, adapters, cancellationToken); + } + else + { + CollectReturnTypeAdapterType((INamedTypeSymbol)member, adapterInterface, adapters); + } + } + } + + /// Adds a type and its nested types to the adapter list when they implement IReturnTypeAdapter. + /// The type to inspect. + /// The resolved IReturnTypeAdapter`2 symbol. + /// The accumulator for discovered adapter types. + private static void CollectReturnTypeAdapterType( + INamedTypeSymbol type, + INamedTypeSymbol adapterInterface, + List adapters) + { + if (type.TypeKind is TypeKind.Class or TypeKind.Struct + && !type.IsAbstract + && FindImplementedAdapterInterface(type, adapterInterface) is not null) + { + adapters.Add(type); + } + + foreach (var nested in type.GetTypeMembers()) + { + CollectReturnTypeAdapterType(nested, adapterInterface, adapters); + } + } + + /// Finds a registered adapter that surfaces the method's return type and its wrapped result type. + /// The declared return type of the interface method. + /// The generation context carrying the discovered adapters. + /// The closed adapter type to instantiate when matched. + /// The result type the HTTP call materializes when matched. + /// when a discovered adapter surfaces the return type; otherwise . + private static bool TryMatchReturnTypeAdapter( + ITypeSymbol returnType, + InterfaceGenerationContext context, + out INamedTypeSymbol closedAdapter, + out ITypeSymbol resultType) + { + closedAdapter = null!; + resultType = null!; + + var adapterInterface = context.ReturnTypeAdapterInterface; + if (adapterInterface is null + || context.ReturnTypeAdapters.Length == 0 + || returnType is not INamedTypeSymbol namedReturn) + { + return false; + } + + foreach (var adapter in context.ReturnTypeAdapters) + { + var closed = CloseAdapterForReturn(adapter, namedReturn, adapterInterface); + if (closed is null) + { + continue; + } + + var implemented = FindConstructedAdapterInterface(closed, namedReturn, adapterInterface); + if (implemented is not null) + { + closedAdapter = closed; + resultType = implemented.TypeArguments[1]; + return true; + } + } + + return false; + } + + /// Closes an adapter definition over the return type's type arguments, or returns it when non-generic. + /// The adapter type definition. + /// The declared return type supplying the type arguments. + /// The resolved IReturnTypeAdapter`2 symbol. + /// The closed adapter type, or when it cannot surface the return type. + private static INamedTypeSymbol? CloseAdapterForReturn( + INamedTypeSymbol adapter, + INamedTypeSymbol returnType, + INamedTypeSymbol adapterInterface) + { + if (adapter.TypeParameters.IsEmpty) + { + return adapter; + } + + // Single-wrapper heuristic: the adapter's TReturn must be its type parameters wrapped in the return type's + // generic definition, so a Wrapper return closes Adapter : IReturnTypeAdapter, ...> over X. + if (returnType.TypeArguments.Length != adapter.TypeParameters.Length) + { + return null; + } + + // Every adapter reaching here was discovered by CollectReturnTypeAdapterType, which only keeps types that + // implement the adapter interface, so this lookup always resolves. + var openInterface = FindImplementedAdapterInterface(adapter, adapterInterface)!; + return openInterface.TypeArguments[0] is INamedTypeSymbol templateReturn + && SymbolEqualityComparer.Default.Equals(templateReturn.OriginalDefinition, returnType.OriginalDefinition) + && TryMapAdapterTypeArguments(templateReturn, returnType, adapter, out var adapterOrdered) + ? adapter.Construct(adapterOrdered) + : null; + } + + /// Maps a wrapper return type's arguments onto the adapter's type parameters, in adapter declaration order. + /// The adapter's declared TReturn (open constructed over the adapter's parameters). + /// The declared return type supplying the concrete arguments. + /// The adapter type definition. + /// The concrete arguments ordered by the adapter's type parameters when the map succeeds. + /// when every adapter type parameter is bound consistently. + /// + /// Supports reordered and concrete-mixed wrappers, not only the in-order case: each template argument that is one of + /// the adapter's type parameters binds the return type's argument at that position, and each concrete template + /// argument must equal the return type's argument. So Adapter<T1, T2> : IReturnTypeAdapter<Wrapper<T2, T1>, …> + /// closes over a Wrapper<A, B> return as Adapter<B, A>. The sole caller invokes this only after the + /// template return and the return type share a generic definition and arity, so the argument counts are equal. + /// + private static bool TryMapAdapterTypeArguments( + INamedTypeSymbol templateReturn, + INamedTypeSymbol returnType, + INamedTypeSymbol adapter, + out ITypeSymbol[] adapterOrdered) + { + adapterOrdered = []; + var templateArguments = templateReturn.TypeArguments; + var returnArguments = returnType.TypeArguments; + var mapped = new ITypeSymbol?[adapter.TypeParameters.Length]; + for (var i = 0; i < templateArguments.Length; i++) + { + if (!TryBindAdapterArgument(templateArguments[i], returnArguments[i], adapter, mapped)) + { + return false; + } + } + + foreach (var argument in mapped) + { + if (argument is null) + { + return false; + } + } + + adapterOrdered = mapped!; + return true; + } + + /// Binds one template argument to its return-type argument, either through a type parameter or an exact match. + /// The adapter template's argument (a type parameter or a concrete type). + /// The return type's argument at the same position. + /// The adapter type definition owning the type parameters. + /// The per-type-parameter binding slots, filled by position. + /// when the argument binds consistently. + private static bool TryBindAdapterArgument( + ITypeSymbol templateArgument, + ITypeSymbol returnArgument, + INamedTypeSymbol adapter, + ITypeSymbol?[] mapped) + { + if (templateArgument is not ITypeParameterSymbol typeParameter + || !SymbolEqualityComparer.Default.Equals(typeParameter.ContainingSymbol, adapter)) + { + return SymbolEqualityComparer.Default.Equals(templateArgument, returnArgument); + } + + var position = typeParameter.Ordinal; + if (mapped[position] is { } existing) + { + return SymbolEqualityComparer.Default.Equals(existing, returnArgument); + } + + mapped[position] = returnArgument; + return true; + } + + /// Gets the closed IReturnTypeAdapter interface whose TReturn equals the return type. + /// The closed adapter type. + /// The declared return type the adapter must surface. + /// The resolved IReturnTypeAdapter`2 symbol. + /// The matching constructed interface, or . + private static INamedTypeSymbol? FindConstructedAdapterInterface( + INamedTypeSymbol closedAdapter, + INamedTypeSymbol returnType, + INamedTypeSymbol adapterInterface) + { + foreach (var implemented in closedAdapter.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(implemented.OriginalDefinition, adapterInterface) + && SymbolEqualityComparer.Default.Equals(implemented.TypeArguments[0], returnType)) + { + return implemented; + } + } + + return null; + } + + /// Gets any IReturnTypeAdapter interface a type implements. + /// The type to inspect. + /// The resolved IReturnTypeAdapter`2 symbol. + /// The implemented adapter interface, or . + private static INamedTypeSymbol? FindImplementedAdapterInterface( + INamedTypeSymbol type, + INamedTypeSymbol adapterInterface) + { + foreach (var implemented in type.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(implemented.OriginalDefinition, adapterInterface)) + { + return implemented; + } + } + + return null; + } +} diff --git a/src/InterfaceStubGenerator.Shared/Parser.Aliases.cs b/src/InterfaceStubGenerator.Shared/Parser.Aliases.cs new file mode 100644 index 000000000..99dd5bc49 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Parser.Aliases.cs @@ -0,0 +1,153 @@ +// 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; + +namespace Refit.Generator; + +/// Parses candidate interfaces and methods into the models used to generate Refit stubs. +/// Extern-alias-aware type qualification, so generated code compiles against types that are only reachable +/// through an extern alias (for example a type that collides with a global one). +internal static partial class Parser +{ + /// The fully-qualified name of a named type without its generic arguments or the global:: prefix. + private static readonly SymbolDisplayFormat AliasQualifiedNameFormat = + SymbolDisplayFormat.FullyQualifiedFormat + .WithGenericsOptions(SymbolDisplayGenericsOptions.None) + .WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted); + + /// Gets the extern alias qualifying a symbol's assembly, or null when it is reachable via global::. + /// The symbol whose containing assembly is inspected. + /// The generation context supplying the compilation and the pass-wide alias cache. + /// The extern alias, or null. + private static string? GetExternAlias(ISymbol symbol, InterfaceGenerationContext context) + { + var assembly = symbol.ContainingAssembly; + if (context.Compilation is not { } compilation || assembly is null) + { + return null; + } + + // The assembly -> alias mapping is invariant for the whole pass, so a single metadata-reference lookup per + // assembly is cached and reused across every type node of every interface (QualifyType runs very often). + var cache = context.AssemblyAliasCache; + if (cache.TryGetValue(assembly, out var cachedAlias)) + { + return cachedAlias; + } + + var resolved = ResolveExternAlias(assembly, compilation); + cache[assembly] = resolved; + return resolved; + } + + /// Resolves the extern alias for an assembly by inspecting its metadata reference. + /// The assembly to resolve. + /// The compilation supplying the assembly's metadata reference. + /// The extern alias, or null when the assembly is reachable via global::. + private static string? ResolveExternAlias(IAssemblySymbol assembly, CSharpCompilation compilation) + { + var aliases = compilation.GetMetadataReference(assembly)?.Properties.Aliases ?? default; + if (aliases.IsDefaultOrEmpty) + { + return null; + } + + // A reference that is also aliased "global" stays reachable without qualification. + foreach (var alias in aliases) + { + if (alias == "global") + { + return null; + } + } + + return aliases[0]; + } + + /// Determines whether a type (or any array element or type argument) lives behind an extern alias. + /// The type to inspect. + /// The generation context supplying the compilation and the pass-wide alias cache. + /// when the type involves an extern-aliased assembly. + private static bool ContainsAliasedType(ITypeSymbol type, InterfaceGenerationContext context) + { + if (type is IArrayTypeSymbol array) + { + return ContainsAliasedType(array.ElementType, context); + } + + if (type is not INamedTypeSymbol named) + { + return false; + } + + if (GetExternAlias(named, context) is not null) + { + return true; + } + + foreach (var argument in named.TypeArguments) + { + if (ContainsAliasedType(argument, context)) + { + return true; + } + } + + return false; + } + + /// Fully qualifies a type, using alias:: for any extern-aliased assembly and recording the alias. + /// The type to qualify. + /// The generation context, whose extern-alias collector records the aliases used. + /// The fully-qualified type name. + private static string QualifyType(ITypeSymbol type, InterfaceGenerationContext context) => + + // The common case is no aliased type at all: Roslyn's own fully-qualified rendering is exactly right. + ContainsAliasedType(type, context) + ? AliasedDisplay(type, context) + : type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + /// Renders a type's fully-qualified name with alias:: for extern-aliased assemblies, recursively. + /// The type to render. + /// The generation context, whose extern-alias collector records the aliases used. + /// The rendered type name. + private static string AliasedDisplay(ITypeSymbol type, InterfaceGenerationContext context) => + type switch + { + IArrayTypeSymbol array => AliasedDisplay(array.ElementType, context) + "[]", + INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable => + AliasedDisplay(nullable.TypeArguments[0], context) + "?", + INamedTypeSymbol named => AliasedNamedDisplay(named, context), + _ => type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + }; + + /// Renders a named type's alias-qualified name with recursively-rendered type arguments. + /// The named type to render. + /// The generation context, whose extern-alias collector records the aliases used. + /// The rendered type name. + private static string AliasedNamedDisplay(INamedTypeSymbol named, InterfaceGenerationContext context) + { + var alias = GetExternAlias(named, context); + if (alias is not null) + { + _ = context.ExternAliases.Add(alias); + } + + var name = (alias ?? "global") + "::" + named.ToDisplayString(AliasQualifiedNameFormat); + if (named.TypeArguments.IsEmpty) + { + return name; + } + + var arguments = new string[named.TypeArguments.Length]; + for (var i = 0; i < named.TypeArguments.Length; i++) + { + arguments[i] = AliasedDisplay(named.TypeArguments[i], context); + } + + return name + "<" + string.Join(", ", arguments) + ">"; + } +} diff --git a/src/InterfaceStubGenerator.Shared/Parser.Helpers.cs b/src/InterfaceStubGenerator.Shared/Parser.Helpers.cs index eb6db94c9..06df37fa5 100644 --- a/src/InterfaceStubGenerator.Shared/Parser.Helpers.cs +++ b/src/InterfaceStubGenerator.Shared/Parser.Helpers.cs @@ -9,6 +9,12 @@ namespace Refit.Generator; /// Internal parser helpers that are directly covered by focused tests. internal static partial class Parser { + /// The length of the <> pair wrapping a generic type parameter list. + private const int GenericBracketLength = 2; + + /// The assumed rendered length of one type parameter, used only to size a . + private const int EstimatedTypeParameterLength = 32; + /// Builds the unqualified declared method name, including any generic type parameters. /// The method symbol. /// The declared method name without its interface qualifier. @@ -28,7 +34,8 @@ internal static string BuildDeclaredBaseName(IMethodSymbol methodSymbol) } var typeParameters = methodSymbol.TypeParameters; - var estimatedCapacity = declaredBaseName.Length + 2 + (typeParameters.Length * 32); + var estimatedCapacity = + declaredBaseName.Length + GenericBracketLength + (typeParameters.Length * EstimatedTypeParameterLength); var builder = new StringBuilder(estimatedCapacity) .Append(declaredBaseName) .Append('<'); @@ -61,7 +68,7 @@ internal static bool IsRefitMethod(IMethodSymbol? methodSymbol, INamedTypeSymbol // 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) + if (attributeData.AttributeClass!.InheritsFromOrEquals(httpMethodAttribute)) { return true; } diff --git a/src/InterfaceStubGenerator.Shared/Parser.InlineEligibility.cs b/src/InterfaceStubGenerator.Shared/Parser.InlineEligibility.cs new file mode 100644 index 000000000..89537f2ac --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Parser.InlineEligibility.cs @@ -0,0 +1,91 @@ +// 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; + +/// Parses candidate interfaces and methods into the models used to generate Refit stubs. +/// +/// The inline-eligibility entry point shared with the RF006 analyzer. The analyzer assemblies compile the request +/// parsing sources directly, so the fallback diagnostic is always the generator's own CanGenerateInline +/// decision rather than a hand-maintained mirror. +/// +internal static partial class Parser +{ + /// Determines whether generated request building can construct a method's request inline. + /// The Refit method symbol. + /// The resolved Refit.HttpMethodAttribute symbol. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// when the method's request is inline-eligible. + internal static bool CanBuildRequestInline( + IMethodSymbol methodSymbol, + INamedTypeSymbol httpMethodBaseAttributeSymbol, + INamedTypeSymbol? formattableSymbol) => + CanBuildRequestInline(methodSymbol, httpMethodBaseAttributeSymbol, formattableSymbol, null, []); + + /// Determines whether generated request building can construct a method's request inline. + /// The Refit method symbol. + /// The resolved Refit.HttpMethodAttribute symbol. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// The resolved Refit.IReturnTypeAdapter`2 symbol, or null. + /// The discovered IReturnTypeAdapter implementations, so the analyzer agrees + /// with the generator that an adapter-backed return type is inline-eligible. + /// when the method's request is inline-eligible. + internal static bool CanBuildRequestInline( + IMethodSymbol methodSymbol, + INamedTypeSymbol httpMethodBaseAttributeSymbol, + INamedTypeSymbol? formattableSymbol, + INamedTypeSymbol? returnTypeAdapterInterface, + INamedTypeSymbol[] returnTypeAdapters) + { + if (FindHttpMethodAttribute(methodSymbol, httpMethodBaseAttributeSymbol) is null) + { + return false; + } + + // The diagnostics collected here duplicate what the generator itself reports, so they are discarded. + var context = new InterfaceGenerationContext( + [], + string.Empty, + null, + httpMethodBaseAttributeSymbol, + formattableSymbol, + SpanFormattableSymbol: null, + SupportsSpanEscape: false, + GeneratedRequestBuilding: true, + EmitGeneratedCodeMarkers: false, + SupportsNullable: false, + SupportsStaticLambdas: false, + SupportsCollectionExpressions: false, + Compilation: null, + returnTypeAdapterInterface, + returnTypeAdapters, + ExternAliases: [], + AssemblyAliasCache: new Dictionary(SymbolEqualityComparer.Default)); + return ParseRequest(methodSymbol, ClassifyInlineReturnShape(methodSymbol.ReturnType), context) + .CanGenerateInline; + } + + /// Classifies a return type into the shape buckets inline eligibility distinguishes. + /// The declared return type. + /// The return shape; unsupported shapes map to . + private static ReturnTypeInfo ClassifyInlineReturnShape(ITypeSymbol returnType) + { + if (returnType is not INamedTypeSymbol namedType) + { + return ReturnTypeInfo.Return; + } + + var ns = namedType.ContainingNamespace.ToDisplayString(); + return namedType.MetadataName switch + { + "Task" when ns == "System.Threading.Tasks" => ReturnTypeInfo.AsyncVoid, + "Task`1" or "ValueTask`1" when ns == "System.Threading.Tasks" => ReturnTypeInfo.AsyncResult, + "IAsyncEnumerable`1" when ns == "System.Collections.Generic" => ReturnTypeInfo.AsyncEnumerable, + "IObservable`1" when ns == "System" => ReturnTypeInfo.Observable, + _ => ReturnTypeInfo.Return + }; + } +} diff --git a/src/InterfaceStubGenerator.Shared/Parser.Members.cs b/src/InterfaceStubGenerator.Shared/Parser.Members.cs new file mode 100644 index 000000000..fb38e34bf --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Parser.Members.cs @@ -0,0 +1,284 @@ +// 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 interface properties and non-Refit methods into the models used to generate Refit stubs. +internal static partial class Parser +{ + /// Builds models for interface properties implemented by the generated stub. + /// The directly declared interface members. + /// The emittable inherited properties collected during the single member walk. + /// The generation context, used to qualify extern-aliased property types. + /// The property models. + private static ImmutableEquatableArray BuildInterfacePropertyModels( + in ImmutableArray members, + List inheritedProperties, + InterfaceGenerationContext context) + { + var properties = new List(); + foreach (var member in members) + { + if (member is IPropertySymbol property && IsEmittableProperty(property)) + { + properties.Add(ParseInterfaceProperty(property, false, context)); + } + } + + foreach (var property in inheritedProperties) + { + properties.Add(ParseInterfaceProperty(property, true, context)); + } + + 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.IsEmpty; + + /// Builds an interface property model. + /// The property to parse. + /// Whether the property comes from a base interface. + /// The generation context, used to qualify extern-aliased property types. + /// The property model. + private static InterfacePropertyModel ParseInterfaceProperty(IPropertySymbol property, bool isDerived, InterfaceGenerationContext context) + { + var annotation = + !property.Type.IsValueType && property.NullableAnnotation == NullableAnnotation.Annotated; + var propertyType = QualifyType(property.Type, context); + var containingType = QualifyType(property.ContainingType, context); + 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.IsEmpty && 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, + InterfaceGenerationContext context) + { + if (methods.Count == 0) + { + return ImmutableEquatableArrayFactory.Empty(); + } + + 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. + /// The non-Refit methods declared directly on the interface. + /// The non-Refit methods inherited from base interfaces. + /// The generation context, used to qualify extern-aliased types. + /// The non-Refit method models. + private static ImmutableEquatableArray BuildNonRefitMethodModels( + List nonRefitMethods, + List derivedNonRefitMethods, + InterfaceGenerationContext context) + { + // Only abstract instance methods become non-Refit method models. + var methodModels = new MethodModel[nonRefitMethods.Count + derivedNonRefitMethods.Count]; + var count = 0; + foreach (var method in nonRefitMethods) + { + if (IsEmittableNonRefitMethod(method)) + { + methodModels[count] = ParseNonRefitMethod(method, false, context); + count++; + } + } + + foreach (var method in derivedNonRefitMethods) + { + if (IsEmittableNonRefitMethod(method)) + { + // Derived non-Refit methods are emitted as explicit interface implementations. + methodModels[count] = ParseNonRefitMethod(method, true, context); + count++; + } + } + + return TrimAndWrap(methodModels, count); + } + + /// Determines whether a non-Refit method should be emitted as a method model. + /// The candidate method. + /// if the method is an abstract instance method; otherwise, . + private static bool IsEmittableNonRefitMethod(IMethodSymbol method) => + !method.IsStatic + && method.MethodKind != MethodKind.PropertyGet + && method.MethodKind != MethodKind.PropertySet + && method.IsAbstract; + + /// Builds a method model for a non-Refit interface method. + /// The non-Refit method symbol. + /// Whether the method comes from a base interface. + /// The generation context, used to qualify extern-aliased types. + /// The model describing the non-Refit method. + private static MethodModel ParseNonRefitMethod( + IMethodSymbol methodSymbol, + bool isDerived, + InterfaceGenerationContext context) + { + // Derived base-interface methods are emitted as explicit implementations. + var explicitImpl = FirstExplicitInterfaceImplementation(methodSymbol); + var containingTypeSymbol = explicitImpl?.ContainingType ?? methodSymbol.ContainingType; + var containingType = QualifyType(containingTypeSymbol, context); + + var declaredBaseName = BuildDeclaredBaseName(methodSymbol); + var returnType = QualifyType(methodSymbol.ReturnType, context); + var returnTypeInfo = GetReturnTypeInfo(methodSymbol); + var parameters = ParseParameters(methodSymbol.Parameters, context); + + var isExplicit = isDerived || explicitImpl is not null; + var constraints = GenerateConstraints(methodSymbol.TypeParameters, isExplicit, context); + + return new( + methodSymbol.Name, + returnType, + containingType, + declaredBaseName, + returnTypeInfo, + RequestModel.Empty, + parameters, + constraints, + isExplicit, + RequiresUnreferencedCode: false, + RequiresDynamicCode: false); + } + + /// 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) + { + var implementations = methodSymbol.ExplicitInterfaceImplementations; + return implementations.IsEmpty ? null : implementations[0]; + } + + /// Classifies a method's return type into its shape. + /// The method symbol. + /// The classified return type information. + private static ReturnTypeInfo GetReturnTypeInfo(IMethodSymbol methodSymbol) => + methodSymbol.ReturnType.MetadataName switch + { + "Task" => ReturnTypeInfo.AsyncVoid, + "Task`1" or "ValueTask`1" => ReturnTypeInfo.AsyncResult, + "IAsyncEnumerable`1" => ReturnTypeInfo.AsyncEnumerable, + "IObservable`1" => ReturnTypeInfo.Observable, + "Void" => ReturnTypeInfo.SyncVoid, + _ => ReturnTypeInfo.Return + }; + + /// 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(in ImmutableArray members) + { + var seenMemberNames = new HashSet(); + var memberNames = new string[members.Length]; + var count = 0; + foreach (var member in members) + { + if (seenMemberNames.Add(member.Name)) + { + memberNames[count] = member.Name; + count++; + } + } + + 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); + } +} diff --git a/src/InterfaceStubGenerator.Shared/Parser.MethodSignature.cs b/src/InterfaceStubGenerator.Shared/Parser.MethodSignature.cs new file mode 100644 index 000000000..283a6c0ca --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Parser.MethodSignature.cs @@ -0,0 +1,246 @@ +// 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.Generator; + +/// Parses a Refit method's type-parameter constraints, parameters, and resulting model. +internal static partial class Parser +{ + /// 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 generation context, used to qualify extern-aliased constraint types. + /// The constraint models for the type parameters. + private static ImmutableEquatableArray GenerateConstraints( + in ImmutableArray typeParameters, + bool isOverrideOrExplicitImplementation, + InterfaceGenerationContext context) + { + if (typeParameters.IsEmpty) + { + return ImmutableEquatableArrayFactory.Empty(); + } + + var constraints = new TypeConstraint[typeParameters.Length]; + for (var i = 0; i < typeParameters.Length; i++) + { + constraints[i] = ParseConstraintsForTypeParameter( + typeParameters[i], + isOverrideOrExplicitImplementation, + context); + } + + return ImmutableEquatableArrayFactory.FromArray(constraints); + } + + /// Builds the constraint model for a single type parameter. + /// The type parameter to parse. + /// Whether the member is an override or explicit implementation. + /// The generation context, used to qualify extern-aliased constraint types. + /// The constraint model for the type parameter. + private static TypeConstraint ParseConstraintsForTypeParameter( + ITypeParameterSymbol typeParameter, + bool isOverrideOrExplicitImplementation, + InterfaceGenerationContext context) + { + var known = ComputeKnownConstraints(typeParameter, isOverrideOrExplicitImplementation); + + var constraints = ImmutableEquatableArray.Empty; + if (!isOverrideOrExplicitImplementation) + { + constraints = ParseConstraintTypes(typeParameter.ConstraintTypes, context); + } + + var declaredName = typeParameter.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + return new(typeParameter.Name, declaredName, known, constraints); + } + + /// Computes the set of well-known constraint flags emittable for a type parameter. + /// The type parameter to inspect. + /// Whether the member is an override or explicit implementation. + /// The combined well-known constraint flags. + private static KnownTypeConstraint ComputeKnownConstraints( + ITypeParameterSymbol typeParameter, + bool isOverrideOrExplicitImplementation) + { + // Explicit implementations and overrides can only emit the subset of constraints that + // the generated member declaration is allowed to repeat. + var known = KnownTypeConstraint.None; + + if (typeParameter.HasReferenceTypeConstraint) + { + known |= KnownTypeConstraint.Class; + } + + if (typeParameter.HasUnmanagedTypeConstraint && !isOverrideOrExplicitImplementation) + { + known |= KnownTypeConstraint.Unmanaged; + } + + // `unmanaged` already implies `struct`, so avoid duplicating it. + if (typeParameter.HasValueTypeConstraint && !typeParameter.HasUnmanagedTypeConstraint) + { + known |= KnownTypeConstraint.Struct; + } + + if (typeParameter.HasNotNullConstraint && !isOverrideOrExplicitImplementation) + { + known |= KnownTypeConstraint.NotNull; + } + + // The `new()` constraint must be emitted last. + if (typeParameter.HasConstructorConstraint && !isOverrideOrExplicitImplementation) + { + known |= KnownTypeConstraint.New; + } + + return known; + } + + /// Builds a parameter model from a parameter symbol. + /// The parameter symbol to parse. + /// The generation context, used to qualify extern-aliased types. + /// The model describing the parameter. + private static ParameterModel ParseParameter(IParameterSymbol param, InterfaceGenerationContext context) + { + var annotation = + !param.Type.IsValueType && param.NullableAnnotation == NullableAnnotation.Annotated; + + var paramType = QualifyType(param.Type, context); + var isGeneric = ContainsTypeParameter(param.Type); + + return new(param.MetadataName, paramType, annotation, isGeneric); + } + + /// Builds parameter models from a fixed Roslyn parameter array. + /// The parameters to parse. + /// The generation context, used to qualify extern-aliased types. + /// The parsed parameter models. + private static ImmutableEquatableArray ParseParameters( + in ImmutableArray parameters, + InterfaceGenerationContext context) + { + if (parameters.IsEmpty) + { + return ImmutableEquatableArrayFactory.Empty(); + } + + var parameterModels = new ParameterModel[parameters.Length]; + for (var i = 0; i < parameters.Length; i++) + { + parameterModels[i] = ParseParameter(parameters[i], context); + } + + return ImmutableEquatableArrayFactory.FromArray(parameterModels); + } + + /// Builds constraint display names from a fixed Roslyn type array. + /// The constraint types to parse. + /// The generation context, used to qualify extern-aliased constraint types. + /// The parsed constraint type display names. + private static ImmutableEquatableArray ParseConstraintTypes( + in ImmutableArray constraintTypes, + InterfaceGenerationContext context) + { + if (constraintTypes.IsEmpty) + { + return ImmutableEquatableArrayFactory.Empty(); + } + + var constraints = new string[constraintTypes.Length]; + for (var i = 0; i < constraintTypes.Length; i++) + { + constraints[i] = QualifyType(constraintTypes[i], context); + } + + 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, . + private static bool ContainsTypeParameter(ITypeSymbol symbol) + { + if (symbol is ITypeParameterSymbol) + { + return true; + } + + if (symbol is not INamedTypeSymbol { TypeParameters.Length: > 0 } namedType) + { + return false; + } + + foreach (var typeArg in namedType.TypeArguments) + { + if (ContainsTypeParameter(typeArg)) + { + return true; + } + } + + return false; + } + + /// 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, + InterfaceGenerationContext context) + { + var returnType = QualifyType(methodSymbol.ReturnType, context); + + // For explicit interface implementations, emit the interface being implemented, not the + // interface that originally declared the method. + var explicitImpl = FirstExplicitInterfaceImplementation(methodSymbol); + var containingTypeSymbol = explicitImpl?.ContainingType ?? methodSymbol.ContainingType; + var containingType = QualifyType(containingTypeSymbol, context); + + var declaredBaseName = BuildDeclaredBaseName(methodSymbol); + var returnTypeInfo = GetReturnTypeInfo(methodSymbol); + var request = ParseRequest(methodSymbol, returnTypeInfo, context); + var parameters = ParseParameters(methodSymbol.Parameters, context); + + var isExplicit = explicitImpl is not null; + var constraints = GenerateConstraints(methodSymbol.TypeParameters, isExplicit || !isImplicitInterface, context); + + return new( + methodSymbol.Name, + returnType, + containingType, + declaredBaseName, + returnTypeInfo, + request, + parameters, + constraints, + isExplicit, + HasTrimAnnotation(methodSymbol, "RequiresUnreferencedCodeAttribute"), + HasTrimAnnotation(methodSymbol, "RequiresDynamicCodeAttribute")); + } + + /// Determines whether a method declares a System.Diagnostics.CodeAnalysis trim annotation. + /// The Refit method symbol. + /// The attribute type name, for example RequiresUnreferencedCodeAttribute. + /// when the method carries the attribute. + private static bool HasTrimAnnotation(IMethodSymbol methodSymbol, string attributeName) + { + foreach (var attribute in methodSymbol.GetAttributes()) + { + var attributeClass = attribute.AttributeClass!; + if (attributeClass.Name == attributeName + && attributeClass.ContainingNamespace.ToDisplayString() == "System.Diagnostics.CodeAnalysis") + { + return true; + } + } + + return false; + } +} diff --git a/src/InterfaceStubGenerator.Shared/Parser.Request.Body.cs b/src/InterfaceStubGenerator.Shared/Parser.Request.Body.cs new file mode 100644 index 000000000..24f3b5259 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Parser.Request.Body.cs @@ -0,0 +1,298 @@ +// 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; + +/// Parses candidate interfaces and methods into the models used to generate Refit stubs. +/// Body and form-field parsing for the Refit source generator. +internal static partial class Parser +{ + /// Builds reflection-free form field descriptors for a URL-encoded body type. + /// The declared body type. + /// The interface generation context, used to classify the scalar fast path. + /// The field descriptors, or when the type is not eligible for the descriptor path. + private static ImmutableEquatableArray? TryBuildFormFields( + ITypeSymbol bodyType, + InterfaceGenerationContext context) + { + if (!IsFormFieldEligibleType(bodyType)) + { + return null; + } + + var fields = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + // bodyType is a concrete class/struct/enum here (interfaces and the like are excluded upstream), so its base + // chain always reaches System.Object; the loop stops there and never dereferences a null BaseType. + for (var current = bodyType; + current.SpecialType != SpecialType.System_Object; + current = current.BaseType!) + { + foreach (var member in current.GetMembers()) + { + if (member is IPropertySymbol property && IsReadableFormProperty(property) && seen.Add(property.Name)) + { + fields.Add(BuildFormFieldModel(property, context)); + } + } + } + + return ImmutableEquatableArrayFactory.FromList(fields); + } + + /// Determines whether a property contributes a readable public instance form field. + /// The property to inspect. + /// when the property is read through a public instance getter. + private static bool IsReadableFormProperty(IPropertySymbol property) => + !property.IsStatic + && !property.IsIndexer + && property.DeclaredAccessibility == Accessibility.Public + && property.GetMethod is { DeclaredAccessibility: Accessibility.Public }; + + /// Determines whether a body type can be flattened to form fields without reflection. + /// The declared body type. + /// when descriptor-based flattening matches the reflection path. + private static bool IsFormFieldEligibleType(ITypeSymbol type) => + type.TypeKind != TypeKind.Interface + && type.TypeKind != TypeKind.TypeParameter + && type.TypeKind != TypeKind.Dynamic + && type.TypeKind != TypeKind.Array + && type.TypeKind != TypeKind.Pointer + && type.TypeKind != TypeKind.Error + && type.SpecialType != SpecialType.System_String + && type.SpecialType != SpecialType.System_Object + && type is not INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } + + // Dictionaries and other enumerables flow through the reflection path, which special-cases them. + && !ImplementsEnumerable(type); + + /// Determines whether a type implements the non-generic . + /// The type to inspect. + /// when the type is enumerable. + private static bool ImplementsEnumerable(ITypeSymbol type) + { + // The only caller (IsFormFieldEligibleType) already excludes interface types, so type is never the + // System.Collections.IEnumerable interface itself; a concrete enumerable always exposes it through AllInterfaces. + foreach (var contract in type.AllInterfaces) + { + if (contract.SpecialType == SpecialType.System_Collections_IEnumerable) + { + return true; + } + } + + return false; + } + + /// Builds the form field descriptor for one body property. + /// The property to describe. + /// The interface generation context, used to classify the scalar fast path. + /// The field descriptor. + private static FormFieldModel BuildFormFieldModel(IPropertySymbol property, InterfaceGenerationContext context) + { + string? aliasName = null; + string? jsonName = null; + var query = default(QueryFormData); + + foreach (var attribute in property.GetAttributes()) + { + var attributeName = attribute.AttributeClass!.ToDisplayString(); + if (attributeName == "Refit.AliasAsAttribute") + { + aliasName = GetFirstStringArgument(attribute); + } + else if (attributeName == "System.Text.Json.Serialization.JsonPropertyNameAttribute") + { + jsonName = GetFirstStringArgument(attribute); + } + else if (attributeName == "Refit.QueryAttribute") + { + query = ParseFormQueryAttribute(attribute); + } + } + + // A scalar property (string/bool/IFormattable, matching the query fast path) can be rendered straight-line + // without the descriptor array; collections and complex properties leave ValueFormat null so the whole body + // falls back to the descriptor path, which special-cases them (STJ-style: fast path the simple shapes). + var valueFormat = IsSimpleType(property.Type, context.FormattableSymbol) + ? BuildValueFormat(property.Type, NormalizeFormat(query.Format), context.FormattableSymbol, context) + : null; + + var prefixSegment = string.IsNullOrWhiteSpace(query.Prefix) ? null : query.Prefix + query.Delimiter; + return new( + property.Name, + aliasName ?? jsonName, + prefixSegment, + query.Format, + query.CollectionFormatValue, + query.SerializeNull, + CanElementBeNull(property.Type), + valueFormat); + } + + /// Reads the first string constructor argument from an attribute. + /// The attribute to inspect. + /// The first string argument, or . + private static string? GetFirstStringArgument(AttributeData attribute) + { + foreach (var argument in attribute.ConstructorArguments) + { + if (argument.Value is string value) + { + return value; + } + } + + return null; + } + + /// Parses the form-relevant members of a [Query] attribute applied to a property. + /// The query attribute data. + /// The parsed form query data. + private static QueryFormData ParseFormQueryAttribute(AttributeData attribute) => + ApplyQueryNamedArguments(attribute, ParseQueryConstructorArguments(attribute)); + + /// Parses the constructor arguments of a [Query] attribute. + /// The query attribute data. + /// The form query data carried by the constructor arguments. + private static QueryFormData ParseQueryConstructorArguments(AttributeData attribute) + { + var delimiter = "."; + string? prefix = null; + string? format = null; + int? collectionFormatValue = null; + var stringArguments = 0; + + foreach (var argument in attribute.ConstructorArguments) + { + // The only enum-typed constructor argument a [Query] attribute accepts is CollectionFormat. + if (argument.Kind == TypedConstantKind.Enum) + { + collectionFormatValue = (int)argument.Value!; + } + else if (argument.Value is string stringValue) + { + if (stringArguments == 0) + { + delimiter = stringValue; + } + else if (stringArguments == 1) + { + prefix = stringValue; + } + else + { + format = stringValue; + } + + stringArguments++; + } + } + + return new(delimiter, prefix, format, collectionFormatValue, false, false); + } + + /// Applies the named arguments of a [Query] attribute over constructor-supplied data. + /// The query attribute data. + /// The data parsed from constructor arguments. + /// The form query data with named arguments applied. + private static QueryFormData ApplyQueryNamedArguments(AttributeData attribute, QueryFormData data) + { + foreach (var named in attribute.NamedArguments) + { + if (named.Key == "Format" && named.Value.Value is string formatValue) + { + data = data with { Format = formatValue }; + } + else if (named.Key == "CollectionFormat") + { + data = data with { CollectionFormatValue = (int)named.Value.Value! }; + } + else if (named.Key == "SerializeNull") + { + data = data with { SerializeNull = (bool)named.Value.Value! }; + } + else if (named.Key == "TreatAsString") + { + data = data with { TreatAsString = (bool)named.Value.Value! }; + } + } + + return data; + } + + /// 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) + { + // The only enum-typed constructor argument a [Body] attribute accepts is BodySerializationMethod. + if (argument.Kind == TypedConstantKind.Enum) + { + methodName = GetBodySerializationMethodName((int)argument.Value!); + return true; + } + + methodName = string.Empty; + return false; + } + + /// Parsed body attribute data. + /// The body serialization method name. + /// The body buffering mode. + private readonly record struct BodyAttributeInfo( + string SerializationMethod, + BodyBufferMode BufferMode); + + /// Form-relevant data parsed from a [Query] attribute on a parameter or body property. + /// The delimiter combined with the prefix. + /// The field name prefix, if any. + /// The value format, if any. + /// The explicit collection format value, if any. + /// Whether null values are serialized as empty fields. + /// Whether the raw value is stringified via ToString() before formatting. + private readonly record struct QueryFormData( + string Delimiter, + string? Prefix, + string? Format, + int? CollectionFormatValue, + bool SerializeNull, + bool TreatAsString); +} diff --git a/src/InterfaceStubGenerator.Shared/Parser.Request.Helpers.cs b/src/InterfaceStubGenerator.Shared/Parser.Request.Helpers.cs index 1e3c43864..c995e27b7 100644 --- a/src/InterfaceStubGenerator.Shared/Parser.Request.Helpers.cs +++ b/src/InterfaceStubGenerator.Shared/Parser.Request.Helpers.cs @@ -8,6 +8,15 @@ namespace Refit.Generator; /// Internal request parser helpers that are directly covered by focused tests. 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; + + /// The underlying value for BodySerializationMethod.JsonLines. + private const int BodySerializationJsonLines = 4; + /// Finds the HTTP method attribute on a Refit method. /// The method to inspect. /// The Refit HTTP method base attribute symbol. @@ -30,10 +39,11 @@ internal static partial class Parser /// Determines whether the initial inline emitter can use the path as a literal URI. /// The path to inspect. /// when the path is supported. + /// A no-leading-slash path is supported: it resolves against the base address under RFC 3986 and throws + /// under legacy resolution at request time, exactly as the reflection request builder validates it. internal static bool IsPathSupported(string path) { - return (path.Length == 0 || path[0] == '/') - && IsPathTemplateValid(path) + return IsPathTemplateValid(path) && path.IndexOf('\\') < 0 && path.IndexOf('\r') < 0 && path.IndexOf('\n') < 0; diff --git a/src/InterfaceStubGenerator.Shared/Parser.Request.HttpMethod.cs b/src/InterfaceStubGenerator.Shared/Parser.Request.HttpMethod.cs new file mode 100644 index 000000000..3c946ac1d --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Parser.Request.HttpMethod.cs @@ -0,0 +1,116 @@ +// 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; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Refit.Generator; + +/// Parses candidate interfaces and methods into the models used to generate Refit stubs. +/// Resolves the HTTP verb from a method's [Get]/[Post]/custom HTTP method attribute. +internal static partial class Parser +{ + /// Maps a built-in Refit HTTP method attribute's metadata name to its HTTP verb. + /// The attribute type's metadata name, for example GetAttribute. + /// The HTTP verb, or for an attribute that is not one of Refit's built-in verbs. + [ExcludeFromCodeCoverage] + internal static string? MapKnownHttpVerb(string attributeMetadataName) => + attributeMetadataName switch + { + "DeleteAttribute" => "DELETE", + "GetAttribute" => "GET", + "HeadAttribute" => "HEAD", + "OptionsAttribute" => "OPTIONS", + "PatchAttribute" => "PATCH", + "PostAttribute" => "POST", + "PutAttribute" => "PUT", + _ => null + }; + + /// Gets the HTTP method name represented by a Refit method attribute. + /// The resolved HTTP method attribute type. + /// The HTTP method name, or an empty string when a custom attribute's verb is not statically readable. + private static string GetHttpMethodName(INamedTypeSymbol attributeClass) => + MapKnownHttpVerb(attributeClass.MetadataName) ?? ResolveCustomHttpVerb(attributeClass); + + /// Resolves a statically-readable custom HTTP verb, or an empty string when the method must fall back. + /// The custom HTTP method attribute type. + /// The verb, or an empty string. + private static string ResolveCustomHttpVerb(INamedTypeSymbol attributeClass) => + TryResolveCustomHttpVerb(attributeClass, out var verb) ? verb : string.Empty; + + /// Reads a custom HTTP verb from a derived attribute whose Method getter returns a string literal. + /// The custom HTTP method attribute type. + /// The resolved verb when the getter is a recognizable literal. + /// when the verb is statically readable. + /// + /// The verb is otherwise an arbitrary runtime value (HttpMethodAttribute.Method is abstract), so only a getter + /// that constructs an HttpMethod from a string literal - the one shape the generator can evaluate - is inlined; + /// any other override keeps using the reflection request builder. Resolution is syntax and symbol only (no semantic + /// model), so the RF006 analyzer and the generator agree. The reflection builder reads the same verb at runtime, so + /// the emitted new HttpMethod("VERB") matches it. + /// + private static bool TryResolveCustomHttpVerb(INamedTypeSymbol attributeClass, out string verb) + { + verb = string.Empty; + + // The property type pins this to the real HttpMethod override rather than an unrelated "Method", and any + // non-HttpMethod or unreadable override falls through. + var (property, getter) = FindMethodPropertyGetter(attributeClass); + if (property.Type.ToDisplayString() != "System.Net.Http.HttpMethod") + { + return false; + } + + foreach (var reference in getter.DeclaringSyntaxReferences) + { + if (GetterReturnExpression(reference.GetSyntax()) is ObjectCreationExpressionSyntax { ArgumentList.Arguments: [var argument] } + && argument.Expression is LiteralExpressionSyntax { Token.Value: string literal } + && literal.Length > 0) + { + verb = literal; + return true; + } + } + + return false; + } + + /// Finds the most-derived Method property and its getter on an attribute type. + /// The custom HTTP method attribute type. + /// The Method property and its getter. + /// Walks the attribute and its bases for the most-derived Method override. Every attribute reaching + /// here derives from HttpMethodAttribute, which declares an (abstract) HttpMethod Method getter, so the walk always + /// resolves one and the trailing fallback is unreachable. + [ExcludeFromCodeCoverage] + private static (IPropertySymbol Property, IMethodSymbol Getter) FindMethodPropertyGetter(INamedTypeSymbol attributeClass) + { + for (INamedTypeSymbol? current = attributeClass; current is not null; current = current.BaseType) + { + foreach (var member in current.GetMembers("Method")) + { + if (member is IPropertySymbol { GetMethod: { } getter } candidate) + { + return (candidate, getter); + } + } + } + + return default!; + } + + /// Gets the expression a property getter returns, as an expression body or a single return statement. + /// The getter or property syntax. + /// The returned expression, or null when the getter is not a single expression. + [ExcludeFromCodeCoverage] + private static ExpressionSyntax? GetterReturnExpression(SyntaxNode syntax) => + syntax switch + { + ArrowExpressionClauseSyntax { Expression: { } arrowBody } => arrowBody, + AccessorDeclarationSyntax { ExpressionBody.Expression: { } accessorBody } => accessorBody, + AccessorDeclarationSyntax { Body.Statements: [ReturnStatementSyntax { Expression: { } returned }] } => returned, + _ => null + }; +} diff --git a/src/InterfaceStubGenerator.Shared/Parser.Request.Multipart.cs b/src/InterfaceStubGenerator.Shared/Parser.Request.Multipart.cs new file mode 100644 index 000000000..78019eaa5 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Parser.Request.Multipart.cs @@ -0,0 +1,354 @@ +// 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; + +/// Parses candidate interfaces and methods into the models used to generate Refit stubs. +/// +/// Multipart part classification for generated request construction. Mirrors the reflection request builder's +/// AddMultiPart/AddMultipartItem dispatch and its part name/file-name selection, resolved statically from +/// each parameter's declared type. A part whose declared type is not statically dispatchable (an object, an +/// interface, an open type parameter, or a type that would fall through to the content serializer) makes the whole +/// method fall back to the reflection request builder. +/// +internal static partial class Parser +{ + /// The metadata name of the obsolete Refit.AttachmentNameAttribute. + private const string AttachmentNameAttributeDisplayName = "AttachmentNameAttribute"; + + /// The default multipart boundary, matching new MultipartAttribute().BoundaryText. + private const string DefaultMultipartBoundary = "----MyGreatBoundary"; + + /// Resolves the multipart boundary and whether a method is multipart in one pass. + /// The method to inspect. + /// Receives whether the method carries [Multipart]. + /// The boundary text for a multipart method, or an empty string otherwise. + private static string ResolveMultipartBoundary(IMethodSymbol methodSymbol, out bool isMultipart) + { + var multipartAttribute = FindMultipartAttribute(methodSymbol); + isMultipart = multipartAttribute is not null; + return isMultipart ? GetMultipartBoundaryText(multipartAttribute!) : string.Empty; + } + + /// Finds the [Multipart] attribute on a method, if present. + /// The method to inspect. + /// The multipart attribute data, or when the method is not multipart. + private static AttributeData? FindMultipartAttribute(IMethodSymbol methodSymbol) + { + foreach (var attribute in methodSymbol.GetAttributes()) + { + if (IsRefitAttribute(attribute.AttributeClass, MultipartAttributeDisplayName)) + { + return attribute; + } + } + + return null; + } + + /// Resolves the multipart boundary text for a method, matching the reflection builder's selection. + /// The method's multipart attribute. + /// The boundary text: the [Multipart(boundary)] argument, or the attribute default. + private static string GetMultipartBoundaryText(AttributeData multipartAttribute) => + !multipartAttribute.ConstructorArguments.IsEmpty + && multipartAttribute.ConstructorArguments[0].Value is string boundary + ? boundary + : DefaultMultipartBoundary; + + /// Determines whether any parameter is an explicit request body binding. + /// The parsed request parameter models. + /// when a parameter carries [Body]. + private static bool HasBodyParameter(ImmutableEquatableArray parameters) + { + foreach (var parameter in parameters) + { + if (parameter.Kind == RequestParameterKind.Body) + { + return true; + } + } + + return false; + } + + /// Classifies one parameter of a multipart method into a form part, a query binding, or a fallback. + /// The parameter to classify. + /// The parameter type display string. + /// The lookup state used to classify the parameter. + /// The parsed parameter and eligibility counters. + private static ParsedRequestParameter ClassifyMultipartParameter( + IParameterSymbol parameter, + string parameterType, + in LooseParameterContext context) + { + // A parameter carrying [Query] still feeds the query string in a multipart method, matching the reflection + // builder, which routes any [Query] parameter to the query map rather than to the multipart content. + if (HasParameterAttribute(parameter, QueryAttributeDisplayName)) + { + return TryBuildQueryModel(parameter, context.UrlName, context.FormattableSymbol, context.Generation, out var query) + ? new(QueryRequestParameter(parameter, parameterType, query!, context.Generation), true, 0, 0, 0) + : new(UnsupportedRequestParameter(parameter, parameterType, context.Generation), false, 0, 0, 0); + } + + return TryBuildMultipartPart(parameter, context) is { } part + ? new(MultipartRequestParameter(parameter, parameterType, part, context.Generation), true, 0, 0, 0) + : new(UnsupportedRequestParameter(parameter, parameterType, context.Generation), false, 0, 0, 0); + } + + /// Builds the multipart part descriptor for a parameter whose declared type is statically dispatchable. + /// The parameter to classify. + /// The lookup state used to classify the parameter. + /// The part descriptor, or when the type is not statically dispatchable. + private static MultipartPartModel? TryBuildMultipartPart(IParameterSymbol parameter, in LooseParameterContext context) + { + if (ClassifyPartType(parameter.Type) is not { } classified) + { + return null; + } + + // The field name (reflection's parameterName) is the aliased-or-declared name. The file name (reflection's + // fileName/itemName) is the obsolete [AttachmentName] override when present, otherwise the same field name. + var attachmentName = FindParameterAttribute(parameter, AttachmentNameAttributeDisplayName) is { } attribute + ? GetFirstStringArgument(attribute) + : null; + return new(classified.Kind, context.UrlName, attachmentName ?? context.UrlName, classified.IsEnumerable); + } + + /// Builds a multipart part request parameter model. + /// The parameter symbol. + /// The parameter type display string. + /// The multipart part descriptor. + /// The interface generation context, used to qualify extern-aliased types. + /// The multipart part request parameter model. + private static RequestParameterModel MultipartRequestParameter( + IParameterSymbol parameter, + string parameterType, + MultipartPartModel part, + InterfaceGenerationContext context) => + new( + parameter.MetadataName, + parameterType, + null, + BuildParameterAttributes(parameter, context), + RequestParameterKind.MultipartPart, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + string.Empty, + string.Empty, + string.Empty, + BodyBufferMode.None) + { + MultipartPart = part, + }; + + /// Classifies a declared parameter type into a multipart dispatch arm and enumerable expansion flag. + /// The declared parameter type. + /// The part kind and whether it expands element-by-element, or when the type is not + /// statically dispatchable and the method must fall back to the reflection request builder. + private static (MultipartPartKind Kind, bool IsEnumerable)? ClassifyPartType(ITypeSymbol type) + { + // string and byte[] are single parts even though they are enumerable: the reflection builder's + // IEnumerable check excludes them because char and byte are value types. + if (type.SpecialType == SpecialType.System_String) + { + return (MultipartPartKind.String, false); + } + + if (IsByteArray(type)) + { + return (MultipartPartKind.ByteArray, false); + } + + // A reference-typed enumerable is IEnumerable at runtime, so the reflection builder adds one part per + // element; the element type drives the part kind. + if (GetReferenceEnumerableElement(type) is { } elementType) + { + return ClassifySingle(elementType) is { } elementKind ? (elementKind, true) : null; + } + + return ClassifySingle(type) is { } kind ? (kind, false) : null; + } + + /// Classifies a single (non-enumerable) value type into its multipart dispatch arm. + /// The value's declared type. + /// The part kind, or when the type is not statically dispatchable. + private static MultipartPartKind? ClassifySingle(ITypeSymbol type) + { + // A Guid?/DateTime? part classifies by its underlying formattable type; the null value itself is guarded + // out at emission, matching the reflection builder's "skip null parameter" behavior. + if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T, TypeArguments: [var underlying] }) + { + type = underlying; + } + + if (IsHttpContentType(type)) + { + return MultipartPartKind.HttpContent; + } + + if (IsMultipartItemType(type)) + { + return MultipartPartKind.MultipartItem; + } + + if (IsStreamType(type)) + { + return MultipartPartKind.Stream; + } + + if (type.SpecialType == SpecialType.System_String) + { + return MultipartPartKind.String; + } + + if (IsFileInfoType(type)) + { + return MultipartPartKind.FileInfo; + } + + if (IsByteArray(type)) + { + return MultipartPartKind.ByteArray; + } + + if (IsMultipartFormattableType(type)) + { + return MultipartPartKind.Formattable; + } + + // A sealed or value type (bool, enum, sealed DTO) is written through the content serializer, exactly as the + // reflection builder's serializer fallback does; its declared type is the runtime type, so the serialized form + // matches. An open, interface, or object-typed part stays on the reflection path (runtime type decides). + return IsConcreteComplexType(type) ? MultipartPartKind.Serialized : null; + } + + /// Gets the reference-typed element of an enumerable parameter, or null when it is not one. + /// The declared parameter type. + /// The element type when the parameter is a reference-typed enumerable (so its runtime value implements + /// IEnumerable<object>), otherwise . + private static ITypeSymbol? GetReferenceEnumerableElement(ITypeSymbol type) + { + if (type is IArrayTypeSymbol { Rank: 1 } array) + { + return array.ElementType.IsReferenceType ? array.ElementType : null; + } + + if (type is INamedTypeSymbol + { + OriginalDefinition.SpecialType: SpecialType.System_Collections_Generic_IEnumerable_T, + TypeArguments: [var self] + }) + { + return self.IsReferenceType ? self : null; + } + + foreach (var implemented in type.AllInterfaces) + { + if (implemented is + { + OriginalDefinition.SpecialType: SpecialType.System_Collections_Generic_IEnumerable_T, + TypeArguments: [var argument] + } + && argument.IsReferenceType) + { + return argument; + } + } + + return null; + } + + /// Determines whether a type is a single-rank array. + /// The type to inspect. + /// when the type is byte[]. + private static bool IsByteArray(ITypeSymbol type) => + type is IArrayTypeSymbol { Rank: 1, ElementType.SpecialType: SpecialType.System_Byte }; + + /// Determines whether a type is . + /// The type to inspect. + /// when the type is . + private static bool IsFileInfoType(ITypeSymbol type) => + type is + { + Name: "FileInfo", + ContainingNamespace.Name: "IO", + ContainingNamespace.ContainingNamespace.Name: SystemNamespace, + ContainingNamespace.ContainingNamespace.ContainingNamespace.IsGlobalNamespace: true + }; + + /// Determines whether a type is or derives from it. + /// The type to inspect. + /// when the type is assignable to . + private static bool IsStreamType(ITypeSymbol type) + { + for (var current = type; current is not null; current = current.BaseType) + { + if (current is + { + Name: "Stream", + ContainingNamespace.Name: "IO", + ContainingNamespace.ContainingNamespace.Name: SystemNamespace, + ContainingNamespace.ContainingNamespace.ContainingNamespace.IsGlobalNamespace: true + }) + { + return true; + } + } + + return false; + } + + /// Determines whether a type is or derives from it. + /// The type to inspect. + /// when the type is assignable to . + private static bool IsHttpContentType(ITypeSymbol type) + { + for (var current = type; current is not null; current = current.BaseType) + { + if (current is + { + Name: "HttpContent", + ContainingNamespace.Name: "Http", + ContainingNamespace.ContainingNamespace.Name: "Net", + ContainingNamespace.ContainingNamespace.ContainingNamespace.Name: SystemNamespace, + ContainingNamespace.ContainingNamespace.ContainingNamespace.ContainingNamespace.IsGlobalNamespace: true + }) + { + return true; + } + } + + return false; + } + + /// Determines whether a type is Refit.MultipartItem or derives from it. + /// The type to inspect. + /// when the type is assignable to Refit.MultipartItem. + private static bool IsMultipartItemType(ITypeSymbol type) + { + for (var current = type; current is not null; current = current.BaseType) + { + if (current is + { + Name: "MultipartItem", + ContainingNamespace.Name: "Refit", + ContainingNamespace.ContainingNamespace.IsGlobalNamespace: true + }) + { + return true; + } + } + + return false; + } + + /// Determines whether a type is a date/time or multipart value. + /// The type to inspect. + /// for the BCL value types the reflection builder renders through the form + /// URL-encoded formatter instead of the content serializer. + private static bool IsMultipartFormattableType(ITypeSymbol type) => + type is { ContainingNamespace.Name: SystemNamespace, ContainingNamespace.ContainingNamespace.IsGlobalNamespace: true } + && type.Name is "Guid" or "DateTime" or "DateTimeOffset" or "TimeSpan" or "DateOnly" or "TimeOnly"; +} diff --git a/src/InterfaceStubGenerator.Shared/Parser.Request.ParameterKinds.cs b/src/InterfaceStubGenerator.Shared/Parser.Request.ParameterKinds.cs new file mode 100644 index 000000000..473727a0b --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Parser.Request.ParameterKinds.cs @@ -0,0 +1,505 @@ +// 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; +using Microsoft.CodeAnalysis.CSharp; + +namespace Refit.Generator; + +/// Parses candidate interfaces and methods into the models used to generate Refit stubs. +/// Classifies a single request parameter into its specific kind, model, and supporting type predicates. +internal static partial class Parser +{ + /// Determines whether a type is, or is constructed over, a generic type parameter. + /// The type to inspect. + /// when the type references a type parameter. + private static bool ReferencesTypeParameter(ITypeSymbol type) + { + switch (type) + { + case ITypeParameterSymbol: + return true; + case IArrayTypeSymbol array: + return ReferencesTypeParameter(array.ElementType); + case INamedTypeSymbol named: + { + foreach (var argument in named.TypeArguments) + { + if (ReferencesTypeParameter(argument)) + { + return true; + } + } + + return false; + } + + default: + return false; + } + } + + /// Determines whether a parameter type renders to a URL scalar and is eligible for inline path formatting. + /// The parameter type to classify. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// when the type is a simple scalar type supported by inline path formatting. + private static bool IsSimpleType(ITypeSymbol type, INamedTypeSymbol? formattableSymbol) + { + // A path value is emitted as UrlParameterFormatter.Format(value, provider, typeof(T)) - the same call the + // reflection path uses - so any type the formatter can render round-trips identically. That is exactly the + // set of IFormattable types (which is also what makes [Query(Format = ...)] and invariant culture work), + // plus string and bool, which are scalars but not IFormattable. Collections, arrays and DTOs are excluded + // and fall back to reflection. Matching on the resolved IFormattable symbol avoids per-parameter name-string + // allocations and automatically covers future BCL scalars. + var underlyingType = GetUnderlyingType(type); + + static ITypeSymbol GetUnderlyingType(ITypeSymbol type) => + type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable + ? nullable.TypeArguments[0] + : type; + + // The built-in value-type scalars occupy a contiguous SpecialType block - System_Boolean (bool, char, + // every integer width, decimal, float, double) through System_Double - so a range check covers them all + // in one comparison. string and DateTime sit just outside that block. + static bool IsScalarSpecialType(SpecialType specialType) => + (specialType >= SpecialType.System_Boolean && specialType <= SpecialType.System_Double) + || specialType == SpecialType.System_String + || specialType == SpecialType.System_DateTime; + + // A null interfaceSymbol (System.IFormattable unresolved) simply matches nothing and falls back. + static bool ImplementsInterface(ITypeSymbol type, INamedTypeSymbol? interfaceSymbol) + { + foreach (var implemented in type.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(implemented, interfaceSymbol)) + { + return true; + } + } + + return false; + } + + // Built-in scalars resolve from SpecialType alone (a jump table, no interface walk); everything else that + // renders to a URL scalar - enums, Guid, DateTimeOffset, DateOnly, TimeOnly, TimeSpan, BigInteger, + // Int128/UInt128, Half - implements IFormattable. + return IsScalarSpecialType(underlyingType.SpecialType) + || ImplementsInterface(underlyingType, formattableSymbol) + || IsUri(underlyingType) + || IsCultureInfo(underlyingType); + } + + /// Determines whether a type is . + /// The type to inspect. + /// when the type is . + /// + /// The reflection request builder treats as a query scalar rather than an object to flatten + /// (its ShouldReturn check), even though it is not . The default formatter renders + /// it through string.Format("{0}", value), which is ToString() for a non-formattable value, so the + /// generated fast path matches exactly. + /// + /// The nested System.Uri namespace pattern's inner segments are only reached for a type literally + /// named Uri, a shape the shared scalar fixtures never present, so the walk cannot be exercised end to end. + [ExcludeFromCodeCoverage] + private static bool IsUri(ITypeSymbol type) => + type is + { + Name: "Uri", + ContainingNamespace.Name: SystemNamespace, + ContainingNamespace.ContainingNamespace.IsGlobalNamespace: true + }; + + /// Determines whether a type is or derives from it. + /// The type to inspect. + /// when the type is assignable to . + /// + /// Mirrors the reflection builder's typeof(CultureInfo).IsAssignableFrom(type), so derived cultures are + /// scalars too rather than objects whose public properties get flattened into the query string. + /// + private static bool IsCultureInfo(ITypeSymbol type) + { + for (var current = type; current is not null; current = current.BaseType) + { + if (current is + { + Name: "CultureInfo", + ContainingNamespace.Name: "Globalization", + ContainingNamespace.ContainingNamespace.Name: SystemNamespace, + ContainingNamespace.ContainingNamespace.ContainingNamespace.IsGlobalNamespace: true + }) + { + return true; + } + } + + return false; + } + + /// Determines whether a type is or nullable . + /// The type to inspect. + /// when the type is a cancellation token. + private static bool IsCancellationToken(ITypeSymbol type) + { + // Structural match instead of allocating a fully-qualified display string for every parameter. + if (type is INamedTypeSymbol + { + OriginalDefinition.SpecialType: SpecialType.System_Nullable_T, + TypeArguments: [var underlying] + }) + { + type = underlying; + } + + return type is + { + Name: "CancellationToken", + ContainingNamespace.Name: "Threading", + ContainingNamespace.ContainingNamespace.Name: SystemNamespace, + ContainingNamespace.ContainingNamespace.ContainingNamespace.IsGlobalNamespace: true + }; + } + + /// Tries to parse an explicitly attributed body parameter. + /// The parameter to inspect. + /// The parameter type display string. + /// The interface generation context, used to qualify extern-aliased types. + /// Receives the body parameter model. + /// when the parameter has a body attribute. + private static bool TryParseBodyParameter( + IParameterSymbol parameter, + string parameterType, + InterfaceGenerationContext context, + out RequestParameterModel bodyParameter) + { + foreach (var attribute in parameter.GetAttributes()) + { + if (!IsRefitAttribute(attribute.AttributeClass, BodyAttributeDisplayName)) + { + continue; + } + + var bodyInfo = ParseBodyAttribute(attribute); + var formFields = bodyInfo.SerializationMethod == "UrlEncoded" + ? TryBuildFormFields(parameter.Type, context) + : null; + bodyParameter = new( + parameter.MetadataName, + parameterType, + null, + BuildParameterAttributes(parameter, context), + RequestParameterKind.Body, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + string.Empty, + string.Empty, + bodyInfo.SerializationMethod, + bodyInfo.BufferMode) + { + FormFields = formFields, + }; + return true; + } + + // The caller only reads the out value on the true branch, so skip building a discarded + // Unsupported model (and its per-attribute BuildParameterAttributes allocations) here. + bodyParameter = null!; + return false; + } + + /// Tries to parse a dynamic header parameter. + /// The parameter to inspect. + /// The parameter type display string. + /// The interface generation context, used to qualify extern-aliased types. + /// Receives the header parameter model. + /// when the parameter has a supported header attribute. + private static bool TryParseHeaderParameter( + IParameterSymbol parameter, + string parameterType, + InterfaceGenerationContext context, + out RequestParameterModel headerParameter) + { + foreach (var attribute in parameter.GetAttributes()) + { + if (!IsRefitAttribute(attribute.AttributeClass, HeaderAttributeDisplayName)) + { + continue; + } + + var arguments = attribute.ConstructorArguments; + if (arguments.IsEmpty || arguments[0].Value is not string headerName || + string.IsNullOrWhiteSpace(headerName)) + { + continue; + } + + headerParameter = new( + parameter.MetadataName, + parameterType, + null, + BuildParameterAttributes(parameter, context), + RequestParameterKind.Header, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + headerName.Trim(), + string.Empty, + string.Empty, + BodyBufferMode.None); + return true; + } + + headerParameter = null!; + return false; + } + + /// Tries to parse a dynamic header collection parameter. + /// The parameter to inspect. + /// The parameter type display string. + /// The interface generation context, used to qualify extern-aliased types. + /// Receives the header collection parameter model. + /// when the parameter has a supported header collection attribute. + private static bool TryParseHeaderCollectionParameter( + IParameterSymbol parameter, + string parameterType, + InterfaceGenerationContext context, + out RequestParameterModel headerCollectionParameter) + { + foreach (var attribute in parameter.GetAttributes()) + { + if (!IsRefitAttribute(attribute.AttributeClass, HeaderCollectionAttributeDisplayName)) + { + continue; + } + + if (IsSupportedHeaderCollectionType(parameter.Type)) + { + headerCollectionParameter = new( + parameter.MetadataName, + parameterType, + null, + BuildParameterAttributes(parameter, context), + RequestParameterKind.HeaderCollection, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + string.Empty, + string.Empty, + string.Empty, + BodyBufferMode.None); + return true; + } + + headerCollectionParameter = null!; + return false; + } + + headerCollectionParameter = null!; + return false; + } + + /// Tries to parse a request property parameter. + /// The parameter to inspect. + /// The parameter type display string. + /// The interface generation context, used to qualify extern-aliased types. + /// Receives the property parameter model. + /// when the parameter has a property attribute. + private static bool TryParsePropertyParameter( + IParameterSymbol parameter, + string parameterType, + InterfaceGenerationContext context, + out RequestParameterModel propertyParameter) + { + foreach (var attribute in parameter.GetAttributes()) + { + if (!IsRefitAttribute(attribute.AttributeClass, PropertyAttributeDisplayName)) + { + continue; + } + + var arguments = attribute.ConstructorArguments; + var propertyKey = !arguments.IsEmpty && arguments[0].Value is string { Length: > 0 } key + ? key + : parameter.MetadataName; + propertyParameter = new( + parameter.MetadataName, + parameterType, + null, + BuildParameterAttributes(parameter, context), + RequestParameterKind.Property, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + string.Empty, + propertyKey, + string.Empty, + BodyBufferMode.None); + return true; + } + + propertyParameter = null!; + return false; + } + + /// Builds the Authorization header parameter model for an [Authorize] parameter. + /// The parameter symbol. + /// The parameter type display string. + /// The authorization scheme, prepended to the value as "{scheme} ". + /// The interface generation context, used to qualify extern-aliased types. + /// The header parameter model that emits Authorization: {scheme} {value}. + private static RequestParameterModel BuildAuthorizeHeaderParameter( + IParameterSymbol parameter, + string parameterType, + string scheme, + InterfaceGenerationContext context) => + new( + parameter.MetadataName, + parameterType, + null, + BuildParameterAttributes(parameter, context), + RequestParameterKind.Header, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + "Authorization", + string.Empty, + string.Empty, + BodyBufferMode.None) + { + HeaderValuePrefix = scheme + " ", + }; + + /// Builds an unsupported request parameter model. + /// The parameter symbol. + /// The parameter type display string. + /// The interface generation context, used to qualify extern-aliased types. + /// The unsupported parameter model. + private static RequestParameterModel UnsupportedRequestParameter( + IParameterSymbol parameter, + string parameterType, + InterfaceGenerationContext context) => + new( + parameter.MetadataName, + parameterType, + null, + BuildParameterAttributes(parameter, context), + RequestParameterKind.Unsupported, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + string.Empty, + string.Empty, + string.Empty, + BodyBufferMode.None); + + /// Builds a path request parameter model. + /// The parameter symbol. + /// The parameter type. + /// The parameter's location in the URL. + /// The interface generation context, used to qualify extern-aliased types. + /// The path request model. + private static RequestParameterModel PathRequestParameter( + IParameterSymbol parameter, + string parameterType, + ImmutableEquatableArray locations, + InterfaceGenerationContext context) => + new( + parameter.MetadataName, + parameterType, + locations, + BuildParameterAttributes(parameter, context), + RequestParameterKind.Path, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + string.Empty, + string.Empty, + string.Empty, + BodyBufferMode.None); + + /// + /// Flattens a parameter's attributes into value-typed models so the incremental generator cache holds no + /// Roslyn symbols. Attribute type names and argument expressions are precomputed for the emitter. + /// + /// The parameter to inspect. + /// The interface generation context, used to qualify extern-aliased types. + /// The precomputed attribute models. + private static ImmutableEquatableArray BuildParameterAttributes(IParameterSymbol parameter, InterfaceGenerationContext context) + { + var attributes = parameter.GetAttributes(); + if (attributes.IsEmpty) + { + return ImmutableEquatableArray.Empty; + } + + var models = new List(attributes.Length); + foreach (var attribute in attributes) + { + var constructorArguments = new List(attribute.ConstructorArguments.Length); + foreach (var argument in attribute.ConstructorArguments) + { + constructorArguments.Add(ConstantValueToString(argument, context)); + } + + var namedArguments = new List(attribute.NamedArguments.Length); + foreach (var named in attribute.NamedArguments) + { + namedArguments.Add(new(named.Key, ConstantValueToString(named.Value, context))); + } + + models.Add(new( + QualifyType(attribute.AttributeClass!, context), + constructorArguments.ToImmutableEquatableArray(), + namedArguments.ToImmutableEquatableArray())); + } + + return models.ToImmutableEquatableArray(); + } + + /// Renders a typed constant attribute argument as the C# source expression the emitter writes. + /// The typed constant. + /// The interface generation context, used to qualify extern-aliased types. + /// The source expression, or "null" when the value is null. + private static string ConstantValueToString(TypedConstant argument, InterfaceGenerationContext context) + { + var result = string.Empty; + + if (!argument.IsNull) + { + // A non-null attribute argument is always one of Enum, Type, Array or Primitive; the primitive rendering + // doubles as the fallback so no unreachable throwing arm is needed. + result = argument.Kind switch + { + TypedConstantKind.Enum => $"({QualifyType(argument.Type!, context)}){argument.Value!}", + TypedConstantKind.Type => $"typeof({QualifyType((ITypeSymbol)argument.Value!, context)})", + TypedConstantKind.Array => RenderConstantArray(argument, context), + _ => SymbolDisplay.FormatPrimitive(argument.Value!, true, false)! + }; + } + + return result.Length > 0 ? result : "null"; + } + + /// Renders an array-valued attribute argument as a C# array-creation expression. + /// The array typed constant. + /// The interface generation context, used to qualify extern-aliased types. + /// The new[] { ... } source expression. + private static string RenderConstantArray(TypedConstant argument, InterfaceGenerationContext context) + { + var parts = new List(argument.Values.Length); + foreach (var value in argument.Values) + { + parts.Add(ConstantValueToString(value, context)); + } + + return $"new[] {{ {string.Join(", ", parts)} }}"; + } + + /// 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"; +} diff --git a/src/InterfaceStubGenerator.Shared/Parser.Request.Parameters.cs b/src/InterfaceStubGenerator.Shared/Parser.Request.Parameters.cs new file mode 100644 index 000000000..d1447761f --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Parser.Request.Parameters.cs @@ -0,0 +1,473 @@ +// 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.Generator; + +/// Parses candidate interfaces and methods into the models used to generate Refit stubs. +/// Classifies each method parameter into its request binding for the generated inline path. +internal static partial class Parser +{ + /// Parses request parameter bindings for the generated inline path. + /// The method parameters. + /// The placeholder names in the URL with their locations. + /// The resolved System.IFormattable symbol used to classify inline-eligible path parameter types, or null when unavailable. + /// Whether an un-attributed complex parameter becomes the implicit request body. + /// Whether the method is multipart, so un-attributed parameters become form parts. + /// The interface generation context, used to qualify extern-aliased types. + /// Receives whether every parameter is supported. + /// The parsed request parameter models. + private static ImmutableEquatableArray ParseRequestParameters( + in ImmutableArray parameters, + Dictionary> parameterLocations, + INamedTypeSymbol? formattableSymbol, + bool allowImplicitBody, + bool isMultipart, + InterfaceGenerationContext context, + out bool canGenerateInline) + { + if (parameters.IsEmpty) + { + canGenerateInline = true; + return ImmutableEquatableArray.Empty; + } + + // An explicit [Body] anywhere suppresses implicit body detection, matching the reflection builder. + var implicitBodyEligible = allowImplicitBody && !HasExplicitBodyParameter(parameters); + + var requestParameters = new RequestParameterModel[parameters.Length]; + var bodyCount = 0; + var cancellationTokenCount = 0; + var headerCollectionCount = 0; + var implicitBodyAssigned = false; + canGenerateInline = true; + + for (var i = 0; i < parameters.Length; i++) + { + var parameter = parameters[i]; + var name = ResolveUrlName(parameter); + _ = parameterLocations.TryGetValue(name, out var location); + List? roundTripLocation = null; + if (location is null) + { + _ = parameterLocations.TryGetValue("**" + name, out roundTripLocation); + } + + var classification = new LooseParameterContext( + name, + location?.ToImmutableEquatableArray(), + roundTripLocation?.ToImmutableEquatableArray(), + parameterLocations, + formattableSymbol, + implicitBodyEligible, + isMultipart, + context); + var parsedParameter = ParseRequestParameter(parameter, classification, ref implicitBodyAssigned); + requestParameters[i] = parsedParameter.Parameter; + bodyCount += parsedParameter.BodyCount; + cancellationTokenCount += parsedParameter.CancellationTokenCount; + headerCollectionCount += parsedParameter.HeaderCollectionCount; + canGenerateInline &= parsedParameter.CanGenerateInline; + } + + // More than one body, cancellation token, header collection, or [Authorize] parameter is an invalid + // definition the reflection builder rejects; fall back so its validation still throws. + canGenerateInline &= HasInlineableParameterCounts( + bodyCount, + cancellationTokenCount, + headerCollectionCount, + requestParameters); + + return ImmutableEquatableArrayFactory.FromArray(requestParameters); + } + + /// Determines whether the single-instance parameter counts allow inline generation. + /// The number of body parameters. + /// The number of cancellation token parameters. + /// The number of header collection parameters. + /// The parsed request parameters, scanned for [Authorize] parameters. + /// when no single-instance binding appears more than once. + private static bool HasInlineableParameterCounts( + int bodyCount, + int cancellationTokenCount, + int headerCollectionCount, + RequestParameterModel[] parameters) => + bodyCount <= 1 + && cancellationTokenCount <= 1 + && headerCollectionCount <= 1 + && CountAuthorizeParameters(parameters) <= 1; + + /// Counts the [Authorize] parameters (Authorization headers carrying a scheme prefix). + /// The parsed request parameters. + /// The number of [Authorize] parameters. + private static int CountAuthorizeParameters(RequestParameterModel[] parameters) + { + var count = 0; + foreach (var parameter in parameters) + { + if (parameter is { Kind: RequestParameterKind.Header, HeaderValuePrefix: not null }) + { + count++; + } + } + + return count; + } + + /// Resolves a parameter's URL name, honoring an [AliasAs] attribute. + /// The parameter symbol. + /// The alias name or the declared parameter name. + private static string ResolveUrlName(IParameterSymbol parameter) + { + var aliasAttr = FindParameterAttribute(parameter, AliasAsAttributeDisplayName); + return aliasAttr is not null ? GetFirstStringArgument(aliasAttr) ?? parameter.Name : parameter.Name; + } + + /// Determines whether any parameter carries an explicit [Body] attribute. + /// The method parameters. + /// when an explicit body parameter exists. + private static bool HasExplicitBodyParameter(in ImmutableArray parameters) + { + foreach (var parameter in parameters) + { + if (HasParameterAttribute(parameter, BodyAttributeDisplayName)) + { + return true; + } + } + + return false; + } + + /// Parses one request parameter binding. + /// The parameter to parse. + /// The lookup state used to classify the parameter. + /// Tracks whether an earlier parameter already claimed the implicit body. + /// The parsed parameter and eligibility counters. + private static ParsedRequestParameter ParseRequestParameter( + IParameterSymbol parameter, + in LooseParameterContext context, + ref bool implicitBodyAssigned) + { + var parameterType = QualifyType(parameter.Type, context.Generation); + if (IsCancellationToken(parameter.Type)) + { + return CancellationTokenParameter(parameter, parameterType, context.Locations, context.Generation); + } + + if (TryParseBodyParameter(parameter, parameterType, context.Generation, out var bodyParameter)) + { + // A form-url-encoded body of a type that references a type parameter would emit + // CreateUrlEncodedBodyContent, whose [DynamicallyAccessedMembers(PublicProperties)] an open type + // parameter cannot satisfy (IL2091), so it keeps using the reflection request builder. + var bodyEligible = bodyParameter.BodySerializationMethod != "UrlEncoded" + || !ReferencesTypeParameter(parameter.Type); + return new(bodyParameter, bodyEligible, 1, 0, 0); + } + + if (TryParseHeaderParameter(parameter, parameterType, context.Generation, out var headerParameter)) + { + return new(headerParameter, true, 0, 0, 0); + } + + if (TryParseHeaderCollectionParameter(parameter, parameterType, context.Generation, out var headerCollectionParameter)) + { + return new( + headerCollectionParameter, + headerCollectionParameter.Kind == RequestParameterKind.HeaderCollection, + 0, + 0, + 1); + } + + return TryParsePropertyParameter(parameter, parameterType, context.Generation, out var propertyParameter) + ? ParsePropertyQueryBinding(parameter, parameterType, context.UrlName, context.FormattableSymbol, context.Generation, propertyParameter) + : ParseBoundPathParameter(parameter, parameterType, context) + ?? ClassifyLooseParameter(parameter, parameterType, context, ref implicitBodyAssigned); + } + + /// Builds the cancellation token parameter binding. + /// The parameter symbol. + /// The parameter type display string. + /// The parameter's placeholder locations, if any. + /// The interface generation context, used to qualify extern-aliased types. + /// The parsed parameter and eligibility counters. + private static ParsedRequestParameter CancellationTokenParameter( + IParameterSymbol parameter, + string parameterType, + ImmutableEquatableArray? locations, + InterfaceGenerationContext context) => + new( + new( + parameter.MetadataName, + parameterType, + locations, + BuildParameterAttributes(parameter, context), + RequestParameterKind.CancellationToken, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + string.Empty, + string.Empty, + string.Empty, + BodyBufferMode.None), + true, + 0, + 1, + 0); + + /// Parses a parameter bound to a path placeholder, when one exists. + /// The parameter to parse. + /// The parameter type display string. + /// The lookup state used to classify the parameter. + /// The parsed path binding, or when the parameter has no placeholder. + private static ParsedRequestParameter? ParseBoundPathParameter( + IParameterSymbol parameter, + string parameterType, + in LooseParameterContext context) => + context switch + { + { Locations: { } locations } => ParseDirectPathParameter(parameter, parameterType, locations, context), + { RoundTripLocations: { } roundTripLocations } => ParseRoundTripPathParameter(parameter, parameterType, roundTripLocations, context), + _ => null, + }; + + /// Parses a parameter bound to a plain {name} placeholder. + /// The parameter to parse. + /// The parameter type display string. + /// The placeholder locations. + /// The lookup state used to classify the parameter. + /// The parsed parameter and eligibility counters. + private static ParsedRequestParameter ParseDirectPathParameter( + IParameterSymbol parameter, + string parameterType, + ImmutableEquatableArray locations, + in LooseParameterContext context) => + CanInlinePathParameterType(parameter.Type, context.FormattableSymbol) + ? new( + PathRequestParameter(parameter, parameterType, locations, context.Generation) with + { + ValueFormat = BuildValueFormat(parameter.Type, NormalizeFormat(ParseParameterQueryData(parameter).Format), context.FormattableSymbol, context.Generation), + PreEncoded = HasParameterAttribute(parameter, EncodedAttributeDisplayName), + }, + true, + 0, + 0, + 0) + : new(UnsupportedRequestParameter(parameter, parameterType, context.Generation), false, 0, 0, 0); + + /// Determines whether a plain {name} path parameter can be bound inline. + /// The declared parameter type. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// when the value stringifies with the same result as the reflection builder. + /// + /// A simple type formats through the fast path. Any concrete class, struct, or array (including a collection like + /// List<int> or byte[]) also binds inline: the reflection builder renders a route value with + /// UrlParameterFormatter.Format(value, ...), which for a non-IFormattable value is value.ToString() + /// - a virtual call that dispatches to the runtime type in the generated code exactly as it does in the reflection + /// builder, so a runtime subtype (and a collection's System.Collections… text) renders identically. (The only + /// divergence is the vanishing case of a subtype whose IFormattable.ToString(null, invariant) differs from its + /// parameterless ToString().) An object, interface, or open generic type stays on the reflection path - + /// it has no usable declared shape. + /// + private static bool CanInlinePathParameterType(ITypeSymbol type, INamedTypeSymbol? formattableSymbol) => + IsSimpleType(type, formattableSymbol) + || (type.SpecialType != SpecialType.System_Object + && type.TypeKind is TypeKind.Class or TypeKind.Struct or TypeKind.Array); + + /// Parses a parameter bound to a round-tripping {**name} placeholder. + /// Round-tripping normally needs the reflection builder's per-segment escaping, but an + /// [Encoded] string value passes through verbatim, so it becomes a plain inline substitution. + /// The parameter to parse. + /// The parameter type display string. + /// The placeholder locations. + /// The lookup state used to classify the parameter. + /// The parsed parameter and eligibility counters. + private static ParsedRequestParameter ParseRoundTripPathParameter( + IParameterSymbol parameter, + string parameterType, + ImmutableEquatableArray roundTripLocations, + in LooseParameterContext context) + { + var encoded = HasParameterAttribute(parameter, EncodedAttributeDisplayName); + + // [Encoded] keeps the caller-encoded string verbatim (string only). A non-[Encoded] catch-all splits the + // value's string form on '/', formatting and escaping each segment while preserving the separators, exactly + // as the reflection builder does — so any type is supported inline. + return encoded && parameter.Type.SpecialType != SpecialType.System_String + ? new(UnsupportedRequestParameter(parameter, parameterType, context.Generation), false, 0, 0, 0) + : new( + PathRequestParameter(parameter, parameterType, roundTripLocations, context.Generation) with + { + ValueFormat = BuildValueFormat(parameter.Type, null, context.FormattableSymbol, context.Generation), + PreEncoded = true, + IsRoundTrip = !encoded, + }, + true, + 0, + 0, + 0); + } + + /// Classifies a parameter with no path binding as a flag, implicit body, query value, or fallback. + /// The parameter to classify. + /// The parameter type display string. + /// The lookup state used to classify the parameter. + /// Tracks whether an earlier parameter already claimed the implicit body. + /// The parsed parameter and eligibility counters. + private static ParsedRequestParameter ClassifyLooseParameter( + IParameterSymbol parameter, + string parameterType, + in LooseParameterContext context, + ref bool implicitBodyAssigned) + { + // Dotted {param.Prop} placeholders bind declared properties into the path; each property is formatted and + // escaped just like a scalar path parameter, and any property left unbound flattens into the query string. + // An unresolvable or non-simple placeholder property, or an unsupported residual property, falls back. + if (HasDottedPlaceholderFor(context.ParameterLocations, context.UrlName)) + { + return BuildPathObjectBinding(parameter, parameterType, context); + } + + // [Authorize] emits an Authorization header of "{scheme} {value}"; the scheme is a compile-time constant. + if (FindParameterAttribute(parameter, AuthorizeAttributeDisplayName) is { } authorizeAttribute) + { + var scheme = GetFirstStringArgument(authorizeAttribute) ?? DefaultAuthorizeScheme; + return new( + BuildAuthorizeHeaderParameter(parameter, parameterType, scheme, context.Generation), + true, + 0, + 0, + 0); + } + + // In a multipart method every remaining parameter is a form part unless it carries [Query]; this replaces + // the implicit-body and auto-query classification the non-multipart path applies below. + if (context.IsMultipart) + { + return ClassifyMultipartParameter(parameter, parameterType, context); + } + + if (HasParameterAttribute(parameter, QueryNameAttributeDisplayName)) + { + var flagModel = BuildFlagModel(parameter, context.FormattableSymbol, context.Generation); + return new(QueryRequestParameter(parameter, parameterType, flagModel, context.Generation), true, 0, 0, 0); + } + + // Body resolution precedes query mapping in the reflection builder: on POST/PUT/PATCH the first + // un-attributed complex parameter is the implicit body; a second one throws there, so fall back. + if (context.ImplicitBodyEligible && IsImplicitBodyCandidate(parameter)) + { + return ClaimImplicitBody(parameter, parameterType, context.Generation, ref implicitBodyAssigned); + } + + return TryBuildQueryModel(parameter, context.UrlName, context.FormattableSymbol, context.Generation, out var query) + ? new(QueryRequestParameter(parameter, parameterType, query!, context.Generation), true, 0, 0, 0) + : new(UnsupportedRequestParameter(parameter, parameterType, context.Generation), false, 0, 0, 0); + } + + /// Determines whether a parameter matches the reflection builder's implicit body candidacy rules. + /// The parameter to inspect. + /// for un-attributed non-string reference-type parameters. + private static bool IsImplicitBodyCandidate(IParameterSymbol parameter) => + !parameter.Type.IsValueType + && parameter.Type.SpecialType != SpecialType.System_String + && !HasParameterAttribute(parameter, QueryAttributeDisplayName) + && !HasParameterAttribute(parameter, QueryConverterAttributeDisplayName); + + /// Claims the implicit body slot, falling back when an earlier parameter already claimed it. + /// The parameter to bind. + /// The parameter type display string. + /// The interface generation context, used to qualify extern-aliased types. + /// Tracks whether an earlier parameter already claimed the implicit body. + /// The parsed parameter and eligibility counters. + private static ParsedRequestParameter ClaimImplicitBody( + IParameterSymbol parameter, + string parameterType, + InterfaceGenerationContext context, + ref bool implicitBodyAssigned) + { + if (implicitBodyAssigned) + { + return new(UnsupportedRequestParameter(parameter, parameterType, context), false, 0, 0, 0); + } + + implicitBodyAssigned = true; + return new(ImplicitBodyRequestParameter(parameter, parameterType, context), true, 1, 0, 0); + } + + /// Attaches query-binding metadata to a property parameter that also carries [Query]. + /// The parameter symbol. + /// The parameter type display string. + /// The resolved URL name. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// The interface generation context, used to qualify extern-aliased types. + /// The parsed property parameter model. + /// The parsed parameter and eligibility counters. + private static ParsedRequestParameter ParsePropertyQueryBinding( + IParameterSymbol parameter, + string parameterType, + string urlName, + INamedTypeSymbol? formattableSymbol, + InterfaceGenerationContext context, + RequestParameterModel propertyParameter) + { + if (!HasParameterAttribute(parameter, QueryAttributeDisplayName)) + { + return new(propertyParameter, true, 0, 0, 0); + } + + // A [Property] parameter that also carries [Query] feeds both the request options and the query string. + return TryBuildQueryModel(parameter, urlName, formattableSymbol, context, out var propertyQuery) + ? new(propertyParameter with { Query = propertyQuery }, true, 0, 0, 0) + : new(UnsupportedRequestParameter(parameter, parameterType, context), false, 0, 0, 0); + } + + /// Builds a query request parameter model. + /// The parameter symbol. + /// The parameter type display string. + /// The query-binding metadata. + /// The interface generation context, used to qualify extern-aliased types. + /// The query request parameter model. + private static RequestParameterModel QueryRequestParameter( + IParameterSymbol parameter, + string parameterType, + QueryParameterModel query, + InterfaceGenerationContext context) => + new( + parameter.MetadataName, + parameterType, + null, + BuildParameterAttributes(parameter, context), + RequestParameterKind.Query, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + string.Empty, + string.Empty, + string.Empty, + BodyBufferMode.None) + { + Query = query, + }; + + /// Builds the implicit body parameter model for POST/PUT/PATCH methods. + /// The parameter symbol. + /// The parameter type display string. + /// The interface generation context, used to qualify extern-aliased types. + /// The implicit body parameter model. + private static RequestParameterModel ImplicitBodyRequestParameter( + IParameterSymbol parameter, + string parameterType, + InterfaceGenerationContext context) => + new( + parameter.MetadataName, + parameterType, + null, + BuildParameterAttributes(parameter, context), + RequestParameterKind.Body, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + string.Empty, + string.Empty, + "Serialized", + BodyBufferMode.Settings); +} diff --git a/src/InterfaceStubGenerator.Shared/Parser.Request.Path.cs b/src/InterfaceStubGenerator.Shared/Parser.Request.Path.cs new file mode 100644 index 000000000..d3110d662 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Parser.Request.Path.cs @@ -0,0 +1,246 @@ +// 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; + +/// Parses candidate interfaces and methods into the models used to generate Refit stubs. +/// Dotted {param.Prop} object path placeholder binding. +internal static partial class Parser +{ + /// Classifies a dotted {param.Prop} object parameter into its path bindings and residual query. + /// The enclosing object parameter. + /// The parameter type display string. + /// The lookup state carrying the placeholder locations and generation context. + /// + /// The parsed path-object parameter carrying its property-to-path bindings and, for any property not bound to the + /// path, a flattened residual query. Falls back when a placeholder property is unresolvable or non-simple, or when + /// a residual property has a shape the query flattener cannot render inline. + /// + private static ParsedRequestParameter BuildPathObjectBinding( + IParameterSymbol parameter, + string parameterType, + in LooseParameterContext context) + { + if (TryBuildPathObjectBindings(parameter, context.UrlName, context) is not { } bindings) + { + return new(UnsupportedRequestParameter(parameter, parameterType, context.Generation), false, 0, 0, 0); + } + + var boundPropertyNames = new HashSet(StringComparer.Ordinal); + foreach (var binding in bindings) + { + _ = boundPropertyNames.Add(binding.TopLevelClrName); + } + + // The reflection builder splits a bound object between the path and the query: matched properties fill the + // placeholders, and every other public readable property is flattened into the query string. Mirror that by + // flattening the declared type's residual properties; an unsupported residual shape falls the whole parameter + // back so the query is never emitted partially. + if (!TryBuildPathResidualQuery(parameter, context.UrlName, boundPropertyNames, context.FormattableSymbol, context.Generation, out var residualQuery)) + { + return new(UnsupportedRequestParameter(parameter, parameterType, context.Generation), false, 0, 0, 0); + } + + var model = BuildPathObjectParameter(parameter, parameterType, bindings, context.Generation); + return new(residualQuery is null ? model : model with { Query = residualQuery }, true, 0, 0, 0); + } + + /// Flattens the properties of a path-bound object that are not consumed by a path placeholder into a query. + /// The enclosing object parameter. + /// The parameter's resolved URL name. + /// The property names already bound to path placeholders, excluded from the query. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// The interface generation context, used to qualify extern-aliased types. + /// Receives the residual object query, or null when every property is bound to the path. + /// when a residual property cannot flatten inline and the parameter must fall back. + private static bool TryBuildPathResidualQuery( + IParameterSymbol parameter, + string urlName, + HashSet boundPropertyNames, + INamedTypeSymbol? formattableSymbol, + InterfaceGenerationContext context, + out QueryParameterModel? residualQuery) + { + residualQuery = null; + + var data = ParseParameterQueryData(parameter); + var parameterPrefixSegment = string.IsNullOrWhiteSpace(data.Prefix) ? null : data.Prefix + data.Delimiter; + + // A path-bound property is always a simple scalar, so it flattens here without issue; a null result therefore + // means a residual property has an unsupported shape, which falls the whole parameter back to reflection. + var properties = TryBuildQueryObjectProperties(parameter.Type, parameterPrefixSegment, formattableSymbol, context); + if (properties is null) + { + return false; + } + + var residual = new List(); + foreach (var property in properties) + { + if (!boundPropertyNames.Contains(property.ClrName)) + { + residual.Add(property); + } + } + + if (residual.Count == 0) + { + return true; + } + + residualQuery = new( + urlName, + QueryParameterShape.Object, + TreatAsString: false, + HasParameterAttribute(parameter, EncodedAttributeDisplayName), + data.CollectionFormatValue, + ElementCanBeNull: false, + BuildValueFormat(parameter.Type, null, formattableSymbol, context), + residual.ToImmutableEquatableArray(), + NestingDelimiter: string.IsNullOrEmpty(data.Delimiter) ? "." : data.Delimiter); + return true; + } + + /// Resolves the dotted {param.Prop} placeholders bound to a parameter into path property bindings. + /// The enclosing object parameter. + /// The parameter's resolved URL name. + /// The lookup state carrying the placeholder locations and generation context. + /// The property bindings, or null when any placeholder cannot be resolved to a simple readable property. + private static ImmutableEquatableArray? TryBuildPathObjectBindings( + IParameterSymbol parameter, + string urlName, + in LooseParameterContext context) + { + var prefix = urlName + "."; + var bindings = new List(); + foreach (var placeholder in context.ParameterLocations) + { + var key = placeholder.Key; + if (key.Length <= prefix.Length || !key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + // A single-level {param.Prop} binds a direct property; a dotted {param.a.b} binds a nested chain, walked + // with null-conditional access so a null link renders empty exactly as the reflection builder does. + var propertyPath = key[prefix.Length..]; + if (TryResolvePathPropertyChain(parameter.Type, propertyPath, context) is not { } chain) + { + return null; + } + + foreach (var location in placeholder.Value) + { + bindings.Add(new(location, chain.AccessPath, chain.TopLevelName, chain.PropertyType, chain.ValueFormat, chain.CanBeNull)); + } + } + + if (bindings.Count == 0) + { + return null; + } + + // The path builder appends fragments in template order, so bindings must be sorted by placeholder position. + bindings.Sort(static (left, right) => left.Location.Start.Value.CompareTo(right.Location.Start.Value)); + return bindings.ToImmutableEquatableArray(); + } + + /// Resolves a dotted path placeholder (single or nested) into its access expression and formatting metadata. + /// The enclosing object parameter's type. + /// The placeholder text after the parameter name (e.g. Slug or a.b). + /// The lookup state carrying the generation context. + /// The resolved access path, top-level property, final type, formatting strategy and nullability, or null. + private static (string AccessPath, string TopLevelName, string PropertyType, InlineValueFormatModel ValueFormat, bool CanBeNull)? + TryResolvePathPropertyChain(ITypeSymbol rootType, string propertyPath, in LooseParameterContext context) + { + var segments = propertyPath.Split('.'); + if (FindReadablePathProperty(rootType, segments[0]) is not { } firstProperty) + { + return null; + } + + var names = new List(segments.Length) { firstProperty.Name }; + var finalProperty = firstProperty; + var currentType = finalProperty.Type; + for (var i = 1; i < segments.Length; i++) + { + if (FindReadablePathProperty(currentType, segments[i]) is not { } property) + { + return null; + } + + names.Add(property.Name); + finalProperty = property; + currentType = property.Type; + } + + if (!IsSimpleType(finalProperty.Type, context.FormattableSymbol)) + { + return null; + } + + var propertyType = QualifyType(finalProperty.Type, context.Generation); + var canBeNull = CanBeNull(finalProperty.Type, finalProperty.NullableAnnotation); + + // A single-level binding keeps the span-formattable fast path. A nested chain is formatted through the URL + // parameter formatter (FormatterOnly): '?.' between links already yields null for a missing intermediate, so the + // fast '.Value' unwrap - which would bind to the wrong link - is skipped, matching the reflection builder's walk. + if (segments.Length == 1) + { + return (names[0], names[0], propertyType, BuildValueFormat(finalProperty.Type, null, context.FormattableSymbol, context.Generation), canBeNull); + } + + var formatterOnly = new InlineValueFormatModel(InlineFormatKind.FormatterOnly, null, propertyType, IsNullableValueType: false, EnumMembers: null); + return (string.Join("?.", names), names[0], propertyType, formatterOnly, canBeNull); + } + + /// Finds a public readable property on a type or its base types by case-insensitive name. + /// The declared parameter type. + /// The property name from the dotted placeholder. + /// The matching property, or null when none is readable. + private static IPropertySymbol? FindReadablePathProperty(ITypeSymbol type, string propertyName) + { + for (var current = type; current is not null && current.SpecialType != SpecialType.System_Object; current = current.BaseType) + { + foreach (var member in current.GetMembers()) + { + if (member is IPropertySymbol property + && string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase) + && IsReadableFormProperty(property)) + { + return property; + } + } + } + + return null; + } + + /// Builds the path parameter model for an object whose dotted placeholders bind its properties. + /// The enclosing object parameter. + /// The parameter type display string. + /// The resolved property bindings. + /// The interface generation context, used to qualify extern-aliased types. + /// The path parameter model carrying the property bindings. + private static RequestParameterModel BuildPathObjectParameter( + IParameterSymbol parameter, + string parameterType, + ImmutableEquatableArray bindings, + InterfaceGenerationContext context) => + new( + parameter.MetadataName, + parameterType, + null, + BuildParameterAttributes(parameter, context), + RequestParameterKind.Path, + CanBeNull(parameter.Type, parameter.NullableAnnotation), + string.Empty, + string.Empty, + string.Empty, + BodyBufferMode.None) + { + PathObjectBindings = bindings, + }; +} diff --git a/src/InterfaceStubGenerator.Shared/Parser.Request.Query.Objects.cs b/src/InterfaceStubGenerator.Shared/Parser.Request.Query.Objects.cs new file mode 100644 index 000000000..70d4eb607 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Parser.Request.Query.Objects.cs @@ -0,0 +1,442 @@ +// 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.Generator; + +/// Parses candidate interfaces and methods into the models used to generate Refit stubs. +/// Flattens complex and dictionary query values and their properties into query pairs. +internal static partial class Parser +{ + /// Determines how a dictionary value type renders inline: scalar, flattened sealed complex, or not at all. + /// The dictionary value type. + /// The parameter-level [Query(Format)] applied to each value, or null. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// The interface generation context, used to qualify extern-aliased types. + /// Receives whether the value type renders inline at all. + /// The flattened property descriptors for a sealed complex value, or null for a simple value. + /// + /// A simple value renders as one entryKey=value pair. A sealed or value complex value (with no per-value + /// format) flattens under each entry's key, matching the reflection builder's per-value BuildQueryMap + /// recursion; because the declared type is the runtime type there is no divergence. An object, interface, open, + /// or collection value keeps falling back, since the runtime value could recurse differently than the declared type. + /// + private static ImmutableEquatableArray? ResolveDictionaryValueProperties( + ITypeSymbol valueType, + string? format, + INamedTypeSymbol? formattableSymbol, + InterfaceGenerationContext context, + out bool inlineable) + { + if (IsSimpleType(valueType, formattableSymbol)) + { + inlineable = true; + return null; + } + + if (format is null + && IsConcreteComplexType(valueType) + && TryBuildQueryObjectProperties(valueType, null, formattableSymbol, context) is { } properties) + { + inlineable = true; + return properties; + } + + inlineable = false; + return null; + } + + /// Determines whether a type is a concrete complex type flattened or serialized by its declared shape. + /// The type to inspect. + /// for a concrete class or value type that is neither object nor a collection. + /// + /// Non-sealed types are accepted: the generator uses the declared shape, the same accepted divergence a non-sealed + /// query object already carries (matching the System.Text.Json source generator). For a value that is not a runtime + /// subtype the rendering matches the reflection builder exactly; a polymorphic subtype value renders by its declared + /// type instead of its runtime type. An object, interface, or open generic type has no usable declared shape + /// and stays on the reflection path. + /// + private static bool IsConcreteComplexType(ITypeSymbol type) => + type.TypeKind is TypeKind.Class or TypeKind.Struct + && type.SpecialType != SpecialType.System_Object + && !TryGetEnumerableElementType(type, out _); + + /// Tries to resolve the key and value types of a dictionary-shaped query parameter. + /// The declared parameter type. + /// Receives the dictionary key type. + /// Receives the dictionary value type. + /// when the type closes IDictionary<TKey, TValue> exactly once. + private static bool TryGetDictionaryTypes(ITypeSymbol type, out ITypeSymbol? keyType, out ITypeSymbol? valueType) + { + keyType = null; + valueType = null; + + if (IsGenericDictionaryInterface(type)) + { + var self = (INamedTypeSymbol)type; + keyType = self.TypeArguments[0]; + valueType = self.TypeArguments[1]; + return true; + } + + foreach (var contract in type.AllInterfaces) + { + if (!IsGenericDictionaryInterface(contract)) + { + continue; + } + + // A type closing IDictionary<,> more than once has an ambiguous entry shape; leave it to reflection. + if (keyType is not null) + { + keyType = null; + valueType = null; + return false; + } + + keyType = contract.TypeArguments[0]; + valueType = contract.TypeArguments[1]; + } + + return keyType is not null; + } + + /// Determines whether a type is a closed System.Collections.Generic.IDictionary<TKey, TValue>. + /// The type to inspect. + /// when the type is the generic dictionary interface. + private static bool IsGenericDictionaryInterface(ITypeSymbol type) => + type is INamedTypeSymbol + { + TypeKind: TypeKind.Interface, + Name: "IDictionary", + Arity: GenericDictionaryArity, + ContainingNamespace.Name: "Generic", + ContainingNamespace.ContainingNamespace.Name: "Collections", + ContainingNamespace.ContainingNamespace.ContainingNamespace.Name: "System" + }; + + /// Tries to flatten a query object's public readable properties into compile-time descriptors. + /// The declared query-object type. + /// The enclosing parameter's prefix + delimiter, or null. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// The interface generation context, used to qualify extern-aliased types. + /// The property descriptors, or null when the type must fall back to the reflection request builder. + /// + /// The declared type's properties are flattened, matching how the System.Text.Json source generator treats a + /// declared type. The reflection request builder instead walks the value's runtime type, so passing a + /// derived instance through a base-typed parameter no longer contributes the derived type's extra properties. + /// + private static ImmutableEquatableArray? TryBuildQueryObjectProperties( + ITypeSymbol type, + string? parameterPrefixSegment, + INamedTypeSymbol? formattableSymbol, + InterfaceGenerationContext context) => + TryBuildQueryObjectProperties( + type, + parameterPrefixSegment, + formattableSymbol, + context, + ImmutableHashSet.Empty.WithComparer(SymbolEqualityComparer.Default), + 0); + + /// Flattens a query object's properties, recursing into nested objects with cycle and depth guards. + /// The declared query-object type. + /// The enclosing parameter's prefix + delimiter, or null for nested levels. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// The interface generation context, used to qualify extern-aliased types. + /// The types already being flattened on this path, guarding against reference cycles. + /// The current nesting depth. + /// The property descriptors, or null when the type must fall back to the reflection request builder. + private static ImmutableEquatableArray? TryBuildQueryObjectProperties( + ITypeSymbol type, + string? parameterPrefixSegment, + INamedTypeSymbol? formattableSymbol, + InterfaceGenerationContext context, + ImmutableHashSet ancestors, + int depth) + { + // A cyclic type or one nested past the depth cap keeps using the reflection builder, which recurses by value. + if (!IsInlineFlattenableQueryObject(type) || depth > MaxNestingDepth || ancestors.Contains(type)) + { + return null; + } + + var nextAncestors = ancestors.Add(type); + var properties = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + // type is a concrete class or struct here, so the base chain always terminates at System.Object. + for (var current = type; current.SpecialType != SpecialType.System_Object; current = current.BaseType!) + { + foreach (var member in current.GetMembers()) + { + if (!IsFlattenableProperty(member, seen, out var property)) + { + continue; + } + + // A property flattens when it is a simple scalar, a collection of simple elements, or a nested concrete + // object; any other shape (dictionary, object-typed value, or a [Query(Format)]-carrying collection) + // falls the whole parameter back to reflection rather than emitting a partial query string. + if (BuildQueryObjectPropertyModel(property, parameterPrefixSegment, formattableSymbol, context, nextAncestors, depth) is not { } model) + { + return null; + } + + properties.Add(model); + } + } + + return ImmutableEquatableArrayFactory.FromList(properties); + } + + /// Determines whether a member is a readable, non-ignored, not-yet-seen flattenable property. + /// The type member to inspect. + /// The set of property names already flattened, updated when this one is accepted. + /// Receives the property when the member qualifies. + /// when the member is a flattenable property. + private static bool IsFlattenableProperty(ISymbol member, HashSet seen, out IPropertySymbol property) + { + if (member is IPropertySymbol candidate + && IsReadableFormProperty(candidate) + && !IsIgnoredQueryProperty(candidate) + && seen.Add(candidate.Name)) + { + property = candidate; + return true; + } + + property = null!; + return false; + } + + /// Determines whether a query-object type's declared properties can be flattened inline. + /// The declared query-object type. + /// when the declared type exposes a statically-known property set. + /// + /// object, interfaces and type parameters are excluded because their property set is only known once a value + /// exists; those keep using the reflection request builder. Enumerables (including dictionaries) are excluded + /// because the reflection builder special-cases them rather than flattening their properties. + /// + private static bool IsInlineFlattenableQueryObject(ITypeSymbol type) => + type.TypeKind is TypeKind.Class or TypeKind.Struct + && type.SpecialType != SpecialType.System_Object + && type.SpecialType != SpecialType.System_String + && type is not INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } + && !ImplementsEnumerable(type); + + /// Determines whether a property is excluded from query flattening by an ignore attribute. + /// The property to inspect. + /// when the property carries a recognized ignore attribute. + /// Matches the reflection builder, which compares attribute full names so any assembly's copy counts. + private static bool IsIgnoredQueryProperty(IPropertySymbol property) + { + foreach (var attribute in property.GetAttributes()) + { + var attributeName = attribute.AttributeClass!.ToDisplayString(); + if (attributeName is "System.Runtime.Serialization.IgnoreDataMemberAttribute" + or "System.Text.Json.Serialization.JsonIgnoreAttribute" + or "Newtonsoft.Json.JsonIgnoreAttribute") + { + return true; + } + } + + return false; + } + + /// Reads the [AliasAs], [JsonPropertyName] and [Query] data from a property. + /// The property to inspect. + /// The alias name, the System.Text.Json name, and the parsed query data. + private static (string? Alias, string? Json, QueryFormData Query) ReadQueryPropertyAttributes(IPropertySymbol property) + { + string? aliasName = null; + string? jsonName = null; + var query = default(QueryFormData); + + foreach (var attribute in property.GetAttributes()) + { + var attributeName = attribute.AttributeClass!.ToDisplayString(); + if (attributeName == "Refit.AliasAsAttribute") + { + aliasName = GetFirstStringArgument(attribute); + } + else if (attributeName == "System.Text.Json.Serialization.JsonPropertyNameAttribute") + { + jsonName = GetFirstStringArgument(attribute); + } + else if (attributeName == "Refit.QueryAttribute") + { + query = ParseFormQueryAttribute(attribute); + } + } + + return (aliasName, jsonName, query); + } + + /// Builds the descriptor for one flattened query-object property, or null when it cannot flatten inline. + /// The property to describe. + /// The enclosing parameter's prefix + delimiter, or null. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// The interface generation context, used to qualify extern-aliased types. + /// The types already being flattened on this path, guarding against reference cycles. + /// The current nesting depth. + /// The property descriptor, or null when the property's shape must fall back to reflection. + private static QueryObjectPropertyModel? BuildQueryObjectPropertyModel( + IPropertySymbol property, + string? parameterPrefixSegment, + INamedTypeSymbol? formattableSymbol, + InterfaceGenerationContext context, + ImmutableHashSet ancestors, + int depth) + { + var (aliasName, jsonName, query) = ReadQueryPropertyAttributes(property); + + var propertyPrefixSegment = string.IsNullOrWhiteSpace(query.Prefix) ? null : query.Prefix + query.Delimiter; + var prefixSegment = parameterPrefixSegment + propertyPrefixSegment; + var normalizedPrefix = string.IsNullOrEmpty(prefixSegment) ? null : prefixSegment; + var propertyFormat = NormalizeFormat(query.Format); + + // [AliasAs] always wins and bypasses the key formatter; the System.Text.Json field name is honored only when + // RefitSettings.HonorContentSerializerPropertyNamesInQuery is set, so it is carried as a separate name. + var serializerName = aliasName is null ? jsonName : null; + + // A simple property flattens to one scalar pair. A [Query(Format)] on a non-simple (complex or collection) + // property also renders the whole value as a single pair, not a flattened or expanded one: the reflection + // builder's TryFormatQueryPropertyValue stringifies the value through the form formatter before any enumerable + // or nested branch. That is exactly the scalar model - the value format is ToString-only for a non-IFormattable + // type (matching string.Format("{0:format}", value)), and the emitter still routes to + // FormUrlEncodedParameterFormatter.Format when the formatter is customized. + if (IsSimpleType(property.Type, formattableSymbol) || propertyFormat is not null) + { + return new( + property.Name, + aliasName, + serializerName, + normalizedPrefix, + query.SerializeNull, + CanElementBeNull(property.Type), + propertyFormat, + BuildValueFormat(property.Type, propertyFormat, formattableSymbol, context)); + } + + if (TryBuildCollectionPropertyModel(property, aliasName, serializerName, normalizedPrefix, query, formattableSymbol, context) is { } collectionModel) + { + return collectionModel; + } + + // A dictionary property of simple keys and values expands its entries under this property's key, one + // key.entryKey=value pair per entry, exactly as the reflection builder's nested BuildQueryMap does. + if (TryBuildDictionaryPropertyModel(property, aliasName, serializerName, normalizedPrefix, query, formattableSymbol, context) is { } dictionaryModel) + { + return dictionaryModel; + } + + // A nested concrete object flattens recursively. Its children carry no parameter prefix (this property's key + // already includes it); they compose their keys under this property's key with the parameter delimiter. + // A nullable value type (Nullable) is unwrapped so its underlying struct flattens like a nullable class, + // accessed through .Value after the null check the emitter already emits for a nullable nested property. + var nestedType = property.Type; + var nestedThroughValue = false; + if (property.Type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + // A nested Nullable is always a constructed named type, so its single type argument is the struct. + nestedType = ((INamedTypeSymbol)property.Type).TypeArguments[0]; + nestedThroughValue = true; + } + + return TryBuildQueryObjectProperties(nestedType, null, formattableSymbol, context, ancestors, depth + 1) is { } nested + ? new( + property.Name, + aliasName, + serializerName, + normalizedPrefix, + query.SerializeNull, + CanElementBeNull(property.Type), + null, + BuildValueFormat(property.Type, null, formattableSymbol, context), + Nested: nested, + NestedThroughValue: nestedThroughValue) + : null; + } + + /// Builds the descriptor for a dictionary property of simple keys and values, or null for any other shape. + /// The property to describe. + /// The resolved [AliasAs] name, or null. + /// The resolved content-serializer name, or null. + /// The resolved key prefix, or null. + /// The property's parsed [Query] data. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// The interface generation context, used to qualify extern-aliased types. + /// The dictionary property descriptor, or null when the property is not a simple-keyed and -valued dictionary. + private static QueryObjectPropertyModel? TryBuildDictionaryPropertyModel( + IPropertySymbol property, + string? aliasName, + string? serializerName, + string? normalizedPrefix, + in QueryFormData query, + INamedTypeSymbol? formattableSymbol, + InterfaceGenerationContext context) => + !TryGetDictionaryTypes(property.Type, out var keyType, out var valueType) + || !IsSimpleType(keyType!, formattableSymbol) + || !IsSimpleType(valueType!, formattableSymbol) + ? null + : new( + property.Name, + aliasName, + serializerName, + normalizedPrefix, + query.SerializeNull, + CanElementBeNull(property.Type), + null, + BuildValueFormat(valueType!, null, formattableSymbol, context), + Dictionary: new( + QualifyType(keyType!, context), + BuildValueFormat(keyType!, null, formattableSymbol, context), + CanElementBeNull(valueType!), + PrefixSegment: null)); + + /// Builds the descriptor for a collection-of-simple-elements property, or null for any other shape. + /// The property to describe. + /// The resolved [AliasAs] name, or null. + /// The resolved content-serializer name, or null. + /// The resolved key prefix, or null. + /// The property's parsed [Query] data. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// The interface generation context, used to qualify extern-aliased types. + /// The collection property descriptor, or null when the property is not a collection of simple elements. + private static QueryObjectPropertyModel? TryBuildCollectionPropertyModel( + IPropertySymbol property, + string? aliasName, + string? serializerName, + string? normalizedPrefix, + in QueryFormData query, + INamedTypeSymbol? formattableSymbol, + InterfaceGenerationContext context) + { + if (!TryGetEnumerableElementType(property.Type, out var elementType) + || !IsSimpleType(elementType!, formattableSymbol)) + { + return null; + } + + var collection = new QueryObjectCollectionModel( + query.CollectionFormatValue, + CanElementBeNull(elementType!), + QualifyType(property.Type, context)); + + return new( + property.Name, + aliasName, + serializerName, + normalizedPrefix, + query.SerializeNull, + CanElementBeNull(property.Type), + null, + BuildValueFormat(elementType!, null, formattableSymbol, context), + collection); + } +} diff --git a/src/InterfaceStubGenerator.Shared/Parser.Request.Query.cs b/src/InterfaceStubGenerator.Shared/Parser.Request.Query.cs new file mode 100644 index 000000000..d406aca12 --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/Parser.Request.Query.cs @@ -0,0 +1,650 @@ +// 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; + +namespace Refit.Generator; + +/// Parses candidate interfaces and methods into the models used to generate Refit stubs. +/// Query parameter classification for generated request construction. +internal static partial class Parser +{ + /// The number of type arguments on IDictionary<TKey, TValue>. + private const int GenericDictionaryArity = 2; + + /// The maximum nested-object depth flattened inline before the whole parameter falls back to reflection. + private const int MaxNestingDepth = 32; + + /// The metadata name of Refit.QueryAttribute. + private const string QueryAttributeDisplayName = "QueryAttribute"; + + /// The metadata name of Refit.QueryNameAttribute. + private const string QueryNameAttributeDisplayName = "QueryNameAttribute"; + + /// The metadata name of Refit.QueryConverterAttribute. + private const string QueryConverterAttributeDisplayName = "QueryConverterAttribute"; + + /// The metadata name of Refit.EncodedAttribute. + private const string EncodedAttributeDisplayName = "EncodedAttribute"; + + /// The metadata name of Refit.AuthorizeAttribute. + private const string AuthorizeAttributeDisplayName = "AuthorizeAttribute"; + + /// The default authorization scheme when [Authorize] is used without an explicit scheme. + private const string DefaultAuthorizeScheme = "Bearer"; + + /// The metadata name of Refit.BodyAttribute. + private const string BodyAttributeDisplayName = "BodyAttribute"; + + /// The metadata name of Refit.HeaderAttribute. + private const string HeaderAttributeDisplayName = "HeaderAttribute"; + + /// The metadata name of Refit.HeaderCollectionAttribute. + private const string HeaderCollectionAttributeDisplayName = "HeaderCollectionAttribute"; + + /// The metadata name of Refit.PropertyAttribute. + private const string PropertyAttributeDisplayName = "PropertyAttribute"; + + /// The metadata name of Refit.AliasAsAttribute. + private const string AliasAsAttributeDisplayName = "AliasAsAttribute"; + + /// The metadata name of Refit.HeadersAttribute. + private const string HeadersAttributeDisplayName = "HeadersAttribute"; + + /// The metadata name of Refit.MultipartAttribute. + private const string MultipartAttributeDisplayName = "MultipartAttribute"; + + /// The metadata name of Refit.QueryUriFormatAttribute. + private const string QueryUriFormatAttributeDisplayName = "QueryUriFormatAttribute"; + + /// The fully-qualified display name of the EnumMember attribute honored by the default formatter. + private const string EnumMemberAttributeDisplayName = "System.Runtime.Serialization.EnumMemberAttribute"; + + /// Determines whether an attribute class is the named Refit attribute, without allocating display strings. + /// The attribute class symbol. + /// The attribute's metadata name inside the Refit namespace. + /// when the attribute matches. + private static bool IsRefitAttribute(INamedTypeSymbol? attributeClass, string attributeMetadataName) => + attributeClass is not null + && attributeClass.Name == attributeMetadataName + && attributeClass.ContainingNamespace is { Name: "Refit", ContainingNamespace.IsGlobalNamespace: true }; + + /// Finds a parameter attribute by its Refit metadata name. + /// The parameter to inspect. + /// The attribute's metadata name inside the Refit namespace. + /// The attribute data, or null when absent. + private static AttributeData? FindParameterAttribute(IParameterSymbol parameter, string attributeMetadataName) + { + foreach (var attribute in parameter.GetAttributes()) + { + if (IsRefitAttribute(attribute.AttributeClass, attributeMetadataName)) + { + return attribute; + } + } + + return null; + } + + /// Determines whether a parameter carries the named Refit attribute. + /// The parameter to inspect. + /// The attribute's metadata name inside the Refit namespace. + /// when the attribute is present. + private static bool HasParameterAttribute(IParameterSymbol parameter, string attributeMetadataName) => + FindParameterAttribute(parameter, attributeMetadataName) is not null; + + /// Determines whether any placeholder binds a property of this parameter (a dotted {param.Prop}). + /// The placeholder names in the URL template. + /// The parameter's resolved URL name. + /// when a dotted placeholder targets this parameter. + private static bool HasDottedPlaceholderFor( + Dictionary> parameterLocations, + string urlName) + { + foreach (var key in parameterLocations.Keys) + { + if (key.Length > urlName.Length + && key[urlName.Length] == '.' + && key.StartsWith(urlName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// Tries to build the query-binding model for a [Query] or auto-appended query parameter. + /// The parameter to classify. + /// The resolved query key. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// The interface generation context, used to qualify extern-aliased types. + /// Receives the query-binding model. + /// when the parameter's shape is supported by inline query generation. + private static bool TryBuildQueryModel( + IParameterSymbol parameter, + string urlName, + INamedTypeSymbol? formattableSymbol, + InterfaceGenerationContext context, + out QueryParameterModel? query) + { + var data = ParseParameterQueryData(parameter); + var preEncoded = HasParameterAttribute(parameter, EncodedAttributeDisplayName); + + // A [QueryConverter] hands flattening to a user-supplied IQueryConverter, so any parameter shape — including + // ones the declared-type walk cannot flatten — generates inline by delegating to it. + if (FindParameterAttribute(parameter, QueryConverterAttributeDisplayName) is { } converterAttribute) + { + query = BuildConverterQueryModel(parameter, converterAttribute, data, preEncoded, formattableSymbol, context); + return query is not null; + } + + // TreatAsString, or an explicitly empty Format, stringifies the raw value via ToString() before the + // formatter runs, mirroring the reflection request builder. This shape supports any parameter type. + if (data.TreatAsString || data.Format is { Length: 0 }) + { + query = new( + urlName, + QueryParameterShape.Scalar, + TreatAsString: true, + preEncoded, + data.CollectionFormatValue, + ElementCanBeNull: false, + BuildValueFormat(parameter.Type, null, formattableSymbol, context)); + return true; + } + + var format = NormalizeFormat(data.Format); + if (IsSimpleType(parameter.Type, formattableSymbol)) + { + query = new( + urlName, + QueryParameterShape.Scalar, + TreatAsString: false, + preEncoded, + data.CollectionFormatValue, + ElementCanBeNull: false, + BuildValueFormat(parameter.Type, format, formattableSymbol, context)); + return true; + } + + query = TryBuildScalarCollectionModel(parameter.Type, urlName, preEncoded, data, format, formattableSymbol, context); + if (query is not null) + { + return true; + } + + query = TryBuildFlattenedObjectQueryModel(parameter, urlName, preEncoded, data, format, formattableSymbol, context); + return query is not null; + } + + /// Builds the flattened-object query model for a complex parameter, or null when it cannot flatten inline. + /// The parameter to classify. + /// The resolved query key. + /// Whether the parameter carries [Encoded]. + /// The parameter's parsed [Query] data. + /// The effective compile-time format, or null. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// The interface generation context, used to qualify extern-aliased types. + /// The flattened object query model, or null when the parameter cannot be flattened inline. + private static QueryParameterModel? TryBuildFlattenedObjectQueryModel( + IParameterSymbol parameter, + string urlName, + bool preEncoded, + in QueryFormData data, + string? format, + INamedTypeSymbol? formattableSymbol, + InterfaceGenerationContext context) + { + // A complex object is flattened into one query pair per public readable property, mirroring the reflection + // builder's BuildQueryMap. The enclosing [Query(Prefix)] segment is folded into each property's key. + var parameterPrefixSegment = string.IsNullOrWhiteSpace(data.Prefix) ? null : data.Prefix + data.Delimiter; + + var dictionaryQuery = TryBuildDictionaryQueryModel(parameter.Type, urlName, preEncoded, format, parameterPrefixSegment, formattableSymbol, context); + if (dictionaryQuery is not null) + { + return dictionaryQuery; + } + + if (TryBuildQueryObjectProperties(parameter.Type, parameterPrefixSegment, formattableSymbol, context) is not { } properties) + { + return null; + } + + return new( + urlName, + QueryParameterShape.Object, + TreatAsString: false, + preEncoded, + data.CollectionFormatValue, + ElementCanBeNull: false, + BuildValueFormat(parameter.Type, format, formattableSymbol, context), + properties, + NestingDelimiter: string.IsNullOrEmpty(data.Delimiter) ? "." : data.Delimiter); + } + + /// Builds the query model for a collection-of-simple-elements parameter, or null for any other shape. + /// The declared parameter type. + /// The resolved query key. + /// Whether the parameter carries [Encoded]. + /// The parameter's parsed [Query] data. + /// The effective compile-time format, or null. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// The interface generation context, used to qualify extern-aliased types. + /// The collection query model, or null when the parameter is not a collection of simple elements. + private static QueryParameterModel? TryBuildScalarCollectionModel( + ITypeSymbol type, + string urlName, + bool preEncoded, + in QueryFormData data, + string? format, + INamedTypeSymbol? formattableSymbol, + InterfaceGenerationContext context) => + TryGetEnumerableElementType(type, out var elementType) && IsSimpleType(elementType!, formattableSymbol) + ? new( + urlName, + QueryParameterShape.Collection, + TreatAsString: false, + preEncoded, + data.CollectionFormatValue, + CanElementBeNull(elementType!), + BuildValueFormat(elementType!, format, formattableSymbol, context)) + : null; + + /// Builds the query model for a [QueryConverter] parameter, or null when the type is unresolved. + /// The parameter carrying the converter. + /// The [QueryConverter] attribute. + /// The parameter's parsed [Query] data supplying any prefix. + /// Whether the parameter carries [Encoded]. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// The interface generation context, used to qualify extern-aliased types. + /// The converter query model, or null when the converter type cannot be resolved. + private static QueryParameterModel? BuildConverterQueryModel( + IParameterSymbol parameter, + AttributeData converterAttribute, + in QueryFormData data, + bool preEncoded, + INamedTypeSymbol? formattableSymbol, + InterfaceGenerationContext context) + { + // The converter type is the sole typeof(...) constructor argument. + if (GetSoleTypeArgument(converterAttribute) is not { } converterType) + { + return null; + } + + var keyPrefix = string.IsNullOrWhiteSpace(data.Prefix) ? string.Empty : data.Prefix + data.Delimiter; + return new( + string.Empty, + QueryParameterShape.Converter, + TreatAsString: false, + preEncoded, + CollectionFormatValue: null, + ElementCanBeNull: false, + BuildValueFormat(parameter.Type, null, formattableSymbol, context), + Converter: new( + QualifyType(converterType, context), + keyPrefix)); + } + + /// Reads the sole typeof(...) constructor argument from an attribute. + /// The attribute whose single type argument is read. + /// The type argument, or when the argument is absent or not a type. + /// The absent-argument guard defends against error-recovery symbols and cannot be reached from an + /// attribute application that compiles. + [ExcludeFromCodeCoverage] + private static ITypeSymbol? GetSoleTypeArgument(AttributeData attribute) => + attribute.ConstructorArguments.IsEmpty + ? null + : attribute.ConstructorArguments[0].Value as ITypeSymbol; + + /// Tries to build the query-binding model for a dictionary-shaped query parameter. + /// The declared parameter type. + /// The resolved query key, unused because each entry supplies its own key. + /// Whether values pass through verbatim. + /// The parameter-level [Query(Format)] applied to each value. + /// The parameter's compile-time prefix + delimiter, or null. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// The interface generation context, used to qualify extern-aliased types. + /// The dictionary query model, or null when the shape must fall back to the reflection request builder. + /// + /// Only simple keys and values generate inline: the reflection builder inspects each value's runtime type + /// to decide whether to recurse into it, which a declared-type walk cannot reproduce for object values. + /// + private static QueryParameterModel? TryBuildDictionaryQueryModel( + ITypeSymbol type, + string urlName, + bool preEncoded, + string? format, + string? parameterPrefixSegment, + INamedTypeSymbol? formattableSymbol, + InterfaceGenerationContext context) + { + if (!TryGetDictionaryTypes(type, out var keyType, out var valueType) + || !IsSimpleType(keyType!, formattableSymbol)) + { + return null; + } + + var valueProperties = ResolveDictionaryValueProperties(valueType!, format, formattableSymbol, context, out var valueInlineable); + return valueInlineable + ? new( + urlName, + QueryParameterShape.Dictionary, + TreatAsString: false, + preEncoded, + CollectionFormatValue: null, + ElementCanBeNull: false, + BuildValueFormat(valueType!, format, formattableSymbol, context), + Dictionary: new( + QualifyType(keyType!, context), + BuildValueFormat(keyType!, null, formattableSymbol, context), + CanElementBeNull(valueType!), + parameterPrefixSegment, + valueProperties)) + : null; + } + + /// Builds the query-binding model for a [QueryName] valueless flag parameter. + /// The parameter to classify. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// The interface generation context, used to qualify extern-aliased types. + /// The flag query model; both a scalar flag and a per-element flag collection resolve to a value. + private static QueryParameterModel BuildFlagModel( + IParameterSymbol parameter, + INamedTypeSymbol? formattableSymbol, + InterfaceGenerationContext context) + { + var data = ParseParameterQueryData(parameter); + var preEncoded = HasParameterAttribute(parameter, EncodedAttributeDisplayName); + var format = NormalizeFormat(data.Format); + + // Strings and other scalar-rendering values become one flag; enumerables render one flag per element. + return parameter.Type.SpecialType != SpecialType.System_String + && TryGetEnumerableElementType(parameter.Type, out var elementType) + ? new( + string.Empty, + QueryParameterShape.FlagCollection, + TreatAsString: false, + preEncoded, + CollectionFormatValue: null, + CanElementBeNull(elementType!), + BuildValueFormat(elementType!, format, formattableSymbol, context)) + : new QueryParameterModel( + string.Empty, + QueryParameterShape.Flag, + TreatAsString: false, + preEncoded, + CollectionFormatValue: null, + ElementCanBeNull: false, + BuildValueFormat(parameter.Type, format, formattableSymbol, context)); + } + + /// Normalizes a compile-time format string, mapping empty/whitespace to no format. + /// The raw format from the query attribute. + /// The effective format, or null. + private static string? NormalizeFormat(string? format) => + string.IsNullOrWhiteSpace(format) ? null : format; + + /// Builds the reflection-free rendering strategy for one statically-known value type. + /// The declared value type. + /// The effective compile-time format, or null. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// The interface generation context, used to qualify extern-aliased types. + /// The rendering strategy matching the default URL parameter formatter's output. + private static InlineValueFormatModel BuildValueFormat( + ITypeSymbol type, + string? format, + INamedTypeSymbol? formattableSymbol, + InterfaceGenerationContext context) + { + var isNullableValueType = false; + if (type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable) + { + isNullableValueType = true; + type = nullable.TypeArguments[0]; + } + + var typeName = QualifyType(type, context); + + if (type.SpecialType == SpecialType.System_String) + { + return new(InlineFormatKind.String, format, typeName, isNullableValueType, null); + } + + // bool and char are scalars without IFormattable; string.Format ignores any format spec for them. + if (type.SpecialType is SpecialType.System_Boolean or SpecialType.System_Char) + { + return new(InlineFormatKind.ToStringOnly, format, typeName, isNullableValueType, null); + } + + if (type.TypeKind == TypeKind.Enum) + { + var members = BuildEnumFormatMembers(type); + return members is null + ? new(InlineFormatKind.FormatterOnly, format, typeName, isNullableValueType, null) + : new(InlineFormatKind.Enum, format, typeName, isNullableValueType, members); + } + + // One pass over AllInterfaces resolves both the IFormattable and ISpanFormattable questions, instead of + // walking the interface list again inside ComputeSpanFormattableTiers. + var (implementsFormattable, implementsSpanFormattable) = + ClassifyFormattable(type, formattableSymbol, context.SpanFormattableSymbol); + if (!implementsFormattable) + { + return new(InlineFormatKind.ToStringOnly, format, typeName, isNullableValueType, null); + } + + var (urlSafe, escapable) = ComputeSpanFormattableTiers( + type, + format, + implementsSpanFormattable, + context); + return new(InlineFormatKind.Formattable, format, typeName, isNullableValueType, null) + { + IsUrlSafeSpanFormattable = urlSafe, + IsSpanFormattableEscapable = escapable, + }; + } + + /// Determines, in a single interface walk, whether a type implements IFormattable and ISpanFormattable. + /// The type to inspect. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// The resolved System.ISpanFormattable symbol, or null when unavailable. + /// Whether the type implements each interface. + private static (bool Formattable, bool SpanFormattable) ClassifyFormattable( + ITypeSymbol type, + INamedTypeSymbol? formattableSymbol, + INamedTypeSymbol? spanFormattableSymbol) + { + var formattable = false; + var spanFormattable = false; + foreach (var implemented in type.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(implemented, formattableSymbol)) + { + formattable = true; + } + + if (!spanFormattable + && spanFormattableSymbol is not null + && SymbolEqualityComparer.Default.Equals(implemented, spanFormattableSymbol)) + { + spanFormattable = true; + } + + if (formattable && spanFormattable) + { + break; + } + } + + return (formattable, spanFormattable); + } + + /// Computes the two path fast-write tiers a formattable value type supports on the consumer target. + /// The unwrapped value type. + /// The compile-time format, or null. + /// Whether the value type implements ISpanFormattable, resolved in the single interface walk. + /// The generation context carrying the resolved fast-path capabilities. + /// Whether the value qualifies for the net6+ URL-safe integer write and the net9+ span-escape write. + /// net6+: an unformatted integer renders as URL-safe digits, so it is written with no escaping. + /// net9+: any ISpanFormattable renders into a stack buffer and escapes span-to-string, skipping the ToString. + private static (bool UrlSafe, bool Escapable) ComputeSpanFormattableTiers( + ITypeSymbol type, + string? format, + bool implementsSpanFormattable, + InterfaceGenerationContext context) + { + // A nullable value type still qualifies: the scalar query emitter formats it inside the existing null guard and + // writes the unwrapped .Value (which is span-formattable). The path and collection fast paths opt out of the + // nullable case separately, keeping their existing string-formatting path. + var urlSafe = context.SpanFormattableSymbol is not null + && format is null + && type.SpecialType is >= SpecialType.System_SByte and <= SpecialType.System_UInt64; + var escapable = context.SupportsSpanEscape + && implementsSpanFormattable; + return (urlSafe, escapable); + } + + /// Resolves the compile-time enum members honored by the default URL parameter formatter. + /// The enum type symbol. + /// The member models, or null when duplicate constants make a compile-time switch unfaithful. + private static ImmutableEquatableArray? BuildEnumFormatMembers(ITypeSymbol enumType) + { + var members = new List(); + var seenValues = new HashSet(); + + foreach (var member in enumType.GetMembers()) + { + if (member is not IFieldSymbol { HasConstantValue: true } field) + { + continue; + } + + // Enum.GetName picks an unspecified alias for duplicated constants, so a compile-time switch + // cannot reproduce the runtime formatter faithfully; fall back to the formatter for such enums. + if (!seenValues.Add(field.ConstantValue!)) + { + return null; + } + + members.Add(new(field.Name, GetEnumMemberOverride(field))); + } + + return members.ToImmutableEquatableArray(); + } + + /// Reads the [EnumMember(Value = ...)] override for an enum field. + /// The enum field symbol. + /// The override value, or null when absent (or declared without a value). + private static string? GetEnumMemberOverride(IFieldSymbol field) + { + foreach (var attribute in field.GetAttributes()) + { + if (attribute.AttributeClass!.ToDisplayString() != EnumMemberAttributeDisplayName) + { + continue; + } + + foreach (var named in attribute.NamedArguments) + { + if (TryReadEnumMemberValue(named) is { } value) + { + return value; + } + } + + return null; + } + + return null; + } + + /// Reads the string value of an [EnumMember] Value named argument. + /// The attribute named argument. + /// The string value, or when the argument is not a string Value. + /// EnumMember declares only the Value named argument, so the key comparison never fails here. + [ExcludeFromCodeCoverage] + private static string? TryReadEnumMemberValue(KeyValuePair namedArgument) => + namedArgument.Key == "Value" ? namedArgument.Value.Value as string : null; + + /// Tries to resolve the single IEnumerable<T> element type of a parameter type. + /// The parameter type. + /// Receives the element type. + /// when exactly one generic enumerable element type is implemented. + private static bool TryGetEnumerableElementType( + ITypeSymbol type, + out ITypeSymbol? elementType) + { + if (type is IArrayTypeSymbol { Rank: 1 } array) + { + elementType = array.ElementType; + return true; + } + + elementType = null; + if (type is INamedTypeSymbol named && IsGenericEnumerable(named)) + { + elementType = named.TypeArguments[0]; + } + + foreach (var implemented in type.AllInterfaces) + { + if (!IsGenericEnumerable(implemented)) + { + continue; + } + + var candidate = implemented.TypeArguments[0]; + if (elementType is null) + { + elementType = candidate; + continue; + } + + // A second IEnumerable among the (symbol-deduplicated) interfaces is always a distinct element type, so + // the parameter closes an ambiguous element type. The reflection path's interface-order behavior is + // unspecified, so fall back. + elementType = null; + return false; + } + + return elementType is not null; + } + + /// Determines whether a type is a closed System.Collections.Generic.IEnumerable<T>. + /// The type to inspect. + /// when the type is the generic enumerable interface. + private static bool IsGenericEnumerable(ITypeSymbol type) => + type is INamedTypeSymbol + { + TypeKind: TypeKind.Interface, + Name: "IEnumerable", + Arity: 1, + ContainingNamespace.Name: "Generic", + ContainingNamespace.ContainingNamespace.Name: "Collections", + ContainingNamespace.ContainingNamespace.ContainingNamespace.Name: "System" + }; + + /// Determines whether collection elements need a null check before formatting. + /// The element type. + /// for reference and nullable-value elements. + /// The nullable-value-element arm is only selected by a Nullable<T> collection element, which the + /// shared collection fixtures never present, so that outcome cannot be exercised. + [ExcludeFromCodeCoverage] + private static bool CanElementBeNull(ITypeSymbol elementType) => + !elementType.IsValueType + || elementType.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T; + + /// Parses the query-relevant data from a parameter's [Query] attribute, if present. + /// The parameter to inspect. + /// The parsed data, or defaults when the attribute is absent. + private static QueryFormData ParseParameterQueryData(IParameterSymbol parameter) + { + var attribute = FindParameterAttribute(parameter, QueryAttributeDisplayName); + return attribute is null ? default : ParseFormQueryAttribute(attribute); + } +} diff --git a/src/InterfaceStubGenerator.Shared/Parser.Request.cs b/src/InterfaceStubGenerator.Shared/Parser.Request.cs index 28436bd4b..19bafd62e 100644 --- a/src/InterfaceStubGenerator.Shared/Parser.Request.cs +++ b/src/InterfaceStubGenerator.Shared/Parser.Request.cs @@ -5,7 +5,6 @@ using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; namespace Refit.Generator; @@ -13,6 +12,9 @@ namespace Refit.Generator; /// Request parsing helpers for the Refit source generator. internal static partial class Parser { + /// The System namespace name, matched structurally to identify well-known BCL types. + private const string SystemNamespace = "System"; + /// Parses the request metadata needed by generated request construction. /// The Refit method symbol. /// The classified return type shape. @@ -21,33 +23,53 @@ internal static partial class Parser private static RequestModel ParseRequest( IMethodSymbol methodSymbol, ReturnTypeInfo returnTypeInfo, - in InterfaceGenerationContext context) + InterfaceGenerationContext context) { if (!context.GeneratedRequestBuilding) { + ReportSourceGenOnlyAttributeMisuse(methodSymbol, context); return RequestModel.Empty; } - var httpMethodAttribute = FindHttpMethodAttribute( - methodSymbol, - context.HttpMethodBaseAttributeSymbol)!; + var (httpMethod, path, normalizedPath, pathParameters) = ResolveRequestTarget(methodSymbol, context); - var httpMethod = GetHttpMethodName(httpMethodAttribute.AttributeClass); - var path = GetHttpPath(httpMethodAttribute); - var normalizedPath = NormalizeConstantPathForInline(path); - var pathParameters = ExtractPathParameterPlaceholderNames(normalizedPath); - var returnTypes = GetRequestReturnTypes(methodSymbol); - var parameters = ParseRequestParameters(methodSymbol.Parameters, pathParameters.ToImmutableDictionary(pathParameters.Comparer), context.FormattableSymbol, out var parameterEligibility); + // A registered IReturnTypeAdapter surfaces the declared return type; the HTTP call materializes the adapter's + // wrapped result, so classify the return types against that inner type just like a Task. + var (adapterTypeExpression, resultTypeSource) = ResolveReturnTypeAdapter(methodSymbol.ReturnType, context); + + var returnTypes = GetRequestReturnTypes(resultTypeSource, context); + var queryUriFormat = ResolveQueryUriFormat(methodSymbol); + + // A multipart method builds its parts inline; each part parameter is classified into a + // MultipartFormDataContent entry instead of feeding the query string or an implicit body. + var multipartBoundary = ResolveMultipartBoundary(methodSymbol, out var isMultipart); + + // Only POST/PUT/PATCH carry an implicit body, and a multipart method never does — every un-attributed + // complex parameter is a form part rather than the request body. + var allowImplicitBody = IsBodyCapableHttpMethod(httpMethod) && !isMultipart; + + var parameters = ParseRequestParameters( + methodSymbol.Parameters, + pathParameters, + context.FormattableSymbol, + allowImplicitBody, + isMultipart, + context, + out var parameterEligibility); var staticHeaders = ParseStaticHeaders(methodSymbol); - var canGenerateInline = - parameterEligibility - && returnTypeInfo is ReturnTypeInfo.AsyncVoid or ReturnTypeInfo.AsyncResult or ReturnTypeInfo.AsyncEnumerable - && methodSymbol.TypeParameters.IsEmpty - && httpMethod.Length > 0 - && IsPathSupported(path) - && IsSupportedInlineBody(parameters) - && !HasUnsupportedInlineRequestMetadata(methodSymbol); + var canGenerateInline = CanGenerateInlineRequest( + parameterEligibility, + IsInlineReturnShape(returnTypeInfo, adapterTypeExpression), + httpMethod, + new(path, normalizedPath), + parameters, + isMultipart); + + if (!canGenerateInline) + { + ReportSourceGenOnlyAttributeMisuse(methodSymbol, context); + } return new( httpMethod, @@ -57,10 +79,121 @@ private static RequestModel ParseRequest( returnTypes.IsApiResponse, returnTypes.DisposeResponse, canGenerateInline, + canGenerateInline ? adapterTypeExpression : null, staticHeaders, - parameters); + parameters) + { + IsMultipart = isMultipart, + MultipartBoundary = multipartBoundary, + QueryUriFormat = queryUriFormat, + }; + } + + /// Resolves the HTTP verb, path, and path-parameter placeholders declared by a method's HTTP attribute. + /// The Refit method symbol. + /// The shared generation context. + /// The HTTP method, raw path, normalized path, and path parameter placeholders. + private static (string HttpMethod, string Path, string NormalizedPath, Dictionary> PathParameters) ResolveRequestTarget( + IMethodSymbol methodSymbol, + InterfaceGenerationContext context) + { + var httpMethodAttribute = FindHttpMethodAttribute( + methodSymbol, + context.HttpMethodBaseAttributeSymbol)!; + + var httpMethod = GetHttpMethodName(httpMethodAttribute.AttributeClass!); + var path = GetHttpPath(httpMethodAttribute); + var normalizedPath = NormalizeConstantPathForInline(path); + var pathParameters = ExtractPathParameterPlaceholderNames(normalizedPath); + return (httpMethod, path, normalizedPath, pathParameters); } + /// Classifies the return type against a registered return-type adapter, if one matches. + /// The method's declared return type. + /// The shared generation context. + /// The adapter type expression (or null) and the result type to classify the request against. + private static (string? AdapterTypeExpression, ITypeSymbol ResultTypeSource) ResolveReturnTypeAdapter( + ITypeSymbol returnType, + InterfaceGenerationContext context) => + TryMatchReturnTypeAdapter(returnType, context, out var closedAdapter, out var adapterResultType) + ? (QualifyType(closedAdapter, context), adapterResultType) + : (null, returnType); + + /// Determines whether a return type shape (or its adapter) is eligible for inline request generation. + /// The classified return type shape. + /// The registered adapter expression, or null. + /// when the return shape can be generated inline. + private static bool IsInlineReturnShape(ReturnTypeInfo returnTypeInfo, string? adapterTypeExpression) => + returnTypeInfo is ReturnTypeInfo.AsyncVoid or ReturnTypeInfo.AsyncResult or ReturnTypeInfo.AsyncEnumerable + or ReturnTypeInfo.Observable + || adapterTypeExpression is not null; + + /// Reports an error when a method uses a source-generation-only attribute but cannot generate inline. + /// The Refit method symbol. + /// The shared generation context. + private static void ReportSourceGenOnlyAttributeMisuse( + IMethodSymbol methodSymbol, + InterfaceGenerationContext context) + { + foreach (var parameter in methodSymbol.Parameters) + { + foreach (var attribute in parameter.GetAttributes()) + { + if (!IsRefitAttribute(attribute.AttributeClass, QueryNameAttributeDisplayName) + && !IsRefitAttribute(attribute.AttributeClass, EncodedAttributeDisplayName) + && !IsRefitAttribute(attribute.AttributeClass, QueryConverterAttributeDisplayName)) + { + continue; + } + + context.Diagnostics.Add(Diagnostic.Create( + DiagnosticDescriptors.SourceGenOnlyAttributeRequiresInlineRequest, + methodSymbol.Locations[0], + methodSymbol.Name, + attribute.AttributeClass!.Name)); + return; + } + } + } + + /// Determines whether an HTTP method may carry an implicit request body. + /// The HTTP method name. + /// for POST, PUT and PATCH. + private static bool IsBodyCapableHttpMethod(string httpMethod) => + httpMethod is "POST" or "PUT" or "PATCH"; + + /// Determines whether a method's request can be constructed by generated inline code. + /// Whether every parameter binding is inline-supported. + /// Whether the return shape is inline-eligible (a built-in async shape or an adapter-backed return type). + /// The HTTP method name. + /// The raw and normalized path forms from the HTTP method attribute. + /// The parsed request parameter models. + /// Whether the method is multipart, which cannot also carry an explicit [Body]. + /// when the request is inline-eligible. + /// + /// Generic methods are inline-eligible: a type parameter flows straight through to the generic runner + /// (SendAsync<T, TBody>) with no reflection. Positions where an open type parameter cannot generate + /// correctly or trim-safely — a complex query object (its properties are only known per value) or a form-url-encoded + /// body ([DynamicallyAccessedMembers]) — are excluded upstream through . + /// + private static bool CanGenerateInlineRequest( + bool parameterEligibility, + bool returnShapeEligible, + string httpMethod, + in RequestPathForms path, + ImmutableEquatableArray parameters, + bool isMultipart) => + parameterEligibility + && returnShapeEligible + && httpMethod.Length > 0 + && IsPathSupported(path.Raw) + && IsPathSupported(path.Normalized) + && IsSupportedInlineBody(parameters) + + // A multipart method with an explicit [Body] is an invalid combination the reflection builder rejects; fall + // back so its validation still throws instead of emitting a non-multipart body request. + && (!isMultipart || !HasBodyParameter(parameters)); + /// Extracts the path parameter placeholder names and their locations from a URL template. /// The normalized path template. /// A map of placeholder name to the ranges where each placeholder occurs in the template. @@ -109,23 +242,6 @@ private static Dictionary> ExtractPathParameterPlaceholderNa return paramNames; } - /// 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. @@ -169,38 +285,45 @@ private static void AppendNonEmptyQueryPart( queryBuffer ??= new char[path.Length - queryStart]; if (queryLength > 0) { - queryBuffer[queryLength++] = '&'; + queryBuffer[queryLength] = '&'; + queryLength++; } path.CopyTo(partStart, queryBuffer, queryLength, partLength); queryLength += partLength; } - /// Determines whether a method carries request metadata the initial inline emitter does not handle. + /// Reads the System.UriFormat value from a method's [QueryUriFormat] attribute. /// 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) + /// The UriFormat enum value, or null when the method has no [QueryUriFormat]. + /// The value re-encodes the whole built path and query, matching the reflection builder's final + /// Uri.GetComponents(PathAndQuery, QueryUriFormat) pass, so the attribute no longer forces the runtime builder. + private static int? ResolveQueryUriFormat(IMethodSymbol methodSymbol) { - foreach (var attribute in attributes) + foreach (var attribute in methodSymbol.GetAttributes()) { - var displayName = attribute.AttributeClass!.ToDisplayString(); - if (displayName is - "Refit.MultipartAttribute" or - "Refit.QueryUriFormatAttribute") + if (TryReadQueryUriFormat(attribute) is { } uriFormat) { - return true; + return uriFormat; } } - return false; + return null; } + /// Reads the UriFormat value from an attribute if it is [QueryUriFormat]. + /// The candidate attribute. + /// The UriFormat enum value, or null when the attribute is not [QueryUriFormat]. + /// The single-int-argument guards match the attribute's only constructor and cannot fail for a + /// [QueryUriFormat] application that compiles. + [ExcludeFromCodeCoverage] + private static int? TryReadQueryUriFormat(AttributeData attribute) => + IsRefitAttribute(attribute.AttributeClass, QueryUriFormatAttributeDisplayName) + && attribute.ConstructorArguments.Length == 1 + && attribute.ConstructorArguments[0].Value is int uriFormat + ? uriFormat + : null; + /// 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. @@ -229,7 +352,7 @@ private static void AddStaticHeaders( { foreach (var attribute in attributes) { - if (attribute.AttributeClass!.ToDisplayString() != "Refit.HeadersAttribute") + if (!IsRefitAttribute(attribute.AttributeClass, HeadersAttributeDisplayName)) { continue; } @@ -261,728 +384,19 @@ private static void AddHeadersAttributeValues(List headers, Attribu } } - /// Parses request parameter bindings for the conservative initial inline path. - /// The method parameters. - /// The placeholder names in the URL with their locations. - /// The resolved System.IFormattable symbol used to classify inline-eligible path parameter types, or null when unavailable. - /// Receives whether every parameter is supported. - /// The parsed request parameter models. - private static ImmutableEquatableArray ParseRequestParameters( - in ImmutableArray parameters, - in ImmutableDictionary> parameterLocations, - INamedTypeSymbol? formattableSymbol, - out bool canGenerateInline) - { - if (parameters.IsEmpty) - { - 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 parameter = parameters[i]; - var aliasAttr = parameter.GetAttributes().FirstOrDefault(static a => a.AttributeClass?.ToDisplayString() == "Refit.AliasAsAttribute"); - var name = aliasAttr is not null ? GetFirstStringArgument(aliasAttr) ?? parameter.Name : parameter.Name; - _ = parameterLocations.TryGetValue(name, out var location); - var parsedParameter = ParseRequestParameter(parameter, location?.ToImmutableEquatableArray(), formattableSymbol); - 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 parameter's locations in the URL template string. This is null if the parameter has no placeholder in the URL i.e. not a path parameter. - /// The resolved System.IFormattable symbol used to classify inline-eligible path parameter types, or null when unavailable. - /// The parsed parameter and eligibility counters. - private static ParsedRequestParameter ParseRequestParameter(IParameterSymbol parameter, ImmutableEquatableArray? locations, INamedTypeSymbol? formattableSymbol) - { - var parameterType = parameter.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var canBeNull = CanBeNull(parameter.Type, parameter.NullableAnnotation); - if (IsCancellationToken(parameter.Type)) - { - return new( - new( - parameter.MetadataName, - parameterType, - locations, - BuildParameterAttributes(parameter), - 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); - } - - if (TryParsePropertyParameter(parameter, parameterType, out var propertyParameter)) - { - return new(propertyParameter, true, 0, 0, 0); - } - - return locations is {} l && IsSimpleType(parameter.Type, formattableSymbol) - ? new(PathRequestParameter(parameter, parameterType, l), true, 0, 0, 0) - : new(UnsupportedRequestParameter(parameter, parameterType), false, 0, 0, 0); - } - - /// Determines whether a parameter type renders to a URL scalar and is eligible for inline path formatting. - /// The parameter type to classify. - /// The resolved System.IFormattable symbol, or null when unavailable. - /// when the type is a simple scalar type supported by inline path formatting. - private static bool IsSimpleType(ITypeSymbol type, INamedTypeSymbol? formattableSymbol) - { - // A path value is emitted as UrlParameterFormatter.Format(value, provider, typeof(T)) - the same call the - // reflection path uses - so any type the formatter can render round-trips identically. That is exactly the - // set of IFormattable types (which is also what makes [Query(Format = ...)] and invariant culture work), - // plus string and bool, which are scalars but not IFormattable. Collections, arrays and DTOs are excluded - // and fall back to reflection. Matching on the resolved IFormattable symbol avoids per-parameter name-string - // allocations and automatically covers future BCL scalars. - var underlyingType = GetUnderlyingType(type); - - static ITypeSymbol GetUnderlyingType(ITypeSymbol type) => - type is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } nullable - ? nullable.TypeArguments[0] - : type; - - // The built-in value-type scalars occupy a contiguous SpecialType block - System_Boolean (bool, char, - // every integer width, decimal, float, double) through System_Double - so a range check covers them all - // in one comparison. string and DateTime sit just outside that block. - static bool IsScalarSpecialType(SpecialType specialType) => - (specialType >= SpecialType.System_Boolean && specialType <= SpecialType.System_Double) - || specialType == SpecialType.System_String - || specialType == SpecialType.System_DateTime; - - // A null interfaceSymbol (System.IFormattable unresolved) simply matches nothing and falls back. - static bool ImplementsInterface(ITypeSymbol type, INamedTypeSymbol? interfaceSymbol) - { - foreach (var implemented in type.AllInterfaces) - { - if (SymbolEqualityComparer.Default.Equals(implemented, interfaceSymbol)) - { - return true; - } - } - - return false; - } - - // Built-in scalars resolve from SpecialType alone (a jump table, no interface walk); everything else that - // renders to a URL scalar - enums, Guid, DateTimeOffset, DateOnly, TimeOnly, TimeSpan, BigInteger, - // Int128/UInt128, Half - implements IFormattable. - return IsScalarSpecialType(underlyingType.SpecialType) - || ImplementsInterface(underlyingType, formattableSymbol); - } - - /// 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); - var formFields = bodyInfo.SerializationMethod == "UrlEncoded" - ? TryBuildFormFields(parameter.Type) - : null; - bodyParameter = new( - parameter.MetadataName, - parameterType, - null, - BuildParameterAttributes(parameter), - RequestParameterKind.Body, - CanBeNull(parameter.Type, parameter.NullableAnnotation), - string.Empty, - string.Empty, - bodyInfo.SerializationMethod, - bodyInfo.BufferMode) - { - FormFields = formFields, - }; - return true; - } - - bodyParameter = new( - parameter.MetadataName, - parameterType, - null, - BuildParameterAttributes(parameter), - RequestParameterKind.Unsupported, - CanBeNull(parameter.Type, parameter.NullableAnnotation), - string.Empty, - string.Empty, - string.Empty, - BodyBufferMode.None); - return false; - } - - /// Builds reflection-free form field descriptors for a URL-encoded body type. - /// The declared body type. - /// The field descriptors, or when the type is not eligible for the descriptor path. - private static ImmutableEquatableArray? TryBuildFormFields(ITypeSymbol bodyType) - { - if (!IsFormFieldEligibleType(bodyType)) - { - return null; - } - - var fields = new List(); - var seen = new HashSet(StringComparer.Ordinal); - - // bodyType is a concrete class/struct/enum here (interfaces and the like are excluded upstream), so its base - // chain always reaches System.Object; the loop stops there and never dereferences a null BaseType. - for (var current = bodyType; - current.SpecialType != SpecialType.System_Object; - current = current.BaseType!) - { - foreach (var member in current.GetMembers()) - { - if (member is IPropertySymbol property && IsReadableFormProperty(property) && seen.Add(property.Name)) - { - fields.Add(BuildFormFieldModel(property)); - } - } - } - - return ImmutableEquatableArrayFactory.FromList(fields); - } - - /// Determines whether a property contributes a readable public instance form field. - /// The property to inspect. - /// when the property is read through a public instance getter. - private static bool IsReadableFormProperty(IPropertySymbol property) => - !property.IsStatic - && !property.IsIndexer - && property.DeclaredAccessibility == Accessibility.Public - && property.GetMethod is { DeclaredAccessibility: Accessibility.Public }; - - /// Determines whether a body type can be flattened to form fields without reflection. - /// The declared body type. - /// when descriptor-based flattening matches the reflection path. - private static bool IsFormFieldEligibleType(ITypeSymbol type) => - type.TypeKind != TypeKind.Interface - && type.TypeKind != TypeKind.TypeParameter - && type.TypeKind != TypeKind.Dynamic - && type.TypeKind != TypeKind.Array - && type.TypeKind != TypeKind.Pointer - && type.TypeKind != TypeKind.Error - && type.SpecialType != SpecialType.System_String - && type.SpecialType != SpecialType.System_Object - && type is not INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T } - - // Dictionaries and other enumerables flow through the reflection path, which special-cases them. - && !ImplementsEnumerable(type); - - /// Determines whether a type implements the non-generic . - /// The type to inspect. - /// when the type is enumerable. - private static bool ImplementsEnumerable(ITypeSymbol type) - { - // The only caller (IsFormFieldEligibleType) already excludes interface types, so type is never the - // System.Collections.IEnumerable interface itself; a concrete enumerable always exposes it through AllInterfaces. - foreach (var contract in type.AllInterfaces) - { - if (contract.SpecialType == SpecialType.System_Collections_IEnumerable) - { - return true; - } - } - - return false; - } - - /// Builds the form field descriptor for one body property. - /// The property to describe. - /// The field descriptor. - private static FormFieldModel BuildFormFieldModel(IPropertySymbol property) - { - string? aliasName = null; - string? jsonName = null; - var query = default(QueryFormData); - - foreach (var attribute in property.GetAttributes()) - { - var attributeName = attribute.AttributeClass!.ToDisplayString(); - if (attributeName == "Refit.AliasAsAttribute") - { - aliasName = GetFirstStringArgument(attribute); - } - else if (attributeName == "System.Text.Json.Serialization.JsonPropertyNameAttribute") - { - jsonName = GetFirstStringArgument(attribute); - } - else if (attributeName == "Refit.QueryAttribute") - { - query = ParseFormQueryAttribute(attribute); - } - } - - var prefixSegment = string.IsNullOrWhiteSpace(query.Prefix) ? null : query.Prefix + query.Delimiter; - return new( - property.Name, - aliasName ?? jsonName, - prefixSegment, - query.Format, - query.CollectionFormatValue, - query.SerializeNull); - } - - /// Reads the first string constructor argument from an attribute. - /// The attribute to inspect. - /// The first string argument, or . - private static string? GetFirstStringArgument(AttributeData attribute) - { - foreach (var argument in attribute.ConstructorArguments) - { - if (argument.Value is string value) - { - return value; - } - } - - return null; - } - - /// Parses the form-relevant members of a [Query] attribute applied to a property. - /// The query attribute data. - /// The parsed form query data. - private static QueryFormData ParseFormQueryAttribute(AttributeData attribute) => - ApplyQueryNamedArguments(attribute, ParseQueryConstructorArguments(attribute)); - - /// Parses the constructor arguments of a [Query] attribute. - /// The query attribute data. - /// The form query data carried by the constructor arguments. - private static QueryFormData ParseQueryConstructorArguments(AttributeData attribute) - { - var delimiter = "."; - string? prefix = null; - string? format = null; - int? collectionFormatValue = null; - var stringArguments = 0; - - foreach (var argument in attribute.ConstructorArguments) - { - // The only enum-typed constructor argument a [Query] attribute accepts is CollectionFormat. - if (argument.Kind == TypedConstantKind.Enum) - { - collectionFormatValue = (int)argument.Value!; - } - else if (argument.Value is string stringValue) - { - if (stringArguments == 0) - { - delimiter = stringValue; - } - else if (stringArguments == 1) - { - prefix = stringValue; - } - else - { - format = stringValue; - } - - stringArguments++; - } - } - - return new(delimiter, prefix, format, collectionFormatValue, false); - } - - /// Applies the named arguments of a [Query] attribute over constructor-supplied data. - /// The query attribute data. - /// The data parsed from constructor arguments. - /// The form query data with named arguments applied. - private static QueryFormData ApplyQueryNamedArguments(AttributeData attribute, QueryFormData data) - { - foreach (var named in attribute.NamedArguments) - { - if (named.Key == "Format" && named.Value.Value is string formatValue) - { - data = data with { Format = formatValue }; - } - else if (named.Key == "CollectionFormat") - { - data = data with { CollectionFormatValue = (int)named.Value.Value! }; - } - else if (named.Key == "SerializeNull") - { - data = data with { SerializeNull = (bool)named.Value.Value! }; - } - } - - return data; - } - - /// 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.IsEmpty || arguments[0].Value is not string headerName || - string.IsNullOrWhiteSpace(headerName)) - { - continue; - } - - headerParameter = new( - parameter.MetadataName, - parameterType, - null, - BuildParameterAttributes(parameter), - 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, - null, - BuildParameterAttributes(parameter), - 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.IsEmpty && arguments[0].Value is string { Length: > 0 } key - ? key - : parameter.MetadataName; - propertyParameter = new( - parameter.MetadataName, - parameterType, - null, - BuildParameterAttributes(parameter), - 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, - null, - BuildParameterAttributes(parameter), - RequestParameterKind.Unsupported, - CanBeNull(parameter.Type, parameter.NullableAnnotation), - string.Empty, - string.Empty, - string.Empty, - BodyBufferMode.None); - - /// Builds a path request parameter model. - /// The parameter symbol. - /// The parameter type. - /// The parameter's location in the URL. - /// The path request model. - private static RequestParameterModel PathRequestParameter( - IParameterSymbol parameter, - string parameterType, - ImmutableEquatableArray locations) => - new( - parameter.MetadataName, - parameterType, - locations, - BuildParameterAttributes(parameter), - RequestParameterKind.Path, - CanBeNull(parameter.Type, parameter.NullableAnnotation), - string.Empty, - string.Empty, - string.Empty, - BodyBufferMode.None); - - /// - /// Flattens a parameter's attributes into value-typed models so the incremental generator cache holds no - /// Roslyn symbols. Attribute type names and argument expressions are precomputed for the emitter. - /// - /// The parameter to inspect. - /// The precomputed attribute models. - private static ImmutableEquatableArray BuildParameterAttributes(IParameterSymbol parameter) - { - var attributes = parameter.GetAttributes(); - if (attributes.IsEmpty) - { - return ImmutableEquatableArray.Empty; - } - - var models = new List(attributes.Length); - foreach (var attribute in attributes) - { - var constructorArguments = new List(attribute.ConstructorArguments.Length); - foreach (var argument in attribute.ConstructorArguments) - { - constructorArguments.Add(ConstantValueToString(argument)); - } - - var namedArguments = new List(attribute.NamedArguments.Length); - foreach (var named in attribute.NamedArguments) - { - namedArguments.Add(new(named.Key, ConstantValueToString(named.Value))); - } - - models.Add(new( - attribute.AttributeClass!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - constructorArguments.ToImmutableEquatableArray(), - namedArguments.ToImmutableEquatableArray())); - } - - return models.ToImmutableEquatableArray(); - } - - /// Renders a typed constant attribute argument as the C# source expression the emitter writes. - /// The typed constant. - /// The source expression, or "null" when the value is null. - private static string ConstantValueToString(TypedConstant argument) - { - var result = string.Empty; - - if (!argument.IsNull) - { - // A non-null attribute argument is always one of Enum, Type, Array or Primitive; the primitive rendering - // doubles as the fallback so no unreachable throwing arm is needed. - result = argument.Kind switch - { - TypedConstantKind.Enum => - $"({argument.Type!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)}){argument.Value!}", - TypedConstantKind.Type => - $"typeof({((ITypeSymbol)argument.Value!).ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)})", - TypedConstantKind.Array => - $"new[] {{ {string.Join(", ", argument.Values.Select(ConstantValueToString))} }}", - _ => SymbolDisplay.FormatPrimitive(argument.Value!, true, false)! - }; - } - - return result.Length > 0 ? result : "null"; - } - - /// 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) - { - // The only enum-typed constructor argument a [Body] attribute accepts is BodySerializationMethod. - if (argument.Kind == TypedConstantKind.Enum) - { - methodName = GetBodySerializationMethodName((int)argument.Value!); - return true; - } - - methodName = string.Empty; - return false; - } - /// Gets return-type details required by the shared generated request runner. - /// The Refit method symbol. + /// The declared return type, or an adapter's wrapped result type. + /// The generation context, used to qualify extern-aliased types. /// The parsed return type details. - private static RequestReturnTypes GetRequestReturnTypes(IMethodSymbol methodSymbol) + private static RequestReturnTypes GetRequestReturnTypes(ITypeSymbol returnType, InterfaceGenerationContext context) { - var resultType = GetReturnResultType(methodSymbol.ReturnType); + var resultType = GetReturnResultType(returnType); var isApiResponse = IsApiResponseType(resultType); - var deserializedResultType = GetDeserializedResultTypeName(resultType, isApiResponse); + var deserializedResultType = GetDeserializedResultTypeName(resultType, isApiResponse, context); var disposeResponse = ShouldDisposeResponse(deserializedResultType); return new( - resultType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + QualifyType(resultType, context), deserializedResultType, isApiResponse, disposeResponse); @@ -1005,6 +419,11 @@ private static ITypeSymbol GetReturnResultType(ITypeSymbol returnType) { return namedType.TypeArguments[0]; } + + if (namedType.MetadataName == "IObservable`1" && ns == "System") + { + return namedType.TypeArguments[0]; + } } return returnType; @@ -1020,40 +439,21 @@ type is INamedTypeSymbol namedType /// Gets the response-content deserialization type for a method result type. /// The method result type. /// Whether the result is an API response wrapper. + /// The generation context, used to qualify extern-aliased types. /// The deserialization target type. - private static string GetDeserializedResultTypeName(ITypeSymbol resultType, bool isApiResponse) + private static string GetDeserializedResultTypeName(ITypeSymbol resultType, bool isApiResponse, InterfaceGenerationContext context) { if (!isApiResponse) { - return resultType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + return QualifyType(resultType, context); } var namedType = (INamedTypeSymbol)resultType; return namedType.MetadataName == "IApiResponse" ? "global::System.Net.Http.HttpContent" - : namedType.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + : QualifyType(namedType.TypeArguments[0], context); } - /// Parsed body attribute data. - /// The body serialization method name. - /// The body buffering mode. - private readonly record struct BodyAttributeInfo( - string SerializationMethod, - BodyBufferMode BufferMode); - - /// Form-relevant data parsed from a [Query] attribute on a body property. - /// The delimiter combined with the prefix. - /// The field name prefix, if any. - /// The value format, if any. - /// The explicit collection format value, if any. - /// Whether null values are serialized as empty fields. - private readonly record struct QueryFormData( - string Delimiter, - string? Prefix, - string? Format, - int? CollectionFormatValue, - bool SerializeNull); - /// Parsed return-type data for generated requests. /// The method result type. /// The response-body deserialization type. @@ -1065,6 +465,25 @@ private readonly record struct RequestReturnTypes( bool IsApiResponse, bool DisposeResponse); + /// Bundles the lookup state used to classify one loosely-bound parameter. + /// The resolved URL name: the [AliasAs] name or the declared parameter name. + /// The parameter's placeholder locations, or null when it has no placeholder. + /// The parameter's round-tripping ({**name}) placeholder locations, if any. + /// All placeholder names in the URL, used to detect dotted property bindings. + /// The resolved System.IFormattable symbol, or null when unavailable. + /// Whether an un-attributed complex parameter becomes the implicit request body. + /// Whether the method is multipart, so an un-attributed parameter becomes a form part. + /// The interface generation context, carrying the extern-alias collector for type qualification. + private readonly record struct LooseParameterContext( + string UrlName, + ImmutableEquatableArray? Locations, + ImmutableEquatableArray? RoundTripLocations, + Dictionary> ParameterLocations, + INamedTypeSymbol? FormattableSymbol, + bool ImplicitBodyEligible, + bool IsMultipart, + InterfaceGenerationContext Generation); + /// Parsed request parameter data plus duplicate-detection counters. /// The parsed parameter model. /// Whether this parameter can be emitted inline. @@ -1077,4 +496,9 @@ private readonly record struct ParsedRequestParameter( int BodyCount, int CancellationTokenCount, int HeaderCollectionCount); + + /// The raw and normalized forms of a method's path template. + /// The raw path literal from the HTTP method attribute. + /// The path after constant-path normalization (fragment/empty-query cleanup). + private readonly record struct RequestPathForms(string Raw, string Normalized); } diff --git a/src/InterfaceStubGenerator.Shared/Parser.cs b/src/InterfaceStubGenerator.Shared/Parser.cs index ef0f77b4e..0ca3bf2f6 100644 --- a/src/InterfaceStubGenerator.Shared/Parser.cs +++ b/src/InterfaceStubGenerator.Shared/Parser.cs @@ -2,7 +2,6 @@ // 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.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -16,15 +15,6 @@ internal static partial class Parser /// The suffix used for generator-private Refit helper types. private const string RefitInternalGeneratedSuffix = "RefitInternalGenerated"; - /// The underlying value for BodySerializationMethod.UrlEncoded. - private const int BodySerializationUrlEncoded = 2; - - /// The underlying value for BodySerializationMethod.Serialized. - private const int BodySerializationSerialized = 3; - - /// The underlying value for BodySerializationMethod.JsonLines. - private const int BodySerializationJsonLines = 4; - /// Builds the generator model for the candidate Refit interfaces. /// The compilation. /// The refit internal namespace. @@ -49,28 +39,13 @@ public static ( refitInternalNamespace = BuildRefitInternalNamespace(refitInternalNamespace); - var options = (CSharpParseOptions)compilation.SyntaxTrees[0].Options; - - // Resolve the handful of well-known symbols directly. The previous WellKnownTypes wrapper - // allocated a dictionary-backed cache object every pass for just these two lookups. - var disposableInterfaceSymbol = compilation.GetTypeByMetadataName("System.IDisposable"); - var httpMethodBaseAttributeSymbol = compilation.GetTypeByMetadataName( - "Refit.HttpMethodAttribute"); - var formattableSymbol = compilation.GetTypeByMetadataName("System.IFormattable"); + var httpMethodBaseAttributeSymbol = compilation.GetTypeByMetadataName("Refit.HttpMethodAttribute"); var diagnostics = new List(); if (httpMethodBaseAttributeSymbol is null) { diagnostics.Add(Diagnostic.Create(DiagnosticDescriptors.RefitNotReferenced, null)); - return ( - diagnostics, - new( - refitInternalNamespace, - string.Empty, - generatedRequestBuilding, - emitGeneratedCodeMarkers, - ImmutableEquatableArrayFactory.Empty()) - ); + return CreateEmptyResult(diagnostics, refitInternalNamespace, generatedRequestBuilding, emitGeneratedCodeMarkers); } var interfaceToNullableEnabledMap = new Dictionary( @@ -88,17 +63,85 @@ public static ( // Nothing needs to be generated in this pass. if (interfaces.Count == 0) { - return ( - diagnostics, - new( - refitInternalNamespace, - string.Empty, - generatedRequestBuilding, - emitGeneratedCodeMarkers, - ImmutableEquatableArrayFactory.Empty()) - ); + return CreateEmptyResult(diagnostics, refitInternalNamespace, generatedRequestBuilding, emitGeneratedCodeMarkers); } + var context = CreateGenerationContext( + compilation, + diagnostics, + refitInternalNamespace, + httpMethodBaseAttributeSymbol, + generatedRequestBuilding, + emitGeneratedCodeMarkers, + cancellationToken); + + var interfaceModels = BuildInterfaceModels( + interfaces, + interfaceToNullableEnabledMap, + context, + cancellationToken); + + var contextGenerationSpec = new ContextGenerationModel( + refitInternalNamespace, + context.PreserveAttributeDisplayName, + generatedRequestBuilding, + emitGeneratedCodeMarkers, + interfaceModels); + return (diagnostics, contextGenerationSpec); + } + + /// Builds the empty generation result returned when there is nothing to generate. + /// The diagnostics collected so far. + /// The resolved internal generated namespace. + /// Whether generated request construction is enabled. + /// Whether generated files include generated-code analyzer skip markers. + /// The diagnostics paired with an empty context model. + private static (List diagnostics, ContextGenerationModel contextGenerationSpec) CreateEmptyResult( + List diagnostics, + string refitInternalNamespace, + bool generatedRequestBuilding, + bool emitGeneratedCodeMarkers) => + ( + diagnostics, + new( + refitInternalNamespace, + string.Empty, + generatedRequestBuilding, + emitGeneratedCodeMarkers, + ImmutableEquatableArrayFactory.Empty()) + ); + + /// Resolves the well-known symbols and language capabilities used throughout a single generation pass. + /// The compilation. + /// The list that collects diagnostics produced during processing. + /// The resolved internal generated namespace. + /// The resolved Refit HTTP method attribute symbol. + /// Whether generated request construction is enabled. + /// Whether generated files include generated-code analyzer skip markers. + /// The cancellation token. + /// The generation context for the pass. + private static InterfaceGenerationContext CreateGenerationContext( + CSharpCompilation compilation, + List diagnostics, + string refitInternalNamespace, + INamedTypeSymbol httpMethodBaseAttributeSymbol, + bool generatedRequestBuilding, + bool emitGeneratedCodeMarkers, + CancellationToken cancellationToken) + { + var options = (CSharpParseOptions)compilation.SyntaxTrees[0].Options; + + // Resolve the handful of well-known symbols directly. The previous WellKnownTypes wrapper + // allocated a dictionary-backed cache object every pass for just these lookups. + var disposableInterfaceSymbol = compilation.GetTypeByMetadataName("System.IDisposable"); + var formattableSymbol = compilation.GetTypeByMetadataName("System.IFormattable"); + + // Resolve the value-formatting fast-path capabilities once per pass (never per interface or per parameter) and + // thread them through the context. ISpanFormattable is net6+, and the query builder percent-encodes a formatted + // span in place on every net6+ target, so the span-escape tier applies wherever ISpanFormattable exists. + var spanFormattableSymbol = compilation.GetTypeByMetadataName("System.ISpanFormattable"); + var supportsSpanEscape = spanFormattableSymbol is not null; + var supportsNullable = options.LanguageVersion >= LanguageVersion.CSharp8; var supportsStaticLambdas = options.LanguageVersion >= LanguageVersion.CSharp9; @@ -109,30 +152,27 @@ public static ( // every generator pass, which mutated the compilation and forced an extra bind. var preserveAttributeDisplayName = $"global::{refitInternalNamespace}.PreserveAttribute"; - var context = new InterfaceGenerationContext( + var returnTypeAdapterInterface = ResolveReturnTypeAdapterInterface(compilation); + var returnTypeAdapters = DiscoverReturnTypeAdapters(compilation, returnTypeAdapterInterface, cancellationToken); + + return new InterfaceGenerationContext( diagnostics, preserveAttributeDisplayName, disposableInterfaceSymbol, httpMethodBaseAttributeSymbol, formattableSymbol, + spanFormattableSymbol, + supportsSpanEscape, generatedRequestBuilding, emitGeneratedCodeMarkers, supportsNullable, - supportsStaticLambdas); - - var interfaceModels = BuildInterfaceModels( - interfaces, - interfaceToNullableEnabledMap, - context, - cancellationToken); - - var contextGenerationSpec = new ContextGenerationModel( - refitInternalNamespace, - preserveAttributeDisplayName, - generatedRequestBuilding, - emitGeneratedCodeMarkers, - interfaceModels); - return (diagnostics, contextGenerationSpec); + supportsStaticLambdas, + options.LanguageVersion >= LanguageVersion.CSharp12, + compilation, + returnTypeAdapterInterface, + returnTypeAdapters, + [], + new Dictionary(SymbolEqualityComparer.Default)); } /// Builds the internal generated namespace from a consumer-provided namespace prefix. @@ -162,7 +202,9 @@ private static string BuildRefitInternalNamespace(string? refitInternalNamespace _ = builder.Append(normalized); } - return builder.Length == 0 ? RefitInternalGeneratedSuffix : builder.ToString(); + // The raw namespace always contains the non-empty RefitInternalGeneratedSuffix segment, so at least one + // normalized part is appended and the builder is never empty here. + return builder.ToString(); } /// Normalizes one namespace segment into a valid identifier. @@ -261,10 +303,10 @@ private static Dictionary> CollectRefitInt foreach (var iface in candidateInterfaces) { var model = compilation.GetSemanticModel(iface.SyntaxTree); - var ifaceSymbol = model.GetDeclaredSymbol(iface, cancellationToken); + var ifaceSymbol = model.GetDeclaredSymbol(iface, cancellationToken)!; // Skip duplicates already captured from candidate methods. - if (ifaceSymbol is null || interfaces.ContainsKey(ifaceSymbol)) + if (interfaces.ContainsKey(ifaceSymbol)) { continue; } @@ -294,7 +336,7 @@ private static Dictionary> CollectRefitInt private static ImmutableEquatableArray BuildInterfaceModels( Dictionary> interfaces, Dictionary interfaceToNullableEnabledMap, - in InterfaceGenerationContext context, + InterfaceGenerationContext context, CancellationToken cancellationToken) { var keyCount = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -311,19 +353,22 @@ private static ImmutableEquatableArray BuildInterfaceModels( var keyName = group.Key.Name; while (keyCount.TryGetValue(keyName, out var value)) { - keyName = $"{keyName}{++value}"; + value++; + keyName = $"{keyName}{value}"; } // A failed TryGetValue leaves value at its default of 0, so the deduplicated name starts at zero. keyCount[keyName] = 0; var fileName = $"{keyName}.g.cs"; - interfaceModels[index++] = ProcessInterface( + // A fresh collector per interface: each generated file declares only the extern aliases its own types use. + interfaceModels[index] = ProcessInterface( fileName, group.Key, group.Value, interfaceToNullableEnabledMap[group.Key], - context); + context with { ExternAliases = [] }); + index++; } return ImmutableEquatableArrayFactory.FromArray(interfaceModels); @@ -341,12 +386,12 @@ private static InterfaceModel ProcessInterface( INamedTypeSymbol interfaceSymbol, List refitMethods, bool nullableEnabled, - in InterfaceGenerationContext context) + InterfaceGenerationContext context) { var names = ComputeInterfaceNames(interfaceSymbol); var members = interfaceSymbol.GetMembers(); - var partition = PartitionMethods( + var partition = PartitionMembers( interfaceSymbol, members, refitMethods, @@ -362,10 +407,11 @@ private static InterfaceModel ProcessInterface( var nonRefitMethodModels = BuildNonRefitMethodModels( partition.NonRefitMethods, - partition.DerivedNonRefitMethods); - var properties = BuildInterfacePropertyModels(interfaceSymbol, members); + partition.DerivedNonRefitMethods, + context); + var properties = BuildInterfacePropertyModels(members, partition.InheritedProperties, context); - var constraints = GenerateConstraints(interfaceSymbol.TypeParameters, false); + var constraints = GenerateConstraints(interfaceSymbol.TypeParameters, false, context); var nullability = (context.SupportsNullable, nullableEnabled) switch { (false, _) => Nullability.None, @@ -384,6 +430,7 @@ private static InterfaceModel ProcessInterface( context.EmitGeneratedCodeMarkers, context.SupportsNullable, context.SupportsStaticLambdas, + context.SupportsCollectionExpressions, constraints, memberNames, properties, @@ -391,7 +438,23 @@ private static InterfaceModel ProcessInterface( refitMethodsArray, derivedRefitMethodsArray, nullability, - partition.HasDispose); + partition.HasDispose, + BuildSortedExternAliases(context.ExternAliases)); + } + + /// Builds the deterministically-ordered set of extern aliases an interface's types reference. + /// The collected extern aliases. + /// The sorted extern aliases, or an empty array when none were used. + private static ImmutableEquatableArray BuildSortedExternAliases(HashSet aliases) + { + if (aliases.Count == 0) + { + return ImmutableEquatableArray.Empty; + } + + var sorted = new List(aliases); + sorted.Sort(StringComparer.Ordinal); + return sorted.ToImmutableEquatableArray(); } /// Computes the generated identifiers and display names for an interface. @@ -411,42 +474,44 @@ private static InterfaceNames ComputeInterfaceNames(INamedTypeSymbol interfaceSy // Use the simple interface name for the generated class suffix. var classSuffix = (interfaceSymbol.ContainingType?.Name) + interfaceSymbol.Name; - var ns = interfaceSymbol.ContainingNamespace?.ToDisplayString(); + var ns = interfaceSymbol.ContainingNamespace.ToDisplayString(); // Keep generated names stable for interfaces declared in the global namespace. - if (interfaceSymbol.ContainingNamespace is { IsGlobalNamespace: true }) + if (interfaceSymbol.ContainingNamespace.IsGlobalNamespace) { ns = string.Empty; } // Flatten dots out of the namespace for generated identifiers. - ns = ns!.Replace(".", string.Empty); + ns = ns.Replace(".", string.Empty); var interfaceDisplayName = interfaceSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); return new(className, classDeclaration, classSuffix, ns, interfaceDisplayName); } - /// Splits the interface's direct and inherited members into Refit and non-Refit sets. + /// Splits the interface's direct and inherited members into the sets the generated stub emits. /// The interface symbol being processed. /// The directly declared members of the interface. /// The Refit methods declared on the interface. /// The shared generation context. - /// The partitioned method sets. - private static MethodPartition PartitionMethods( + /// The partitioned member sets. + private static MethodPartition PartitionMembers( INamedTypeSymbol interfaceSymbol, in ImmutableArray members, List refitMethods, - in InterfaceGenerationContext context) + InterfaceGenerationContext context) { var nonRefitMethods = CollectDirectNonRefitMethods(members, refitMethods); var derivedRefitMethods = new List(); var derivedNonRefitMethods = new List(); - var hasDispose = CollectDerivedMethods( + var inheritedProperties = new List(); + var hasDispose = CollectDerivedMembers( interfaceSymbol, context, derivedRefitMethods, - derivedNonRefitMethods); + derivedNonRefitMethods, + inheritedProperties); derivedNonRefitMethods = ExcludeExplicitlyImplementedBaseMethods( members, @@ -456,6 +521,7 @@ private static MethodPartition PartitionMethods( nonRefitMethods, derivedRefitMethods, derivedNonRefitMethods, + inheritedProperties, hasDispose); } @@ -490,45 +556,58 @@ member is IMethodSymbol method return nonRefitMethods; } - /// Walks the inherited interfaces, splitting their methods into Refit and non-Refit sets. + /// Walks the inherited interfaces once, collecting derived methods and inherited properties. /// The interface symbol being processed. /// The shared generation context. /// Receives the Refit methods inherited from base interfaces. /// Receives the non-Refit methods inherited from base interfaces. + /// Receives the emittable properties inherited from base interfaces. /// if the interface inherits IDisposable.Dispose; otherwise, . - private static bool CollectDerivedMethods( + private static bool CollectDerivedMembers( INamedTypeSymbol interfaceSymbol, - in InterfaceGenerationContext context, + InterfaceGenerationContext context, List derivedRefitMethods, - List derivedNonRefitMethods) + List derivedNonRefitMethods, + List inheritedProperties) { - // Walk all inherited interfaces once, pulling out the IDisposable.Dispose method and - // splitting the rest into Refit and non-Refit methods. + // Walk all inherited interfaces (and each base's members) exactly once: pull out the IDisposable.Dispose + // method, split the remaining methods into Refit and non-Refit sets, and collect inherited emittable + // properties in the same pass. Methods and properties were previously two separate AllInterfaces walks. var disposableInterfaceSymbol = context.DisposableInterfaceSymbol; var hasDispose = false; - var seenDerivedNonRefitMethods = new HashSet(SymbolEqualityComparer.Default); + var seenInheritedProperties = new HashSet(SymbolEqualityComparer.Default); foreach (var baseInterface in interfaceSymbol.AllInterfaces) { foreach (var member in baseInterface.GetMembers()) { - if (member is not IMethodSymbol method) - { - continue; - } - - if (IsDisposeMethod(method, disposableInterfaceSymbol)) + switch (member) { - hasDispose = true; - continue; - } - - if (IsRefitMethod(method, context.HttpMethodBaseAttributeSymbol)) - { - derivedRefitMethods.Add(method); - } - else if (seenDerivedNonRefitMethods.Add(method)) - { - derivedNonRefitMethods.Add(method); + case IMethodSymbol method when IsDisposeMethod(method, disposableInterfaceSymbol): + { + hasDispose = true; + break; + } + + case IMethodSymbol method when IsRefitMethod(method, context.HttpMethodBaseAttributeSymbol): + { + derivedRefitMethods.Add(method); + break; + } + + case IMethodSymbol method: + { + // Each inherited interface contributes its own members once across AllInterfaces, so a method + // symbol never repeats here and no per-method de-duplication is needed. + derivedNonRefitMethods.Add(method); + break; + } + + case IPropertySymbol property + when IsEmittableProperty(property) && seenInheritedProperties.Add(property): + { + inheritedProperties.Add(property); + break; + } } } } @@ -541,9 +620,7 @@ private static bool CollectDerivedMethods( /// The IDisposable symbol, if available. /// if the method is declared on IDisposable; otherwise, . private static bool IsDisposeMethod(IMethodSymbol method, ISymbol? disposableInterfaceSymbol) => - method.ContainingType?.Equals( - disposableInterfaceSymbol, - SymbolEqualityComparer.Default) == true; + method.ContainingType.Equals(disposableInterfaceSymbol, SymbolEqualityComparer.Default); /// Removes base methods that the current interface re-declares as explicit Refit members. /// The directly declared members of the interface. @@ -591,478 +668,6 @@ private static List ExcludeExplicitlyImplementedBaseMethods( return filteredDerivedNonRefitMethods; } - /// 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(in ImmutableArray members) - { - var seenMemberNames = new HashSet(); - var memberNames = new string[members.Length]; - var count = 0; - foreach (var member in members) - { - if (seenMemberNames.Add(member.Name)) - { - memberNames[count++] = member.Name; - } - } - - 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.IsEmpty; - - /// 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.IsEmpty && 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, - in InterfaceGenerationContext context) - { - if (methods.Count == 0) - { - return ImmutableEquatableArrayFactory.Empty(); - } - - 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. - /// The non-Refit methods declared directly on the interface. - /// The non-Refit methods inherited from base interfaces. - /// The non-Refit method models. - private static ImmutableEquatableArray BuildNonRefitMethodModels( - List nonRefitMethods, - List derivedNonRefitMethods) - { - // Only abstract instance methods become non-Refit method models. - var methodModels = new MethodModel[nonRefitMethods.Count + derivedNonRefitMethods.Count]; - var count = 0; - foreach (var method in nonRefitMethods) - { - if (IsEmittableNonRefitMethod(method)) - { - methodModels[count++] = ParseNonRefitMethod(method, false); - } - } - - foreach (var method in derivedNonRefitMethods) - { - if (IsEmittableNonRefitMethod(method)) - { - // Derived non-Refit methods are emitted as explicit interface implementations. - methodModels[count++] = ParseNonRefitMethod(method, true); - } - } - - return TrimAndWrap(methodModels, count); - } - - /// Determines whether a non-Refit method should be emitted as a method model. - /// The candidate method. - /// if the method is an abstract instance method; otherwise, . - private static bool IsEmittableNonRefitMethod(IMethodSymbol method) => - !method.IsStatic - && method.MethodKind != MethodKind.PropertyGet - && method.MethodKind != MethodKind.PropertySet - && method.IsAbstract; - - /// Builds a method model for a non-Refit interface method. - /// The non-Refit method symbol. - /// Whether the method comes from a base interface. - /// The model describing the non-Refit method. - private static MethodModel ParseNonRefitMethod( - IMethodSymbol methodSymbol, - bool isDerived) - { - // Derived base-interface methods are emitted as explicit implementations. - 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 = ParseParameters(methodSymbol.Parameters); - - var isExplicit = isDerived || explicitImpl is not null; - var constraints = GenerateConstraints(methodSymbol.TypeParameters, isExplicit); - - return new( - methodSymbol.Name, - returnType, - containingType, - declaredBaseName, - returnTypeInfo, - RequestModel.Empty, - parameters, - constraints, - isExplicit); - } - - /// 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) - { - var implementations = methodSymbol.ExplicitInterfaceImplementations; - return implementations.IsEmpty ? null : implementations[0]; - } - - /// Classifies a method's return type into its shape. - /// The method symbol. - /// The classified return type information. - private static ReturnTypeInfo GetReturnTypeInfo(IMethodSymbol methodSymbol) => - methodSymbol.ReturnType.MetadataName switch - { - "Task" => ReturnTypeInfo.AsyncVoid, - "Task`1" or "ValueTask`1" => ReturnTypeInfo.AsyncResult, - "IAsyncEnumerable`1" => ReturnTypeInfo.AsyncEnumerable, - "Void" => ReturnTypeInfo.SyncVoid, - _ => ReturnTypeInfo.Return - }; - - /// 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 (typeParameters.IsEmpty) - { - return ImmutableEquatableArrayFactory.Empty(); - } - - var constraints = new TypeConstraint[typeParameters.Length]; - for (var i = 0; i < typeParameters.Length; i++) - { - constraints[i] = ParseConstraintsForTypeParameter( - typeParameters[i], - isOverrideOrExplicitImplementation); - } - - return ImmutableEquatableArrayFactory.FromArray(constraints); - } - - /// Builds the constraint model for a single type parameter. - /// The type parameter to parse. - /// Whether the member is an override or explicit implementation. - /// The constraint model for the type parameter. - private static TypeConstraint ParseConstraintsForTypeParameter( - ITypeParameterSymbol typeParameter, - bool isOverrideOrExplicitImplementation) - { - var known = ComputeKnownConstraints(typeParameter, isOverrideOrExplicitImplementation); - - var constraints = ImmutableEquatableArray.Empty; - if (!isOverrideOrExplicitImplementation) - { - constraints = ParseConstraintTypes(typeParameter.ConstraintTypes); - } - - var declaredName = typeParameter.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - return new(typeParameter.Name, declaredName, known, constraints); - } - - /// Computes the set of well-known constraint flags emittable for a type parameter. - /// The type parameter to inspect. - /// Whether the member is an override or explicit implementation. - /// The combined well-known constraint flags. - private static KnownTypeConstraint ComputeKnownConstraints( - ITypeParameterSymbol typeParameter, - bool isOverrideOrExplicitImplementation) - { - // Explicit implementations and overrides can only emit the subset of constraints that - // the generated member declaration is allowed to repeat. - var known = KnownTypeConstraint.None; - - if (typeParameter.HasReferenceTypeConstraint) - { - known |= KnownTypeConstraint.Class; - } - - if (typeParameter.HasUnmanagedTypeConstraint && !isOverrideOrExplicitImplementation) - { - known |= KnownTypeConstraint.Unmanaged; - } - - // `unmanaged` already implies `struct`, so avoid duplicating it. - if (typeParameter.HasValueTypeConstraint && !typeParameter.HasUnmanagedTypeConstraint) - { - known |= KnownTypeConstraint.Struct; - } - - if (typeParameter.HasNotNullConstraint && !isOverrideOrExplicitImplementation) - { - known |= KnownTypeConstraint.NotNull; - } - - // The `new()` constraint must be emitted last. - if (typeParameter.HasConstructorConstraint && !isOverrideOrExplicitImplementation) - { - known |= KnownTypeConstraint.New; - } - - return known; - } - - /// Builds a parameter model from a parameter symbol. - /// The parameter symbol to parse. - /// The model describing the parameter. - private static ParameterModel ParseParameter(IParameterSymbol param) - { - var annotation = - !param.Type.IsValueType && param.NullableAnnotation == NullableAnnotation.Annotated; - - var paramType = param.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var isGeneric = ContainsTypeParameter(param.Type); - - 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.IsEmpty) - { - 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.IsEmpty) - { - 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, . - private static bool ContainsTypeParameter(ITypeSymbol symbol) - { - if (symbol is ITypeParameterSymbol) - { - return true; - } - - if (symbol is not INamedTypeSymbol { TypeParameters.Length: > 0 } namedType) - { - return false; - } - - foreach (var typeArg in namedType.TypeArguments) - { - if (ContainsTypeParameter(typeArg)) - { - return true; - } - } - - return false; - } - - /// 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, - 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 = FirstExplicitInterfaceImplementation(methodSymbol); - var containingTypeSymbol = explicitImpl?.ContainingType ?? methodSymbol.ContainingType; - var containingType = containingTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - - var declaredBaseName = BuildDeclaredBaseName(methodSymbol); - var returnTypeInfo = GetReturnTypeInfo(methodSymbol); - var request = ParseRequest(methodSymbol, returnTypeInfo, context); - var parameters = ParseParameters(methodSymbol.Parameters); - - var isExplicit = explicitImpl is not null; - var constraints = GenerateConstraints(methodSymbol.TypeParameters, isExplicit || !isImplicitInterface); - - return new( - methodSymbol.Name, - returnType, - containingType, - declaredBaseName, - returnTypeInfo, - request, - parameters, - constraints, - isExplicit); - } - /// The generated identifiers and display names computed for an interface. /// The simple generated class name. /// The generated class declaration name. @@ -1076,14 +681,16 @@ private readonly record struct InterfaceNames( string Namespace, string InterfaceDisplayName); - /// The interface's methods partitioned into Refit and non-Refit sets. + /// The interface's members partitioned into the sets the generated stub emits. /// The non-Refit methods declared directly on the interface. /// The Refit methods inherited from base interfaces. /// The non-Refit methods inherited from base interfaces. + /// The emittable properties inherited from base interfaces, in discovery order. /// Whether the interface inherits IDisposable.Dispose. private readonly record struct MethodPartition( List NonRefitMethods, List DerivedRefitMethods, List DerivedNonRefitMethods, + List InheritedProperties, bool HasDispose); } diff --git a/src/InterfaceStubGenerator.Shared/PooledStringBuilder.cs b/src/InterfaceStubGenerator.Shared/PooledStringBuilder.cs new file mode 100644 index 000000000..983e9310f --- /dev/null +++ b/src/InterfaceStubGenerator.Shared/PooledStringBuilder.cs @@ -0,0 +1,110 @@ +// 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.Buffers; +using System.Globalization; + +namespace Refit.Generator; + +/// A drop-in fluent string builder for the emitter that grows using buffers. +/// The emitter builds many transient fragment strings. Backing accumulation with a pooled char[] lets the +/// underlying buffer be reused across emissions instead of allocating fresh +/// chunks each time. The final returns the buffer to the pool, so an instance is single-use. +internal sealed class PooledStringBuilder +{ + /// The default rented capacity, sized to hold a typical generated statement block without growing. + private const int DefaultCapacity = 256; + + /// The buffer growth factor applied when the current backing array is exhausted. + private const int GrowthFactor = 2; + + /// The line terminator emitted by the overloads. + /// Fixed to \n for deterministic generated output (matching the emitter's explicit \n + /// literals); analyzers are banned from reading Environment.NewLine. + private const string NewLine = "\n"; + + /// The pooled array currently backing the builder. + private char[] _buffer; + + /// The current write position within the buffer. + private int _pos; + + /// Initializes a new instance of the class. + public PooledStringBuilder() + : this(DefaultCapacity) + { + } + + /// Initializes a new instance of the class with an initial capacity. + /// The initial buffer capacity to rent. + public PooledStringBuilder(int capacity) => _buffer = ArrayPool.Shared.Rent(Math.Max(capacity, DefaultCapacity)); + + /// Appends a string. + /// The string to append, or null. + /// This builder, for chaining. + public PooledStringBuilder Append(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return this; + } + + EnsureCapacity(_pos + value!.Length); + value.CopyTo(0, _buffer, _pos, value.Length); + _pos += value.Length; + return this; + } + + /// Appends the invariant decimal rendering of a 32-bit integer. + /// The value to append. + /// This builder, for chaining. + public PooledStringBuilder Append(int value) => Append(value.ToString(CultureInfo.InvariantCulture)); + + /// Appends a single character. + /// The character to append. + /// This builder, for chaining. + public PooledStringBuilder Append(char value) + { + EnsureCapacity(_pos + 1); + _buffer[_pos] = value; + _pos++; + return this; + } + + /// Appends a string followed by a line terminator. + /// The string to append, or null. + /// This builder, for chaining. + public PooledStringBuilder AppendLine(string? value) => Append(value).Append(NewLine); + + /// Appends a line terminator. + /// This builder, for chaining. + public PooledStringBuilder AppendLine() => Append(NewLine); + + /// Materializes the accumulated content into a string and returns the pooled buffer. + /// The accumulated string. + public override string ToString() + { + var result = _pos == 0 ? string.Empty : new string(_buffer, 0, _pos); + var toReturn = _buffer; + _buffer = []; + _pos = 0; + ArrayPool.Shared.Return(toReturn); + return result; + } + + /// Ensures the backing buffer can hold at least the requested number of characters. + /// The required total capacity. + private void EnsureCapacity(int required) + { + if (required <= _buffer.Length) + { + return; + } + + var next = ArrayPool.Shared.Rent(Math.Max(required, _buffer.Length * GrowthFactor)); + Array.Copy(_buffer, next, _pos); + var toReturn = _buffer; + _buffer = next; + ArrayPool.Shared.Return(toReturn); + } +} diff --git a/src/InterfaceStubGenerator.Shared/UniqueNameBuilder.cs b/src/InterfaceStubGenerator.Shared/UniqueNameBuilder.cs index fd4c2fcfa..02581db43 100644 --- a/src/InterfaceStubGenerator.Shared/UniqueNameBuilder.cs +++ b/src/InterfaceStubGenerator.Shared/UniqueNameBuilder.cs @@ -27,6 +27,10 @@ public void Reserve(IEnumerable names) } } + /// Reserves a single name so it will not be handed out by . + /// The name to reserve. + public void Reserve(string name) => _usedNames.Add(name); + /// Generate a unique name. /// The desired base name. /// A unique name not used in this or any parent scope. diff --git a/src/Polyfills/DynamicallyAccessedMemberTypes.cs b/src/Polyfills/DynamicallyAccessedMemberTypes.cs index c32e331af..903ece52f 100644 --- a/src/Polyfills/DynamicallyAccessedMemberTypes.cs +++ b/src/Polyfills/DynamicallyAccessedMemberTypes.cs @@ -7,8 +7,10 @@ namespace System.Diagnostics.CodeAnalysis; /// Specifies the member types that are dynamically accessed and must be preserved. [Flags] -[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.")] +[SuppressMessage( + "Design", + "SST2303:An enum marked [Flags] has members that are not distinct bit values", + Justification = "Mirrors the BCL flags enum; several members intentionally combine bits (e.g. PublicConstructors = 1 | 2).")] internal enum DynamicallyAccessedMemberTypes { /// No members are dynamically accessed. diff --git a/src/Polyfills/HashCode.cs b/src/Polyfills/HashCode.cs index 18aab9c7e..0fb1cc2f1 100644 --- a/src/Polyfills/HashCode.cs +++ b/src/Polyfills/HashCode.cs @@ -14,10 +14,6 @@ namespace System; /// because every use is a stack-local accumulator; that keeps it from ever being boxed or /// compared, so it needs no equality members. /// -[Diagnostics.CodeAnalysis.SuppressMessage( - "Style", - "S1118:Utility classes should not have public constructors", - Justification = "BCL-shaped mutable hash accumulator must be constructible.")] [Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] internal ref struct HashCode { diff --git a/src/Refit.Analyzers.Roslyn48/Refit.Analyzers.Roslyn48.csproj b/src/Refit.Analyzers.Roslyn48/Refit.Analyzers.Roslyn48.csproj index 2dd2cc034..97bd363fd 100644 --- a/src/Refit.Analyzers.Roslyn48/Refit.Analyzers.Roslyn48.csproj +++ b/src/Refit.Analyzers.Roslyn48/Refit.Analyzers.Roslyn48.csproj @@ -27,6 +27,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $(BuildVersion) diff --git a/src/Refit.Analyzers.Roslyn50/Refit.Analyzers.Roslyn50.csproj b/src/Refit.Analyzers.Roslyn50/Refit.Analyzers.Roslyn50.csproj index de9acef75..5affbcda5 100644 --- a/src/Refit.Analyzers.Roslyn50/Refit.Analyzers.Roslyn50.csproj +++ b/src/Refit.Analyzers.Roslyn50/Refit.Analyzers.Roslyn50.csproj @@ -27,6 +27,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $(BuildVersion) diff --git a/src/Refit.Analyzers.Shared/DiagnosticDescriptors.cs b/src/Refit.Analyzers.Shared/DiagnosticDescriptors.cs index 627eefbf9..fb5c6b964 100644 --- a/src/Refit.Analyzers.Shared/DiagnosticDescriptors.cs +++ b/src/Refit.Analyzers.Shared/DiagnosticDescriptors.cs @@ -52,6 +52,41 @@ internal static class DiagnosticDescriptors DiagnosticSeverity.Warning, true); + /// Diagnostic reported when a Refit method cannot use generated request building and falls back to reflection. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "RS2008", Justification = "Diagnostic IDs are stable and intentionally not tracked in an analyzer release-tracking file.")] + public static readonly DiagnosticDescriptor GeneratedRequestBuildingFallback = + new( + DiagnosticIds.GeneratedRequestBuildingFallback, + "Refit method is not compatible with generated-only client registration", + "Method {0}.{1} uses request features the Refit source generator cannot build without reflection, so it falls back to the reflection request builder. " + + "It will throw at runtime when resolved through AddRefitGeneratedClient (generated-only) or under NativeAOT. " + + "Use RestService.For where reflection is acceptable, or change the method to use only features supported by generated request building.", + Category, + DiagnosticSeverity.Warning, + true); + + /// Diagnostic reported when a Refit method declares more than one HeaderCollection parameter. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "RS2008", Justification = "Diagnostic IDs are stable and intentionally not tracked in an analyzer release-tracking file.")] + public static readonly DiagnosticDescriptor MultipleHeaderCollections = + new( + DiagnosticIds.MultipleHeaderCollections, + "Refit methods can only have one HeaderCollection parameter", + "Method {0}.{1} has more than one HeaderCollection parameter", + Category, + DiagnosticSeverity.Warning, + true); + + /// Diagnostic reported when a Refit method declares more than one Authorize parameter. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "RS2008", Justification = "Diagnostic IDs are stable and intentionally not tracked in an analyzer release-tracking file.")] + public static readonly DiagnosticDescriptor MultipleAuthorizeParameters = + new( + DiagnosticIds.MultipleAuthorizeParameters, + "Refit methods can only have one Authorize parameter", + "Method {0}.{1} has more than one Authorize parameter", + Category, + DiagnosticSeverity.Warning, + true); + /// The diagnostic category for Refit analyzer diagnostics. private const string Category = "Refit"; } diff --git a/src/Refit.Analyzers.Shared/DiagnosticIds.cs b/src/Refit.Analyzers.Shared/DiagnosticIds.cs index d53e30067..0be1a1784 100644 --- a/src/Refit.Analyzers.Shared/DiagnosticIds.cs +++ b/src/Refit.Analyzers.Shared/DiagnosticIds.cs @@ -17,4 +17,13 @@ internal static class DiagnosticIds /// Diagnostic reported when a header collection parameter uses an unsupported type. public const string InvalidHeaderCollectionParameter = "RF005"; + + /// Diagnostic reported when a Refit method falls back to the reflection request builder and is not compatible with generated-only registration. + public const string GeneratedRequestBuildingFallback = "RF006"; + + /// Diagnostic reported when a Refit method declares more than one [HeaderCollection] parameter. + public const string MultipleHeaderCollections = "RF008"; + + /// Diagnostic reported when a Refit method declares more than one [Authorize] parameter. + public const string MultipleAuthorizeParameters = "RF009"; } diff --git a/src/Refit.Analyzers.Shared/RefitInterfaceAnalyzer.cs b/src/Refit.Analyzers.Shared/RefitInterfaceAnalyzer.cs index bf82dd515..a35848828 100644 --- a/src/Refit.Analyzers.Shared/RefitInterfaceAnalyzer.cs +++ b/src/Refit.Analyzers.Shared/RefitInterfaceAnalyzer.cs @@ -19,7 +19,10 @@ public sealed class RefitInterfaceAnalyzer : DiagnosticAnalyzer DiagnosticDescriptors.InvalidRefitMember, DiagnosticDescriptors.InvalidRouteBackslash, DiagnosticDescriptors.MultipleCancellationTokens, - DiagnosticDescriptors.InvalidHeaderCollectionParameter + DiagnosticDescriptors.InvalidHeaderCollectionParameter, + DiagnosticDescriptors.GeneratedRequestBuildingFallback, + DiagnosticDescriptors.MultipleHeaderCollections, + DiagnosticDescriptors.MultipleAuthorizeParameters ]; #else CreateSupportedDiagnostics(); @@ -54,11 +57,15 @@ internal static string GetHttpPath(AttributeData? attributeData) /// The supported diagnostics. private static ImmutableArray CreateSupportedDiagnostics() { - var builder = ImmutableArray.CreateBuilder(4); + const int supportedDiagnosticCount = 7; + var builder = ImmutableArray.CreateBuilder(supportedDiagnosticCount); builder.Add(DiagnosticDescriptors.InvalidRefitMember); builder.Add(DiagnosticDescriptors.InvalidRouteBackslash); builder.Add(DiagnosticDescriptors.MultipleCancellationTokens); builder.Add(DiagnosticDescriptors.InvalidHeaderCollectionParameter); + builder.Add(DiagnosticDescriptors.GeneratedRequestBuildingFallback); + builder.Add(DiagnosticDescriptors.MultipleHeaderCollections); + builder.Add(DiagnosticDescriptors.MultipleAuthorizeParameters); return builder.MoveToImmutable(); } #endif @@ -73,30 +80,72 @@ private static void RegisterCompilationAnalysis(CompilationStartAnalysisContext return; } + // RF006 only makes sense when the source generator would actually build requests inline. + // Mirror the generator's option handling: it is on by default, off when the consumer disables + // generated request building or the generator entirely, in which case every method uses + // reflection by design and the fallback diagnostic would be pure noise. + var globalOptions = context.Options.AnalyzerConfigOptionsProvider.GlobalOptions; + var reportGeneratedRequestBuildingFallback = + GetBooleanOption(globalOptions, "RefitGeneratedRequestBuilding", defaultValue: true) + && !GetBooleanOption(globalOptions, "DisableRefitSourceGenerator", defaultValue: false); + var disposableInterface = context.Compilation.GetSpecialType(SpecialType.System_IDisposable); + var formattableInterface = context.Compilation.GetTypeByMetadataName("System.IFormattable"); + + // Discover return-type adapters the same way the generator does, so RF006 agrees an adapter-backed method is + // built inline instead of falling back to reflection. + var returnTypeAdapterInterface = Refit.Generator.Parser.ResolveReturnTypeAdapterInterface(context.Compilation); + var returnTypeAdapters = Refit.Generator.Parser.DiscoverReturnTypeAdapters( + context.Compilation, + returnTypeAdapterInterface, + context.CancellationToken); + context.RegisterSymbolAction( symbolContext => AnalyzeInterface( (INamedTypeSymbol)symbolContext.Symbol, - httpMethodAttribute, - disposableInterface, + new( + httpMethodAttribute, + disposableInterface, + formattableInterface, + returnTypeAdapterInterface, + returnTypeAdapters, + reportGeneratedRequestBuildingFallback), symbolContext.ReportDiagnostic, symbolContext.CancellationToken), SymbolKind.NamedType); } + /// Reads a boolean analyzer-config option by bare name or MSBuild build-property name. + /// The analyzer-config options. + /// The option name without the build-property prefix. + /// The value to use when the option is absent or unparsable. + /// The parsed option value. + private static bool GetBooleanOption(AnalyzerConfigOptions options, string name, bool defaultValue) + { + if (options.TryGetValue("build_property." + name, out var buildPropertyValue) + && bool.TryParse(buildPropertyValue, out var buildPropertyParsed)) + { + return buildPropertyParsed; + } + + return options.TryGetValue(name, out var analyzerConfigValue) && bool.TryParse(analyzerConfigValue, out var analyzerConfigParsed) + ? analyzerConfigParsed + : defaultValue; + } + /// Analyzes a single interface and reports Refit contract diagnostics. /// The interface symbol. - /// The Refit HTTP method base attribute. - /// The interface symbol. + /// The resolved symbols and options shared by the compilation. /// The diagnostic reporting callback. /// The cancellation token. private static void AnalyzeInterface( INamedTypeSymbol interfaceSymbol, - INamedTypeSymbol httpMethodAttribute, - INamedTypeSymbol disposableInterface, + in CompilationAnalysisState analysis, Action reportDiagnostic, CancellationToken cancellationToken) { + var httpMethodAttribute = analysis.HttpMethodAttribute; + var disposableInterface = analysis.DisposableInterface; if (interfaceSymbol.TypeKind != TypeKind.Interface) { return; @@ -120,7 +169,7 @@ private static void AnalyzeInterface( } hasRefitMethods = true; - AnalyzeRefitMethod(method, httpMethodAttribute, reportDiagnostic); + AnalyzeRefitMethod(method, analysis, reportDiagnostic); } var hasInheritedRefitMethods = HasInheritedRefitMethods(interfaceSymbol, httpMethodAttribute); @@ -145,13 +194,14 @@ private static void AnalyzeInterface( /// Reports diagnostics for invalid Refit method shapes. /// The Refit method. - /// The Refit HTTP method base attribute. + /// The resolved symbols and options shared by the compilation. /// The diagnostic reporting callback. private static void AnalyzeRefitMethod( IMethodSymbol method, - INamedTypeSymbol httpMethodAttribute, + in CompilationAnalysisState analysis, Action reportDiagnostic) { + var httpMethodAttribute = analysis.HttpMethodAttribute; var httpMethod = FindHttpMethodAttribute(method, httpMethodAttribute); var path = GetHttpPath(httpMethod); if (path.IndexOf('\\') >= 0) @@ -165,28 +215,26 @@ private static void AnalyzeRefitMethod( method.Name)); } - var cancellationTokenCount = 0; - foreach (var parameter in method.Parameters) - { - if (IsCancellationToken(parameter.Type) && cancellationTokenCount++ > 0) - { - reportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.MultipleCancellationTokens, - FirstLocation(parameter), - method.ContainingType.Name, - method.Name)); - } + ReportParameterShapeDiagnostics(method, reportDiagnostic); - if (HasHeaderCollectionAttribute(parameter) && !IsSupportedHeaderCollectionType(parameter.Type)) - { - reportDiagnostic(Diagnostic.Create( - DiagnosticDescriptors.InvalidHeaderCollectionParameter, - FirstLocation(parameter), - parameter.Name, - method.ContainingType.Name, - method.Name)); - } + // The eligibility decision is the source generator's own classifier, compiled into this assembly, + // so RF006 can never drift from what the generator actually emits. + if (!analysis.ReportGeneratedRequestBuildingFallback + || Refit.Generator.Parser.CanBuildRequestInline( + method, + httpMethodAttribute, + analysis.FormattableInterface, + analysis.ReturnTypeAdapterInterface, + analysis.ReturnTypeAdapters)) + { + return; } + + reportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.GeneratedRequestBuildingFallback, + FirstLocation(method), + method.ContainingType.Name, + method.Name)); } /// Reports diagnostics for directly declared non-Refit methods on a Refit interface. @@ -355,6 +403,92 @@ private static bool IsCancellationToken(ITypeSymbol type) => && namedType.TypeArguments[0].ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) == "global::System.Threading.CancellationToken"); + /// Reports the per-parameter duplicate and type diagnostics for a Refit method. + /// The Refit method. + /// The diagnostic reporting callback. + private static void ReportParameterShapeDiagnostics(IMethodSymbol method, Action reportDiagnostic) + { + var cancellationTokenCount = 0; + var headerCollectionCount = 0; + var authorizeCount = 0; + foreach (var parameter in method.Parameters) + { + ReportParameterShapeDiagnostics( + method, + parameter, + reportDiagnostic, + ref cancellationTokenCount, + ref headerCollectionCount, + ref authorizeCount); + } + } + + /// Reports the duplicate and type diagnostics for a single Refit method parameter. + /// The Refit method. + /// The parameter to inspect. + /// The diagnostic reporting callback. + /// The running count of cancellation-token parameters seen so far. + /// The running count of header-collection parameters seen so far. + /// The running count of authorize parameters seen so far. + private static void ReportParameterShapeDiagnostics( + IMethodSymbol method, + IParameterSymbol parameter, + Action reportDiagnostic, + ref int cancellationTokenCount, + ref int headerCollectionCount, + ref int authorizeCount) + { + if (IsCancellationToken(parameter.Type)) + { + if (cancellationTokenCount > 0) + { + reportDiagnostic(MethodShapeDiagnostic(DiagnosticDescriptors.MultipleCancellationTokens, method, parameter)); + } + + cancellationTokenCount++; + } + + if (HasHeaderCollectionAttribute(parameter)) + { + if (!IsSupportedHeaderCollectionType(parameter.Type)) + { + reportDiagnostic(Diagnostic.Create( + DiagnosticDescriptors.InvalidHeaderCollectionParameter, + FirstLocation(parameter), + parameter.Name, + method.ContainingType.Name, + method.Name)); + } + + if (headerCollectionCount > 0) + { + reportDiagnostic(MethodShapeDiagnostic(DiagnosticDescriptors.MultipleHeaderCollections, method, parameter)); + } + + headerCollectionCount++; + } + + if (!HasAuthorizeAttribute(parameter)) + { + return; + } + + if (authorizeCount > 0) + { + reportDiagnostic(MethodShapeDiagnostic(DiagnosticDescriptors.MultipleAuthorizeParameters, method, parameter)); + } + + authorizeCount++; + } + + /// Creates a method-scoped diagnostic located at a parameter. + /// The diagnostic descriptor. + /// The Refit method. + /// The parameter to locate the diagnostic at. + /// The diagnostic. + private static Diagnostic MethodShapeDiagnostic(DiagnosticDescriptor descriptor, IMethodSymbol method, IParameterSymbol parameter) => + Diagnostic.Create(descriptor, FirstLocation(parameter), method.ContainingType.Name, method.Name); + /// Determines whether a parameter has HeaderCollectionAttribute. /// The parameter to inspect. /// when the parameter has the attribute. @@ -373,6 +507,22 @@ private static bool HasHeaderCollectionAttribute(IParameterSymbol parameter) return false; } + /// Determines whether a parameter carries the [Authorize] attribute. + /// The parameter to inspect. + /// when the parameter has the attribute. + private static bool HasAuthorizeAttribute(IParameterSymbol parameter) + { + foreach (var attribute in parameter.GetAttributes()) + { + if (attribute.AttributeClass!.ToDisplayString() == "Refit.AuthorizeAttribute") + { + return true; + } + } + + return false; + } + /// Determines whether a header collection parameter matches runtime semantics. /// The parameter type. /// when the type is supported. @@ -385,4 +535,19 @@ private static bool IsSupportedHeaderCollectionType(ITypeSymbol type) => /// The first available location, or . private static Location? FirstLocation(ISymbol symbol) => symbol.Locations.FirstOrDefault(); + + /// Bundles the resolved symbols and options shared by one compilation's analysis. + /// The Refit HTTP method base attribute. + /// The interface symbol. + /// The interface symbol, if available. + /// The Refit.IReturnTypeAdapter`2 symbol, if available. + /// The discovered IReturnTypeAdapter implementations, kept in lockstep with the generator. + /// Whether the RF006 fallback diagnostic is enabled. + private readonly record struct CompilationAnalysisState( + INamedTypeSymbol HttpMethodAttribute, + INamedTypeSymbol DisposableInterface, + INamedTypeSymbol? FormattableInterface, + INamedTypeSymbol? ReturnTypeAdapterInterface, + INamedTypeSymbol[] ReturnTypeAdapters, + bool ReportGeneratedRequestBuildingFallback); } diff --git a/src/Refit.CodeFixes.Shared/RefitInterfaceCodeFixProvider.cs b/src/Refit.CodeFixes.Shared/RefitInterfaceCodeFixProvider.cs index 64464dac9..9fc5d3728 100644 --- a/src/Refit.CodeFixes.Shared/RefitInterfaceCodeFixProvider.cs +++ b/src/Refit.CodeFixes.Shared/RefitInterfaceCodeFixProvider.cs @@ -152,7 +152,8 @@ internal static async Task UseHeaderCollectionTypeAsync( /// The fixable diagnostic IDs. private static ImmutableArray CreateFixableDiagnosticIds() { - var builder = ImmutableArray.CreateBuilder(2); + const int fixableDiagnosticCount = 2; + var builder = ImmutableArray.CreateBuilder(fixableDiagnosticCount); builder.Add(DiagnosticIds.InvalidRouteBackslash); builder.Add(DiagnosticIds.InvalidHeaderCollectionParameter); return builder.MoveToImmutable(); diff --git a/src/Refit.HttpClientFactory/HttpClientFactoryCore.cs b/src/Refit.HttpClientFactory/HttpClientFactoryCore.cs index 525ee1a88..2857cb017 100644 --- a/src/Refit.HttpClientFactory/HttpClientFactoryCore.cs +++ b/src/Refit.HttpClientFactory/HttpClientFactoryCore.cs @@ -92,7 +92,10 @@ internal static IHttpClientBuilder AddRefitClientCore( /// A factory that produces the Refit settings, or null. /// A name for the underlying HTTP client, or null. /// The HTTP client builder for further configuration. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] internal static IHttpClientBuilder AddRefitClientCore< [DynamicallyAccessedMembers( @@ -111,20 +114,20 @@ internal static IHttpClientBuilder AddRefitClientCore< _ = services.AddSingleton(provider => new SettingsFor(settings?.Invoke(provider))); // register RequestBuilder - _ = services.AddSingleton(provider => + _ = services.AddSingleton(static provider => RequestBuilder.ForType(provider.GetRequiredService>().Settings)); // create HttpClientBuilder var builder = services.AddHttpClient(httpClientName ?? UniqueName.ForType()); // configure the primary handler from the supplied settings (or fall back to the default) - _ = builder.ConfigurePrimaryHttpMessageHandler(serviceProvider => + _ = builder.ConfigurePrimaryHttpMessageHandler(static serviceProvider => CreateInnerHandlerIfProvided( serviceProvider.GetRequiredService>().Settings) ?? new HttpClientHandler()); // add typed client using framework AddTypedClient - return builder.AddTypedClient((client, serviceProvider) => + return builder.AddTypedClient(static (client, serviceProvider) => RestService.For( client, serviceProvider.GetRequiredService>())); @@ -208,7 +211,10 @@ internal static IHttpClientBuilder AddKeyedRefitClientCore( /// A factory that produces the Refit settings, or null. /// A name for the underlying HTTP client, or null. /// The HTTP client builder for further configuration. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] internal static IHttpClientBuilder AddKeyedRefitClientCore< [DynamicallyAccessedMembers( @@ -267,7 +273,10 @@ internal static IHttpClientBuilder AddKeyedRefitClientCore< /// A factory that produces the Refit settings, or null. /// A name for the underlying HTTP client, or null. /// The HTTP client builder for further configuration. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] internal static IHttpClientBuilder AddRefitGeneratedClientCore( IServiceCollection services, Func? settings, @@ -283,13 +292,13 @@ internal static IHttpClientBuilder AddRefitGeneratedClientCore( var builder = services.AddHttpClient(httpClientName ?? UniqueName.ForType()); // configure the primary handler from the supplied settings (or fall back to the default) - _ = builder.ConfigurePrimaryHttpMessageHandler(serviceProvider => + _ = builder.ConfigurePrimaryHttpMessageHandler(static serviceProvider => CreateInnerHandlerIfProvided( serviceProvider.GetRequiredService>().Settings) ?? new HttpClientHandler()); // add typed client backed by the source generator only; throws clearly if no generated client exists - return builder.AddTypedClient((client, serviceProvider) => + return builder.AddTypedClient(static (client, serviceProvider) => RestService.ForGenerated( client, serviceProvider.GetRequiredService>().Settings ?? new RefitSettings())); diff --git a/src/Refit.HttpClientFactory/HttpClientFactoryExtensions.HttpClientBuilder.cs b/src/Refit.HttpClientFactory/HttpClientFactoryExtensions.HttpClientBuilder.cs index 321ad89d0..ccb5af1c5 100644 --- a/src/Refit.HttpClientFactory/HttpClientFactoryExtensions.HttpClientBuilder.cs +++ b/src/Refit.HttpClientFactory/HttpClientFactoryExtensions.HttpClientBuilder.cs @@ -77,7 +77,10 @@ public IHttpClientBuilder AddRefitClient( /// Adds a Refit client to the dependency injection container. /// The type of the Refit interface. /// The HTTP client builder for chaining. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] public IHttpClientBuilder AddRefitClient< [DynamicallyAccessedMembers( @@ -99,7 +102,10 @@ public IHttpClientBuilder AddRefitClient< /// The type of the Refit interface. /// The settings used to configure the instance. /// The HTTP client builder for chaining. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] public IHttpClientBuilder AddRefitClient< [DynamicallyAccessedMembers( @@ -122,7 +128,10 @@ public IHttpClientBuilder AddRefitClient< /// An action used to configure the Refit settings from the service provider. /// The HTTP client builder for chaining. [System.Runtime.CompilerServices.OverloadResolutionPriority(1)] - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] public IHttpClientBuilder AddRefitClient< [DynamicallyAccessedMembers( @@ -221,7 +230,10 @@ public IHttpClientBuilder AddKeyedRefitClient( /// The type of the Refit interface. /// A key used to associate with the specific Refit client instance. /// The HTTP client builder for chaining. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] public IHttpClientBuilder AddKeyedRefitClient< [DynamicallyAccessedMembers( @@ -247,7 +259,10 @@ public IHttpClientBuilder AddKeyedRefitClient< /// A key used to associate with the specific Refit client instance. /// The settings used to configure the instance. /// The HTTP client builder for chaining. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] public IHttpClientBuilder AddKeyedRefitClient< [DynamicallyAccessedMembers( @@ -276,7 +291,10 @@ public IHttpClientBuilder AddKeyedRefitClient< /// An action used to configure the Refit settings from the service provider. /// The HTTP client builder for chaining. [System.Runtime.CompilerServices.OverloadResolutionPriority(1)] - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] public IHttpClientBuilder AddKeyedRefitClient< [DynamicallyAccessedMembers( diff --git a/src/Refit.HttpClientFactory/HttpClientFactoryExtensions.ServiceCollection.cs b/src/Refit.HttpClientFactory/HttpClientFactoryExtensions.ServiceCollection.cs index 81df9e593..929435cbe 100644 --- a/src/Refit.HttpClientFactory/HttpClientFactoryExtensions.ServiceCollection.cs +++ b/src/Refit.HttpClientFactory/HttpClientFactoryExtensions.ServiceCollection.cs @@ -132,7 +132,10 @@ public IHttpClientBuilder AddRefitClient( /// Adds a Refit client to the dependency injection container. /// The type of the Refit interface. /// The HTTP client builder for chaining. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] public IHttpClientBuilder AddRefitClient< [DynamicallyAccessedMembers( @@ -154,7 +157,10 @@ public IHttpClientBuilder AddRefitClient< /// The type of the Refit interface. /// The settings used to configure the instance. /// The HTTP client builder for chaining. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] public IHttpClientBuilder AddRefitClient< [DynamicallyAccessedMembers( @@ -177,7 +183,10 @@ public IHttpClientBuilder AddRefitClient< /// The settings used to configure the instance. /// Allows the name of the underlying HttpClient to be changed. /// The HTTP client builder for chaining. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] public IHttpClientBuilder AddRefitClient< [DynamicallyAccessedMembers( @@ -202,7 +211,10 @@ public IHttpClientBuilder AddRefitClient< /// An action used to configure the Refit settings from the service provider. /// The HTTP client builder for chaining. [System.Runtime.CompilerServices.OverloadResolutionPriority(1)] - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] public IHttpClientBuilder AddRefitClient< [DynamicallyAccessedMembers( @@ -226,7 +238,10 @@ public IHttpClientBuilder AddRefitClient< /// Allows the name of the underlying HttpClient to be changed. /// The HTTP client builder for chaining. [System.Runtime.CompilerServices.OverloadResolutionPriority(1)] - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] public IHttpClientBuilder AddRefitClient< [DynamicallyAccessedMembers( @@ -384,7 +399,10 @@ public IHttpClientBuilder AddKeyedRefitClient( /// The type of the Refit interface. /// A key used to associate with the specific Refit client instance. /// The HTTP client builder for chaining. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] public IHttpClientBuilder AddKeyedRefitClient< [DynamicallyAccessedMembers( @@ -410,7 +428,10 @@ public IHttpClientBuilder AddKeyedRefitClient< /// A key used to associate with the specific Refit client instance. /// The settings used to configure the instance. /// The HTTP client builder for chaining. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] public IHttpClientBuilder AddKeyedRefitClient< [DynamicallyAccessedMembers( @@ -439,7 +460,10 @@ public IHttpClientBuilder AddKeyedRefitClient< /// The settings used to configure the instance. /// Allows the name of the underlying HttpClient to be changed. /// The HTTP client builder for chaining. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] public IHttpClientBuilder AddKeyedRefitClient< [DynamicallyAccessedMembers( @@ -469,7 +493,10 @@ public IHttpClientBuilder AddKeyedRefitClient< /// An action used to configure the Refit settings from the service provider. /// The HTTP client builder for chaining. [System.Runtime.CompilerServices.OverloadResolutionPriority(1)] - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] public IHttpClientBuilder AddKeyedRefitClient< [DynamicallyAccessedMembers( @@ -499,7 +526,10 @@ public IHttpClientBuilder AddKeyedRefitClient< /// Allows the name of the underlying HttpClient to be changed. /// The HTTP client builder for chaining. [System.Runtime.CompilerServices.OverloadResolutionPriority(1)] - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] [RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)] public IHttpClientBuilder AddKeyedRefitClient< [DynamicallyAccessedMembers( @@ -532,7 +562,10 @@ public IHttpClientBuilder AddKeyedRefitClient< /// /// The type of the Refit interface. /// The HTTP client builder for chaining. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] public IHttpClientBuilder AddRefitGeneratedClient() where T : class { @@ -548,7 +581,10 @@ public IHttpClientBuilder AddRefitGeneratedClient() /// The type of the Refit interface. /// The settings used to configure the instance. /// The HTTP client builder for chaining. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] public IHttpClientBuilder AddRefitGeneratedClient(RefitSettings? settings) where T : class { @@ -565,7 +601,10 @@ public IHttpClientBuilder AddRefitGeneratedClient(RefitSettings? settings) /// The settings used to configure the instance. /// Allows the name of the underlying HttpClient to be changed. /// The HTTP client builder for chaining. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] public IHttpClientBuilder AddRefitGeneratedClient( RefitSettings? settings, string? httpClientName) @@ -584,7 +623,10 @@ public IHttpClientBuilder AddRefitGeneratedClient( /// An action used to configure the Refit settings from the service provider. /// The HTTP client builder for chaining. [System.Runtime.CompilerServices.OverloadResolutionPriority(1)] - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] public IHttpClientBuilder AddRefitGeneratedClient( Func? settingsAction) where T : class @@ -603,7 +645,10 @@ public IHttpClientBuilder AddRefitGeneratedClient( /// Allows the name of the underlying HttpClient to be changed. /// The HTTP client builder for chaining. [System.Runtime.CompilerServices.OverloadResolutionPriority(1)] - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "The Refit interface type is intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The Refit interface type is intentionally specified explicitly by callers.")] public IHttpClientBuilder AddRefitGeneratedClient( Func? settingsAction, string? httpClientName) diff --git a/src/Refit.HttpClientFactory/SettingsFor{T}.cs b/src/Refit.HttpClientFactory/SettingsFor{T}.cs index 5d7b73ad9..bedbd8e29 100644 --- a/src/Refit.HttpClientFactory/SettingsFor{T}.cs +++ b/src/Refit.HttpClientFactory/SettingsFor{T}.cs @@ -9,10 +9,6 @@ namespace Refit; /// The Refit interface type the settings belong to. /// Initializes a new instance of the class. /// The settings. -[SuppressMessage( - "Major Code Smell", - "S2326:Unused type parameters should be removed", - Justification = "T is the DI service identity: SettingsFor is registered and resolved by closed generic type to key settings to a Refit interface.")] [SuppressMessage( "StyleSharp", "SST1452:Unused type parameters should be removed", diff --git a/src/Refit.NativeAotSmoke/INativeAotApi.cs b/src/Refit.NativeAotSmoke/INativeAotApi.cs index 0e92092e7..c8041a3de 100644 --- a/src/Refit.NativeAotSmoke/INativeAotApi.cs +++ b/src/Refit.NativeAotSmoke/INativeAotApi.cs @@ -22,4 +22,36 @@ public interface INativeAotApi /// The service status response. [Get("/status")] Task> GetStatusAsync(); + + /// Round-trips an item through a generic method, proving generic methods generate inline (the type + /// parameter flows through to the generated runner for both the JSON body and the result, with no reflection) + /// and stay Native AOT clean when closed over a concrete type. + /// The request and response type. + /// The item to send. + /// The round-tripped item. + [Post("/echo")] + Task EchoAsync([Body] T item); + + /// Searches with generated inline query construction covering every supported shape. + /// A value requiring escaping. + /// A nullable page number. + /// Identifiers expanded as repeated pairs. + /// An enum resolved at compile time. + /// A valueless query flag. + /// A caller-encoded value passed through verbatim. + /// The service status response. + [Get("/search")] + Task SearchAsync( + string q, + int? page, + [Query(CollectionFormat.Multi)] int[] ids, + SmokeSort sort, + [QueryName] string flag, + [Encoded] string cursor); + + /// An intentionally reflection-backed method shape proving the generated fallback builds cleanly + /// under full trimming with IL2026/IL3050 promoted to errors (reactiveui/refit#2200). It is never invoked. + /// An observable sequence of raw responses. + [Get("/legacy")] + IObservable ObserveLegacy(); } diff --git a/src/Refit.NativeAotSmoke/NativeAotSmokeHandler.cs b/src/Refit.NativeAotSmoke/NativeAotSmokeHandler.cs index 66a68af47..170b532db 100644 --- a/src/Refit.NativeAotSmoke/NativeAotSmokeHandler.cs +++ b/src/Refit.NativeAotSmoke/NativeAotSmokeHandler.cs @@ -15,6 +15,9 @@ internal sealed class NativeAotSmokeHandler : HttpMessageHandler /// Gets a value indicating whether a URL-encoded form body was observed. public bool SawFormBody { get; private set; } + /// Gets a value indicating whether the generated query string matched the expected shape. + public bool SawExpectedQuery { get; private set; } + /// protected override async Task SendAsync( HttpRequestMessage request, @@ -30,6 +33,11 @@ protected override async Task SendAsync( return Json("""{"id":42,"title":"prove native aot"}"""); } + if (request.RequestUri?.AbsolutePath == "/echo") + { + return Json("""{"id":42,"title":"generic inline"}"""); + } + if (request.RequestUri?.AbsolutePath == "/forms") { var body = request.Content is null @@ -41,9 +49,7 @@ protected override async Task SendAsync( return new(HttpStatusCode.OK) { Content = new StringContent("accepted", Encoding.UTF8, "text/plain") }; } - return request.RequestUri?.AbsolutePath == "/status" - ? Json("""{"name":"native-aot"}""") - : new(HttpStatusCode.NotFound); + return HandleGetRequest(request); } /// Builds an OK response with the given JSON content. @@ -51,4 +57,21 @@ protected override async Task SendAsync( /// The constructed JSON response message. private static HttpResponseMessage Json(string content) => new(HttpStatusCode.OK) { Content = new StringContent(content, Encoding.UTF8, "application/json") }; + + /// Serves the bodyless GET endpoints. + /// The request to serve. + /// The canned response. + private HttpResponseMessage HandleGetRequest(HttpRequestMessage request) + { + if (request.RequestUri?.AbsolutePath == "/search") + { + SawExpectedQuery = request.RequestUri.PathAndQuery + == "/search?q=a%20b&page=3&ids=1&ids=2&sort=date-desc&ready&cursor=x%2Fy"; + return Json("""{"name":"native-aot"}"""); + } + + return request.RequestUri?.AbsolutePath == "/status" + ? Json("""{"name":"native-aot"}""") + : new(HttpStatusCode.NotFound); + } } diff --git a/src/Refit.NativeAotSmoke/Program.cs b/src/Refit.NativeAotSmoke/Program.cs index a96fa729e..e758f49c5 100644 --- a/src/Refit.NativeAotSmoke/Program.cs +++ b/src/Refit.NativeAotSmoke/Program.cs @@ -7,6 +7,12 @@ const int ExpectedTodoId = 42; +const int FormCount = 2; + +const int SearchPage = 3; + +const int SecondSearchId = 2; + var handler = new NativeAotSmokeHandler(); using var client = new HttpClient(handler) { BaseAddress = new("https://aot.refit.test") }; @@ -25,7 +31,7 @@ throw new InvalidOperationException("The AOT POST response was not deserialized correctly."); } -var formResponse = await api.SubmitFormAsync(new("Ada", 2)).ConfigureAwait(false); +var formResponse = await api.SubmitFormAsync(new("Ada", FormCount)).ConfigureAwait(false); if (formResponse != "accepted") { @@ -44,9 +50,35 @@ throw new InvalidOperationException("The AOT request body was not serialized through Refit."); } +// A generic method closed over a concrete type: generated inline (generic JSON body + SendAsync), no reflection. +var echoed = await api.EchoAsync(new("generic inline")).ConfigureAwait(false); + +if (echoed.Id != ExpectedTodoId || echoed.Title != "generic inline") +{ + throw new InvalidOperationException("The AOT generic method result was not deserialized correctly."); +} + +var searched = await api + .SearchAsync("a b", SearchPage, [1, SecondSearchId], SmokeSort.DateDescending, "ready", "x%2Fy") + .ConfigureAwait(false); + +if (searched.Name != "native-aot" || !handler.SawExpectedQuery) +{ + throw new InvalidOperationException("The AOT generated query string was not constructed correctly."); +} + if (!handler.SawFormBody) { throw new InvalidOperationException("The AOT URL-encoded request body was not serialized through generated Refit code."); } Console.WriteLine("Native AOT Refit smoke test passed."); + +/// The generated top-level program's declaring type, sealed so the JIT can devirtualize its members. +internal sealed partial class Program +{ + /// Initializes a new instance of the class. Unused; the entry point is the generated top-level Main. + private Program() + { + } +} diff --git a/src/Refit.NativeAotSmoke/SmokeApiFactory.cs b/src/Refit.NativeAotSmoke/SmokeApiFactory.cs index d82054fb7..ed978d54c 100644 --- a/src/Refit.NativeAotSmoke/SmokeApiFactory.cs +++ b/src/Refit.NativeAotSmoke/SmokeApiFactory.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 System.Text.Json; namespace Refit.NativeAotSmoke; @@ -9,32 +8,14 @@ namespace Refit.NativeAotSmoke; /// Builds the Refit API used by the native AOT smoke test. internal static class SmokeApiFactory { - /// Explains why the trimming warning is suppressed for the generator-backed call. - private const string TrimSafeJustification = - "The Refit source generator (referenced as an analyzer) registers a generated factory for " - + "INativeAotApi, so RestService.For resolves it without reflection. This project builds with " - + "PublishAot to prove that path stays AOT-safe."; - - /// Explains why the AOT warning is suppressed for the generator-backed call. - private const string AotSafeJustification = - "The Refit source generator (referenced as an analyzer) emits the request builder for " - + "INativeAotApi, so no runtime code generation occurs. This project builds with PublishAot " - + "to prove that path stays AOT-safe."; - /// Creates the implementation backed by the source generator. + /// The generated-only entry point never touches the reflection request builder, so this project + /// does not reference the Refit.Reflection package and needs no trim/AOT suppressions. /// The the implementation will use. /// The source-generated JSON serializer options. /// An AOT-safe implementation of . - [UnconditionalSuppressMessage( - "Trimming", - "IL2026:RequiresUnreferencedCode", - Justification = TrimSafeJustification)] - [UnconditionalSuppressMessage( - "AOT", - "IL3050:RequiresDynamicCode", - Justification = AotSafeJustification)] public static INativeAotApi Create(HttpClient client, JsonSerializerOptions jsonOptions) => - RestService.For( + RestService.ForGenerated( client, new RefitSettings(new SystemTextJsonContentSerializer(jsonOptions))); } diff --git a/src/Refit.NativeAotSmoke/SmokeSort.cs b/src/Refit.NativeAotSmoke/SmokeSort.cs new file mode 100644 index 000000000..70b5e15af --- /dev/null +++ b/src/Refit.NativeAotSmoke/SmokeSort.cs @@ -0,0 +1,17 @@ +// 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.Runtime.Serialization; + +namespace Refit.NativeAotSmoke; + +/// The sort order used by the native AOT smoke test's query scenario. +public enum SmokeSort +{ + /// Sort by date, newest first. + [EnumMember(Value = "date-desc")] + DateDescending, + + /// Sort by name. + Name, +} diff --git a/src/Refit.Newtonsoft.Json/NewtonsoftJsonContentSerializer.cs b/src/Refit.Newtonsoft.Json/NewtonsoftJsonContentSerializer.cs index f3d76c21c..b297098ec 100644 --- a/src/Refit.Newtonsoft.Json/NewtonsoftJsonContentSerializer.cs +++ b/src/Refit.Newtonsoft.Json/NewtonsoftJsonContentSerializer.cs @@ -72,8 +72,8 @@ public HttpContent ToHttpContent(T item) => "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.")] [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Implements IHttpContentSerializer.FromHttpContentAsync; the type parameter is the deserialization target and cannot be inferred from arguments.")] public async Task FromHttpContentAsync( HttpContent content, @@ -119,8 +119,8 @@ public HttpContent ToHttpContent(T item) => "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.")] [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Implements ISynchronousContentDeserializer.DeserializeFromString; the type parameter is the deserialization target and cannot be inferred from arguments.")] public T? DeserializeFromString(string content) => JsonConvert.DeserializeObject(content, _jsonSerializerSettings.Value); diff --git a/src/Refit/CachedRequestBuilderImplementation.cs b/src/Refit.Reflection/CachedRequestBuilderImplementation.cs similarity index 100% rename from src/Refit/CachedRequestBuilderImplementation.cs rename to src/Refit.Reflection/CachedRequestBuilderImplementation.cs diff --git a/src/Refit/CachedRequestBuilderImplementation{T}.cs b/src/Refit.Reflection/CachedRequestBuilderImplementation{T}.cs similarity index 100% rename from src/Refit/CachedRequestBuilderImplementation{T}.cs rename to src/Refit.Reflection/CachedRequestBuilderImplementation{T}.cs diff --git a/src/Refit/CloseGenericMethodKey.cs b/src/Refit.Reflection/CloseGenericMethodKey.cs similarity index 100% rename from src/Refit/CloseGenericMethodKey.cs rename to src/Refit.Reflection/CloseGenericMethodKey.cs diff --git a/src/Refit/MethodTableKey.cs b/src/Refit.Reflection/MethodTableKey.cs similarity index 100% rename from src/Refit/MethodTableKey.cs rename to src/Refit.Reflection/MethodTableKey.cs diff --git a/src/Refit/ParameterFragment.cs b/src/Refit.Reflection/ParameterFragment.cs similarity index 100% rename from src/Refit/ParameterFragment.cs rename to src/Refit.Reflection/ParameterFragment.cs diff --git a/src/Refit.Reflection/PublicAPI/net10.0/PublicAPI.Shipped.txt b/src/Refit.Reflection/PublicAPI/net10.0/PublicAPI.Shipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net10.0/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Refit.Reflection/PublicAPI/net10.0/PublicAPI.Unshipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net11.0/PublicAPI.Shipped.txt b/src/Refit.Reflection/PublicAPI/net11.0/PublicAPI.Shipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net11.0/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net11.0/PublicAPI.Unshipped.txt b/src/Refit.Reflection/PublicAPI/net11.0/PublicAPI.Unshipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net11.0/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net462/PublicAPI.Shipped.txt b/src/Refit.Reflection/PublicAPI/net462/PublicAPI.Shipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net462/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net462/PublicAPI.Unshipped.txt b/src/Refit.Reflection/PublicAPI/net462/PublicAPI.Unshipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net462/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net470/PublicAPI.Shipped.txt b/src/Refit.Reflection/PublicAPI/net470/PublicAPI.Shipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net470/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net470/PublicAPI.Unshipped.txt b/src/Refit.Reflection/PublicAPI/net470/PublicAPI.Unshipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net470/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net471/PublicAPI.Shipped.txt b/src/Refit.Reflection/PublicAPI/net471/PublicAPI.Shipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net471/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net471/PublicAPI.Unshipped.txt b/src/Refit.Reflection/PublicAPI/net471/PublicAPI.Unshipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net471/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net472/PublicAPI.Shipped.txt b/src/Refit.Reflection/PublicAPI/net472/PublicAPI.Shipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net472/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net472/PublicAPI.Unshipped.txt b/src/Refit.Reflection/PublicAPI/net472/PublicAPI.Unshipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net48/PublicAPI.Shipped.txt b/src/Refit.Reflection/PublicAPI/net48/PublicAPI.Shipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net48/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net48/PublicAPI.Unshipped.txt b/src/Refit.Reflection/PublicAPI/net48/PublicAPI.Unshipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net48/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net481/PublicAPI.Shipped.txt b/src/Refit.Reflection/PublicAPI/net481/PublicAPI.Shipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net481/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net481/PublicAPI.Unshipped.txt b/src/Refit.Reflection/PublicAPI/net481/PublicAPI.Unshipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net481/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net8.0/PublicAPI.Shipped.txt b/src/Refit.Reflection/PublicAPI/net8.0/PublicAPI.Shipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net8.0/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/src/Refit.Reflection/PublicAPI/net8.0/PublicAPI.Unshipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net9.0/PublicAPI.Shipped.txt b/src/Refit.Reflection/PublicAPI/net9.0/PublicAPI.Shipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net9.0/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/src/Refit.Reflection/PublicAPI/net9.0/PublicAPI.Unshipped.txt new file mode 100644 index 000000000..7dc5c5811 --- /dev/null +++ b/src/Refit.Reflection/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Refit.Reflection/QueryMapEntry.cs b/src/Refit.Reflection/QueryMapEntry.cs new file mode 100644 index 000000000..342495d40 --- /dev/null +++ b/src/Refit.Reflection/QueryMapEntry.cs @@ -0,0 +1,10 @@ +// 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; + +/// One flattened key/value produced while expanding an object into query-map entries. +/// The flattened query key, before prefixing. +/// The raw property or element value. +internal readonly record struct QueryMapEntry(string Key, object? Value); diff --git a/src/Refit.Reflection/QueryParameterEntry.cs b/src/Refit.Reflection/QueryParameterEntry.cs new file mode 100644 index 000000000..6c4565e4c --- /dev/null +++ b/src/Refit.Reflection/QueryParameterEntry.cs @@ -0,0 +1,10 @@ +// 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; + +/// One pending query-string entry collected while building a request. +/// The query key, unescaped. +/// The formatted value, or to omit the entry. +internal readonly record struct QueryParameterEntry(string Key, string? Value); diff --git a/src/Refit.Reflection/Refit.Reflection.csproj b/src/Refit.Reflection/Refit.Reflection.csproj new file mode 100644 index 000000000..ec17e263a --- /dev/null +++ b/src/Refit.Reflection/Refit.Reflection.csproj @@ -0,0 +1,47 @@ + + + + Refit.Reflection ($(TargetFramework)) + The opt-in reflection request builder for Refit. Install this package to use RestService.For / AddRefitClient with interface shapes that the Refit source generator cannot build inline. Applications that only use generated clients never need it, and stay trimmable and Native AOT friendly without it. + refit;rest;http;httpclient;api-client;typed-client;reflection + $(RefitTargets) + Refit + + enable + + + + + + + + + + + + + + + + + + + true + link + true + true + + + + + + + + + + + + + diff --git a/src/Refit/RequestBuilderFactory.cs b/src/Refit.Reflection/RequestBuilderFactory.cs similarity index 94% rename from src/Refit/RequestBuilderFactory.cs rename to src/Refit.Reflection/RequestBuilderFactory.cs index 042146882..b7314aaef 100644 --- a/src/Refit/RequestBuilderFactory.cs +++ b/src/Refit.Reflection/RequestBuilderFactory.cs @@ -10,8 +10,8 @@ internal class RequestBuilderFactory : IRequestBuilderFactory { /// [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresUnreferencedCode("Building requests from reflected interface methods requires interface and request object metadata to be available at runtime.")] public IRequestBuilder Create< diff --git a/src/Refit/RequestBuilderImplementation.Execution.cs b/src/Refit.Reflection/RequestBuilderImplementation.Execution.cs similarity index 95% rename from src/Refit/RequestBuilderImplementation.Execution.cs rename to src/Refit.Reflection/RequestBuilderImplementation.Execution.cs index 53cf56181..7b924796d 100644 --- a/src/Refit/RequestBuilderImplementation.Execution.cs +++ b/src/Refit.Reflection/RequestBuilderImplementation.Execution.cs @@ -17,8 +17,8 @@ internal partial class RequestBuilderImplementation /// The linked cancellation source, disposed when enumeration finishes. /// The streamed elements. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")] internal async IAsyncEnumerable StreamAsyncEnumerableRequestAsync( @@ -117,8 +117,8 @@ await RequestExecutionHelpers.SendVoidAsync( /// A token to cancel the request. /// The deserialized result, or default when there is no content. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")] private async Task ExecuteRequestAsync( @@ -154,8 +154,8 @@ await RequestExecutionHelpers.SendVoidAsync( /// , which is covered directly. /// [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")] [ExcludeFromCodeCoverage] @@ -181,8 +181,8 @@ await RequestExecutionHelpers.SendVoidAsync( /// The rest method to build a delegate for. /// A delegate that sends the request with a cancellation token. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")] private Func> BuildCancellableTaskFuncForMethod( @@ -218,8 +218,8 @@ await RequestExecutionHelpers.SendVoidAsync( /// A token to cancel the request. /// The deserialized result, or default when there is no content. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private Task SendAndProcessResponseAsync( HttpClient client, diff --git a/src/Refit.Reflection/RequestBuilderImplementation.Payload.cs b/src/Refit.Reflection/RequestBuilderImplementation.Payload.cs new file mode 100644 index 000000000..b80784ac8 --- /dev/null +++ b/src/Refit.Reflection/RequestBuilderImplementation.Payload.cs @@ -0,0 +1,467 @@ +// 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.Reflection; +using System.Text; + +namespace Refit; + +/// Body, query and multipart payload assembly for . +internal partial class RequestBuilderImplementation +{ + /// Sets the request content from the body parameter using the configured serialization method. + /// The rest method being invoked. + /// The body argument value. + /// The request message to populate. + [RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")] + private void AddBodyToRequest(RestMethodInfoInternal restMethod, object param, HttpRequestMessage ret) + { + if (param is HttpContent httpContentParam) + { + ret.Content = httpContentParam; + return; + } + + if (param is Stream streamParam) + { + ret.Content = new StreamContent(streamParam); + return; + } + + // Default sends raw strings + if (restMethod.BodyParameterInfo!.Item1 == BodySerializationMethod.Default + && param is string stringParam) + { + ret.Content = new StringContent(stringParam); + return; + } + + switch (restMethod.BodyParameterInfo.Item1) + { + case BodySerializationMethod.UrlEncoded: + { + ret.Content = param is string str + ? new StringContent( + StringHelpers.EscapeDataString(str), + Encoding.UTF8, + "application/x-www-form-urlencoded") + : new FormUrlEncodedContent(new FormValueMultimap(param, _settings)); + break; + } + + case BodySerializationMethod.JsonLines: + { + ret.Content = new JsonLinesContent(AsJsonLinesSequence(param), _serializer); + break; + } + + case BodySerializationMethod.Default or BodySerializationMethod.Serialized: + { + AddSerializedBodyToRequest(restMethod, param, ret); + break; + } + + default: + { + // The obsolete legacy JSON serialization method must still serialize, because + // already-compiled callers can pass it. Treating it as Default would incorrectly + // send string bodies as raw text. It is matched by value rather than by name so + // this file never references the obsolete member. + if (GeneratedRequestRunner.IsObsoleteJsonSerializationMethod(restMethod.BodyParameterInfo.Item1)) + { + AddSerializedBodyToRequest(restMethod, param, ret); + } + + break; + } + } + } + + /// Sets the request content from a serialized body, optionally streaming it. + /// The rest method being invoked. + /// The body argument value. + /// The request message to populate. + [RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")] + private void AddSerializedBodyToRequest(RestMethodInfoInternal restMethod, object param, HttpRequestMessage ret) + { + var declaredBodyType = restMethod.ParameterInfoArray[ + restMethod.BodyParameterInfo!.Item3].ParameterType; + + // Synchronous serialization lets the source-gen fast-path engage: Buffered produces a ByteArrayContent up + // front, Streamed writes through a Utf8JsonWriter on the request stream without buffering the whole body. + if (_settings.RequestBodySerialization != RequestBodySerializationMode.Default + && _serializer is ISynchronousContentSerializer syncSerializer) + { + ret.Content = _settings.RequestBodySerialization == RequestBodySerializationMode.Streamed + ? SerializeBodyStreaming(syncSerializer, param, declaredBodyType) + : SerializeBodySynchronously(syncSerializer, param, declaredBodyType); + return; + } + + var content = SerializeBody(_serializer, param, declaredBodyType); + + if (restMethod.BodyParameterInfo.Item2) + { + ret.Content = content; + return; + } + + ret.Content = new PushStreamContent( + async (stream, _, _) => + { +#if NET8_0_OR_GREATER + await using (stream.ConfigureAwait(false)) +#else + using (stream) +#endif + { + await content + .CopyToAsync(stream) + .ConfigureAwait(false); + } + }, + content.Headers.ContentType); + } + + /// Adds query-string parameters for a single argument to the pending query list. + /// The rest method being invoked. + /// The query attribute on the parameter, if any. + /// The argument value. + /// The list of query parameters being built. + /// The index of the parameter. + /// Optional parameter info for property-bound parameters. + private void AddQueryParameters( + RestMethodInfoInternal restMethod, + QueryAttribute? queryAttribute, + object param, + List queryParamsToAdd, + int i, + RestMethodParameterInfo? parameterInfo) + { + var attr = queryAttribute ?? DefaultQueryAttribute; + + // TreatAsString, or an explicitly empty Format, serializes the value via ToString() under the + // parameter name instead of flattening a complex object's public properties into the query. + if (attr.TreatAsString || attr.Format is { Length: 0 }) + { + AppendQueryParameter( + queryParamsToAdd, + param.ToString(), + restMethod.ParameterInfoArray[i], + restMethod.QueryParameterMap[i], + attr); + return; + } + + if (DoNotConvertToQueryMap(param)) + { + AppendQueryParameter( + queryParamsToAdd, + param, + restMethod.ParameterInfoArray[i], + restMethod.QueryParameterMap[i], + attr); + return; + } + + var parameterCollectionFormat = attr.IsCollectionFormatSpecified + ? attr.CollectionFormat + : (CollectionFormat?)null; + 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; + AppendQueryParameter( + queryParamsToAdd, + kvp.Value, + restMethod.ParameterInfoArray[i], + path, + attr); + } + } + + /// Adds one (or each enumerated) multipart part for a single argument. + /// The rest method being invoked. + /// The index of the parameter. + /// The argument value, which may be a single item or an enumerable. + /// The multipart content to add to. + private void AddMultiPart( + RestMethodInfoInternal restMethod, + int i, + object param, + MultipartFormDataContent? multiPartContent) + { + // we are in a multipart method, add the part to the content + // the parameter name should be either the attachment name or the parameter name (as fallback) + string itemName; + string parameterName; + + if (!restMethod.AttachmentNameMap.TryGetValue(i, out var attachment)) + { + itemName = restMethod.QueryParameterMap[i]; + parameterName = itemName; + } + else + { + itemName = attachment.Item1; + parameterName = attachment.Item2; + } + + // Check to see if it's an IEnumerable + if (param is IEnumerable enumerable) + { + foreach (var item in enumerable) + { + AddMultipartItem(multiPartContent!, itemName, parameterName, item); + } + } + else + { + AddMultipartItem(multiPartContent!, itemName, parameterName, param); + } + } + + /// Adds a single value to a multipart form as the appropriate content type. + /// The multipart content to add to. + /// The file name to use for file-like parts. + /// The form field name for the part. + /// The value to add. + private void AddMultipartItem( + MultipartFormDataContent multiPartContent, + string fileName, + string parameterName, + object itemValue) + { + if (itemValue is HttpContent content) + { + multiPartContent.Add(content); + return; + } + + if (itemValue is MultipartItem multipartItem) + { + var httpContent = multipartItem.ToContent(); + multiPartContent.Add( + httpContent, + multipartItem.Name ?? parameterName, + string.IsNullOrEmpty(multipartItem.FileName) ? fileName : multipartItem.FileName); + return; + } + + if (itemValue is Stream streamValue) + { + var streamContent = new StreamContent(streamValue); + multiPartContent.Add(streamContent, parameterName, fileName); + return; + } + + if (itemValue is string stringValue) + { + multiPartContent.Add(new StringContent(stringValue), parameterName); + return; + } + + if (itemValue is FileInfo fileInfoValue) + { + var fileContent = new StreamContent(fileInfoValue.OpenRead()); + multiPartContent.Add(fileContent, parameterName, fileInfoValue.Name); + return; + } + + if (itemValue is byte[] byteArrayValue) + { + var fileContent = new ByteArrayContent(byteArrayValue); + multiPartContent.Add(fileContent, parameterName, fileName); + return; + } + + AddSerializedMultipartItem(multiPartContent, fileName, parameterName, itemValue); + } + + /// Adds a multipart part by serializing the value, throwing a descriptive error on failure. + /// The multipart content to add to. + /// The file name used in the error message. + /// The form field name for the part. + /// The value to serialize and add. + private void AddSerializedMultipartItem( + MultipartFormDataContent multiPartContent, + string fileName, + string parameterName, + object itemValue) + { + // Date/time and Guid values are wrapped in quotes by the JSON serializer (e.g. ""), + // which servers reject when reading a multipart form field. Send them as their plain text + // representation instead. Numbers, booleans and enums are intentionally left to the + // serializer to preserve existing behavior. + if (itemValue is Guid or DateTime or DateTimeOffset or TimeSpan +#if NET6_0_OR_GREATER + or DateOnly or TimeOnly +#endif + ) + { + var formatted = _settings.FormUrlEncodedParameterFormatter.Format(itemValue, null); + multiPartContent.Add(new StringContent(formatted ?? string.Empty), parameterName); + return; + } + + // Fallback to serializer + Exception e; + try + { + multiPartContent.Add( + _settings.ContentSerializer.ToHttpContent(itemValue), + parameterName); + return; + } + catch (Exception ex) + { + // Eat this since we're about to throw as a fallback anyway + e = ex; + } + + throw new ArgumentException( + $"Unexpected parameter type in a Multipart request. Parameter {fileName} is of type {itemValue.GetType().Name}, " + + "whereas allowed types are String, Stream, FileInfo, Byte array and anything that's JSON serializable", + nameof(itemValue), + e); + } + + /// 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. + private void AppendQueryParameter( + List queryParamsToAdd, + object? param, + ParameterInfo parameterInfo, + string queryPath, + QueryAttribute queryAttribute) + { + if (param is not string and IEnumerable paramValues) + { + foreach (var value in ParseEnumerableQueryParameterValue( + paramValues, + parameterInfo, + parameterInfo.ParameterType, + queryAttribute)) + { + queryParamsToAdd.Add(new(queryPath, value)); + } + + return; + } + + queryParamsToAdd.Add( + new( + queryPath, + _settings.UrlParameterFormatter.Format( + param, + parameterInfo, + parameterInfo.ParameterType))); + } + + /// Formats an enumerable parameter value according to the effective collection format. + /// The enumerable values to format. + /// The attribute provider for the parameter or property. + /// The element type used for formatting. + /// The query attribute governing the collection format, if any. + /// The collection format to use when none is specified. + /// The formatted query values. + private IEnumerable ParseEnumerableQueryParameterValue( + IEnumerable paramValues, + ICustomAttributeProvider customAttributeProvider, + Type type, + QueryAttribute? queryAttribute, + CollectionFormat? fallbackCollectionFormat = null) + { + // Precedence: the property's own [Query] format wins; otherwise the format + // supplied by the enclosing parameter's [Query] attribute (if any); finally + // the global RefitSettings default. + var collectionFormat = + queryAttribute?.IsCollectionFormatSpecified == true + ? queryAttribute.CollectionFormat + : fallbackCollectionFormat ?? _settings.CollectionFormat; + + if (collectionFormat == CollectionFormat.Multi) + { + foreach (var paramValue in paramValues) + { + yield return _settings.UrlParameterFormatter.Format( + paramValue, + customAttributeProvider, + type); + } + + yield break; + } + + var delimiter = + collectionFormat switch + { + CollectionFormat.Ssv => " ", + CollectionFormat.Tsv => "\t", + CollectionFormat.Pipes => "|", + _ => "," + }; + + // Missing a "default" clause was preventing the collection from serializing at all, as it was hitting "continue" thus causing an off-by-one error + 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. + [SuppressMessage( + "Correctness", + "SST2410:A created disposable is never 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( + enumerator.Current, + customAttributeProvider, + type)); + } + + return builder.ToString(); + } + finally + { + (enumerator as IDisposable)?.Dispose(); + } + } +} diff --git a/src/Refit/RequestBuilderImplementation.QueryAndHeaders.Helpers.cs b/src/Refit.Reflection/RequestBuilderImplementation.QueryAndHeaders.Helpers.cs similarity index 96% rename from src/Refit/RequestBuilderImplementation.QueryAndHeaders.Helpers.cs rename to src/Refit.Reflection/RequestBuilderImplementation.QueryAndHeaders.Helpers.cs index 6aa25168e..490c83055 100644 --- a/src/Refit/RequestBuilderImplementation.QueryAndHeaders.Helpers.cs +++ b/src/Refit.Reflection/RequestBuilderImplementation.QueryAndHeaders.Helpers.cs @@ -109,6 +109,7 @@ internal static void SetHeader(HttpRequestMessage request, string name, string? /// Determines whether a type is a simple string or type. /// The type to inspect; a nullable value type is unwrapped first. /// if the type formats directly into a query value. + [ExcludeFromCodeCoverage] // The CultureInfo query-value arm needs a settable-collection value that SST2305 forbids, so the branch cannot be covered by a test. private static bool ShouldReturn(Type type) => Nullable.GetUnderlyingType(type) is { } underlyingType ? ShouldReturn(underlyingType) diff --git a/src/Refit/RequestBuilderImplementation.QueryAndHeaders.cs b/src/Refit.Reflection/RequestBuilderImplementation.QueryAndHeaders.cs similarity index 94% rename from src/Refit/RequestBuilderImplementation.QueryAndHeaders.cs rename to src/Refit.Reflection/RequestBuilderImplementation.QueryAndHeaders.cs index 6b942fb15..60a91ac2a 100644 --- a/src/Refit/RequestBuilderImplementation.QueryAndHeaders.cs +++ b/src/Refit.Reflection/RequestBuilderImplementation.QueryAndHeaders.cs @@ -27,7 +27,7 @@ private static bool ShouldSkipQueryProperty(PropertyInfo propertyInfo, RestMetho // would otherwise be wrongly emitted as a duplicate query parameter. return ShouldIgnorePropertyInQueryMap(propertyInfo) || (parameterInfo is { IsObjectPropertyParameter: true } - && parameterInfo.ParameterProperties.Exists(x => x.PropertyInfo.Name == propertyInfo.Name)); + && parameterInfo.ParameterProperties.Exists(x => x.PropertyChain[0].Name == propertyInfo.Name)); } /// Determines whether a property is marked to be ignored during serialization. @@ -78,7 +78,7 @@ private static void AddHeadersToRequest(Dictionary? headersToAd /// Extracts any query parameters already present on the URI into the pending list. /// The URI builder whose query is read. /// The pending query parameter list, created if needed. - private static void ParseExistingQueryString(UriBuilder uri, ref List>? queryParamsToAdd) => + private static void ParseExistingQueryString(UriBuilder uri, ref List? queryParamsToAdd) => ParseQueryStringInto(uri.Query, ref queryParamsToAdd); /// Assigns the request URI as a bare relative reference so the merges it @@ -89,7 +89,7 @@ private static void ParseExistingQueryString(UriBuilder uri, ref List>? queryParamsToAdd) + List? queryParamsToAdd) { var path = urlTarget; var queryIndex = urlTarget.IndexOf('?'); @@ -109,7 +109,7 @@ private static void AssignRequestUriRfc3986( /// Parses a raw query string into the pending query parameter list. /// The raw query string, with or without a leading '?'. /// The pending query parameter list, created if needed. - private static void ParseQueryStringInto(string? queryString, ref List>? queryParamsToAdd) + private static void ParseQueryStringInto(string? queryString, ref List? queryParamsToAdd) { if (string.IsNullOrEmpty(queryString)) { @@ -126,8 +126,9 @@ private static void ParseQueryStringInto(string? queryString, ref ListThe query parameters to encode. /// The encoded query string. [SuppressMessage( - "Major Code Smell", - "S2930:\"IDisposables\" should be disposed", + "Correctness", + "SST2410:A created disposable is never disposed", Justification = "ValueStringBuilder.ToString() disposes the builder and returns its pooled buffer; Dispose is idempotent.")] - private static string CreateQueryString(List> queryParamsToAdd) + private static string CreateQueryString(List queryParamsToAdd) { var vsb = new ValueStringBuilder(stackalloc char[StackallocThreshold]); var firstQuery = true; @@ -289,7 +290,7 @@ 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. - private List> BuildQueryMap( + private List BuildQueryMap( object @object, string? delimiter = null, RestMethodParameterInfo? parameterInfo = null, @@ -300,7 +301,7 @@ private void AddVersionToRequest(HttpRequestMessage ret) return BuildQueryMap(idictionary, delimiter, collectionFormat); } - var kvps = new List>(); + var kvps = new List(); var props = GetCachedQueryProperties(@object.GetType()); for (var i = 0; i < props.Length; i++) @@ -317,12 +318,12 @@ private void AddVersionToRequest(HttpRequestMessage ret) /// The delimiter used between nested keys. /// The collection format for enumerable values. /// The query-string key/value pairs. - private List> BuildQueryMap( + private List BuildQueryMap( IDictionary dictionary, string? delimiter = null, CollectionFormat? collectionFormat = null) { - var kvps = new List>(); + var kvps = new List(); foreach (var key in dictionary.Keys) { @@ -371,7 +372,7 @@ private void AddVersionToRequest(HttpRequestMessage ret) private void AppendPropertyToQueryMap( object @object, PropertyInfo propertyInfo, - List> kvps, + List kvps, string? delimiter, RestMethodParameterInfo? parameterInfo, CollectionFormat? collectionFormat) @@ -426,7 +427,15 @@ private void AppendPropertyToQueryMap( private string BuildPropertyQueryKey(PropertyInfo propertyInfo, QueryAttribute? queryAttribute) { var aliasAttribute = propertyInfo.GetCustomAttribute(); - var name = aliasAttribute?.Name ?? _settings.UrlParameterKeyFormatter.Format(propertyInfo.Name); + + // Match the form-encoded field-naming precedence (FormValueMultimap.GetFieldNameForProperty): an [AliasAs] + // wins, then the configured content serializer's field name ([JsonPropertyName] for System.Text.Json, + // [JsonProperty] for Newtonsoft.Json) unless disabled, then the URL parameter key formatter over the CLR name. + var name = aliasAttribute?.Name + ?? (_settings.HonorContentSerializerPropertyNamesInQuery + ? _settings.ContentSerializer.GetFieldNameForProperty(propertyInfo) + : null) + ?? _settings.UrlParameterKeyFormatter.Format(propertyInfo.Name); // Honor a property-level [Query(delimiter, prefix)], matching how form-encoded fields are named. return queryAttribute is not null && !string.IsNullOrWhiteSpace(queryAttribute.Prefix) @@ -475,7 +484,7 @@ private void AppendEnumerablePropertyValues( IEnumerable values, PropertyInfo propertyInfo, string key, - List> kvps, + List kvps, QueryAttribute? queryAttribute, CollectionFormat? collectionFormat) { @@ -499,7 +508,7 @@ private void AppendEnumerablePropertyValues( private void AppendNestedQueryMap( object obj, string key, - List> kvps, + List kvps, string? delimiter, CollectionFormat? collectionFormat) { diff --git a/src/Refit/RequestBuilderImplementation.RequestBuilding.cs b/src/Refit.Reflection/RequestBuilderImplementation.RequestBuilding.cs similarity index 57% rename from src/Refit/RequestBuilderImplementation.RequestBuilding.cs rename to src/Refit.Reflection/RequestBuilderImplementation.RequestBuilding.cs index fc7eee37e..6b0debb3b 100644 --- a/src/Refit/RequestBuilderImplementation.RequestBuilding.cs +++ b/src/Refit.Reflection/RequestBuilderImplementation.RequestBuilding.cs @@ -6,7 +6,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Reflection; -using System.Text; namespace Refit; @@ -100,6 +99,11 @@ private static bool IsPropertyOnlyParameter(RestMethodInfoInternal restMethod, i restMethod.PropertyParameterMap.ContainsKey(i) && restMethod.ParameterInfoArray[i].GetCustomAttribute() is null; + /// Reduces a round-tripping parameter value to its string form, using ToString for non-strings. + /// The parameter value. + /// The value's string form, or null. + private static string? RoundTripStringValue(object? param) => (param as string) ?? param?.ToString(); + /// Serializes a request body using the declared body type. /// The content serializer to use. /// The body value to serialize. @@ -125,8 +129,8 @@ private static HttpContent SerializeBody( /// The body value to serialize. /// The serialized HTTP content. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private static HttpContent SerializeBodyGeneric(IHttpContentSerializer serializer, object? body) => serializer.ToHttpContent((T)body!); @@ -156,8 +160,8 @@ private static HttpContent SerializeBodySynchronously( /// The body value to serialize. /// The serialized, buffered HTTP content. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private static HttpContent SerializeBodySynchronouslyGeneric(ISynchronousContentSerializer serializer, object? body) => serializer.ToHttpContentSynchronous((T)body!); @@ -187,8 +191,8 @@ private static HttpContent SerializeBodyStreaming( /// The body value to serialize. /// The streaming HTTP content. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private static HttpContent SerializeBodyStreamingGeneric(ISynchronousContentSerializer serializer, object? body) => serializer.ToStreamingHttpContent((T)body!); @@ -226,7 +230,8 @@ private static object[] RemoveCancellationTokens(object[] paramList) { if (paramList[i] is not CancellationToken) { - mappedParams[index++] = paramList[i]; + mappedParams[index] = paramList[i]; + index++; } } @@ -264,7 +269,7 @@ private async Task BuildRequestMessageForMethodAsync( ret.Content = multiPartContent; } - List>? queryParamsToAdd = null; + List? queryParamsToAdd = null; var headersToAdd = restMethod.Headers.Count > 0 ? new Dictionary(restMethod.Headers) : null; @@ -303,7 +308,7 @@ private void MapParametersToRequest( HttpRequestMessage ret, MultipartFormDataContent? multiPartContent, ref Dictionary? headersToAdd, - ref List>? queryParamsToAdd) + ref List? queryParamsToAdd) { RestMethodParameterInfo? parameterInfo = null; @@ -399,7 +404,7 @@ private void AssignRequestUri( HttpRequestMessage ret, string basePath, object[] paramList, - List>? queryParamsToAdd) + List? queryParamsToAdd) { var urlTarget = BuildRelativePath(basePath, restMethod, paramList); @@ -503,9 +508,20 @@ private void AppendObjectPropertyFragment( ParameterFragment fragment, RestMethodParameterInfo parameterMapValue) { - var param = paramList[fragment.ArgumentIndex]; var property = parameterMapValue.ParameterProperties[fragment.PropertyIndex]; - var propertyObject = property.PropertyInfo.GetValue(param); + + // Walk the property chain (one link for a single-level binding, more for a dotted {a.b.c} binding), stopping at + // the first null so a missing intermediate formats as an empty segment rather than throwing. + object? propertyObject = paramList[fragment.ArgumentIndex]; + foreach (var link in property.PropertyChain) + { + if (propertyObject is null) + { + break; + } + + propertyObject = link.GetValue(propertyObject); + } vsb.Append(StringHelpers.EscapeDataString(_settings.UrlParameterFormatter.Format( propertyObject, @@ -539,9 +555,10 @@ private void AppendDynamicRouteFragment( return; } - // If round tripping, format each path segment independently. + // If round tripping, split the value's string form on '/' (ToString for a non-string, so any type is + // supported) and format+escape each segment independently, preserving the separators. Debug.Assert(parameterMapValue.Type == ParameterType.RoundTripping, "Dynamic route fragments must be Normal or RoundTripping."); - var paramValue = (string)param; + var paramValue = RoundTripStringValue(param); if (paramValue is null) { @@ -577,457 +594,4 @@ private void AppendDynamicRouteFragment( sectionStart = i + 1; } } - - /// Sets the request content from the body parameter using the configured serialization method. - /// The rest method being invoked. - /// The body argument value. - /// The request message to populate. - [RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")] - private void AddBodyToRequest(RestMethodInfoInternal restMethod, object param, HttpRequestMessage ret) - { - if (param is HttpContent httpContentParam) - { - ret.Content = httpContentParam; - return; - } - - if (param is Stream streamParam) - { - ret.Content = new StreamContent(streamParam); - return; - } - - // Default sends raw strings - if (restMethod.BodyParameterInfo!.Item1 == BodySerializationMethod.Default - && param is string stringParam) - { - ret.Content = new StringContent(stringParam); - return; - } - - switch (restMethod.BodyParameterInfo.Item1) - { - case BodySerializationMethod.UrlEncoded: - { - ret.Content = param is string str - ? new StringContent( - StringHelpers.EscapeDataString(str), - Encoding.UTF8, - "application/x-www-form-urlencoded") - : new FormUrlEncodedContent(new FormValueMultimap(param, _settings)); - break; - } - - case BodySerializationMethod.JsonLines: - { - ret.Content = new JsonLinesContent(AsJsonLinesSequence(param), _serializer); - break; - } - - case BodySerializationMethod.Default or BodySerializationMethod.Serialized: - { - AddSerializedBodyToRequest(restMethod, param, ret); - break; - } - - default: - { - // The obsolete legacy JSON serialization method must still serialize, because - // already-compiled callers can pass it. Treating it as Default would incorrectly - // send string bodies as raw text. It is matched by value rather than by name so - // this file never references the obsolete member. - if (GeneratedRequestRunner.IsObsoleteJsonSerializationMethod(restMethod.BodyParameterInfo.Item1)) - { - AddSerializedBodyToRequest(restMethod, param, ret); - } - - break; - } - } - } - - /// Sets the request content from a serialized body, optionally streaming it. - /// The rest method being invoked. - /// The body argument value. - /// The request message to populate. - [RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")] - private void AddSerializedBodyToRequest(RestMethodInfoInternal restMethod, object param, HttpRequestMessage ret) - { - var declaredBodyType = restMethod.ParameterInfoArray[ - restMethod.BodyParameterInfo!.Item3].ParameterType; - - // Synchronous serialization lets the source-gen fast-path engage: Buffered produces a ByteArrayContent up - // front, Streamed writes through a Utf8JsonWriter on the request stream without buffering the whole body. - if (_settings.RequestBodySerialization != RequestBodySerializationMode.Default - && _serializer is ISynchronousContentSerializer syncSerializer) - { - ret.Content = _settings.RequestBodySerialization == RequestBodySerializationMode.Streamed - ? SerializeBodyStreaming(syncSerializer, param, declaredBodyType) - : SerializeBodySynchronously(syncSerializer, param, declaredBodyType); - return; - } - - var content = SerializeBody(_serializer, param, declaredBodyType); - - if (restMethod.BodyParameterInfo.Item2) - { - ret.Content = content; - return; - } - - ret.Content = new PushStreamContent( - async (stream, _, _) => - { -#if NET8_0_OR_GREATER - await using (stream.ConfigureAwait(false)) -#else - using (stream) -#endif - { - await content - .CopyToAsync(stream) - .ConfigureAwait(false); - } - }, - content.Headers.ContentType); - } - - /// Adds query-string parameters for a single argument to the pending query list. - /// The rest method being invoked. - /// The query attribute on the parameter, if any. - /// The argument value. - /// The list of query parameters being built. - /// The index of the parameter. - /// Optional parameter info for property-bound parameters. - private void AddQueryParameters( - RestMethodInfoInternal restMethod, - QueryAttribute? queryAttribute, - object param, - List> queryParamsToAdd, - int i, - RestMethodParameterInfo? parameterInfo) - { - var attr = queryAttribute ?? DefaultQueryAttribute; - - // TreatAsString, or an explicitly empty Format, serializes the value via ToString() under the - // parameter name instead of flattening a complex object's public properties into the query. - if (attr.TreatAsString || attr.Format is { Length: 0 }) - { - AppendQueryParameter( - queryParamsToAdd, - param.ToString(), - restMethod.ParameterInfoArray[i], - restMethod.QueryParameterMap[i], - attr); - return; - } - - if (DoNotConvertToQueryMap(param)) - { - AppendQueryParameter( - queryParamsToAdd, - param, - restMethod.ParameterInfoArray[i], - restMethod.QueryParameterMap[i], - attr); - return; - } - - var parameterCollectionFormat = attr.IsCollectionFormatSpecified - ? attr.CollectionFormat - : (CollectionFormat?)null; - 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; - AppendQueryParameter( - queryParamsToAdd, - kvp.Value, - restMethod.ParameterInfoArray[i], - path, - attr); - } - } - - /// Adds one (or each enumerated) multipart part for a single argument. - /// The rest method being invoked. - /// The index of the parameter. - /// The argument value, which may be a single item or an enumerable. - /// The multipart content to add to. - private void AddMultiPart( - RestMethodInfoInternal restMethod, - int i, - object param, - MultipartFormDataContent? multiPartContent) - { - // we are in a multipart method, add the part to the content - // the parameter name should be either the attachment name or the parameter name (as fallback) - string itemName; - string parameterName; - - if (!restMethod.AttachmentNameMap.TryGetValue(i, out var attachment)) - { - itemName = restMethod.QueryParameterMap[i]; - parameterName = itemName; - } - else - { - itemName = attachment.Item1; - parameterName = attachment.Item2; - } - - // Check to see if it's an IEnumerable - if (param is IEnumerable enumerable) - { - foreach (var item in enumerable) - { - AddMultipartItem(multiPartContent!, itemName, parameterName, item); - } - } - else - { - AddMultipartItem(multiPartContent!, itemName, parameterName, param); - } - } - - /// Adds a single value to a multipart form as the appropriate content type. - /// The multipart content to add to. - /// The file name to use for file-like parts. - /// The form field name for the part. - /// The value to add. - private void AddMultipartItem( - MultipartFormDataContent multiPartContent, - string fileName, - string parameterName, - object itemValue) - { - if (itemValue is HttpContent content) - { - multiPartContent.Add(content); - return; - } - - if (itemValue is MultipartItem multipartItem) - { - var httpContent = multipartItem.ToContent(); - multiPartContent.Add( - httpContent, - multipartItem.Name ?? parameterName, - string.IsNullOrEmpty(multipartItem.FileName) ? fileName : multipartItem.FileName); - return; - } - - if (itemValue is Stream streamValue) - { - var streamContent = new StreamContent(streamValue); - multiPartContent.Add(streamContent, parameterName, fileName); - return; - } - - if (itemValue is string stringValue) - { - multiPartContent.Add(new StringContent(stringValue), parameterName); - return; - } - - if (itemValue is FileInfo fileInfoValue) - { - var fileContent = new StreamContent(fileInfoValue.OpenRead()); - multiPartContent.Add(fileContent, parameterName, fileInfoValue.Name); - return; - } - - if (itemValue is byte[] byteArrayValue) - { - var fileContent = new ByteArrayContent(byteArrayValue); - multiPartContent.Add(fileContent, parameterName, fileName); - return; - } - - AddSerializedMultipartItem(multiPartContent, fileName, parameterName, itemValue); - } - - /// Adds a multipart part by serializing the value, throwing a descriptive error on failure. - /// The multipart content to add to. - /// The file name used in the error message. - /// The form field name for the part. - /// The value to serialize and add. - private void AddSerializedMultipartItem( - MultipartFormDataContent multiPartContent, - string fileName, - string parameterName, - object itemValue) - { - // Date/time and Guid values are wrapped in quotes by the JSON serializer (e.g. ""), - // which servers reject when reading a multipart form field. Send them as their plain text - // representation instead. Numbers, booleans and enums are intentionally left to the - // serializer to preserve existing behavior. - if (itemValue is Guid or DateTime or DateTimeOffset or TimeSpan -#if NET6_0_OR_GREATER - or DateOnly or TimeOnly -#endif - ) - { - var formatted = _settings.FormUrlEncodedParameterFormatter.Format(itemValue, null); - multiPartContent.Add(new StringContent(formatted ?? string.Empty), parameterName); - return; - } - - // Fallback to serializer - Exception e; - try - { - multiPartContent.Add( - _settings.ContentSerializer.ToHttpContent(itemValue), - parameterName); - return; - } - catch (Exception ex) - { - // Eat this since we're about to throw as a fallback anyway - e = ex; - } - - throw new ArgumentException( - $"Unexpected parameter type in a Multipart request. Parameter {fileName} is of type {itemValue.GetType().Name}, " - + "whereas allowed types are String, Stream, FileInfo, Byte array and anything that's JSON serializable", - nameof(itemValue), - e); - } - - /// 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. - private void AppendQueryParameter( - List> queryParamsToAdd, - object? param, - ParameterInfo parameterInfo, - string queryPath, - QueryAttribute queryAttribute) - { - if (param is not string and IEnumerable paramValues) - { - foreach (var value in ParseEnumerableQueryParameterValue( - paramValues, - parameterInfo, - parameterInfo.ParameterType, - queryAttribute)) - { - queryParamsToAdd.Add(new(queryPath, value)); - } - - return; - } - - queryParamsToAdd.Add( - new( - queryPath, - _settings.UrlParameterFormatter.Format( - param, - parameterInfo, - parameterInfo.ParameterType))); - } - - /// Formats an enumerable parameter value according to the effective collection format. - /// The enumerable values to format. - /// The attribute provider for the parameter or property. - /// The element type used for formatting. - /// The query attribute governing the collection format, if any. - /// The collection format to use when none is specified. - /// The formatted query values. - private IEnumerable ParseEnumerableQueryParameterValue( - IEnumerable paramValues, - ICustomAttributeProvider customAttributeProvider, - Type type, - QueryAttribute? queryAttribute, - CollectionFormat? fallbackCollectionFormat = null) - { - // Precedence: the property's own [Query] format wins; otherwise the format - // supplied by the enclosing parameter's [Query] attribute (if any); finally - // the global RefitSettings default. - var collectionFormat = - queryAttribute?.IsCollectionFormatSpecified == true - ? queryAttribute.CollectionFormat - : fallbackCollectionFormat ?? _settings.CollectionFormat; - - if (collectionFormat == CollectionFormat.Multi) - { - foreach (var paramValue in paramValues) - { - yield return _settings.UrlParameterFormatter.Format( - paramValue, - customAttributeProvider, - type); - } - - yield break; - } - - var delimiter = - collectionFormat switch - { - CollectionFormat.Ssv => " ", - CollectionFormat.Tsv => "\t", - CollectionFormat.Pipes => "|", - _ => "," - }; - - // Missing a "default" clause was preventing the collection from serializing at all, as it was hitting "continue" thus causing an off-by-one error - 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. - [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( - enumerator.Current, - customAttributeProvider, - type)); - } - - return builder.ToString(); - } - finally - { - (enumerator as IDisposable)?.Dispose(); - } - } } diff --git a/src/Refit/RequestBuilderImplementation.cs b/src/Refit.Reflection/RequestBuilderImplementation.cs similarity index 84% rename from src/Refit/RequestBuilderImplementation.cs rename to src/Refit.Reflection/RequestBuilderImplementation.cs index 33b71ddf8..4d6170cee 100644 --- a/src/Refit/RequestBuilderImplementation.cs +++ b/src/Refit.Reflection/RequestBuilderImplementation.cs @@ -18,6 +18,9 @@ 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 name of the method, resolved reflectively. + private const string AdaptMethodName = "Adapt"; + /// The default query attribute applied when a parameter has none. private static readonly QueryAttribute DefaultQueryAttribute = new(); @@ -119,8 +122,15 @@ public RequestBuilderImplementation( } // IObservable - return IsGenericReturnType(restMethod, typeof(IObservable<>)) - ? BuildResultFuncForMethod(restMethod, nameof(BuildRxFuncForMethod)) + if (IsGenericReturnType(restMethod, typeof(IObservable<>))) + { + return BuildResultFuncForMethod(restMethod, nameof(BuildRxFuncForMethod)); + } + + // A registered IReturnTypeAdapter surfaces this return type; this check runs after the built-in shapes so + // registering an adapter never overrides them. + return restMethod.HasReturnTypeAdapter + ? BuildAdapterFuncForMethod(restMethod) : BuildGeneratedSyncFuncForMethod(restMethod); } @@ -415,6 +425,69 @@ genericArgumentTypes is { } genericArguments return (client, args) => resultFunc!.DynamicInvoke([client, args]); } + /// Builds a delegate for a method whose return type a registered surfaces. + /// The rest method to build a delegate for. + /// A delegate that adapts the deferred HTTP call into the surfaced return type. + [RequiresUnreferencedCode("Building return-type adapter delegates requires generic method metadata to be available at runtime.")] + [RequiresDynamicCode("Building return-type adapter delegates requires runtime generic method instantiation.")] + private Func BuildAdapterFuncForMethod(RestMethodInfoInternal restMethod) + { + var builderMethodInfo = FindDeclaredMethod(nameof(BuildAdapterFuncForMethodGeneric)); + return (Func) + builderMethodInfo!.MakeGenericMethod( + restMethod.ReturnResultType, + restMethod.DeserializedResultType) + .Invoke(this, [restMethod])!; + } + + /// Builds an adapter invocation delegate for a method with known result and body types. + /// The result type the HTTP call materializes (the adapter's TResult). + /// The body type used for API responses. + /// The rest method to build a delegate for. + /// A delegate that returns the adapter's surfaced value. + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Type parameter intentionally specified explicitly by callers.")] + [RequiresUnreferencedCode("Instantiating the adapter and resolving its interface method requires adapter metadata to be available at runtime.")] + [RequiresDynamicCode("Instantiating the adapter and closing its interface method requires runtime generic type instantiation.")] + private Func BuildAdapterFuncForMethodGeneric( + RestMethodInfoInternal restMethod) + { + var taskFunc = BuildCancellableTaskFuncForMethod(restMethod); + + // This runs only when HasReturnTypeAdapter already matched an adapter for this exact return type and adapter + // set, so ResolveClosedAdapterType resolves the same match and never returns null here. + var adapterType = ReturnTypeAdapterResolver.ResolveClosedAdapterType( + restMethod.ReturnType, + restMethod.RefitSettings.ReturnTypeAdapters)!; + var adapter = Activator.CreateInstance(adapterType)!; + + // The adapter implements IReturnTypeAdapter; T is the inner result classified for it, so the + // closed interface method matches the strongly typed invoke delegate built below. + var adapterInterface = typeof(IReturnTypeAdapter<,>).MakeGenericType(restMethod.ReturnType, typeof(T)); + var adaptMethod = adapterInterface.GetMethod(AdaptMethodName)!; + + return (client, paramList) => + { + var methodCt = restMethod.CancellationToken is not null + ? GetCancellationToken(paramList) + : CancellationToken.None; + + Func> invoke = ct => + { + // Link the adapter's per-invocation token with the method's token, and keep the linked source alive + // until the request finishes. The reflection builder rebuilds the request on each invoke, so a cold + // adapter can re-subscribe. + var cts = CancellationTokenSource.CreateLinkedTokenSource(methodCt, ct); + var task = taskFunc(client, cts.Token, paramList); + return DisposeWhenDoneAsync(task, cts); + }; + + return adaptMethod.Invoke(adapter, [invoke]); + }; + } + /// 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. @@ -454,8 +527,8 @@ genericArgumentTypes is { } genericArguments /// The rest method to build a delegate for. /// A delegate that invokes the method synchronously. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")] private Func BuildGeneratedSyncFuncForMethodGeneric( @@ -475,8 +548,8 @@ genericArgumentTypes is { } genericArguments /// The rest method to build a delegate for. /// A delegate that returns an observable of the result. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")] private Func> BuildRxFuncForMethod( @@ -509,13 +582,9 @@ genericArgumentTypes is { } genericArguments /// The rest method to build a delegate for. /// A delegate that returns an asynchronous sequence of the result. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] - [SuppressMessage( - "Major Code Smell", - "S2326:Unused type parameters should be removed", - Justification = "The second type parameter is required so this factory matches the two-type-argument shape invoked reflectively by BuildResultFuncForMethod.")] [SuppressMessage( "StyleSharp", "SST1452:Unused type parameters should be removed", @@ -531,8 +600,8 @@ genericArgumentTypes is { } genericArguments /// The rest method to build a delegate for. /// A delegate that returns a task of the result. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")] private Func> BuildTaskFuncForMethod( @@ -552,8 +621,8 @@ restMethod.CancellationToken is not null /// The rest method to build a delegate for. /// A delegate that returns a value task of the result. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresDynamicCode("Serializing a body by runtime Type requires runtime generic method instantiation.")] private Func> BuildValueTaskFuncForMethod( diff --git a/src/Refit/RequestBuilderImplementation{TApi}.cs b/src/Refit.Reflection/RequestBuilderImplementation{TApi}.cs similarity index 100% rename from src/Refit/RequestBuilderImplementation{TApi}.cs rename to src/Refit.Reflection/RequestBuilderImplementation{TApi}.cs diff --git a/src/Refit.Reflection/RestMethodInfoInternal.ParameterBinding.cs b/src/Refit.Reflection/RestMethodInfoInternal.ParameterBinding.cs new file mode 100644 index 000000000..ecb9cdfa5 --- /dev/null +++ b/src/Refit.Reflection/RestMethodInfoInternal.ParameterBinding.cs @@ -0,0 +1,405 @@ +// 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.Reflection; +using System.Text.RegularExpressions; + +namespace Refit; + +/// Route parameter binding: builds the parameter map and URL fragments, and resolves parameter/property URL names for . +internal partial class RestMethodInfoInternal +{ + /// Builds the route parameter map and the ordered URL fragments for the relative path. + /// The relative URL path template. + /// The array of method parameters. + /// When true, a placeholder with no matching argument is left in the path verbatim instead of throwing. + /// A tuple containing the parameter map and the ordered list of URL fragments. + [RequiresUnreferencedCode("Binding route parameters from request object properties requires public property metadata to be available at runtime.")] + private static (Dictionary Map, List Fragments) + BuildParameterMap( + string relativePath, + ParameterInfo[] parameterInfo, + bool allowUnmatchedRouteParameters) + { + var ret = new Dictionary(); + + var parameterizedParts = ParameterRegex().Matches(relativePath); + + if (parameterizedParts.Count == 0) + { + return string.IsNullOrEmpty(relativePath) + ? (ret, []) + : (ret, [ParameterFragment.Constant(relativePath)]); + } + + var paramValidationDict = BuildParamValidationDict(parameterInfo); + var objectParamValidationDict = BuildObjectParamValidationDict(parameterInfo); + + var fragmentList = new List(); + var index = 0; + + for (var i = 0; i < parameterizedParts.Count; i++) + { + var match = parameterizedParts[i]; + + // Add constant value from given http path + if (match.Index != index) + { + fragmentList.Add(ParameterFragment.Constant(relativePath.Substring(index, match.Index - index))); + } + + index = match.Index + match.Length; + + AddFragmentForMatch( + relativePath, + parameterInfo, + ret, + fragmentList, + (paramValidationDict, objectParamValidationDict), + match, + allowUnmatchedRouteParameters); + } + + if (index >= relativePath.Length) + { + return (ret, fragmentList); + } + + // Add trailing string. + var trailingConstant = relativePath[index..]; + fragmentList.Add(ParameterFragment.Constant(trailingConstant)); + + return (ret, fragmentList); + } + + /// Builds a lookup of lower-cased URL parameter names to their declaring method parameter. + /// The array of method parameters. + /// A map of URL parameter names to method parameters. + private static Dictionary BuildParamValidationDict(ParameterInfo[] parameterInfo) + { + var paramValidationDict = new Dictionary(parameterInfo.Length); + for (var i = 0; i < parameterInfo.Length; i++) + { + paramValidationDict[GetUrlNameForParameter(parameterInfo[i]).ToLowerInvariant()] = parameterInfo[i]; + } + + return paramValidationDict; + } + + /// Builds a lookup of lower-cased "parameter.property" names to the parameter/property pair that can bind them. + /// The array of method parameters. + /// A map of nested property names to their parameter/property pair. + [RequiresUnreferencedCode("Binding route parameters from request object properties requires public property metadata to be available at runtime.")] + private static Dictionary> BuildObjectParamValidationDict( + ParameterInfo[] parameterInfo) + { + // If the parameter is a class, build a dictionary for all of its potential bound properties. + 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(); + _ = objectParamValidationDict.TryAdd(key, Tuple.Create(parameter, properties[j])); + } + } + + return objectParamValidationDict; + } + + /// Resolves a single parameterized URL fragment against the parameter maps and appends the result. + /// The relative URL path template. + /// The array of method parameters. + /// The parameter map being built. + /// The fragment list being built. + /// The lookups of directly matched parameter names and nested object-property names. + /// The parameterized URL match being resolved. + /// When true, an unmatched placeholder is left in the path verbatim instead of throwing. + [RequiresUnreferencedCode("Binding route parameters from request object properties requires public property metadata to be available at runtime.")] + private static void AddFragmentForMatch( + string relativePath, + ParameterInfo[] parameterInfo, + Dictionary ret, + List fragmentList, + (Dictionary Param, Dictionary> Object) validation, + Match match, + bool allowUnmatchedRouteParameters) + { + const string roundTripPrefix = "**"; + var rawName = match.Groups[1].Value.ToLowerInvariant(); + var isRoundTripping = rawName.StartsWith(roundTripPrefix, StringComparison.Ordinal); + var name = isRoundTripping ? rawName[roundTripPrefix.Length..] : rawName; + + if (validation.Param.TryGetValue(name, out var value)) + { + AddStandardParameter( + parameterInfo, + ret, + fragmentList, + new(rawName, name, isRoundTripping), + value); + } + else if (validation.Object.TryGetValue(name, out var value1) && !isRoundTripping) + { + AddObjectPropertyParameter(parameterInfo, ret, fragmentList, name, value1.Item1, [value1.Item2]); + } + else if (TryResolveNestedPropertyChain(parameterInfo, name) is { } nested) + { + // A round-trip placeholder only ever matches a direct parameter above, so it never reaches a nested chain + // (which requires a dotted name); no isRoundTripping guard is needed here. + AddObjectPropertyParameter(parameterInfo, ret, fragmentList, name, nested.Parameter, nested.Chain); + } + else if (allowUnmatchedRouteParameters) + { + // Leave the unmatched placeholder in the URL verbatim (including its braces) so the + // caller can resolve it later, e.g. inside a DelegatingHandler. + fragmentList.Add(ParameterFragment.Constant(match.Value)); + } + else + { + throw new ArgumentException( + $"URL {relativePath} has parameter {rawName}, but no method parameter matches"); + } + } + + /// Adds a standard (directly matched) route parameter to the parameter map and fragment list. + /// The array of method parameters. + /// The parameter map being built. + /// The fragment list being built. + /// The parsed parameter name details from the URL template. + /// The matched method parameter. + private static void AddStandardParameter( + ParameterInfo[] parameterInfo, + Dictionary ret, + List fragmentList, + ParsedParameterName parsedName, + ParameterInfo value) + { + // A round-tripping parameter may be any type: its value is formatted through the URL parameter formatter + // (ToString by default) and each '/'-delimited segment is escaped independently, preserving the separators. + var parameterType = parsedName.IsRoundTripping + ? ParameterType.RoundTripping + : ParameterType.Normal; + var restMethodParameterInfo = new RestMethodParameterInfo(parsedName.Name, value) { Type = parameterType }; + + var parameterIndex = Array.IndexOf(parameterInfo, restMethodParameterInfo.ParameterInfo); + fragmentList.Add(ParameterFragment.Dynamic(parameterIndex)); +#if NET6_0_OR_GREATER + _ = ret.TryAdd(parameterIndex, restMethodParameterInfo); +#else + if (ret.ContainsKey(parameterIndex)) + { + return; + } + + ret.Add(parameterIndex, restMethodParameterInfo); +#endif + } + + /// Adds an object-property route parameter, resolving a nested {a.b.c} chain when needed. + /// The array of method parameters. + /// The parameter map being built. + /// The fragment list being built. + /// The normalized parameter name. + /// The parameter whose property chain binds the placeholder. + /// The ordered property navigation from the parameter to the bound value. + private static void AddObjectPropertyParameter( + ParameterInfo[] parameterInfo, + Dictionary ret, + List fragmentList, + string name, + ParameterInfo owner, + IReadOnlyList propertyChain) + { + var parameterIndex = Array.IndexOf(parameterInfo, owner); + + // If we already have this parameter, add an additional ParameterProperty. + if (ret.TryGetValue(parameterIndex, out var value2)) + { + if (!value2.IsObjectPropertyParameter) + { + throw new ArgumentException( + $"Parameter {owner.Name} matches both a parameter and nested parameter on a parameter object"); + } + + value2.ParameterProperties.Add(new(name, propertyChain)); + fragmentList.Add( + ParameterFragment.DynamicObject(parameterIndex, value2.ParameterProperties.Count - 1)); + return; + } + + var restMethodParameterInfo = new RestMethodParameterInfo(true, owner); + restMethodParameterInfo.ParameterProperties.Add(new(name, propertyChain)); + + var idx = Array.IndexOf(parameterInfo, restMethodParameterInfo.ParameterInfo); + fragmentList.Add(ParameterFragment.DynamicObject(idx, 0)); +#if NET6_0_OR_GREATER + _ = ret.TryAdd(idx, restMethodParameterInfo); +#else + if (ret.ContainsKey(idx)) + { + return; + } + + ret.Add(idx, restMethodParameterInfo); +#endif + } + + /// Resolves a dotted {a.b.c} placeholder into the parameter and its nested property navigation chain. + /// The array of method parameters. + /// The normalized (lower-cased) placeholder name. + /// The parameter and its property chain, or null when the placeholder is not a resolvable nested chain. + [RequiresUnreferencedCode("Binding route parameters from request object properties requires public property metadata to be available at runtime.")] + private static (ParameterInfo Parameter, IReadOnlyList Chain)? TryResolveNestedPropertyChain( + ParameterInfo[] parameterInfo, + string name) + { + // A single dot ("a.b") is a direct object property handled by the validation dictionary; only deeper chains reach here. + var segments = name.Split('.'); + if (segments.Length < 3) + { + return null; + } + + var parameter = Array.Find(parameterInfo, p => string.Equals(p.Name, segments[0], StringComparison.OrdinalIgnoreCase)); + if (parameter is null) + { + return null; + } + + if (!parameter.ParameterType.GetTypeInfo().IsClass) + { + return null; + } + + var chain = new List(segments.Length - 1); + var currentType = parameter.ParameterType; + for (var i = 1; i < segments.Length; i++) + { + if (FindPropertyByUrlName(currentType, segments[i]) is not { } property) + { + return null; + } + + chain.Add(property); + currentType = property.PropertyType; + } + + return (parameter, chain); + } + + /// Finds a readable public property by its URL name (honoring any alias), case-insensitively. + /// The declaring type to search. + /// The URL name segment to match. + /// The matching property, or null when none matches. + [RequiresUnreferencedCode("Binding route parameters from request object properties requires public property metadata to be available at runtime.")] + private static PropertyInfo? FindPropertyByUrlName(Type type, string urlName) + { + foreach (var property in ReflectionPropertyHelpers.GetReadablePublicInstanceProperties(type)) + { + if (string.Equals(GetUrlNameForProperty(property), urlName, StringComparison.OrdinalIgnoreCase)) + { + return property; + } + } + + return null; + } + + /// Gets the URL name to use for a parameter, honoring any alias attribute. + /// The parameter whose URL name is resolved. + /// The aliased or declared parameter name. + private static string GetUrlNameForParameter(ParameterInfo paramInfo) + { + var aliasAttr = paramInfo.GetCustomAttribute(true); + return aliasAttr is not null ? aliasAttr.Name : paramInfo.Name!; + } + + /// Gets the URL name to use for a property, honoring any alias attribute. + /// The property whose URL name is resolved. + /// The aliased or declared property name. + private static string GetUrlNameForProperty(PropertyInfo propInfo) + { + var aliasAttr = propInfo.GetCustomAttribute(true); + return aliasAttr is not null ? aliasAttr.Name : propInfo.Name; + } + + /// Gets the multipart attachment name to use for a parameter. + /// The parameter whose attachment name is resolved. + /// The attachment name, or null when none is specified. + [ExcludeFromCodeCoverage] // The AttachmentName arm needs the [Obsolete] AttachmentNameAttribute, which CS0618 forbids a test from applying, so the branch cannot be covered. + private static string GetAttachmentNameForParameter(ParameterInfo paramInfo) + { +#pragma warning disable CS0618 // Type or member is obsolete + var nameAttr = paramInfo.GetCustomAttribute(true); +#pragma warning restore CS0618 // Type or member is obsolete + + // also check for AliasAs + return nameAttr?.Name ?? paramInfo.GetCustomAttribute(true)?.Name!; + } + + /// Finds the parameter that carries the authorization value. + /// The array of method parameters. + /// The authorization parameter information, or null when there is no authorize parameter. + private static Tuple? FindAuthorizationParameter(ParameterInfo[] parameterArray) + { + AuthorizeAttribute? authorizeAttribute = null; + var authorizeIndex = -1; + + for (var i = 0; i < parameterArray.Length; i++) + { + var attribute = parameterArray[i].GetCustomAttribute(true); + if (attribute is null) + { + continue; + } + + if (authorizeAttribute is not null) + { + throw new ArgumentException("Only one parameter can be an Authorize parameter"); + } + + authorizeAttribute = attribute; + authorizeIndex = i; + } + + return authorizeAttribute is null + ? null + : Tuple.Create(authorizeAttribute.Scheme, authorizeIndex); + } + + /// Finds the single cancellation token parameter for the method. + /// The reflected method information. + /// The cancellation token parameter, or null when none is present. + private static ParameterInfo? FindCancellationTokenParameter(MethodInfo methodInfo) + { + var parameters = methodInfo.GetParameters(); + ParameterInfo? cancellationTokenParam = null; + for (var i = 0; i < parameters.Length; i++) + { + 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; + } +} diff --git a/src/Refit/RestMethodInfoInternal.cs b/src/Refit.Reflection/RestMethodInfoInternal.cs similarity index 67% rename from src/Refit/RestMethodInfoInternal.cs rename to src/Refit.Reflection/RestMethodInfoInternal.cs index ac4f68a07..bf472cdba 100644 --- a/src/Refit/RestMethodInfoInternal.cs +++ b/src/Refit.Reflection/RestMethodInfoInternal.cs @@ -11,11 +11,7 @@ namespace Refit; /// Internal representation of a Refit REST method holding the parsed metadata used to construct requests. [DebuggerDisplay("{MethodInfo}")] -#if NET7_0_OR_GREATER internal partial class RestMethodInfoInternal -#else -internal class RestMethodInfoInternal -#endif { /// The HTTP PATCH method instance. private static readonly HttpMethod _patchMethod = new("PATCH"); @@ -60,7 +56,12 @@ public RestMethodInfoInternal( VerifyUrlPathIsSane(RelativePath, RefitSettings.UrlResolution); - var (returnType, returnResultType, deserializedResultType) = DetermineReturnTypeInfo(methodInfo); + HasReturnTypeAdapter = ReturnTypeAdapterResolver.TryResolveResultType( + methodInfo.ReturnType, + RefitSettings.ReturnTypeAdapters, + out var adapterResultType); + var (returnType, returnResultType, deserializedResultType) = + DetermineReturnTypeInfo(methodInfo, adapterResultType); ReturnType = returnType; ReturnResultType = returnResultType; DeserializedResultType = deserializedResultType; @@ -68,7 +69,13 @@ public RestMethodInfoInternal( // Exclude cancellation token parameters from this list ParameterInfoArray = GetNonCancellationTokenParameters(methodInfo.GetParameters()); - (ParameterMap, FragmentPath) = BuildParameterMap(RelativePath, ParameterInfoArray, RefitSettings.AllowUnmatchedRouteParameters); + + // An open generic method definition cannot resolve dotted {obj.Prop} placeholders yet — the generic parameter + // has no properties until the method is closed over a concrete type. This RestMethodInfo is only used for + // method selection; the closed instantiation (built on first call) resolves the placeholders, so leave any + // unmatched placeholder in place here instead of throwing. + var allowUnmatched = RefitSettings.AllowUnmatchedRouteParameters || methodInfo.IsGenericMethodDefinition; + (ParameterMap, FragmentPath) = BuildParameterMap(RelativePath, ParameterInfoArray, allowUnmatched); BodyParameterInfo = FindBodyParameter(ParameterInfoArray, IsMultipart, hma.Method); AuthorizeParameterInfo = FindAuthorizationParameter(ParameterInfoArray); @@ -156,6 +163,9 @@ public RestMethodInfoInternal( /// Gets or sets the type that the response content is deserialized into. public Type DeserializedResultType { get; set; } + /// Gets a value indicating whether a registered surfaces this method's return type. + public bool HasReturnTypeAdapter { get; } + /// Gets the Refit settings used when building the request. public RefitSettings RefitSettings { get; } @@ -202,7 +212,8 @@ private static ParameterInfo[] GetNonCancellationTokenParameters(ParameterInfo[] { if (!IsCancellationTokenParameter(parameters[i])) { - mappedParameters[index++] = parameters[i]; + mappedParameters[index] = parameters[i]; + index++; } } @@ -222,8 +233,7 @@ private static bool IsCancellationTokenParameter(ParameterInfo parameter) => /// The multipart boundary text, or an empty string for non-multipart requests. private static string GetMultipartBoundary(MethodInfo methodInfo, bool isMultipart) => isMultipart - ? methodInfo.GetCustomAttribute(true)?.BoundaryText - ?? new MultipartAttribute().BoundaryText + ? methodInfo.GetCustomAttribute(true)!.BoundaryText // [Multipart] is present whenever isMultipart is true, and its BoundaryText is never null. : string.Empty; /// Determines whether the result type is one of the supported API response wrappers. @@ -342,334 +352,6 @@ private static void VerifyUrlPathIsSane(string relativePath, UrlResolutionMode u $"URL path {relativePath} must not contain CR or LF characters"); } - /// Builds the route parameter map and the ordered URL fragments for the relative path. - /// The relative URL path template. - /// The array of method parameters. - /// When true, a placeholder with no matching argument is left in the path verbatim instead of throwing. - /// A tuple containing the parameter map and the ordered list of URL fragments. - [RequiresUnreferencedCode("Binding route parameters from request object properties requires public property metadata to be available at runtime.")] - private static (Dictionary Map, List Fragments) - BuildParameterMap( - string relativePath, - ParameterInfo[] parameterInfo, - bool allowUnmatchedRouteParameters) - { - var ret = new Dictionary(); - - var parameterizedParts = ParameterRegex().Matches(relativePath); - - if (parameterizedParts.Count == 0) - { - return string.IsNullOrEmpty(relativePath) - ? (ret, []) - : (ret, [ParameterFragment.Constant(relativePath)]); - } - - var paramValidationDict = BuildParamValidationDict(parameterInfo); - var objectParamValidationDict = BuildObjectParamValidationDict(parameterInfo); - - var fragmentList = new List(); - var index = 0; - - for (var i = 0; i < parameterizedParts.Count; i++) - { - var match = parameterizedParts[i]; - - // Add constant value from given http path - if (match.Index != index) - { - fragmentList.Add(ParameterFragment.Constant(relativePath.Substring(index, match.Index - index))); - } - - index = match.Index + match.Length; - - AddFragmentForMatch( - relativePath, - parameterInfo, - ret, - fragmentList, - (paramValidationDict, objectParamValidationDict), - match, - allowUnmatchedRouteParameters); - } - - if (index >= relativePath.Length) - { - return (ret, fragmentList); - } - - // Add trailing string. - var trailingConstant = relativePath[index..]; - fragmentList.Add(ParameterFragment.Constant(trailingConstant)); - - return (ret, fragmentList); - } - - /// Builds a lookup of lower-cased URL parameter names to their declaring method parameter. - /// The array of method parameters. - /// A map of URL parameter names to method parameters. - private static Dictionary BuildParamValidationDict(ParameterInfo[] parameterInfo) - { - var paramValidationDict = new Dictionary(parameterInfo.Length); - for (var i = 0; i < parameterInfo.Length; i++) - { - paramValidationDict[GetUrlNameForParameter(parameterInfo[i]).ToLowerInvariant()] = parameterInfo[i]; - } - - return paramValidationDict; - } - - /// Builds a lookup of lower-cased "parameter.property" names to the parameter/property pair that can bind them. - /// The array of method parameters. - /// A map of nested property names to their parameter/property pair. - [RequiresUnreferencedCode("Binding route parameters from request object properties requires public property metadata to be available at runtime.")] - private static Dictionary> BuildObjectParamValidationDict( - ParameterInfo[] parameterInfo) - { - // If the parameter is a class, build a dictionary for all of its potential bound properties. - 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(); - _ = objectParamValidationDict.TryAdd(key, Tuple.Create(parameter, properties[j])); - } - } - - return objectParamValidationDict; - } - - /// Resolves a single parameterized URL fragment against the parameter maps and appends the result. - /// The relative URL path template. - /// The array of method parameters. - /// The parameter map being built. - /// The fragment list being built. - /// The lookups of directly matched parameter names and nested object-property names. - /// The parameterized URL match being resolved. - /// When true, an unmatched placeholder is left in the path verbatim instead of throwing. - private static void AddFragmentForMatch( - string relativePath, - ParameterInfo[] parameterInfo, - Dictionary ret, - List fragmentList, - (Dictionary Param, Dictionary> Object) validation, - Match match, - bool allowUnmatchedRouteParameters) - { - var rawName = match.Groups[1].Value.ToLowerInvariant(); - var isRoundTripping = rawName.StartsWith("**", StringComparison.Ordinal); - var name = isRoundTripping ? rawName[2..] : rawName; - - if (validation.Param.TryGetValue(name, out var value)) - { - AddStandardParameter( - relativePath, - parameterInfo, - ret, - fragmentList, - new(rawName, name, isRoundTripping), - value); - } - else if (validation.Object.TryGetValue(name, out var value1) && !isRoundTripping) - { - AddObjectPropertyParameter(parameterInfo, ret, fragmentList, name, value1); - } - else if (allowUnmatchedRouteParameters) - { - // Leave the unmatched placeholder in the URL verbatim (including its braces) so the - // caller can resolve it later, e.g. inside a DelegatingHandler. - fragmentList.Add(ParameterFragment.Constant(match.Value)); - } - else - { - throw new ArgumentException( - $"URL {relativePath} has parameter {rawName}, but no method parameter matches"); - } - } - - /// Adds a standard (directly matched) route parameter to the parameter map and fragment list. - /// The relative URL path template. - /// The array of method parameters. - /// The parameter map being built. - /// The fragment list being built. - /// The parsed parameter name details from the URL template. - /// The matched method parameter. - private static void AddStandardParameter( - string relativePath, - ParameterInfo[] parameterInfo, - Dictionary ret, - List fragmentList, - ParsedParameterName parsedName, - ParameterInfo value) - { - var paramType = value.ParameterType; - if (parsedName.IsRoundTripping && paramType != typeof(string)) - { - throw new ArgumentException( - $"URL {relativePath} has round-tripping parameter {parsedName.RawName}, but the type of matched method parameter is {paramType.FullName}. It must be a string."); - } - - var parameterType = parsedName.IsRoundTripping - ? ParameterType.RoundTripping - : ParameterType.Normal; - var restMethodParameterInfo = new RestMethodParameterInfo(parsedName.Name, value) { Type = parameterType }; - - var parameterIndex = Array.IndexOf(parameterInfo, restMethodParameterInfo.ParameterInfo); - fragmentList.Add(ParameterFragment.Dynamic(parameterIndex)); -#if NET6_0_OR_GREATER - _ = ret.TryAdd(parameterIndex, restMethodParameterInfo); -#else - if (ret.ContainsKey(parameterIndex)) - { - return; - } - - ret.Add(parameterIndex, restMethodParameterInfo); -#endif - } - - /// Adds an object-property route parameter to the parameter map and fragment list. - /// The array of method parameters. - /// The parameter map being built. - /// The fragment list being built. - /// The normalized parameter name. - /// The matched parameter and property pair. - private static void AddObjectPropertyParameter( - ParameterInfo[] parameterInfo, - Dictionary ret, - List fragmentList, - string name, - Tuple property) - { - var parameterIndex = Array.IndexOf(parameterInfo, property.Item1); - - // If we already have this parameter, add an additional ParameterProperty. - if (ret.TryGetValue(parameterIndex, out var value2)) - { - if (!value2.IsObjectPropertyParameter) - { - throw new ArgumentException( - $"Parameter {property.Item1.Name} matches both a parameter and nested parameter on a parameter object"); - } - - value2.ParameterProperties.Add(new(name, property.Item2)); - fragmentList.Add( - ParameterFragment.DynamicObject(parameterIndex, value2.ParameterProperties.Count - 1)); - return; - } - - var restMethodParameterInfo = new RestMethodParameterInfo(true, property.Item1); - restMethodParameterInfo.ParameterProperties.Add(new(name, property.Item2)); - - var idx = Array.IndexOf(parameterInfo, restMethodParameterInfo.ParameterInfo); - fragmentList.Add(ParameterFragment.DynamicObject(idx, 0)); -#if NET6_0_OR_GREATER - _ = ret.TryAdd(idx, restMethodParameterInfo); -#else - if (ret.ContainsKey(idx)) - { - return; - } - - ret.Add(idx, restMethodParameterInfo); -#endif - } - - /// Gets the URL name to use for a parameter, honoring any alias attribute. - /// The parameter whose URL name is resolved. - /// The aliased or declared parameter name. - private static string GetUrlNameForParameter(ParameterInfo paramInfo) - { - var aliasAttr = paramInfo.GetCustomAttribute(true); - return aliasAttr is not null ? aliasAttr.Name : paramInfo.Name!; - } - - /// Gets the URL name to use for a property, honoring any alias attribute. - /// The property whose URL name is resolved. - /// The aliased or declared property name. - private static string GetUrlNameForProperty(PropertyInfo propInfo) - { - var aliasAttr = propInfo.GetCustomAttribute(true); - return aliasAttr is not null ? aliasAttr.Name : propInfo.Name; - } - - /// Gets the multipart attachment name to use for a parameter. - /// The parameter whose attachment name is resolved. - /// The attachment name, or null when none is specified. - private static string GetAttachmentNameForParameter(ParameterInfo paramInfo) - { -#pragma warning disable CS0618 // Type or member is obsolete - var nameAttr = paramInfo.GetCustomAttribute(true); -#pragma warning restore CS0618 // Type or member is obsolete - - // also check for AliasAs - return nameAttr?.Name ?? paramInfo.GetCustomAttribute(true)?.Name!; - } - - /// Finds the parameter that carries the authorization value. - /// The array of method parameters. - /// The authorization parameter information, or null when there is no authorize parameter. - private static Tuple? FindAuthorizationParameter(ParameterInfo[] parameterArray) - { - AuthorizeAttribute? authorizeAttribute = null; - var authorizeIndex = -1; - - for (var i = 0; i < parameterArray.Length; i++) - { - var attribute = parameterArray[i].GetCustomAttribute(true); - if (attribute is null) - { - continue; - } - - if (authorizeAttribute is not null) - { - throw new ArgumentException("Only one parameter can be an Authorize parameter"); - } - - authorizeAttribute = attribute; - authorizeIndex = i; - } - - return authorizeAttribute is null - ? null - : Tuple.Create(authorizeAttribute.Scheme, authorizeIndex); - } - - /// Finds the single cancellation token parameter for the method. - /// The reflected method information. - /// The cancellation token parameter, or null when none is present. - private static ParameterInfo? FindCancellationTokenParameter(MethodInfo methodInfo) - { - var parameters = methodInfo.GetParameters(); - ParameterInfo? cancellationTokenParam = null; - for (var i = 0; i < parameters.Length; i++) - { - 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. @@ -772,10 +454,13 @@ private static Dictionary BuildHeaderParameterMap(ParameterInfo[] p } /// Determines the return type, result type, and deserialized result type for the method. - /// The reflected method information. + /// The reflected method whose return type is classified. + /// The wrapped result type of a matched return-type adapter, or + /// when the return type is a built-in shape or unadapted. /// A tuple of the return type, result type, and deserialized result type. private static (Type ReturnType, Type ReturnResultType, Type DeserializedResultType) DetermineReturnTypeInfo( - MethodInfo methodInfo) + MethodInfo methodInfo, + Type? adapterResultType) { var returnType = methodInfo.ReturnType; if ( @@ -797,6 +482,13 @@ private static (Type ReturnType, Type ReturnResultType, Type DeserializedResultT return (returnType, typeof(void), typeof(void)); } + // A registered return-type adapter surfaces this return type; the HTTP call materializes the adapter's + // wrapped result, so classify against that inner type just like a Task. + if (adapterResultType is not null) + { + return (returnType, adapterResultType, DetermineDeserializedResultType(adapterResultType)); + } + // Allow synchronous return types only for methods that are implemented by generated stubs // (for example explicit/default interface implementations). Public top-level Refit methods must // still use async-compatible return shapes. diff --git a/src/Refit.Reflection/ReturnTypeAdapterResolver.cs b/src/Refit.Reflection/ReturnTypeAdapterResolver.cs new file mode 100644 index 000000000..4d666af4f --- /dev/null +++ b/src/Refit.Reflection/ReturnTypeAdapterResolver.cs @@ -0,0 +1,251 @@ +// 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; + +/// Matches a Refit method's return type against the adapters registered for the reflection request builder. +/// +/// Matching reads only type metadata, so it stays trim-safe and Native-AOT-safe and can run while building method +/// metadata; closing a generic adapter over the return type needs runtime generic instantiation and is deferred to +/// at delegate-build time, which the reflection builder already gates behind +/// [RequiresDynamicCode]. +/// +internal static class ReturnTypeAdapterResolver +{ + /// Resolves the result type wrapped by a registered adapter that surfaces . + /// The declared return type of the interface method. + /// The adapter types registered on the settings. + /// The result type the HTTP call materializes (the adapter's TResult) when matched. + /// when a registered adapter surfaces the return type; otherwise . + [RequiresUnreferencedCode("Resolving return-type adapters inspects adapter interface metadata that trimming may remove.")] + internal static bool TryResolveResultType( + Type returnType, + IList registeredAdapters, + [NotNullWhen(true)] out Type? resultType) + { + for (var i = 0; i < registeredAdapters.Count; i++) + { + if (TryMatch(returnType, registeredAdapters[i], out _, out resultType)) + { + return true; + } + } + + resultType = null; + return false; + } + + /// Resolves the closed adapter type to instantiate for . + /// The declared return type of the interface method. + /// The adapter types registered on the settings. + /// The closed type, or when none matches. + [RequiresUnreferencedCode("Resolving return-type adapters inspects adapter interface metadata that trimming may remove.")] + [RequiresDynamicCode("Closing a generic return-type adapter over the return type requires runtime generic type instantiation.")] + internal static Type? ResolveClosedAdapterType(Type returnType, IList registeredAdapters) + { + for (var i = 0; i < registeredAdapters.Count; i++) + { + var adapter = registeredAdapters[i]; + if (TryMatch(returnType, adapter, out var typeArguments, out _)) + { + return typeArguments.Length == 0 ? adapter : adapter.MakeGenericType(typeArguments); + } + } + + return null; + } + + /// Matches an adapter type against a return type using only metadata, without instantiating anything. + /// The declared return type the adapter must surface. + /// The registered adapter type, either closed or an open generic definition. + /// The type arguments needed to close a generic adapter, or empty for a closed one. + /// The result type the adapter wraps (its TResult) when matched. + /// when the adapter surfaces the return type; otherwise . + [RequiresUnreferencedCode("Resolving return-type adapters inspects adapter interface metadata that trimming may remove.")] + private static bool TryMatch( + Type returnType, + Type adapter, + out Type[] typeArguments, + [NotNullWhen(true)] out Type? resultType) + { + typeArguments = []; + resultType = null; + + if (adapter is null) + { + return false; + } + + return adapter.IsGenericTypeDefinition + ? TryMatchGenericDefinition(returnType, adapter, out typeArguments, out resultType) + : TryMatchClosed(returnType, adapter, out resultType); + } + + /// Matches a non-generic (or already closed) adapter whose TReturn equals the return type. + /// The declared return type the adapter must surface. + /// The closed adapter type. + /// The adapter's TResult when matched. + /// when the adapter surfaces the return type; otherwise . + [RequiresUnreferencedCode("Resolving return-type adapters inspects adapter interface metadata that trimming may remove.")] + private static bool TryMatchClosed(Type returnType, Type adapter, [NotNullWhen(true)] out Type? resultType) + { + foreach (var implemented in adapter.GetInterfaces()) + { + if (IsAdapterInterface(implemented) && implemented.GetGenericArguments()[0] == returnType) + { + resultType = implemented.GetGenericArguments()[1]; + return true; + } + } + + resultType = null; + return false; + } + + /// Matches an open generic adapter definition using the single-wrapper heuristic. + /// The declared return type the adapter must surface. + /// The open generic adapter definition. + /// The return type's type arguments used to close the adapter when matched. + /// The adapter's TResult resolved against the return type when matched. + /// when the adapter surfaces the return type; otherwise . + /// + /// The adapter's TReturn must be a constructed type whose type arguments are the adapter's type parameters + /// in order (for example Adapter<T> : IReturnTypeAdapter<Wrapper<T>, T>), and whose + /// generic definition matches the return type's, so Wrapper<X> closes the adapter over X. + /// + [RequiresUnreferencedCode("Resolving return-type adapters inspects adapter interface metadata that trimming may remove.")] + private static bool TryMatchGenericDefinition( + Type returnType, + Type adapter, + out Type[] typeArguments, + [NotNullWhen(true)] out Type? resultType) + { + typeArguments = []; + resultType = null; + + if (!returnType.IsGenericType) + { + return false; + } + + var returnArguments = returnType.GetGenericArguments(); + if (returnArguments.Length != adapter.GetGenericArguments().Length) + { + return false; + } + + foreach (var implemented in adapter.GetInterfaces()) + { + if (!IsAdapterInterface(implemented)) + { + continue; + } + + var templateReturn = implemented.GetGenericArguments()[0]; + if (!TryMapTypeArguments(templateReturn, returnType, out var adapterOrdered)) + { + continue; + } + + var resolved = ResolveTemplateResult(implemented.GetGenericArguments()[1], adapterOrdered); + if (resolved is null) + { + continue; + } + + typeArguments = adapterOrdered; + resultType = resolved; + return true; + } + + return false; + } + + /// Determines whether an implemented interface is a closed . + /// The implemented interface to test. + /// when the interface is a return-type adapter. + private static bool IsAdapterInterface(Type implemented) => + implemented.IsGenericType && implemented.GetGenericTypeDefinition() == typeof(IReturnTypeAdapter<,>); + + /// Maps a wrapper return type's arguments onto the adapter's type parameters, in adapter declaration order. + /// The adapter's declared TReturn (open constructed over the adapter's parameters). + /// The declared return type of the interface method. + /// The concrete arguments ordered by the adapter's type parameters when the map succeeds. + /// when every adapter type parameter is bound consistently. + /// + /// Supports reordered and concrete-mixed wrappers, not only the in-order case: each template argument that is a generic + /// parameter binds the return type's argument at its position, and each concrete template argument must equal the + /// return type's argument, so Adapter<T1, T2> : IReturnTypeAdapter<Wrapper<T2, T1>, …> closes over a + /// Wrapper<A, B> return as Adapter<B, A>. Mirrors the generator's TryMapAdapterTypeArguments. + /// + private static bool TryMapTypeArguments(Type templateReturn, Type returnType, out Type[] adapterOrdered) + { + adapterOrdered = []; + if (!templateReturn.IsGenericType + || templateReturn.GetGenericTypeDefinition() != returnType.GetGenericTypeDefinition()) + { + return false; + } + + var templateArguments = templateReturn.GetGenericArguments(); + var returnArguments = returnType.GetGenericArguments(); + var mapped = new Type?[templateArguments.Length]; + for (var i = 0; i < templateArguments.Length; i++) + { + if (!TryBindArgument(templateArguments[i], returnArguments[i], mapped)) + { + return false; + } + } + + if (!Array.TrueForAll(mapped, static argument => argument is not null)) + { + return false; + } + + adapterOrdered = mapped!; + return true; + } + + /// Binds one template argument to its return-type argument, either through a type parameter or an exact match. + /// The adapter template's argument (a type parameter or a concrete type). + /// The return type's argument at the same position. + /// The per-type-parameter binding slots, filled by position. + /// when the argument binds consistently. + private static bool TryBindArgument(Type templateArgument, Type returnArgument, Type?[] mapped) + { + if (!templateArgument.IsGenericParameter) + { + return templateArgument == returnArgument; + } + + // The upstream arity check guarantees the parameter's position indexes into the mapped slots, matching the + // source-generated adapter binder, which indexes the same slots by ordinal without a bounds guard. + var position = templateArgument.GenericParameterPosition; + if (mapped[position] is { } existing) + { + return existing == returnArgument; + } + + mapped[position] = returnArgument; + return true; + } + + /// Resolves the adapter's template TResult against the return type's type arguments. + /// The adapter's declared TResult. + /// The return type's type arguments, ordered by the adapter's type parameters. + /// The concrete result type, or when it needs runtime generic instantiation. + private static Type? ResolveTemplateResult(Type templateResult, Type[] returnArguments) => + templateResult switch + { + { IsGenericParameter: true } => returnArguments[templateResult.GenericParameterPosition], + + // A concrete, fully-closed TResult (for example a bare class) is used verbatim; a TResult that is itself + // constructed over the adapter's type parameters would need MakeGenericType and is left to the caller. + { ContainsGenericParameters: false } => templateResult, + _ => null + }; +} diff --git a/src/Refit.Reflection/targets/Refit.Reflection.targets b/src/Refit.Reflection/targets/Refit.Reflection.targets new file mode 100644 index 000000000..e960af745 --- /dev/null +++ b/src/Refit.Reflection/targets/Refit.Reflection.targets @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/src/Refit.Testing/NetworkBehavior.cs b/src/Refit.Testing/NetworkBehavior.cs index 4f8308ceb..68d9e3f0d 100644 --- a/src/Refit.Testing/NetworkBehavior.cs +++ b/src/Refit.Testing/NetworkBehavior.cs @@ -25,16 +25,16 @@ namespace Refit.Testing; public sealed class NetworkBehavior { /// The default base delay, in seconds. - private const double DefaultDelaySeconds = 2d; + private const double DefaultDelaySeconds = 2D; /// The default latency variance as a fraction of the delay. - private const double DefaultVariance = 0.4d; + private const double DefaultVariance = 0.4D; /// The default probability that a request simulates a network failure. - private const double DefaultFailurePercent = 0.03d; + private const double DefaultFailurePercent = 0.03D; /// The width of the symmetric variance window applied to the delay. - private const double VarianceSpan = 2d; + private const double VarianceSpan = 2D; /// Guards , which is not thread-safe. private readonly Lock _gate = new(); @@ -88,8 +88,8 @@ public TimeSpan NextDelay() sample = _random.NextDouble(); } - var factor = 1d + (((sample * VarianceSpan) - 1d) * Variance); - return TimeSpan.FromMilliseconds(Delay.TotalMilliseconds * Math.Max(0d, factor)); + var factor = 1D + (((sample * VarianceSpan) - 1D) * Variance); + return TimeSpan.FromMilliseconds(Delay.TotalMilliseconds * Math.Max(0D, factor)); } /// Determines whether the next request should simulate a network failure. diff --git a/src/Refit.Testing/PublicAPI/net10.0/PublicAPI.Shipped.txt b/src/Refit.Testing/PublicAPI/net10.0/PublicAPI.Shipped.txt index 1ca550c14..5110f9967 100644 --- a/src/Refit.Testing/PublicAPI/net10.0/PublicAPI.Shipped.txt +++ b/src/Refit.Testing/PublicAPI/net10.0/PublicAPI.Shipped.txt @@ -128,3 +128,6 @@ static Refit.Testing.Route.Head(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Patch(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Post(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Put(string! template) -> Refit.Testing.RouteMatcher! +Refit.Testing.RouteMatcher.Fallback.get -> bool +Refit.Testing.RouteMatcher.Fallback.init -> void +static Refit.Testing.Route.Fallback() -> Refit.Testing.RouteMatcher! diff --git a/src/Refit.Testing/PublicAPI/net11.0/PublicAPI.Shipped.txt b/src/Refit.Testing/PublicAPI/net11.0/PublicAPI.Shipped.txt index 1ca550c14..5110f9967 100644 --- a/src/Refit.Testing/PublicAPI/net11.0/PublicAPI.Shipped.txt +++ b/src/Refit.Testing/PublicAPI/net11.0/PublicAPI.Shipped.txt @@ -128,3 +128,6 @@ static Refit.Testing.Route.Head(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Patch(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Post(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Put(string! template) -> Refit.Testing.RouteMatcher! +Refit.Testing.RouteMatcher.Fallback.get -> bool +Refit.Testing.RouteMatcher.Fallback.init -> void +static Refit.Testing.Route.Fallback() -> Refit.Testing.RouteMatcher! diff --git a/src/Refit.Testing/PublicAPI/net462/PublicAPI.Shipped.txt b/src/Refit.Testing/PublicAPI/net462/PublicAPI.Shipped.txt index 1ca550c14..5110f9967 100644 --- a/src/Refit.Testing/PublicAPI/net462/PublicAPI.Shipped.txt +++ b/src/Refit.Testing/PublicAPI/net462/PublicAPI.Shipped.txt @@ -128,3 +128,6 @@ static Refit.Testing.Route.Head(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Patch(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Post(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Put(string! template) -> Refit.Testing.RouteMatcher! +Refit.Testing.RouteMatcher.Fallback.get -> bool +Refit.Testing.RouteMatcher.Fallback.init -> void +static Refit.Testing.Route.Fallback() -> Refit.Testing.RouteMatcher! diff --git a/src/Refit.Testing/PublicAPI/net470/PublicAPI.Shipped.txt b/src/Refit.Testing/PublicAPI/net470/PublicAPI.Shipped.txt index 1ca550c14..5110f9967 100644 --- a/src/Refit.Testing/PublicAPI/net470/PublicAPI.Shipped.txt +++ b/src/Refit.Testing/PublicAPI/net470/PublicAPI.Shipped.txt @@ -128,3 +128,6 @@ static Refit.Testing.Route.Head(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Patch(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Post(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Put(string! template) -> Refit.Testing.RouteMatcher! +Refit.Testing.RouteMatcher.Fallback.get -> bool +Refit.Testing.RouteMatcher.Fallback.init -> void +static Refit.Testing.Route.Fallback() -> Refit.Testing.RouteMatcher! diff --git a/src/Refit.Testing/PublicAPI/net471/PublicAPI.Shipped.txt b/src/Refit.Testing/PublicAPI/net471/PublicAPI.Shipped.txt index 1ca550c14..5110f9967 100644 --- a/src/Refit.Testing/PublicAPI/net471/PublicAPI.Shipped.txt +++ b/src/Refit.Testing/PublicAPI/net471/PublicAPI.Shipped.txt @@ -128,3 +128,6 @@ static Refit.Testing.Route.Head(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Patch(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Post(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Put(string! template) -> Refit.Testing.RouteMatcher! +Refit.Testing.RouteMatcher.Fallback.get -> bool +Refit.Testing.RouteMatcher.Fallback.init -> void +static Refit.Testing.Route.Fallback() -> Refit.Testing.RouteMatcher! diff --git a/src/Refit.Testing/PublicAPI/net472/PublicAPI.Shipped.txt b/src/Refit.Testing/PublicAPI/net472/PublicAPI.Shipped.txt index 1ca550c14..5110f9967 100644 --- a/src/Refit.Testing/PublicAPI/net472/PublicAPI.Shipped.txt +++ b/src/Refit.Testing/PublicAPI/net472/PublicAPI.Shipped.txt @@ -128,3 +128,6 @@ static Refit.Testing.Route.Head(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Patch(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Post(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Put(string! template) -> Refit.Testing.RouteMatcher! +Refit.Testing.RouteMatcher.Fallback.get -> bool +Refit.Testing.RouteMatcher.Fallback.init -> void +static Refit.Testing.Route.Fallback() -> Refit.Testing.RouteMatcher! diff --git a/src/Refit.Testing/PublicAPI/net48/PublicAPI.Shipped.txt b/src/Refit.Testing/PublicAPI/net48/PublicAPI.Shipped.txt index 1ca550c14..5110f9967 100644 --- a/src/Refit.Testing/PublicAPI/net48/PublicAPI.Shipped.txt +++ b/src/Refit.Testing/PublicAPI/net48/PublicAPI.Shipped.txt @@ -128,3 +128,6 @@ static Refit.Testing.Route.Head(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Patch(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Post(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Put(string! template) -> Refit.Testing.RouteMatcher! +Refit.Testing.RouteMatcher.Fallback.get -> bool +Refit.Testing.RouteMatcher.Fallback.init -> void +static Refit.Testing.Route.Fallback() -> Refit.Testing.RouteMatcher! diff --git a/src/Refit.Testing/PublicAPI/net481/PublicAPI.Shipped.txt b/src/Refit.Testing/PublicAPI/net481/PublicAPI.Shipped.txt index 1ca550c14..5110f9967 100644 --- a/src/Refit.Testing/PublicAPI/net481/PublicAPI.Shipped.txt +++ b/src/Refit.Testing/PublicAPI/net481/PublicAPI.Shipped.txt @@ -128,3 +128,6 @@ static Refit.Testing.Route.Head(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Patch(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Post(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Put(string! template) -> Refit.Testing.RouteMatcher! +Refit.Testing.RouteMatcher.Fallback.get -> bool +Refit.Testing.RouteMatcher.Fallback.init -> void +static Refit.Testing.Route.Fallback() -> Refit.Testing.RouteMatcher! diff --git a/src/Refit.Testing/PublicAPI/net8.0/PublicAPI.Shipped.txt b/src/Refit.Testing/PublicAPI/net8.0/PublicAPI.Shipped.txt index 1ca550c14..5110f9967 100644 --- a/src/Refit.Testing/PublicAPI/net8.0/PublicAPI.Shipped.txt +++ b/src/Refit.Testing/PublicAPI/net8.0/PublicAPI.Shipped.txt @@ -128,3 +128,6 @@ static Refit.Testing.Route.Head(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Patch(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Post(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Put(string! template) -> Refit.Testing.RouteMatcher! +Refit.Testing.RouteMatcher.Fallback.get -> bool +Refit.Testing.RouteMatcher.Fallback.init -> void +static Refit.Testing.Route.Fallback() -> Refit.Testing.RouteMatcher! diff --git a/src/Refit.Testing/PublicAPI/net9.0/PublicAPI.Shipped.txt b/src/Refit.Testing/PublicAPI/net9.0/PublicAPI.Shipped.txt index 1ca550c14..5110f9967 100644 --- a/src/Refit.Testing/PublicAPI/net9.0/PublicAPI.Shipped.txt +++ b/src/Refit.Testing/PublicAPI/net9.0/PublicAPI.Shipped.txt @@ -128,3 +128,6 @@ static Refit.Testing.Route.Head(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Patch(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Post(string! template) -> Refit.Testing.RouteMatcher! static Refit.Testing.Route.Put(string! template) -> Refit.Testing.RouteMatcher! +Refit.Testing.RouteMatcher.Fallback.get -> bool +Refit.Testing.RouteMatcher.Fallback.init -> void +static Refit.Testing.Route.Fallback() -> Refit.Testing.RouteMatcher! diff --git a/src/Refit.Testing/Route.cs b/src/Refit.Testing/Route.cs index 06bff3664..5cf1d6119 100644 --- a/src/Refit.Testing/Route.cs +++ b/src/Refit.Testing/Route.cs @@ -52,4 +52,9 @@ public static class Route /// The path template (relative or absolute; {name} matches one segment). /// A route for the given method. public static RouteMatcher For(HttpMethod method, string template) => new() { Method = method, Template = template }; + + /// Matches any request not matched by a more specific route, tried after all one-shot and reusable + /// routes regardless of declaration order. + /// A catch-all fallback route. + public static RouteMatcher Fallback() => new() { Template = "*", Fallback = true }; } diff --git a/src/Refit.Testing/RouteMatcher.cs b/src/Refit.Testing/RouteMatcher.cs index 0f067a19b..737d1f017 100644 --- a/src/Refit.Testing/RouteMatcher.cs +++ b/src/Refit.Testing/RouteMatcher.cs @@ -53,4 +53,12 @@ public sealed class RouteMatcher /// Gets a value indicating whether this route may match repeatedly and is not required by . public bool Reusable { get; init; } + + /// + /// Gets a value indicating whether this route is a catch-all fallback, tried only after every one-shot and + /// reusable route fails to match, regardless of where it appears in the table. Like a reusable route it may match + /// any number of requests and is not required by . Build one with + /// . + /// + public bool Fallback { get; init; } } diff --git a/src/Refit.Testing/RouteTier.cs b/src/Refit.Testing/RouteTier.cs new file mode 100644 index 000000000..26ff3f0ca --- /dev/null +++ b/src/Refit.Testing/RouteTier.cs @@ -0,0 +1,18 @@ +// 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.Testing; + +/// The priority tier a stubbed route is matched in; tiers are tried in ascending order. +internal enum RouteTier +{ + /// A one-shot expectation, matched first and consumed on match. + OneShot, + + /// A reusable background stub, matched after one-shot expectations. + Reusable, + + /// A catch-all fallback, matched only when no other route matches. + Fallback, +} diff --git a/src/Refit.Testing/StubHttp.Matching.cs b/src/Refit.Testing/StubHttp.Matching.cs new file mode 100644 index 000000000..b2c3e12e7 --- /dev/null +++ b/src/Refit.Testing/StubHttp.Matching.cs @@ -0,0 +1,264 @@ +// 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 System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; + +namespace Refit.Testing; + +/// Route, path, query, header and body matching helpers for . +public sealed partial class StubHttp +{ + /// Matches the request path against a template, treating {name} segments as wildcards. + /// The expected template ("*" matches any; relative or absolute). + /// The request URI. + /// when the path matches the template. + private static bool MatchesTemplate(string template, Uri? actual) + { + if (template == "*") + { + return true; + } + + if (actual is null) + { + return false; + } + + var expectedPath = template.Split('?')[0]; + + // A relative template (e.g. "/users") matches the request's absolute path; an absolute template + // matches the full scheme/host/path. +#if NETFRAMEWORK + var isRelative = expectedPath.StartsWith("/", StringComparison.Ordinal); +#else + var isRelative = expectedPath.StartsWith('/'); +#endif + var actualPath = isRelative ? actual.AbsolutePath : actual.GetLeftPart(UriPartial.Path); + return SegmentsMatch(expectedPath, actualPath); + } + + /// Compares two paths segment by segment, where an expected {name} segment matches any one segment. + /// The expected template path. + /// The actual request path. + /// when the segments match. + private static bool SegmentsMatch(string expected, string actual) + { + var expectedSegments = expected.Split('/'); + var actualSegments = actual.Split('/'); + if (expectedSegments.Length != actualSegments.Length) + { + return false; + } + + for (var i = 0; i < expectedSegments.Length; i++) + { + var expectedSegment = expectedSegments[i]; + if (IsPlaceholder(expectedSegment)) + { + if (actualSegments[i].Length == 0) + { + return false; + } + + continue; + } + + if (!string.Equals(expectedSegment, actualSegments[i], StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + /// Determines whether a template segment is a {name} placeholder. + /// The template segment. + /// when the segment is a placeholder. + private static bool IsPlaceholder(string segment) => + segment.Length >= 2 && segment[0] == '{' && segment[^1] == '}'; + + /// Classifies a route into its priority tier. + /// The route to classify. + /// The route's priority tier. + private static RouteTier TierOf(RouteMatcher route) => + route switch + { + { Fallback: true } => RouteTier.Fallback, + { Reusable: true } => RouteTier.Reusable, + _ => RouteTier.OneShot + }; + + /// Applies the partial and exact query matchers, if any, to the request URI. + /// The candidate route. + /// The request URI. + /// when the query matches (or no query matcher is set). + private static bool MatchesQuery(RouteMatcher route, Uri? actual) + { + var raw = actual?.Query.TrimStart('?') ?? string.Empty; + var pairs = ParsePairs(raw); + return (route.ExactQuery is null || ExactMatch(pairs, ParsePairs(route.ExactQuery))) + && (route.ExactQueryParams is null || ExactMatch(pairs, route.ExactQueryParams)) + && (route.Query is null || ContainsAll(pairs, route.Query)); + } + + /// Reads and compares the request body for the exact-body and form-data matchers. + /// The candidate route. + /// The incoming request. + /// A token to cancel the body read. + /// when the body matches (or no body matcher is set). + private static async Task MatchesBodyAsync(RouteMatcher route, HttpRequestMessage request, CancellationToken cancellationToken) + { + if (route.Body is null && route.FormData is null) + { + return true; + } + + var body = request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + return (route.Body is null || string.Equals(body, route.Body, StringComparison.Ordinal)) + && (route.FormData is null || ContainsAll(ParsePairs(body), route.FormData)); + } + + /// Confirms the request carries each expected header, checking request and content headers. + /// The incoming request. + /// The expected header name/value pairs. + /// when every expected header is present with the expected value. + private static bool MatchesHeaders(HttpRequestMessage request, (string Name, string Value)[] headers) + { + foreach (var (name, value) in headers) + { + if (!HeaderMatches(request, name, value)) + { + return false; + } + } + + return true; + } + + /// Determines whether a single named header is present with the expected value. + /// The incoming request. + /// The header name. + /// The expected header value. + /// when the header matches. + private static bool HeaderMatches(HttpRequestMessage request, string name, string value) + { + var present = TryGetHeader(request.Headers, name, out var actual) || + (request.Content is not null && TryGetHeader(request.Content.Headers, name, out actual)); + return present && string.Equals(actual, value, StringComparison.Ordinal); + } + + /// Gets the combined value of a named header, if present. + /// The header collection to search. + /// The header name. + /// The comma-joined header value when found. + /// when the header exists. + private static bool TryGetHeader(HttpHeaders headers, string name, out string? value) + { + if (headers.TryGetValues(name, out var values)) + { + value = string.Join(", ", values); + return true; + } + + value = null; + return false; + } + + /// Parses a URL-encoded key=value&... string into decoded pairs. + /// The encoded query or form body. + /// The decoded key/value pairs, in order. + private static List<(string Key, string Value)> ParsePairs(string encoded) + { + var result = new List<(string, string)>(); + if (encoded.Length == 0) + { + return result; + } + + foreach (var pair in encoded.Split('&')) + { + if (pair.Length == 0) + { + continue; + } + + var eq = pair.IndexOf('='); + var key = eq < 0 ? pair : pair[..eq]; + var value = eq < 0 ? string.Empty : pair[(eq + 1)..]; + result.Add((Decode(key), Decode(value))); + } + + return result; + } + + /// URL-decodes a single query or form token. + /// The encoded token. + /// The decoded token. + private static string Decode(string value) => Uri.UnescapeDataString(value.Replace('+', ' ')); + + /// Determines whether every expected pair is present in the actual pairs. + /// The pairs parsed from the request. + /// The pairs that must all be present. + /// when all expected pairs are found. + private static bool ContainsAll(List<(string Key, string Value)> actual, (string Key, string Value)[] expected) + { + foreach (var (key, value) in expected) + { + if (!Contains(actual, key, value)) + { + return false; + } + } + + return true; + } + + /// Determines whether the actual pairs are exactly the expected pairs (same set, no extras). + /// The pairs parsed from the request. + /// The complete expected pairs. + /// when the pair sets are equal, ignoring order. + private static bool ExactMatch(List<(string Key, string Value)> actual, IReadOnlyList<(string Key, string Value)> expected) + { + if (actual.Count != expected.Count) + { + return false; + } + + foreach (var (key, value) in expected) + { + if (!Contains(actual, key, value)) + { + return false; + } + } + + return true; + } + + /// Determines whether a specific key/value pair is present. + /// The pairs to search. + /// The key to find. + /// The value to find. + /// when the pair exists. + private static bool Contains(List<(string Key, string Value)> pairs, string key, string value) + { + foreach (var pair in pairs) + { + if (pair.Key == key && pair.Value == value) + { + return true; + } + } + + return false; + } +} diff --git a/src/Refit.Testing/StubHttp.cs b/src/Refit.Testing/StubHttp.cs index a507fc876..5e826fca5 100644 --- a/src/Refit.Testing/StubHttp.cs +++ b/src/Refit.Testing/StubHttp.cs @@ -4,11 +4,11 @@ using System.Collections; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Net; using System.Net.Http; -using System.Net.Http.Headers; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -40,7 +40,7 @@ namespace Refit.Testing; /// including typed inspection of the sent body via . /// /// -public sealed class StubHttp : HttpMessageHandler, IEnumerable +public sealed partial class StubHttp : HttpMessageHandler, IEnumerable { /// The default timeout used by the parameterless . private static readonly TimeSpan DefaultVerifyTimeout = TimeSpan.FromSeconds(1); @@ -54,9 +54,6 @@ public sealed class StubHttp : HttpMessageHandler, IEnumerable /// Tracks, by index, which non-reusable route has already satisfied a request. private readonly List _consumed = []; - /// The requests received so far, in order. - private readonly List _requests = []; - /// The buffered request bodies, parallel to , captured before disposal. private readonly List _bodies = []; @@ -66,6 +63,12 @@ public sealed class StubHttp : HttpMessageHandler, IEnumerable /// Completes when every non-reusable route has been consumed; awaited by . private readonly TaskCompletionSource _allConsumed = new(TaskCreationOptions.RunContinuationsAsynchronously); + /// The requests received so far, in order. + private readonly List _requests = []; + + /// A cached read-only view over , returned by so reads never copy. + private readonly ReadOnlyCollection _requestsView; + /// The serializer used for typed replies and typed request capture; replaced by . private IHttpContentSerializer _serializer = new SystemTextJsonContentSerializer(); @@ -73,25 +76,15 @@ public sealed class StubHttp : HttpMessageHandler, IEnumerable private int _outstanding; /// Initializes a new instance of the class with an empty route table. - public StubHttp() - { - } + public StubHttp() => _requestsView = new(_requests); /// Initializes a new instance of the class with network-fault simulation. /// The network behavior applied to every matched request. - public StubHttp(NetworkBehavior behavior) => Behavior = behavior; + public StubHttp(NetworkBehavior behavior) + : this() => Behavior = behavior; - /// Gets a snapshot of the requests this handler has received, in order. - public IReadOnlyList Requests - { - get - { - lock (_gate) - { - return _requests.ToArray(); - } - } - } + /// Gets a read-only view of the requests this handler has received, in order. + public IReadOnlyList Requests => _requestsView; /// Gets or sets the network behavior simulated for each matched request; null disables simulation. public NetworkBehavior? Behavior { get; set; } @@ -109,7 +102,7 @@ public void Add(RouteMatcher route, StubResponse response) _routes.Add(route); _responses.Add(response); _consumed.Add(false); - if (!route.Reusable) + if (!route.Reusable && !route.Fallback) { _outstanding++; } @@ -142,8 +135,8 @@ public RefitSettings ToSettings(RefitSettings baseSettings) /// The base address the client sends requests to. /// A Refit client that sends every request to this handler. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "The interface type is intentionally specified explicitly by the caller, matching RestService.For.")] [RequiresUnreferencedCode("Creating a Refit client through the reflection path requires runtime type lookup and request metadata.")] public T CreateClient< @@ -163,8 +156,8 @@ public T CreateClient< /// The settings to route through this handler. /// A Refit client that sends every request to this handler. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "The interface type is intentionally specified explicitly by the caller, matching RestService.For.")] [RequiresUnreferencedCode("Creating a Refit client through the reflection path requires runtime type lookup and request metadata.")] public T CreateClient< @@ -184,8 +177,8 @@ public T CreateClient< /// A source-generated Refit client that sends every request to this handler. /// No generated implementation is registered for . [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "The interface type is intentionally specified explicitly by the caller, matching RestService.ForGenerated.")] public T CreateGeneratedClient(string hostUrl) => CreateGeneratedClient(hostUrl, new RefitSettings()); @@ -199,8 +192,8 @@ public T CreateClient< /// A source-generated Refit client that sends every request to this handler. /// No generated implementation is registered for . [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "The interface type is intentionally specified explicitly by the caller, matching RestService.ForGenerated.")] public T CreateGeneratedClient(string hostUrl, RefitSettings baseSettings) => RestService.ForGenerated(hostUrl, ToSettings(baseSettings)); @@ -209,8 +202,8 @@ public T CreateClient< /// The deserialized request body, or when the body is empty. /// No request has been received yet. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "The body type is intentionally specified explicitly by the caller, like a deserialization target.")] public Task LastRequestBodyAsync() { @@ -234,8 +227,8 @@ public T CreateClient< /// The deserialized request body, or when the body is empty. /// No request exists at . [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "The body type is intentionally specified explicitly by the caller, like a deserialization target.")] public Task RequestBodyAsync(int index) { @@ -326,11 +319,17 @@ protected override async Task SendAsync( // for typed inspection even after the client disposes the request. await BufferRequestAsync(request, requestIndex).ConfigureAwait(false); - // Non-reusable (one-shot) routes take priority; reusable background stubs are the fallback. - var index = await FindMatchAsync(request, false, cancellationToken).ConfigureAwait(false); + // Priority tiers, tried in order regardless of declaration order: one-shot expectations, then reusable + // background stubs, then catch-all fallbacks. + var index = await FindMatchAsync(request, RouteTier.OneShot, cancellationToken).ConfigureAwait(false); if (index < 0) { - index = await FindMatchAsync(request, true, cancellationToken).ConfigureAwait(false); + index = await FindMatchAsync(request, RouteTier.Reusable, cancellationToken).ConfigureAwait(false); + } + + if (index < 0) + { + index = await FindMatchAsync(request, RouteTier.Fallback, cancellationToken).ConfigureAwait(false); } if (index < 0) @@ -339,7 +338,7 @@ protected override async Task SendAsync( $"No stubbed route matched the request: {request.Method} {request.RequestUri}"); } - if (!_routes[index].Reusable) + if (!_routes[index].Reusable && !_routes[index].Fallback) { Consume(index); } @@ -380,244 +379,6 @@ private static async Task MatchesPredicatesAsync(RouteMatcher route, HttpR => (route.Where is null || route.Where(request)) && (route.WhereAsync is null || await route.WhereAsync(request).ConfigureAwait(false)); - /// Matches the request path against a template, treating {name} segments as wildcards. - /// The expected template ("*" matches any; relative or absolute). - /// The request URI. - /// when the path matches the template. - private static bool MatchesTemplate(string template, Uri? actual) - { - if (template == "*") - { - return true; - } - - if (actual is null) - { - return false; - } - - var expectedPath = template.Split('?')[0]; - - // A relative template (e.g. "/users") matches the request's absolute path; an absolute template - // matches the full scheme/host/path. -#if NETFRAMEWORK - var isRelative = expectedPath.StartsWith("/", StringComparison.Ordinal); -#else - var isRelative = expectedPath.StartsWith('/'); -#endif - var actualPath = isRelative ? actual.AbsolutePath : actual.GetLeftPart(UriPartial.Path); - return SegmentsMatch(expectedPath, actualPath); - } - - /// Compares two paths segment by segment, where an expected {name} segment matches any one segment. - /// The expected template path. - /// The actual request path. - /// when the segments match. - private static bool SegmentsMatch(string expected, string actual) - { - var expectedSegments = expected.Split('/'); - var actualSegments = actual.Split('/'); - if (expectedSegments.Length != actualSegments.Length) - { - return false; - } - - for (var i = 0; i < expectedSegments.Length; i++) - { - var expectedSegment = expectedSegments[i]; - if (IsPlaceholder(expectedSegment)) - { - if (actualSegments[i].Length == 0) - { - return false; - } - - continue; - } - - if (!string.Equals(expectedSegment, actualSegments[i], StringComparison.Ordinal)) - { - return false; - } - } - - return true; - } - - /// Determines whether a template segment is a {name} placeholder. - /// The template segment. - /// when the segment is a placeholder. - private static bool IsPlaceholder(string segment) => - segment.Length >= 2 && segment[0] == '{' && segment[^1] == '}'; - - /// Applies the partial and exact query matchers, if any, to the request URI. - /// The candidate route. - /// The request URI. - /// when the query matches (or no query matcher is set). - private static bool MatchesQuery(RouteMatcher route, Uri? actual) - { - var raw = actual?.Query.TrimStart('?') ?? string.Empty; - var pairs = ParsePairs(raw); - return (route.ExactQuery is null || ExactMatch(pairs, ParsePairs(route.ExactQuery))) - && (route.ExactQueryParams is null || ExactMatch(pairs, route.ExactQueryParams)) - && (route.Query is null || ContainsAll(pairs, route.Query)); - } - - /// Reads and compares the request body for the exact-body and form-data matchers. - /// The candidate route. - /// The incoming request. - /// A token to cancel the body read. - /// when the body matches (or no body matcher is set). - private static async Task MatchesBodyAsync(RouteMatcher route, HttpRequestMessage request, CancellationToken cancellationToken) - { - if (route.Body is null && route.FormData is null) - { - return true; - } - - var body = request.Content is null - ? string.Empty - : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); - - return (route.Body is null || string.Equals(body, route.Body, StringComparison.Ordinal)) - && (route.FormData is null || ContainsAll(ParsePairs(body), route.FormData)); - } - - /// Confirms the request carries each expected header, checking request and content headers. - /// The incoming request. - /// The expected header name/value pairs. - /// when every expected header is present with the expected value. - private static bool MatchesHeaders(HttpRequestMessage request, (string Name, string Value)[] headers) - { - foreach (var (name, value) in headers) - { - if (!HeaderMatches(request, name, value)) - { - return false; - } - } - - return true; - } - - /// Determines whether a single named header is present with the expected value. - /// The incoming request. - /// The header name. - /// The expected header value. - /// when the header matches. - private static bool HeaderMatches(HttpRequestMessage request, string name, string value) - { - var present = TryGetHeader(request.Headers, name, out var actual) || - (request.Content is not null && TryGetHeader(request.Content.Headers, name, out actual)); - return present && string.Equals(actual, value, StringComparison.Ordinal); - } - - /// Gets the combined value of a named header, if present. - /// The header collection to search. - /// The header name. - /// The comma-joined header value when found. - /// when the header exists. - private static bool TryGetHeader(HttpHeaders headers, string name, out string? value) - { - if (headers.TryGetValues(name, out var values)) - { - value = string.Join(", ", values); - return true; - } - - value = null; - return false; - } - - /// Parses a URL-encoded key=value&... string into decoded pairs. - /// The encoded query or form body. - /// The decoded key/value pairs, in order. - private static List<(string Key, string Value)> ParsePairs(string encoded) - { - var result = new List<(string, string)>(); - if (encoded.Length == 0) - { - return result; - } - - foreach (var pair in encoded.Split('&')) - { - if (pair.Length == 0) - { - continue; - } - - var eq = pair.IndexOf('='); - var key = eq < 0 ? pair : pair[..eq]; - var value = eq < 0 ? string.Empty : pair[(eq + 1)..]; - result.Add((Decode(key), Decode(value))); - } - - return result; - } - - /// URL-decodes a single query or form token. - /// The encoded token. - /// The decoded token. - private static string Decode(string value) => Uri.UnescapeDataString(value.Replace('+', ' ')); - - /// Determines whether every expected pair is present in the actual pairs. - /// The pairs parsed from the request. - /// The pairs that must all be present. - /// when all expected pairs are found. - private static bool ContainsAll(List<(string Key, string Value)> actual, (string Key, string Value)[] expected) - { - foreach (var (key, value) in expected) - { - if (!Contains(actual, key, value)) - { - return false; - } - } - - return true; - } - - /// Determines whether the actual pairs are exactly the expected pairs (same set, no extras). - /// The pairs parsed from the request. - /// The complete expected pairs. - /// when the pair sets are equal, ignoring order. - private static bool ExactMatch(List<(string Key, string Value)> actual, IReadOnlyList<(string Key, string Value)> expected) - { - if (actual.Count != expected.Count) - { - return false; - } - - foreach (var (key, value) in expected) - { - if (!Contains(actual, key, value)) - { - return false; - } - } - - return true; - } - - /// Determines whether a specific key/value pair is present. - /// The pairs to search. - /// The key to find. - /// The value to find. - /// when the pair exists. - private static bool Contains(List<(string Key, string Value)> pairs, string key, string value) - { - foreach (var pair in pairs) - { - if (pair.Key == key && pair.Value == value) - { - return true; - } - } - - return false; - } - /// Cancels the token source, using the async cancellation path where the framework provides it. /// The token source to cancel. /// A task that completes once cancellation has been requested. @@ -678,8 +439,8 @@ private async Task BuildResponseAsync(StubResponse response /// The buffered body, or when none was captured. /// The deserialized body, or when there is no content. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "The body type is intentionally specified explicitly by the caller, like a deserialization target.")] private Task DeserializeBodyAsync(CapturedBody? body) { @@ -750,7 +511,7 @@ private void ThrowIfOutstanding() for (var i = 0; i < _routes.Count; i++) { var route = _routes[i]; - if (route.Reusable || _consumed[i]) + if (route.Reusable || route.Fallback || _consumed[i]) { continue; } @@ -785,7 +546,8 @@ private void Consume(int index) } _consumed[index] = true; - if (--_outstanding == 0) + --_outstanding; + if (_outstanding == 0) { _ = _allConsumed.TrySetResult(true); } @@ -822,12 +584,12 @@ private void Consume(int index) return error; } - /// Finds the first eligible route of the requested kind that matches the request. + /// Finds the first eligible route of the requested priority tier that matches the request. /// The incoming request. - /// Whether to search reusable (background) routes or one-shot expectations. + /// The priority tier to search. /// A token to cancel body reads. /// The matching route index, or -1 if none match. - private async Task FindMatchAsync(HttpRequestMessage request, bool reusable, CancellationToken cancellationToken) + private async Task FindMatchAsync(HttpRequestMessage request, RouteTier tier, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -842,7 +604,7 @@ private async Task FindMatchAsync(HttpRequestMessage request, bool reusable for (var i = 0; i < routes.Length; i++) { var route = routes[i]; - if (route.Reusable != reusable || (!reusable && consumed[i])) + if (TierOf(route) != tier || (tier == RouteTier.OneShot && consumed[i])) { continue; } diff --git a/src/Refit.Xml/XmlContentSerializer.cs b/src/Refit.Xml/XmlContentSerializer.cs index 83f771d04..a32e8ba1c 100644 --- a/src/Refit.Xml/XmlContentSerializer.cs +++ b/src/Refit.Xml/XmlContentSerializer.cs @@ -82,7 +82,10 @@ public HttpContent ToHttpContent(T item) /// The deserialized object of type . [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = XmlReflectionTrimmingJustification)] [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = XmlReflectionAotJustification)] - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter selected explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Type parameter selected explicitly by callers.")] public async Task FromHttpContentAsync( HttpContent content, CancellationToken cancellationToken = default) @@ -110,7 +113,10 @@ public HttpContent ToHttpContent(T item) /// [UnconditionalSuppressMessage("Trimming", "IL2026:RequiresUnreferencedCode", Justification = XmlReflectionTrimmingJustification)] [UnconditionalSuppressMessage("AOT", "IL3050:RequiresDynamicCode", Justification = XmlReflectionAotJustification)] - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter selected explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Type parameter selected explicitly by callers.")] public T? DeserializeFromString(string content) { var xmlSerializer = _serializerCache.GetOrAdd( diff --git a/src/Refit.Xml/XmlReaderWriterSettings.cs b/src/Refit.Xml/XmlReaderWriterSettings.cs index 3be9a97e2..e9e52b387 100644 --- a/src/Refit.Xml/XmlReaderWriterSettings.cs +++ b/src/Refit.Xml/XmlReaderWriterSettings.cs @@ -108,8 +108,8 @@ public XmlWriterSettings WriterSettings "Enabling DTD processing re-opens XML External Entity (XXE) and entity-expansion attacks and is strongly " + "discouraged. Only set this for XML from a fully trusted source.")] [SuppressMessage( - "Major Code Smell", - "S1133:Do not forget to remove this deprecated code someday", + "Design", + "SST2310:Deprecated code should be removed", Justification = "The attribute is a permanent, intentional warning on a security opt-out, not pending-removal deprecation.")] public bool AllowDtdProcessing { get; set; } diff --git a/src/Refit.slnx b/src/Refit.slnx index bda4ec1d8..656b3754d 100644 --- a/src/Refit.slnx +++ b/src/Refit.slnx @@ -51,6 +51,7 @@ + diff --git a/src/Refit/ApiException.cs b/src/Refit/ApiException.cs index 38b665ab8..4aaff420b 100644 --- a/src/Refit/ApiException.cs +++ b/src/Refit/ApiException.cs @@ -10,12 +10,8 @@ namespace Refit; /// Represents an error that occurred after a response was received from the server. [SuppressMessage( - "Usage", - "CA1032:Implement standard exception constructors", - Justification = "This exception requires HTTP request/response context and cannot be constructed via the parameterless or message-only constructors.")] -[SuppressMessage( - "Major Code Smell", - "S4027:Exceptions should provide standard constructors", + "Design", + "SST1488:Exception types should declare the standard constructors", Justification = "This exception requires HTTP request/response context and cannot be constructed via the parameterless or message-only constructors.")] public class ApiException : ApiExceptionBase { @@ -56,6 +52,7 @@ protected ApiException( /// The headers. /// The refit settings. /// The inner exception. + [SuppressMessage("Design", "SST1472:Signatures should not declare too many parameters", Justification = "Shipped protected API constructor; grouping parameters would break the public surface.")] protected ApiException( HttpRequestMessage message, HttpMethod httpMethod, @@ -87,6 +84,7 @@ protected ApiException( /// The reason phrase. /// The headers. /// The refit settings. + [SuppressMessage("Design", "SST1472:Signatures should not declare too many parameters", Justification = "Shipped protected API constructor; grouping parameters would break the public surface.")] protected ApiException( string exceptionMessage, HttpRequestMessage message, @@ -119,6 +117,7 @@ protected ApiException( /// The headers. /// The refit settings. /// The inner exception. + [SuppressMessage("Design", "SST1472:Signatures should not declare too many parameters", Justification = "Shipped protected API constructor; grouping parameters would break the public surface.")] protected ApiException( string exceptionMessage, HttpRequestMessage message, @@ -242,10 +241,6 @@ public static Task Create( "Usage", "VSTHRD200:Use \"Async\" suffix for async methods", Justification = "Public API name preserved for backwards compatibility.")] - [SuppressMessage( - "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.")] public static async Task Create( string exceptionMessage, HttpRequestMessage message, @@ -310,8 +305,8 @@ public static async Task Create( /// Type to deserialize the content to. /// The response content deserialized as [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] public async Task GetContentAsAsync() => HasContent @@ -331,8 +326,8 @@ public static async Task Create( /// . /// [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] public T? GetContentAs() { @@ -362,13 +357,9 @@ public static async Task Create( /// when the content was present and deserialized to a non-null value; otherwise . /// [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] - [SuppressMessage( - "Major Code Smell", - "CA1031:Do not catch general exception types", - Justification = "Try-pattern peek into an error body must never throw; any failure simply yields false.")] public bool TryGetContentAs(out T? content) { content = default; diff --git a/src/Refit/ApiExceptionBase.cs b/src/Refit/ApiExceptionBase.cs index 91ba6c4d8..e76240e9e 100644 --- a/src/Refit/ApiExceptionBase.cs +++ b/src/Refit/ApiExceptionBase.cs @@ -7,13 +7,13 @@ namespace Refit; /// Represents an error that occurred while sending an API request. [SuppressMessage( - "Usage", - "CA1032:Implement standard exception constructors", + "Design", + "SST1488:Exception types should declare the standard constructors", Justification = "This exception requires HTTP request/response context and cannot be constructed via the parameterless or message-only constructors.")] [SuppressMessage( - "Major Code Smell", - "S4027:Exceptions should provide standard constructors", - Justification = "This exception requires HTTP request/response context and cannot be constructed via the parameterless or message-only constructors.")] + "Design", + "SST1496:An abstract type declares nothing abstract", + Justification = "Intentional abstract base for the API exception hierarchy; instantiation is prevented by its protected constructors and it shares state rather than an abstract contract.")] public abstract class ApiExceptionBase : Exception { /// Initializes a new instance of the class. diff --git a/src/Refit/ApiRequestException.cs b/src/Refit/ApiRequestException.cs index 32b4c4af1..d5a291bc7 100644 --- a/src/Refit/ApiRequestException.cs +++ b/src/Refit/ApiRequestException.cs @@ -11,12 +11,8 @@ namespace Refit; /// such as and . /// [SuppressMessage( - "Usage", - "CA1032:Implement standard exception constructors", - Justification = "This exception requires HTTP request/response context and cannot be constructed via the parameterless or message-only constructors.")] -[SuppressMessage( - "Major Code Smell", - "S4027:Exceptions should provide standard constructors", + "Design", + "SST1488:Exception types should declare the standard constructors", Justification = "This exception requires HTTP request/response context and cannot be constructed via the parameterless or message-only constructors.")] public class ApiRequestException : ApiExceptionBase { diff --git a/src/Refit/ApiResponse.cs b/src/Refit/ApiResponse.cs index 5b2bf735b..90eea53ec 100644 --- a/src/Refit/ApiResponse.cs +++ b/src/Refit/ApiResponse.cs @@ -18,8 +18,8 @@ internal static class ApiResponse /// The exception, if the request failed. /// The created API response. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] internal static T Create( HttpRequestMessage request, diff --git a/src/Refit/ApiResponseExtensions.cs b/src/Refit/ApiResponseExtensions.cs index 5891a60eb..45e6ac45c 100644 --- a/src/Refit/ApiResponseExtensions.cs +++ b/src/Refit/ApiResponseExtensions.cs @@ -20,12 +20,12 @@ public static class ApiResponseExtensions /// The response is . /// Thrown when an unsuccessful response was received from the server. /// Thrown when the request failed before receiving a response from the server. - public Task> EnsureSuccessStatusCodeAsync() + public ValueTask> EnsureSuccessStatusCodeAsync() { var checkedResponse = response ?? throw new ArgumentNullException(nameof(response)); return checkedResponse.IsSuccessStatusCode - ? Task.FromResult(checkedResponse) - : Task.FromException>(GetError(checkedResponse)); + ? new(checkedResponse) + : new(Task.FromException>(GetError(checkedResponse))); } /// @@ -36,12 +36,12 @@ public Task> EnsureSuccessStatusCodeAsync() /// The response is . /// Thrown when an unsuccessful response was received from the server. /// Thrown when the request failed before receiving a response from the server. - public Task> EnsureSuccessfulAsync() + public ValueTask> EnsureSuccessfulAsync() { var checkedResponse = response ?? throw new ArgumentNullException(nameof(response)); return checkedResponse.IsSuccessful - ? Task.FromResult(checkedResponse) - : Task.FromException>(GetError(checkedResponse)); + ? new(checkedResponse) + : new(Task.FromException>(GetError(checkedResponse))); } } diff --git a/src/Refit/ApiResponse{T}.cs b/src/Refit/ApiResponse{T}.cs index 418350af3..4fe9a0c96 100644 --- a/src/Refit/ApiResponse{T}.cs +++ b/src/Refit/ApiResponse{T}.cs @@ -81,6 +81,7 @@ public ApiResponse( public HttpResponseHeaders? Headers => response?.Headers; /// Gets the HTTP response content headers as defined in RFC 2616. + [ExcludeFromCodeCoverage] // Content is nullable in the BCL contract, but HttpClient always sets it, so the null-defensive branch is unreachable. public HttpContentHeaders? ContentHeaders => response?.Content?.Headers; /// @@ -127,18 +128,18 @@ 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 Task> EnsureSuccessStatusCodeAsync() => + public ValueTask> EnsureSuccessStatusCodeAsync() => IsSuccessStatusCode - ? Task.FromResult(this) + ? new(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 Task> EnsureSuccessfulAsync() => + public ValueTask> EnsureSuccessfulAsync() => IsSuccessful - ? Task.FromResult(this) + ? new(this) : EnsureSlowAsync(); /// @@ -168,9 +169,21 @@ public bool HasResponseError( /// Releases the underlying response when disposing. /// Whether the method is being called from . + [ExcludeFromCodeCoverage] // There is no finalizer, so Dispose(false) never runs and the !disposing guard is unreachable. private void Dispose(bool disposing) { - if (!disposing || _disposed) + if (!disposing) + { + return; + } + + DisposeResponse(); + } + + /// Disposes the underlying response once, guarding against repeated disposal. + private void DisposeResponse() + { + if (_disposed) { return; } @@ -182,11 +195,11 @@ private void Dispose(bool disposing) /// Throws the appropriate API exception for an unsuccessful response. /// A task that represents the asynchronous validation operation. - private Task> EnsureSlowAsync() => ThrowsApiExceptionAsync(); + private ValueTask> 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 ValueTask> ThrowsApiExceptionAsync() { var responseMessage = response ?? throw new InvalidOperationException( diff --git a/src/Refit/AttachmentNameAttribute.cs b/src/Refit/AttachmentNameAttribute.cs index 6edbb413e..6b6243ab4 100644 --- a/src/Refit/AttachmentNameAttribute.cs +++ b/src/Refit/AttachmentNameAttribute.cs @@ -14,8 +14,8 @@ namespace Refit; "Use Refit.StreamPart, Refit.ByteArrayPart, Refit.FileInfoPart or if necessary, inherit from Refit.MultipartItem", false)] [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Major Code Smell", - "S1133:Deprecated code should be removed", + "Design", + "SST2310:Deprecated code should be removed", Justification = "Public API retained for backwards compatibility; cannot remove without a breaking change.")] public sealed class AttachmentNameAttribute(string name) : Attribute { diff --git a/src/Refit/BodySerializationMethod.cs b/src/Refit/BodySerializationMethod.cs index a9341805e..3915333ad 100644 --- a/src/Refit/BodySerializationMethod.cs +++ b/src/Refit/BodySerializationMethod.cs @@ -13,8 +13,8 @@ public enum BodySerializationMethod /// Json encodes everything, including strings. [Obsolete("Use BodySerializationMethod.Serialized instead", false)] [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Major Code Smell", - "S1133:Deprecated code should be removed", + "Design", + "SST2310:Deprecated code should be removed", Justification = "Public API retained for backwards compatibility; cannot remove without a breaking change.")] Json, diff --git a/src/Refit/Buffers/PooledBufferWriter.Stream.cs b/src/Refit/Buffers/PooledBufferWriter.Stream.cs index 6f4a9c32f..86d3dfc96 100644 --- a/src/Refit/Buffers/PooledBufferWriter.Stream.cs +++ b/src/Refit/Buffers/PooledBufferWriter.Stream.cs @@ -180,7 +180,14 @@ public override int ReadByte() { var pooledBuffer = _pooledBuffer ?? throw CreateObjectDisposedException(); - return _position >= _length ? -1 : pooledBuffer[_position++]; + if (_position >= _length) + { + return -1; + } + + var value = pooledBuffer[_position]; + _position++; + return value; } /// diff --git a/src/Refit/DefaultApiExceptionFactory.cs b/src/Refit/DefaultApiExceptionFactory.cs index 541e419ce..3d3f40024 100644 --- a/src/Refit/DefaultApiExceptionFactory.cs +++ b/src/Refit/DefaultApiExceptionFactory.cs @@ -8,22 +8,19 @@ namespace Refit; /// The Refit settings used when building exceptions. public class DefaultApiExceptionFactory(RefitSettings refitSettings) { - /// A completed task that yields a null exception for successful responses. - private static readonly Task _nullTask = Task.FromResult(null); - /// Creates the asynchronous. /// The response message. /// A task that yields the created exception, or null when the response was successful. - public Task CreateAsync(HttpResponseMessage responseMessage) => + public ValueTask CreateAsync(HttpResponseMessage responseMessage) => responseMessage?.IsSuccessStatusCode == false ? CreateExceptionAsync(responseMessage, refitSettings) - : _nullTask; + : default; /// Builds an for the given unsuccessful response. /// The response message. /// The Refit settings. /// The created exception. - private static async Task CreateExceptionAsync( + private static async ValueTask CreateExceptionAsync( HttpResponseMessage responseMessage, RefitSettings refitSettings) { diff --git a/src/Refit/DefaultUrlParameterFormatter.cs b/src/Refit/DefaultUrlParameterFormatter.cs index 60584d454..6edeb1bc8 100644 --- a/src/Refit/DefaultUrlParameterFormatter.cs +++ b/src/Refit/DefaultUrlParameterFormatter.cs @@ -11,6 +11,15 @@ namespace Refit; /// Default Url parameter formater. public class DefaultUrlParameterFormatter : IUrlParameterFormatter { + /// + /// Gets a value indicating whether this instance is the unmodified default formatter — not a derived type and + /// with no registered formats — so generated request building may format statically-known values inline. + /// + internal bool IsPristineDefault => + GetType() == typeof(DefaultUrlParameterFormatter) + && SpecificFormats.Count == 0 + && GeneralFormats.Count == 0; + /// Gets the registered format strings keyed by container and parameter type. private Dictionary<(Type containerType, Type parameterType), string> SpecificFormats { get; } = []; @@ -24,14 +33,20 @@ public class DefaultUrlParameterFormatter : IUrlParameterFormatter /// The format string. /// Container class type. /// Parameter type. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Type parameter intentionally specified explicitly by callers.")] public void AddFormat(string format) => SpecificFormats.Add((typeof(TContainer), typeof(TParameter)), format); /// Add format for specified parameter type. Might be suppressed by a QueryAttribute format or a container specific format. /// The format string. /// Parameter type. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Type parameter intentionally specified explicitly by callers.")] public void AddFormat(string format) => GeneralFormats.Add(typeof(TParameter), format); /// Formats the specified parameter value. @@ -71,16 +86,9 @@ public void AddFormat(string format) => /// The first query attribute, or null when absent. private static QueryAttribute? GetFirstQueryAttribute(ICustomAttributeProvider attributeProvider) { + // GetCustomAttributes is type-filtered, so every element is a QueryAttribute; take the first, if any. 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; + return attributes.Length > 0 ? attributes[0] as QueryAttribute : null; } /// Selects the effective format string, preferring the attribute format, then specific, then general formats. diff --git a/src/Refit/DelegatingStream.cs b/src/Refit/DelegatingStream.cs index 4683122a0..0a96f87b2 100644 --- a/src/Refit/DelegatingStream.cs +++ b/src/Refit/DelegatingStream.cs @@ -11,6 +11,10 @@ namespace Refit; /// https://github.com/ASP-NET-MVC/aspnetwebstack/blob/d5188c8a75b5b26b09ab89bedfd7ee635ae2ff17/src/System.Net.Http.Formatting/Internal/DelegatingStream.cs. /// [ExcludeFromCodeCoverage] +[SuppressMessage( + "Design", + "SST1496:An abstract type declares nothing abstract", + Justification = "Verbatim base class ported from System.Net.Http; kept abstract so only concrete stream wrappers are instantiated.")] internal abstract class DelegatingStream : Stream { /// Indicates whether the inner stream is disposed when this stream is disposed. diff --git a/src/Refit/EncodedAttribute.cs b/src/Refit/EncodedAttribute.cs new file mode 100644 index 000000000..af1ab600f --- /dev/null +++ b/src/Refit/EncodedAttribute.cs @@ -0,0 +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. + +namespace Refit; + +/// +/// Marks a parameter value as already URL-encoded so Refit passes it through verbatim instead of escaping it — +/// the equivalent of Retrofit's encoded = true. Applies to path segment values (including round-tripping +/// {**param} segments), query values, and flags. The caller becomes +/// responsible for producing valid encoded output; malformed values are sent as-is. +/// +[AttributeUsage(AttributeTargets.Parameter)] +public sealed class EncodedAttribute : Attribute; diff --git a/src/Refit/FormValueMultimap.cs b/src/Refit/FormValueMultimap.cs index d4e1cd4d2..5c004838c 100644 --- a/src/Refit/FormValueMultimap.cs +++ b/src/Refit/FormValueMultimap.cs @@ -167,8 +167,8 @@ private static string GetDelimiter(CollectionFormat collectionFormat) => /// The Refit settings controlling formatting. /// The joined formatted value. [SuppressMessage( - "Major Code Smell", - "S2930:\"IDisposables\" should be disposed", + "Correctness", + "SST2410:A created disposable is never disposed", Justification = "ValueStringBuilder.ToString() disposes the builder and returns its pooled buffer; Dispose is idempotent.")] private static string JoinFormattedValues( IEnumerable enumerable, diff --git a/src/Refit/GeneratedParameterAttributeProvider.cs b/src/Refit/GeneratedParameterAttributeProvider.cs index 5f9dcd7e6..40f48a86c 100644 --- a/src/Refit/GeneratedParameterAttributeProvider.cs +++ b/src/Refit/GeneratedParameterAttributeProvider.cs @@ -2,7 +2,6 @@ // 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.Reflection; namespace Refit; @@ -11,41 +10,23 @@ namespace Refit; /// The attribute information. public sealed class GeneratedParameterAttributeProvider(Dictionary attributes) : ICustomAttributeProvider { - /// Gets a lazily initialised array of all attributes. - private object[] AllAttributesCache + /// A shared provider for parameters that declare no attributes, avoiding a per-parameter empty dictionary. + public static readonly GeneratedParameterAttributeProvider Empty = new([]); + + /// The lazily flattened array of every attribute, memoized on first access. + private object[]? _allAttributes; + + /// + public object[] GetCustomAttributes(bool inherit) { - [SuppressMessage("StyleCop.Analyzers", "SST1443:ReduceNestedFlowComplexity", Justification = "The nested logic is necessary here to avoid LINQ usage.")] - get + if (Volatile.Read(ref _allAttributes) is { } cached) { - if (field is not null) - { - return field; - } - - var totalCount = 0; - foreach (var attributeArray in attributes.Values) - { - totalCount += attributeArray.Length; - } - - var allAttributes = new object[totalCount]; - var index = 0; - foreach (var attributeArray in attributes.Values) - { - foreach (var item in attributeArray) - { - allAttributes[index++] = item; - } - } - - _ = Interlocked.CompareExchange(ref field, allAttributes, null); - - return field; + return cached; } - } - /// - public object[] GetCustomAttributes(bool inherit) => AllAttributesCache; + var flattened = FlattenAttributes(attributes); + return Interlocked.CompareExchange(ref _allAttributes, flattened, null) ?? flattened; + } /// public object[] GetCustomAttributes(Type attributeType, bool inherit) @@ -57,4 +38,26 @@ public object[] GetCustomAttributes(Type attributeType, bool inherit) /// public bool IsDefined(Type attributeType, bool inherit) => attributes.ContainsKey(attributeType); + + /// Flattens the per-type attribute arrays into a single array without nested iteration. + /// The attribute arrays keyed by attribute type. + /// Every attribute in a single array. + private static object[] FlattenAttributes(Dictionary attributes) + { + var totalCount = 0; + foreach (var attributeArray in attributes.Values) + { + totalCount += attributeArray.Length; + } + + var allAttributes = new object[totalCount]; + var index = 0; + foreach (var attributeArray in attributes.Values) + { + Array.Copy(attributeArray, 0, allAttributes, index, attributeArray.Length); + index += attributeArray.Length; + } + + return allAttributes; + } } diff --git a/src/Refit/GeneratedQueryStringBuilder.cs b/src/Refit/GeneratedQueryStringBuilder.cs new file mode 100644 index 000000000..ee59b9b0d --- /dev/null +++ b/src/Refit/GeneratedQueryStringBuilder.cs @@ -0,0 +1,369 @@ +// 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; + +namespace Refit; + +/// +/// Appends query parameters to a relative request path without reflection, matching the escaping, ordering, +/// null-omission and collection-format semantics of the reflection request builder. Used by source-generated +/// request construction; the API is also callable directly by hand-written AOT-friendly clients. +/// +/// +/// Values must already be formatted (see and +/// ); a formatted value omits its parameter. Query keys +/// and values are escaped with unless a call passes +/// preEncoded: true (the contract). +/// +[System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] +public ref struct GeneratedQueryStringBuilder +{ + /// The extra capacity reserved beyond the path when the first parameter is appended. + private const int InitialQueryCapacity = 128; + +#if NET6_0_OR_GREATER + /// The stack buffer size for span-formatting a single query value; larger renderings grow a rented buffer. + private const int FormatBufferLength = 128; + + /// The factor by which the rented span-formatting buffer grows when a value overflows the current buffer. + private const int BufferGrowthFactor = 2; +#endif + + /// The relative path the query string is appended to. + private readonly string _relativePath; + + /// The accumulating path plus query text; unused until the first parameter is appended. + private ValueStringBuilder _text; + + /// The accumulating joined collection value while inside a non-multi collection. + private ValueStringBuilder _joinedValues; + + /// The key of the collection currently being appended, or null outside a collection. + private string? _collectionKey; + + /// The delimiter between joined collection values. + private char _collectionDelimiter; + + /// Whether the current collection renders one key=value pair per element. + private bool _collectionIsMulti; + + /// Whether the current collection is caller-encoded. + private bool _collectionPreEncoded; + + /// The number of values appended to the current joined collection. + private int _collectionValueCount; + + /// Whether the path or an appended parameter already established a query string. + private bool _hasQuery; + + /// Whether any parameter has been appended, requiring to materialize new text. + private bool _hasAppended; + + /// Initializes a new instance of the struct. + /// The relative path, whose template query string (if any) is preserved in front + /// of appended parameters. Dynamic path segments must already be escaped. + public GeneratedQueryStringBuilder(string relativePath) + : this(relativePath, StringHelpers.Contains(relativePath, '?')) + { + } + + /// Initializes a new instance of the struct with a known query state. + /// The relative path, whose template query string (if any) is preserved in front of + /// appended parameters. Dynamic path segments must already be escaped. + /// Whether already contains a ?; the generator passes + /// the compile-time answer so the path is not rescanned per call. + public GeneratedQueryStringBuilder(string relativePath, bool hasQuery) + { + _relativePath = relativePath; + _text = default; + _joinedValues = default; + _hasQuery = hasQuery; + } + + /// Appends one key=value query parameter. + /// The query key. + /// The formatted value; the parameter is omitted when this is . + /// Whether the key and value are caller-encoded and appended verbatim. + public void Add(string name, string? value, bool preEncoded) + { + if (value is null) + { + return; + } + + AppendPair(name, value, keyEscaped: false, preEncoded); + } + + /// Appends one key=value query parameter whose key the generator already escaped. + /// The pre-escaped (or caller-encoded) query key, appended verbatim. + /// The formatted value; the parameter is omitted when this is . + /// Whether the value is caller-encoded and appended verbatim. + /// Used for compile-time-constant keys, which the generator escapes once instead of on every call. + public void AddPreEscapedKey(string name, string? value, bool preEncoded) + { + if (value is null) + { + return; + } + + AppendPair(name, value, keyEscaped: true, preEncoded); + } + +#if NET6_0_OR_GREATER + /// Appends one key=value query parameter, formatting an value straight + /// into the query buffer with no intermediate formatted string. + /// The span-formattable value type. + /// The query key. + /// The value to render; the generator routes only non-null values here. + /// The compile-time format from [Query(Format = ...)], or null. + /// Whether the key and value are caller-encoded and appended verbatim. + /// The value is rendered invariant and escaped to produce exactly what yields for the + /// same rendered string. On targets without Uri.EscapeDataString(ReadOnlySpan<char>) the generator only + /// routes a URL-unreserved integer here, whose formatted span (digits and an optional -) needs no escaping. + public void AddFormatted(string name, T value, string? format, bool preEncoded) + where T : ISpanFormattable => + AppendFormattedPair(name, value, format, keyEscaped: false, preEncoded); + + /// Appends one pre-escaped-key key=value pair, formatting a span-formattable value into the buffer. + /// The span-formattable value type. + /// The pre-escaped (or caller-encoded) query key, appended verbatim. + /// The value to render; the generator routes only non-null values here. + /// The compile-time format from [Query(Format = ...)], or null. + /// Whether the value is caller-encoded and appended verbatim. + public void AddFormattedPreEscapedKey(string name, T value, string? format, bool preEncoded) + where T : ISpanFormattable => + AppendFormattedPair(name, value, format, keyEscaped: true, preEncoded); +#endif + + /// Appends one valueless query flag (?name). + /// The formatted flag name; the flag is omitted when this is . + /// Whether the name is caller-encoded and appended verbatim. + /// The flag name is the parameter's runtime value (a [QueryName] value or collection element), so it + /// is escaped here rather than pre-escaped by the generator. + public void AddFlag(string? name, bool preEncoded) + { + if (name is null) + { + return; + } + + AppendSeparator(); + _text.Append(preEncoded ? name : StringHelpers.EscapeDataString(name)); + } + + /// Starts a collection-valued parameter fed by calls and finished by . + /// The query key. + /// The resolved collection format (an explicit attribute value, or the + /// default). + /// Whether the key and values are caller-encoded and appended verbatim. + public void BeginCollection(string name, CollectionFormat collectionFormat, bool preEncoded) + { + Debug.Assert(_collectionKey is null, "BeginCollection must not be nested."); + _collectionKey = name; + _collectionIsMulti = collectionFormat == CollectionFormat.Multi; + _collectionPreEncoded = preEncoded; + _collectionValueCount = 0; + _collectionDelimiter = collectionFormat switch + { + CollectionFormat.Ssv => ' ', + CollectionFormat.Tsv => '\t', + CollectionFormat.Pipes => '|', + _ => ',' + }; + } + + /// Appends one formatted element of the current collection. + /// The formatted element value; under a + /// element is omitted, otherwise it joins as an empty value. + public void AddCollectionValue(string? value) + { + Debug.Assert(_collectionKey is not null, "AddCollectionValue requires BeginCollection."); + if (_collectionIsMulti) + { + if (value is not null) + { + AppendPair(_collectionKey!, value, keyEscaped: false, _collectionPreEncoded); + } + + return; + } + + if (_collectionValueCount > 0) + { + _joinedValues.Append(_collectionDelimiter); + } + + _collectionValueCount++; + _joinedValues.Append(value); + } + +#if NET6_0_OR_GREATER + /// Appends one element of the current collection, formatting it straight into + /// the query buffer with no intermediate formatted string. + /// The span-formattable element type. + /// The element to render; the generator routes only non-null values here. + /// The element is rendered invariant with no format. Under it becomes + /// its own escaped key=value pair; otherwise it joins into the value that escapes + /// as a whole, so the joined element is appended unescaped exactly like . + public void AddCollectionValueFormatted(T value) + where T : ISpanFormattable + { + Debug.Assert(_collectionKey is not null, "AddCollectionValueFormatted requires BeginCollection."); + if (_collectionIsMulti) + { + AppendFormattedPair(_collectionKey!, value, null, keyEscaped: false, _collectionPreEncoded); + return; + } + + if (_collectionValueCount > 0) + { + _joinedValues.Append(_collectionDelimiter); + } + + _collectionValueCount++; + AppendFormattedValue(ref _joinedValues, value, null, escape: false); + } +#endif + + /// Finishes the current collection, emitting the joined key=value pair for non-multi formats. + public void EndCollection() + { + Debug.Assert(_collectionKey is not null, "EndCollection requires BeginCollection."); + if (!_collectionIsMulti) + { + // A joined collection always emits its pair, even when the collection was empty (key=), + // matching the reflection request builder. + AppendPair(_collectionKey!, _joinedValues.ToString(), keyEscaped: false, _collectionPreEncoded); + } + + _collectionKey = null; + } + + /// Builds the final relative path with the appended query string and releases pooled buffers. + /// The relative path, unchanged when no parameter was appended. + public string Build() + { + _joinedValues.Dispose(); + if (!_hasAppended) + { + _text.Dispose(); + return _relativePath; + } + + return _text.ToString(); + } + +#if NET6_0_OR_GREATER + /// Formats a span-formattable value into , growing a rented buffer until the value fits. + /// The span-formattable value type. + /// The value to render. + /// The target buffer, replaced with a larger rented buffer when the value overflows it. + /// The rented buffer to grow and return to the pool, or null while the stack buffer is in use. + /// The compile-time format, or null for the default rendering. + /// The number of characters written into . + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] // The grow back-edge only fires for a value larger than the stack buffer; the compiler's loop second-jump stays unreachable in practice. + private static int FormatWithGrowth(T value, ref Span buffer, ref char[]? rented, string? format) + where T : ISpanFormattable + { + int written; + while (!value.TryFormat(buffer, out written, format.AsSpan(), System.Globalization.CultureInfo.InvariantCulture)) + { + if (rented is not null) + { + System.Buffers.ArrayPool.Shared.Return(rented); + } + + rented = System.Buffers.ArrayPool.Shared.Rent(buffer.Length * BufferGrowthFactor); + buffer = rented; + } + + return written; + } +#endif + + /// Appends the ? or & separator, materializing the text buffer on first use. + private void AppendSeparator() + { + if (!_hasAppended) + { + _text.EnsureCapacity(_relativePath.Length + InitialQueryCapacity); + _text.Append(_relativePath); + _hasAppended = true; + } + + _text.Append(_hasQuery ? '&' : '?'); + _hasQuery = true; + } + + /// Appends one key=value pair with the configured escaping. + /// The query key. + /// The non-null formatted value. + /// Whether the key is already escaped by the generator and appended verbatim. + /// Whether the value (and, when not , the key) is caller-encoded. + private void AppendPair(string name, string value, bool keyEscaped, bool preEncoded) + { + AppendSeparator(); + _text.Append(keyEscaped || preEncoded ? name : StringHelpers.EscapeDataString(name)); + _text.Append('='); + _text.Append(preEncoded ? value : StringHelpers.EscapeDataString(value)); + } + +#if NET6_0_OR_GREATER + /// Appends one key=value pair, formatting a span-formattable value straight into the buffer. + /// The span-formattable value type. + /// The query key. + /// The value to render. + /// The compile-time format, or null. + /// Whether the key is already escaped by the generator and appended verbatim. + /// Whether the value (and, when not , the key) is caller-encoded. + private void AppendFormattedPair(string name, T value, string? format, bool keyEscaped, bool preEncoded) + where T : ISpanFormattable + { + AppendSeparator(); + _text.Append(keyEscaped || preEncoded ? name : StringHelpers.EscapeDataString(name)); + _text.Append('='); + AppendFormattedValue(ref _text, value, format, escape: !preEncoded); + } + + /// Formats a span-formattable value into a stack buffer (growing a rented buffer when it overflows) and + /// appends it to the target, escaping the formatted span in place when requested. + /// The span-formattable value type. + /// The buffer receiving the rendered value. + /// The value to render. + /// The compile-time format, or null for the default rendering. + /// Whether the formatted span is URI-data-escaped before it is appended. + private readonly void AppendFormattedValue(ref ValueStringBuilder target, T value, string? format, bool escape) + where T : ISpanFormattable + { + Span buffer = stackalloc char[FormatBufferLength]; + char[]? rented = null; + try + { + var written = FormatWithGrowth(value, ref buffer, ref rented, format); + + var formatted = (ReadOnlySpan)buffer[..written]; + if (escape) + { + // Percent-encode straight into the builder with no intermediate escaped string, on every target + // framework (the span overload of Uri.EscapeDataString only exists on net9+). + StringHelpers.AppendUriDataEscaped(ref target, formatted); + return; + } + + // Copy into a reserved slice so the stack buffer is never captured by the builder (ref-safety), matching a + // verbatim span append with no intermediate string. + formatted.CopyTo(target.AppendSpan(written)); + } + finally + { + if (rented is not null) + { + System.Buffers.ArrayPool.Shared.Return(rented); + } + } + } + +#endif +} diff --git a/src/Refit/GeneratedRequestRunner.BodyContent.cs b/src/Refit/GeneratedRequestRunner.BodyContent.cs new file mode 100644 index 000000000..03ccf6568 --- /dev/null +++ b/src/Refit/GeneratedRequestRunner.BodyContent.cs @@ -0,0 +1,188 @@ +// 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.Text; + +namespace Refit; + +/// Request body content creation for source-generated requests: JSON, JSON Lines, and URL-encoded forms. +public static partial class GeneratedRequestRunner +{ + /// 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. + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Type parameter intentionally specified explicitly by generated callers.")] + 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); + + // A synchronously-serialized body is already a buffer (and lets the fast-path engage), so never re-stream it. + return streamBody && !UsesSynchronousSerialization(settings) + ? new PushStreamContent( + async (stream, _, _) => + { +#if NET8_0_OR_GREATER + await using (stream.ConfigureAwait(false)) +#else + using (stream) +#endif + { + await content.CopyToAsync(stream).ConfigureAwait(false); + } + }, + content.Headers.ContentType) + : content; + } + + /// Serializes a generated request body as JSON Lines (newline-delimited JSON). + /// The declared body type. + /// The Refit settings to use. + /// The enumerable body value. + /// The HTTP content for the JSON Lines body. + public static HttpContent CreateJsonLinesBodyContent( + RefitSettings settings, + TBody body) + { + if (body is HttpContent httpContent) + { + return httpContent; + } + + if (body is Stream stream) + { + return new StreamContent(stream); + } + + var items = body is IEnumerable enumerable and not string + ? enumerable + : new[] { (object?)body }; + + return new JsonLinesContent(items, settings.ContentSerializer); + } + + /// Serializes a generated URL-encoded request body using the declared body type. + /// The declared body type. + /// The Refit settings to use. + /// The body value. + /// The HTTP content for the URL-encoded body. + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Generated callers specify the declared body type for AOT-safe form property discovery.")] + public static HttpContent CreateUrlEncodedBodyContent< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + TBody>( + RefitSettings settings, + TBody body) + { + if (body is HttpContent httpContent) + { + return httpContent; + } + + if (body is Stream stream) + { + return new StreamContent(stream); + } + + return body is string stringBody + ? new StringContent( + StringHelpers.EscapeDataString(stringBody), + Encoding.UTF8, + "application/x-www-form-urlencoded") + : new FormUrlEncodedContent(FormValueMultimap.Create(body, settings)); + } + + /// Serializes a generated URL-encoded request body using source-generated field descriptors. + /// The declared body type. + /// The Refit settings to use. + /// The body value. + /// The compile-time field descriptors for the body type. + /// The HTTP content for the URL-encoded body. + /// + /// The reflection-free path runs only when the configured content serializer resolves field names purely + /// from attributes the generator already inlined (the built-in ). + /// For any other serializer the field-name hook may need the runtime , + /// so this falls back to the reflection-based . + /// + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Generated callers specify the declared body type for AOT-safe form property discovery.")] + public static HttpContent CreateUrlEncodedBodyContent< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + TBody>( + RefitSettings settings, + TBody body, + FormField[] fields) + { + if (body is HttpContent httpContent) + { + return httpContent; + } + + if (body is Stream stream) + { + return new StreamContent(stream); + } + + if (body is string stringBody) + { + return new StringContent( + StringHelpers.EscapeDataString(stringBody), + Encoding.UTF8, + "application/x-www-form-urlencoded"); + } + + // The descriptor path only matches the reflection path when the serializer resolves field names from + // attributes the generator already inlined (the built-in System.Text.Json serializer); otherwise fall back. + var useDescriptors = body is not null and not System.Collections.IDictionary + && settings.ContentSerializer is SystemTextJsonContentSerializer; + + return new FormUrlEncodedContent( + useDescriptors + ? FormValueMultimap.CreateFromFields(body, fields, settings) + : FormValueMultimap.Create(body, settings)); + } + + /// Determines whether a form body can be serialized by the generated straight-line unrolled fast path. + /// The body instance. + /// when the body is a plain object the unrolled path can flatten field-by-field; + /// for the , , , + /// , and bodies the reflection path special-cases. + /// The lets generated code dereference the body directly inside the guard. + public static bool CanUnrollForm([NotNullWhen(true)] object? body) => + body is not null + && body is not HttpContent + && body is not Stream + && body is not string + && body is not System.Collections.IDictionary; +} diff --git a/src/Refit/GeneratedRequestRunner.Sending.cs b/src/Refit/GeneratedRequestRunner.Sending.cs new file mode 100644 index 000000000..d90535b91 --- /dev/null +++ b/src/Refit/GeneratedRequestRunner.Sending.cs @@ -0,0 +1,189 @@ +// 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; + +/// Request-sending entry points that dispatch a built request and process, observe, or stream its response. +public static partial class GeneratedRequestRunner +{ + /// 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. + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the 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( + isApiResponse, + shouldDisposeResponse, + bufferBody, + true), + cancellationToken) + .ConfigureAwait(false); + } + } + + /// Sends a generated request as a cold : each subscription rebuilds and sends + /// the request, mirroring the reflection request builder. + /// The result type yielded to subscribers. + /// The deserialized body type for API response wrappers. + /// The HTTP client to send with. + /// Builds a fresh request per subscription, so a second subscription never reuses a + /// disposed request. + /// 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. + /// The cancellation token supplied as a method argument, if any. + /// A cold observable of the deserialized or wrapped response. + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Type parameters intentionally specified explicitly by generated callers.")] + public static IObservable SendObservable( + HttpClient client, + Func requestFactory, + RefitSettings settings, + bool isApiResponse, + bool shouldDisposeResponse, + bool bufferBody, + CancellationToken methodCancellationToken) => + new ReactiveUI.Primitives.Advanced.FromAsyncSignal(async subscriptionToken => + { + // Link the method's CancellationToken argument (if any) with the per-subscription token, allocating a linked + // source only when both can cancel - mirroring StreamAsync. + var (token, linked) = ResolveRequestCancellationToken(methodCancellationToken, subscriptionToken); + + try + { + return await SendAsync(client, requestFactory(), settings, isApiResponse, shouldDisposeResponse, bufferBody, token) + .ConfigureAwait(false); + } + finally + { + linked?.Dispose(); + } + }); + + /// Sends a generated request and streams the response as an . + /// The element type yielded to the caller. + /// The HTTP client to send with. + /// The generated request message; disposed when streaming completes. + /// The Refit settings to use. + /// The cancellation token supplied as a method argument, if any. + /// The token supplied by the consumer's enumeration. + /// An asynchronous sequence of deserialized elements. + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Type parameter intentionally specified explicitly by generated callers.")] + [SuppressMessage( + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", + Justification = "The optional CancellationToken carries the [EnumeratorCancellation] token for the await-foreach WithCancellation pattern.")] + [ExcludeFromCodeCoverage] // async-iterator dispose-mode epilogue: the compiler-generated <>w__disposeMode false-edge cannot be exercised or removed. + public static async IAsyncEnumerable StreamAsync( + HttpClient client, + HttpRequestMessage request, + RefitSettings settings, + CancellationToken methodCancellationToken, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + RequestExecutionHelpers.ThrowIfBaseAddressMissing(client); + + // Only allocate a linked source when both tokens can actually cancel; linking a non-cancelable token is a + // no-op, so when the method has no CancellationToken parameter or the consumer enumerates without + // WithCancellation the request runs against whichever token can cancel (or none) with no CTS allocation. + var (token, linked) = ResolveRequestCancellationToken(methodCancellationToken, cancellationToken); + + try + { + await foreach (var item in RequestExecutionHelpers + .StreamResponseAsync(client, request, settings, true, token) + .ConfigureAwait(false)) + { + yield return item; + } + } + finally + { + linked?.Dispose(); + } + } + + /// Resolves the effective cancellation token for a request, linking the method-argument token with the + /// per-subscription or enumeration token into a new source only when both can cancel. + /// The cancellation token supplied as a method argument, if any. + /// The per-subscription (or per-enumeration) cancellation token. + /// The token the request should run against, and the linked source to dispose once the request completes + /// (null when no source was allocated). + private static (CancellationToken Token, CancellationTokenSource? LinkedSource) ResolveRequestCancellationToken( + CancellationToken methodCancellationToken, + CancellationToken consumerCancellationToken) + { + if (methodCancellationToken.CanBeCanceled && consumerCancellationToken.CanBeCanceled) + { + var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(methodCancellationToken, consumerCancellationToken); + return (linkedSource.Token, linkedSource); + } + + return (methodCancellationToken.CanBeCanceled ? methodCancellationToken : consumerCancellationToken, null); + } +} diff --git a/src/Refit/GeneratedRequestRunner.cs b/src/Refit/GeneratedRequestRunner.cs index 8a6587261..b0b6ca83d 100644 --- a/src/Refit/GeneratedRequestRunner.cs +++ b/src/Refit/GeneratedRequestRunner.cs @@ -1,19 +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. +using System.Collections; using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; +using System.Reflection; using System.Text; namespace Refit; /// Shared runtime helpers used by source-generated request construction. [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)] -public static class GeneratedRequestRunner +public static partial class GeneratedRequestRunner { /// The underlying value of the obsolete BodySerializationMethod.Json member. private const int ObsoleteJsonBodySerializationMethodValue = 1; + /// A dummy absolute base used only to re-encode a relative path and query for [QueryUriFormat]. The + /// host is irrelevant because ignores it; it mirrors the reflection builder's + /// own base so the two produce byte-identical output. + private static readonly Uri QueryUriFormatBase = new("https://api", UriKind.Absolute); + /// Builds the relative request URI for a generated request, joining the client base address with the method path. /// The HTTP client whose base address is used under legacy resolution. /// The method's relative path, including any leading slash and query string. @@ -27,26 +33,55 @@ public static Uri BuildRelativeUri(HttpClient client, string relativePath, UrlRe return new(relativePath, UriKind.Relative); } + RequireLeadingSlashUnderLegacy(relativePath); var basePath = client.BaseAddress?.AbsolutePath ?? throw new InvalidOperationException("BaseAddress must be set on the HttpClient instance"); basePath = basePath == "/" ? string.Empty : basePath.TrimEnd('/'); return new(basePath + relativePath, UriKind.Relative); } + /// Builds the relative request URI, re-encoding the whole path and query with a [QueryUriFormat] mode. + /// The HTTP client whose base address is used under legacy resolution. + /// The method's relative path, including any leading slash and query string. + /// The configured URL resolution mode. + /// The escaping mode from the method's [QueryUriFormat] attribute. + /// A relative whose path and query are re-encoded with . + /// Mirrors the reflection request builder: it always assembles the path and query with the escaping query + /// builder, then re-encodes the whole thing through with the + /// method's QueryUriFormat (so decodes it). Rfc3986 resolution ignores the + /// format, exactly as the reflection builder does. + public static Uri BuildRelativeUri(HttpClient client, string relativePath, UrlResolutionMode urlResolution, UriFormat queryUriFormat) + { + if (urlResolution == UrlResolutionMode.Rfc3986) + { + return new(relativePath, UriKind.Relative); + } + + RequireLeadingSlashUnderLegacy(relativePath); + var basePath = client.BaseAddress?.AbsolutePath + ?? throw new InvalidOperationException("BaseAddress must be set on the HttpClient instance"); + basePath = basePath == "/" ? string.Empty : basePath.TrimEnd('/'); + var absolute = new Uri(QueryUriFormatBase, basePath + relativePath); + return new(absolute.GetComponents(UriComponents.PathAndQuery, queryUriFormat), UriKind.Relative); + } + /// Builds the request path for a generated request from a template. /// The method's relative path, including any leading slash and query string. /// Whether to allow unmatched URL parameters. - /// The replacement uri parameters. + /// The replacement uri parameters, ordered by template position. /// A path with all the placeholder parameters in the path template replaced. /// - /// A URI template parameter is not available in the provided parameter array and unmatched URL parameters aren't allowed. + /// A URI template parameter is not available in the provided parameter span and unmatched URL parameters aren't allowed. /// + /// Generated call sites pass the replacements as a collection expression. On C# 12 and a runtime with inline + /// array support (net8.0+) that materializes on the stack, so any number of path parameters is expanded without a heap + /// allocation; older consumers pass a small array via the same signature. public static string BuildRequestPath( string relativePathTemplate, bool allowUnmatchedParameter, - params ((int startIdx, int endIdx) range, string? value)[] uriParams) + ReadOnlySpan<((int startIdx, int endIdx) range, string? value)> uriParams) { - if (uriParams.Length == 0 && allowUnmatchedParameter) + if (uriParams.IsEmpty && allowUnmatchedParameter) { return relativePathTemplate; } @@ -66,295 +101,291 @@ public static string BuildRequestPath( } sb.Append(pathSpan[pos..]); + return ThrowIfUnmatchedParameter(sb.ToString(), relativePathTemplate, allowUnmatchedParameter); + } - var path = sb.ToString(); - var i = path.IndexOf('{'); - if (i < 0 || allowUnmatchedParameter) +#if NET6_0_OR_GREATER + /// Builds a single-placeholder request path, formatting an unformatted integer straight into the path + /// buffer with no intermediate string and no escaping. + /// The integer value type. + /// The method's relative path, including any leading slash and query string. + /// Whether to allow unmatched URL parameters. + /// The replacement range for the placeholder. + /// The integer value to render. + /// A path with the placeholder replaced. + /// The generator emits this only for an unformatted integer parameter, whose invariant rendering is digits + /// and an optional leading - - all URL-unreserved - so the formatted span is appended without escaping. + public static string BuildRequestPath( + string relativePathTemplate, + bool allowUnmatchedParameter, + (int startIdx, int endIdx) range, + T value) + where T : ISpanFormattable + { + var pathSpan = relativePathTemplate.AsSpan(); + using var sb = new ValueStringBuilder(stackalloc char[256]); + sb.Append(pathSpan[..range.startIdx]); + + // long.MinValue and ulong.MaxValue both render in 20 characters, so 32 always succeeds for an integer. + Span buffer = stackalloc char[32]; + if (value.TryFormat(buffer, out var written, default, System.Globalization.CultureInfo.InvariantCulture)) { - return path; + sb.Append(buffer[..written]); } - - var j = path.AsSpan(i).IndexOfAny('}', '/'); - if (j < 0 || path[j += i] != '}') + else { - return path; + sb.Append(StringHelpers.EscapeDataString(value.ToString(null, System.Globalization.CultureInfo.InvariantCulture))); } - var key = path[(i + 1)..j]; - throw new ArgumentException( - $"URL {relativePathTemplate} has parameter {{{key}}}, but no method parameter matches"); + sb.Append(pathSpan[range.endIdx..]); + return ThrowIfUnmatchedParameter(sb.ToString(), relativePathTemplate, allowUnmatchedParameter); } +#endif - /// 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) +#if NET8_0_OR_GREATER + /// Builds a single-placeholder request path, formatting an value into a stack + /// buffer and escaping the span directly, so the intermediate formatted string is never allocated. + /// The span-formattable value type. + /// The method's relative path, including any leading slash and query string. + /// Whether to allow unmatched URL parameters. + /// The replacement range for the placeholder. + /// The value to render. + /// The compile-time format from [Query(Format = ...)], or null. + /// A path with the placeholder replaced. + public static string BuildRequestPath( + string relativePathTemplate, + bool allowUnmatchedParameter, + (int startIdx, int endIdx) range, + T value, + string? format) + where T : ISpanFormattable { - RequestExecutionHelpers.ThrowIfBaseAddressMissing(client); - - using (request) + var pathSpan = relativePathTemplate.AsSpan(); + var sb = new ValueStringBuilder(stackalloc char[256]); + try { - await RequestExecutionHelpers.SendVoidAsync( - client, - request, - settings, - bufferBody, - true, - cancellationToken) - .ConfigureAwait(false); - } - } + sb.Append(pathSpan[..range.startIdx]); - /// 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. - [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); + Span buffer = stackalloc char[128]; + if (value.TryFormat(buffer, out var written, format.AsSpan(), System.Globalization.CultureInfo.InvariantCulture)) + { + StringHelpers.AppendUriDataEscaped(ref sb, buffer[..written]); + } + else + { + sb.Append(StringHelpers.EscapeDataString(value.ToString(format, System.Globalization.CultureInfo.InvariantCulture))); + } - using (request) - { - return await RequestExecutionHelpers.SendAndProcessResponseAsync( - client, - request, - settings, - new( - isApiResponse, - shouldDisposeResponse, - bufferBody, - true), - cancellationToken) - .ConfigureAwait(false); + sb.Append(pathSpan[range.endIdx..]); + return ThrowIfUnmatchedParameter(sb.ToString(), relativePathTemplate, allowUnmatchedParameter); } - } - - /// Sends a generated request and streams the response as an . - /// The element type yielded to the caller. - /// The HTTP client to send with. - /// The generated request message; disposed when streaming completes. - /// The Refit settings to use. - /// The cancellation token supplied as a method argument, if any. - /// The token supplied by the consumer's enumeration. - /// An asynchronous sequence of deserialized elements. - [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", - Justification = "Type parameter intentionally specified explicitly by generated callers.")] - [SuppressMessage( - "Major Code Smell", - "S2360:Optional parameters should not be used", - Justification = "The optional CancellationToken carries the [EnumeratorCancellation] token for the await-foreach WithCancellation pattern.")] - public static async IAsyncEnumerable StreamAsync( - HttpClient client, - HttpRequestMessage request, - RefitSettings settings, - CancellationToken methodCancellationToken, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - RequestExecutionHelpers.ThrowIfBaseAddressMissing(client); - - using var linked = CancellationTokenSource.CreateLinkedTokenSource(methodCancellationToken, cancellationToken); - await foreach (var item in RequestExecutionHelpers - .StreamResponseAsync(client, request, settings, true, linked.Token) - .ConfigureAwait(false)) + finally { - yield return item; + sb.Dispose(); } } +#endif - /// 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. - [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", - Justification = "Type parameter intentionally specified explicitly by generated callers.")] - [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) + /// Validates a parameterless request path template, throwing for unmatched placeholders. + /// The method's relative path, including any leading slash and query string. + /// Whether to allow unmatched URL parameters. + /// The template, unchanged. + /// + /// The template contains a placeholder and unmatched URL parameters aren't allowed. + /// + public static string BuildRequestPath(string relativePathTemplate, bool allowUnmatchedParameter) => + ThrowIfUnmatchedParameter(relativePathTemplate, relativePathTemplate, allowUnmatchedParameter); + + /// Builds the request path for a generated request from a template, honoring per-value encoding opt-outs. + /// The method's relative path, including any leading slash and query string. + /// Whether to allow unmatched URL parameters. + /// The replacement uri parameters, ordered by template position; a preEncoded value is appended verbatim. + /// A path with all the placeholder parameters in the path template replaced. + /// + /// A URI template parameter is not available in the provided parameter span and unmatched URL parameters aren't allowed. + /// + /// Generated call sites pass the replacements as a collection expression, stack-allocated on C# 12 with a + /// net8.0+ runtime and passed as a small array otherwise. + public static string BuildRequestPath( + string relativePathTemplate, + bool allowUnmatchedParameter, + ReadOnlySpan<((int startIdx, int endIdx) range, string? value, bool preEncoded)> uriParams) { - if (body is HttpContent httpContent) + if (uriParams.IsEmpty && allowUnmatchedParameter) { - return httpContent; + return relativePathTemplate; } - if (body is Stream stream) + var pathSpan = relativePathTemplate.AsSpan(); + using var sb = new ValueStringBuilder(stackalloc char[256]); + var pos = 0; + foreach (var ((startIdx, endIdx), value, preEncoded) in uriParams) { - return new StreamContent(stream); - } + sb.Append(pathSpan[pos..startIdx]); + if (value is not null) + { + sb.Append(preEncoded ? value : StringHelpers.EscapeDataString(value)); + } - if (serializationMethod == BodySerializationMethod.Default && body is string stringBody) - { - return new StringContent(stringBody); + pos = endIdx; } - var content = CreateSerializedBodyContent(settings, body, serializationMethod); - - // A synchronously-serialized body is already a buffer (and lets the fast-path engage), so never re-stream it. - return streamBody && !UsesSynchronousSerialization(settings) - ? new PushStreamContent( - async (stream, _, _) => - { -#if NET8_0_OR_GREATER - await using (stream.ConfigureAwait(false)) -#else - using (stream) -#endif - { - await content.CopyToAsync(stream).ConfigureAwait(false); - } - }, - content.Headers.ContentType) - : content; + sb.Append(pathSpan[pos..]); + return ThrowIfUnmatchedParameter(sb.ToString(), relativePathTemplate, allowUnmatchedParameter); } - /// Serializes a generated request body as JSON Lines (newline-delimited JSON). - /// The declared body type. - /// The Refit settings to use. - /// The enumerable body value. - /// The HTTP content for the JSON Lines body. - public static HttpContent CreateJsonLinesBodyContent( - RefitSettings settings, - TBody body) + /// Round-trips a catch-all {**param} path value: each /-separated section is formatted and + /// escaped while the separators are preserved, matching the reflection request builder. + /// The value's string form (from ToString), or null. + /// The configured URL parameter formatter. + /// The parameter's attribute provider passed to the formatter. + /// The parameter's declared type passed to the formatter. + /// The round-trip-escaped path fragment, ready to append verbatim. + public static string RoundTripEscapePath( + string? value, + IUrlParameterFormatter formatter, + ICustomAttributeProvider attributeProvider, + Type type) { - if (body is HttpContent httpContent) + if (value is null) { - return httpContent; + return StringHelpers.EscapeDataString(formatter.Format(null, attributeProvider, type) ?? string.Empty); } - if (body is Stream stream) + var sb = new StringBuilder(value.Length); + var sectionStart = 0; + for (var i = 0; i <= value.Length; i++) { - return new StreamContent(stream); - } + if (i != value.Length && value[i] != '/') + { + continue; + } + + if (sectionStart > 0) + { + _ = sb.Append('/'); + } - var items = body is System.Collections.IEnumerable enumerable and not string - ? enumerable - : new[] { (object?)body }; + var section = value.Substring(sectionStart, i - sectionStart); + _ = sb.Append(StringHelpers.EscapeDataString(formatter.Format(section, attributeProvider, type) ?? string.Empty)); + sectionStart = i + 1; + } - return new JsonLinesContent(items, settings.ContentSerializer); + return sb.ToString(); } - /// Serializes a generated URL-encoded request body using the declared body type. - /// The declared body type. - /// The Refit settings to use. - /// The body value. - /// The HTTP content for the URL-encoded body. - [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", - Justification = "Generated callers specify the declared body type for AOT-safe form property discovery.")] - public static HttpContent CreateUrlEncodedBodyContent< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] - TBody>( + /// Determines whether the settings use the pristine default URL parameter formatter, letting + /// generated code format statically-known values inline without calling the formatter. + /// The Refit settings to inspect. + /// when generated inline formatting matches the configured formatter. + public static bool UsesDefaultUrlParameterFormatting(RefitSettings settings) => + settings.UrlParameterFormatter is DefaultUrlParameterFormatter formatter + && formatter.IsPristineDefault; + + /// Determines whether the settings use the pristine default form-url-encoded parameter formatter. + /// The Refit settings to inspect. + /// when generated inline formatting matches the configured formatter. + /// + /// A property-level [Query(Format = ...)] on a flattened query object is applied by + /// , not . Its default renders + /// string.Format(InvariantCulture, "{0:format}", enumMemberValue ?? value), which is what generated inline + /// formatting reproduces. Format is virtual, so a derived formatter must disable the fast path. + /// + public static bool UsesDefaultFormUrlEncodedParameterFormatting(RefitSettings settings) => + settings.FormUrlEncodedParameterFormatter.GetType() == typeof(DefaultFormUrlEncodedParameterFormatter); + + /// Determines whether the settings use the pristine default URL parameter key formatter, letting + /// generated code use a compile-time constant query key instead of calling the formatter. + /// The Refit settings to inspect. + /// when a property's CLR name is its query key verbatim. + public static bool UsesDefaultUrlParameterKeyFormatting(RefitSettings settings) => + settings.UrlParameterKeyFormatter.GetType() == typeof(DefaultUrlParameterKeyFormatter); + + /// Composes the query key for a property flattened out of a query object. + /// The Refit settings supplying the key formatter. + /// The declared CLR property name. + /// The name from [AliasAs] or [JsonPropertyName], which bypasses the key formatter, or . + /// The compile-time prefix + delimiter from [Query(Prefix = ...)], or . + /// The query key. + /// + /// Matches the reflection request builder's BuildPropertyQueryKey: an explicit [AliasAs] or + /// [JsonPropertyName] name (resolved by the generator and passed as ) is used + /// verbatim; otherwise the CLR name passes through . + /// + public static string BuildQueryKey( RefitSettings settings, - TBody body) + string clrName, + string? explicitName, + string? prefixSegment) { - if (body is HttpContent httpContent) - { - return httpContent; - } - - if (body is Stream stream) - { - return new StreamContent(stream); - } + var name = explicitName + ?? (UsesDefaultUrlParameterKeyFormatting(settings) + ? clrName + : settings.UrlParameterKeyFormatter.Format(clrName)); - return body is string stringBody - ? new StringContent( - StringHelpers.EscapeDataString(stringBody), - Encoding.UTF8, - "application/x-www-form-urlencoded") - : new FormUrlEncodedContent(FormValueMultimap.Create(body, settings)); + return prefixSegment is null ? name : prefixSegment + name; } - /// Serializes a generated URL-encoded request body using source-generated field descriptors. - /// The declared body type. - /// The Refit settings to use. - /// The body value. - /// The compile-time field descriptors for the body type. - /// The HTTP content for the URL-encoded body. + /// Formats a value with the invariant culture, matching the default URL parameter formatter's + /// rendering for values without boxing or reflection. + /// The formattable value type. + /// The value to format. + /// The compile-time format from [Query(Format = ...)], or null. + /// The formatted value. + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Type parameter intentionally inferred from generated call sites to avoid boxing.")] + public static string FormatInvariant(T value, string? format) + where T : IFormattable => + value.ToString(format, System.Globalization.CultureInfo.InvariantCulture); + + /// Expands a query-object collection property through a customized , + /// reproducing the reflection request builder's two formatting passes. + /// The query-string builder to append to. + /// The Refit settings supplying the URL parameter formatter. + /// The collection value; a null collection appends nothing. + /// The already-composed query key. + /// The resolved collection format. + /// Whether the key and values are caller-encoded and appended verbatim. + /// The two-pass formatting targets: the declared property type used as both the attribute + /// provider and type for each element (matching the reflection builder's propertyInfo.PropertyType element + /// pass), and the enclosing parameter's attribute provider and declared type for the second pass. /// - /// The reflection-free path runs only when the configured content serializer resolves field names purely - /// from attributes the generator already inlined (the built-in ). - /// For any other serializer the field-name hook may need the runtime , - /// so this falls back to the reflection-based . + /// Each element is formatted with the property's provider and type; the results are joined (or, under + /// , kept separate) and formatted again with the parameter's provider and type. + /// A pristine default formatter makes the second pass a no-op, so generated code takes this slow path only when the + /// formatter is customized; the fast path uses directly. /// - [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", - Justification = "Generated callers specify the declared body type for AOT-safe form property discovery.")] - public static HttpContent CreateUrlEncodedBodyContent< - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] - TBody>( + public static void AddFormattedCollectionProperty( + ref GeneratedQueryStringBuilder builder, RefitSettings settings, - TBody body, - FormField[] fields) + IEnumerable? values, + string key, + CollectionFormat collectionFormat, + bool preEncoded, + (Type ElementProviderType, ICustomAttributeProvider JoinedProvider, Type JoinedType) formatting) { - if (body is HttpContent httpContent) + if (values is null) { - return httpContent; + return; } - if (body is Stream stream) + var formatter = settings.UrlParameterFormatter; + var element = formatting.ElementProviderType; + if (collectionFormat == CollectionFormat.Multi) { - return new StreamContent(stream); - } + foreach (var value in values) + { + var formatted = formatter.Format(value, element, element); + builder.Add(key, formatter.Format(formatted, formatting.JoinedProvider, formatting.JoinedType), preEncoded); + } - if (body is string stringBody) - { - return new StringContent( - StringHelpers.EscapeDataString(stringBody), - Encoding.UTF8, - "application/x-www-form-urlencoded"); + return; } - // The descriptor path only matches the reflection path when the serializer resolves field names from - // attributes the generator already inlined (the built-in System.Text.Json serializer); otherwise fall back. - var useDescriptors = body is not null and not System.Collections.IDictionary - && settings.ContentSerializer is SystemTextJsonContentSerializer; - - return new FormUrlEncodedContent( - useDescriptors - ? FormValueMultimap.CreateFromFields(body, fields, settings) - : FormValueMultimap.Create(body, settings)); + var joined = JoinFormattedElements(values, formatter, element, CollectionDelimiter(collectionFormat)); + builder.Add(key, formatter.Format(joined, formatting.JoinedProvider, formatting.JoinedType), preEncoded); } /// Sets, replaces, or removes a generated request header. @@ -439,8 +470,8 @@ public static void AddConfiguredRequestOptions( /// The property key. /// The property value. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Generated callers specify the declared property type to avoid call-site boxing.")] public static void AddRequestProperty(HttpRequestMessage request, string key, TValue value) { @@ -451,6 +482,30 @@ public static void AddRequestProperty(HttpRequestMessage request, string #endif } + /// Serializes a multipart part through the content serializer, wrapping a failure with the same descriptive + /// argument exception the reflection request builder raises for an unserializable part. + /// The declared part type. + /// The Refit settings to use. + /// The part value to serialize. + /// The multipart field name, named in the failure message. + /// The serialized HTTP content. + /// The content serializer could not serialize the value. + public static HttpContent SerializeMultipartPart(RefitSettings settings, T value, string fieldName) + { + try + { + return settings.ContentSerializer.ToHttpContent(value); + } + catch (Exception ex) + { + throw new ArgumentException( + $"Unexpected parameter type in a Multipart request. Parameter {fieldName} is of type {value?.GetType().Name}, " + + "whereas allowed types are String, Stream, FileInfo, Byte array and anything that's JSON serializable", + nameof(value), + ex); + } + } + /// Determines whether the body should use the legacy JSON enum member. /// The body serialization method. /// for the legacy JSON value. @@ -458,6 +513,88 @@ public static void AddRequestProperty(HttpRequestMessage request, string internal static bool IsObsoleteJsonSerializationMethod(BodySerializationMethod serializationMethod) => (int)serializationMethod == ObsoleteJsonBodySerializationMethodValue; + /// Resolves the single-character delimiter for a non-multi collection format. + /// The collection format. + /// The delimiter character. + private static char CollectionDelimiter(CollectionFormat collectionFormat) => + collectionFormat switch + { + CollectionFormat.Ssv => ' ', + CollectionFormat.Tsv => '\t', + CollectionFormat.Pipes => '|', + _ => ',' + }; + + /// Formats each element with the property's provider and type and joins them with the delimiter. + /// The collection value. + /// The URL parameter formatter. + /// The declared property type used as the attribute provider and type. + /// The delimiter between formatted values. + /// The joined formatted values, empty when the collection has no elements. + [SuppressMessage( + "Correctness", + "SST2410:A created disposable is never disposed", + Justification = "ValueStringBuilder.ToString() disposes the builder and returns its pooled buffer; Dispose is idempotent.")] + private static string JoinFormattedElements( + IEnumerable values, + IUrlParameterFormatter formatter, + Type elementProviderType, + char delimiter) + { + var builder = new ValueStringBuilder(stackalloc char[256]); + var first = true; + foreach (var value in values) + { + if (!first) + { + builder.Append(delimiter); + } + + first = false; + builder.Append(formatter.Format(value, elementProviderType, elementProviderType)); + } + + return builder.ToString(); + } + + /// Throws when the expanded path still contains a placeholder and unmatched parameters are not allowed. + /// The expanded request path to validate. + /// The original path template, used in the error message. + /// Whether to allow unmatched URL parameters. + /// The validated path, returned unchanged. + private static string ThrowIfUnmatchedParameter(string path, string relativePathTemplate, bool allowUnmatchedParameter) + { + var i = path.IndexOf('{'); + if (i < 0 || allowUnmatchedParameter) + { + return path; + } + + var j = path.AsSpan(i).IndexOfAny('}', '/'); + if (j < 0 || path[j += i] != '}') + { + return path; + } + + var key = path[(i + 1)..j]; + throw new ArgumentException( + $"URL {relativePathTemplate} has parameter {{{key}}}, but no method parameter matches"); + } + + /// Rejects a no-leading-slash path under legacy resolution, matching the reflection request builder. + /// The resolved relative request path. + /// The path is non-empty and does not start with '/'. + private static void RequireLeadingSlashUnderLegacy(string relativePath) + { + if (relativePath.Length == 0 || relativePath[0] == '/') + { + return; + } + + throw new ArgumentException( + $"URL path {relativePath} must start with '/' and be of the form '/foo/bar/baz'"); + } + /// Adds one pre-boxed configured request property or option value. /// The request to modify. /// The property key. @@ -526,6 +663,12 @@ private static bool IsBodyless(HttpMethod method) => /// when the header key exists; otherwise . private static bool ContainsHeader(System.Net.Http.Headers.HttpHeaders headers, string name) { +#if NET6_0_OR_GREATER + // NonValidated checks key presence (case-insensitively, like the store) without parsing or materializing the + // stored header values, and never throws for unsupported header shapes, so it preserves the tolerant behavior + // of the manual scan while avoiding the per-check value enumeration. + return headers.NonValidated.Contains(name); +#else foreach (var header in headers) { if (string.Equals(header.Key, name, StringComparison.OrdinalIgnoreCase)) @@ -535,6 +678,7 @@ private static bool ContainsHeader(System.Net.Http.Headers.HttpHeaders headers, } return false; +#endif } /// Removes CR and LF characters from a generated header name or value. diff --git a/src/Refit/IHttpContentSerializer.cs b/src/Refit/IHttpContentSerializer.cs index 088267925..c87d91659 100644 --- a/src/Refit/IHttpContentSerializer.cs +++ b/src/Refit/IHttpContentSerializer.cs @@ -16,7 +16,10 @@ public interface IHttpContentSerializer /// that contains the serialized object. #if !NET8_0_OR_GREATER #endif - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Type parameter intentionally specified explicitly by callers.")] HttpContent ToHttpContent(T item); /// Deserializes an object of type from an object. @@ -26,10 +29,13 @@ public interface IHttpContentSerializer /// The deserialized object of type . #if !NET8_0_OR_GREATER #endif - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [SuppressMessage( - "Major Code Smell", - "S2360:Optional parameters should not be used", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Type parameter intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", Justification = "Optional CancellationToken is part of the published interface contract; overloads need default interface methods unavailable on netstandard2.0/net4x.")] Task FromHttpContentAsync( HttpContent content, diff --git a/src/Refit/IQueryConverter.cs b/src/Refit/IQueryConverter.cs new file mode 100644 index 000000000..0a52c106f --- /dev/null +++ b/src/Refit/IQueryConverter.cs @@ -0,0 +1,28 @@ +// 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; + +/// +/// Flattens a query parameter value into query-string pairs by hand, for shapes the source generator cannot flatten +/// from the declared type (an value, a polymorphic base type, a Dictionary<string, object>, +/// and similar). Attach an implementation to a parameter with . +/// +/// The declared parameter type the converter handles. +/// +/// A converter is a source-generation-only feature: it lets an otherwise-unflattenable parameter generate inline, +/// writing directly into the pooled so the path stays reflection- and +/// allocation-free. It is not consulted by the reflection request builder, which walks the value's runtime type +/// instead. Implementations should be stateless; the generator caches a single instance per converter type. +/// +public interface IQueryConverter +{ + /// Writes the query pairs for into . + /// The parameter value; never null when the parameter is null-guarded by the generator. + /// The resolved key prefix from the parameter's [Query(Prefix)], or an empty + /// string. Prepend it to each key you write. + /// The query-string builder to append pairs to (via ). + /// The active Refit settings, exposing the configured formatters. + void Flatten(T value, string keyPrefix, ref GeneratedQueryStringBuilder builder, RefitSettings settings); +} diff --git a/src/Refit/IRequestBuilder.cs b/src/Refit/IRequestBuilder.cs index d0408306a..be41fc5f3 100644 --- a/src/Refit/IRequestBuilder.cs +++ b/src/Refit/IRequestBuilder.cs @@ -32,8 +32,8 @@ public interface IRequestBuilder /// A delegate that takes an HttpClient and an array of argument values, and returns the result of invoking the /// specified REST method. The return type matches the method's declared return type. [SuppressMessage( - "Major Code Smell", - "S2360:Optional parameters should not be used", + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", Justification = "The optional parameters are part of Refit's public request-builder contract and are relied on by the Refit source generator and existing callers.")] [RequiresUnreferencedCode("Building request delegates from reflected method metadata requires generic method metadata to be available at runtime.")] [RequiresDynamicCode("Building request delegates from reflected method metadata requires runtime generic method instantiation.")] diff --git a/src/Refit/IRequestBuilderFactory.cs b/src/Refit/IRequestBuilderFactory.cs index 641165e51..ae87ea907 100644 --- a/src/Refit/IRequestBuilderFactory.cs +++ b/src/Refit/IRequestBuilderFactory.cs @@ -13,8 +13,8 @@ internal interface IRequestBuilderFactory /// The settings to configure the builder. /// A request builder for the interface type. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresUnreferencedCode("Building requests from reflected interface methods requires interface and request object metadata to be available at runtime.")] IRequestBuilder Create< diff --git a/src/Refit/IRequestBuilder{T}.cs b/src/Refit/IRequestBuilder{T}.cs index 6afab08f2..4029d0bae 100644 --- a/src/Refit/IRequestBuilder{T}.cs +++ b/src/Refit/IRequestBuilder{T}.cs @@ -8,10 +8,6 @@ namespace Refit; /// Defines a generic contract for building requests with a specified result type. /// The type of the result produced by the request builder. -[SuppressMessage( - "Major Code Smell", - "S2326:Unused type parameters should be removed", - Justification = "The type parameter identifies the Refit interface and is intentionally carried by this marker interface for strongly typed APIs.")] [SuppressMessage( "StyleSharp", "SST1452:Unused type parameters should be removed", diff --git a/src/Refit/IReturnTypeAdapter.cs b/src/Refit/IReturnTypeAdapter.cs new file mode 100644 index 000000000..38e8b04c2 --- /dev/null +++ b/src/Refit/IReturnTypeAdapter.cs @@ -0,0 +1,29 @@ +// 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; + +/// +/// Adapts a deferred asynchronous HTTP call producing a into the return type +/// surfaced by a Refit interface method. Implement this to teach Refit a new +/// return shape (for example IObservable<T> or Result<T>). +/// +/// The return type the interface method exposes. +/// The result the HTTP call materializes — the deserialized response body type. +/// +/// The source generator discovers implementors at compile time by their closed +/// and emits a direct call, needing no reflection or registration. The opt-in reflection request builder resolves +/// adapters registered in . Implementations must expose a public +/// parameterless constructor; a generic adapter's single type parameter is treated as the wrapped result type +/// (for example Adapter<T> : IReturnTypeAdapter<Wrapper<T>, T>). +/// +public interface IReturnTypeAdapter +{ + /// Adapts the deferred HTTP call into the surfaced return value. + /// Sends the request and yields the materialized result. Cold shapes may invoke it lazily + /// on each subscription; the reflection builder rebuilds the request per invocation, while a generated call + /// captures the request built once, so it is single-use. + /// The surfaced return value. + TReturn Adapt(Func> invoke); +} diff --git a/src/Refit/IStreamingContentSerializer.cs b/src/Refit/IStreamingContentSerializer.cs index 33ef2de97..32d155b4f 100644 --- a/src/Refit/IStreamingContentSerializer.cs +++ b/src/Refit/IStreamingContentSerializer.cs @@ -19,10 +19,13 @@ public interface IStreamingContentSerializer /// How the body is framed (a single JSON array or newline-delimited JSON). /// A token to cancel enumeration. /// An asynchronous sequence of deserialized values. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [SuppressMessage( - "Major Code Smell", - "S2360:Optional parameters should not be used", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Type parameter intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", Justification = "Optional CancellationToken is part of the published interface contract; overloads need default interface methods unavailable on netstandard2.0/net4x.")] IAsyncEnumerable DeserializeStreamAsync( Stream stream, diff --git a/src/Refit/ISynchronousContentDeserializer.cs b/src/Refit/ISynchronousContentDeserializer.cs index 04b06f2e1..09f8236bb 100644 --- a/src/Refit/ISynchronousContentDeserializer.cs +++ b/src/Refit/ISynchronousContentDeserializer.cs @@ -19,6 +19,9 @@ public interface ISynchronousContentDeserializer /// The type to deserialize the content to. /// The already-buffered content string. /// The deserialized object of type . - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Type parameter intentionally specified explicitly by callers.")] T? DeserializeFromString(string content); } diff --git a/src/Refit/ISynchronousContentSerializer.cs b/src/Refit/ISynchronousContentSerializer.cs index 604df4ff6..642b040a3 100644 --- a/src/Refit/ISynchronousContentSerializer.cs +++ b/src/Refit/ISynchronousContentSerializer.cs @@ -20,7 +20,10 @@ public interface ISynchronousContentSerializer /// The type of the object to serialize. /// The object to serialize. /// A buffered (for example a ByteArrayContent) containing the serialized object. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Type parameter intentionally specified explicitly by callers.")] HttpContent ToHttpContentSynchronous(T item); /// @@ -30,6 +33,9 @@ public interface ISynchronousContentSerializer /// The type of the object to serialize. /// The object to serialize. /// A streaming that writes the serialized object when sent. - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Type parameter intentionally specified explicitly by callers.")] HttpContent ToStreamingHttpContent(T item); } diff --git a/src/Refit/JsonContentSerializer.cs b/src/Refit/JsonContentSerializer.cs index 7aa831633..ff19f0297 100644 --- a/src/Refit/JsonContentSerializer.cs +++ b/src/Refit/JsonContentSerializer.cs @@ -13,8 +13,8 @@ namespace Refit; true)] [ExcludeFromCodeCoverage] [SuppressMessage( - "Major Code Smell", - "S1133:Deprecated code should be removed", + "Design", + "SST2310:Deprecated code should be removed", Justification = "Public API type retained for backwards compatibility; cannot be removed without a breaking change.")] public class JsonContentSerializer : IHttpContentSerializer { @@ -36,8 +36,8 @@ public class JsonContentSerializer : IHttpContentSerializer /// /// Always thrown; this serializer is obsolete. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] public Task FromHttpContentAsync( HttpContent content, diff --git a/src/Refit/PublicAPI/net10.0/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net10.0/PublicAPI.Shipped.txt index d8acfc713..9dfd71936 100644 --- a/src/Refit/PublicAPI/net10.0/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net10.0/PublicAPI.Shipped.txt @@ -40,8 +40,8 @@ Refit.ApiResponse.ApiResponse(System.Net.Http.HttpResponseMessage! response, Refit.ApiResponse.Content.get -> T? Refit.ApiResponse.ContentHeaders.get -> System.Net.Http.Headers.HttpContentHeaders? Refit.ApiResponse.Dispose() -> void -Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.ApiResponse.Error.get -> Refit.ApiExceptionBase? Refit.ApiResponse.HasContent.get -> bool Refit.ApiResponse.HasRequestError(out Refit.ApiRequestException? error) -> bool @@ -58,8 +58,8 @@ Refit.ApiResponse.StatusCode.get -> System.Net.HttpStatusCode? Refit.ApiResponse.Version.get -> System.Version? Refit.ApiResponseExtensions Refit.ApiResponseExtensions.extension(Refit.IApiResponse!) -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.AttachmentNameAttribute Refit.AttachmentNameAttribute.AttachmentNameAttribute(string! name) -> void Refit.AttachmentNameAttribute.Name.get -> string! @@ -93,7 +93,7 @@ Refit.CollectionFormat.RefitParameterFormatter = 0 -> Refit.CollectionFormat Refit.CollectionFormat.Ssv = 2 -> Refit.CollectionFormat Refit.CollectionFormat.Tsv = 3 -> Refit.CollectionFormat Refit.DefaultApiExceptionFactory -Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.Task! +Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.ValueTask Refit.DefaultApiExceptionFactory.DefaultApiExceptionFactory(Refit.RefitSettings! refitSettings) -> void Refit.DefaultFormUrlEncodedParameterFormatter Refit.DefaultFormUrlEncodedParameterFormatter.DefaultFormUrlEncodedParameterFormatter() -> void @@ -105,6 +105,8 @@ Refit.DefaultUrlParameterKeyFormatter Refit.DefaultUrlParameterKeyFormatter.DefaultUrlParameterKeyFormatter() -> void Refit.DeleteAttribute Refit.DeleteAttribute.DeleteAttribute(string! path) -> void +Refit.EncodedAttribute +Refit.EncodedAttribute.EncodedAttribute() -> 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! @@ -123,6 +125,15 @@ Refit.GeneratedParameterAttributeProvider.GeneratedParameterAttributeProvider(Sy Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(System.Type! attributeType, bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.IsDefined(System.Type! attributeType, bool inherit) -> bool +Refit.GeneratedQueryStringBuilder +Refit.GeneratedQueryStringBuilder.Add(string! name, string? value, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.AddCollectionValue(string? value) -> void +Refit.GeneratedQueryStringBuilder.AddFlag(string? name, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.BeginCollection(string! name, Refit.CollectionFormat collectionFormat, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.Build() -> string! +Refit.GeneratedQueryStringBuilder.EndCollection() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath) -> void Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void @@ -247,13 +258,15 @@ Refit.QueryAttribute.SerializeNull.get -> bool Refit.QueryAttribute.SerializeNull.set -> void Refit.QueryAttribute.TreatAsString.get -> bool Refit.QueryAttribute.TreatAsString.set -> void +Refit.QueryNameAttribute +Refit.QueryNameAttribute.QueryNameAttribute() -> void Refit.QueryUriFormatAttribute Refit.QueryUriFormatAttribute.QueryUriFormatAttribute(System.UriFormat uriFormat) -> void Refit.QueryUriFormatAttribute.UriFormat.get -> System.UriFormat Refit.RefitSettings Refit.RefitSettings.AllowUnmatchedRouteParameters.get -> bool Refit.RefitSettings.AllowUnmatchedRouteParameters.set -> void -Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func!>? +Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func>? Refit.RefitSettings.AuthorizationHeaderValueGetter.set -> void Refit.RefitSettings.Buffered.get -> bool Refit.RefitSettings.Buffered.set -> void @@ -263,9 +276,9 @@ Refit.RefitSettings.CollectionFormat.get -> Refit.CollectionFormat Refit.RefitSettings.CollectionFormat.set -> void Refit.RefitSettings.ContentSerializer.get -> Refit.IHttpContentSerializer! Refit.RefitSettings.ContentSerializer.set -> void -Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func!>? +Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func>? Refit.RefitSettings.DeserializationExceptionFactory.set -> void -Refit.RefitSettings.ExceptionFactory.get -> System.Func!>! +Refit.RefitSettings.ExceptionFactory.get -> System.Func>! Refit.RefitSettings.ExceptionFactory.set -> void Refit.RefitSettings.ExceptionRedactor.get -> System.Action? Refit.RefitSettings.ExceptionRedactor.set -> void @@ -384,21 +397,23 @@ 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.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! -static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! +static Refit.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> +static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> 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.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution) -> System.Uri! -static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, params ((int startIdx, int endIdx) range, string? value)[]! uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter) -> string! static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateJsonLinesBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body, Refit.FormField![]! fields) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.FormatInvariant(T value, string? format) -> string! 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.GeneratedRequestRunner.StreamAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Threading.CancellationToken methodCancellationToken, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(Refit.RefitSettings! settings) -> bool static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RequestContent.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! @@ -444,3 +459,21 @@ virtual Refit.DefaultUrlParameterFormatter.Format(object? value, System.Reflecti virtual Refit.DefaultUrlParameterKeyFormatter.Format(string! key) -> string! virtual Refit.HttpMethodAttribute.Path.get -> string! virtual Refit.HttpMethodAttribute.Path.set -> void +static Refit.GeneratedRequestRunner.BuildQueryKey(Refit.RefitSettings! settings, string! clrName, string? explicitName, string? prefixSegment) -> string! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterKeyFormatting(Refit.RefitSettings! settings) -> bool +static Refit.GeneratedRequestRunner.UsesDefaultFormUrlEncodedParameterFormatting(Refit.RefitSettings! settings) -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.get -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.set -> void +static Refit.GeneratedRequestRunner.AddFormattedCollectionProperty(ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings, System.Collections.IEnumerable? values, string! key, Refit.CollectionFormat collectionFormat, bool preEncoded, (System.Type! ElementProviderType, System.Reflection.ICustomAttributeProvider! JoinedProvider, System.Type! JoinedType) formatting) -> void +Refit.IQueryConverter +Refit.IQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.QueryConverterAttribute +Refit.QueryConverterAttribute.QueryConverterAttribute(System.Type! converterType) -> void +Refit.QueryConverterAttribute.ConverterType.get -> System.Type! +Refit.SystemTextJsonContentSerializer.SerializerOptions.get -> System.Text.Json.JsonSerializerOptions! +Refit.SystemTextJsonQueryConverter +Refit.SystemTextJsonQueryConverter.SystemTextJsonQueryConverter() -> void +Refit.SystemTextJsonQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.IReturnTypeAdapter +Refit.IReturnTypeAdapter.Adapt(System.Func!>! invoke) -> TReturn +Refit.RefitSettings.ReturnTypeAdapters.get -> System.Collections.Generic.IList! diff --git a/src/Refit/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/src/Refit/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 7dc5c5811..0024fb6e1 100644 --- a/src/Refit/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/src/Refit/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1 +1,19 @@ #nullable enable +Refit.GeneratedQueryStringBuilder.AddCollectionValueFormatted(T value) -> void +Refit.GeneratedQueryStringBuilder.AddFormatted(string! name, T value, string? format, bool preEncoded) -> void +static Refit.GeneratedRequestRunner.CanUnrollForm(object? body) -> bool +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, (int startIdx, int endIdx) range, T value) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, (int startIdx, int endIdx) range, T value, string? format) -> string! +static Refit.GeneratedRequestRunner.RoundTripEscapePath(string? value, Refit.IUrlParameterFormatter! formatter, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string! +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath, bool hasQuery) -> void +static readonly Refit.GeneratedParameterAttributeProvider.Empty -> Refit.GeneratedParameterAttributeProvider! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value, bool preEncoded)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution, System.UriFormat queryUriFormat) -> System.Uri! +static Refit.GeneratedRequestRunner.SendObservable(System.Net.Http.HttpClient! client, System.Func! requestFactory, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken methodCancellationToken) -> System.IObservable! +Refit.RestMethodParameterProperty.RestMethodParameterProperty(string! name, System.Collections.Generic.IReadOnlyList! propertyChain) -> void +Refit.RestMethodParameterProperty.PropertyChain.get -> System.Collections.Generic.IReadOnlyList! +Refit.RestMethodParameterProperty.PropertyChain.set -> void +static Refit.GeneratedRequestRunner.SerializeMultipartPart(Refit.RefitSettings! settings, T value, string! fieldName) -> System.Net.Http.HttpContent! +Refit.GeneratedQueryStringBuilder.AddPreEscapedKey(string! name, string? value, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.AddFormattedPreEscapedKey(string! name, T value, string? format, bool preEncoded) -> void diff --git a/src/Refit/PublicAPI/net11.0/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net11.0/PublicAPI.Shipped.txt index d8acfc713..9dfd71936 100644 --- a/src/Refit/PublicAPI/net11.0/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net11.0/PublicAPI.Shipped.txt @@ -40,8 +40,8 @@ Refit.ApiResponse.ApiResponse(System.Net.Http.HttpResponseMessage! response, Refit.ApiResponse.Content.get -> T? Refit.ApiResponse.ContentHeaders.get -> System.Net.Http.Headers.HttpContentHeaders? Refit.ApiResponse.Dispose() -> void -Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.ApiResponse.Error.get -> Refit.ApiExceptionBase? Refit.ApiResponse.HasContent.get -> bool Refit.ApiResponse.HasRequestError(out Refit.ApiRequestException? error) -> bool @@ -58,8 +58,8 @@ Refit.ApiResponse.StatusCode.get -> System.Net.HttpStatusCode? Refit.ApiResponse.Version.get -> System.Version? Refit.ApiResponseExtensions Refit.ApiResponseExtensions.extension(Refit.IApiResponse!) -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.AttachmentNameAttribute Refit.AttachmentNameAttribute.AttachmentNameAttribute(string! name) -> void Refit.AttachmentNameAttribute.Name.get -> string! @@ -93,7 +93,7 @@ Refit.CollectionFormat.RefitParameterFormatter = 0 -> Refit.CollectionFormat Refit.CollectionFormat.Ssv = 2 -> Refit.CollectionFormat Refit.CollectionFormat.Tsv = 3 -> Refit.CollectionFormat Refit.DefaultApiExceptionFactory -Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.Task! +Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.ValueTask Refit.DefaultApiExceptionFactory.DefaultApiExceptionFactory(Refit.RefitSettings! refitSettings) -> void Refit.DefaultFormUrlEncodedParameterFormatter Refit.DefaultFormUrlEncodedParameterFormatter.DefaultFormUrlEncodedParameterFormatter() -> void @@ -105,6 +105,8 @@ Refit.DefaultUrlParameterKeyFormatter Refit.DefaultUrlParameterKeyFormatter.DefaultUrlParameterKeyFormatter() -> void Refit.DeleteAttribute Refit.DeleteAttribute.DeleteAttribute(string! path) -> void +Refit.EncodedAttribute +Refit.EncodedAttribute.EncodedAttribute() -> 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! @@ -123,6 +125,15 @@ Refit.GeneratedParameterAttributeProvider.GeneratedParameterAttributeProvider(Sy Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(System.Type! attributeType, bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.IsDefined(System.Type! attributeType, bool inherit) -> bool +Refit.GeneratedQueryStringBuilder +Refit.GeneratedQueryStringBuilder.Add(string! name, string? value, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.AddCollectionValue(string? value) -> void +Refit.GeneratedQueryStringBuilder.AddFlag(string? name, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.BeginCollection(string! name, Refit.CollectionFormat collectionFormat, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.Build() -> string! +Refit.GeneratedQueryStringBuilder.EndCollection() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath) -> void Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void @@ -247,13 +258,15 @@ Refit.QueryAttribute.SerializeNull.get -> bool Refit.QueryAttribute.SerializeNull.set -> void Refit.QueryAttribute.TreatAsString.get -> bool Refit.QueryAttribute.TreatAsString.set -> void +Refit.QueryNameAttribute +Refit.QueryNameAttribute.QueryNameAttribute() -> void Refit.QueryUriFormatAttribute Refit.QueryUriFormatAttribute.QueryUriFormatAttribute(System.UriFormat uriFormat) -> void Refit.QueryUriFormatAttribute.UriFormat.get -> System.UriFormat Refit.RefitSettings Refit.RefitSettings.AllowUnmatchedRouteParameters.get -> bool Refit.RefitSettings.AllowUnmatchedRouteParameters.set -> void -Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func!>? +Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func>? Refit.RefitSettings.AuthorizationHeaderValueGetter.set -> void Refit.RefitSettings.Buffered.get -> bool Refit.RefitSettings.Buffered.set -> void @@ -263,9 +276,9 @@ Refit.RefitSettings.CollectionFormat.get -> Refit.CollectionFormat Refit.RefitSettings.CollectionFormat.set -> void Refit.RefitSettings.ContentSerializer.get -> Refit.IHttpContentSerializer! Refit.RefitSettings.ContentSerializer.set -> void -Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func!>? +Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func>? Refit.RefitSettings.DeserializationExceptionFactory.set -> void -Refit.RefitSettings.ExceptionFactory.get -> System.Func!>! +Refit.RefitSettings.ExceptionFactory.get -> System.Func>! Refit.RefitSettings.ExceptionFactory.set -> void Refit.RefitSettings.ExceptionRedactor.get -> System.Action? Refit.RefitSettings.ExceptionRedactor.set -> void @@ -384,21 +397,23 @@ 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.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! -static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! +static Refit.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> +static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> 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.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution) -> System.Uri! -static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, params ((int startIdx, int endIdx) range, string? value)[]! uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter) -> string! static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateJsonLinesBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body, Refit.FormField![]! fields) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.FormatInvariant(T value, string? format) -> string! 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.GeneratedRequestRunner.StreamAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Threading.CancellationToken methodCancellationToken, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(Refit.RefitSettings! settings) -> bool static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RequestContent.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! @@ -444,3 +459,21 @@ virtual Refit.DefaultUrlParameterFormatter.Format(object? value, System.Reflecti virtual Refit.DefaultUrlParameterKeyFormatter.Format(string! key) -> string! virtual Refit.HttpMethodAttribute.Path.get -> string! virtual Refit.HttpMethodAttribute.Path.set -> void +static Refit.GeneratedRequestRunner.BuildQueryKey(Refit.RefitSettings! settings, string! clrName, string? explicitName, string? prefixSegment) -> string! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterKeyFormatting(Refit.RefitSettings! settings) -> bool +static Refit.GeneratedRequestRunner.UsesDefaultFormUrlEncodedParameterFormatting(Refit.RefitSettings! settings) -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.get -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.set -> void +static Refit.GeneratedRequestRunner.AddFormattedCollectionProperty(ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings, System.Collections.IEnumerable? values, string! key, Refit.CollectionFormat collectionFormat, bool preEncoded, (System.Type! ElementProviderType, System.Reflection.ICustomAttributeProvider! JoinedProvider, System.Type! JoinedType) formatting) -> void +Refit.IQueryConverter +Refit.IQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.QueryConverterAttribute +Refit.QueryConverterAttribute.QueryConverterAttribute(System.Type! converterType) -> void +Refit.QueryConverterAttribute.ConverterType.get -> System.Type! +Refit.SystemTextJsonContentSerializer.SerializerOptions.get -> System.Text.Json.JsonSerializerOptions! +Refit.SystemTextJsonQueryConverter +Refit.SystemTextJsonQueryConverter.SystemTextJsonQueryConverter() -> void +Refit.SystemTextJsonQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.IReturnTypeAdapter +Refit.IReturnTypeAdapter.Adapt(System.Func!>! invoke) -> TReturn +Refit.RefitSettings.ReturnTypeAdapters.get -> System.Collections.Generic.IList! diff --git a/src/Refit/PublicAPI/net11.0/PublicAPI.Unshipped.txt b/src/Refit/PublicAPI/net11.0/PublicAPI.Unshipped.txt index 7dc5c5811..0024fb6e1 100644 --- a/src/Refit/PublicAPI/net11.0/PublicAPI.Unshipped.txt +++ b/src/Refit/PublicAPI/net11.0/PublicAPI.Unshipped.txt @@ -1 +1,19 @@ #nullable enable +Refit.GeneratedQueryStringBuilder.AddCollectionValueFormatted(T value) -> void +Refit.GeneratedQueryStringBuilder.AddFormatted(string! name, T value, string? format, bool preEncoded) -> void +static Refit.GeneratedRequestRunner.CanUnrollForm(object? body) -> bool +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, (int startIdx, int endIdx) range, T value) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, (int startIdx, int endIdx) range, T value, string? format) -> string! +static Refit.GeneratedRequestRunner.RoundTripEscapePath(string? value, Refit.IUrlParameterFormatter! formatter, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string! +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath, bool hasQuery) -> void +static readonly Refit.GeneratedParameterAttributeProvider.Empty -> Refit.GeneratedParameterAttributeProvider! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value, bool preEncoded)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution, System.UriFormat queryUriFormat) -> System.Uri! +static Refit.GeneratedRequestRunner.SendObservable(System.Net.Http.HttpClient! client, System.Func! requestFactory, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken methodCancellationToken) -> System.IObservable! +Refit.RestMethodParameterProperty.RestMethodParameterProperty(string! name, System.Collections.Generic.IReadOnlyList! propertyChain) -> void +Refit.RestMethodParameterProperty.PropertyChain.get -> System.Collections.Generic.IReadOnlyList! +Refit.RestMethodParameterProperty.PropertyChain.set -> void +static Refit.GeneratedRequestRunner.SerializeMultipartPart(Refit.RefitSettings! settings, T value, string! fieldName) -> System.Net.Http.HttpContent! +Refit.GeneratedQueryStringBuilder.AddPreEscapedKey(string! name, string? value, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.AddFormattedPreEscapedKey(string! name, T value, string? format, bool preEncoded) -> void diff --git a/src/Refit/PublicAPI/net462/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net462/PublicAPI.Shipped.txt index 3bfb35082..fe39613b2 100644 --- a/src/Refit/PublicAPI/net462/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net462/PublicAPI.Shipped.txt @@ -40,8 +40,8 @@ Refit.ApiResponse.ApiResponse(System.Net.Http.HttpResponseMessage! response, Refit.ApiResponse.Content.get -> T? Refit.ApiResponse.ContentHeaders.get -> System.Net.Http.Headers.HttpContentHeaders? Refit.ApiResponse.Dispose() -> void -Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.ApiResponse.Error.get -> Refit.ApiExceptionBase? Refit.ApiResponse.HasContent.get -> bool Refit.ApiResponse.HasRequestError(out Refit.ApiRequestException? error) -> bool @@ -58,8 +58,8 @@ Refit.ApiResponse.StatusCode.get -> System.Net.HttpStatusCode? Refit.ApiResponse.Version.get -> System.Version? Refit.ApiResponseExtensions Refit.ApiResponseExtensions.extension(Refit.IApiResponse!) -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.AttachmentNameAttribute Refit.AttachmentNameAttribute.AttachmentNameAttribute(string! name) -> void Refit.AttachmentNameAttribute.Name.get -> string! @@ -93,7 +93,7 @@ Refit.CollectionFormat.RefitParameterFormatter = 0 -> Refit.CollectionFormat Refit.CollectionFormat.Ssv = 2 -> Refit.CollectionFormat Refit.CollectionFormat.Tsv = 3 -> Refit.CollectionFormat Refit.DefaultApiExceptionFactory -Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.Task! +Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.ValueTask Refit.DefaultApiExceptionFactory.DefaultApiExceptionFactory(Refit.RefitSettings! refitSettings) -> void Refit.DefaultFormUrlEncodedParameterFormatter Refit.DefaultFormUrlEncodedParameterFormatter.DefaultFormUrlEncodedParameterFormatter() -> void @@ -105,6 +105,8 @@ Refit.DefaultUrlParameterKeyFormatter Refit.DefaultUrlParameterKeyFormatter.DefaultUrlParameterKeyFormatter() -> void Refit.DeleteAttribute Refit.DeleteAttribute.DeleteAttribute(string! path) -> void +Refit.EncodedAttribute +Refit.EncodedAttribute.EncodedAttribute() -> 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! @@ -123,6 +125,15 @@ Refit.GeneratedParameterAttributeProvider.GeneratedParameterAttributeProvider(Sy Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(System.Type! attributeType, bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.IsDefined(System.Type! attributeType, bool inherit) -> bool +Refit.GeneratedQueryStringBuilder +Refit.GeneratedQueryStringBuilder.Add(string! name, string? value, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.AddCollectionValue(string? value) -> void +Refit.GeneratedQueryStringBuilder.AddFlag(string? name, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.BeginCollection(string! name, Refit.CollectionFormat collectionFormat, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.Build() -> string! +Refit.GeneratedQueryStringBuilder.EndCollection() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath) -> void Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void @@ -247,13 +258,15 @@ Refit.QueryAttribute.SerializeNull.get -> bool Refit.QueryAttribute.SerializeNull.set -> void Refit.QueryAttribute.TreatAsString.get -> bool Refit.QueryAttribute.TreatAsString.set -> void +Refit.QueryNameAttribute +Refit.QueryNameAttribute.QueryNameAttribute() -> void Refit.QueryUriFormatAttribute Refit.QueryUriFormatAttribute.QueryUriFormatAttribute(System.UriFormat uriFormat) -> void Refit.QueryUriFormatAttribute.UriFormat.get -> System.UriFormat Refit.RefitSettings Refit.RefitSettings.AllowUnmatchedRouteParameters.get -> bool Refit.RefitSettings.AllowUnmatchedRouteParameters.set -> void -Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func!>? +Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func>? Refit.RefitSettings.AuthorizationHeaderValueGetter.set -> void Refit.RefitSettings.Buffered.get -> bool Refit.RefitSettings.Buffered.set -> void @@ -263,9 +276,9 @@ Refit.RefitSettings.CollectionFormat.get -> Refit.CollectionFormat Refit.RefitSettings.CollectionFormat.set -> void Refit.RefitSettings.ContentSerializer.get -> Refit.IHttpContentSerializer! Refit.RefitSettings.ContentSerializer.set -> void -Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func!>? +Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func>? Refit.RefitSettings.DeserializationExceptionFactory.set -> void -Refit.RefitSettings.ExceptionFactory.get -> System.Func!>! +Refit.RefitSettings.ExceptionFactory.get -> System.Func>! Refit.RefitSettings.ExceptionFactory.set -> void Refit.RefitSettings.ExceptionRedactor.get -> System.Action? Refit.RefitSettings.ExceptionRedactor.set -> void @@ -380,21 +393,23 @@ 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.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! -static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! +static Refit.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> +static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> 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.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution) -> System.Uri! -static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, params ((int startIdx, int endIdx) range, string? value)[]! uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter) -> string! static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateJsonLinesBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body, Refit.FormField![]! fields) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.FormatInvariant(T value, string? format) -> string! 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.GeneratedRequestRunner.StreamAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Threading.CancellationToken methodCancellationToken, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(Refit.RefitSettings! settings) -> bool static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RequestContent.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! @@ -440,3 +455,21 @@ virtual Refit.DefaultUrlParameterFormatter.Format(object? value, System.Reflecti virtual Refit.DefaultUrlParameterKeyFormatter.Format(string! key) -> string! virtual Refit.HttpMethodAttribute.Path.get -> string! virtual Refit.HttpMethodAttribute.Path.set -> void +static Refit.GeneratedRequestRunner.BuildQueryKey(Refit.RefitSettings! settings, string! clrName, string? explicitName, string? prefixSegment) -> string! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterKeyFormatting(Refit.RefitSettings! settings) -> bool +static Refit.GeneratedRequestRunner.UsesDefaultFormUrlEncodedParameterFormatting(Refit.RefitSettings! settings) -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.get -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.set -> void +static Refit.GeneratedRequestRunner.AddFormattedCollectionProperty(ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings, System.Collections.IEnumerable? values, string! key, Refit.CollectionFormat collectionFormat, bool preEncoded, (System.Type! ElementProviderType, System.Reflection.ICustomAttributeProvider! JoinedProvider, System.Type! JoinedType) formatting) -> void +Refit.IQueryConverter +Refit.IQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.QueryConverterAttribute +Refit.QueryConverterAttribute.QueryConverterAttribute(System.Type! converterType) -> void +Refit.QueryConverterAttribute.ConverterType.get -> System.Type! +Refit.SystemTextJsonContentSerializer.SerializerOptions.get -> System.Text.Json.JsonSerializerOptions! +Refit.SystemTextJsonQueryConverter +Refit.SystemTextJsonQueryConverter.SystemTextJsonQueryConverter() -> void +Refit.SystemTextJsonQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.IReturnTypeAdapter +Refit.IReturnTypeAdapter.Adapt(System.Func!>! invoke) -> TReturn +Refit.RefitSettings.ReturnTypeAdapters.get -> System.Collections.Generic.IList! diff --git a/src/Refit/PublicAPI/net462/PublicAPI.Unshipped.txt b/src/Refit/PublicAPI/net462/PublicAPI.Unshipped.txt index 7dc5c5811..0290036a3 100644 --- a/src/Refit/PublicAPI/net462/PublicAPI.Unshipped.txt +++ b/src/Refit/PublicAPI/net462/PublicAPI.Unshipped.txt @@ -1 +1,14 @@ #nullable enable +static Refit.GeneratedRequestRunner.CanUnrollForm(object? body) -> bool +static Refit.GeneratedRequestRunner.RoundTripEscapePath(string? value, Refit.IUrlParameterFormatter! formatter, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string! +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath, bool hasQuery) -> void +static readonly Refit.GeneratedParameterAttributeProvider.Empty -> Refit.GeneratedParameterAttributeProvider! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value, bool preEncoded)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution, System.UriFormat queryUriFormat) -> System.Uri! +static Refit.GeneratedRequestRunner.SendObservable(System.Net.Http.HttpClient! client, System.Func! requestFactory, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken methodCancellationToken) -> System.IObservable! +Refit.RestMethodParameterProperty.RestMethodParameterProperty(string! name, System.Collections.Generic.IReadOnlyList! propertyChain) -> void +Refit.RestMethodParameterProperty.PropertyChain.get -> System.Collections.Generic.IReadOnlyList! +Refit.RestMethodParameterProperty.PropertyChain.set -> void +static Refit.GeneratedRequestRunner.SerializeMultipartPart(Refit.RefitSettings! settings, T value, string! fieldName) -> System.Net.Http.HttpContent! +Refit.GeneratedQueryStringBuilder.AddPreEscapedKey(string! name, string? value, bool preEncoded) -> void diff --git a/src/Refit/PublicAPI/net470/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net470/PublicAPI.Shipped.txt index 3bfb35082..fe39613b2 100644 --- a/src/Refit/PublicAPI/net470/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net470/PublicAPI.Shipped.txt @@ -40,8 +40,8 @@ Refit.ApiResponse.ApiResponse(System.Net.Http.HttpResponseMessage! response, Refit.ApiResponse.Content.get -> T? Refit.ApiResponse.ContentHeaders.get -> System.Net.Http.Headers.HttpContentHeaders? Refit.ApiResponse.Dispose() -> void -Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.ApiResponse.Error.get -> Refit.ApiExceptionBase? Refit.ApiResponse.HasContent.get -> bool Refit.ApiResponse.HasRequestError(out Refit.ApiRequestException? error) -> bool @@ -58,8 +58,8 @@ Refit.ApiResponse.StatusCode.get -> System.Net.HttpStatusCode? Refit.ApiResponse.Version.get -> System.Version? Refit.ApiResponseExtensions Refit.ApiResponseExtensions.extension(Refit.IApiResponse!) -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.AttachmentNameAttribute Refit.AttachmentNameAttribute.AttachmentNameAttribute(string! name) -> void Refit.AttachmentNameAttribute.Name.get -> string! @@ -93,7 +93,7 @@ Refit.CollectionFormat.RefitParameterFormatter = 0 -> Refit.CollectionFormat Refit.CollectionFormat.Ssv = 2 -> Refit.CollectionFormat Refit.CollectionFormat.Tsv = 3 -> Refit.CollectionFormat Refit.DefaultApiExceptionFactory -Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.Task! +Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.ValueTask Refit.DefaultApiExceptionFactory.DefaultApiExceptionFactory(Refit.RefitSettings! refitSettings) -> void Refit.DefaultFormUrlEncodedParameterFormatter Refit.DefaultFormUrlEncodedParameterFormatter.DefaultFormUrlEncodedParameterFormatter() -> void @@ -105,6 +105,8 @@ Refit.DefaultUrlParameterKeyFormatter Refit.DefaultUrlParameterKeyFormatter.DefaultUrlParameterKeyFormatter() -> void Refit.DeleteAttribute Refit.DeleteAttribute.DeleteAttribute(string! path) -> void +Refit.EncodedAttribute +Refit.EncodedAttribute.EncodedAttribute() -> 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! @@ -123,6 +125,15 @@ Refit.GeneratedParameterAttributeProvider.GeneratedParameterAttributeProvider(Sy Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(System.Type! attributeType, bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.IsDefined(System.Type! attributeType, bool inherit) -> bool +Refit.GeneratedQueryStringBuilder +Refit.GeneratedQueryStringBuilder.Add(string! name, string? value, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.AddCollectionValue(string? value) -> void +Refit.GeneratedQueryStringBuilder.AddFlag(string? name, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.BeginCollection(string! name, Refit.CollectionFormat collectionFormat, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.Build() -> string! +Refit.GeneratedQueryStringBuilder.EndCollection() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath) -> void Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void @@ -247,13 +258,15 @@ Refit.QueryAttribute.SerializeNull.get -> bool Refit.QueryAttribute.SerializeNull.set -> void Refit.QueryAttribute.TreatAsString.get -> bool Refit.QueryAttribute.TreatAsString.set -> void +Refit.QueryNameAttribute +Refit.QueryNameAttribute.QueryNameAttribute() -> void Refit.QueryUriFormatAttribute Refit.QueryUriFormatAttribute.QueryUriFormatAttribute(System.UriFormat uriFormat) -> void Refit.QueryUriFormatAttribute.UriFormat.get -> System.UriFormat Refit.RefitSettings Refit.RefitSettings.AllowUnmatchedRouteParameters.get -> bool Refit.RefitSettings.AllowUnmatchedRouteParameters.set -> void -Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func!>? +Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func>? Refit.RefitSettings.AuthorizationHeaderValueGetter.set -> void Refit.RefitSettings.Buffered.get -> bool Refit.RefitSettings.Buffered.set -> void @@ -263,9 +276,9 @@ Refit.RefitSettings.CollectionFormat.get -> Refit.CollectionFormat Refit.RefitSettings.CollectionFormat.set -> void Refit.RefitSettings.ContentSerializer.get -> Refit.IHttpContentSerializer! Refit.RefitSettings.ContentSerializer.set -> void -Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func!>? +Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func>? Refit.RefitSettings.DeserializationExceptionFactory.set -> void -Refit.RefitSettings.ExceptionFactory.get -> System.Func!>! +Refit.RefitSettings.ExceptionFactory.get -> System.Func>! Refit.RefitSettings.ExceptionFactory.set -> void Refit.RefitSettings.ExceptionRedactor.get -> System.Action? Refit.RefitSettings.ExceptionRedactor.set -> void @@ -380,21 +393,23 @@ 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.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! -static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! +static Refit.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> +static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> 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.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution) -> System.Uri! -static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, params ((int startIdx, int endIdx) range, string? value)[]! uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter) -> string! static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateJsonLinesBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body, Refit.FormField![]! fields) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.FormatInvariant(T value, string? format) -> string! 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.GeneratedRequestRunner.StreamAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Threading.CancellationToken methodCancellationToken, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(Refit.RefitSettings! settings) -> bool static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RequestContent.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! @@ -440,3 +455,21 @@ virtual Refit.DefaultUrlParameterFormatter.Format(object? value, System.Reflecti virtual Refit.DefaultUrlParameterKeyFormatter.Format(string! key) -> string! virtual Refit.HttpMethodAttribute.Path.get -> string! virtual Refit.HttpMethodAttribute.Path.set -> void +static Refit.GeneratedRequestRunner.BuildQueryKey(Refit.RefitSettings! settings, string! clrName, string? explicitName, string? prefixSegment) -> string! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterKeyFormatting(Refit.RefitSettings! settings) -> bool +static Refit.GeneratedRequestRunner.UsesDefaultFormUrlEncodedParameterFormatting(Refit.RefitSettings! settings) -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.get -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.set -> void +static Refit.GeneratedRequestRunner.AddFormattedCollectionProperty(ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings, System.Collections.IEnumerable? values, string! key, Refit.CollectionFormat collectionFormat, bool preEncoded, (System.Type! ElementProviderType, System.Reflection.ICustomAttributeProvider! JoinedProvider, System.Type! JoinedType) formatting) -> void +Refit.IQueryConverter +Refit.IQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.QueryConverterAttribute +Refit.QueryConverterAttribute.QueryConverterAttribute(System.Type! converterType) -> void +Refit.QueryConverterAttribute.ConverterType.get -> System.Type! +Refit.SystemTextJsonContentSerializer.SerializerOptions.get -> System.Text.Json.JsonSerializerOptions! +Refit.SystemTextJsonQueryConverter +Refit.SystemTextJsonQueryConverter.SystemTextJsonQueryConverter() -> void +Refit.SystemTextJsonQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.IReturnTypeAdapter +Refit.IReturnTypeAdapter.Adapt(System.Func!>! invoke) -> TReturn +Refit.RefitSettings.ReturnTypeAdapters.get -> System.Collections.Generic.IList! diff --git a/src/Refit/PublicAPI/net470/PublicAPI.Unshipped.txt b/src/Refit/PublicAPI/net470/PublicAPI.Unshipped.txt index 7dc5c5811..0290036a3 100644 --- a/src/Refit/PublicAPI/net470/PublicAPI.Unshipped.txt +++ b/src/Refit/PublicAPI/net470/PublicAPI.Unshipped.txt @@ -1 +1,14 @@ #nullable enable +static Refit.GeneratedRequestRunner.CanUnrollForm(object? body) -> bool +static Refit.GeneratedRequestRunner.RoundTripEscapePath(string? value, Refit.IUrlParameterFormatter! formatter, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string! +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath, bool hasQuery) -> void +static readonly Refit.GeneratedParameterAttributeProvider.Empty -> Refit.GeneratedParameterAttributeProvider! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value, bool preEncoded)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution, System.UriFormat queryUriFormat) -> System.Uri! +static Refit.GeneratedRequestRunner.SendObservable(System.Net.Http.HttpClient! client, System.Func! requestFactory, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken methodCancellationToken) -> System.IObservable! +Refit.RestMethodParameterProperty.RestMethodParameterProperty(string! name, System.Collections.Generic.IReadOnlyList! propertyChain) -> void +Refit.RestMethodParameterProperty.PropertyChain.get -> System.Collections.Generic.IReadOnlyList! +Refit.RestMethodParameterProperty.PropertyChain.set -> void +static Refit.GeneratedRequestRunner.SerializeMultipartPart(Refit.RefitSettings! settings, T value, string! fieldName) -> System.Net.Http.HttpContent! +Refit.GeneratedQueryStringBuilder.AddPreEscapedKey(string! name, string? value, bool preEncoded) -> void diff --git a/src/Refit/PublicAPI/net471/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net471/PublicAPI.Shipped.txt index 3bfb35082..fe39613b2 100644 --- a/src/Refit/PublicAPI/net471/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net471/PublicAPI.Shipped.txt @@ -40,8 +40,8 @@ Refit.ApiResponse.ApiResponse(System.Net.Http.HttpResponseMessage! response, Refit.ApiResponse.Content.get -> T? Refit.ApiResponse.ContentHeaders.get -> System.Net.Http.Headers.HttpContentHeaders? Refit.ApiResponse.Dispose() -> void -Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.ApiResponse.Error.get -> Refit.ApiExceptionBase? Refit.ApiResponse.HasContent.get -> bool Refit.ApiResponse.HasRequestError(out Refit.ApiRequestException? error) -> bool @@ -58,8 +58,8 @@ Refit.ApiResponse.StatusCode.get -> System.Net.HttpStatusCode? Refit.ApiResponse.Version.get -> System.Version? Refit.ApiResponseExtensions Refit.ApiResponseExtensions.extension(Refit.IApiResponse!) -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.AttachmentNameAttribute Refit.AttachmentNameAttribute.AttachmentNameAttribute(string! name) -> void Refit.AttachmentNameAttribute.Name.get -> string! @@ -93,7 +93,7 @@ Refit.CollectionFormat.RefitParameterFormatter = 0 -> Refit.CollectionFormat Refit.CollectionFormat.Ssv = 2 -> Refit.CollectionFormat Refit.CollectionFormat.Tsv = 3 -> Refit.CollectionFormat Refit.DefaultApiExceptionFactory -Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.Task! +Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.ValueTask Refit.DefaultApiExceptionFactory.DefaultApiExceptionFactory(Refit.RefitSettings! refitSettings) -> void Refit.DefaultFormUrlEncodedParameterFormatter Refit.DefaultFormUrlEncodedParameterFormatter.DefaultFormUrlEncodedParameterFormatter() -> void @@ -105,6 +105,8 @@ Refit.DefaultUrlParameterKeyFormatter Refit.DefaultUrlParameterKeyFormatter.DefaultUrlParameterKeyFormatter() -> void Refit.DeleteAttribute Refit.DeleteAttribute.DeleteAttribute(string! path) -> void +Refit.EncodedAttribute +Refit.EncodedAttribute.EncodedAttribute() -> 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! @@ -123,6 +125,15 @@ Refit.GeneratedParameterAttributeProvider.GeneratedParameterAttributeProvider(Sy Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(System.Type! attributeType, bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.IsDefined(System.Type! attributeType, bool inherit) -> bool +Refit.GeneratedQueryStringBuilder +Refit.GeneratedQueryStringBuilder.Add(string! name, string? value, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.AddCollectionValue(string? value) -> void +Refit.GeneratedQueryStringBuilder.AddFlag(string? name, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.BeginCollection(string! name, Refit.CollectionFormat collectionFormat, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.Build() -> string! +Refit.GeneratedQueryStringBuilder.EndCollection() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath) -> void Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void @@ -247,13 +258,15 @@ Refit.QueryAttribute.SerializeNull.get -> bool Refit.QueryAttribute.SerializeNull.set -> void Refit.QueryAttribute.TreatAsString.get -> bool Refit.QueryAttribute.TreatAsString.set -> void +Refit.QueryNameAttribute +Refit.QueryNameAttribute.QueryNameAttribute() -> void Refit.QueryUriFormatAttribute Refit.QueryUriFormatAttribute.QueryUriFormatAttribute(System.UriFormat uriFormat) -> void Refit.QueryUriFormatAttribute.UriFormat.get -> System.UriFormat Refit.RefitSettings Refit.RefitSettings.AllowUnmatchedRouteParameters.get -> bool Refit.RefitSettings.AllowUnmatchedRouteParameters.set -> void -Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func!>? +Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func>? Refit.RefitSettings.AuthorizationHeaderValueGetter.set -> void Refit.RefitSettings.Buffered.get -> bool Refit.RefitSettings.Buffered.set -> void @@ -263,9 +276,9 @@ Refit.RefitSettings.CollectionFormat.get -> Refit.CollectionFormat Refit.RefitSettings.CollectionFormat.set -> void Refit.RefitSettings.ContentSerializer.get -> Refit.IHttpContentSerializer! Refit.RefitSettings.ContentSerializer.set -> void -Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func!>? +Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func>? Refit.RefitSettings.DeserializationExceptionFactory.set -> void -Refit.RefitSettings.ExceptionFactory.get -> System.Func!>! +Refit.RefitSettings.ExceptionFactory.get -> System.Func>! Refit.RefitSettings.ExceptionFactory.set -> void Refit.RefitSettings.ExceptionRedactor.get -> System.Action? Refit.RefitSettings.ExceptionRedactor.set -> void @@ -380,21 +393,23 @@ 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.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! -static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! +static Refit.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> +static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> 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.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution) -> System.Uri! -static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, params ((int startIdx, int endIdx) range, string? value)[]! uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter) -> string! static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateJsonLinesBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body, Refit.FormField![]! fields) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.FormatInvariant(T value, string? format) -> string! 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.GeneratedRequestRunner.StreamAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Threading.CancellationToken methodCancellationToken, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(Refit.RefitSettings! settings) -> bool static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RequestContent.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! @@ -440,3 +455,21 @@ virtual Refit.DefaultUrlParameterFormatter.Format(object? value, System.Reflecti virtual Refit.DefaultUrlParameterKeyFormatter.Format(string! key) -> string! virtual Refit.HttpMethodAttribute.Path.get -> string! virtual Refit.HttpMethodAttribute.Path.set -> void +static Refit.GeneratedRequestRunner.BuildQueryKey(Refit.RefitSettings! settings, string! clrName, string? explicitName, string? prefixSegment) -> string! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterKeyFormatting(Refit.RefitSettings! settings) -> bool +static Refit.GeneratedRequestRunner.UsesDefaultFormUrlEncodedParameterFormatting(Refit.RefitSettings! settings) -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.get -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.set -> void +static Refit.GeneratedRequestRunner.AddFormattedCollectionProperty(ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings, System.Collections.IEnumerable? values, string! key, Refit.CollectionFormat collectionFormat, bool preEncoded, (System.Type! ElementProviderType, System.Reflection.ICustomAttributeProvider! JoinedProvider, System.Type! JoinedType) formatting) -> void +Refit.IQueryConverter +Refit.IQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.QueryConverterAttribute +Refit.QueryConverterAttribute.QueryConverterAttribute(System.Type! converterType) -> void +Refit.QueryConverterAttribute.ConverterType.get -> System.Type! +Refit.SystemTextJsonContentSerializer.SerializerOptions.get -> System.Text.Json.JsonSerializerOptions! +Refit.SystemTextJsonQueryConverter +Refit.SystemTextJsonQueryConverter.SystemTextJsonQueryConverter() -> void +Refit.SystemTextJsonQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.IReturnTypeAdapter +Refit.IReturnTypeAdapter.Adapt(System.Func!>! invoke) -> TReturn +Refit.RefitSettings.ReturnTypeAdapters.get -> System.Collections.Generic.IList! diff --git a/src/Refit/PublicAPI/net471/PublicAPI.Unshipped.txt b/src/Refit/PublicAPI/net471/PublicAPI.Unshipped.txt index 7dc5c5811..0290036a3 100644 --- a/src/Refit/PublicAPI/net471/PublicAPI.Unshipped.txt +++ b/src/Refit/PublicAPI/net471/PublicAPI.Unshipped.txt @@ -1 +1,14 @@ #nullable enable +static Refit.GeneratedRequestRunner.CanUnrollForm(object? body) -> bool +static Refit.GeneratedRequestRunner.RoundTripEscapePath(string? value, Refit.IUrlParameterFormatter! formatter, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string! +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath, bool hasQuery) -> void +static readonly Refit.GeneratedParameterAttributeProvider.Empty -> Refit.GeneratedParameterAttributeProvider! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value, bool preEncoded)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution, System.UriFormat queryUriFormat) -> System.Uri! +static Refit.GeneratedRequestRunner.SendObservable(System.Net.Http.HttpClient! client, System.Func! requestFactory, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken methodCancellationToken) -> System.IObservable! +Refit.RestMethodParameterProperty.RestMethodParameterProperty(string! name, System.Collections.Generic.IReadOnlyList! propertyChain) -> void +Refit.RestMethodParameterProperty.PropertyChain.get -> System.Collections.Generic.IReadOnlyList! +Refit.RestMethodParameterProperty.PropertyChain.set -> void +static Refit.GeneratedRequestRunner.SerializeMultipartPart(Refit.RefitSettings! settings, T value, string! fieldName) -> System.Net.Http.HttpContent! +Refit.GeneratedQueryStringBuilder.AddPreEscapedKey(string! name, string? value, bool preEncoded) -> void diff --git a/src/Refit/PublicAPI/net472/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net472/PublicAPI.Shipped.txt index 3bfb35082..fe39613b2 100644 --- a/src/Refit/PublicAPI/net472/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net472/PublicAPI.Shipped.txt @@ -40,8 +40,8 @@ Refit.ApiResponse.ApiResponse(System.Net.Http.HttpResponseMessage! response, Refit.ApiResponse.Content.get -> T? Refit.ApiResponse.ContentHeaders.get -> System.Net.Http.Headers.HttpContentHeaders? Refit.ApiResponse.Dispose() -> void -Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.ApiResponse.Error.get -> Refit.ApiExceptionBase? Refit.ApiResponse.HasContent.get -> bool Refit.ApiResponse.HasRequestError(out Refit.ApiRequestException? error) -> bool @@ -58,8 +58,8 @@ Refit.ApiResponse.StatusCode.get -> System.Net.HttpStatusCode? Refit.ApiResponse.Version.get -> System.Version? Refit.ApiResponseExtensions Refit.ApiResponseExtensions.extension(Refit.IApiResponse!) -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.AttachmentNameAttribute Refit.AttachmentNameAttribute.AttachmentNameAttribute(string! name) -> void Refit.AttachmentNameAttribute.Name.get -> string! @@ -93,7 +93,7 @@ Refit.CollectionFormat.RefitParameterFormatter = 0 -> Refit.CollectionFormat Refit.CollectionFormat.Ssv = 2 -> Refit.CollectionFormat Refit.CollectionFormat.Tsv = 3 -> Refit.CollectionFormat Refit.DefaultApiExceptionFactory -Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.Task! +Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.ValueTask Refit.DefaultApiExceptionFactory.DefaultApiExceptionFactory(Refit.RefitSettings! refitSettings) -> void Refit.DefaultFormUrlEncodedParameterFormatter Refit.DefaultFormUrlEncodedParameterFormatter.DefaultFormUrlEncodedParameterFormatter() -> void @@ -105,6 +105,8 @@ Refit.DefaultUrlParameterKeyFormatter Refit.DefaultUrlParameterKeyFormatter.DefaultUrlParameterKeyFormatter() -> void Refit.DeleteAttribute Refit.DeleteAttribute.DeleteAttribute(string! path) -> void +Refit.EncodedAttribute +Refit.EncodedAttribute.EncodedAttribute() -> 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! @@ -123,6 +125,15 @@ Refit.GeneratedParameterAttributeProvider.GeneratedParameterAttributeProvider(Sy Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(System.Type! attributeType, bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.IsDefined(System.Type! attributeType, bool inherit) -> bool +Refit.GeneratedQueryStringBuilder +Refit.GeneratedQueryStringBuilder.Add(string! name, string? value, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.AddCollectionValue(string? value) -> void +Refit.GeneratedQueryStringBuilder.AddFlag(string? name, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.BeginCollection(string! name, Refit.CollectionFormat collectionFormat, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.Build() -> string! +Refit.GeneratedQueryStringBuilder.EndCollection() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath) -> void Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void @@ -247,13 +258,15 @@ Refit.QueryAttribute.SerializeNull.get -> bool Refit.QueryAttribute.SerializeNull.set -> void Refit.QueryAttribute.TreatAsString.get -> bool Refit.QueryAttribute.TreatAsString.set -> void +Refit.QueryNameAttribute +Refit.QueryNameAttribute.QueryNameAttribute() -> void Refit.QueryUriFormatAttribute Refit.QueryUriFormatAttribute.QueryUriFormatAttribute(System.UriFormat uriFormat) -> void Refit.QueryUriFormatAttribute.UriFormat.get -> System.UriFormat Refit.RefitSettings Refit.RefitSettings.AllowUnmatchedRouteParameters.get -> bool Refit.RefitSettings.AllowUnmatchedRouteParameters.set -> void -Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func!>? +Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func>? Refit.RefitSettings.AuthorizationHeaderValueGetter.set -> void Refit.RefitSettings.Buffered.get -> bool Refit.RefitSettings.Buffered.set -> void @@ -263,9 +276,9 @@ Refit.RefitSettings.CollectionFormat.get -> Refit.CollectionFormat Refit.RefitSettings.CollectionFormat.set -> void Refit.RefitSettings.ContentSerializer.get -> Refit.IHttpContentSerializer! Refit.RefitSettings.ContentSerializer.set -> void -Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func!>? +Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func>? Refit.RefitSettings.DeserializationExceptionFactory.set -> void -Refit.RefitSettings.ExceptionFactory.get -> System.Func!>! +Refit.RefitSettings.ExceptionFactory.get -> System.Func>! Refit.RefitSettings.ExceptionFactory.set -> void Refit.RefitSettings.ExceptionRedactor.get -> System.Action? Refit.RefitSettings.ExceptionRedactor.set -> void @@ -380,21 +393,23 @@ 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.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! -static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! +static Refit.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> +static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> 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.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution) -> System.Uri! -static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, params ((int startIdx, int endIdx) range, string? value)[]! uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter) -> string! static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateJsonLinesBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body, Refit.FormField![]! fields) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.FormatInvariant(T value, string? format) -> string! 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.GeneratedRequestRunner.StreamAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Threading.CancellationToken methodCancellationToken, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(Refit.RefitSettings! settings) -> bool static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RequestContent.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! @@ -440,3 +455,21 @@ virtual Refit.DefaultUrlParameterFormatter.Format(object? value, System.Reflecti virtual Refit.DefaultUrlParameterKeyFormatter.Format(string! key) -> string! virtual Refit.HttpMethodAttribute.Path.get -> string! virtual Refit.HttpMethodAttribute.Path.set -> void +static Refit.GeneratedRequestRunner.BuildQueryKey(Refit.RefitSettings! settings, string! clrName, string? explicitName, string? prefixSegment) -> string! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterKeyFormatting(Refit.RefitSettings! settings) -> bool +static Refit.GeneratedRequestRunner.UsesDefaultFormUrlEncodedParameterFormatting(Refit.RefitSettings! settings) -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.get -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.set -> void +static Refit.GeneratedRequestRunner.AddFormattedCollectionProperty(ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings, System.Collections.IEnumerable? values, string! key, Refit.CollectionFormat collectionFormat, bool preEncoded, (System.Type! ElementProviderType, System.Reflection.ICustomAttributeProvider! JoinedProvider, System.Type! JoinedType) formatting) -> void +Refit.IQueryConverter +Refit.IQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.QueryConverterAttribute +Refit.QueryConverterAttribute.QueryConverterAttribute(System.Type! converterType) -> void +Refit.QueryConverterAttribute.ConverterType.get -> System.Type! +Refit.SystemTextJsonContentSerializer.SerializerOptions.get -> System.Text.Json.JsonSerializerOptions! +Refit.SystemTextJsonQueryConverter +Refit.SystemTextJsonQueryConverter.SystemTextJsonQueryConverter() -> void +Refit.SystemTextJsonQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.IReturnTypeAdapter +Refit.IReturnTypeAdapter.Adapt(System.Func!>! invoke) -> TReturn +Refit.RefitSettings.ReturnTypeAdapters.get -> System.Collections.Generic.IList! diff --git a/src/Refit/PublicAPI/net472/PublicAPI.Unshipped.txt b/src/Refit/PublicAPI/net472/PublicAPI.Unshipped.txt index 7dc5c5811..0290036a3 100644 --- a/src/Refit/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/src/Refit/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1 +1,14 @@ #nullable enable +static Refit.GeneratedRequestRunner.CanUnrollForm(object? body) -> bool +static Refit.GeneratedRequestRunner.RoundTripEscapePath(string? value, Refit.IUrlParameterFormatter! formatter, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string! +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath, bool hasQuery) -> void +static readonly Refit.GeneratedParameterAttributeProvider.Empty -> Refit.GeneratedParameterAttributeProvider! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value, bool preEncoded)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution, System.UriFormat queryUriFormat) -> System.Uri! +static Refit.GeneratedRequestRunner.SendObservable(System.Net.Http.HttpClient! client, System.Func! requestFactory, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken methodCancellationToken) -> System.IObservable! +Refit.RestMethodParameterProperty.RestMethodParameterProperty(string! name, System.Collections.Generic.IReadOnlyList! propertyChain) -> void +Refit.RestMethodParameterProperty.PropertyChain.get -> System.Collections.Generic.IReadOnlyList! +Refit.RestMethodParameterProperty.PropertyChain.set -> void +static Refit.GeneratedRequestRunner.SerializeMultipartPart(Refit.RefitSettings! settings, T value, string! fieldName) -> System.Net.Http.HttpContent! +Refit.GeneratedQueryStringBuilder.AddPreEscapedKey(string! name, string? value, bool preEncoded) -> void diff --git a/src/Refit/PublicAPI/net48/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net48/PublicAPI.Shipped.txt index 3bfb35082..fe39613b2 100644 --- a/src/Refit/PublicAPI/net48/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net48/PublicAPI.Shipped.txt @@ -40,8 +40,8 @@ Refit.ApiResponse.ApiResponse(System.Net.Http.HttpResponseMessage! response, Refit.ApiResponse.Content.get -> T? Refit.ApiResponse.ContentHeaders.get -> System.Net.Http.Headers.HttpContentHeaders? Refit.ApiResponse.Dispose() -> void -Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.ApiResponse.Error.get -> Refit.ApiExceptionBase? Refit.ApiResponse.HasContent.get -> bool Refit.ApiResponse.HasRequestError(out Refit.ApiRequestException? error) -> bool @@ -58,8 +58,8 @@ Refit.ApiResponse.StatusCode.get -> System.Net.HttpStatusCode? Refit.ApiResponse.Version.get -> System.Version? Refit.ApiResponseExtensions Refit.ApiResponseExtensions.extension(Refit.IApiResponse!) -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.AttachmentNameAttribute Refit.AttachmentNameAttribute.AttachmentNameAttribute(string! name) -> void Refit.AttachmentNameAttribute.Name.get -> string! @@ -93,7 +93,7 @@ Refit.CollectionFormat.RefitParameterFormatter = 0 -> Refit.CollectionFormat Refit.CollectionFormat.Ssv = 2 -> Refit.CollectionFormat Refit.CollectionFormat.Tsv = 3 -> Refit.CollectionFormat Refit.DefaultApiExceptionFactory -Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.Task! +Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.ValueTask Refit.DefaultApiExceptionFactory.DefaultApiExceptionFactory(Refit.RefitSettings! refitSettings) -> void Refit.DefaultFormUrlEncodedParameterFormatter Refit.DefaultFormUrlEncodedParameterFormatter.DefaultFormUrlEncodedParameterFormatter() -> void @@ -105,6 +105,8 @@ Refit.DefaultUrlParameterKeyFormatter Refit.DefaultUrlParameterKeyFormatter.DefaultUrlParameterKeyFormatter() -> void Refit.DeleteAttribute Refit.DeleteAttribute.DeleteAttribute(string! path) -> void +Refit.EncodedAttribute +Refit.EncodedAttribute.EncodedAttribute() -> 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! @@ -123,6 +125,15 @@ Refit.GeneratedParameterAttributeProvider.GeneratedParameterAttributeProvider(Sy Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(System.Type! attributeType, bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.IsDefined(System.Type! attributeType, bool inherit) -> bool +Refit.GeneratedQueryStringBuilder +Refit.GeneratedQueryStringBuilder.Add(string! name, string? value, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.AddCollectionValue(string? value) -> void +Refit.GeneratedQueryStringBuilder.AddFlag(string? name, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.BeginCollection(string! name, Refit.CollectionFormat collectionFormat, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.Build() -> string! +Refit.GeneratedQueryStringBuilder.EndCollection() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath) -> void Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void @@ -247,13 +258,15 @@ Refit.QueryAttribute.SerializeNull.get -> bool Refit.QueryAttribute.SerializeNull.set -> void Refit.QueryAttribute.TreatAsString.get -> bool Refit.QueryAttribute.TreatAsString.set -> void +Refit.QueryNameAttribute +Refit.QueryNameAttribute.QueryNameAttribute() -> void Refit.QueryUriFormatAttribute Refit.QueryUriFormatAttribute.QueryUriFormatAttribute(System.UriFormat uriFormat) -> void Refit.QueryUriFormatAttribute.UriFormat.get -> System.UriFormat Refit.RefitSettings Refit.RefitSettings.AllowUnmatchedRouteParameters.get -> bool Refit.RefitSettings.AllowUnmatchedRouteParameters.set -> void -Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func!>? +Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func>? Refit.RefitSettings.AuthorizationHeaderValueGetter.set -> void Refit.RefitSettings.Buffered.get -> bool Refit.RefitSettings.Buffered.set -> void @@ -263,9 +276,9 @@ Refit.RefitSettings.CollectionFormat.get -> Refit.CollectionFormat Refit.RefitSettings.CollectionFormat.set -> void Refit.RefitSettings.ContentSerializer.get -> Refit.IHttpContentSerializer! Refit.RefitSettings.ContentSerializer.set -> void -Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func!>? +Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func>? Refit.RefitSettings.DeserializationExceptionFactory.set -> void -Refit.RefitSettings.ExceptionFactory.get -> System.Func!>! +Refit.RefitSettings.ExceptionFactory.get -> System.Func>! Refit.RefitSettings.ExceptionFactory.set -> void Refit.RefitSettings.ExceptionRedactor.get -> System.Action? Refit.RefitSettings.ExceptionRedactor.set -> void @@ -380,21 +393,23 @@ 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.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! -static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! +static Refit.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> +static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> 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.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution) -> System.Uri! -static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, params ((int startIdx, int endIdx) range, string? value)[]! uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter) -> string! static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateJsonLinesBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body, Refit.FormField![]! fields) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.FormatInvariant(T value, string? format) -> string! 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.GeneratedRequestRunner.StreamAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Threading.CancellationToken methodCancellationToken, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(Refit.RefitSettings! settings) -> bool static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RequestContent.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! @@ -440,3 +455,21 @@ virtual Refit.DefaultUrlParameterFormatter.Format(object? value, System.Reflecti virtual Refit.DefaultUrlParameterKeyFormatter.Format(string! key) -> string! virtual Refit.HttpMethodAttribute.Path.get -> string! virtual Refit.HttpMethodAttribute.Path.set -> void +static Refit.GeneratedRequestRunner.BuildQueryKey(Refit.RefitSettings! settings, string! clrName, string? explicitName, string? prefixSegment) -> string! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterKeyFormatting(Refit.RefitSettings! settings) -> bool +static Refit.GeneratedRequestRunner.UsesDefaultFormUrlEncodedParameterFormatting(Refit.RefitSettings! settings) -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.get -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.set -> void +static Refit.GeneratedRequestRunner.AddFormattedCollectionProperty(ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings, System.Collections.IEnumerable? values, string! key, Refit.CollectionFormat collectionFormat, bool preEncoded, (System.Type! ElementProviderType, System.Reflection.ICustomAttributeProvider! JoinedProvider, System.Type! JoinedType) formatting) -> void +Refit.IQueryConverter +Refit.IQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.QueryConverterAttribute +Refit.QueryConverterAttribute.QueryConverterAttribute(System.Type! converterType) -> void +Refit.QueryConverterAttribute.ConverterType.get -> System.Type! +Refit.SystemTextJsonContentSerializer.SerializerOptions.get -> System.Text.Json.JsonSerializerOptions! +Refit.SystemTextJsonQueryConverter +Refit.SystemTextJsonQueryConverter.SystemTextJsonQueryConverter() -> void +Refit.SystemTextJsonQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.IReturnTypeAdapter +Refit.IReturnTypeAdapter.Adapt(System.Func!>! invoke) -> TReturn +Refit.RefitSettings.ReturnTypeAdapters.get -> System.Collections.Generic.IList! diff --git a/src/Refit/PublicAPI/net48/PublicAPI.Unshipped.txt b/src/Refit/PublicAPI/net48/PublicAPI.Unshipped.txt index 7dc5c5811..0290036a3 100644 --- a/src/Refit/PublicAPI/net48/PublicAPI.Unshipped.txt +++ b/src/Refit/PublicAPI/net48/PublicAPI.Unshipped.txt @@ -1 +1,14 @@ #nullable enable +static Refit.GeneratedRequestRunner.CanUnrollForm(object? body) -> bool +static Refit.GeneratedRequestRunner.RoundTripEscapePath(string? value, Refit.IUrlParameterFormatter! formatter, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string! +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath, bool hasQuery) -> void +static readonly Refit.GeneratedParameterAttributeProvider.Empty -> Refit.GeneratedParameterAttributeProvider! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value, bool preEncoded)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution, System.UriFormat queryUriFormat) -> System.Uri! +static Refit.GeneratedRequestRunner.SendObservable(System.Net.Http.HttpClient! client, System.Func! requestFactory, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken methodCancellationToken) -> System.IObservable! +Refit.RestMethodParameterProperty.RestMethodParameterProperty(string! name, System.Collections.Generic.IReadOnlyList! propertyChain) -> void +Refit.RestMethodParameterProperty.PropertyChain.get -> System.Collections.Generic.IReadOnlyList! +Refit.RestMethodParameterProperty.PropertyChain.set -> void +static Refit.GeneratedRequestRunner.SerializeMultipartPart(Refit.RefitSettings! settings, T value, string! fieldName) -> System.Net.Http.HttpContent! +Refit.GeneratedQueryStringBuilder.AddPreEscapedKey(string! name, string? value, bool preEncoded) -> void diff --git a/src/Refit/PublicAPI/net481/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net481/PublicAPI.Shipped.txt index 3bfb35082..fe39613b2 100644 --- a/src/Refit/PublicAPI/net481/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net481/PublicAPI.Shipped.txt @@ -40,8 +40,8 @@ Refit.ApiResponse.ApiResponse(System.Net.Http.HttpResponseMessage! response, Refit.ApiResponse.Content.get -> T? Refit.ApiResponse.ContentHeaders.get -> System.Net.Http.Headers.HttpContentHeaders? Refit.ApiResponse.Dispose() -> void -Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.ApiResponse.Error.get -> Refit.ApiExceptionBase? Refit.ApiResponse.HasContent.get -> bool Refit.ApiResponse.HasRequestError(out Refit.ApiRequestException? error) -> bool @@ -58,8 +58,8 @@ Refit.ApiResponse.StatusCode.get -> System.Net.HttpStatusCode? Refit.ApiResponse.Version.get -> System.Version? Refit.ApiResponseExtensions Refit.ApiResponseExtensions.extension(Refit.IApiResponse!) -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.AttachmentNameAttribute Refit.AttachmentNameAttribute.AttachmentNameAttribute(string! name) -> void Refit.AttachmentNameAttribute.Name.get -> string! @@ -93,7 +93,7 @@ Refit.CollectionFormat.RefitParameterFormatter = 0 -> Refit.CollectionFormat Refit.CollectionFormat.Ssv = 2 -> Refit.CollectionFormat Refit.CollectionFormat.Tsv = 3 -> Refit.CollectionFormat Refit.DefaultApiExceptionFactory -Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.Task! +Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.ValueTask Refit.DefaultApiExceptionFactory.DefaultApiExceptionFactory(Refit.RefitSettings! refitSettings) -> void Refit.DefaultFormUrlEncodedParameterFormatter Refit.DefaultFormUrlEncodedParameterFormatter.DefaultFormUrlEncodedParameterFormatter() -> void @@ -105,6 +105,8 @@ Refit.DefaultUrlParameterKeyFormatter Refit.DefaultUrlParameterKeyFormatter.DefaultUrlParameterKeyFormatter() -> void Refit.DeleteAttribute Refit.DeleteAttribute.DeleteAttribute(string! path) -> void +Refit.EncodedAttribute +Refit.EncodedAttribute.EncodedAttribute() -> 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! @@ -123,6 +125,15 @@ Refit.GeneratedParameterAttributeProvider.GeneratedParameterAttributeProvider(Sy Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(System.Type! attributeType, bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.IsDefined(System.Type! attributeType, bool inherit) -> bool +Refit.GeneratedQueryStringBuilder +Refit.GeneratedQueryStringBuilder.Add(string! name, string? value, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.AddCollectionValue(string? value) -> void +Refit.GeneratedQueryStringBuilder.AddFlag(string? name, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.BeginCollection(string! name, Refit.CollectionFormat collectionFormat, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.Build() -> string! +Refit.GeneratedQueryStringBuilder.EndCollection() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath) -> void Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void @@ -247,13 +258,15 @@ Refit.QueryAttribute.SerializeNull.get -> bool Refit.QueryAttribute.SerializeNull.set -> void Refit.QueryAttribute.TreatAsString.get -> bool Refit.QueryAttribute.TreatAsString.set -> void +Refit.QueryNameAttribute +Refit.QueryNameAttribute.QueryNameAttribute() -> void Refit.QueryUriFormatAttribute Refit.QueryUriFormatAttribute.QueryUriFormatAttribute(System.UriFormat uriFormat) -> void Refit.QueryUriFormatAttribute.UriFormat.get -> System.UriFormat Refit.RefitSettings Refit.RefitSettings.AllowUnmatchedRouteParameters.get -> bool Refit.RefitSettings.AllowUnmatchedRouteParameters.set -> void -Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func!>? +Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func>? Refit.RefitSettings.AuthorizationHeaderValueGetter.set -> void Refit.RefitSettings.Buffered.get -> bool Refit.RefitSettings.Buffered.set -> void @@ -263,9 +276,9 @@ Refit.RefitSettings.CollectionFormat.get -> Refit.CollectionFormat Refit.RefitSettings.CollectionFormat.set -> void Refit.RefitSettings.ContentSerializer.get -> Refit.IHttpContentSerializer! Refit.RefitSettings.ContentSerializer.set -> void -Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func!>? +Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func>? Refit.RefitSettings.DeserializationExceptionFactory.set -> void -Refit.RefitSettings.ExceptionFactory.get -> System.Func!>! +Refit.RefitSettings.ExceptionFactory.get -> System.Func>! Refit.RefitSettings.ExceptionFactory.set -> void Refit.RefitSettings.ExceptionRedactor.get -> System.Action? Refit.RefitSettings.ExceptionRedactor.set -> void @@ -380,21 +393,23 @@ 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.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! -static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! +static Refit.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> +static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> 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.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution) -> System.Uri! -static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, params ((int startIdx, int endIdx) range, string? value)[]! uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter) -> string! static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateJsonLinesBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body, Refit.FormField![]! fields) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.FormatInvariant(T value, string? format) -> string! 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.GeneratedRequestRunner.StreamAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Threading.CancellationToken methodCancellationToken, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(Refit.RefitSettings! settings) -> bool static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RequestContent.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! @@ -440,3 +455,21 @@ virtual Refit.DefaultUrlParameterFormatter.Format(object? value, System.Reflecti virtual Refit.DefaultUrlParameterKeyFormatter.Format(string! key) -> string! virtual Refit.HttpMethodAttribute.Path.get -> string! virtual Refit.HttpMethodAttribute.Path.set -> void +static Refit.GeneratedRequestRunner.BuildQueryKey(Refit.RefitSettings! settings, string! clrName, string? explicitName, string? prefixSegment) -> string! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterKeyFormatting(Refit.RefitSettings! settings) -> bool +static Refit.GeneratedRequestRunner.UsesDefaultFormUrlEncodedParameterFormatting(Refit.RefitSettings! settings) -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.get -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.set -> void +static Refit.GeneratedRequestRunner.AddFormattedCollectionProperty(ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings, System.Collections.IEnumerable? values, string! key, Refit.CollectionFormat collectionFormat, bool preEncoded, (System.Type! ElementProviderType, System.Reflection.ICustomAttributeProvider! JoinedProvider, System.Type! JoinedType) formatting) -> void +Refit.IQueryConverter +Refit.IQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.QueryConverterAttribute +Refit.QueryConverterAttribute.QueryConverterAttribute(System.Type! converterType) -> void +Refit.QueryConverterAttribute.ConverterType.get -> System.Type! +Refit.SystemTextJsonContentSerializer.SerializerOptions.get -> System.Text.Json.JsonSerializerOptions! +Refit.SystemTextJsonQueryConverter +Refit.SystemTextJsonQueryConverter.SystemTextJsonQueryConverter() -> void +Refit.SystemTextJsonQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.IReturnTypeAdapter +Refit.IReturnTypeAdapter.Adapt(System.Func!>! invoke) -> TReturn +Refit.RefitSettings.ReturnTypeAdapters.get -> System.Collections.Generic.IList! diff --git a/src/Refit/PublicAPI/net481/PublicAPI.Unshipped.txt b/src/Refit/PublicAPI/net481/PublicAPI.Unshipped.txt index 7dc5c5811..0290036a3 100644 --- a/src/Refit/PublicAPI/net481/PublicAPI.Unshipped.txt +++ b/src/Refit/PublicAPI/net481/PublicAPI.Unshipped.txt @@ -1 +1,14 @@ #nullable enable +static Refit.GeneratedRequestRunner.CanUnrollForm(object? body) -> bool +static Refit.GeneratedRequestRunner.RoundTripEscapePath(string? value, Refit.IUrlParameterFormatter! formatter, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string! +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath, bool hasQuery) -> void +static readonly Refit.GeneratedParameterAttributeProvider.Empty -> Refit.GeneratedParameterAttributeProvider! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value, bool preEncoded)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution, System.UriFormat queryUriFormat) -> System.Uri! +static Refit.GeneratedRequestRunner.SendObservable(System.Net.Http.HttpClient! client, System.Func! requestFactory, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken methodCancellationToken) -> System.IObservable! +Refit.RestMethodParameterProperty.RestMethodParameterProperty(string! name, System.Collections.Generic.IReadOnlyList! propertyChain) -> void +Refit.RestMethodParameterProperty.PropertyChain.get -> System.Collections.Generic.IReadOnlyList! +Refit.RestMethodParameterProperty.PropertyChain.set -> void +static Refit.GeneratedRequestRunner.SerializeMultipartPart(Refit.RefitSettings! settings, T value, string! fieldName) -> System.Net.Http.HttpContent! +Refit.GeneratedQueryStringBuilder.AddPreEscapedKey(string! name, string? value, bool preEncoded) -> void diff --git a/src/Refit/PublicAPI/net8.0/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net8.0/PublicAPI.Shipped.txt index d8acfc713..9dfd71936 100644 --- a/src/Refit/PublicAPI/net8.0/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net8.0/PublicAPI.Shipped.txt @@ -40,8 +40,8 @@ Refit.ApiResponse.ApiResponse(System.Net.Http.HttpResponseMessage! response, Refit.ApiResponse.Content.get -> T? Refit.ApiResponse.ContentHeaders.get -> System.Net.Http.Headers.HttpContentHeaders? Refit.ApiResponse.Dispose() -> void -Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.ApiResponse.Error.get -> Refit.ApiExceptionBase? Refit.ApiResponse.HasContent.get -> bool Refit.ApiResponse.HasRequestError(out Refit.ApiRequestException? error) -> bool @@ -58,8 +58,8 @@ Refit.ApiResponse.StatusCode.get -> System.Net.HttpStatusCode? Refit.ApiResponse.Version.get -> System.Version? Refit.ApiResponseExtensions Refit.ApiResponseExtensions.extension(Refit.IApiResponse!) -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.AttachmentNameAttribute Refit.AttachmentNameAttribute.AttachmentNameAttribute(string! name) -> void Refit.AttachmentNameAttribute.Name.get -> string! @@ -93,7 +93,7 @@ Refit.CollectionFormat.RefitParameterFormatter = 0 -> Refit.CollectionFormat Refit.CollectionFormat.Ssv = 2 -> Refit.CollectionFormat Refit.CollectionFormat.Tsv = 3 -> Refit.CollectionFormat Refit.DefaultApiExceptionFactory -Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.Task! +Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.ValueTask Refit.DefaultApiExceptionFactory.DefaultApiExceptionFactory(Refit.RefitSettings! refitSettings) -> void Refit.DefaultFormUrlEncodedParameterFormatter Refit.DefaultFormUrlEncodedParameterFormatter.DefaultFormUrlEncodedParameterFormatter() -> void @@ -105,6 +105,8 @@ Refit.DefaultUrlParameterKeyFormatter Refit.DefaultUrlParameterKeyFormatter.DefaultUrlParameterKeyFormatter() -> void Refit.DeleteAttribute Refit.DeleteAttribute.DeleteAttribute(string! path) -> void +Refit.EncodedAttribute +Refit.EncodedAttribute.EncodedAttribute() -> 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! @@ -123,6 +125,15 @@ Refit.GeneratedParameterAttributeProvider.GeneratedParameterAttributeProvider(Sy Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(System.Type! attributeType, bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.IsDefined(System.Type! attributeType, bool inherit) -> bool +Refit.GeneratedQueryStringBuilder +Refit.GeneratedQueryStringBuilder.Add(string! name, string? value, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.AddCollectionValue(string? value) -> void +Refit.GeneratedQueryStringBuilder.AddFlag(string? name, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.BeginCollection(string! name, Refit.CollectionFormat collectionFormat, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.Build() -> string! +Refit.GeneratedQueryStringBuilder.EndCollection() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath) -> void Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void @@ -247,13 +258,15 @@ Refit.QueryAttribute.SerializeNull.get -> bool Refit.QueryAttribute.SerializeNull.set -> void Refit.QueryAttribute.TreatAsString.get -> bool Refit.QueryAttribute.TreatAsString.set -> void +Refit.QueryNameAttribute +Refit.QueryNameAttribute.QueryNameAttribute() -> void Refit.QueryUriFormatAttribute Refit.QueryUriFormatAttribute.QueryUriFormatAttribute(System.UriFormat uriFormat) -> void Refit.QueryUriFormatAttribute.UriFormat.get -> System.UriFormat Refit.RefitSettings Refit.RefitSettings.AllowUnmatchedRouteParameters.get -> bool Refit.RefitSettings.AllowUnmatchedRouteParameters.set -> void -Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func!>? +Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func>? Refit.RefitSettings.AuthorizationHeaderValueGetter.set -> void Refit.RefitSettings.Buffered.get -> bool Refit.RefitSettings.Buffered.set -> void @@ -263,9 +276,9 @@ Refit.RefitSettings.CollectionFormat.get -> Refit.CollectionFormat Refit.RefitSettings.CollectionFormat.set -> void Refit.RefitSettings.ContentSerializer.get -> Refit.IHttpContentSerializer! Refit.RefitSettings.ContentSerializer.set -> void -Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func!>? +Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func>? Refit.RefitSettings.DeserializationExceptionFactory.set -> void -Refit.RefitSettings.ExceptionFactory.get -> System.Func!>! +Refit.RefitSettings.ExceptionFactory.get -> System.Func>! Refit.RefitSettings.ExceptionFactory.set -> void Refit.RefitSettings.ExceptionRedactor.get -> System.Action? Refit.RefitSettings.ExceptionRedactor.set -> void @@ -384,21 +397,23 @@ 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.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! -static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! +static Refit.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> +static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> 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.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution) -> System.Uri! -static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, params ((int startIdx, int endIdx) range, string? value)[]! uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter) -> string! static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateJsonLinesBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body, Refit.FormField![]! fields) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.FormatInvariant(T value, string? format) -> string! 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.GeneratedRequestRunner.StreamAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Threading.CancellationToken methodCancellationToken, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(Refit.RefitSettings! settings) -> bool static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RequestContent.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! @@ -444,3 +459,21 @@ virtual Refit.DefaultUrlParameterFormatter.Format(object? value, System.Reflecti virtual Refit.DefaultUrlParameterKeyFormatter.Format(string! key) -> string! virtual Refit.HttpMethodAttribute.Path.get -> string! virtual Refit.HttpMethodAttribute.Path.set -> void +static Refit.GeneratedRequestRunner.BuildQueryKey(Refit.RefitSettings! settings, string! clrName, string? explicitName, string? prefixSegment) -> string! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterKeyFormatting(Refit.RefitSettings! settings) -> bool +static Refit.GeneratedRequestRunner.UsesDefaultFormUrlEncodedParameterFormatting(Refit.RefitSettings! settings) -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.get -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.set -> void +static Refit.GeneratedRequestRunner.AddFormattedCollectionProperty(ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings, System.Collections.IEnumerable? values, string! key, Refit.CollectionFormat collectionFormat, bool preEncoded, (System.Type! ElementProviderType, System.Reflection.ICustomAttributeProvider! JoinedProvider, System.Type! JoinedType) formatting) -> void +Refit.IQueryConverter +Refit.IQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.QueryConverterAttribute +Refit.QueryConverterAttribute.QueryConverterAttribute(System.Type! converterType) -> void +Refit.QueryConverterAttribute.ConverterType.get -> System.Type! +Refit.SystemTextJsonContentSerializer.SerializerOptions.get -> System.Text.Json.JsonSerializerOptions! +Refit.SystemTextJsonQueryConverter +Refit.SystemTextJsonQueryConverter.SystemTextJsonQueryConverter() -> void +Refit.SystemTextJsonQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.IReturnTypeAdapter +Refit.IReturnTypeAdapter.Adapt(System.Func!>! invoke) -> TReturn +Refit.RefitSettings.ReturnTypeAdapters.get -> System.Collections.Generic.IList! diff --git a/src/Refit/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/src/Refit/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 7dc5c5811..24887daee 100644 --- a/src/Refit/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/src/Refit/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1 +1,19 @@ #nullable enable +Refit.GeneratedQueryStringBuilder.AddCollectionValueFormatted(T value) -> void +Refit.GeneratedQueryStringBuilder.AddFormatted(string! name, T value, string? format, bool preEncoded) -> void +static Refit.GeneratedRequestRunner.CanUnrollForm(object? body) -> bool +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, (int startIdx, int endIdx) range, T value) -> string! +static Refit.GeneratedRequestRunner.RoundTripEscapePath(string? value, Refit.IUrlParameterFormatter! formatter, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string! +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath, bool hasQuery) -> void +static readonly Refit.GeneratedParameterAttributeProvider.Empty -> Refit.GeneratedParameterAttributeProvider! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value, bool preEncoded)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution, System.UriFormat queryUriFormat) -> System.Uri! +static Refit.GeneratedRequestRunner.SendObservable(System.Net.Http.HttpClient! client, System.Func! requestFactory, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken methodCancellationToken) -> System.IObservable! +Refit.RestMethodParameterProperty.RestMethodParameterProperty(string! name, System.Collections.Generic.IReadOnlyList! propertyChain) -> void +Refit.RestMethodParameterProperty.PropertyChain.get -> System.Collections.Generic.IReadOnlyList! +Refit.RestMethodParameterProperty.PropertyChain.set -> void +static Refit.GeneratedRequestRunner.SerializeMultipartPart(Refit.RefitSettings! settings, T value, string! fieldName) -> System.Net.Http.HttpContent! +Refit.GeneratedQueryStringBuilder.AddPreEscapedKey(string! name, string? value, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.AddFormattedPreEscapedKey(string! name, T value, string? format, bool preEncoded) -> void +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, (int startIdx, int endIdx) range, T value, string? format) -> string! diff --git a/src/Refit/PublicAPI/net9.0/PublicAPI.Shipped.txt b/src/Refit/PublicAPI/net9.0/PublicAPI.Shipped.txt index d8acfc713..9dfd71936 100644 --- a/src/Refit/PublicAPI/net9.0/PublicAPI.Shipped.txt +++ b/src/Refit/PublicAPI/net9.0/PublicAPI.Shipped.txt @@ -40,8 +40,8 @@ Refit.ApiResponse.ApiResponse(System.Net.Http.HttpResponseMessage! response, Refit.ApiResponse.Content.get -> T? Refit.ApiResponse.ContentHeaders.get -> System.Net.Http.Headers.HttpContentHeaders? Refit.ApiResponse.Dispose() -> void -Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponse.EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponse.EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.ApiResponse.Error.get -> Refit.ApiExceptionBase? Refit.ApiResponse.HasContent.get -> bool Refit.ApiResponse.HasRequestError(out Refit.ApiRequestException? error) -> bool @@ -58,8 +58,8 @@ Refit.ApiResponse.StatusCode.get -> System.Net.HttpStatusCode? Refit.ApiResponse.Version.get -> System.Version? Refit.ApiResponseExtensions Refit.ApiResponseExtensions.extension(Refit.IApiResponse!) -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.Task!>! -Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.Task!>! +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessStatusCodeAsync() -> System.Threading.Tasks.ValueTask!> +Refit.ApiResponseExtensions.extension(Refit.IApiResponse!).EnsureSuccessfulAsync() -> System.Threading.Tasks.ValueTask!> Refit.AttachmentNameAttribute Refit.AttachmentNameAttribute.AttachmentNameAttribute(string! name) -> void Refit.AttachmentNameAttribute.Name.get -> string! @@ -93,7 +93,7 @@ Refit.CollectionFormat.RefitParameterFormatter = 0 -> Refit.CollectionFormat Refit.CollectionFormat.Ssv = 2 -> Refit.CollectionFormat Refit.CollectionFormat.Tsv = 3 -> Refit.CollectionFormat Refit.DefaultApiExceptionFactory -Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.Task! +Refit.DefaultApiExceptionFactory.CreateAsync(System.Net.Http.HttpResponseMessage! responseMessage) -> System.Threading.Tasks.ValueTask Refit.DefaultApiExceptionFactory.DefaultApiExceptionFactory(Refit.RefitSettings! refitSettings) -> void Refit.DefaultFormUrlEncodedParameterFormatter Refit.DefaultFormUrlEncodedParameterFormatter.DefaultFormUrlEncodedParameterFormatter() -> void @@ -105,6 +105,8 @@ Refit.DefaultUrlParameterKeyFormatter Refit.DefaultUrlParameterKeyFormatter.DefaultUrlParameterKeyFormatter() -> void Refit.DeleteAttribute Refit.DeleteAttribute.DeleteAttribute(string! path) -> void +Refit.EncodedAttribute +Refit.EncodedAttribute.EncodedAttribute() -> 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! @@ -123,6 +125,15 @@ Refit.GeneratedParameterAttributeProvider.GeneratedParameterAttributeProvider(Sy Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(System.Type! attributeType, bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.GetCustomAttributes(bool inherit) -> object![]! Refit.GeneratedParameterAttributeProvider.IsDefined(System.Type! attributeType, bool inherit) -> bool +Refit.GeneratedQueryStringBuilder +Refit.GeneratedQueryStringBuilder.Add(string! name, string? value, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.AddCollectionValue(string? value) -> void +Refit.GeneratedQueryStringBuilder.AddFlag(string? name, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.BeginCollection(string! name, Refit.CollectionFormat collectionFormat, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.Build() -> string! +Refit.GeneratedQueryStringBuilder.EndCollection() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder() -> void +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath) -> void Refit.GeneratedRequestRunner Refit.GetAttribute Refit.GetAttribute.GetAttribute(string! path) -> void @@ -247,13 +258,15 @@ Refit.QueryAttribute.SerializeNull.get -> bool Refit.QueryAttribute.SerializeNull.set -> void Refit.QueryAttribute.TreatAsString.get -> bool Refit.QueryAttribute.TreatAsString.set -> void +Refit.QueryNameAttribute +Refit.QueryNameAttribute.QueryNameAttribute() -> void Refit.QueryUriFormatAttribute Refit.QueryUriFormatAttribute.QueryUriFormatAttribute(System.UriFormat uriFormat) -> void Refit.QueryUriFormatAttribute.UriFormat.get -> System.UriFormat Refit.RefitSettings Refit.RefitSettings.AllowUnmatchedRouteParameters.get -> bool Refit.RefitSettings.AllowUnmatchedRouteParameters.set -> void -Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func!>? +Refit.RefitSettings.AuthorizationHeaderValueGetter.get -> System.Func>? Refit.RefitSettings.AuthorizationHeaderValueGetter.set -> void Refit.RefitSettings.Buffered.get -> bool Refit.RefitSettings.Buffered.set -> void @@ -263,9 +276,9 @@ Refit.RefitSettings.CollectionFormat.get -> Refit.CollectionFormat Refit.RefitSettings.CollectionFormat.set -> void Refit.RefitSettings.ContentSerializer.get -> Refit.IHttpContentSerializer! Refit.RefitSettings.ContentSerializer.set -> void -Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func!>? +Refit.RefitSettings.DeserializationExceptionFactory.get -> System.Func>? Refit.RefitSettings.DeserializationExceptionFactory.set -> void -Refit.RefitSettings.ExceptionFactory.get -> System.Func!>! +Refit.RefitSettings.ExceptionFactory.get -> System.Func>! Refit.RefitSettings.ExceptionFactory.set -> void Refit.RefitSettings.ExceptionRedactor.get -> System.Action? Refit.RefitSettings.ExceptionRedactor.set -> void @@ -384,21 +397,23 @@ 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.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! -static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.Task!>! +static Refit.ApiResponseExtensions.EnsureSuccessStatusCodeAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> +static Refit.ApiResponseExtensions.EnsureSuccessfulAsync(this Refit.IApiResponse! response) -> System.Threading.Tasks.ValueTask!> 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.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution) -> System.Uri! -static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, params ((int startIdx, int endIdx) range, string? value)[]! uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter) -> string! static Refit.GeneratedRequestRunner.CreateBodyContent(Refit.RefitSettings! settings, TBody body, Refit.BodySerializationMethod serializationMethod, bool streamBody) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateJsonLinesBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body) -> System.Net.Http.HttpContent! static Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent(Refit.RefitSettings! settings, TBody body, Refit.FormField![]! fields) -> System.Net.Http.HttpContent! +static Refit.GeneratedRequestRunner.FormatInvariant(T value, string? format) -> string! 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.GeneratedRequestRunner.StreamAsync(System.Net.Http.HttpClient! client, System.Net.Http.HttpRequestMessage! request, Refit.RefitSettings! settings, System.Threading.CancellationToken methodCancellationToken, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(Refit.RefitSettings! settings) -> bool static Refit.HttpRequestMessageOptions.InterfaceType.get -> string! static Refit.HttpRequestMessageOptions.RequestContent.get -> string! static Refit.HttpRequestMessageOptions.RestMethodInfo.get -> string! @@ -444,3 +459,21 @@ virtual Refit.DefaultUrlParameterFormatter.Format(object? value, System.Reflecti virtual Refit.DefaultUrlParameterKeyFormatter.Format(string! key) -> string! virtual Refit.HttpMethodAttribute.Path.get -> string! virtual Refit.HttpMethodAttribute.Path.set -> void +static Refit.GeneratedRequestRunner.BuildQueryKey(Refit.RefitSettings! settings, string! clrName, string? explicitName, string? prefixSegment) -> string! +static Refit.GeneratedRequestRunner.UsesDefaultUrlParameterKeyFormatting(Refit.RefitSettings! settings) -> bool +static Refit.GeneratedRequestRunner.UsesDefaultFormUrlEncodedParameterFormatting(Refit.RefitSettings! settings) -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.get -> bool +Refit.RefitSettings.HonorContentSerializerPropertyNamesInQuery.set -> void +static Refit.GeneratedRequestRunner.AddFormattedCollectionProperty(ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings, System.Collections.IEnumerable? values, string! key, Refit.CollectionFormat collectionFormat, bool preEncoded, (System.Type! ElementProviderType, System.Reflection.ICustomAttributeProvider! JoinedProvider, System.Type! JoinedType) formatting) -> void +Refit.IQueryConverter +Refit.IQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.QueryConverterAttribute +Refit.QueryConverterAttribute.QueryConverterAttribute(System.Type! converterType) -> void +Refit.QueryConverterAttribute.ConverterType.get -> System.Type! +Refit.SystemTextJsonContentSerializer.SerializerOptions.get -> System.Text.Json.JsonSerializerOptions! +Refit.SystemTextJsonQueryConverter +Refit.SystemTextJsonQueryConverter.SystemTextJsonQueryConverter() -> void +Refit.SystemTextJsonQueryConverter.Flatten(T value, string! keyPrefix, ref Refit.GeneratedQueryStringBuilder builder, Refit.RefitSettings! settings) -> void +Refit.IReturnTypeAdapter +Refit.IReturnTypeAdapter.Adapt(System.Func!>! invoke) -> TReturn +Refit.RefitSettings.ReturnTypeAdapters.get -> System.Collections.Generic.IList! diff --git a/src/Refit/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/src/Refit/PublicAPI/net9.0/PublicAPI.Unshipped.txt index 7dc5c5811..0024fb6e1 100644 --- a/src/Refit/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/src/Refit/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1 +1,19 @@ #nullable enable +Refit.GeneratedQueryStringBuilder.AddCollectionValueFormatted(T value) -> void +Refit.GeneratedQueryStringBuilder.AddFormatted(string! name, T value, string? format, bool preEncoded) -> void +static Refit.GeneratedRequestRunner.CanUnrollForm(object? body) -> bool +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, (int startIdx, int endIdx) range, T value) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, (int startIdx, int endIdx) range, T value, string? format) -> string! +static Refit.GeneratedRequestRunner.RoundTripEscapePath(string? value, Refit.IUrlParameterFormatter! formatter, System.Reflection.ICustomAttributeProvider! attributeProvider, System.Type! type) -> string! +Refit.GeneratedQueryStringBuilder.GeneratedQueryStringBuilder(string! relativePath, bool hasQuery) -> void +static readonly Refit.GeneratedParameterAttributeProvider.Empty -> Refit.GeneratedParameterAttributeProvider! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRequestPath(string! relativePathTemplate, bool allowUnmatchedParameter, System.ReadOnlySpan<((int startIdx, int endIdx) range, string? value, bool preEncoded)> uriParams) -> string! +static Refit.GeneratedRequestRunner.BuildRelativeUri(System.Net.Http.HttpClient! client, string! relativePath, Refit.UrlResolutionMode urlResolution, System.UriFormat queryUriFormat) -> System.Uri! +static Refit.GeneratedRequestRunner.SendObservable(System.Net.Http.HttpClient! client, System.Func! requestFactory, Refit.RefitSettings! settings, bool isApiResponse, bool shouldDisposeResponse, bool bufferBody, System.Threading.CancellationToken methodCancellationToken) -> System.IObservable! +Refit.RestMethodParameterProperty.RestMethodParameterProperty(string! name, System.Collections.Generic.IReadOnlyList! propertyChain) -> void +Refit.RestMethodParameterProperty.PropertyChain.get -> System.Collections.Generic.IReadOnlyList! +Refit.RestMethodParameterProperty.PropertyChain.set -> void +static Refit.GeneratedRequestRunner.SerializeMultipartPart(Refit.RefitSettings! settings, T value, string! fieldName) -> System.Net.Http.HttpContent! +Refit.GeneratedQueryStringBuilder.AddPreEscapedKey(string! name, string? value, bool preEncoded) -> void +Refit.GeneratedQueryStringBuilder.AddFormattedPreEscapedKey(string! name, T value, string? format, bool preEncoded) -> void diff --git a/src/Refit/QueryConverterAttribute.cs b/src/Refit/QueryConverterAttribute.cs new file mode 100644 index 000000000..4fcd66582 --- /dev/null +++ b/src/Refit/QueryConverterAttribute.cs @@ -0,0 +1,27 @@ +// 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; + +/// +/// Flattens a query parameter with a hand-written instead of the source generator's +/// declared-type walk, for shapes that are only known at runtime (an value, a polymorphic base +/// type, a Dictionary<string, object>, and similar). +/// +/// +/// This is a source-generation-only attribute: it lets an otherwise-unflattenable parameter generate inline. A method +/// that carries it but cannot generate inline for another reason reports RF007. The named converter type must +/// implement for the parameter's declared type and expose a public parameterless +/// constructor. +/// +[AttributeUsage(AttributeTargets.Parameter)] +public sealed class QueryConverterAttribute : Attribute +{ + /// Initializes a new instance of the class. + /// The implementation to flatten the parameter with. + public QueryConverterAttribute(Type converterType) => ConverterType = converterType; + + /// Gets the implementation type. + public Type ConverterType { get; } +} diff --git a/src/Refit/QueryNameAttribute.cs b/src/Refit/QueryNameAttribute.cs new file mode 100644 index 000000000..1282a12b8 --- /dev/null +++ b/src/Refit/QueryNameAttribute.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. + +namespace Refit; + +/// +/// Renders the parameter value as a valueless query flag — a bare ?name segment with no =value — +/// the equivalent of Retrofit's @QueryName. A collection produces one flag per element +/// (?a&b&c); a argument (or element) is omitted. The value is rendered +/// through like any other query value, and is URL-encoded +/// unless the parameter also carries . +/// +[AttributeUsage(AttributeTargets.Parameter)] +public sealed class QueryNameAttribute : Attribute; diff --git a/src/Refit/Refit.csproj b/src/Refit/Refit.csproj index 620b40a49..616b094e4 100644 --- a/src/Refit/Refit.csproj +++ b/src/Refit/Refit.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Refit/RefitSettings.cs b/src/Refit/RefitSettings.cs index 7ee0888f4..a8dea222c 100644 --- a/src/Refit/RefitSettings.cs +++ b/src/Refit/RefitSettings.cs @@ -2,11 +2,16 @@ // 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.Json; namespace Refit; /// Defines various parameters on how Refit should work. +[SuppressMessage( + "Reliability", + "SST2403:'this' escapes from a constructor before the object is fully built", + Justification = "The default exception-factory delegate captures the settings instance but is only invoked after construction completes.")] public class RefitSettings { /// Initializes a new instance of the class. @@ -80,7 +85,7 @@ public RefitSettings( public Func< HttpRequestMessage, CancellationToken, - Task + ValueTask >? AuthorizationHeaderValueGetter { get; set; } @@ -88,23 +93,46 @@ public Func< public Func? HttpMessageHandlerFactory { get; set; } /// Gets or sets a function to provide based on . If function returns null - no exception is thrown. - public Func> ExceptionFactory { get; set; } + public Func> ExceptionFactory { get; set; } /// /// Gets or sets a function to provide when deserialization exception is encountered. /// If function returns null - no exception is thrown. /// - public Func>? DeserializationExceptionFactory { get; set; } + public Func>? DeserializationExceptionFactory { get; set; } /// Gets or sets how requests' content should be serialized. (defaults to ). public IHttpContentSerializer ContentSerializer { get; set; } + /// Gets the return-type adapters the opt-in reflection request builder uses to surface custom return + /// shapes (for example IObservable<T> or Result<T>) from interface methods. + /// + /// Each entry is a type implementing : either a closed type, + /// or an open generic definition whose single type parameter is the wrapped result type. The source generator + /// does not consult this collection — it discovers adapters at compile time — so this only affects + /// and other reflection-based builds. + /// + public IList ReturnTypeAdapters { get; } = []; + /// /// Gets or sets the instance to use for formatting URL parameter keys /// (defaults to ). Allows customization of key naming conventions. /// public IUrlParameterKeyFormatter UrlParameterKeyFormatter { get; set; } + /// + /// Gets or sets a value indicating whether a query object's flattened property keys honor the content serializer's + /// property name (for example [JsonPropertyName] with the default System.Text.Json serializer, or + /// [JsonProperty] with Refit.Newtonsoft.Json), matching how form-encoded field names are resolved + /// (defaults to ). + /// + /// + /// When , flattened query keys use only [AliasAs] and the + /// over the CLR property name, as in Refit 13 and earlier. [AliasAs] + /// always takes precedence regardless of this setting. + /// + public bool HonorContentSerializerPropertyNamesInQuery { get; set; } = true; + /// Gets or sets the instance to use (defaults to ). public IUrlParameterFormatter UrlParameterFormatter { get; set; } diff --git a/src/Refit/ReflectionPropertyHelpers.cs b/src/Refit/ReflectionPropertyHelpers.cs index 2cd9d9da4..880f5e007 100644 --- a/src/Refit/ReflectionPropertyHelpers.cs +++ b/src/Refit/ReflectionPropertyHelpers.cs @@ -38,7 +38,8 @@ internal static PropertyInfo[] GetReadablePublicInstanceProperties( { if (IsReadablePublicProperty(properties[i])) { - readableProperties[index++] = properties[i]; + readableProperties[index] = properties[i]; + index++; } } @@ -49,5 +50,5 @@ internal static PropertyInfo[] GetReadablePublicInstanceProperties( /// The property to inspect. /// when the property is readable; otherwise . private static bool IsReadablePublicProperty(PropertyInfo property) => - property.CanRead && property.GetMethod?.IsPublic == true; + property.CanRead && property.GetMethod!.IsPublic; // A readable property (CanRead) always has a non-null GetMethod. } diff --git a/src/Refit/ReflectionRequestBuilderResolver.cs b/src/Refit/ReflectionRequestBuilderResolver.cs new file mode 100644 index 000000000..48875f089 --- /dev/null +++ b/src/Refit/ReflectionRequestBuilderResolver.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. + +using System.Diagnostics.CodeAnalysis; + +namespace Refit; + +/// +/// Lazily resolves the reflection request-builder factory from the optional Refit.Reflection package. Core Refit +/// never references that assembly statically, so applications that only use generated clients neither ship it nor +/// carry the reflection pipeline through trimming and Native AOT. +/// +internal static class ReflectionRequestBuilderResolver +{ + /// The assembly-qualified name of the reflection request-builder factory. + private const string FactoryTypeName = "Refit.RequestBuilderFactory, Refit.Reflection"; + + /// The lazily resolved factory instance. + private static IRequestBuilderFactory? _factory; + + /// Gets the reflection request-builder factory, loading the Refit.Reflection assembly on first use. + /// The factory instance. + /// The Refit.Reflection package is not installed. + [RequiresUnreferencedCode("The reflection request builder requires runtime type lookup and request metadata.")] + internal static IRequestBuilderFactory GetFactory() => _factory ??= CreateFactory(); + + /// Loads and instantiates the reflection request-builder factory. + /// The factory instance. + [RequiresUnreferencedCode("The reflection request builder requires runtime type lookup and request metadata.")] + [ExcludeFromCodeCoverage] // The not-installed throw is unreachable in-process: Refit.Reflection is always present when this resolver runs. + private static IRequestBuilderFactory CreateFactory() => + Type.GetType(FactoryTypeName, throwOnError: false) is { } factoryType + && Activator.CreateInstance(factoryType) is IRequestBuilderFactory factory + ? factory + : throw new NotSupportedException( + "This interface needs the reflection request builder, which is not installed. Add a reference to " + + "the Refit.Reflection NuGet package to opt in to it, or change the interface so every method " + + "generates inline (the RF006 diagnostic reports the methods that cannot) and use a generated " + + "client via RestService.ForGenerated or AddRefitGeneratedClient."); +} diff --git a/src/Refit/RequestBuilder.cs b/src/Refit/RequestBuilder.cs index ba27c6eb5..84e0e00de 100644 --- a/src/Refit/RequestBuilder.cs +++ b/src/Refit/RequestBuilder.cs @@ -15,9 +15,6 @@ namespace Refit; /// of this class are thread-safe and can be used concurrently across multiple threads. public static class RequestBuilder { - /// The platform request builder factory used to create builders. - private static readonly RequestBuilderFactory _platformRequestBuilderFactory = new(); - /// Creates an HTTP request builder for the specified interface type. /// Use this method to obtain a request builder for a given API interface, typically for /// advanced scenarios such as custom client generation or integration with dependency injection. The returned @@ -28,8 +25,8 @@ public static class RequestBuilder /// An instance of IRequestBuilder T that can be used to construct HTTP requests for the specified interface /// type. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresUnreferencedCode("Building requests from reflected interface methods requires interface and request object metadata to be available at runtime.")] public static IRequestBuilder ForType< @@ -38,15 +35,15 @@ public static IRequestBuilder ForType< DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] T>(RefitSettings? settings) => - _platformRequestBuilderFactory.Create(settings); + ReflectionRequestBuilderResolver.GetFactory().Create(settings); /// Creates a request builder for the specified type, enabling construction of requests against its members. /// The type for which to create the request builder. This type's methods will be available for request /// construction. /// An object that can be used to build requests for the specified type. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresUnreferencedCode("Building requests from reflected interface methods requires interface and request object metadata to be available at runtime.")] public static IRequestBuilder ForType< @@ -55,7 +52,7 @@ public static IRequestBuilder ForType< DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] T>() => - _platformRequestBuilderFactory.Create(null); + ReflectionRequestBuilderResolver.GetFactory().Create(null); /// Creates an implementation of the specified Refit interface for making HTTP requests. /// The returned IRequestBuilder uses reflection to analyze the provided interface type. @@ -74,8 +71,7 @@ public static IRequestBuilder ForType( DynamicallyAccessedMemberTypes.NonPublicMethods)] Type refitInterfaceType, RefitSettings? settings) => - new CachedRequestBuilderImplementation( - new RequestBuilderImplementation(refitInterfaceType, settings)); + ReflectionRequestBuilderResolver.GetFactory().Create(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/RequestExecutionHelpers.cs b/src/Refit/RequestExecutionHelpers.cs index 674c3fe99..8b9d8948c 100644 --- a/src/Refit/RequestExecutionHelpers.cs +++ b/src/Refit/RequestExecutionHelpers.cs @@ -77,12 +77,12 @@ await AddAuthorizationHeaderFromGetterAsync(request, settings, cancellationToken /// 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", + "Maintainability", + "SST1442:A function has too many direct branch points", 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", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "TBody is intentionally passed explicitly by generated and reflection callers for ApiResponse body deserialization.")] public static async Task SendAndProcessResponseAsync( HttpClient client, @@ -118,30 +118,13 @@ await AddAuthorizationHeaderFromGetterAsync(request, settings, cancellationToken ? 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) + // A non-ApiResponse error hands ownership of the response to the thrown exception, so it must outlive this scope. + if (!options.IsApiResponse && exception is not null) { disposeResponse = false; - throw exception; } - return await DeserializeOrThrowAsync( - request, - response, - content, - settings, - cancellationToken) + return await DispatchResponseAsync(request, response, content, settings, options, exception, cancellationToken) .ConfigureAwait(false); } finally @@ -163,8 +146,8 @@ await AddAuthorizationHeaderFromGetterAsync(request, settings, cancellationToken /// A token to cancel streaming. /// An asynchronous sequence of deserialized elements. [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by generated and reflection callers.")] public static IAsyncEnumerable StreamResponseAsync( HttpClient client, @@ -250,6 +233,7 @@ internal static async Task CaptureRequestContentAsync( /// Returns the response content, substituting empty content when the response has none. /// The response whose content is read. /// The response content, or empty content when none is present. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] // Content is nullable in the BCL contract, but HttpClient always sets it, so the null-defensive fallback is unreachable. internal static HttpContent EnsureResponseContent(HttpResponseMessage response) => response.Content ?? new StringContent(string.Empty); @@ -291,9 +275,10 @@ await AddAuthorizationHeaderFromGetterAsync(request, settings, cancellationToken /// A token to cancel streaming. /// An asynchronous sequence of deserialized elements. [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by generated and reflection callers.")] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] // async-iterator dispose-mode epilogue: the compiler-generated <>w__disposeMode false-edge cannot be exercised or removed. private static async IAsyncEnumerable StreamResponseIteratorAsync( HttpClient client, HttpRequestMessage request, @@ -369,8 +354,8 @@ private static StreamingContentFormat DetectStreamingFormat(HttpContent content) /// A token to cancel the request. /// The send result. [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "TBody is intentionally passed explicitly by callers for ApiResponse failure wrapping.")] private static async Task> SendOrCaptureExceptionAsync( HttpClient client, @@ -405,9 +390,68 @@ private static async Task> SendOrCaptureExceptionAsync( return SendResult.FromFailure(failure); } - System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(transportException).Throw(); - throw transportException; + throw Rethrow(transportException); + } + } + + /// Rethrows an exception preserving its original stack trace; never returns normally. + /// The exception to rethrow. + /// Never returns; the return type only lets callers write throw Rethrow(...) as a terminator. + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] // The trailing return is unreachable: ExceptionDispatchInfo.Throw() always throws first. + private static Exception Rethrow(Exception exception) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exception).Throw(); + return exception; + } + + /// Turns a received response into the caller's result: an API-response wrapper, a thrown error, or a deserialized value. + /// The result type returned to the caller. + /// The deserialized body type for API response wrappers. + /// The request message. + /// The response message. + /// The response content. + /// The Refit settings to use. + /// The send and response-processing options. + /// The exception produced by the exception factory, if any. + /// A token to cancel the request. + /// The deserialized or wrapped response. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "TBody is intentionally passed explicitly by generated and reflection callers for ApiResponse body deserialization.")] + private static async Task DispatchResponseAsync( + HttpRequestMessage request, + HttpResponseMessage response, + HttpContent content, + RefitSettings settings, + RequestExecutionOptions options, + Exception? exception, + CancellationToken cancellationToken) + { + if (options.IsApiResponse) + { + return await BuildApiResponseAsync( + request, + response, + content, + settings, + exception, + cancellationToken) + .ConfigureAwait(false); + } + + if (exception is not null) + { + throw exception; } + + return await DeserializeOrThrowAsync( + request, + response, + content, + settings, + cancellationToken) + .ConfigureAwait(false); } /// Builds an API response, deserializing content unless an earlier error exists. @@ -421,10 +465,10 @@ private static async Task> SendOrCaptureExceptionAsync( /// A token to cancel the read. /// The constructed API response. [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "TBody is intentionally passed explicitly by callers for ApiResponse body deserialization.")] - private static async Task BuildApiResponseAsync( + private static async ValueTask BuildApiResponseAsync( HttpRequestMessage request, HttpResponseMessage response, HttpContent content, @@ -466,10 +510,10 @@ exception is null /// A token to cancel the read. /// The deserialized result. [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Callers intentionally close the result type; type inference is not part of this helper contract.")] - private static async Task DeserializeOrThrowAsync( + private static async ValueTask DeserializeOrThrowAsync( HttpRequestMessage request, HttpResponseMessage response, HttpContent content, @@ -516,7 +560,7 @@ exception is null /// The Refit settings to use. /// The original exception. /// The wrapped exception, or null when a configured factory returns null. - private static async Task CreateDeserializationExceptionAsync( + private static async ValueTask CreateDeserializationExceptionAsync( HttpRequestMessage request, HttpResponseMessage response, RefitSettings settings, @@ -540,10 +584,10 @@ settings.DeserializationExceptionFactory is not null /// 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", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Callers intentionally close the result type; type inference is not part of this helper contract.")] - private static async Task DeserializeContentAsync( + private static async ValueTask DeserializeContentAsync( HttpResponseMessage response, HttpContent content, RefitSettings settings, @@ -571,6 +615,7 @@ settings.DeserializationExceptionFactory is not null var stream = await content .ReadAsStreamAsync(cancellationToken) .ConfigureAwait(false); + string text; #if NET8_0_OR_GREATER await using (stream.ConfigureAwait(false)) #else @@ -579,13 +624,14 @@ settings.DeserializationExceptionFactory is not null { using var reader = new StreamReader(stream); #if NET8_0_OR_GREATER - var str = (object)await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + text = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); #else cancellationToken.ThrowIfCancellationRequested(); - var str = (object)await reader.ReadToEndAsync().ConfigureAwait(false); + text = await reader.ReadToEndAsync().ConfigureAwait(false); #endif - return (T)str; } + + return (T)(object)text; } return await DeserializeSerializedContentAsync( @@ -604,10 +650,10 @@ settings.DeserializationExceptionFactory is not null /// 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", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Callers intentionally close the result type; type inference is not part of this helper contract.")] - private static async Task DeserializeSerializedContentAsync( + private static async ValueTask DeserializeSerializedContentAsync( HttpResponseMessage response, HttpContent content, RefitSettings settings, @@ -630,10 +676,6 @@ settings.DeserializationExceptionFactory is not null /// 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 diff --git a/src/Refit/RestMethodParameterProperty.cs b/src/Refit/RestMethodParameterProperty.cs index 27b405cf4..3dcc64383 100644 --- a/src/Refit/RestMethodParameterProperty.cs +++ b/src/Refit/RestMethodParameterProperty.cs @@ -8,15 +8,40 @@ namespace Refit; /// Describes a property used as part of a REST method parameter. /// -/// Initializes a new instance of the class. +/// A dotted route placeholder such as {order.customer.id} binds through a chain of properties. The +/// is the ordered navigation from the parameter to the bound value, and +/// is its final element (the value formatted into the URL). A single-level binding has a +/// one-element chain, so existing consumers that read behave unchanged. /// -/// The name. -/// The property information. -public class RestMethodParameterProperty(string name, PropertyInfo propertyInfo) +public class RestMethodParameterProperty { + /// Initializes a new instance of the class for a single property. + /// The name. + /// The property information. + public RestMethodParameterProperty(string name, PropertyInfo propertyInfo) + { + Name = name; + PropertyInfo = propertyInfo; + PropertyChain = [propertyInfo]; + } + + /// Initializes a new instance of the class for a nested property chain. + /// The name. + /// The ordered navigation from the parameter to the bound value. + public RestMethodParameterProperty(string name, IReadOnlyList propertyChain) + { + Name = name; + PropertyChain = propertyChain; + PropertyInfo = propertyChain[propertyChain.Count - 1]; + } + /// Gets or sets the property name. - public string Name { get; set; } = name; + public string Name { get; set; } + + /// Gets or sets the property information for the bound value (the final link of ). + public PropertyInfo PropertyInfo { get; set; } - /// Gets or sets the property information. - public PropertyInfo PropertyInfo { get; set; } = propertyInfo; + /// Gets or sets the ordered property navigation from the parameter to the bound value. + /// A single-level binding is a one-element chain; a dotted {a.b.c} binding walks each link in order. + public IReadOnlyList PropertyChain { get; set; } } diff --git a/src/Refit/RestService.cs b/src/Refit/RestService.cs index 79e78d43b..cbc5c0a36 100644 --- a/src/Refit/RestService.cs +++ b/src/Refit/RestService.cs @@ -66,8 +66,8 @@ public static void RegisterGeneratedSettingsFactory(FuncAn instance that implements . /// Thrown when no generated implementation is registered for . [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] public static T ForGenerated(HttpClient client) => ForGenerated(client, new()); @@ -78,8 +78,8 @@ public static void RegisterGeneratedSettingsFactory(FuncAn instance that implements . /// Thrown when no generated implementation is registered for . [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] public static T ForGenerated(HttpClient client, RefitSettings settings) { @@ -115,8 +115,8 @@ public static T ForGenerated(HttpClient client, RefitSettings settings) /// An instance that implements . /// Thrown when no generated implementation is registered for . [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] public static T ForGenerated(string hostUrl) => ForGenerated(hostUrl, new()); @@ -127,8 +127,8 @@ public static T ForGenerated(HttpClient client, RefitSettings settings) /// An instance that implements . /// Thrown when no generated implementation is registered for . [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] public static T ForGenerated(string hostUrl, RefitSettings settings) { @@ -202,8 +202,8 @@ public static T For< /// to use to configure the HttpClient. /// An instance that implements . [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresUnreferencedCode("Creating a generated client through the reflection path requires runtime type lookup and request metadata.")] public static T For< @@ -213,6 +213,18 @@ public static T For< DynamicallyAccessedMemberTypes.NonPublicMethods)] T>(HttpClient client, RefitSettings? settings) { + // A generated settings factory means every method builds its request inline, so the reflection + // request builder (and the Refit.Reflection assembly) is never needed for this interface. + if (GeneratedSettingsFactory.Factory is { } settingsFactory) + { + return settingsFactory(client, settings ?? new()); + } + + if (_generatedSettingsFactories.TryGetValue(typeof(T), out var untypedSettingsFactory)) + { + return (T)untypedSettingsFactory(client, settings ?? new()); + } + var requestBuilder = RequestBuilder.ForType(settings); return For(client, requestBuilder); @@ -223,8 +235,8 @@ public static T For< /// The the implementation will use to send requests. /// An instance that implements . [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresUnreferencedCode("Creating a generated client through the reflection path requires runtime type lookup and request metadata.")] public static T For< @@ -240,8 +252,8 @@ public static T For< /// to use to configure the HttpClient. /// An instance that implements . [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresUnreferencedCode("Creating a generated client through the reflection path requires runtime type lookup and request metadata.")] public static T For< @@ -261,8 +273,8 @@ public static T For< /// Base address the implementation will use. /// An instance that implements . [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [RequiresUnreferencedCode("Creating a generated client through the reflection path requires runtime type lookup and request metadata.")] public static T For< @@ -312,6 +324,13 @@ public static object For( HttpClient client, RefitSettings? settings) { + // A generated settings factory means every method builds its request inline, so the reflection + // request builder (and the Refit.Reflection assembly) is never needed for this interface. + if (_generatedSettingsFactories.TryGetValue(refitInterfaceType, out var settingsFactory)) + { + return settingsFactory(client, settings ?? new()); + } + var requestBuilder = RequestBuilder.ForType(refitInterfaceType, settings); return For(refitInterfaceType, client, requestBuilder); diff --git a/src/Refit/SeparatedCaseFormatter.cs b/src/Refit/SeparatedCaseFormatter.cs index fffc49873..0aa096be3 100644 --- a/src/Refit/SeparatedCaseFormatter.cs +++ b/src/Refit/SeparatedCaseFormatter.cs @@ -9,6 +9,9 @@ namespace Refit; /// Converts PascalCase/camelCase identifiers into a lower-case, separator-delimited form (e.g. snake_case or kebab-case). internal static class SeparatedCaseFormatter { + /// Extra capacity reserved for the separators inserted before upper-case characters. + private const int SeparatorCapacityHeadroom = 8; + /// Formats the given identifier using the supplied word separator. /// The identifier to format. /// The character inserted between words. @@ -20,7 +23,7 @@ public static string Format(string key, char separator) return key; } - var builder = new StringBuilder(key.Length + 8); + var builder = new StringBuilder(key.Length + SeparatorCapacityHeadroom); for (var i = 0; i < key.Length; i++) { var current = key[i]; diff --git a/src/Refit/StringHelpers.cs b/src/Refit/StringHelpers.cs index d24886f79..11d3d76e1 100644 --- a/src/Refit/StringHelpers.cs +++ b/src/Refit/StringHelpers.cs @@ -13,6 +13,18 @@ namespace Refit; internal static class StringHelpers { #if NET8_0_OR_GREATER + /// The highest code point that percent-encodes as a single ASCII byte. + private const char MaxAsciiChar = (char)0x7F; + + /// The bit shift selecting the high nibble of a byte for hex encoding. + private const int HexShift = 4; + + /// The mask selecting the low nibble of a byte for hex encoding. + private const int HexMask = 0xF; + + /// The uppercase hex digits used by percent-encoding, matching . + private const string UpperHexDigits = "0123456789ABCDEF"; + /// Characters that must be removed from HTTP header names and values. private static readonly SearchValues _lineBreakCharacters = SearchValues.Create("\r\n"); #else @@ -42,6 +54,18 @@ internal static bool StartsWith(string value, char prefix) => value.Length > 0 && value[0] == prefix; #endif + /// Determines whether the value contains the specified character. + /// The value to inspect. + /// The character to find. + /// if the value contains . + /// The overload taking a only exists on the modern targets. + internal static bool Contains(string value, char character) => +#if NET8_0_OR_GREATER + value.Contains(character); +#else + value.IndexOf(character) >= 0; +#endif + /// Escapes a string for a URI data component. /// The value to escape. /// The escaped value. @@ -63,8 +87,8 @@ internal static string EscapeDataString(string value, int start, int length) => /// The header name or value. /// The sanitized value. [SuppressMessage( - "Major Code Smell", - "S2930:\"IDisposables\" should be disposed", + "Correctness", + "SST2410:A created disposable is never disposed", Justification = "ValueStringBuilder.ToString() disposes the builder and returns its pooled buffer; Dispose is idempotent.")] internal static string RemoveCrOrLf(string value) { @@ -88,6 +112,44 @@ internal static string RemoveCrOrLf(string value) return builder.ToString(); } +#if NET8_0_OR_GREATER + /// Percent-encodes a formatted span per RFC 3986 straight into the target, with no intermediate string. + /// The buffer receiving the escaped text. + /// The invariant-formatted value to escape. + /// Span-formattable values render as ASCII under the invariant culture, which this escapes in place. A + /// non-ASCII character (never produced by the routed value types) defers to the framework escaper so the UTF-8 + /// percent-encoding matches exactly. + internal static void AppendUriDataEscaped(ref ValueStringBuilder target, scoped ReadOnlySpan span) + { + foreach (var c in span) + { + if (c > MaxAsciiChar) + { +#if NET9_0_OR_GREATER + target.Append(Uri.EscapeDataString(span)); +#else + target.Append(Uri.EscapeDataString(span.ToString())); +#endif + return; + } + } + + foreach (var c in span) + { + if (IsUriUnreserved(c)) + { + target.Append(c); + } + else + { + target.Append('%'); + target.Append(UpperHexDigits[c >> HexShift]); + target.Append(UpperHexDigits[c & HexMask]); + } + } + } +#endif + /// Finds the first CR or LF character in the value. /// The value to inspect. /// The first CR or LF index, or -1 when none is present. @@ -97,4 +159,12 @@ private static int IndexOfCrOrLf(string value) => #else value.IndexOfAny(_lineBreakCharacters); #endif + +#if NET8_0_OR_GREATER + /// Determines whether a character is RFC 3986 unreserved and needs no percent-encoding. + /// The character to test. + /// for an unreserved character. + private static bool IsUriUnreserved(char c) => + c is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9') or '-' or '.' or '_' or '~'; +#endif } diff --git a/src/Refit/SystemTextJsonContentSerializer.cs b/src/Refit/SystemTextJsonContentSerializer.cs index ec6ead9ea..501c7c0d7 100644 --- a/src/Refit/SystemTextJsonContentSerializer.cs +++ b/src/Refit/SystemTextJsonContentSerializer.cs @@ -12,7 +12,6 @@ using System.Text.Json; using System.Text.Json.Serialization; #if NET8_0_OR_GREATER -using System.Globalization; using System.Text.Json.Serialization.Metadata; #endif @@ -36,8 +35,16 @@ public SystemTextJsonContentSerializer() { } + /// Gets the JSON serialization options this serializer uses, exposed so a query converter can walk a + /// registered type's without reflection. + public JsonSerializerOptions SerializerOptions => jsonSerializerOptions; + /// Creates new and fills it with default parameters. /// The default . + [SuppressMessage( + "Performance", + "PSH1416:Cache constructed objects in a static readonly field", + Justification = "Returns a new mutable options instance on each call; callers mutate it, so a cached instance would be shared.")] public static JsonSerializerOptions GetDefaultJsonSerializerOptions() { // Default to case insensitive property name matching as that's likely the behavior most users expect @@ -71,6 +78,10 @@ public static JsonSerializerOptions GetDefaultJsonSerializerOptions() "Design", "CA1024:Use properties where appropriate", Justification = "Returns a new mutable options instance on each call; a property would wrongly imply a cached value.")] + [SuppressMessage( + "Performance", + "PSH1416:Cache constructed objects in a static readonly field", + Justification = "Returns a new mutable options instance on each call; callers mutate it, so a cached instance would be shared.")] public static JsonSerializerOptions GetFastPathJsonSerializerOptions() => new() { @@ -104,8 +115,8 @@ public HttpContent ToHttpContent(T item) /// [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] public HttpContent ToHttpContentSynchronous(T item) { @@ -116,8 +127,8 @@ public HttpContent ToHttpContentSynchronous(T item) /// [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [SuppressMessage("Roslynator", "RCS1261:Resource can be disposed asynchronously", Justification = "Newer .NET versions only.")] public HttpContent ToStreamingHttpContent(T item) @@ -143,8 +154,8 @@ public HttpContent ToStreamingHttpContent(T item) /// [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] public Task FromHttpContentAsync( HttpContent content, @@ -159,8 +170,8 @@ jsonSerializerOptions.TypeInfoResolver is not null /// [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] public T? DeserializeFromString(string content) { @@ -175,8 +186,8 @@ jsonSerializerOptions.TypeInfoResolver is not null /// [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] public IAsyncEnumerable DeserializeStreamAsync( Stream stream, @@ -222,18 +233,52 @@ private static bool DeclaredTypeIsPolymorphic(Type type, JsonSerializerOptions j #endif } +#if !NET9_0_OR_GREATER + /// Determines whether a buffered line is blank, ignoring a trailing carriage return and treating spaces and tabs as blank. + /// The buffer holding the line bytes. + /// The offset of the line. + /// The length of the line. + /// when the line contains only spaces and tabs. + private static bool IsBlankLine(byte[] lineBuffer, int lineStart, int lineLength) + { + var line = new ReadOnlySpan(lineBuffer, lineStart, lineLength); + if (!line.IsEmpty && line[line.Length - 1] == (byte)'\r') + { + line = line.Slice(0, line.Length - 1); + } + + foreach (var b in line) + { + if (b != (byte)' ' && b != (byte)'\t') + { + return false; + } + } + + return true; + } + + /// Grows the pooled line-scan buffer, copying the buffered prefix into a larger rented buffer and returning the old one to the pool. + /// The current buffer, returned to the pool once its prefix is copied. + /// The number of buffered bytes to preserve. + /// The factor by which the buffer capacity grows. + /// The larger rented buffer holding the preserved prefix. + private static byte[] GrowLineScanBuffer(byte[] buffer, int length, int growthFactor) + { + var larger = ArrayPool.Shared.Rent(buffer.Length * growthFactor); + Buffer.BlockCopy(buffer, 0, larger, 0, length); + ArrayPool.Shared.Return(buffer); + return larger; + } +#endif + #if NET8_0_OR_GREATER /// Gets the JSON type metadata for the given runtime type from the supplied options. /// The runtime type to resolve metadata for. /// The serializer options to consult. /// The JSON type metadata. private static JsonTypeInfo GetJsonTypeInfo(Type type, JsonSerializerOptions jsonSerializerOptions) => - jsonSerializerOptions.GetTypeInfo(type) - ?? throw new InvalidOperationException( - string.Format( - CultureInfo.InvariantCulture, - "The serializer options did not provide metadata for {0}.", - type)); + jsonSerializerOptions.GetTypeInfo(type); // Returns non-null metadata for the requested type or throws; there is no null case to guard. #if NET11_0_OR_GREATER /// Gets the JSON type metadata for the given type from the supplied options. @@ -241,8 +286,8 @@ private static JsonTypeInfo GetJsonTypeInfo(Type type, JsonSerializerOptions jso /// The serializer options to consult. /// The JSON type metadata. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private static JsonTypeInfo GetJsonTypeInfo(JsonSerializerOptions jsonSerializerOptions) => jsonSerializerOptions.GetTypeInfo(); @@ -254,20 +299,15 @@ private static JsonTypeInfo GetJsonTypeInfo(JsonSerializerOptions jsonSeri /// The type to resolve metadata for. /// The JSON type metadata. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private JsonTypeInfo GetJsonTypeInfo() => #if NET11_0_OR_GREATER - GetJsonTypeInfo(jsonSerializerOptions) + GetJsonTypeInfo(jsonSerializerOptions); // Returns the strongly typed metadata for the exact type T, never null. #else - (GetJsonTypeInfo(typeof(T), jsonSerializerOptions) as JsonTypeInfo) + (JsonTypeInfo)GetJsonTypeInfo(typeof(T), jsonSerializerOptions); // GetTypeInfo(typeof(T)) is the exact JsonTypeInfo, so the cast always succeeds. #endif - ?? throw new InvalidOperationException( - string.Format( - CultureInfo.InvariantCulture, - "The serializer options did not provide metadata for {0}.", - typeof(T))); #endif /// Serializes the item to HTTP content using the supplied runtime type. @@ -309,8 +349,8 @@ private JsonContent ToHttpContentReflection(T item) => /// A token used to cancel the operation. /// The deserialized value. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = ReflectionFallbackJustification)] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = ReflectionFallbackJustification)] @@ -322,8 +362,8 @@ private JsonContent ToHttpContentReflection(T item) => /// The item to serialize. /// The UTF-8 encoded JSON. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private byte[] SerializeToUtf8Bytes(T item) { @@ -343,8 +383,8 @@ private byte[] SerializeToUtf8Bytes(T item) [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = ReflectionFallbackJustification)] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = ReflectionFallbackJustification)] [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private byte[] SerializeToUtf8BytesReflection(T item) => JsonSerializer.SerializeToUtf8Bytes(item, jsonSerializerOptions); @@ -354,8 +394,8 @@ private byte[] SerializeToUtf8BytesReflection(T item) => /// The item to serialize. /// The writer to serialize into. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private void SerializeToWriter(T item, Utf8JsonWriter writer) { @@ -380,8 +420,8 @@ private void SerializeToWriter(T item, Utf8JsonWriter writer) [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = ReflectionFallbackJustification)] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = ReflectionFallbackJustification)] [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private void SerializeToWriterReflection(T item, Utf8JsonWriter writer) => JsonSerializer.Serialize(writer, item, jsonSerializerOptions); @@ -392,8 +432,8 @@ private void SerializeToWriterReflection(T item, Utf8JsonWriter writer) => /// A token to cancel enumeration. /// An asynchronous sequence of deserialized elements. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private IAsyncEnumerable DeserializeJsonArrayAsync(Stream stream, CancellationToken cancellationToken) { @@ -414,8 +454,8 @@ private void SerializeToWriterReflection(T item, Utf8JsonWriter writer) => [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = ReflectionFallbackJustification)] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = ReflectionFallbackJustification)] [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private IAsyncEnumerable DeserializeJsonArrayReflectionAsync(Stream stream, CancellationToken cancellationToken) => JsonSerializer.DeserializeAsyncEnumerable(stream, jsonSerializerOptions, cancellationToken); @@ -426,8 +466,8 @@ private void SerializeToWriterReflection(T item, Utf8JsonWriter writer) => /// A token to cancel enumeration. /// An asynchronous sequence of deserialized values. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private IAsyncEnumerable DeserializeJsonLinesAsync(Stream stream, CancellationToken cancellationToken) { @@ -451,8 +491,8 @@ private void SerializeToWriterReflection(T item, Utf8JsonWriter writer) => [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = ReflectionFallbackJustification)] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = ReflectionFallbackJustification)] [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private IAsyncEnumerable DeserializeJsonLinesTopLevelReflectionAsync(Stream stream, CancellationToken cancellationToken) => JsonSerializer.DeserializeAsyncEnumerable(stream, topLevelValues: true, jsonSerializerOptions, cancellationToken); @@ -463,33 +503,19 @@ private void SerializeToWriterReflection(T item, Utf8JsonWriter writer) => /// A token to cancel enumeration. /// An asynchronous sequence of deserialized values. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] + [ExcludeFromCodeCoverage] // async-iterator dispose-mode epilogue: the compiler-generated <>w__disposeMode false-edge cannot be exercised or removed. private async IAsyncEnumerable DeserializeJsonLinesManualAsync( Stream stream, [EnumeratorCancellation] CancellationToken cancellationToken) { - static bool IsBlankLine(byte[] lineBuffer, int lineStart, int lineLength) - { - var line = new ReadOnlySpan(lineBuffer, lineStart, lineLength); - if (!line.IsEmpty && line[line.Length - 1] == (byte)'\r') - { - line = line.Slice(0, line.Length - 1); - } - - foreach (var b in line) - { - if (b != (byte)' ' && b != (byte)'\t') - { - return false; - } - } - - return true; - } + // The initial pooled buffer, and the factor it grows by when a single line does not fit in it. + const int lineScanBufferSize = 4096; + const int lineScanBufferGrowthFactor = 2; - var buffer = ArrayPool.Shared.Rent(4096); + var buffer = ArrayPool.Shared.Rent(lineScanBufferSize); var start = 0; var end = 0; try @@ -518,15 +544,12 @@ static bool IsBlankLine(byte[] lineBuffer, int lineStart, int lineLength) if (end == buffer.Length) { - var larger = ArrayPool.Shared.Rent(buffer.Length * 2); - Buffer.BlockCopy(buffer, 0, larger, 0, end); - ArrayPool.Shared.Return(buffer); - buffer = larger; + buffer = GrowLineScanBuffer(buffer, end, lineScanBufferGrowthFactor); } #if NET8_0_OR_GREATER var read = await stream - .ReadAsync(buffer.AsMemory(end, buffer.Length - end), cancellationToken) + .ReadAsync(buffer.AsMemory(end), cancellationToken) .ConfigureAwait(false); #else var read = await stream @@ -559,8 +582,8 @@ static bool IsBlankLine(byte[] lineBuffer, int lineStart, int lineLength) /// The length of the line. /// The deserialized value. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private T? DeserializeLine(byte[] buffer, int start, int length) { @@ -587,8 +610,8 @@ static bool IsBlankLine(byte[] lineBuffer, int lineStart, int lineLength) [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = ReflectionFallbackJustification)] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = ReflectionFallbackJustification)] [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private T? DeserializeLineReflection(ReadOnlySpan utf8Json) => JsonSerializer.Deserialize(utf8Json, jsonSerializerOptions); @@ -601,8 +624,8 @@ static bool IsBlankLine(byte[] lineBuffer, int lineStart, int lineLength) [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = ReflectionFallbackJustification)] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = ReflectionFallbackJustification)] [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameter for inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] private T? DeserializeFromStringReflection(string content) => JsonSerializer.Deserialize(content, jsonSerializerOptions); diff --git a/src/Refit/SystemTextJsonQueryConverter.cs b/src/Refit/SystemTextJsonQueryConverter.cs new file mode 100644 index 000000000..2153d60e1 --- /dev/null +++ b/src/Refit/SystemTextJsonQueryConverter.cs @@ -0,0 +1,135 @@ +// 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.Text.Json; + +namespace Refit; + +/// +/// An that flattens a query parameter by walking the JsonTypeInfo of the +/// configured , reusing a registered JsonSerializerContext so +/// arbitrary (including polymorphic) types flatten without hand-written code. Attach it with +/// [QueryConverter(typeof(SystemTextJsonQueryConverter<MyType>))]. +/// +/// The declared parameter type. +/// +/// Property names come from System.Text.Json (honoring [JsonPropertyName] and the naming policy); values are +/// rendered by , so enums, dates and numbers match the rest of Refit. +/// The value's runtime type is walked, so a polymorphic value contributes its actual properties. When the configured +/// serializer uses a source-generated TypeInfoResolver the walk is reflection- and AOT-free; otherwise it falls +/// back to System.Text.Json's reflection resolver. Nested objects are flattened under a dotted key; collections use the +/// configured . +/// +public sealed class SystemTextJsonQueryConverter : IQueryConverter +{ + /// The recursion cap that bounds a cyclic runtime object graph. + private const int MaxDepth = 32; + + /// + public void Flatten(T value, string keyPrefix, ref GeneratedQueryStringBuilder builder, RefitSettings settings) + { + if (value is null) + { + return; + } + + if (settings.ContentSerializer is not SystemTextJsonContentSerializer serializer) + { + throw new NotSupportedException( + $"SystemTextJsonQueryConverter requires {nameof(RefitSettings)}.{nameof(RefitSettings.ContentSerializer)} to be a {nameof(SystemTextJsonContentSerializer)}."); + } + + FlattenObject(value, keyPrefix, ref builder, settings, serializer.SerializerOptions, 0); + } + + /// Determines whether a value renders as a single query value rather than a nested object. + /// The value to inspect. + /// for strings and formattable scalars. + private static bool IsSimpleQueryValue(object value) => + value is string or bool or char or IFormattable or Uri or System.Globalization.CultureInfo; + + /// Walks a value's JSON properties and appends each non-null one. + /// The object to flatten. + /// The key prefix for this object's properties. + /// The query-string builder to append to. + /// The active Refit settings. + /// The serializer options supplying the type metadata. + /// The current recursion depth. + private static void FlattenObject( + object value, + string keyPrefix, + ref GeneratedQueryStringBuilder builder, + RefitSettings settings, + JsonSerializerOptions options, + int depth) + { + var properties = options.GetTypeInfo(value.GetType()).Properties; + for (var i = 0; i < properties.Count; i++) + { + var property = properties[i]; + if (property.Get is not { } getter) + { + continue; + } + + var propertyValue = getter(value); + if (propertyValue is not null) + { + AppendValue(property.Name, propertyValue, keyPrefix, ref builder, settings, options, depth); + } + } + } + + /// Appends one property value as a scalar, a collection, or a nested object. + /// The JSON property name. + /// The non-null property value. + /// The enclosing object's key prefix. + /// The query-string builder to append to. + /// The active Refit settings. + /// The serializer options supplying the type metadata. + /// The current recursion depth. + private static void AppendValue( + string name, + object propertyValue, + string keyPrefix, + ref GeneratedQueryStringBuilder builder, + RefitSettings settings, + JsonSerializerOptions options, + int depth) + { + var key = keyPrefix + name; + if (IsSimpleQueryValue(propertyValue)) + { + builder.Add(key, Format(propertyValue, settings), false); + return; + } + + if (propertyValue is IEnumerable enumerable) + { + builder.BeginCollection(key, settings.CollectionFormat, false); + foreach (var element in enumerable) + { + builder.AddCollectionValue(element is null ? null : Format(element, settings)); + } + + builder.EndCollection(); + return; + } + + if (depth >= MaxDepth) + { + return; + } + + FlattenObject(propertyValue, key + ".", ref builder, settings, options, depth + 1); + } + + /// Formats one value through the configured URL parameter formatter. + /// The value to format. + /// The active Refit settings. + /// The formatted value. + private static string? Format(object value, RefitSettings settings) => + settings.UrlParameterFormatter.Format(value, value.GetType(), value.GetType()); +} diff --git a/src/Refit/ValidationApiException.cs b/src/Refit/ValidationApiException.cs index ea2243523..604bfcf1a 100644 --- a/src/Refit/ValidationApiException.cs +++ b/src/Refit/ValidationApiException.cs @@ -10,12 +10,8 @@ namespace Refit; /// An ApiException that is raised according to RFC 7807, which contains problem details for validation exceptions. [SuppressMessage( - "Usage", - "CA1032:Implement standard exception constructors", - Justification = "This exception requires HTTP request/response context and cannot be constructed via the parameterless or message-only constructors.")] -[SuppressMessage( - "Major Code Smell", - "S4027:Exceptions should provide standard constructors", + "Design", + "SST1488:Exception types should declare the standard constructors", Justification = "This exception requires HTTP request/response context and cannot be constructed via the parameterless or message-only constructors.")] public class ValidationApiException : ApiException { @@ -259,7 +255,7 @@ private static string[] ReadErrorMessages(JsonElement element) /// The error message. private static string ReadErrorMessage(JsonElement element) => element.ValueKind == JsonValueKind.String - ? element.GetString() ?? string.Empty + ? element.GetString()! // A String-kind element always yields a non-null string. : element.GetRawText(); /// Reads extension data using the same inferred primitives as the System.Text.Json converter. @@ -273,7 +269,7 @@ private static object ReadExtensionValue(JsonElement element) => JsonValueKind.Number when element.TryGetInt64(out var integer) => integer, JsonValueKind.Number => element.GetDouble(), JsonValueKind.String when element.TryGetDateTime(out var dateTime) => dateTime, - JsonValueKind.String => element.GetString() ?? string.Empty, + JsonValueKind.String => element.GetString()!, // A String-kind element always yields a non-null string. _ => element.Clone() }; } diff --git a/src/Refit/ValueStringBuilder.cs b/src/Refit/ValueStringBuilder.cs index 557ef5f7a..8fde3d6fd 100644 --- a/src/Refit/ValueStringBuilder.cs +++ b/src/Refit/ValueStringBuilder.cs @@ -61,6 +61,7 @@ public int Length /// Gets the DebuggerDisplay attribute. /// ToString() clears the builder, so we need a side-effect free debugger display. [DebuggerBrowsable(DebuggerBrowsableState.Never)] + [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] // Only evaluated by the debugger, never by tests. private readonly string DebuggerDisplay => AsSpan().ToString(); /// Gets a reference to the character at the specified index. @@ -358,12 +359,13 @@ private void Grow(int additionalCapacityBeyondPos) "Grow called incorrectly, no resize is needed."); const uint ArrayMaxLength = 0x7FFFFFC7; // same as Array.MaxLength + const uint GrowthFactor = 2; // Increase to at least the required size (_pos + additionalCapacityBeyondPos), but try // to double the size if possible, bounding the doubling to not go beyond the max array length. var newCapacity = (int)Math.Max( (uint)(_pos + additionalCapacityBeyondPos), - Math.Min((uint)_chars.Length * 2, ArrayMaxLength)); + Math.Min((uint)_chars.Length * GrowthFactor, ArrayMaxLength)); // Make sure to let Rent throw an exception if the caller has a bug and the desired capacity is negative. // This could also go negative if the actual required length wraps around. diff --git a/src/Shared/AuthenticatedHttpClientHandler.cs b/src/Shared/AuthenticatedHttpClientHandler.cs index 715767948..f82c11306 100644 --- a/src/Shared/AuthenticatedHttpClientHandler.cs +++ b/src/Shared/AuthenticatedHttpClientHandler.cs @@ -9,7 +9,7 @@ namespace Refit; internal sealed class AuthenticatedHttpClientHandler : DelegatingHandler { /// The function used to retrieve the authentication token for a request. - private readonly Func> _getToken; + private readonly Func> _getToken; /// Initializes a new instance of the class. /// The function to get the authentication token. @@ -21,7 +21,7 @@ internal sealed class AuthenticatedHttpClientHandler : DelegatingHandler /// a behavior which is incompatible with the IHttpClientBuilder. /// public AuthenticatedHttpClientHandler( - Func> getToken, + Func> getToken, HttpMessageHandler? innerHandler = null) : base(innerHandler ?? new HttpClientHandler()) { @@ -41,7 +41,7 @@ public AuthenticatedHttpClientHandler( /// public AuthenticatedHttpClientHandler( HttpMessageHandler? innerHandler, - Func> getToken) + Func> getToken) { ArgumentExceptionHelper.ThrowIfNull(getToken); _getToken = getToken; diff --git a/src/Shared/UniqueName.cs b/src/Shared/UniqueName.cs index 290918dd9..3633fc941 100644 --- a/src/Shared/UniqueName.cs +++ b/src/Shared/UniqueName.cs @@ -15,14 +15,20 @@ public static class UniqueName /// Builds the unique name for the generated implementation of the given interface type. /// The Refit interface type. /// The unique generated type name. - [SuppressMessage("Sonar", "S4018", Justification = "T is bound via typeof(T); there is no value argument to infer it from, so callers pass it explicitly.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "T is bound via typeof(T); there is no value argument to infer it from, so callers pass it explicitly.")] public static string ForType() => ForType(typeof(T)); /// Builds the unique name for the given interface type and service key. /// The Refit interface type. /// The service key to incorporate. /// The unique generated type name. - [SuppressMessage("Sonar", "S4018", Justification = "T is bound via typeof(T); there is no value argument to infer it from, so callers pass it explicitly.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "T is bound via typeof(T); there is no value argument to infer it from, so callers pass it explicitly.")] public static string ForType(object? serviceKey) => ForType(typeof(T), serviceKey); /// Builds the unique name for the given interface type and service key. diff --git a/src/benchmarks/Refit.Benchmarks/FormBodySerializationBenchmark.cs b/src/benchmarks/Refit.Benchmarks/FormBodySerializationBenchmark.cs index 3ae01857b..4a0b19746 100644 --- a/src/benchmarks/Refit.Benchmarks/FormBodySerializationBenchmark.cs +++ b/src/benchmarks/Refit.Benchmarks/FormBodySerializationBenchmark.cs @@ -3,16 +3,21 @@ // See the LICENSE file in the project root for full license information. using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Diagnosers; namespace Refit.Benchmarks; -/// Benchmarks producing a URL-encoded form body via the reflection and generated descriptor paths. +/// Benchmarks producing a URL-encoded form body via the reflection, generated descriptor, and unrolled paths. [MemoryDiagnoser] +[EventPipeProfiler(EventPipeProfile.GcVerbose)] public class FormBodySerializationBenchmark { /// A representative age value for the sample payload. private const int SampleAge = 36; + /// The estimated form entry count used to size the unrolled buffer. + private const int EstimatedEntryCount = 6; + /// Settings using the built-in serializer that enables the reflection-free descriptor path. private static readonly RefitSettings _settings = new(new SystemTextJsonContentSerializer()); @@ -46,6 +51,8 @@ public void Setup() new(static b => b.Note, "Note", null, null, null, null, true), new(static b => b.Roles, "Roles", null, null, null, CollectionFormat.Multi, false) ]; + + VerifyUnrolledMatchesDescriptor(); } /// Serializes the form body through the reflection-based FormValueMultimap path. @@ -60,6 +67,59 @@ public Task ReflectionAsync() => public Task DescriptorAsync() => ProduceAsync(GeneratedRequestRunner.CreateUrlEncodedBodyContent(_settings, _body, _fields)); + /// Serializes the form body through the straight-line unrolled path (no descriptor array, delegates, or + /// value boxing) exactly as an unrolled source generator would emit it. + /// The number of bytes produced. + [Benchmark] + public Task UnrolledAsync() => ProduceAsync(BuildUnrolled(_body)); + + /// Builds the URL-encoded content straight-line, as an unrolled generator would emit for the model — + /// direct field access, for value types (no boxing), and + /// for identical wire encoding. + /// The payload to serialize. + /// The URL-encoded HTTP content. + private static FormUrlEncodedContent BuildUnrolled(FormBenchmarkModel body) + { + var entries = new List>(EstimatedEntryCount); + if (body.FirstName is not null) + { + entries.Add(new("first_name", body.FirstName)); + } + + if (body.LastName is not null) + { + entries.Add(new("last_name", body.LastName)); + } + + if (body.Email is not null) + { + entries.Add(new("Email", body.Email)); + } + + entries.Add(new("Age", GeneratedRequestRunner.FormatInvariant(body.Age, null))); + + entries.Add(body.Note is not null ? new("Note", body.Note) : new("Note", string.Empty)); + + foreach (var role in body.Roles) + { + entries.Add(new("Roles", role)); + } + + return new FormUrlEncodedContent(entries); + } + + /// Reads the buffered content synchronously for the one-time correctness gate. + /// The content to materialize. + /// The materialized string. + private static string ReadSynchronously(HttpContent content) + { + using (content) + { + using var reader = new StreamReader(content.ReadAsStream()); + return reader.ReadToEnd(); + } + } + /// Serializes the content to a buffer and returns the byte count. /// The HTTP content to materialize. /// The number of bytes produced. @@ -72,4 +132,18 @@ private static async Task ProduceAsync(HttpContent content) return stream.Length; } } + + /// Fails setup unless the unrolled fast path produces byte-identical output to the descriptor path. + private void VerifyUnrolledMatchesDescriptor() + { + var descriptor = ReadSynchronously(GeneratedRequestRunner.CreateUrlEncodedBodyContent(_settings, _body, _fields)); + var unrolled = ReadSynchronously(BuildUnrolled(_body)); + if (string.Equals(descriptor, unrolled, StringComparison.Ordinal)) + { + return; + } + + throw new InvalidOperationException( + $"Unrolled form output does not match the descriptor path.\n descriptor: {descriptor}\n unrolled: {unrolled}"); + } } diff --git a/src/benchmarks/Refit.Benchmarks/IGitHubService.cs b/src/benchmarks/Refit.Benchmarks/IGitHubService.cs index 0d6340a11..d7b37ae43 100644 --- a/src/benchmarks/Refit.Benchmarks/IGitHubService.cs +++ b/src/benchmarks/Refit.Benchmarks/IGitHubService.cs @@ -12,61 +12,61 @@ public interface IGitHubService /// Gets users and returns a non-generic task. /// A task that completes when the request has finished. [Get("/users")] - public Task GetUsersTaskAsync(); + Task GetUsersTaskAsync(); /// Posts users and returns a non-generic task. /// The users to post. /// A task that completes when the request has finished. [Post("/users")] - public Task PostUsersTaskAsync([Body] IEnumerable users); + Task PostUsersTaskAsync([Body] IEnumerable users); // Task - throws /// Gets users as a raw string response. /// The response body as a string. [Get("/users")] - public Task GetUsersTaskStringAsync(); + Task GetUsersTaskStringAsync(); /// Posts users and returns a raw string response. /// The users to post. /// The response body as a string. [Post("/users")] - public Task PostUsersTaskStringAsync([Body] IEnumerable users); + Task PostUsersTaskStringAsync([Body] IEnumerable users); // Task - throws /// Gets users as a response stream. /// The response body as a stream. [Get("/users")] - public Task GetUsersTaskStreamAsync(); + Task GetUsersTaskStreamAsync(); /// Posts users and returns a response stream. /// The users to post. /// The response body as a stream. [Post("/users")] - public Task PostUsersTaskStreamAsync([Body] IEnumerable users); + Task PostUsersTaskStreamAsync([Body] IEnumerable users); // Task - throws /// Gets users as raw HTTP content. /// The response HTTP content. [Get("/users")] - public Task GetUsersTaskHttpContentAsync(); + Task GetUsersTaskHttpContentAsync(); /// Posts users and returns raw HTTP content. /// The users to post. /// The response HTTP content. [Post("/users")] - public Task PostUsersTaskHttpContentAsync([Body] IEnumerable users); + Task PostUsersTaskHttpContentAsync([Body] IEnumerable users); // Task /// Gets users as a full HTTP response message. /// The HTTP response message. [Get("/users")] - public Task GetUsersTaskHttpResponseMessageAsync(); + Task GetUsersTaskHttpResponseMessageAsync(); /// Posts users and returns a full HTTP response message. /// The users to post. /// The HTTP response message. [Post("/users")] - public Task PostUsersTaskHttpResponseMessageAsync( + Task PostUsersTaskHttpResponseMessageAsync( [Body] IEnumerable users); // IObservable @@ -74,37 +74,37 @@ public Task PostUsersTaskHttpResponseMessageAsync( /// An observable producing the HTTP response message. [Get("/users")] [SuppressMessage("Design", "CA1024:Use properties where appropriate", Justification = "Refit interface method describing an HTTP GET endpoint; it cannot be a property.")] - public IObservable GetUsersObservableHttpResponseMessage(); + IObservable GetUsersObservableHttpResponseMessage(); /// Posts users and returns an observable HTTP response message. /// The users to post. /// An observable producing the HTTP response message. [Post("/users")] - public IObservable PostUsersObservableHttpResponseMessage( + IObservable PostUsersObservableHttpResponseMessage( [Body] IEnumerable users); // Task<> - throws /// Gets users deserialized into a list. /// The list of users. [Get("/users")] - public Task> GetUsersTaskTAsync(); + Task> GetUsersTaskTAsync(); /// Posts users and returns the deserialized list. /// The users to post. /// The list of users. [Post("/users")] - public Task> PostUsersTaskTAsync([Body] IEnumerable users); + Task> PostUsersTaskTAsync([Body] IEnumerable users); // Task> /// Gets users wrapped in an API response. /// The API response containing the list of users. [Get("/users")] - public Task>> GetUsersTaskApiResponseTAsync(); + Task>> GetUsersTaskApiResponseTAsync(); /// Posts users and returns them wrapped in an API response. /// The users to post. /// The API response containing the list of users. [Post("/users")] - public Task>> PostUsersTaskApiResponseTAsync( + Task>> PostUsersTaskApiResponseTAsync( [Body] IEnumerable users); } diff --git a/src/benchmarks/Refit.Benchmarks/IPerformanceService.cs b/src/benchmarks/Refit.Benchmarks/IPerformanceService.cs index cf05dadcb..f8040ede0 100644 --- a/src/benchmarks/Refit.Benchmarks/IPerformanceService.cs +++ b/src/benchmarks/Refit.Benchmarks/IPerformanceService.cs @@ -9,13 +9,13 @@ public interface IPerformanceService /// Performs a request against a constant route. /// The HTTP response. [Get("/users")] - public Task ConstantRouteAsync(); + Task ConstantRouteAsync(); /// Performs a request against a route with one dynamic segment. /// The user identifier. /// The HTTP response. [Get("/users/{id}")] - public Task DynamicRouteAsync(int id); + Task DynamicRouteAsync(int id); /// Performs a request against a route with several dynamic segments. /// The user identifier. @@ -23,13 +23,13 @@ public interface IPerformanceService /// The user status. /// The HTTP response. [Get("/users/{id}/{user}/{status}")] - public Task ComplexDynamicRouteAsync(int id, string user, string status); + Task ComplexDynamicRouteAsync(int id, string user, string status); /// Performs a request whose route is bound to an object property. /// The request object. /// The HTTP response. [Get("/users/{request.someProperty}")] - public Task ObjectRequestAsync(PathBoundObject request); + Task ObjectRequestAsync(PathBoundObject request); /// Performs a POST request combining dynamic segments, an object and queries. /// The user identifier. @@ -38,7 +38,7 @@ public interface IPerformanceService /// The HTTP response. [Post("/users/{id}/{request.someProperty}")] [Headers("User-Agent: Awesome Octocat App", "X-Emoji: :smile_cat:")] - public Task ComplexRequestAsync( + Task ComplexRequestAsync( int id, PathBoundObject request, [Query(CollectionFormat.Multi)] int[] queries); diff --git a/src/benchmarks/Refit.Benchmarks/IQueryRequestService.cs b/src/benchmarks/Refit.Benchmarks/IQueryRequestService.cs new file mode 100644 index 000000000..1e2389577 --- /dev/null +++ b/src/benchmarks/Refit.Benchmarks/IQueryRequestService.cs @@ -0,0 +1,66 @@ +// 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.Benchmarks; + +/// The Refit interface exercised by the query request-building benchmarks; every method is inline-eligible. +public interface IQueryRequestService +{ + /// Sends a request with one auto-appended query parameter. + /// The query text. + /// The HTTP response message. + [Get("/search")] + Task SingleQueryAsync(string q); + + /// Sends a request with several scalar query parameters. + /// The query text. + /// The page number. + /// The page size. + /// Whether archived entries are included. + /// The sort order. + /// The HTTP response message. + [Get("/search/full")] + Task MultiParameterAsync(string q, int page, int size, bool includeArchived, QuerySort sort); + + /// Sends a request with a csv-joined collection. + /// The identifiers. + /// The HTTP response message. + [Get("/csv")] + Task CsvCollectionAsync([Query(CollectionFormat.Csv)] int[] ids); + + /// Sends a request with a multi-expanded collection. + /// The identifiers. + /// The HTTP response message. + [Get("/multi")] + Task MultiCollectionAsync([Query(CollectionFormat.Multi)] int[] ids); + + /// Sends a request with a valueless query flag. + /// The flag name. + /// The HTTP response message. + [Get("/flags")] + Task FlagAsync([QueryName] string flag); + + /// Sends a request with a caller-encoded query value. + /// The pre-encoded continuation cursor. + /// The HTTP response message. + [Get("/cursor")] + Task EncodedAsync([Encoded] string cursor); + + /// Sends a request with a span-formattable timestamp query value that requires percent-encoding. + /// The timestamp, whose invariant form contains reserved characters. + /// The HTTP response message. + [Get("/events")] + Task TimestampQueryAsync(DateTimeOffset at); + + /// Sends a request with a span-formattable timestamp path value that requires percent-encoding. + /// The timestamp substituted into the path, whose invariant form contains reserved characters. + /// The HTTP response message. + [Get("/events/{at}")] + Task TimestampPathAsync(DateTimeOffset at); + + /// Sends a request with a custom HTTP verb, exercising the cached verb instance. + /// The query text. + /// The HTTP response message. + [QueryVerb("/documents")] + Task CustomVerbAsync(string q); +} diff --git a/src/benchmarks/Refit.Benchmarks/Program.cs b/src/benchmarks/Refit.Benchmarks/Program.cs index 2c35c9e1f..847c2a7b6 100644 --- a/src/benchmarks/Refit.Benchmarks/Program.cs +++ b/src/benchmarks/Refit.Benchmarks/Program.cs @@ -15,3 +15,12 @@ // To run a different suite by default, swap the type above for StartupBenchmark, // PerformanceBenchmark, or SourceGeneratorBenchmark (or pass a filter on the command line). } + +/// The generated top-level program's declaring type, sealed so the JIT can devirtualize its members. +internal sealed partial class Program +{ + /// Initializes a new instance of the class. Unused; the entry point is the generated top-level Main. + private Program() + { + } +} diff --git a/src/benchmarks/Refit.Benchmarks/QueryRequestBuildingBenchmark.cs b/src/benchmarks/Refit.Benchmarks/QueryRequestBuildingBenchmark.cs new file mode 100644 index 000000000..909d8bc3b --- /dev/null +++ b/src/benchmarks/Refit.Benchmarks/QueryRequestBuildingBenchmark.cs @@ -0,0 +1,185 @@ +// 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.Net; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; + +namespace Refit.Benchmarks; + +/// +/// Compares generated inline query request building against the reflection request builder for the same +/// method shapes, with allocation tracking. The generated rows go through the source-generated client; +/// the reflection rows invoke the cached reflection request delegate the fallback path uses. +/// +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +public class QueryRequestBuildingBenchmark +{ + /// The base host address used for requests. + private const string Host = "https://api.example.test"; + + /// The sample page number. + private const int SamplePage = 3; + + /// The sample page size. + private const int SamplePageSize = 25; + + /// The sample scalar query text. + private const string QueryText = "widgets"; + + /// The sample collection values. + private static readonly int[] _sampleIds = [1, 2, 3, 4, 5, 6, 7, 8]; + + /// A sample timestamp whose invariant form contains reserved characters (:, +). + private static readonly DateTimeOffset _sampleTimestamp = new(2026, 7, 13, 12, 30, 0, TimeSpan.Zero); + + /// The generated Refit client. + private IQueryRequestService _generated = null!; + + /// The HTTP client used by the reflection request delegates. + private HttpClient _client = null!; + + /// The cached reflection delegate for . + private Func _reflectionSingleQuery = null!; + + /// The cached reflection delegate for . + private Func _reflectionMultiParameter = null!; + + /// The cached reflection delegate for . + private Func _reflectionCsvCollection = null!; + + /// The cached reflection delegate for . + private Func _reflectionMultiCollection = null!; + + /// The cached reflection delegate for . + private Func _reflectionTimestampQuery = null!; + + /// The cached reflection delegate for . + private Func _reflectionTimestampPath = null!; + + /// Initializes the clients before the benchmarks run. + [GlobalSetup] + public void Setup() + { + var settings = new RefitSettings(new SystemTextJsonContentSerializer()); + _client = new(new StaticValueHttpResponseHandler("Ok", HttpStatusCode.OK)) + { + BaseAddress = new(Host), + }; + + _generated = RestService.ForGenerated(_client, settings); + + var reflectionBuilder = RequestBuilder.ForType(settings); + _reflectionSingleQuery = reflectionBuilder.BuildRestResultFuncForMethod(nameof(IQueryRequestService.SingleQueryAsync)); + _reflectionMultiParameter = reflectionBuilder.BuildRestResultFuncForMethod(nameof(IQueryRequestService.MultiParameterAsync)); + _reflectionCsvCollection = reflectionBuilder.BuildRestResultFuncForMethod(nameof(IQueryRequestService.CsvCollectionAsync)); + _reflectionMultiCollection = reflectionBuilder.BuildRestResultFuncForMethod(nameof(IQueryRequestService.MultiCollectionAsync)); + _reflectionTimestampQuery = reflectionBuilder.BuildRestResultFuncForMethod(nameof(IQueryRequestService.TimestampQueryAsync)); + _reflectionTimestampPath = reflectionBuilder.BuildRestResultFuncForMethod(nameof(IQueryRequestService.TimestampPathAsync)); + } + + /// Cleans up the HTTP client. + [GlobalCleanup] + public void Cleanup() => _client.Dispose(); + + /// Benchmarks one generated query parameter. + /// The HTTP response message. + [Benchmark(Baseline = true)] + [BenchmarkCategory("SingleQuery")] + public Task GeneratedSingleQueryAsync() => _generated.SingleQueryAsync("widgets and gadgets"); + + /// Benchmarks one reflection-built query parameter. + /// The HTTP response message. + [Benchmark] + [BenchmarkCategory("SingleQuery")] + public Task ReflectionSingleQueryAsync() => + (Task)_reflectionSingleQuery(_client, ["widgets and gadgets"])!; + + /// Benchmarks five generated scalar query parameters including an enum. + /// The HTTP response message. + [Benchmark(Baseline = true)] + [BenchmarkCategory("MultiParameter")] + public Task GeneratedMultiParameterAsync() => + _generated.MultiParameterAsync(QueryText, SamplePage, SamplePageSize, true, QuerySort.DateDescending); + + /// Benchmarks five reflection-built scalar query parameters including an enum. + /// The HTTP response message. + [Benchmark] + [BenchmarkCategory("MultiParameter")] + public Task ReflectionMultiParameterAsync() => + (Task)_reflectionMultiParameter( + _client, + [QueryText, SamplePage, SamplePageSize, true, QuerySort.DateDescending])!; + + /// Benchmarks a generated csv-joined collection. + /// The HTTP response message. + [Benchmark(Baseline = true)] + [BenchmarkCategory("CsvCollection")] + public Task GeneratedCsvCollectionAsync() => _generated.CsvCollectionAsync(_sampleIds); + + /// Benchmarks a reflection-built csv-joined collection. + /// The HTTP response message. + [Benchmark] + [BenchmarkCategory("CsvCollection")] + public Task ReflectionCsvCollectionAsync() => + (Task)_reflectionCsvCollection(_client, [_sampleIds])!; + + /// Benchmarks a generated multi-expanded collection. + /// The HTTP response message. + [Benchmark(Baseline = true)] + [BenchmarkCategory("MultiCollection")] + public Task GeneratedMultiCollectionAsync() => _generated.MultiCollectionAsync(_sampleIds); + + /// Benchmarks a reflection-built multi-expanded collection. + /// The HTTP response message. + [Benchmark] + [BenchmarkCategory("MultiCollection")] + public Task ReflectionMultiCollectionAsync() => + (Task)_reflectionMultiCollection(_client, [_sampleIds])!; + + /// Benchmarks a generated valueless query flag (source-generation only). + /// The HTTP response message. + [Benchmark] + [BenchmarkCategory("SourceGenOnly")] + public Task GeneratedFlagAsync() => _generated.FlagAsync("ready"); + + /// Benchmarks a generated caller-encoded value (source-generation only). + /// The HTTP response message. + [Benchmark] + [BenchmarkCategory("SourceGenOnly")] + public Task GeneratedEncodedAsync() => _generated.EncodedAsync("a%2Fb%2Fc"); + + /// Benchmarks a generated span-formattable query value that requires escaping (span-escape path). + /// The HTTP response message. + [Benchmark(Baseline = true)] + [BenchmarkCategory("TimestampQuery")] + public Task GeneratedTimestampQueryAsync() => _generated.TimestampQueryAsync(_sampleTimestamp); + + /// Benchmarks a reflection-built span-formattable query value that requires escaping. + /// The HTTP response message. + [Benchmark] + [BenchmarkCategory("TimestampQuery")] + public Task ReflectionTimestampQueryAsync() => + (Task)_reflectionTimestampQuery(_client, [_sampleTimestamp])!; + + /// Benchmarks a generated span-formattable path value that requires escaping (path span-escape). + /// The HTTP response message. + [Benchmark(Baseline = true)] + [BenchmarkCategory("TimestampPath")] + public Task GeneratedTimestampPathAsync() => _generated.TimestampPathAsync(_sampleTimestamp); + + /// Benchmarks a reflection-built span-formattable path value that requires escaping. + /// The HTTP response message. + [Benchmark] + [BenchmarkCategory("TimestampPath")] + public Task ReflectionTimestampPathAsync() => + (Task)_reflectionTimestampPath(_client, [_sampleTimestamp])!; + + /// Benchmarks a generated custom-verb request, exercising the cached verb instance (source-generation only). + /// The HTTP response message. + [Benchmark] + [BenchmarkCategory("SourceGenOnly")] + public Task GeneratedCustomVerbAsync() => _generated.CustomVerbAsync(QueryText); +} diff --git a/src/benchmarks/Refit.Benchmarks/QuerySort.cs b/src/benchmarks/Refit.Benchmarks/QuerySort.cs new file mode 100644 index 000000000..673ecf90c --- /dev/null +++ b/src/benchmarks/Refit.Benchmarks/QuerySort.cs @@ -0,0 +1,17 @@ +// 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.Runtime.Serialization; + +namespace Refit.Benchmarks; + +/// The sort order used by the query request-building benchmarks. +public enum QuerySort +{ + /// Sort by date, newest first. + [EnumMember(Value = "date-desc")] + DateDescending, + + /// Sort by name. + Name, +} diff --git a/src/benchmarks/Refit.Benchmarks/QueryVerbAttribute.cs b/src/benchmarks/Refit.Benchmarks/QueryVerbAttribute.cs new file mode 100644 index 000000000..15b056763 --- /dev/null +++ b/src/benchmarks/Refit.Benchmarks/QueryVerbAttribute.cs @@ -0,0 +1,18 @@ +// 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.Net.Http; + +namespace Refit.Benchmarks; + +/// A custom HTTP verb attribute (the draft-standard QUERY method) used to exercise the cached verb instance. +/// Initializes a new instance of the class. +/// The relative request path. +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)] +public sealed class QueryVerbAttribute(string path) : HttpMethodAttribute(path) +{ + /// + public override HttpMethod Method => new HttpMethod("QUERY"); +} diff --git a/src/benchmarks/Refit.Benchmarks/Refit.Benchmarks.csproj b/src/benchmarks/Refit.Benchmarks/Refit.Benchmarks.csproj index df6a63dd1..50d444a17 100644 --- a/src/benchmarks/Refit.Benchmarks/Refit.Benchmarks.csproj +++ b/src/benchmarks/Refit.Benchmarks/Refit.Benchmarks.csproj @@ -10,6 +10,7 @@ + diff --git a/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmark.cs b/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmark.cs index 5c3059d86..2986484c0 100644 --- a/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmark.cs +++ b/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmark.cs @@ -60,4 +60,26 @@ public void SetupCachedMany() => /// The resulting generator driver. [Benchmark] public GeneratorDriver CachedMany() => _driver.RunGeneratorsAndUpdateCompilation(_compilation, out _, out _); + + /// Prepares the query-heavy compilation for a cold generator run. + [GlobalSetup(Target = nameof(CompileQueryHeavy))] + public void SetupQueryHeavy() => + (_compilation, _driver) = GeneratorBenchmarkHarness.Create( + SourceGeneratorBenchmarksProjects.QueryHeavyInterface); + + /// Benchmarks a cold generator run over the query-heavy interface. + /// The updated generator driver. + [Benchmark] + public GeneratorDriver CompileQueryHeavy() => _driver.RunGeneratorsAndUpdateCompilation(_compilation, out _, out _); + + /// Prepares the query-heavy compilation for a cached generator run. + [GlobalSetup(Target = nameof(CachedQueryHeavy))] + public void SetupCachedQueryHeavy() => + (_compilation, _driver) = GeneratorBenchmarkHarness.CreatePrimedForCachedRun( + SourceGeneratorBenchmarksProjects.QueryHeavyInterface); + + /// Benchmarks a cached generator run over the query-heavy interface. + /// The updated generator driver. + [Benchmark] + public GeneratorDriver CachedQueryHeavy() => _driver.RunGeneratorsAndUpdateCompilation(_compilation, out _, out _); } diff --git a/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.ManyInterfacesPart1.cs b/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.ManyInterfacesPart1.cs new file mode 100644 index 000000000..d39dec2b8 --- /dev/null +++ b/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.ManyInterfacesPart1.cs @@ -0,0 +1,449 @@ +// 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.Benchmarks; + +/// Holds segment 1 of the many-interfaces benchmark source project (interfaces 0-24). +public static partial class SourceGeneratorBenchmarksProjects +{ + /// Gets segment 1 of the source concatenated by . + private const string ManyInterfacesPart1 = + """ + using System; + using System.Collections.Generic; + using System.Net.Http; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IReallyExcitingCrudApi0 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi1 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi2 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi3 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi4 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi5 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi6 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi7 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi8 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi9 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi10 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi11 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi12 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi13 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi14 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi15 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi16 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi17 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi18 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi19 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi20 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi21 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi22 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi23 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi24 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + + """; +} diff --git a/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.ManyInterfacesPart2.cs b/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.ManyInterfacesPart2.cs new file mode 100644 index 000000000..71a3ebad3 --- /dev/null +++ b/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.ManyInterfacesPart2.cs @@ -0,0 +1,439 @@ +// 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.Benchmarks; + +/// Holds segment 2 of the many-interfaces benchmark source project (interfaces 25-49). +public static partial class SourceGeneratorBenchmarksProjects +{ + /// Gets segment 2 of the source concatenated by . + private const string ManyInterfacesPart2 = + """ + public interface IReallyExcitingCrudApi25 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi26 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi27 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi28 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi29 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi30 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi31 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi32 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi33 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi34 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi35 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi36 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi37 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi38 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi39 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi40 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi41 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi42 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi43 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi44 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi45 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi46 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi47 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi48 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi49 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + + """; +} diff --git a/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.ManyInterfacesPart3.cs b/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.ManyInterfacesPart3.cs new file mode 100644 index 000000000..d1899bfb9 --- /dev/null +++ b/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.ManyInterfacesPart3.cs @@ -0,0 +1,439 @@ +// 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.Benchmarks; + +/// Holds segment 3 of the many-interfaces benchmark source project (interfaces 50-74). +public static partial class SourceGeneratorBenchmarksProjects +{ + /// Gets segment 3 of the source concatenated by . + private const string ManyInterfacesPart3 = + """ + public interface IReallyExcitingCrudApi50 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi51 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi52 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi53 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi54 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi55 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi56 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi57 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi58 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi59 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi60 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi61 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi62 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi63 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi64 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi65 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi66 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi67 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi68 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi69 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi70 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi71 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi72 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi73 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi74 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + + """; +} diff --git a/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.ManyInterfacesPart4.cs b/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.ManyInterfacesPart4.cs new file mode 100644 index 000000000..a7f07049f --- /dev/null +++ b/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.ManyInterfacesPart4.cs @@ -0,0 +1,438 @@ +// 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.Benchmarks; + +/// Holds segment 4 of the many-interfaces benchmark source project (interfaces 75-99). +public static partial class SourceGeneratorBenchmarksProjects +{ + /// Gets segment 4 of the source concatenated by . + private const string ManyInterfacesPart4 = + """ + public interface IReallyExcitingCrudApi75 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi76 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi77 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi78 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi79 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi80 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi81 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi82 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi83 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi84 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi85 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi86 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi87 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi88 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi89 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi90 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi91 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi92 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi93 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi94 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi95 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi96 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi97 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi98 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + public interface IReallyExcitingCrudApi99 where T : class + { + [Post("")] + Task Create([Body] T payload); + + [Get("")] + Task> ReadAll(); + + [Get("/{key}")] + Task ReadOne(TKey key); + + [Put("/{key}")] + Task Update(TKey key, [Body]T payload); + + [Delete("/{key}")] + Task Delete(TKey key); + } + """; +} diff --git a/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.cs b/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.cs index 3715640d6..d285b486c 100644 --- a/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.cs +++ b/src/benchmarks/Refit.Benchmarks/SourceGeneratorBenchmarksProjects.cs @@ -4,7 +4,7 @@ namespace Refit.Benchmarks; /// Provides sample Refit interface source projects used by the source generator benchmarks. -public static class SourceGeneratorBenchmarksProjects +public static partial class SourceGeneratorBenchmarksProjects { /// Gets the source text for a single small Refit interface used by benchmarks. public static string SmallInterface => @@ -38,1718 +38,53 @@ public interface IReallyExcitingCrudApi where T : class } """; - /// Gets the source text containing many Refit interfaces used by larger benchmarks. - public static string ManyInterfaces => + /// Gets the source text for a query-heavy Refit interface exercising inline query classification. + public static string QueryHeavyInterface => """ using System; using System.Collections.Generic; - using System.Net.Http; - using System.Text; + using System.Runtime.Serialization; using System.Threading; using System.Threading.Tasks; using Refit; namespace RefitGeneratorTest; - public interface IReallyExcitingCrudApi0 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi1 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi2 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi3 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi4 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi5 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi6 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi7 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi8 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi9 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi10 where T : class + public enum BenchSort { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); + [EnumMember(Value = "date-desc")] + DateDescending, + Name, } - public interface IReallyExcitingCrudApi11 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi12 where T : class + public sealed class BenchPayload { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); + public string? Name { get; set; } } - public interface IReallyExcitingCrudApi13 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi14 where T : class + public interface IQueryHeavyApi { - [Post("")] - Task Create([Body] T payload); + [Get("/search")] + Task Search(string q, int? page, int size, bool archived, BenchSort sort); - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); + [Get("/csv")] + Task Csv([Query(CollectionFormat.Csv)] int[] ids, [AliasAs("t")] string tag); - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi15 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); + [Get("/multi")] + Task Multi([Query(CollectionFormat.Multi)] IReadOnlyList ids); - [Get("/{key}")] - Task ReadOne(TKey key); + [Get("/flags")] + Task Flags([QueryName] string[] flags, [Encoded] string cursor); - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); + [Post("/create")] + Task Create(BenchPayload payload, string tag, CancellationToken token); - [Delete("/{key}")] - Task Delete(TKey key); + [Get("/fmt")] + Task Formatted([Query(Format = "0.00")] double price, [Query(TreatAsString = true)] object raw); } - public interface IReallyExcitingCrudApi16 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); + """; - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi17 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi18 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi19 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi20 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi21 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi22 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi23 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi24 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi25 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi26 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi27 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi28 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi29 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi30 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi31 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi32 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi33 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi34 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi35 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi36 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi37 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi38 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi39 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi40 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi41 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi42 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi43 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi44 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi45 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi46 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi47 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi48 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi49 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi50 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi51 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi52 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi53 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi54 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi55 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi56 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi57 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi58 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi59 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi60 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi61 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi62 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi63 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi64 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi65 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi66 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi67 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi68 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi69 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi70 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi71 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi72 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi73 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi74 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi75 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi76 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi77 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi78 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi79 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi80 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi81 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi82 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi83 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi84 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi85 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi86 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi87 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi88 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi89 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi90 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi91 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi92 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi93 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi94 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi95 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi96 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi97 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi98 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - public interface IReallyExcitingCrudApi99 where T : class - { - [Post("")] - Task Create([Body] T payload); - - [Get("")] - Task> ReadAll(); - - [Get("/{key}")] - Task ReadOne(TKey key); - - [Put("/{key}")] - Task Update(TKey key, [Body]T payload); - - [Delete("/{key}")] - Task Delete(TKey key); - } - """; + /// Gets the source text containing many Refit interfaces used by larger benchmarks. + public static string ManyInterfaces => + ManyInterfacesPart1 + ManyInterfacesPart2 + ManyInterfacesPart3 + ManyInterfacesPart4; } diff --git a/src/examples/BlazorWasmIssue2065/Program.cs b/src/examples/BlazorWasmIssue2065/Program.cs index e89996b0b..a8fc2b922 100644 --- a/src/examples/BlazorWasmIssue2065/Program.cs +++ b/src/examples/BlazorWasmIssue2065/Program.cs @@ -14,10 +14,10 @@ _ = builder.Services.AddScoped(_ => new HttpClient { BaseAddress = new(builder.HostEnvironment.BaseAddress) }); -_ = builder.Services.AddScoped(sp => +_ = builder.Services.AddScoped(static sp => RestService.For(sp.GetRequiredService())); -_ = builder.Services.AddScoped(sp => +_ = builder.Services.AddScoped(static sp => RestService.For( sp.GetRequiredService(), new RefitSettings( @@ -25,3 +25,12 @@ SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions())))); await builder.Build().RunAsync(); + +/// The generated top-level program's declaring type, sealed so the JIT can devirtualize its members. +internal sealed partial class Program +{ + /// Initializes a new instance of the class. Unused; the entry point is the generated top-level Main. + private Program() + { + } +} diff --git a/src/examples/Meow.Common/Services/DemoBackendHandler.cs b/src/examples/Meow.Common/Services/DemoBackendHandler.cs index 08cac061c..2d59234cf 100644 --- a/src/examples/Meow.Common/Services/DemoBackendHandler.cs +++ b/src/examples/Meow.Common/Services/DemoBackendHandler.cs @@ -55,8 +55,9 @@ private static HttpResponseMessage LargePayload(HttpRequestMessage request) var size = 100; foreach (var part in query) { - var kv = part.Split('=', 2); - if (kv.Length == 2 && kv[0] == "size" && int.TryParse(Uri.UnescapeDataString(kv[1]), out var parsed)) + const int keyValuePartCount = 2; + var kv = part.Split('=', keyValuePartCount); + if (kv.Length == keyValuePartCount && kv[0] == "size" && int.TryParse(Uri.UnescapeDataString(kv[1]), out var parsed)) { size = parsed; break; diff --git a/src/tests/.editorconfig b/src/tests/.editorconfig index a9c3de7be..eb87c0dbc 100644 --- a/src/tests/.editorconfig +++ b/src/tests/.editorconfig @@ -16,3 +16,17 @@ dotnet_diagnostic.CA2213.severity = none # Disposable fields should be disposed # SonarAnalyzer (S) — relaxed for test projects ################### dotnet_diagnostic.S5332.severity = none # Using http protocol is insecure (test URLs are not real endpoints) + +################### +# StyleSharp (SST) — relaxed for test projects +################### +dotnet_diagnostic.SST2016.severity = none # Prefer DateTimeOffset over DateTime on a visible signature — test models exercise DateTime handling deliberately +dotnet_diagnostic.SST2410.severity = none # A created disposable is never disposed — test-local disposables are short-lived and GC-reclaimed + +################### +# PerformanceSharp (PSH) — relaxed for test projects +################### +performancesharp.avoid_linq_on_hot_path = false +dotnet_diagnostic.PSH1100.severity = none # Tests can use LINQ for clarity +dotnet_diagnostic.PSH1101.severity = error # In tests, simplify LINQ Where plus terminal calls instead of banning LINQ +dotnet_diagnostic.PSH1102.severity = error # In tests, simplify LINQ type filters instead of banning LINQ diff --git a/src/tests/Refit.Analyzers.Tests/AnalyzerFixture.cs b/src/tests/Refit.Analyzers.Tests/AnalyzerFixture.cs index 02858b626..38959f268 100644 --- a/src/tests/Refit.Analyzers.Tests/AnalyzerFixture.cs +++ b/src/tests/Refit.Analyzers.Tests/AnalyzerFixture.cs @@ -25,22 +25,41 @@ internal static class AnalyzerFixture /// Runs the Refit interface analyzer over an interface body snippet. /// The interface body source. + /// The value forced for the RefitGeneratedRequestBuilding option, or to use the default. /// The diagnostics produced by the analyzer. - public static Task> RunForBody(string body) => - Run(BuildBodySource(body)); + public static Task> RunForBody(string body, bool? generatedRequestBuilding = null) => + Run(BuildBodySource(body), generatedRequestBuilding); /// Runs the Refit interface analyzer over a complete source string. /// The source to analyze. + /// The value forced for the RefitGeneratedRequestBuilding option, or to use the default. /// The diagnostics produced by the analyzer. - public static Task> Run(string source) + public static Task> Run(string source, bool? generatedRequestBuilding = null) { - var compilation = CreateLibrary(source, includeRefitReference: true); - var analyzer = new RefitInterfaceAnalyzer(); - var compilationWithAnalyzers = compilation.WithAnalyzers( - [analyzer]); - return compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + var analyzerOptions = generatedRequestBuilding is null + ? null + : new AnalyzerOptions( + [], + new TestAnalyzerConfigOptionsProvider( + "build_property.RefitGeneratedRequestBuilding", + generatedRequestBuilding.Value ? "true" : "false")); + return Analyze(source, analyzerOptions); } + /// Runs the Refit interface analyzer over an interface body snippet with a bare analyzer-config option. + /// + /// The key is supplied without the build_property. prefix, mirroring an .editorconfig/.globalconfig + /// entry rather than an MSBuild property, so the analyzer reads it through the bare-name option path. + /// + /// The interface body source. + /// The bare analyzer-config option key. + /// The analyzer-config option value. + /// The diagnostics produced by the analyzer. + public static Task> RunForBodyWithAnalyzerConfigOption(string body, string optionKey, string optionValue) => + Analyze( + BuildBodySource(body), + new AnalyzerOptions([], new TestAnalyzerConfigOptionsProvider(optionKey, optionValue))); + /// Runs the Refit interface analyzer over source without referencing Refit. /// The source to analyze. /// The diagnostics produced by the analyzer. @@ -53,6 +72,17 @@ public static Task> RunWithoutRefitReference(string s return compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); } + /// Runs the Refit interface analyzer over Refit-referencing source with the supplied analyzer options. + /// The source to analyze. + /// The analyzer options, or to use the defaults. + /// The diagnostics produced by the analyzer. + private static Task> Analyze(string source, AnalyzerOptions? analyzerOptions) + { + var compilation = CreateLibrary(source, includeRefitReference: true); + var analyzer = new RefitInterfaceAnalyzer(); + return compilation.WithAnalyzers([analyzer], analyzerOptions).GetAnalyzerDiagnosticsAsync(); + } + /// Creates a compilation for analyzer tests. /// The source to compile. /// Whether the Refit assembly should be referenced. @@ -153,4 +183,38 @@ public interface IGeneratedClient {{body}} } """; + + /// An analyzer-config options provider that exposes a single global option. + /// The global option key. + /// The global option value. + private sealed class TestAnalyzerConfigOptionsProvider(string key, string value) : AnalyzerConfigOptionsProvider + { + /// + public override AnalyzerConfigOptions GlobalOptions { get; } = new TestAnalyzerConfigOptions(key, value); + + /// + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => GlobalOptions; + + /// + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => GlobalOptions; + } + + /// An analyzer-config options set containing a single key/value pair. + /// The option key. + /// The option value. + private sealed class TestAnalyzerConfigOptions(string optionKey, string optionValue) : AnalyzerConfigOptions + { + /// + public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value) + { + if (string.Equals(key, optionKey, StringComparison.Ordinal)) + { + value = optionValue; + return true; + } + + value = null; + return false; + } + } } diff --git a/src/tests/Refit.Analyzers.Tests/RefitInterfaceAnalyzerTests.cs b/src/tests/Refit.Analyzers.Tests/RefitInterfaceAnalyzerTests.cs index 2c4f03002..f1d462a36 100644 --- a/src/tests/Refit.Analyzers.Tests/RefitInterfaceAnalyzerTests.cs +++ b/src/tests/Refit.Analyzers.Tests/RefitInterfaceAnalyzerTests.cs @@ -9,6 +9,16 @@ public sealed class RefitInterfaceAnalyzerTests /// The diagnostic identifier for non-Refit interface members. private const string NonRefitMemberDiagnosticId = "RF001"; + /// The diagnostic identifier for methods that fall back to the reflection request builder. + private const string GeneratedRequestBuildingFallbackDiagnosticId = "RF006"; + + /// A Refit method shape that the generator cannot build inline, so it falls back to reflection (RF006). + private const string ReflectionFallbackMethodBody = + """ + [Get("/query-map")] + Task Search(object filters); + """; + /// Verifies analysis exits when the compilation does not reference Refit. /// A task representing the asynchronous test. [Test] @@ -81,6 +91,26 @@ public async Task ReportsInvalidRequestShapeDiagnostics() await Assert.That(diagnosticIds).Contains("RF005"); } + /// Verifies duplicate HeaderCollection and Authorize parameters are reported. + /// A task representing the asynchronous test. + [Test] + public async Task ReportsDuplicateSpecialParameterDiagnostics() + { + var diagnostics = await AnalyzerFixture.RunForBody( + """ + [Get("/headers")] + Task TwoHeaderCollections([HeaderCollection] IDictionary a, [HeaderCollection] IDictionary b); + + [Get("/auth")] + Task TwoAuthorize([Authorize] string a, [Authorize] string b); + """); + + var diagnosticIds = diagnostics.Select(static diagnostic => diagnostic.Id).ToArray(); + + await Assert.That(diagnosticIds).Contains("RF008"); + await Assert.That(diagnosticIds).Contains("RF009"); + } + /// Verifies non-Refit members on Refit interfaces are reported. /// A task representing the asynchronous test. [Test] @@ -154,6 +184,112 @@ await Assert.That(diagnostics.Select(static diagnostic => diagnostic.Id)) .Contains(NonRefitMemberDiagnosticId); } + /// Verifies reflection-only method shapes are flagged as incompatible with generated-only clients. + /// The interface member body source. + /// A task representing the asynchronous test. + [Test] + [Arguments(ReflectionFallbackMethodBody)] + [Arguments(""" + [Post("/form")] + Task PostForm([Body(BodySerializationMethod.UrlEncoded)] T form); + """)] + [Arguments(""" + [Multipart] + [Post("/upload")] + Task Upload(object payload); + """)] + public async Task ReportsReflectionFallbackShapes(string body) + { + var diagnostics = await AnalyzerFixture.RunForBody(body); + + await Assert.That(diagnostics.Select(static diagnostic => diagnostic.Id)) + .Contains(GeneratedRequestBuildingFallbackDiagnosticId); + } + + /// Verifies inline-eligible methods - including query and route parameters - are not flagged. + /// A task representing the asynchronous test. + [Test] + public async Task DoesNotReportInlineSupportedMethods() + { + var diagnostics = await AnalyzerFixture.RunForBody( + """ + [Get("/users")] + Task List(); + + [Get("/signin")] + Task SignIn([AliasAs("login")] string login, [AliasAs("code")] string code); + + [Get("/users/{id}")] + Task GetUser(string id); + + [Get("/items")] + Task Items([Query(CollectionFormat.Multi)] int[] ids); + + [Post("/users")] + Task Create([Body] string payload); + + [Get("/ping")] + Task Ping([Header("X-Trace")] string trace, CancellationToken cancellationToken); + + [Get("/values")] + Task GetValue(); + + [Post("/echo")] + Task Echo([Body] T payload); + + [Multipart] + [Post("/upload")] + Task Upload([AliasAs("file")] StreamPart stream); + + [Get("/stream")] + IObservable Observe(); + """); + + await Assert.That(diagnostics.Select(static diagnostic => diagnostic.Id)) + .DoesNotContain(GeneratedRequestBuildingFallbackDiagnosticId); + } + + /// Verifies the fallback diagnostic is suppressed when generated request building is disabled. + /// A task representing the asynchronous test. + [Test] + public async Task DoesNotReportFallbackWhenGeneratedRequestBuildingDisabled() + { + var diagnostics = await AnalyzerFixture.RunForBody( + ReflectionFallbackMethodBody, + generatedRequestBuilding: false); + + await Assert.That(diagnostics.Select(static diagnostic => diagnostic.Id)) + .DoesNotContain(GeneratedRequestBuildingFallbackDiagnosticId); + } + + /// Verifies a bare .editorconfig toggle disabling generated request building suppresses the fallback diagnostic. + /// A task representing the asynchronous test. + [Test] + public async Task DoesNotReportFallbackWhenEditorConfigDisablesGeneratedRequestBuilding() + { + var diagnostics = await AnalyzerFixture.RunForBodyWithAnalyzerConfigOption( + ReflectionFallbackMethodBody, + "RefitGeneratedRequestBuilding", + "false"); + + await Assert.That(diagnostics.Select(static diagnostic => diagnostic.Id)) + .DoesNotContain(GeneratedRequestBuildingFallbackDiagnosticId); + } + + /// Verifies an unparsable bare .editorconfig toggle is ignored and the default (fallback reported) behavior applies. + /// A task representing the asynchronous test. + [Test] + public async Task ReportsFallbackWhenEditorConfigToggleIsUnparsable() + { + var diagnostics = await AnalyzerFixture.RunForBodyWithAnalyzerConfigOption( + ReflectionFallbackMethodBody, + "RefitGeneratedRequestBuilding", + "notabool"); + + await Assert.That(diagnostics.Select(static diagnostic => diagnostic.Id)) + .Contains(GeneratedRequestBuildingFallbackDiagnosticId); + } + /// Verifies HTTP path extraction handles missing attribute data. /// A task representing the asynchronous test. [Test] diff --git a/src/tests/Refit.CodeFixes.Tests/CodeFixFixture.cs b/src/tests/Refit.CodeFixes.Tests/CodeFixFixture.cs index 3eea99c2d..7372f6b43 100644 --- a/src/tests/Refit.CodeFixes.Tests/CodeFixFixture.cs +++ b/src/tests/Refit.CodeFixes.Tests/CodeFixFixture.cs @@ -24,6 +24,9 @@ internal static class CodeFixFixture /// The runtime data key containing framework assemblies available to the current test host. private const string TrustedPlatformAssemblies = "TRUSTED_PLATFORM_ASSEMBLIES"; + /// The in-memory document name used for code-fix test compilations. + private const string DefaultTestFileName = "Test.cs"; + /// Applies the first available code fix for a diagnostic ID. /// The source to fix. /// The diagnostic ID to fix. @@ -36,7 +39,7 @@ public static async Task ApplyFirstFix(string source, string diagnosticI .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) .WithParseOptions(CSharpParseOptions.Default) .AddMetadataReferences(GetMetadataReferences()); - var document = project.AddDocument("Test.cs", SourceText.From(source)); + var document = project.AddDocument(DefaultTestFileName, SourceText.From(source)); var compilation = await document.Project.GetCompilationAsync() ?? throw new InvalidOperationException("Could not create compilation for code fix test."); @@ -47,9 +50,9 @@ public static async Task ApplyFirstFix(string source, string diagnosticI var diagnostic = diagnostics.SingleOrDefault(x => x.Id == diagnosticId) ?? throw new InvalidOperationException( "Expected diagnostic was not produced. Compiler diagnostics: " - + string.Join(", ", compilation.GetDiagnostics().Select(x => x.ToString())) + + string.Join(", ", compilation.GetDiagnostics().Select(static x => x.ToString())) + ". Analyzer diagnostics: " - + string.Join(", ", diagnostics.Select(x => x.ToString()))); + + string.Join(", ", diagnostics.Select(static x => x.ToString()))); var actions = new List(); var provider = new RefitInterfaceCodeFixProvider(); @@ -142,7 +145,7 @@ private static Document CreateDocument(string source) .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) .WithParseOptions(CSharpParseOptions.Default) .AddMetadataReferences(GetMetadataReferences()) - .AddDocument("Test.cs", SourceText.From(source)); + .AddDocument(DefaultTestFileName, SourceText.From(source)); } /// Creates a diagnostic over the first occurrence of a marker string. @@ -167,7 +170,7 @@ private static Diagnostic CreateDiagnostic(string diagnosticId, string source, s "Refit", DiagnosticSeverity.Warning, isEnabledByDefault: true); - return Diagnostic.Create(descriptor, Location.Create("Test.cs", span, lineSpan)); + return Diagnostic.Create(descriptor, Location.Create(DefaultTestFileName, span, lineSpan)); } /// Gets the assemblies referenced when compiling code-fix test input. diff --git a/src/tests/Refit.CodeFixes.Tests/RefitInterfaceCodeFixProviderTests.cs b/src/tests/Refit.CodeFixes.Tests/RefitInterfaceCodeFixProviderTests.cs index 5f296e1a3..7a86d5f5b 100644 --- a/src/tests/Refit.CodeFixes.Tests/RefitInterfaceCodeFixProviderTests.cs +++ b/src/tests/Refit.CodeFixes.Tests/RefitInterfaceCodeFixProviderTests.cs @@ -59,6 +59,28 @@ public interface IGeneratedClient await Assert.That(fixedSource).IsEqualTo(Source); } + /// Verifies RF003 leaves source unchanged when the attribute has no string literal to rewrite. + /// A task representing the asynchronous test. + [Test] + public async Task RouteBackslashFixLeavesAttributesWithoutStringLiteralUnchanged() + { + const string Source = + """ + using System; + + namespace RefitCodeFixTest; + + [Obsolete] + public interface IGeneratedClient + { + } + """; + + var fixedSource = await CodeFixFixture.UseForwardSlashesDirectly(Source, "Obsolete"); + + await Assert.That(fixedSource).IsEqualTo(Source); + } + /// Verifies RF005 changes HeaderCollection parameters to the supported dictionary type. /// A task representing the asynchronous test. [Test] diff --git a/src/tests/Refit.GeneratedCode.TestModels/Scenarios/GeneratedUserSort.cs b/src/tests/Refit.GeneratedCode.TestModels/Scenarios/GeneratedUserSort.cs new file mode 100644 index 000000000..accd1cfcc --- /dev/null +++ b/src/tests/Refit.GeneratedCode.TestModels/Scenarios/GeneratedUserSort.cs @@ -0,0 +1,18 @@ +// 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.Runtime.Serialization; + +namespace Refit.GeneratedCode.TestModels.Scenarios +{ + /// The sort order exercised by the generated query scenario. + public enum GeneratedUserSort + { + /// Sort by creation date, newest first. + [EnumMember(Value = "date-desc")] + DateDescending, + + /// Sort by user name. + Name, + } +} diff --git a/src/tests/Refit.GeneratedCode.TestModels/Scenarios/IGeneratedMultipartApi.cs b/src/tests/Refit.GeneratedCode.TestModels/Scenarios/IGeneratedMultipartApi.cs new file mode 100644 index 000000000..1a696eb13 --- /dev/null +++ b/src/tests/Refit.GeneratedCode.TestModels/Scenarios/IGeneratedMultipartApi.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; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Threading.Tasks; + +namespace Refit.GeneratedCode.TestModels.Scenarios +{ + /// Exercises generated multipart request construction under the repository analyzer configuration. + public interface IGeneratedMultipartApi + { + /// Uploads a raw stream part. + /// The stream to upload. + /// The response payload. + [Multipart] + [Post("/upload")] + Task UploadStreamAsync(Stream stream); + + /// Uploads a stream, byte array and string part together. + /// The stream part to upload. + /// The byte array to upload. + /// The aliased string to upload. + /// The response payload. + [Multipart] + [Post("/upload")] + Task UploadMixedAsync( + StreamPart stream, + byte[] bytes, + [AliasAs("note")] string text); + + /// Uploads a byte array part using a custom multipart boundary. + /// The byte array to upload. + /// The response payload. + [Multipart("----CustomBoundary")] + [Post("/upload")] + Task UploadCustomBoundaryAsync([AliasAs("blob")] ByteArrayPart bytes); + + /// Uploads a collection of files alongside a single file and formattable identifier. + /// The files to upload as one part each. + /// An additional file to upload. + /// A formattable identifier rendered through the form formatter. + /// The response payload. + [Multipart] + [Post("/upload")] + Task UploadFilesAsync( + IEnumerable files, + FileInfo extra, + [AliasAs("id")] Guid id); + + /// Uploads raw HTTP content directly. + /// The HTTP content to upload. + /// The response payload. + [Multipart] + [Post("/upload")] + Task UploadContentAsync(HttpContent content); + + /// Uploads a stream part alongside a path, header and request property that must not become parts. + /// The path segment. + /// The authorization header value. + /// The request property value. + /// The stream part to upload. + /// The response payload. + [Multipart] + [Post("/upload/{folder}")] + Task UploadWithMetadataAsync( + string folder, + [Header("X-Token")] string token, + [Property("Trace")] string trace, + [AliasAs("file")] StreamPart stream); + } +} diff --git a/src/tests/Refit.GeneratedCode.TestModels/Scenarios/IGeneratedUserApi.cs b/src/tests/Refit.GeneratedCode.TestModels/Scenarios/IGeneratedUserApi.cs index cf76d1477..f86f9100e 100644 --- a/src/tests/Refit.GeneratedCode.TestModels/Scenarios/IGeneratedUserApi.cs +++ b/src/tests/Refit.GeneratedCode.TestModels/Scenarios/IGeneratedUserApi.cs @@ -14,7 +14,7 @@ public interface IGeneratedUserApi /// The cancellation token. /// The user payload. [Get("/users")] - public Task> GetUserAsync( + Task> GetUserAsync( CancellationToken cancellationToken); /// Creates a user. @@ -22,8 +22,25 @@ public Task> GetUserAsync( /// The request headers. /// The created user payload. [Post("/users")] - public Task CreateUserAsync( + Task CreateUserAsync( [Body] string payload, [HeaderCollection] IDictionary headers); + + /// Searches users with generated inline query construction. + /// The search text. + /// The optional page number. + /// Identifiers expanded as repeated pairs. + /// The compile-time-resolved sort order. + /// A valueless query flag. + /// A caller-encoded continuation cursor. + /// The matching user payload. + [Get("/users/search")] + Task SearchUsersAsync( + [AliasAs("q")] string query, + int? page, + [Query(CollectionFormat.Multi)] IReadOnlyList ids, + GeneratedUserSort sort, + [QueryName] string flag, + [Encoded] string cursor); } } diff --git a/src/tests/Refit.GeneratorTests/ExternAliasTests.cs b/src/tests/Refit.GeneratorTests/ExternAliasTests.cs new file mode 100644 index 000000000..2a2813e9d --- /dev/null +++ b/src/tests/Refit.GeneratorTests/ExternAliasTests.cs @@ -0,0 +1,271 @@ +// 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.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Refit.GeneratorTests; + +/// Verifies the generator qualifies types reached through an extern alias and emits the directive, +/// so the generated code compiles for types that are not reachable via global:: (issue #1101). +public sealed class ExternAliasTests +{ + /// The alias-qualified Widget type name the generator must emit into the generated code. + private const string AliasedWidgetQualifiedName = "CompanyLib::Colliding.Widget"; + + /// Source for a separate assembly, referenced only through the extern alias CompanyLib. + private const string AliasedLibrarySource = + "namespace Colliding { public sealed class Widget { public int Size { get; set; } } public enum Color { Red, Green } public sealed class Box { public T Item { get; set; } } }"; + + /// The extern alias directive the generator emits for the aliased library. + private const string ExternAliasDirective = "extern alias CompanyLib;"; + + /// Verifies a return type behind an extern alias is emitted as alias:: with an extern alias + /// directive, and the generated code compiles. + /// A task representing the asynchronous test. + [Test] + public async Task ExternAliasedReturnTypeGeneratesAliasQualifiedAndCompiles() + { + const string consumer = + """ + extern alias CompanyLib; + using System.Threading.Tasks; + using Refit; + + namespace ConsumerNs; + + public interface IWidgetApi + { + [Get("/widget")] + Task GetWidget(); + } + """; + + var generated = await RunAndAssertNoErrors(consumer); + await Assert.That(generated).Contains(AliasedWidgetQualifiedName); + } + + /// Verifies a body parameter type behind an extern alias is emitted as alias:: and compiles. + /// A task representing the asynchronous test. + [Test] + public async Task ExternAliasedBodyParameterGeneratesAliasQualifiedAndCompiles() + { + const string consumer = + """ + extern alias CompanyLib; + using System.Threading.Tasks; + using Refit; + + namespace ConsumerNs; + + public interface IWidgetApi + { + [Post("/widget")] + Task Create([Body] CompanyLib::Colliding.Widget widget); + } + """; + + var generated = await RunAndAssertNoErrors(consumer); + await Assert.That(generated).Contains(AliasedWidgetQualifiedName); + } + + /// Verifies an extern-aliased enum flattened into the query string is emitted as alias:: and compiles. + /// A task representing the asynchronous test. + [Test] + public async Task ExternAliasedQueryEnumGeneratesAliasQualifiedAndCompiles() + { + const string consumer = + """ + extern alias CompanyLib; + using System.Threading.Tasks; + using Refit; + + namespace ConsumerNs; + + public interface IWidgetApi + { + [Get("/widget")] + Task Find([Query] CompanyLib::Colliding.Color color); + } + """; + + var generated = await RunAndAssertNoErrors(consumer); + await Assert.That(generated).Contains("CompanyLib::Colliding.Color"); + } + + /// Verifies an extern-aliased interface property type is emitted as alias:: and compiles. + /// A task representing the asynchronous test. + [Test] + public async Task ExternAliasedInterfacePropertyGeneratesAliasQualifiedAndCompiles() + { + const string consumer = + """ + extern alias CompanyLib; + using System.Threading.Tasks; + using Refit; + + namespace ConsumerNs; + + public interface IWidgetApi + { + CompanyLib::Colliding.Widget Config { get; } + + [Get("/widget")] + Task GetWidget(); + } + """; + + var generated = await RunAndAssertNoErrors(consumer); + await Assert.That(generated).Contains(AliasedWidgetQualifiedName); + } + + /// Verifies an array of an extern-aliased element type qualifies each element as alias::. + /// A task representing the asynchronous test. + [Test] + public async Task ExternAliasedArrayBodyParameterGeneratesAliasQualifiedAndCompiles() + { + const string consumer = + """ + extern alias CompanyLib; + using System.Threading.Tasks; + using Refit; + + namespace ConsumerNs; + + public interface IWidgetApi + { + [Post("/widgets")] + Task Create([Body] CompanyLib::Colliding.Widget[] widgets); + } + """; + + var generated = await RunAndAssertNoErrors(consumer); + await Assert.That(generated).Contains(AliasedWidgetQualifiedName + "[]"); + } + + /// Verifies a nullable extern-aliased enum query parameter qualifies its underlying type as alias::. + /// A task representing the asynchronous test. + [Test] + public async Task ExternAliasedNullableEnumQueryGeneratesAliasQualifiedAndCompiles() + { + const string consumer = + """ + extern alias CompanyLib; + using System.Threading.Tasks; + using Refit; + + namespace ConsumerNs; + + public interface IWidgetApi + { + [Get("/widget")] + Task Find([Query] CompanyLib::Colliding.Color? color); + } + """; + + var generated = await RunAndAssertNoErrors(consumer); + await Assert.That(generated).Contains("CompanyLib::Colliding.Color"); + } + + /// Verifies an extern-aliased generic type closed over a method type parameter renders the argument + /// through the display fallback while still qualifying the aliased outer type. + /// A task representing the asynchronous test. + [Test] + public async Task ExternAliasedGenericOverTypeParameterGeneratesAliasQualified() + { + const string consumer = + """ + extern alias CompanyLib; + using System.Threading.Tasks; + using Refit; + + namespace ConsumerNs; + + public interface IWidgetApi + { + [Post("/boxes")] + Task Store([Body] CompanyLib::Colliding.Box box); + } + """; + + // The method is generic, so it falls back, but the parameter type is still alias-qualified during parsing. + var aliasedLibrary = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(AliasedLibrarySource)) + .ToMetadataReference() + .WithAliases(["CompanyLib"]); + var compilation = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(consumer)).AddReferences(aliasedLibrary); + var result = Fixture.RunGenerator(compilation, generatedRequestBuilding: true, false, null); + var generated = string.Join("\n", result.GeneratedSources.Values); + + await Assert.That(generated).Contains("CompanyLib::Colliding.Box<"); + } + + /// Verifies a reference imported under the global alias stays reachable without qualification, + /// so the generator emits the plain global:: name rather than an extern alias. + /// A task representing the asynchronous test. + [Test] + public async Task GlobalAliasedReferenceGeneratesPlainQualifiedNameAndCompiles() + { + const string library = + "namespace GlobalNs { public sealed class Parcel { public int Weight { get; set; } } }"; + const string consumer = + """ + using System.Threading.Tasks; + using Refit; + + namespace ConsumerNs; + + public interface IParcelApi + { + [Post("/parcels")] + Task Ship([Body] global::GlobalNs.Parcel parcel); + } + """; + + // Aliasing the reference as "global" merges it into the global namespace, so ResolveExternAlias must treat + // it as reachable without an extern alias. + var globalLibrary = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(library)) + .ToMetadataReference() + .WithAliases(["global"]); + var compilation = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(consumer)).AddReferences(globalLibrary); + var result = Fixture.RunGenerator(compilation, generatedRequestBuilding: true, false, null); + var generated = string.Join("\n", result.GeneratedSources.Values); + + var errors = result.OutputCompilation.GetDiagnostics() + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Select(static diagnostic => diagnostic.ToString()) + .ToArray(); + await Assert.That(errors).IsEmpty(); + await Assert.That(generated).DoesNotContain(ExternAliasDirective); + await Assert.That(generated).Contains("global::GlobalNs.Parcel"); + } + + /// Runs the generator over a consumer that reaches the aliased library, asserting the emitted directive, + /// the absence of any global::Colliding reference, and a clean compile. + /// The consumer interface source referencing the aliased library. + /// The concatenation of every generated source, for content assertions. + private static async Task RunAndAssertNoErrors(string consumer) + { + // The Colliding types are reachable only through the extern alias "CompanyLib": if the generator emitted + // global::Colliding.* the code would not compile, so the compile check below is the real assertion. + var aliasedLibrary = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(AliasedLibrarySource)) + .ToMetadataReference() + .WithAliases(["CompanyLib"]); + + var compilation = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(consumer)).AddReferences(aliasedLibrary); + var result = Fixture.RunGenerator(compilation, generatedRequestBuilding: true, false, null); + + // The interface stub file name varies (generic arity), so assert across every generated source. + var generated = string.Join("\n", result.GeneratedSources.Values); + await Assert.That(generated).Contains(ExternAliasDirective); + await Assert.That(generated).DoesNotContain("global::Colliding"); + + var errors = result.OutputCompilation.GetDiagnostics() + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .Select(static diagnostic => diagnostic.ToString()) + .ToArray(); + await Assert.That(errors).IsEmpty(); + return generated; + } +} diff --git a/src/tests/Refit.GeneratorTests/GeneratedCodeComplianceTests.cs b/src/tests/Refit.GeneratorTests/GeneratedCodeComplianceTests.cs index e84b045e4..d515b8ae7 100644 --- a/src/tests/Refit.GeneratorTests/GeneratedCodeComplianceTests.cs +++ b/src/tests/Refit.GeneratorTests/GeneratedCodeComplianceTests.cs @@ -15,6 +15,9 @@ public class GeneratedCodeComplianceTests /// The generated implementation hint name used by compatibility tests. private const string GeneratedClientHintName = "IGeneratedClient.g.cs"; + /// The #nullable directive that must be absent from C# 7.3-targeted generated source. + private const string NullableDirective = "#nullable"; + /// Verifies generated source compiles for projects using C# 7.3. /// A task representing the asynchronous test. [Test] @@ -43,11 +46,104 @@ public interface IGeneratedClient await Assert.That(GetCompilerErrors(result.OutputCompilation)).IsEqualTo(string.Empty); foreach (var generatedSource in result.GeneratedSources) { - await Assert.That(generatedSource.Value).DoesNotContain("#nullable"); + await Assert.That(generatedSource.Value).DoesNotContain(NullableDirective); await Assert.That(generatedSource.Value).DoesNotContain("static ("); } } + /// Verifies the unrolled form-url-encoded fast path compiles down to the C# 7.3 baseline. + /// A task representing the asynchronous test. + [Test] + public async Task GeneratedFormBodyUnrollCompilesWithCSharp73Baseline() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest + { + /// A scalar form body. + public class LoginForm + { + /// Gets or sets the user name. + public string UserName { get; set; } + + /// Gets or sets the age. + public int Age { get; set; } + } + + /// Generated client test interface. + public interface IGeneratedClient + { + /// Posts a scalar form body. + /// The form body. + /// A task representing the request. + [Post("/login")] + Task Login([Body(BodySerializationMethod.UrlEncoded)] LoginForm form); + } + } + """; + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true, LanguageVersion.CSharp7_3); + + // A stray nullable annotation ('string?') or C# 9 pattern would be a compile error at the 7.3 baseline. + await Assert.That(GetCompilerErrors(result.OutputCompilation)).IsEqualTo(string.Empty); + + var generated = result.GeneratedSources[GeneratedClientHintName]; + await Assert.That(generated).DoesNotContain(NullableDirective); + await Assert.That(generated).DoesNotContain(".Add(new("); + await Assert.That(generated).Contains("new global::System.Collections.Generic.KeyValuePair("); + await Assert.That(generated).Contains(" != null"); + } + + /// Verifies the form-field descriptor path (collection body) compiles down to the C# 7.3 baseline. + /// A task representing the asynchronous test. + [Test] + public async Task GeneratedFormDescriptorCompilesWithCSharp73Baseline() + { + const string source = + """ + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest + { + /// A form body with a collection field, which uses the descriptor path. + public class RolesForm + { + /// Gets or sets the user name. + public string UserName { get; set; } + + /// Gets or sets the roles. + [Query(CollectionFormat.Multi)] + public List Roles { get; set; } + } + + /// Generated client test interface. + public interface IGeneratedClient + { + /// Posts a form with a collection field. + /// The form body. + /// A task representing the request. + [Post("/roles")] + Task Post([Body(BodySerializationMethod.UrlEncoded)] RolesForm form); + } + } + """; + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true, LanguageVersion.CSharp7_3); + + // The 'static' lambda (C# 9) and 'object?' cast (C# 8) in the descriptor getter must degrade at the 7.3 baseline. + await Assert.That(GetCompilerErrors(result.OutputCompilation)).IsEqualTo(string.Empty); + + var generated = result.GeneratedSources[GeneratedClientHintName]; + await Assert.That(generated).DoesNotContain(NullableDirective); + await Assert.That(generated).DoesNotContain("static body =>"); + await Assert.That(generated).DoesNotContain("(object?)"); + await Assert.That(generated).Contains("global::Refit.FormField<"); + await Assert.That(generated).Contains("body => (object)body.@UserName"); + } + /// Verifies generated source emits nullable annotations when the consumer language version supports them. /// A task representing the asynchronous test. [Test] diff --git a/src/tests/Refit.GeneratorTests/GeneratedRequestBuildingFallbackContractTests.cs b/src/tests/Refit.GeneratorTests/GeneratedRequestBuildingFallbackContractTests.cs new file mode 100644 index 000000000..0ac247721 --- /dev/null +++ b/src/tests/Refit.GeneratorTests/GeneratedRequestBuildingFallbackContractTests.cs @@ -0,0 +1,79 @@ +// 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. +extern alias RefitAnalyzers; + +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Refit.GeneratorTests; + +/// Guards against drift between the source generator's inline-eligibility decision and the RF006 analyzer diagnostic. +/// +/// The analyzer compiles the generator's request classification sources directly, so RF006 fires for exactly the +/// methods the generator emits against the reflection request builder (BuildRestResultFuncForMethod). These +/// cases pin the contract from both sides: if either half changes eligibility without the other, a case fails - +/// which is the intended alarm. +/// +public sealed class GeneratedRequestBuildingFallbackContractTests +{ + /// The reflection-fallback diagnostic identifier. + private const string FallbackDiagnosticId = "RF006"; + + /// Verifies that methods the generator builds inline are not flagged by the analyzer. + /// The interface member body source. + /// The method whose fallback state is checked. + /// A task representing the asynchronous test. + [Test] + [Arguments("[Get(\"/users\")] Task List();", "List")] + [Arguments("[Post(\"/users\")] Task Create([Body] string payload);", "Create")] + [Arguments("[Get(\"/ping\")] Task Ping([Header(\"X-Trace\")] string trace, CancellationToken token);", "Ping")] + [Arguments("[Get(\"/signin\")] Task SignIn([AliasAs(\"login\")] string login);", "SignIn")] + [Arguments("[Get(\"/users/{id}\")] Task GetUser(string id);", "GetUser")] + [Arguments("[Get(\"/items\")] Task Items([Query(CollectionFormat.Multi)] int[] ids);", "Items")] + [Arguments("[Post(\"/create\")] Task Post(System.IO.Stream payload, string tag);", "Post")] + [Arguments("[Get(\"/values\")] Task GetValue();", "GetValue")] + [Arguments("[Post(\"/echo\")] Task Echo([Body] T payload);", "Echo")] + [Arguments("[Get(\"/cal/{**rest}\")] Task RoundTrip(string rest);", "RoundTrip")] + [Arguments("[Multipart][Post(\"/upload\")] Task Upload([AliasAs(\"file\")] StreamPart stream);", "Upload")] + [Arguments("[Get(\"/stream\")] IObservable Observe();", "Observe")] + public Task InlineMethodsAreNotFlagged(string body, string methodName) => + AssertGeneratorAndAnalyzerAgree(body, methodName, expectedFallback: false); + + /// Verifies that methods the generator builds via reflection are flagged by the analyzer. + /// The interface member body source. + /// The method whose fallback state is checked. + /// A task representing the asynchronous test. + [Test] + [Arguments("[Get(\"/query-map\")] Task Search(object filters);", "Search")] + [Arguments("[Post(\"/form\")] Task PostForm([Body(BodySerializationMethod.UrlEncoded)] T form);", "PostForm")] + [Arguments("[Multipart][Post(\"/upload\")] Task Upload(object payload);", "Upload")] + public Task FallbackMethodsAreFlagged(string body, string methodName) => + AssertGeneratorAndAnalyzerAgree(body, methodName, expectedFallback: true); + + /// Asserts the generator and analyzer produce the same reflection-fallback verdict for a method. + /// The interface member body source. + /// The method whose fallback state is checked. + /// The expected fallback verdict. + /// A task representing the asynchronous test. + private static async Task AssertGeneratorAndAnalyzerAgree( + string body, + string methodName, + bool expectedFallback) + { + var result = Fixture.RunGeneratorForBody(body, null); + + var generatedText = string.Concat(result.GeneratedSources.Values); + var generatorFallsBack = generatedText.Contains( + $"BuildRestResultFuncForMethod(\"{methodName}\"", + StringComparison.Ordinal); + + var analyzerDiagnostics = await result.OutputCompilation + .WithAnalyzers([new RefitAnalyzers::Refit.Analyzers.RefitInterfaceAnalyzer()]) + .GetAnalyzerDiagnosticsAsync(); + var analyzerFallsBack = analyzerDiagnostics.Any(static diagnostic => diagnostic.Id == FallbackDiagnosticId); + + // The generator is the source of truth; the analyzer must agree with it, and both with the expectation. + await Assert.That(generatorFallsBack).IsEqualTo(expectedFallback); + await Assert.That(analyzerFallsBack).IsEqualTo(expectedFallback); + } +} diff --git a/src/tests/Refit.GeneratorTests/GeneratedRequestBuildingTests.InlineEmissionAndFallbacks.cs b/src/tests/Refit.GeneratorTests/GeneratedRequestBuildingTests.InlineEmissionAndFallbacks.cs new file mode 100644 index 000000000..c76d03ded --- /dev/null +++ b/src/tests/Refit.GeneratorTests/GeneratedRequestBuildingTests.InlineEmissionAndFallbacks.cs @@ -0,0 +1,426 @@ +// 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 inline versus reflective-fallback emission across method, verb, body and parameter shapes. +public partial class GeneratedRequestBuildingTests +{ + /// 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("/bad\r\npath")] + Task ControlCharacters(); + + [Multipart] + [Post("/multipart")] + Task Multipart([Body] string body); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + await Assert.That(generated).DoesNotContain("BuildRelativeUri(this.Client, \"/bad"); + } + + /// Verifies a [QueryUriFormat] method generates inline, re-encoding the URI with the attribute's + /// format instead of falling back to the reflection request builder. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnGeneratesInlineForQueryUriFormat() + { + var generated = Fixture.GenerateForBody( + """ + [QueryUriFormat(UriFormat.Unescaped)] + [Get("/query")] + Task Query(string filter); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains(".UrlResolution, (global::System.UriFormat)"); + } + + /// 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 a custom HTTP method attribute whose Method getter is a literal new HttpMethod("VERB") + /// generates inline with that verb instead of falling back. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnGeneratesInlineForCustomHttpVerbLiteral() + { + var generated = Fixture.GenerateForDeclaration( + """ + public sealed class PurgeAttribute : HttpMethodAttribute + { + public PurgeAttribute(string path) : base(path) { } + public override System.Net.Http.HttpMethod Method => new System.Net.Http.HttpMethod("PURGE"); + } + + public interface IGeneratedClient + { + [Purge("/cache/{id}")] + Task Evict(string id); + } + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains(NewHttpRequestMessage); + + // The custom verb is allocated once in a static field and the request references it, not a per-call allocation. + await Assert.That(generated).Contains("private static readonly global::System.Net.Http.HttpMethod ______httpMethod = new global::System.Net.Http.HttpMethod(\"PURGE\");"); + await Assert.That(generated).Contains("new global::System.Net.Http.HttpRequestMessage(______httpMethod,"); + } + + /// Verifies a custom HTTP QUERY verb attribute (a draft-standard body-carrying method) with an explicit + /// [Body] generates inline, emitting the custom verb and serializing the body instead of falling back. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnGeneratesInlineForQueryVerbWithBody() + { + var generated = Fixture.GenerateForDeclaration( + """ + public sealed class QueryVerbAttribute : HttpMethodAttribute + { + public QueryVerbAttribute(string path) : base(path) { } + public override System.Net.Http.HttpMethod Method => new System.Net.Http.HttpMethod("QUERY"); + } + + public sealed class SearchBody { public string? Term { get; set; } } + + public interface IGeneratedClient + { + [QueryVerb("/documents")] + Task Query([Body] SearchBody body); + } + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains(NewHttpRequestMessage); + await Assert.That(generated).Contains("new global::System.Net.Http.HttpMethod(\"QUERY\")"); + await Assert.That(generated).Contains("GeneratedRequestRunner.CreateBodyContent"); + } + + /// 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)refitFunc(this.Client, refitArguments);"); + await Assert.That(generated).Contains("refitFunc(this.Client, refitArguments);"); + 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("BuildRelativeUri(this.Client, \"/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(refitRequest, \"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(refitRequest, \"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(refitRequest, \"X-Value\", @value?.ToString())"); + await Assert.That(generated).Contains("GeneratedRequestRunner.SetHeader(refitRequest, \"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(refitRequest, \" \","); + await Assert.That(generated).DoesNotContain("var refitSettings = _requestBuilder.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("refitSettings.Buffered"); + await Assert.That(generated).Contains("BodySerializationMethod.Serialized"); + await Assert.That(generated).Contains("!refitSettings.Buffered"); + await Assert.That(generated).Contains("true,"); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies URL-encoded bodies use generated request construction. + /// A task representing the asynchronous test. + [Test] + public async Task SwitchOnEmitsUrlEncodedBodyContent() + { + var generated = Fixture.GenerateForBody( + """ + [Post("/form")] + Task Form([Body(BodySerializationMethod.UrlEncoded)] Dictionary form); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("GeneratedRequestRunner.CreateUrlEncodedBodyContent>"); + } + + /// 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("/unknown")] + Task Unknown([Body((BodySerializationMethod)123)] string body); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + await Assert.That(generated).DoesNotContain("GeneratedRequestRunner.CreateBodyContent<"); + await Assert.That(generated).DoesNotContain("GeneratedRequestRunner.CreateUrlEncodedBodyContent<"); + } + + /// 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); + } +} diff --git a/src/tests/Refit.GeneratorTests/GeneratedRequestBuildingTests.PropertiesFormBodiesAndPaths.cs b/src/tests/Refit.GeneratorTests/GeneratedRequestBuildingTests.PropertiesFormBodiesAndPaths.cs new file mode 100644 index 000000000..52e6433a3 --- /dev/null +++ b/src/tests/Refit.GeneratorTests/GeneratedRequestBuildingTests.PropertiesFormBodiesAndPaths.cs @@ -0,0 +1,390 @@ +// 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 interface property implementation, URL-encoded form-body descriptors, and path and query request construction. +public partial class GeneratedRequestBuildingTests +{ + /// 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(refitRequest, \"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"); + } + + /// Verifies a URL-encoded form body emits reflection-free generated field descriptors. + /// A task representing the asynchronous test. + [Test] + public async Task UrlEncodedFormBodyEmitsGeneratedFieldDescriptors() + { + const string source = + """ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using System.Text.Json.Serialization; + using Refit; + + namespace RefitGeneratorTest; + + public class LoginForm + { + [AliasAs("user_name")] + public string UserName { get; set; } + + [JsonPropertyName("pwd")] + public string Password { get; set; } + + [Query(SerializeNull = true)] + public string Note { get; set; } + + [Query(CollectionFormat.Multi)] + public List Roles { get; set; } + } + + public interface IGeneratedClient + { + [Post("/login")] + Task Login([Body(BodySerializationMethod.UrlEncoded)] LoginForm form); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).Contains( + "global::Refit.FormField[]"); + await Assert.That(generated).Contains( + "static body => (object?)body.@UserName"); + await Assert.That(generated).Contains("\"user_name\""); + await Assert.That(generated).Contains("\"pwd\""); + await Assert.That(generated).Contains("(global::Refit.CollectionFormat)"); + await Assert.That(generated).Contains( + "global::Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent("); + } + + /// Verifies special characters in a form field alias are escaped in the generated descriptor literal. + /// A task representing the asynchronous test. + [Test] + public async Task UrlEncodedFormBodyEscapesSpecialCharactersInFieldNames() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public class QuotedForm + { + [AliasAs("a\"b\\c")] + public string Value { get; set; } + } + + public interface IGeneratedClient + { + [Post("/login")] + Task Login([Body(BodySerializationMethod.UrlEncoded)] QuotedForm form); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).Contains("\"a\\\"b\\\\c\""); + } + + /// Verifies a string-typed form body keeps the reflection-based content path. + /// A task representing the asynchronous test. + [Test] + public async Task UrlEncodedStringBodyKeepsReflectionContentPath() + { + var generated = Fixture.GenerateForBody( + """ + [Post("/login")] + Task Login([Body(BodySerializationMethod.UrlEncoded)] string form); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains( + "global::Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent("); + await Assert.That(generated).DoesNotContain("global::Refit.FormField<"); + } + + /// Verifies non-flattenable form body types keep the reflection-based content path. + /// The ineligible body type expression. + /// A task representing the asynchronous test. + [Test] + [Arguments("object")] + [Arguments("System.Collections.Generic.Dictionary")] + [Arguments("System.Collections.Generic.List")] + [Arguments("int[]")] + [Arguments("System.IDisposable")] + public async Task UrlEncodedIneligibleBodyKeepsReflectionContentPath(string bodyType) + { + var generated = Fixture.GenerateForBody( + $$""" + [Post("/x")] + Task Post([Body(BodySerializationMethod.UrlEncoded)] {{bodyType}} body); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains( + "global::Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent<"); + await Assert.That(generated).DoesNotContain("global::Refit.FormField<"); + } + + /// Verifies form field descriptors include inherited properties and apply the query prefix. + /// A task representing the asynchronous test. + [Test] + public async Task UrlEncodedFormBodyEmitsInheritedAndPrefixedFieldDescriptors() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public class BaseForm + { + public string BaseValue { get; set; } + } + + public class DerivedForm : BaseForm + { + [Query("-", "secret")] + public string Token { get; set; } + } + + public interface IGeneratedClient + { + [Post("/login")] + Task Login([Body(BodySerializationMethod.UrlEncoded)] DerivedForm form); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).Contains("@form.@Token"); + await Assert.That(generated).Contains("@form.@BaseValue"); + await Assert.That(generated).Contains("\"secret-\""); + } + + /// Verifies that path parameters are supported by the source generator. + /// A task representing the asynchronous test. + [Test] + public async Task EmitsInlineConstructionForPathParameters() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/a/{aVal}")] + Task Sample(int aVal); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).Contains("""GeneratedRequestRunner.BuildRequestPath("/a/{aVal}", refitSettings.AllowUnmatchedRouteParameters, [((3, 9), """); + } + + /// Verifies that path parameters are supported by the source generator. + /// A task representing the asynchronous test. + [Test] + public async Task UsesGeneratedRequestBuilderForTemplatedQueryParameters() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/a?b={bVal}")] + Task Sample(string bVal); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).Contains("""GeneratedRequestRunner.BuildRequestPath("/a?b={bVal}", refitSettings.AllowUnmatchedRouteParameters, [((5, 11), """); + } + + /// Verifies that auto-appended query parameters generate inline query construction. + /// A task representing the asynchronous test. + [Test] + public async Task EmitsInlineConstructionForNonTemplatedQueryParameters() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/a")] + Task Sample(string bVal); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("new global::Refit.GeneratedQueryStringBuilder(\"/a\", false)"); + await Assert.That(generated).Contains("refitQueryBuilder.AddPreEscapedKey(\"bVal\""); + } + + /// Verifies that round trip path parameters are not supported by the source generator. + /// A task representing the asynchronous test. + [Test] + public async Task GeneratesInlineForRoundTripPathPlaceholders() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/a/{**path}")] + Task Sample(string path); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("RoundTripEscapePath"); + } +} diff --git a/src/tests/Refit.GeneratorTests/GeneratedRequestBuildingTests.cs b/src/tests/Refit.GeneratorTests/GeneratedRequestBuildingTests.cs index e34ba1dac..b3dbab415 100644 --- a/src/tests/Refit.GeneratorTests/GeneratedRequestBuildingTests.cs +++ b/src/tests/Refit.GeneratorTests/GeneratedRequestBuildingTests.cs @@ -4,7 +4,7 @@ namespace Refit.GeneratorTests; /// Generator tests for opt-in generated request construction. -public class GeneratedRequestBuildingTests +public partial class GeneratedRequestBuildingTests { /// The generated implementation source hint name used by these tests. private const string GeneratedClientHintName = "IGeneratedClient.g.cs"; @@ -39,41 +39,6 @@ public async Task DefaultUsesGeneratedRequestConstruction() await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); } - /// Verifies an IAsyncEnumerable method is generated inline through StreamAsync, not the reflective builder. - /// A task representing the asynchronous test. - [Test] - public async Task IAsyncEnumerableUsesInlineStreamAsync() - { - var generated = Fixture.GenerateForBody( - """ - [Get("/items")] - IAsyncEnumerable Stream(); - """, - GeneratedClientHintName, - generatedRequestBuilding: true); - - await Assert.That(generated).Contains("GeneratedRequestRunner.StreamAsync<"); - await Assert.That(generated).Contains(NewHttpRequestMessage); - await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); - } - - /// Verifies an IAsyncEnumerable method falls back to the reflective builder when inline generation is off. - /// A task representing the asynchronous test. - [Test] - public async Task IAsyncEnumerableSwitchOffUsesReflectiveRequestBuilder() - { - var generated = Fixture.GenerateForBody( - """ - [Get("/items")] - IAsyncEnumerable Stream(); - """, - GeneratedClientHintName, - generatedRequestBuilding: false); - - await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); - await Assert.That(generated).DoesNotContain("GeneratedRequestRunner.StreamAsync<"); - } - /// Verifies an explicit switch-off keeps using the reflective request builder. /// A task representing the asynchronous test. [Test] @@ -425,757 +390,4 @@ public async Task SwitchOnEscapesInlineStringLiterals() 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("/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("BuildRelativeUri(this.Client, \"relative\""); - await Assert.That(generated).DoesNotContain("BuildRelativeUri(this.Client, \"/users/{id}\""); - await Assert.That(generated).DoesNotContain("BuildRelativeUri(this.Client, \"/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)refitFunc(this.Client, refitArguments);"); - await Assert.That(generated).Contains("refitFunc(this.Client, refitArguments);"); - 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("BuildRelativeUri(this.Client, \"/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(refitRequest, \"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(refitRequest, \"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(refitRequest, \"X-Value\", @value?.ToString())"); - await Assert.That(generated).Contains("GeneratedRequestRunner.SetHeader(refitRequest, \"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(refitRequest, \" \","); - await Assert.That(generated).DoesNotContain("var refitSettings = _requestBuilder.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("refitSettings.Buffered"); - await Assert.That(generated).Contains("BodySerializationMethod.Serialized"); - await Assert.That(generated).Contains("!refitSettings.Buffered"); - await Assert.That(generated).Contains("true,"); - await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); - } - - /// Verifies URL-encoded bodies use generated request construction. - /// A task representing the asynchronous test. - [Test] - public async Task SwitchOnEmitsUrlEncodedBodyContent() - { - var generated = Fixture.GenerateForBody( - """ - [Post("/form")] - Task Form([Body(BodySerializationMethod.UrlEncoded)] Dictionary form); - """, - GeneratedClientHintName, - generatedRequestBuilding: true); - - await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); - await Assert.That(generated).Contains("GeneratedRequestRunner.CreateUrlEncodedBodyContent>"); - } - - /// 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("/unknown")] - Task Unknown([Body((BodySerializationMethod)123)] string body); - """, - GeneratedClientHintName, - generatedRequestBuilding: true); - - await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); - await Assert.That(generated).DoesNotContain("GeneratedRequestRunner.CreateBodyContent<"); - await Assert.That(generated).DoesNotContain("GeneratedRequestRunner.CreateUrlEncodedBodyContent<"); - } - - /// 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(refitRequest, \"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"); - } - - /// Verifies a URL-encoded form body emits reflection-free generated field descriptors. - /// A task representing the asynchronous test. - [Test] - public async Task UrlEncodedFormBodyEmitsGeneratedFieldDescriptors() - { - const string source = - """ - using System; - using System.Collections.Generic; - using System.Threading.Tasks; - using System.Text.Json.Serialization; - using Refit; - - namespace RefitGeneratorTest; - - public class LoginForm - { - [AliasAs("user_name")] - public string UserName { get; set; } - - [JsonPropertyName("pwd")] - public string Password { get; set; } - - [Query(SerializeNull = true)] - public string Note { get; set; } - - [Query(CollectionFormat.Multi)] - public List Roles { get; set; } - } - - public interface IGeneratedClient - { - [Post("/login")] - Task Login([Body(BodySerializationMethod.UrlEncoded)] LoginForm form); - } - """; - - var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); - var generated = result.GeneratedSources[GeneratedClientHintName]; - - await Assert.That(result.CompilesWithoutErrors).IsTrue(); - await Assert.That(generated).Contains( - "global::Refit.FormField[]"); - await Assert.That(generated).Contains( - "static body => (object?)body.@UserName"); - await Assert.That(generated).Contains("\"user_name\""); - await Assert.That(generated).Contains("\"pwd\""); - await Assert.That(generated).Contains("(global::Refit.CollectionFormat)"); - await Assert.That(generated).Contains( - "global::Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent("); - } - - /// Verifies special characters in a form field alias are escaped in the generated descriptor literal. - /// A task representing the asynchronous test. - [Test] - public async Task UrlEncodedFormBodyEscapesSpecialCharactersInFieldNames() - { - const string source = - """ - using System.Threading.Tasks; - using Refit; - - namespace RefitGeneratorTest; - - public class QuotedForm - { - [AliasAs("a\"b\\c")] - public string Value { get; set; } - } - - public interface IGeneratedClient - { - [Post("/login")] - Task Login([Body(BodySerializationMethod.UrlEncoded)] QuotedForm form); - } - """; - - var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); - var generated = result.GeneratedSources[GeneratedClientHintName]; - - await Assert.That(result.CompilesWithoutErrors).IsTrue(); - await Assert.That(generated).Contains("\"a\\\"b\\\\c\""); - } - - /// Verifies a string-typed form body keeps the reflection-based content path. - /// A task representing the asynchronous test. - [Test] - public async Task UrlEncodedStringBodyKeepsReflectionContentPath() - { - var generated = Fixture.GenerateForBody( - """ - [Post("/login")] - Task Login([Body(BodySerializationMethod.UrlEncoded)] string form); - """, - GeneratedClientHintName, - generatedRequestBuilding: true); - - await Assert.That(generated).Contains( - "global::Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent("); - await Assert.That(generated).DoesNotContain("global::Refit.FormField<"); - } - - /// Verifies non-flattenable form body types keep the reflection-based content path. - /// The ineligible body type expression. - /// A task representing the asynchronous test. - [Test] - [Arguments("object")] - [Arguments("System.Collections.Generic.Dictionary")] - [Arguments("System.Collections.Generic.List")] - [Arguments("int[]")] - [Arguments("System.IDisposable")] - public async Task UrlEncodedIneligibleBodyKeepsReflectionContentPath(string bodyType) - { - var generated = Fixture.GenerateForBody( - $$""" - [Post("/x")] - Task Post([Body(BodySerializationMethod.UrlEncoded)] {{bodyType}} body); - """, - GeneratedClientHintName, - generatedRequestBuilding: true); - - await Assert.That(generated).Contains( - "global::Refit.GeneratedRequestRunner.CreateUrlEncodedBodyContent<"); - await Assert.That(generated).DoesNotContain("global::Refit.FormField<"); - } - - /// Verifies form field descriptors include inherited properties and apply the query prefix. - /// A task representing the asynchronous test. - [Test] - public async Task UrlEncodedFormBodyEmitsInheritedAndPrefixedFieldDescriptors() - { - const string source = - """ - using System.Threading.Tasks; - using Refit; - - namespace RefitGeneratorTest; - - public class BaseForm - { - public string BaseValue { get; set; } - } - - public class DerivedForm : BaseForm - { - [Query("-", "secret")] - public string Token { get; set; } - } - - public interface IGeneratedClient - { - [Post("/login")] - Task Login([Body(BodySerializationMethod.UrlEncoded)] DerivedForm form); - } - """; - - var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); - var generated = result.GeneratedSources[GeneratedClientHintName]; - - await Assert.That(result.CompilesWithoutErrors).IsTrue(); - await Assert.That(generated).Contains("static body => (object?)body.@Token"); - await Assert.That(generated).Contains("static body => (object?)body.@BaseValue"); - await Assert.That(generated).Contains("\"secret-\""); - } - - /// Verifies that path parameters are supported by the source generator. - /// A task representing the asynchronous test. - [Test] - public async Task EmitsInlineConstructionForPathParameters() - { - const string source = - """ - using System.Threading.Tasks; - using Refit; - - namespace RefitGeneratorTest; - - public interface IGeneratedClient - { - [Get("/a/{aVal}")] - Task Sample(int aVal); - } - """; - - var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); - var generated = result.GeneratedSources[GeneratedClientHintName]; - - await Assert.That(result.CompilesWithoutErrors).IsTrue(); - await Assert.That(generated).Contains("""GeneratedRequestRunner.BuildRequestPath("/a/{aVal}", refitSettings.AllowUnmatchedRouteParameters, ((3, 9), """); - } - - /// Verifies that path parameters are supported by the source generator. - /// A task representing the asynchronous test. - [Test] - public async Task UsesGeneratedRequestBuilderForTemplatedQueryParameters() - { - const string source = - """ - using System.Threading.Tasks; - using Refit; - - namespace RefitGeneratorTest; - - public interface IGeneratedClient - { - [Get("/a?b={bVal}")] - Task Sample(string bVal); - } - """; - - var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); - var generated = result.GeneratedSources[GeneratedClientHintName]; - - await Assert.That(result.CompilesWithoutErrors).IsTrue(); - await Assert.That(generated).Contains("""GeneratedRequestRunner.BuildRequestPath("/a?b={bVal}", refitSettings.AllowUnmatchedRouteParameters, ((5, 11), """); - } - - /// Verifies that non-templated parameters are not supported by the source generator. - /// A task representing the asynchronous test. - [Test] - public async Task UsesFallbackForNonTemplatedQueryParameters() - { - const string source = - """ - using System.Threading.Tasks; - using Refit; - - namespace RefitGeneratorTest; - - public interface IGeneratedClient - { - [Get("/a")] - Task Sample(string bVal); - } - """; - - var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); - var generated = result.GeneratedSources[GeneratedClientHintName]; - - await Assert.That(result.CompilesWithoutErrors).IsTrue(); - await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); - } - - /// Verifies that dotted path parameters are not supported by the source generator. - /// A task representing the asynchronous test. - [Test] - public async Task UsesFallbackForDottedPathPlaceholders() - { - const string source = - """ - using System.Threading.Tasks; - using Refit; - - namespace RefitGeneratorTest; - - public record class Data(string Value); - - public interface IGeneratedClient - { - [Get("/a/{data.Value}")] - Task Sample(Data data); - } - """; - - var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); - var generated = result.GeneratedSources[GeneratedClientHintName]; - - await Assert.That(result.CompilesWithoutErrors).IsTrue(); - await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); - } - - /// Verifies that round trip path parameters are not supported by the source generator. - /// A task representing the asynchronous test. - [Test] - public async Task UsesFallbackForRoundTripPathPlaceholders() - { - const string source = - """ - using System.Threading.Tasks; - using Refit; - - namespace RefitGeneratorTest; - - public interface IGeneratedClient - { - [Get("/a/{**path}")] - Task Sample(string path); - } - """; - - var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); - var generated = result.GeneratedSources[GeneratedClientHintName]; - - await Assert.That(result.CompilesWithoutErrors).IsTrue(); - await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); - } } diff --git a/src/tests/Refit.GeneratorTests/GeneratorComponentTests.EmitterHelpers.cs b/src/tests/Refit.GeneratorTests/GeneratorComponentTests.EmitterHelpers.cs new file mode 100644 index 000000000..fc37383a2 --- /dev/null +++ b/src/tests/Refit.GeneratorTests/GeneratorComponentTests.EmitterHelpers.cs @@ -0,0 +1,479 @@ +// 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.Linq; + +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +using Refit.Generator; + +namespace Refit.GeneratorTests; + +/// Direct unit tests for the source generator emitter formatting helpers. +public static partial class GeneratorComponentTests +{ + /// Tests for direct emitter formatting helpers. + public class EmitterHelperTests + { + /// The default body serialization method name. + private const string DefaultSerializationMethod = "Default"; + + /// The property name used to test explicit and public property access expressions. + private const string TenantPropertyName = "Tenant"; + + /// The non-standard HTTP method attribute name used by candidate-combining tests. + private const string CustomMethodName = "Custom"; + + /// The generated false literal. + private const string FalseLiteral = "false"; + + /// The generated true literal. + private const string TrueLiteral = "true"; + + /// The generated fully-qualified task type name. + private const string TaskTypeName = "global::System.Threading.Tasks.Task"; + + /// The generated string type name. + private const string StringTypeName = "string"; + + /// The populated part count used by join helper tests. + private const int PopulatedPartCount = 2; + + /// Verifies escaping every special C# string-literal character. + /// A task representing the asynchronous test. + [Test] + public async Task AppendEscapedCharacter_HandlesSpecialCharacters() + { + var builder = new PooledStringBuilder(); + + foreach (var value in new[] { '\\', '"', '\0', '\a', '\b', '\f', '\n', '\r', '\t', '\v', '\u0085', '\u2028', '\u2029', 'x' }) + { + Emitter.AppendEscapedCharacter(builder, value); + } + + await Assert.That(builder.ToString()).IsEqualTo("""\\\"\0\a\b\f\n\r\t\v\u0085\u2028\u2029x"""); + } + + /// Verifies the string-literal escape lookup for special, line-terminator, and verbatim characters. + /// A task representing the asynchronous test. + [Test] + public async Task EscapeSequence_ReturnsEscapesForSpecialCharactersAndNullOtherwise() + { + await Assert.That(Emitter.EscapeSequence('\\')).IsEqualTo(@"\\"); + await Assert.That(Emitter.EscapeSequence('"')).IsEqualTo("\\\""); + await Assert.That(Emitter.EscapeSequence('\0')).IsEqualTo(@"\0"); + await Assert.That(Emitter.EscapeSequence('\a')).IsEqualTo(@"\a"); + await Assert.That(Emitter.EscapeSequence('\b')).IsEqualTo(@"\b"); + await Assert.That(Emitter.EscapeSequence('\f')).IsEqualTo(@"\f"); + await Assert.That(Emitter.EscapeSequence('\n')).IsEqualTo(@"\n"); + await Assert.That(Emitter.EscapeSequence('\r')).IsEqualTo(@"\r"); + await Assert.That(Emitter.EscapeSequence('\t')).IsEqualTo(@"\t"); + await Assert.That(Emitter.EscapeSequence('\v')).IsEqualTo(@"\v"); + await Assert.That(Emitter.EscapeSequence('\u0085')).IsEqualTo(@"\u0085"); + await Assert.That(Emitter.EscapeSequence('\u2028')).IsEqualTo(@"\u2028"); + await Assert.That(Emitter.EscapeSequence('\u2029')).IsEqualTo(@"\u2029"); + await Assert.That(Emitter.EscapeSequence('x')).IsNull(); + } + + /// Verifies the rendered length of a quoted literal accounts for escapes and the null keyword. + /// A task representing the asynchronous test. + [Test] + public async Task LiteralOrNullLength_AccountsForEscapesAndNull() + { + const int NullLength = 4; + const int PlainQuotedLength = 5; + + // '"' wrapper (2) + 'a' (1) + '"' escape (2) + U+2028 escape (6) = 11. + const int EscapedLength = 11; + + await Assert.That(Emitter.LiteralOrNullLength(null)).IsEqualTo(NullLength); + await Assert.That(Emitter.LiteralOrNullLength("abc")).IsEqualTo(PlainQuotedLength); + await Assert.That(Emitter.LiteralOrNullLength("a\"\u2028")).IsEqualTo(EscapedLength); + } + + /// Verifies the decimal length helper for zero, single-digit, and multi-digit values. + /// A task representing the asynchronous test. + [Test] + public async Task Int32Length_HandlesZeroAndMultipleDigits() + { + const int SingleDigit = 1; + const int ThreeDigits = 3; + const int PositiveThreeDigitValue = 123; + + await Assert.That(Emitter.Int32Length(0)).IsEqualTo(SingleDigit); + await Assert.That(Emitter.Int32Length(SingleDigit)).IsEqualTo(SingleDigit); + await Assert.That(Emitter.Int32Length(PositiveThreeDigitValue)).IsEqualTo(ThreeDigits); + } + + /// Verifies body buffering and streaming expressions for all supported modes. + /// A task representing the asynchronous test. + [Test] + public async Task BodyExpressionHelpers_HandleBufferModes() + { + const string settings = "refitSettings"; + 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(UrlEncodedSerializationMethod, BodyBufferMode.Streaming); + + await Assert.That(Emitter.BuildBufferBodyExpression(null, settings)).IsEqualTo(FalseLiteral); + await Assert.That(Emitter.BuildBufferBodyExpression(settingsBody, settings)).IsEqualTo($"{settings}.Buffered"); + await Assert.That(Emitter.BuildBufferBodyExpression(bufferedBody, settings)).IsEqualTo(TrueLiteral); + await Assert.That(Emitter.BuildBufferBodyExpression(streamingBody, settings)).IsEqualTo(FalseLiteral); + await Assert.That(Emitter.BuildBufferBodyExpression(noneBody, settings)).IsEqualTo(FalseLiteral); + await Assert.That(Emitter.BuildStreamBodyExpression(settingsBody, settings)).IsEqualTo($"!{settings}.Buffered"); + await Assert.That(Emitter.BuildStreamBodyExpression(bufferedBody, settings)).IsEqualTo(FalseLiteral); + await Assert.That(Emitter.BuildStreamBodyExpression(streamingBody, settings)).IsEqualTo(TrueLiteral); + await Assert.That(Emitter.BuildStreamBodyExpression(noneBody, settings)).IsEqualTo(FalseLiteral); + await Assert.That(Emitter.BuildStreamBodyExpression(urlEncodedBody, settings)).IsEqualTo(FalseLiteral); + + // With a different settings local name, the expression follows it (collision-avoidance threading). + await Assert.That(Emitter.BuildBufferBodyExpression(settingsBody, "renamed")).IsEqualTo("renamed.Buffered"); + await Assert.That(Emitter.BuildStreamBodyExpression(settingsBody, "renamed")).IsEqualTo("!renamed.Buffered"); + } + + /// 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(TenantPropertyName, "int", TenantInterface, false, true); + var prefixedExplicitProperty = CreateProperty(TenantPropertyName, "int", GlobalTenantInterface, false, true); + var publicProperty = CreateProperty(TenantPropertyName, "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"); + + // A verb outside the cached singletons (a custom HTTP method attribute's verb) constructs an HttpMethod. + await Assert.That(Emitter.ToHttpMethodExpression("TRACE")).IsEqualTo("new global::System.Net.Http.HttpMethod(\"TRACE\")"); + } + + /// 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(static () => 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 BuildMethodOpening_QualifiesExplicitInterfaceMethods() + { + var method = new MethodModel( + "Ping", + TaskTypeName, + "RefitGeneratorTest.IBase", + "Ping", + ReturnTypeInfo.AsyncVoid, + RequestModel.Empty, + ImmutableEquatableArray.Empty, + ImmutableEquatableArray.Empty, + false, + false, + false); + + var source = Emitter.BuildMethodOpening(method, true, true, supportsNullable: true, isAsync: true); + + await Assert.That(source) + .Contains("async global::System.Threading.Tasks.Task global::RefitGeneratorTest.IBase.Ping("); + } + + /// Verifies emitted XML documentation text escapes special characters. + /// A task representing the asynchronous test. + [Test] + public async Task ToXmlDocumentationText_EscapesSpecialCharacters() => + await Assert.That(Emitter.ToXmlDocumentationTextForTesting("A&B")).IsEqualTo("A&B<C>"); + + /// Verifies generated file headers for analyzer-enabled generated code tests. + /// A task representing the asynchronous test. + [Test] + public async Task BuildGeneratedFileHeader_SupportsNonGeneratedMarkers() + { + var noneHeader = Emitter.BuildGeneratedFileHeaderForTesting(Nullability.None, emitGeneratedCodeMarkers: false); + var nullableHeader = Emitter.BuildGeneratedFileHeaderForTesting(Nullability.Enabled, emitGeneratedCodeMarkers: false); + + await Assert.That(noneHeader).Contains("ReactiveUI and Contributors"); + await Assert.That(noneHeader).DoesNotContain("#nullable"); + await Assert.That(nullableHeader).Contains("#nullable enable annotations"); + } + + /// Verifies generated settings factories require at least one inline-capable Refit method. + /// A task representing the asynchronous test. + [Test] + public async Task CanUseGeneratedSettingsFactory_RejectsEmptyInterfaces() + { + var emptyModel = CreateInterfaceModel( + ImmutableEquatableArray.Empty, + ImmutableEquatableArray.Empty); + var inlineModel = CreateInterfaceModel( + new([CreateRefitMethod(canGenerateInline: true)]), + ImmutableEquatableArray.Empty); + + await Assert.That(Emitter.CanUseGeneratedSettingsFactoryForTesting(emptyModel)).IsFalse(); + await Assert.That(Emitter.CanUseGeneratedSettingsFactoryForTesting(inlineModel)).IsTrue(); + } + + /// Verifies parameter type-list helpers handle empty and populated parameter lists. + /// A task representing the asynchronous test. + [Test] + public async Task BuildParameterTypeList_HandlesEmptyAndPopulatedParameters() + { + var parameters = new ImmutableEquatableArray( + [ + new("first", StringTypeName, false, false), + new("second", "global::System.Int32", false, false) + ]); + + await Assert.That(Emitter.BuildParameterTypeListForTesting(ImmutableEquatableArray.Empty)) + .IsEqualTo(string.Empty); + await Assert.That(Emitter.BuildParameterTypeListForTesting(parameters)) + .IsEqualTo("typeof(string), typeof(global::System.Int32)"); + } + + /// Verifies source fragment joining avoids extra separators for empty input. + /// A task representing the asynchronous test. + [Test] + public async Task JoinParts_HandlesEmptyAndPopulatedParts() + { + await Assert.That(Emitter.JoinPartsForTesting([], 0, ", ")).IsEqualTo(string.Empty); + await Assert.That(Emitter.JoinPartsForTesting(["a", "b", "ignored"], PopulatedPartCount, ", ")).IsEqualTo("a, b"); + } + + /// Verifies candidate method combining preserves standard and custom methods. + /// A task representing the asynchronous test. + [Test] + public async Task CombineCandidateMethods_CombinesStandardAndCustomMethods() + { + var standard = ParseMethod("Standard", "[Get(\"/standard\")]"); + var custom = ParseMethod(CustomMethodName, "[Custom(\"CUSTOM\", \"/custom\")]"); + + var result = InterfaceStubGeneratorV2.CombineCandidateMethodsForTesting( + ([standard], [custom])); + + var names = result.Select(static method => method.Identifier.ValueText).ToArray(); + + await Assert.That(names.Length).IsEqualTo(PopulatedPartCount); + await Assert.That(names[0]).IsEqualTo("Standard"); + await Assert.That(names[1]).IsEqualTo(CustomMethodName); + } + + /// Verifies standard HTTP method attribute detection handles suffixes and qualified names. + /// A task representing the asynchronous test. + [Test] + public async Task IsStandardHttpMethodAttributeName_HandlesQualifiedAndAliasNames() + { + await Assert.That(InterfaceStubGeneratorV2.IsStandardHttpMethodAttributeNameForTesting(ParseName(nameof(GetAttribute)))).IsTrue(); + await Assert.That(InterfaceStubGeneratorV2.IsStandardHttpMethodAttributeNameForTesting(ParseName("Refit.Post"))).IsTrue(); + await Assert.That(InterfaceStubGeneratorV2.IsStandardHttpMethodAttributeNameForTesting(ParseName("global::PutAttribute"))).IsTrue(); + await Assert.That(InterfaceStubGeneratorV2.IsStandardHttpMethodAttributeNameForTesting(ParseName("global::Refit.PutAttribute"))).IsTrue(); + await Assert.That(InterfaceStubGeneratorV2.IsStandardHttpMethodAttributeNameForTesting(ParseName("Delete"))).IsTrue(); + await Assert.That(InterfaceStubGeneratorV2.IsStandardHttpMethodAttributeNameForTesting(ParseName(nameof(HeadAttribute)))).IsTrue(); + await Assert.That(InterfaceStubGeneratorV2.IsStandardHttpMethodAttributeNameForTesting(ParseName("Options"))).IsTrue(); + await Assert.That(InterfaceStubGeneratorV2.IsStandardHttpMethodAttributeNameForTesting(ParseName(nameof(PatchAttribute)))).IsTrue(); + await Assert.That(InterfaceStubGeneratorV2.IsStandardHttpMethodAttributeNameForTesting(ParseName("GetAttribute"))).IsFalse(); + await Assert.That(InterfaceStubGeneratorV2.IsStandardHttpMethodAttributeNameForTesting(ParseName(CustomMethodName))).IsFalse(); + } + + /// Verifies an explicitly-implemented Refit method emits an explicit interface prefix on both paths. + /// A task representing the asynchronous test. + [Test] + public async Task BuildRefitMethod_EmitsExplicitInterfaceForExplicitMethod() + { + var explicitReflective = CreateRefitMethod(canGenerateInline: false) with { IsExplicitInterface = true }; + var explicitInline = CreateRefitMethod(canGenerateInline: true) with { IsExplicitInterface = true }; + var model = CreateInterfaceModel( + new([explicitReflective]), + ImmutableEquatableArray.Empty); + + var reflective = Emitter.BuildRefitMethodForTesting( + explicitReflective, + isTopLevel: true, + model, + new(), + "_requestBuilder", + "_settings"); + var inline = Emitter.BuildRefitMethodForTesting( + explicitInline, + isTopLevel: true, + model, + new(), + "_requestBuilder", + "_settings"); + + await Assert.That(reflective).Contains("global::RefitGeneratorTest.IGeneratedClient.Get"); + await Assert.That(inline).Contains("global::RefitGeneratorTest.IGeneratedClient.Get"); + } + + /// Verifies constraint emission suppresses override-incompatible keywords for explicit implementations. + /// A task representing the asynchronous test. + [Test] + public async Task HasConstraintKeywords_HandlesOverrideAndNonOverridePaths() + { + // class/struct are always emitted; unmanaged/notnull/new/textual are suppressed on overrides/explicit impls. + await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.Class), true)).IsTrue(); + await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.Struct), true)).IsTrue(); + await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.Unmanaged), true)).IsFalse(); + await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.NotNull), true)).IsFalse(); + await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.New), true)).IsFalse(); + await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.None, "global::System.IDisposable"), true)).IsFalse(); + + await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.Unmanaged), false)).IsTrue(); + await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.NotNull), false)).IsTrue(); + await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.New), false)).IsTrue(); + await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.None, "global::System.IDisposable"), false)).IsTrue(); + await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.None), false)).IsFalse(); + } + + /// Creates a type-parameter constraint model. + /// The well-known constraint flags. + /// Additional textual constraints. + /// The type constraint. + private static TypeConstraint Constraint(KnownTypeConstraint known, params string[] extra) + { + var textual = extra.Length == 0 + ? ImmutableEquatableArray.Empty + : new(extra); + return new("T", "T", known, textual); + } + + /// 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", StringTypeName, null, ImmutableEquatableArray.Empty, 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); + + /// Creates an interface model for direct emitter helper tests. + /// The directly declared Refit methods. + /// The inherited Refit methods. + /// The interface model. + private static InterfaceModel CreateInterfaceModel( + ImmutableEquatableArray refitMethods, + ImmutableEquatableArray derivedRefitMethods) => + new( + "RefitInternalGenerated.PreserveAttribute", + "IGeneratedClient.g.cs", + "IGeneratedClient", + "RefitGeneratorTest", + "public partial class IGeneratedClient", + "RefitGeneratorTest.IGeneratedClient", + string.Empty, + GeneratedRequestBuilding: true, + EmitGeneratedCodeMarkers: true, + SupportsNullable: true, + SupportsStaticLambdas: true, + SupportsCollectionExpressions: true, + ImmutableEquatableArray.Empty, + ImmutableEquatableArray.Empty, + ImmutableEquatableArray.Empty, + ImmutableEquatableArray.Empty, + refitMethods, + derivedRefitMethods, + Nullability.Enabled, + false, + ImmutableEquatableArray.Empty); + + /// Creates a Refit method model for direct emitter helper tests. + /// Whether the request can be generated inline. + /// The method model. + private static MethodModel CreateRefitMethod(bool canGenerateInline) => + new( + "Get", + TaskTypeName, + "RefitGeneratorTest.IGeneratedClient", + "Get", + ReturnTypeInfo.AsyncVoid, + new( + "GET", + "/", + TaskTypeName, + "global::System.Threading.Tasks.Task", + false, + true, + canGenerateInline, + null, + ImmutableEquatableArray.Empty, + ImmutableEquatableArray.Empty), + ImmutableEquatableArray.Empty, + ImmutableEquatableArray.Empty, + false, + false, + false); + + /// Parses a method declaration for syntax helper tests. + /// The method name. + /// The attribute source. + /// The method declaration syntax. + private static MethodDeclarationSyntax ParseMethod(string name, string attribute) + { + var compilationUnit = SyntaxFactory.ParseCompilationUnit( + $"public interface I {{ {attribute} void {name}(); }}"); + return compilationUnit.DescendantNodes().OfType().Single(); + } + + /// Parses an attribute name for syntax helper tests. + /// The name source. + /// The parsed name syntax. + private static NameSyntax ParseName(string name) => + SyntaxFactory.ParseName(name); + } +} diff --git a/src/tests/Refit.GeneratorTests/GeneratorComponentTests.ParserHelpers.cs b/src/tests/Refit.GeneratorTests/GeneratorComponentTests.ParserHelpers.cs new file mode 100644 index 000000000..9d6d8beeb --- /dev/null +++ b/src/tests/Refit.GeneratorTests/GeneratorComponentTests.ParserHelpers.cs @@ -0,0 +1,121 @@ +// 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 Refit.Generator; + +namespace Refit.GeneratorTests; + +/// Direct unit tests for the source generator parser request helpers. +public static partial class GeneratorComponentTests +{ + /// 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; + + /// The enum value for JSON Lines body serialization. + private const int JsonLinesSerializationValue = 4; + + /// An unsupported body serialization enum value. + private const int UnsupportedSerializationValue = 99; + + /// 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.IsPathSupported(string.Empty)).IsTrue(); + await Assert.That(Parser.IsPathSupported(SimplePath)).IsTrue(); + + // A no-leading-slash path is supported: it resolves against the base under RFC 3986 and throws under legacy. + await Assert.That(Parser.IsPathSupported("relative")).IsTrue(); + await Assert.That(Parser.IsPathSupported("/{id}")).IsTrue(); + await Assert.That(Parser.IsPathSupported("/id}")).IsFalse(); + await Assert.That(Parser.IsPathSupported("/line\nbreak")).IsFalse(); + await Assert.That(Parser.IsPathSupported("/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(UrlEncodedSerializationMethod); + await Assert.That(Parser.GetBodySerializationMethodName(SerializedSerializationValue)).IsEqualTo("Serialized"); + await Assert.That(Parser.GetBodySerializationMethodName(JsonLinesSerializationValue)).IsEqualTo("JsonLines"); + await Assert.That(Parser.GetBodySerializationMethodName(UnsupportedSerializationValue)).IsEqualTo(string.Empty); + await Assert.That(Parser.IsSupportedInlineBody(ImmutableEquatableArray.Empty)).IsTrue(); + await Assert.That(Parser.IsSupportedInlineBody(new([CreateHeaderParameter()]))).IsTrue(); + await Assert.That(Parser.IsSupportedInlineBody(new([CreateBody(string.Empty)]))).IsFalse(); + await Assert.That(Parser.IsSupportedInlineBody(new([CreateBody(UrlEncodedSerializationMethod)]))).IsTrue(); + await Assert.That(Parser.IsSupportedInlineBody(new([CreateBody("Serialized")]))).IsTrue(); + await Assert.That(Parser.IsSupportedInlineBody(new([CreateBody("JsonLines")]))).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", null, ImmutableEquatableArray.Empty, 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", + null, + ImmutableEquatableArray.Empty, + RequestParameterKind.Body, + false, + string.Empty, + string.Empty, + serializationMethod, + BodyBufferMode.Buffered); + } +} diff --git a/src/tests/Refit.GeneratorTests/GeneratorComponentTests.cs b/src/tests/Refit.GeneratorTests/GeneratorComponentTests.cs index e9c932b3a..e29bb6074 100644 --- a/src/tests/Refit.GeneratorTests/GeneratorComponentTests.cs +++ b/src/tests/Refit.GeneratorTests/GeneratorComponentTests.cs @@ -5,11 +5,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; using Refit.Generator; @@ -19,8 +17,11 @@ 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 +public static partial class GeneratorComponentTests { + /// The URL-encoded body serialization method name shared across emitter and parser helper tests. + private const string UrlEncodedSerializationMethod = "UrlEncoded"; + /// Tests for . public class UniqueNameBuilderTests { @@ -30,6 +31,9 @@ public class UniqueNameBuilderTests /// The first generated collision suffix for . private const string FirstClientCollisionName = "client0"; + /// The generated local name used to test cross-builder name isolation. + private const string GeneratedLocalName = "local"; + /// Verifies that an unused name is returned unchanged. /// A task representing the asynchronous test. [Test] @@ -106,11 +110,11 @@ public async Task IndependentBuilders_DoNotShareReservations() public async Task IndependentBuilders_DoNotShareGeneratedNames() { var first = new UniqueNameBuilder(); - _ = first.New("local"); + _ = first.New(GeneratedLocalName); var second = new UniqueNameBuilder(); - await Assert.That(second.New("local")).IsEqualTo("local"); + await Assert.That(second.New(GeneratedLocalName)).IsEqualTo(GeneratedLocalName); } } @@ -181,555 +185,6 @@ public async Task Enumerator_YieldsAllValuesInOrder() 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"; - - /// The generated fully-qualified task type name. - private const string TaskTypeName = "global::System.Threading.Tasks.Task"; - - /// The generated string type name. - private const string StringTypeName = "string"; - - /// The populated part count used by join helper tests. - private const int PopulatedPartCount = 2; - - /// 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', '\u0085', '\u2028', '\u2029', 'x' }) - { - Emitter.AppendEscapedCharacter(builder, value); - } - - await Assert.That(builder.ToString()).IsEqualTo("""\\\"\0\a\b\f\n\r\t\v\u0085\u2028\u2029x"""); - } - - /// Verifies the string-literal escape lookup for special, line-terminator, and verbatim characters. - /// A task representing the asynchronous test. - [Test] - public async Task EscapeSequence_ReturnsEscapesForSpecialCharactersAndNullOtherwise() - { - await Assert.That(Emitter.EscapeSequence('\\')).IsEqualTo(@"\\"); - await Assert.That(Emitter.EscapeSequence('"')).IsEqualTo("\\\""); - await Assert.That(Emitter.EscapeSequence('\0')).IsEqualTo(@"\0"); - await Assert.That(Emitter.EscapeSequence('\a')).IsEqualTo(@"\a"); - await Assert.That(Emitter.EscapeSequence('\b')).IsEqualTo(@"\b"); - await Assert.That(Emitter.EscapeSequence('\f')).IsEqualTo(@"\f"); - await Assert.That(Emitter.EscapeSequence('\n')).IsEqualTo(@"\n"); - await Assert.That(Emitter.EscapeSequence('\r')).IsEqualTo(@"\r"); - await Assert.That(Emitter.EscapeSequence('\t')).IsEqualTo(@"\t"); - await Assert.That(Emitter.EscapeSequence('\v')).IsEqualTo(@"\v"); - await Assert.That(Emitter.EscapeSequence('\u0085')).IsEqualTo(@"\u0085"); - await Assert.That(Emitter.EscapeSequence('\u2028')).IsEqualTo(@"\u2028"); - await Assert.That(Emitter.EscapeSequence('\u2029')).IsEqualTo(@"\u2029"); - await Assert.That(Emitter.EscapeSequence('x')).IsNull(); - } - - /// Verifies the rendered length of a quoted literal accounts for escapes and the null keyword. - /// A task representing the asynchronous test. - [Test] - public async Task LiteralOrNullLength_AccountsForEscapesAndNull() - { - const int NullLength = 4; - const int PlainQuotedLength = 5; - - // '"' wrapper (2) + 'a' (1) + '"' escape (2) + U+2028 escape (6) = 11. - const int EscapedLength = 11; - - await Assert.That(Emitter.LiteralOrNullLength(null)).IsEqualTo(NullLength); - await Assert.That(Emitter.LiteralOrNullLength("abc")).IsEqualTo(PlainQuotedLength); - await Assert.That(Emitter.LiteralOrNullLength("a\"\u2028")).IsEqualTo(EscapedLength); - } - - /// Verifies the decimal length helper for zero, single-digit, and multi-digit values. - /// A task representing the asynchronous test. - [Test] - public async Task Int32Length_HandlesZeroAndMultipleDigits() - { - const int SingleDigit = 1; - const int ThreeDigits = 3; - const int PositiveThreeDigitValue = 123; - - await Assert.That(Emitter.Int32Length(0)).IsEqualTo(SingleDigit); - await Assert.That(Emitter.Int32Length(SingleDigit)).IsEqualTo(SingleDigit); - await Assert.That(Emitter.Int32Length(PositiveThreeDigitValue)).IsEqualTo(ThreeDigits); - } - - /// Verifies body buffering and streaming expressions for all supported modes. - /// A task representing the asynchronous test. - [Test] - public async Task BodyExpressionHelpers_HandleBufferModes() - { - const string settings = "refitSettings"; - 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, settings)).IsEqualTo(FalseLiteral); - await Assert.That(Emitter.BuildBufferBodyExpression(settingsBody, settings)).IsEqualTo($"{settings}.Buffered"); - await Assert.That(Emitter.BuildBufferBodyExpression(bufferedBody, settings)).IsEqualTo(TrueLiteral); - await Assert.That(Emitter.BuildBufferBodyExpression(streamingBody, settings)).IsEqualTo(FalseLiteral); - await Assert.That(Emitter.BuildBufferBodyExpression(noneBody, settings)).IsEqualTo(FalseLiteral); - await Assert.That(Emitter.BuildStreamBodyExpression(settingsBody, settings)).IsEqualTo($"!{settings}.Buffered"); - await Assert.That(Emitter.BuildStreamBodyExpression(bufferedBody, settings)).IsEqualTo(FalseLiteral); - await Assert.That(Emitter.BuildStreamBodyExpression(streamingBody, settings)).IsEqualTo(TrueLiteral); - await Assert.That(Emitter.BuildStreamBodyExpression(noneBody, settings)).IsEqualTo(FalseLiteral); - await Assert.That(Emitter.BuildStreamBodyExpression(urlEncodedBody, settings)).IsEqualTo(FalseLiteral); - - // With a different settings local name, the expression follows it (collision-avoidance threading). - await Assert.That(Emitter.BuildBufferBodyExpression(settingsBody, "renamed")).IsEqualTo("renamed.Buffered"); - await Assert.That(Emitter.BuildStreamBodyExpression(settingsBody, "renamed")).IsEqualTo("!renamed.Buffered"); - } - - /// 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(static () => 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(static () => 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 BuildMethodOpening_QualifiesExplicitInterfaceMethods() - { - var method = new MethodModel( - "Ping", - TaskTypeName, - "RefitGeneratorTest.IBase", - "Ping", - ReturnTypeInfo.AsyncVoid, - RequestModel.Empty, - ImmutableEquatableArray.Empty, - ImmutableEquatableArray.Empty, - false); - - var source = Emitter.BuildMethodOpening(method, true, true, supportsNullable: true, isAsync: true); - - await Assert.That(source) - .Contains("async global::System.Threading.Tasks.Task global::RefitGeneratorTest.IBase.Ping("); - } - - /// Verifies emitted XML documentation text escapes special characters. - /// A task representing the asynchronous test. - [Test] - public async Task ToXmlDocumentationText_EscapesSpecialCharacters() => - await Assert.That(Emitter.ToXmlDocumentationTextForTesting("A&B")).IsEqualTo("A&B<C>"); - - /// Verifies generated file headers for analyzer-enabled generated code tests. - /// A task representing the asynchronous test. - [Test] - public async Task BuildGeneratedFileHeader_SupportsNonGeneratedMarkers() - { - var noneHeader = Emitter.BuildGeneratedFileHeaderForTesting(Nullability.None, emitGeneratedCodeMarkers: false); - var nullableHeader = Emitter.BuildGeneratedFileHeaderForTesting(Nullability.Enabled, emitGeneratedCodeMarkers: false); - - await Assert.That(noneHeader).Contains("ReactiveUI and Contributors"); - await Assert.That(noneHeader).DoesNotContain("#nullable"); - await Assert.That(nullableHeader).Contains("#nullable enable annotations"); - } - - /// Verifies generated settings factories require at least one inline-capable Refit method. - /// A task representing the asynchronous test. - [Test] - public async Task CanUseGeneratedSettingsFactory_RejectsEmptyInterfaces() - { - var emptyModel = CreateInterfaceModel( - ImmutableEquatableArray.Empty, - ImmutableEquatableArray.Empty); - var inlineModel = CreateInterfaceModel( - new([CreateRefitMethod(canGenerateInline: true)]), - ImmutableEquatableArray.Empty); - - await Assert.That(Emitter.CanUseGeneratedSettingsFactoryForTesting(emptyModel)).IsFalse(); - await Assert.That(Emitter.CanUseGeneratedSettingsFactoryForTesting(inlineModel)).IsTrue(); - } - - /// Verifies parameter type-list helpers handle empty and populated parameter lists. - /// A task representing the asynchronous test. - [Test] - public async Task BuildParameterTypeList_HandlesEmptyAndPopulatedParameters() - { - var parameters = new ImmutableEquatableArray( - [ - new("first", StringTypeName, false, false), - new("second", "global::System.Int32", false, false) - ]); - - await Assert.That(Emitter.BuildParameterTypeListForTesting(ImmutableEquatableArray.Empty)) - .IsEqualTo(string.Empty); - await Assert.That(Emitter.BuildParameterTypeListForTesting(parameters)) - .IsEqualTo("typeof(string), typeof(global::System.Int32)"); - } - - /// Verifies source fragment joining avoids extra separators for empty input. - /// A task representing the asynchronous test. - [Test] - public async Task JoinParts_HandlesEmptyAndPopulatedParts() - { - await Assert.That(Emitter.JoinPartsForTesting([], 0, ", ")).IsEqualTo(string.Empty); - await Assert.That(Emitter.JoinPartsForTesting(["a", "b", "ignored"], PopulatedPartCount, ", ")).IsEqualTo("a, b"); - } - - /// Verifies candidate method combining preserves standard and custom methods. - /// A task representing the asynchronous test. - [Test] - public async Task CombineCandidateMethods_CombinesStandardAndCustomMethods() - { - var standard = ParseMethod("Standard", "[Get(\"/standard\")]"); - var custom = ParseMethod("Custom", "[Custom(\"CUSTOM\", \"/custom\")]"); - - var result = InterfaceStubGeneratorV2.CombineCandidateMethodsForTesting( - ([standard], [custom])); - - var names = result.Select(static method => method.Identifier.ValueText).ToArray(); - - await Assert.That(names.Length).IsEqualTo(PopulatedPartCount); - await Assert.That(names[0]).IsEqualTo("Standard"); - await Assert.That(names[1]).IsEqualTo("Custom"); - } - - /// Verifies standard HTTP method attribute detection handles suffixes and qualified names. - /// A task representing the asynchronous test. - [Test] - public async Task IsStandardHttpMethodAttributeName_HandlesQualifiedAndAliasNames() - { - await Assert.That(InterfaceStubGeneratorV2.IsStandardHttpMethodAttributeNameForTesting(ParseName(nameof(GetAttribute)))).IsTrue(); - await Assert.That(InterfaceStubGeneratorV2.IsStandardHttpMethodAttributeNameForTesting(ParseName("Refit.Post"))).IsTrue(); - await Assert.That(InterfaceStubGeneratorV2.IsStandardHttpMethodAttributeNameForTesting(ParseName("global::PutAttribute"))).IsTrue(); - await Assert.That(InterfaceStubGeneratorV2.IsStandardHttpMethodAttributeNameForTesting(ParseName("global::Refit.PutAttribute"))).IsTrue(); - await Assert.That(InterfaceStubGeneratorV2.IsStandardHttpMethodAttributeNameForTesting(ParseName("GetAttribute"))).IsFalse(); - await Assert.That(InterfaceStubGeneratorV2.IsStandardHttpMethodAttributeNameForTesting(ParseName("Custom"))).IsFalse(); - } - - /// Verifies an explicitly-implemented Refit method emits an explicit interface prefix on both paths. - /// A task representing the asynchronous test. - [Test] - public async Task BuildRefitMethod_EmitsExplicitInterfaceForExplicitMethod() - { - var explicitReflective = CreateRefitMethod(canGenerateInline: false) with { IsExplicitInterface = true }; - var explicitInline = CreateRefitMethod(canGenerateInline: true) with { IsExplicitInterface = true }; - var model = CreateInterfaceModel( - new([explicitReflective]), - ImmutableEquatableArray.Empty); - - var reflective = Emitter.BuildRefitMethodForTesting( - explicitReflective, - isTopLevel: true, - model, - new(), - "_requestBuilder", - "_settings"); - var inline = Emitter.BuildRefitMethodForTesting( - explicitInline, - isTopLevel: true, - model, - new(), - "_requestBuilder", - "_settings"); - - await Assert.That(reflective).Contains("global::RefitGeneratorTest.IGeneratedClient.Get"); - await Assert.That(inline).Contains("global::RefitGeneratorTest.IGeneratedClient.Get"); - } - - /// Verifies constraint emission suppresses override-incompatible keywords for explicit implementations. - /// A task representing the asynchronous test. - [Test] - public async Task HasConstraintKeywords_HandlesOverrideAndNonOverridePaths() - { - // class/struct are always emitted; unmanaged/notnull/new/textual are suppressed on overrides/explicit impls. - await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.Class), true)).IsTrue(); - await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.Struct), true)).IsTrue(); - await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.Unmanaged), true)).IsFalse(); - await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.NotNull), true)).IsFalse(); - await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.New), true)).IsFalse(); - await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.None, "global::System.IDisposable"), true)).IsFalse(); - - await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.Unmanaged), false)).IsTrue(); - await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.NotNull), false)).IsTrue(); - await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.New), false)).IsTrue(); - await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.None, "global::System.IDisposable"), false)).IsTrue(); - await Assert.That(Emitter.HasConstraintKeywordsForTesting(Constraint(KnownTypeConstraint.None), false)).IsFalse(); - } - - /// Creates a type-parameter constraint model. - /// The well-known constraint flags. - /// Additional textual constraints. - /// The type constraint. - private static TypeConstraint Constraint(KnownTypeConstraint known, params string[] extra) - { - var textual = extra.Length == 0 - ? ImmutableEquatableArray.Empty - : new(extra); - return new("T", "T", known, textual); - } - - /// 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", StringTypeName, null, ImmutableEquatableArray.Empty, 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); - - /// Creates an interface model for direct emitter helper tests. - /// The directly declared Refit methods. - /// The inherited Refit methods. - /// The interface model. - private static InterfaceModel CreateInterfaceModel( - ImmutableEquatableArray refitMethods, - ImmutableEquatableArray derivedRefitMethods) => - new( - "RefitInternalGenerated.PreserveAttribute", - "IGeneratedClient.g.cs", - "IGeneratedClient", - "RefitGeneratorTest", - "public partial class IGeneratedClient", - "RefitGeneratorTest.IGeneratedClient", - string.Empty, - GeneratedRequestBuilding: true, - EmitGeneratedCodeMarkers: true, - SupportsNullable: true, - SupportsStaticLambdas: true, - ImmutableEquatableArray.Empty, - ImmutableEquatableArray.Empty, - ImmutableEquatableArray.Empty, - ImmutableEquatableArray.Empty, - refitMethods, - derivedRefitMethods, - Nullability.Enabled, - false); - - /// Creates a Refit method model for direct emitter helper tests. - /// Whether the request can be generated inline. - /// The method model. - private static MethodModel CreateRefitMethod(bool canGenerateInline) => - new( - "Get", - TaskTypeName, - "RefitGeneratorTest.IGeneratedClient", - "Get", - ReturnTypeInfo.AsyncVoid, - new( - "GET", - "/", - TaskTypeName, - "global::System.Threading.Tasks.Task", - false, - true, - canGenerateInline, - ImmutableEquatableArray.Empty, - ImmutableEquatableArray.Empty), - ImmutableEquatableArray.Empty, - ImmutableEquatableArray.Empty, - false); - - /// Parses a method declaration for syntax helper tests. - /// The method name. - /// The attribute source. - /// The method declaration syntax. - private static MethodDeclarationSyntax ParseMethod(string name, string attribute) - { - var compilationUnit = SyntaxFactory.ParseCompilationUnit( - $"public interface I {{ {attribute} void {name}(); }}"); - return compilationUnit.DescendantNodes().OfType().Single(); - } - - /// Parses an attribute name for syntax helper tests. - /// The name source. - /// The parsed name syntax. - private static NameSyntax ParseName(string name) => - SyntaxFactory.ParseName(name); - } - - /// 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; - - /// The enum value for JSON Lines body serialization. - private const int JsonLinesSerializationValue = 4; - - /// An unsupported body serialization enum value. - private const int UnsupportedSerializationValue = 99; - - /// 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.IsPathSupported(string.Empty)).IsTrue(); - await Assert.That(Parser.IsPathSupported(SimplePath)).IsTrue(); - await Assert.That(Parser.IsPathSupported("relative")).IsFalse(); - await Assert.That(Parser.IsPathSupported("/{id}")).IsTrue(); - await Assert.That(Parser.IsPathSupported("/id}")).IsFalse(); - await Assert.That(Parser.IsPathSupported("/line\nbreak")).IsFalse(); - await Assert.That(Parser.IsPathSupported("/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(JsonLinesSerializationValue)).IsEqualTo("JsonLines"); - await Assert.That(Parser.GetBodySerializationMethodName(UnsupportedSerializationValue)).IsEqualTo(string.Empty); - await Assert.That(Parser.IsSupportedInlineBody(ImmutableEquatableArray.Empty)).IsTrue(); - await Assert.That(Parser.IsSupportedInlineBody(new([CreateHeaderParameter()]))).IsTrue(); - await Assert.That(Parser.IsSupportedInlineBody(new([CreateBody(string.Empty)]))).IsFalse(); - await Assert.That(Parser.IsSupportedInlineBody(new([CreateBody("UrlEncoded")]))).IsTrue(); - await Assert.That(Parser.IsSupportedInlineBody(new([CreateBody("Serialized")]))).IsTrue(); - await Assert.That(Parser.IsSupportedInlineBody(new([CreateBody("JsonLines")]))).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", null, ImmutableEquatableArray.Empty, 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", - null, - ImmutableEquatableArray.Empty, - RequestParameterKind.Body, - false, - string.Empty, - string.Empty, - serializationMethod, - BodyBufferMode.Buffered); - } - /// Tests for the ITypeSymbol generator extension helpers. public class ITypeSymbolExtensionsTests { diff --git a/src/tests/Refit.GeneratorTests/InterfaceMemberGenerationCoverageTests.cs b/src/tests/Refit.GeneratorTests/InterfaceMemberGenerationCoverageTests.cs new file mode 100644 index 000000000..2ce824cc7 --- /dev/null +++ b/src/tests/Refit.GeneratorTests/InterfaceMemberGenerationCoverageTests.cs @@ -0,0 +1,281 @@ +// 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; + +/// Generation coverage for interface-member partitioning, property emission, inherited Refit methods, and +/// attribute-namespace matching branches that only run for specific interface shapes. +public sealed class InterfaceMemberGenerationCoverageTests +{ + /// The generated implementation source hint name. + private const string Hint = "IGeneratedClient.g.cs"; + + /// The reflective request-builder call emitted by fallback paths. + private const string ReflectiveFallback = "BuildRestResultFuncForMethod"; + + /// Verifies emittable and non-emittable interface properties: a nullable-annotated abstract property is + /// implemented, while a static, a default-implemented, and (implicitly) an accessor-only member are skipped. + /// A task representing the asynchronous test. + [Test] + public async Task InterfacePropertyEmissionSkipsStaticAndDefaultImplementedMembers() + { + const string Source = + """ + #nullable enable + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/a")] + Task Get(); + + string? Name { get; set; } + + int Age { get; } + + static int Shared => 1; + + int Defaulted => 42; + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[Hint]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).Contains("public string? Name"); + await Assert.That(generated).Contains("public int Age"); + } + + /// Verifies an abstract indexer (a parameterized property) is not emitted as a stub property. + /// A task representing the asynchronous test. + [Test] + public async Task InterfaceIndexerPropertyIsNotEmitted() + { + const string Source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/a")] + Task Get(); + + int this[int index] { get; } + } + """; + + // The abstract indexer is skipped by the emitter, so the stub does not satisfy it; the generated file is still + // produced, which is all the property-partitioning branch under test needs. + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.GeneratedSources.ContainsKey(Hint)).IsTrue(); + await Assert.That(result.GeneratedSources[Hint]).DoesNotContain("this["); + } + + /// Verifies an interface inheriting a Refit method from a base interface generates both the inherited and + /// the directly-declared methods inline. + /// A task representing the asynchronous test. + [Test] + public async Task InheritedRefitMethodGeneratesInline() + { + const string Source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IBase + { + [Get("/base")] + Task BaseCall(); + } + + public interface IGeneratedClient : IBase + { + [Get("/derived")] + Task DerivedCall(); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[Hint]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveFallback); + await Assert.That(generated).Contains("BaseCall"); + await Assert.That(generated).Contains("DerivedCall"); + } + + /// Verifies an interface inheriting non-Refit members - a plain method, an emittable property, a static + /// property, and an event - alongside an inherited Refit method partitions each inherited member correctly. + /// A task representing the asynchronous test. + [Test] + public async Task InheritedNonRefitMembersPartitionAcrossKinds() + { + const string Source = + """ + using System; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IBase + { + [Get("/base")] + Task BaseCall(); + + void PlainMethod(); + + string Setting { get; } + + static int Shared => 1; + + event EventHandler Changed; + } + + public interface IGeneratedClient : IBase + { + [Get("/derived")] + Task DerivedCall(); + } + """; + + // The inherited event is not implemented by the stub, so compilation is not asserted; the branch under test is + // the inherited-member partition switch, which classifies the event, the property and the plain method. + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.GeneratedSources.ContainsKey(Hint)).IsTrue(); + await Assert.That(result.GeneratedSources[Hint]).Contains("BaseCall"); + } + + /// Verifies a derived interface that explicitly re-declares base methods - a Refit method and a non-Refit + /// method - resolves each through its explicit interface implementation. + /// A task representing the asynchronous test. + [Test] + public async Task ExplicitInterfaceReDeclarationsResolveThroughImplementations() + { + const string Source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IBase + { + [Get("/op")] + Task Op(); + + void Notify(); + } + + public interface IGeneratedClient : IBase + { + [Get("/own")] + Task Own(); + + [Get("/op2")] + Task IBase.Op(); + + abstract void IBase.Notify(); + } + """; + + // The explicit re-declarations exercise the explicit-interface-implementation parsing branches; the emitted + // stub for these reabstractions is not asserted to compile. + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.GeneratedSources.ContainsKey(Hint)).IsTrue(); + } + + /// Verifies a Refit method annotated with a user attribute whose type name matches a trim attribute but is + /// declared in a different namespace is not treated as a trim annotation, and still generates inline. + /// A task representing the asynchronous test. + [Test] + public async Task TrimAttributeLookalikeInForeignNamespaceIsIgnored() + { + const string Source = + """ + using System; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + [AttributeUsage(AttributeTargets.Method)] + public sealed class RequiresUnreferencedCodeAttribute : Attribute + { + public RequiresUnreferencedCodeAttribute(string message) { } + } + + [AttributeUsage(AttributeTargets.Method)] + public sealed class RequiresDynamicCodeAttribute : Attribute + { + public RequiresDynamicCodeAttribute(string message) { } + } + + public interface IGeneratedClient + { + [Get("/a")] + [RequiresUnreferencedCode("nope")] + [RequiresDynamicCode("nope")] + Task Get(); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[Hint]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveFallback); + } + + /// Verifies parameter attributes whose type names match Refit attributes but live outside the Refit + /// global-namespace are not treated as Refit attributes, so the method still generates inline. + /// A task representing the asynchronous test. + [Test] + public async Task RefitAttributeLookalikesInForeignNamespacesAreIgnored() + { + const string Source = + """ + using System; + using System.Threading.Tasks; + + namespace Outer.Refit + { + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class EncodedAttribute : Attribute { } + } + + namespace RefitGeneratorTest + { + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class HeaderAttribute : Attribute { } + + public interface IGeneratedClient + { + [global::Refit.Get("/a")] + System.Threading.Tasks.Task Get( + [RefitGeneratorTest.HeaderAttribute] int header, + [Outer.Refit.EncodedAttribute] int encoded); + } + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[Hint]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveFallback); + } +} diff --git a/src/tests/Refit.GeneratorTests/LiveCompilationTests.cs b/src/tests/Refit.GeneratorTests/LiveCompilationTests.cs index b32e8170f..4e1122eb7 100644 --- a/src/tests/Refit.GeneratorTests/LiveCompilationTests.cs +++ b/src/tests/Refit.GeneratorTests/LiveCompilationTests.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for full license information. using System.Diagnostics.CodeAnalysis; using System.Net; +using System.Reflection; using System.Text; namespace Refit.GeneratorTests; @@ -16,10 +17,6 @@ public sealed class LiveCompilationTests [RequiresUnreferencedCode("The live compilation test loads a generated assembly and reflects over generated types and members.")] public async Task GeneratedRequestBuilding_CanBeEmittedLoadedAndInvoked() { - const int HeaderId = 42; - const int PropertyTenantId = 17; - const int ParameterTenantId = 23; - var result = Fixture.RunGenerator( """ using System; @@ -52,55 +49,68 @@ Task Get( 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); + await AssertGeneratedRequestSentAsync(assembly); } } + /// Instantiates the generated client, invokes it, and asserts the captured request. + /// The compiled assembly holding the generated client. + /// A task representing the asynchronous assertions. + [RequiresUnreferencedCode("Reflects over generated types and members.")] + private static async Task AssertGeneratedRequestSentAsync(Assembly assembly) + { + const int HeaderId = 42; + const int PropertyTenantId = 17; + const int ParameterTenantId = 23; + + 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 { diff --git a/src/tests/Refit.GeneratorTests/ModuleInitializer.cs b/src/tests/Refit.GeneratorTests/ModuleInitializer.cs index 876b4f7e0..1837c0014 100644 --- a/src/tests/Refit.GeneratorTests/ModuleInitializer.cs +++ b/src/tests/Refit.GeneratorTests/ModuleInitializer.cs @@ -9,15 +9,10 @@ namespace Refit.GeneratorTests; /// Initializes Verify settings for the generator snapshot tests. public static class ModuleInitializer { - /// Carriage return, which Verify rejects in a verified file. - private const byte CarriageReturn = (byte)'\r'; - /// Configures the snapshot path and Verify source generator support. [ModuleInitializer] public static void Init() { - NormalizeSnapshotLineEndings(); - DerivePathInfo(static (file, _, type, method) => new(Path.Combine(Path.GetDirectoryName(file) ?? AppContext.BaseDirectory, "_snapshots"), type.Name, method.Name)); @@ -28,32 +23,4 @@ public static void Init() VerifySourceGenerators.Initialize(); VerifyDiffPlex.Initialize(OutputType.Compact); } - - /// Rewrites verified snapshots to LF endings before Verify reads them. - /// The path of this source file, supplied by the compiler. - /// - /// Verify throws if a verified file contains a carriage return, and offers no way to opt out. - /// A Windows checkout rewrites the LF stored in git to CRLF, so normalize on disk here rather - /// than depend on how the repository happens to be cloned. Bytes are rewritten in place so the - /// byte-order mark is preserved. - /// - private static void NormalizeSnapshotLineEndings([CallerFilePath] string sourceFile = "") - { - var directory = Path.Combine(Path.GetDirectoryName(sourceFile) ?? AppContext.BaseDirectory, "_snapshots"); - if (!Directory.Exists(directory)) - { - return; - } - - foreach (var file in Directory.EnumerateFiles(directory, "*.verified.*")) - { - var bytes = File.ReadAllBytes(file); - if (Array.IndexOf(bytes, CarriageReturn) < 0) - { - continue; - } - - File.WriteAllBytes(file, [.. bytes.Where(static b => b != CarriageReturn)]); - } - } } diff --git a/src/tests/Refit.GeneratorTests/MultipartRequestBuildingLiveTests.cs b/src/tests/Refit.GeneratorTests/MultipartRequestBuildingLiveTests.cs new file mode 100644 index 000000000..32c2d3d88 --- /dev/null +++ b/src/tests/Refit.GeneratorTests/MultipartRequestBuildingLiveTests.cs @@ -0,0 +1,380 @@ +// 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; + +/// +/// Compiles, loads and invokes generated multipart request building, asserting the produced +/// matches the reflection request builder for every part type +/// that generates inline: same boundary, same part count, and for each part the same Content-Disposition name +/// and file name, Content-Type, and body bytes. +/// +public sealed class MultipartRequestBuildingLiveTests +{ + /// A stable score used by the serialized DTO part scenario. + private const int ReportScore = 42; + + /// The bytes uploaded by the primary binary part scenarios. + private static readonly byte[] SampleBytes = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + /// A second, distinct byte payload used by multi-part scenarios. + private static readonly byte[] OtherBytes = [200, 150, 100, 50, 25]; + + /// A stable identifier used by the formattable-part scenario. + private static readonly Guid SampleId = new("6f9619ff-8b86-d011-b42d-00cf4fc964ff"); + + /// A stable timestamp used by the formattable-part scenario. + private static readonly DateTimeOffset SampleTimestamp = new(2026, 7, 4, 12, 30, 0, TimeSpan.Zero); + + /// Verifies generated stream and binary parts match the reflection builder. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task StreamAndBinaryPartsMatchReflection() + { + using var harness = LiveMultipartHarness.Create(); + + await harness.AssertParityAsync("UploadStream", static () => [new MemoryStream(SampleBytes)]); + await harness.AssertParityAsync( + "UploadStreamPart", + static () => [new StreamPart(new MemoryStream(SampleBytes), "upload.bin", "application/octet-stream", "custom")]); + await harness.AssertParityAsync("UploadBytes", static () => [SampleBytes]); + await harness.AssertParityAsync("UploadBytesPart", static () => [new ByteArrayPart(SampleBytes, "data.bin")]); + } + + /// Verifies generated string and formattable parts match the reflection builder. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task TextAndFormattablePartsMatchReflection() + { + using var harness = LiveMultipartHarness.Create(); + + await harness.AssertParityAsync("UploadString", static () => ["hello world"]); + await harness.AssertParityAsync("UploadFormattable", static () => [SampleId, SampleTimestamp]); + await harness.AssertParityAsync("UploadCustomBoundary", static () => [OtherBytes]); + } + + /// Verifies generated file and enumerable parts match the reflection builder. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task FileAndEnumerablePartsMatchReflection() + { + using var harness = LiveMultipartHarness.Create(); + var first = harness.CreateTempFile(SampleBytes); + var second = harness.CreateTempFile(OtherBytes); + + await harness.AssertParityAsync("UploadFileInfoPart", () => [new FileInfoPart(new FileInfo(first), "doc.pdf")]); + await harness.AssertParityAsync("UploadFile", () => [new FileInfo(first)]); + await harness.AssertParityAsync( + "UploadFiles", + () => [new[] { new FileInfo(first), new FileInfo(second) }, new FileInfo(first)]); + await harness.AssertParityAsync( + "UploadStreamParts", + static () => + [ + new[] + { + new StreamPart(new MemoryStream(SampleBytes), "a.bin"), + new StreamPart(new MemoryStream(OtherBytes), "b.bin"), + }, + ]); + } + + /// Verifies generated serialized parts (a bool and a concrete (non-sealed) DTO) match the reflection builder, byte for byte + /// through the content serializer, mirroring its AddSerializedMultipartItem fallback. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task SerializedPartsMatchReflection() + { + using var harness = LiveMultipartHarness.Create(); + + await harness.AssertParityAsync("UploadFlag", static () => [true]); + await harness.AssertParityAsync( + "UploadReport", + () => [harness.CreateApiValue("Refit.LiveMultipart.Report", ("Title", "Q3"), ("Score", ReportScore))]); + } + + /// Verifies a header, request property and path parameter never become multipart parts. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task HeaderPropertyAndPathParametersAreNotParts() + { + using var harness = LiveMultipartHarness.Create(); + + // Only the StreamPart becomes a part; the path, header and property parameters must not. Content parity with + // the reflection builder (a single part) confirms none of them leaked into the multipart body. + await harness.AssertParityAsync( + "UploadWithHeaderPropertyPath", + static () => ["folder", "token-value", "trace-value", new StreamPart(new MemoryStream(SampleBytes), "x.bin")]); + } + + /// Hosts one compiled generated client plus the reflection builder for multipart parity assertions. + /// The collectible load context holding the compiled assembly. + /// The capturing message handler. + /// The HTTP client shared by both request paths. + /// The compiled Refit interface type. + /// The generated client instance. + /// The reflection request builder for the compiled interface. + private sealed class LiveMultipartHarness( + CollectibleAssemblyLoadContext context, + CapturingMultipartHandler handler, + HttpClient client, + Type interfaceType, + object generatedApi, + IRequestBuilder requestBuilder) : IDisposable + { + /// The base address the relative request URIs resolve against. + private const string BaseAddress = "https://example.test/base/"; + + /// The multipart interface compiled through the generator for every scenario. + private const string ApiSource = + """ + using System; + using System.Collections.Generic; + using System.IO; + using System.Threading.Tasks; + using Refit; + + namespace Refit.LiveMultipart; + + // Not sealed: exercises the concrete (non-sealed) serialized part; the test value is not a subtype. + public class Report + { + public string? Title { get; set; } + + public int Score { get; set; } + } + + public interface ILiveMultipartApi + { + [Multipart] + [Post("/upload")] + Task UploadFlag([AliasAs("flag")] bool flag); + + [Multipart] + [Post("/upload")] + Task UploadReport([AliasAs("report")] Report report); + + [Multipart] + [Post("/upload")] + Task UploadStream(Stream stream); + + [Multipart] + [Post("/upload")] + Task UploadStreamPart(StreamPart part); + + [Multipart] + [Post("/upload")] + Task UploadBytes(byte[] bytes); + + [Multipart] + [Post("/upload")] + Task UploadBytesPart([AliasAs("blob")] ByteArrayPart part); + + [Multipart] + [Post("/upload")] + Task UploadString([AliasAs("alias")] string value); + + [Multipart] + [Post("/upload")] + Task UploadFileInfoPart(FileInfoPart part); + + [Multipart] + [Post("/upload")] + Task UploadFile(FileInfo file); + + [Multipart] + [Post("/upload")] + Task UploadFiles(IEnumerable files, FileInfo extra); + + [Multipart] + [Post("/upload")] + Task UploadStreamParts(IEnumerable parts); + + [Multipart] + [Post("/upload")] + Task UploadFormattable([AliasAs("id")] Guid id, [AliasAs("at")] DateTimeOffset at); + + [Multipart("----CustomBoundary")] + [Post("/upload")] + Task UploadCustomBoundary(byte[] bytes); + + [Multipart] + [Post("/upload/{folder}")] + Task UploadWithHeaderPropertyPath( + string folder, + [Header("X-Token")] string token, + [Property("Trace")] string trace, + [AliasAs("file")] StreamPart part); + } + """; + + /// The temporary files created for file-part scenarios, deleted on disposal. + private readonly List _tempFiles = []; + + /// Compiles the multipart interface and creates the generated and reflection clients. + /// The live harness. + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + public static LiveMultipartHarness Create() + { + var result = Fixture.RunGenerator(ApiSource, generatedRequestBuilding: true); + if (!result.CompilesWithoutErrors) + { + throw new InvalidOperationException( + "Generated compilation failed: " + string.Join(Environment.NewLine, result.CompilationErrors)); + } + + var (assembly, loadContext) = Fixture.EmitAndLoad(result); + var interfaceType = assembly.GetType("Refit.LiveMultipart.ILiveMultipartApi", throwOnError: true)!; + var generatedType = assembly + .GetTypes() + .Single(type => type.IsClass && interfaceType.IsAssignableFrom(type)); + + var handler = new CapturingMultipartHandler(); + var client = new HttpClient(handler) { BaseAddress = new(BaseAddress) }; + var requestBuilder = RequestBuilder.ForType(interfaceType, new RefitSettings()); + var generatedApi = Activator.CreateInstance(generatedType, [client, requestBuilder])!; + return new(loadContext, handler, client, interfaceType, generatedApi, requestBuilder); + } + + /// Creates an instance of a compiled scenario type with the given properties assigned. + /// The compiled type's full name. + /// The property name/value pairs to assign. + /// The created instance. + [RequiresUnreferencedCode("Reflects over generated types and members.")] + public object CreateApiValue(string typeName, params (string Name, object? Value)[] properties) + { + var type = interfaceType.Assembly.GetType(typeName, throwOnError: true)!; + var instance = Activator.CreateInstance(type)!; + foreach (var (name, value) in properties) + { + type.GetProperty(name)!.SetValue(instance, value); + } + + return instance; + } + + /// Creates a temporary file with the given bytes, tracked for cleanup on disposal. + /// The file content. + /// The temporary file path. + public string CreateTempFile(byte[] bytes) + { + var path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + File.WriteAllBytes(path, bytes); + _tempFiles.Add(path); + return path; + } + + /// Invokes a method through both request paths and asserts the multipart contents are identical. + /// The interface method name. + /// Produces a fresh argument set for each path, so single-use streams are not shared. + /// A task representing the asynchronous assertion. + [RequiresUnreferencedCode("Reflects over generated types and members.")] + [RequiresDynamicCode("Builds the reflection request delegate for parity comparison.")] + public async Task AssertParityAsync(string methodName, Func argsFactory) + { + var generatedTask = (Task)interfaceType.GetMethod(methodName)!.Invoke(generatedApi, argsFactory())!; + await generatedTask.ConfigureAwait(false); + var generated = handler.TakeSnapshot(); + + var reflectionFunc = requestBuilder.BuildRestResultFuncForMethod(methodName); + var reflectionTask = (Task)reflectionFunc(client, argsFactory())!; + await reflectionTask.ConfigureAwait(false); + var reflection = handler.TakeSnapshot(); + + await Assert.That(generated.Boundary).IsEqualTo(reflection.Boundary); + await Assert.That(generated.Parts.Count).IsEqualTo(reflection.Parts.Count); + for (var i = 0; i < reflection.Parts.Count; i++) + { + var generatedPart = generated.Parts[i]; + var reflectionPart = reflection.Parts[i]; + await Assert.That(generatedPart.Name).IsEqualTo(reflectionPart.Name); + await Assert.That(generatedPart.FileName).IsEqualTo(reflectionPart.FileName); + await Assert.That(generatedPart.ContentType).IsEqualTo(reflectionPart.ContentType); + await Assert.That(generatedPart.Body.SequenceEqual(reflectionPart.Body)).IsTrue(); + } + } + + /// + public void Dispose() + { + client.Dispose(); + handler.Dispose(); + context.Dispose(); + foreach (var path in _tempFiles) + { + File.Delete(path); + } + } + } + + /// Captures each outgoing multipart request, snapshotting its boundary and parts before disposal. + private sealed class CapturingMultipartHandler : HttpMessageHandler + { + /// The snapshot captured for the most recent request. + private MultipartSnapshot? _snapshot; + + /// Takes the snapshot captured for the last request, clearing the slot. + /// The captured multipart snapshot. + public MultipartSnapshot TakeSnapshot() + { + var snapshot = _snapshot ?? throw new InvalidOperationException("No multipart content was captured."); + _snapshot = null; + return snapshot; + } + + /// + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var multipart = (MultipartContent)request.Content!; + var boundary = multipart.Headers.ContentType!.Parameters + .Single(static parameter => parameter.Name == "boundary").Value; + + var parts = new List(); + foreach (var part in multipart) + { + var disposition = part.Headers.ContentDisposition; + var body = await part.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + parts.Add(new( + disposition?.Name, + disposition?.FileName, + part.Headers.ContentType?.ToString(), + body)); + } + + _snapshot = new(boundary, parts); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("\"done\"", Encoding.UTF8, "application/json"), + }; + } + } + + /// The captured boundary and ordered parts of one multipart request. + /// The multipart boundary value from the content type header. + /// The captured parts in order. + private sealed record MultipartSnapshot(string? Boundary, List Parts); + + /// The captured metadata and body of one multipart part. + /// The Content-Disposition form-field name. + /// The Content-Disposition file name, or null. + /// The part's Content-Type, or null. + /// The part's body bytes. + private sealed record CapturedPart(string? Name, string? FileName, string? ContentType, byte[] Body); +} diff --git a/src/tests/Refit.GeneratorTests/ObservableReturnRequestBuildingLiveTests.cs b/src/tests/Refit.GeneratorTests/ObservableReturnRequestBuildingLiveTests.cs new file mode 100644 index 000000000..fcec25473 --- /dev/null +++ b/src/tests/Refit.GeneratorTests/ObservableReturnRequestBuildingLiveTests.cs @@ -0,0 +1,197 @@ +// 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; + +/// +/// Compiles, loads and subscribes generated request building, asserting the cold +/// per-subscription semantics and parity with the reflection request builder. +/// +public sealed class ObservableReturnRequestBuildingLiveTests +{ + /// The request count expected after subscribing to the cold observable a second time. + private const int TwoSubscriptionRequestCount = 2; + + /// Verifies a cold generated observable rebuilds and re-sends per subscription, never reusing a disposed + /// request, and that the request it sends matches the reflection request builder. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task ColdObservableResendsPerSubscriptionAndMatchesReflection() + { + using var harness = ObservableHarness.Create(); + + var observable = harness.InvokeGenerated("Watch", ["42", "hello"]); + + // Cold: no request is sent until the first subscription. + await Assert.That(harness.RequestCount).IsEqualTo(0); + + var first = await harness.SubscribeAndCaptureAsync(observable); + await Assert.That(harness.RequestCount).IsEqualTo(1); + + // A second subscription rebuilds and re-sends over a fresh request instead of throwing on a disposed one. + var second = await harness.SubscribeAndCaptureAsync(observable); + await Assert.That(harness.RequestCount).IsEqualTo(TwoSubscriptionRequestCount); + await Assert.That(second.RequestUri!.AbsoluteUri).IsEqualTo(first.RequestUri!.AbsoluteUri); + + var reflection = await harness.SubscribeReflectionAsync("Watch", ["42", "hello"]); + await Assert.That(first.Method).IsEqualTo(reflection.Method); + await Assert.That(first.RequestUri!.AbsoluteUri).IsEqualTo(reflection.RequestUri!.AbsoluteUri); + } + + /// Hosts one compiled generated observable client plus the reflection builder for parity assertions. + /// The collectible load context holding the compiled assembly. + /// The capturing message handler. + /// The HTTP client shared by both request paths. + /// The compiled Refit interface type. + /// The generated client instance. + /// The reflection request builder for the compiled interface. + private sealed class ObservableHarness( + CollectibleAssemblyLoadContext context, + CapturingHandler handler, + HttpClient client, + Type interfaceType, + object generatedApi, + IRequestBuilder requestBuilder) : IDisposable + { + /// The base address the relative request URIs resolve against. + private const string BaseAddress = "https://example.test/base/"; + + /// The interface source compiled through the generator for every scenario. + private const string ApiSource = + """ + using System; + using Refit; + + namespace Refit.LiveObservable; + + public interface IObservableApi + { + [Get("/items/{id}")] + IObservable Watch(string id, string q); + } + """; + + /// Gets the number of requests sent through the handler so far. + public int RequestCount => handler.RequestCount; + + /// Compiles the scenario interface and creates the generated and reflection clients. + /// The live harness. + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + public static ObservableHarness Create() + { + var result = Fixture.RunGenerator(ApiSource, generatedRequestBuilding: true); + if (!result.CompilesWithoutErrors) + { + throw new InvalidOperationException( + "Generated compilation failed: " + string.Join(Environment.NewLine, result.CompilationErrors)); + } + + var (assembly, loadContext) = Fixture.EmitAndLoad(result); + var interfaceType = assembly.GetType("Refit.LiveObservable.IObservableApi", throwOnError: true)!; + var generatedType = assembly + .GetTypes() + .Single(type => type.IsClass && interfaceType.IsAssignableFrom(type)); + + var handler = new CapturingHandler(); + var client = new HttpClient(handler) { BaseAddress = new(BaseAddress) }; + var requestBuilder = RequestBuilder.ForType(interfaceType, new RefitSettings()); + var generatedApi = Activator.CreateInstance(generatedType, [client, requestBuilder])!; + return new(loadContext, handler, client, interfaceType, generatedApi, requestBuilder); + } + + /// Invokes a method on the generated client, returning the cold observable it produces. + /// The interface method name. + /// The argument values. + /// The observable returned by the generated method. + [RequiresUnreferencedCode("Reflects over generated types and members.")] + public object InvokeGenerated(string methodName, object?[] args) => + interfaceType.GetMethod(methodName)!.Invoke(generatedApi, args)!; + + /// Subscribes to an observable, awaits its first notification and returns the captured request. + /// The observable to subscribe to. + /// The request captured for the subscription. + public async Task SubscribeAndCaptureAsync(object observable) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using (((IObservable)observable).Subscribe(new AwaitingObserver(completion))) + { + _ = await completion.Task.ConfigureAwait(false); + } + + return handler.TakeLastRequest(); + } + + /// Builds and subscribes the reflection observable for the same method, returning its request. + /// The interface method name. + /// The argument values. + /// The request captured for the reflection subscription. + [RequiresUnreferencedCode("Reflects over generated types and members.")] + [RequiresDynamicCode("Builds the reflection request delegate for parity comparison.")] + public Task SubscribeReflectionAsync(string methodName, object?[] args) + { + var reflectionFunc = requestBuilder.BuildRestResultFuncForMethod(methodName); + return SubscribeAndCaptureAsync(reflectionFunc(client, args!)!); + } + + /// + public void Dispose() + { + client.Dispose(); + handler.Dispose(); + context.Dispose(); + } + } + + /// Completes a task from the first notification of a single-value observable. + /// The completion source signalled by the first notification. + private sealed class AwaitingObserver(TaskCompletionSource completion) : IObserver + { + /// + public void OnCompleted() => completion.TrySetResult(null); + + /// + public void OnError(Exception error) => completion.TrySetException(error); + + /// + public void OnNext(object? value) => completion.TrySetResult(value); + } + + /// Captures every outgoing request and returns a fixed JSON string response. + private sealed class CapturingHandler : HttpMessageHandler + { + /// The last request sent through the handler. + private HttpRequestMessage? _lastRequest; + + /// Gets the number of requests sent through the handler so far. + public int RequestCount { get; private set; } + + /// Takes the last captured request, clearing the slot. + /// The captured request. + public HttpRequestMessage TakeLastRequest() + { + var request = _lastRequest ?? throw new InvalidOperationException("No request was captured."); + _lastRequest = null; + return request; + } + + /// + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + _lastRequest = request; + RequestCount++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("\"done\"", Encoding.UTF8, "application/json") + }); + } + } +} diff --git a/src/tests/Refit.GeneratorTests/ParserCoverageTests.cs b/src/tests/Refit.GeneratorTests/ParserCoverageTests.cs index 9daac05e9..b3d7c2fec 100644 --- a/src/tests/Refit.GeneratorTests/ParserCoverageTests.cs +++ b/src/tests/Refit.GeneratorTests/ParserCoverageTests.cs @@ -15,6 +15,18 @@ namespace Refit.GeneratorTests; /// Focused tests for parser helper paths that are awkward to reach through snapshot tests. public sealed class ParserCoverageTests { + /// A minimal interface source with no Refit methods. + private const string UnusedInterfaceSource = "public interface IUnused { }"; + + /// The metadata name of the Refit HTTP method base attribute. + private const string HttpMethodAttributeMetadataName = "Refit.HttpMethodAttribute"; + + /// The metadata name of System.IFormattable. + private const string FormattableMetadataName = "System.IFormattable"; + + /// The metadata name of the sample interface used by inline-eligibility tests. + private const string SampleApiMetadataName = "RefitGeneratorTest.IApi"; + /// Verifies parser argument validation. /// A task representing the asynchronous test. [Test] @@ -35,7 +47,7 @@ await Assert.That( [Test] public async Task GenerateInterfaceStubsReportsMissingRefitReference() { - var syntaxTree = CSharpSyntaxTree.ParseText("public interface IUnused { }"); + var syntaxTree = CSharpSyntaxTree.ParseText(UnusedInterfaceSource); var compilation = CSharpCompilation.Create("no-refit", [syntaxTree]); var (diagnostics, model) = Parser.GenerateInterfaceStubs( @@ -57,7 +69,7 @@ public async Task GenerateInterfaceStubsReportsMissingRefitReference() [Test] public async Task GenerateInterfaceStubsSanitizesInternalNamespaceSegments() { - var syntaxTree = CSharpSyntaxTree.ParseText("public interface IUnused { }"); + var syntaxTree = CSharpSyntaxTree.ParseText(UnusedInterfaceSource); var compilation = CSharpCompilation.Create("no-refit", [syntaxTree]); var (_, leadingDotModel) = Parser.GenerateInterfaceStubs( @@ -268,6 +280,262 @@ public interface ILegacyClient await Assert.That(model.Interfaces.AsArray()[0].Nullability).IsEqualTo(Nullability.None); } + /// Verifies inline eligibility is denied for a method with no HTTP method attribute. + /// A task representing the asynchronous test. + [Test] + public async Task CanBuildRequestInlineRejectsMethodWithoutHttpAttribute() + { + var compilation = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText( + """ + using System.Threading.Tasks; + using Refit; + namespace RefitGeneratorTest; + public interface IApi { Task Plain(); } + """)); + var httpBase = compilation.GetTypeByMetadataName(HttpMethodAttributeMetadataName)!; + var formattable = compilation.GetTypeByMetadataName(FormattableMetadataName); + var method = compilation.GetTypeByMetadataName(SampleApiMetadataName)! + .GetMembers("Plain").OfType().First(); + + await Assert.That(Parser.CanBuildRequestInline(method, httpBase, formattable)).IsFalse(); + } + + /// Verifies inline return-shape classification for non-named and plain named return types. + /// A task representing the asynchronous test. + [Test] + public async Task CanBuildRequestInlineClassifiesNonNamedAndNamedReturnShapes() + { + var compilation = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText( + """ + using Refit; + namespace RefitGeneratorTest; + public interface IApi + { + [Get("/a")] int[] ArrayReturn(); + [Get("/b")] string StringReturn(); + } + """)); + var httpBase = compilation.GetTypeByMetadataName(HttpMethodAttributeMetadataName)!; + var formattable = compilation.GetTypeByMetadataName(FormattableMetadataName); + var api = compilation.GetTypeByMetadataName(SampleApiMetadataName)!; + var arrayReturn = api.GetMembers("ArrayReturn").OfType().First(); + var stringReturn = api.GetMembers("StringReturn").OfType().First(); + + // Neither is inline-eligible; the point is that classifying the non-named (array) and the plain named + // (string) declared result type both run here. + await Assert.That(Parser.CanBuildRequestInline(arrayReturn, httpBase, formattable)).IsFalse(); + await Assert.That(Parser.CanBuildRequestInline(stringReturn, httpBase, formattable)).IsFalse(); + } + + /// Verifies adapter discovery returns nothing when the adapter interface is unresolved. + /// A task representing the asynchronous test. + [Test] + public async Task DiscoverReturnTypeAdaptersReturnsEmptyWhenInterfaceUnresolved() + { + var compilation = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(UnusedInterfaceSource)); + + var adapters = Parser.DiscoverReturnTypeAdapters(compilation, null, CancellationToken.None); + + await Assert.That(adapters).IsEmpty(); + } + + /// Verifies the declared base name strips an explicit interface qualifier. + /// A task representing the asynchronous test. + [Test] + public async Task BuildDeclaredBaseNameStripsExplicitInterfaceQualifier() + { + var compilation = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText( + """ + namespace Explicit; + public interface IFoo { System.Threading.Tasks.Task Bar(); } + public class Impl : IFoo + { + System.Threading.Tasks.Task IFoo.Bar() => System.Threading.Tasks.Task.CompletedTask; + } + """)); + var explicitImplementation = compilation.GetTypeByMetadataName("Explicit.Impl")! + .GetMembers() + .OfType() + .First(static method => method.Name.Contains('.')); + + await Assert.That(Parser.BuildDeclaredBaseName(explicitImplementation)).IsEqualTo("Bar"); + } + + /// Verifies the Refit-method predicate rejects a null method symbol. + /// A task representing the asynchronous test. + [Test] + public async Task IsRefitMethodReturnsFalseForNullMethod() + { + var compilation = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(UnusedInterfaceSource)); + var httpMethodBase = compilation.GetTypeByMetadataName(HttpMethodAttributeMetadataName)!; + + await Assert.That(Parser.IsRefitMethod(null, httpMethodBase)).IsFalse(); + } + + /// Verifies the inline return-shape classifier resolves the IAsyncEnumerable<T> shape. + /// A task representing the asynchronous test. + [Test] + public async Task CanBuildRequestInlineClassifiesAsyncEnumerableReturnShape() + { + var compilation = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText( + """ + using System.Collections.Generic; + using Refit; + namespace RefitGeneratorTest; + public interface IApi { [Get("/a")] IAsyncEnumerable Enumerate(); } + """)); + var httpBase = compilation.GetTypeByMetadataName(HttpMethodAttributeMetadataName)!; + var formattable = compilation.GetTypeByMetadataName(FormattableMetadataName); + var enumerate = compilation.GetTypeByMetadataName(SampleApiMetadataName)! + .GetMembers("Enumerate").OfType().First(); + + await Assert.That(Parser.CanBuildRequestInline(enumerate, httpBase, formattable)).IsTrue(); + } + + /// Verifies interface collection captures a child that only inherits Refit methods and skips a candidate + /// interface with no Refit methods, while a property member on the child is ignored during base-method exclusion. + /// A task representing the asynchronous test. + [Test] + public async Task CollectRefitInterfacesHandlesInheritedAndEmptyCandidates() + { + const int ExpectedInterfaceCount = 2; + var syntaxTree = CSharpSyntaxTree.ParseText( + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IParent + { + [Get("/p")] + Task P(); + + void NonRefitBase(); + } + + public interface IChild : IParent + { + string Prop { get; } + } + + public interface IEmpty + { + void Plain(); + } + """); + 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, + "inherited", + generatedRequestBuilding: true, + emitGeneratedCodeMarkers: true, + candidateMethods, + candidateInterfaces, + CancellationToken.None); + + var interfaceNames = model.Interfaces.AsArray().Select(static i => i.InterfaceDisplayName).ToArray(); + + // IParent (declares a Refit method) and IChild (inherits one); IEmpty has none and is dropped. + await Assert.That(model.Interfaces.AsArray().Length).IsEqualTo(ExpectedInterfaceCount); + await Assert.That(interfaceNames).Contains(static name => name.Contains("IChild", StringComparison.Ordinal)); + await Assert.That(interfaceNames).DoesNotContain(static name => name.Contains("IEmpty", StringComparison.Ordinal)); + } + + /// Verifies the inline return-shape classifier resolves every recognized async wrapper and treats a + /// same-named type in a foreign namespace as a plain return shape. + /// A task representing the asynchronous test. + [Test] + public async Task CanBuildRequestInlineClassifiesAsyncReturnShapesAndForeignLookalikes() + { + var compilation = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText( + """ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace Foreign + { + public sealed class Task { } + public sealed class ValueTask { } + public interface IAsyncEnumerable { } + public interface IObservable { } + } + + namespace RefitGeneratorTest + { + public interface IApi + { + [Get("/a")] System.Threading.Tasks.Task AsyncVoid(); + [Get("/b")] System.Threading.Tasks.ValueTask ValueResult(); + [Get("/c")] System.IObservable Observe(); + [Get("/d")] Foreign.Task ForeignTask(); + [Get("/e")] Foreign.ValueTask ForeignValueTask(); + [Get("/f")] Foreign.IAsyncEnumerable ForeignAsyncEnumerable(); + [Get("/g")] Foreign.IObservable ForeignObservable(); + } + } + """)); + var httpBase = compilation.GetTypeByMetadataName(HttpMethodAttributeMetadataName)!; + var formattable = compilation.GetTypeByMetadataName(FormattableMetadataName); + var api = compilation.GetTypeByMetadataName(SampleApiMetadataName)!; + + static IMethodSymbol Method(INamedTypeSymbol type, string name) => + type.GetMembers(name).OfType().First(); + + await Assert.That(Parser.CanBuildRequestInline(Method(api, "AsyncVoid"), httpBase, formattable)).IsTrue(); + await Assert.That(Parser.CanBuildRequestInline(Method(api, "ValueResult"), httpBase, formattable)).IsTrue(); + await Assert.That(Parser.CanBuildRequestInline(Method(api, "Observe"), httpBase, formattable)).IsTrue(); + await Assert.That(Parser.CanBuildRequestInline(Method(api, "ForeignTask"), httpBase, formattable)).IsFalse(); + await Assert.That(Parser.CanBuildRequestInline(Method(api, "ForeignValueTask"), httpBase, formattable)).IsFalse(); + await Assert.That(Parser.CanBuildRequestInline(Method(api, "ForeignAsyncEnumerable"), httpBase, formattable)).IsFalse(); + await Assert.That(Parser.CanBuildRequestInline(Method(api, "ForeignObservable"), httpBase, formattable)).IsFalse(); + } + + /// Verifies the interface nullable-context flag is set from the compilation-level nullable option even when + /// the method's own span reports a disabled context. + /// A task representing the asynchronous test. + [Test] + public async Task GenerateInterfaceStubsHonorsCompilationLevelNullableContext() + { + var syntaxTree = CSharpSyntaxTree.ParseText( + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/a")] + Task Get(); + } + """); + var root = await syntaxTree.GetRootAsync(); + var candidateMethods = root.DescendantNodes().OfType().ToImmutableArray(); + var candidateInterfaces = root.DescendantNodes().OfType().ToImmutableArray(); + var compilation = (CSharpCompilation)Fixture.CreateLibrary(syntaxTree) + .WithOptions(new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable)); + + var (_, model) = Parser.GenerateInterfaceStubs( + compilation, + "nullable", + generatedRequestBuilding: true, + emitGeneratedCodeMarkers: true, + candidateMethods, + candidateInterfaces, + CancellationToken.None); + + await Assert.That(model.Interfaces.AsArray()[0].Nullability).IsNotEqualTo(Nullability.None); + } + /// Analyzer config options backed by a dictionary for direct helper tests. /// The option values. private sealed class DictionaryAnalyzerConfigOptions(IReadOnlyDictionary values) diff --git a/src/tests/Refit.GeneratorTests/PathObjectBindingGenerationTests.cs b/src/tests/Refit.GeneratorTests/PathObjectBindingGenerationTests.cs new file mode 100644 index 000000000..2753f1ccc --- /dev/null +++ b/src/tests/Refit.GeneratorTests/PathObjectBindingGenerationTests.cs @@ -0,0 +1,314 @@ +// 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; + +/// Verifies dotted {param.Property} path binding, including how residual properties flatten into the query. +public sealed class PathObjectBindingGenerationTests +{ + /// The generated client hint name. + private const string GeneratedClientHintName = "IGeneratedClient.g.cs"; + + /// The reflection request-builder call emitted when a method falls back. + private const string ReflectiveRequestBuilderCall = "BuildRestResultFuncForMethod"; + + /// The inline path fragment emitted when the data.Value placeholder binds. + private const string BoundValuePlaceholder = "@data.Value"; + + /// Verifies that dotted {param.Property} path placeholders are built inline, not via reflection. + /// A task representing the asynchronous test. + [Test] + public async Task GeneratesInlineForDottedPathPlaceholders() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public record class Data(string Value); + + public interface IGeneratedClient + { + [Get("/a/{data.Value}")] + Task Sample(Data data); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains(BoundValuePlaceholder); + } + + /// Verifies a property not bound to a dotted path placeholder flattens into the query string inline. + /// A task representing the asynchronous test. + [Test] + public async Task GeneratesInlineForDottedPathWithResidualQueryProperty() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public record class Data(string Value, string Note); + + public interface IGeneratedClient + { + [Get("/a/{data.Value}")] + Task Sample(Data data); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains(BoundValuePlaceholder); + await Assert.That(generated).Contains("@data.Note"); + } + + /// Verifies a residual property whose shape cannot flatten inline falls the whole parameter back. + /// A task representing the asynchronous test. + [Test] + public async Task UsesFallbackForDottedPathWithUnsupportedResidualProperty() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public record class Data(string Value) + { + public object Extra { get; init; } + } + + public interface IGeneratedClient + { + [Get("/a/{data.Value}")] + Task Sample(Data data); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + } + + /// Verifies a separate scalar path placeholder alongside a dotted binding does not bind to the object + /// parameter, while the dotted property still binds inline. + /// A task representing the asynchronous test. + [Test] + public async Task DottedPathIgnoresForeignPlaceholderAndBindsInline() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public record class Data(string Value, string Note); + + public interface IGeneratedClient + { + [Get("/a/{data.Value}/{id}")] + Task Sample(Data data, int id); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains(BoundValuePlaceholder); + } + + /// Verifies a dotted placeholder binds a property inherited from a base class. + /// A task representing the asynchronous test. + [Test] + public async Task DottedPathBindsInheritedPropertyInline() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public class BaseData { public string Value { get; set; } = ""; } + + public class Data : BaseData { } + + public interface IGeneratedClient + { + [Get("/a/{data.Value}")] + Task Sample(Data data); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains(BoundValuePlaceholder); + } + + /// Verifies a dotted placeholder naming a non-existent property falls the whole parameter back. + /// A task representing the asynchronous test. + [Test] + public async Task DottedPathWithUnknownPropertyFallsBack() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public record class Data(string Value); + + public interface IGeneratedClient + { + [Get("/a/{data.Missing}")] + Task Sample(Data data); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + + await Assert.That(result.GeneratedSources[GeneratedClientHintName]).Contains(ReflectiveRequestBuilderCall); + } + + /// Verifies a dotted placeholder with an empty property name binds nothing and falls back. + /// A task representing the asynchronous test. + [Test] + public async Task DottedPathWithEmptyPropertyNameFallsBack() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public record class Data(string Value); + + public interface IGeneratedClient + { + [Get("/a/{data.}")] + Task Sample(Data data); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + + await Assert.That(result.GeneratedSources[GeneratedClientHintName]).Contains(ReflectiveRequestBuilderCall); + } + + /// Verifies a dotted placeholder bound to an enum property with duplicate constants renders through the + /// formatter while still binding inline. + /// A task representing the asynchronous test. + [Test] + public async Task DottedPathBindsDuplicateEnumPropertyInline() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public enum Duplicated { First = 1, Alias = 1 } + + public record class Data(Duplicated Code); + + public interface IGeneratedClient + { + [Get("/a/{data.Code}")] + Task Sample(Data data); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("@data.Code"); + } + + /// Verifies a dotted path object parameter that also carries a [Query] prefix and delimiter folds them + /// into its residual property's query key. + /// A task representing the asynchronous test. + [Test] + public async Task DottedPathResidualPropertyHonorsQueryPrefixAndDelimiter() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public record class Data(string Value, string Note); + + public interface IGeneratedClient + { + [Get("/a/{data.Value}")] + Task Sample([Query("-", "pfx")] Data data); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("@data.Note"); + await Assert.That(generated).Contains("pfx-"); + } + + /// Verifies a dotted placeholder on an interface-typed parameter whose property chain cannot resolve walks + /// the type's (empty) base chain to its end and falls the parameter back. + /// A task representing the asynchronous test. + [Test] + public async Task DottedPathOnInterfaceWithUnknownPropertyFallsBack() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IData + { + string Value { get; } + } + + public interface IGeneratedClient + { + [Get("/a/{data.Missing}")] + Task Sample(IData data); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + } +} diff --git a/src/tests/Refit.GeneratorTests/PathParameterTypeTests.cs b/src/tests/Refit.GeneratorTests/PathParameterTypeTests.cs index 04b551098..008f2cc6f 100644 --- a/src/tests/Refit.GeneratorTests/PathParameterTypeTests.cs +++ b/src/tests/Refit.GeneratorTests/PathParameterTypeTests.cs @@ -35,27 +35,103 @@ public sealed class PathParameterTypeTests [Arguments("System.Int128")] [Arguments("System.Half")] [Arguments("System.DayOfWeek")] - public async Task ScalarPathParameterGeneratesInline(string parameterType) + [Arguments("byte[]")] + [Arguments("int[]")] + [Arguments("System.Collections.Generic.List")] + public async Task ScalarOrCollectionPathParameterGeneratesInline(string parameterType) { var generated = Generate($"[Get(\"/items/{{value}}\")] Task Get({parameterType} value);"); await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); } - /// Verifies non-scalar path-parameter types fall back to the reflection request builder. + /// Verifies an enum with duplicate constants as a path parameter renders through the URL parameter + /// formatter (no reflection-free fast path) while still generating inline. + /// A task representing the asynchronous test. + [Test] + public async Task DuplicateEnumPathParameterUsesFormatter() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public enum Duplicated { First = 1, Alias = 1 } + + public interface IGeneratedClient + { + [Get("/items/{mode}")] + Task Get(Duplicated mode); + } + """; + + var generated = Fixture.RunGenerator(source, generatedRequestBuilding: true) + .GeneratedSources[GeneratedClientHintName]; + + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies a concrete class (sealed or open) or value type that only overrides ToString generates + /// inline: the reflection builder's route value is a virtual ToString call that dispatches to the runtime type + /// exactly as the generated code does, so the rendering matches. + /// The custom type declaration. /// The path parameter type expression. /// A task representing the asynchronous test. [Test] - [Arguments("byte[]")] - [Arguments("int[]")] - [Arguments("System.Collections.Generic.List")] - public async Task NonScalarPathParameterFallsBack(string parameterType) + [Arguments("public sealed class Money { public override string ToString() => \"$5\"; }", "Money")] + [Arguments("public readonly struct Coordinate { public override string ToString() => \"1,2\"; }", "Coordinate")] + [Arguments("public class OpenValue { public override string ToString() => \"x\"; }", "OpenValue")] + public async Task ConcreteOrValuePathParameterGeneratesInline(string typeDeclaration, string parameterType) { - var generated = Generate($"[Get(\"/items/{{value}}\")] Task Get({parameterType} value);"); + var generated = GenerateWithType(typeDeclaration, parameterType); + + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies an object or interface path parameter stays on the reflection builder - it has no usable + /// declared shape, so the value must be inspected at runtime. + /// The custom type declaration, or empty for a built-in type. + /// The path parameter type expression. + /// A task representing the asynchronous test. + [Test] + [Arguments("public interface IThing { }", "IThing")] + [Arguments("", "object")] + public async Task PolymorphicPathParameterFallsBack(string typeDeclaration, string parameterType) + { + var generated = GenerateWithType(typeDeclaration, parameterType); await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); } + /// Runs the generator over a client that declares a custom path-parameter type. + /// The custom type declaration, or empty for a built-in type. + /// The path parameter type expression. + /// The generated client source text. + private static string GenerateWithType(string typeDeclaration, string parameterType) + { + var source = + $$""" + using System; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + {{typeDeclaration}} + + public interface IGeneratedClient + { + [Get("/items/{value}")] + Task Get({{parameterType}} value); + } + """; + + return Fixture.RunGenerator(source, generatedRequestBuilding: true) + .GeneratedSources[GeneratedClientHintName]; + } + /// Runs the generator over an interface body and returns the generated client source. /// The interface member body source. /// The generated client source text. diff --git a/src/tests/Refit.GeneratorTests/QueryObjectFlatteningGenerationTests.cs b/src/tests/Refit.GeneratorTests/QueryObjectFlatteningGenerationTests.cs new file mode 100644 index 000000000..29fc3e332 --- /dev/null +++ b/src/tests/Refit.GeneratorTests/QueryObjectFlatteningGenerationTests.cs @@ -0,0 +1,350 @@ +// 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; + +/// +/// Verifies that generated request building flattens [Query] object parameters inline across the property +/// shapes the reflection builder supports: nested objects, collection properties, per-property formats and names, +/// serialize-null scalars, ignored properties, and enum properties, for both nullable and non-nullable parameters. +/// +public sealed class QueryObjectFlatteningGenerationTests +{ + /// The generated implementation source hint name. + private const string Hint = "IGeneratedClient.g.cs"; + + /// The reflective request-builder call emitted by fallback paths. + private const string ReflectiveFallback = "BuildRestResultFuncForMethod"; + + /// The Refit source declaring a query object exercising every inline-supported property shape. + private const string QueryObjectSource = + """ + #nullable enable + using System.Collections.Generic; + using System.Runtime.Serialization; + using System.Text.Json.Serialization; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public enum SortKind + { + [EnumMember(Value = "date-desc")] DateDescending, + Name, + } + + public sealed class Address + { + public string? City { get; set; } + public int Zip { get; set; } + } + + public sealed class CustomerQuery + { + public string? Name { get; set; } + public Address? Home { get; set; } + public Address Work { get; set; } = new(); + public int[]? Ids { get; set; } + [Query(CollectionFormat.Multi)] public IReadOnlyList? Tags { get; set; } + [Query(Format = "0.00")] public double Amount { get; set; } + [Query(SerializeNull = true)] public string? Note { get; set; } + [JsonPropertyName("json_name")] public string? Named { get; set; } + [AliasAs("alias")] public string? Aliased { get; set; } + [JsonIgnore] public string? Skipped { get; set; } + public SortKind Sort { get; set; } + } + + public interface IGeneratedClient + { + [Get("/customers")] + Task FindNullable([Query] CustomerQuery? query); + + [Get("/customers")] + Task FindNonNullable([Query] CustomerQuery query); + } + """; + + /// + /// A value-type (struct) query object. Struct parameters and struct nested properties are never null, so they + /// exercise the non-guarded flattening branches that a reference type (always treated as nullable) never reaches. + /// + private const string StructQueryObjectSource = + """ + #nullable enable + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class Waypoint + { + public string? City { get; set; } + [AliasAs("z")] public string? Zip { get; set; } + } + + public struct Coord + { + [Query(Format = "0.00")] public double Lat { get; set; } + public double Lng { get; set; } + } + + public struct GeoQuery + { + public string? Name { get; set; } + public Coord Origin { get; set; } + public Coord? Peak { get; set; } + [Query(SerializeNull = true)] public Coord? Base { get; set; } + [Query(SerializeNull = true)] public Waypoint? Fallback { get; set; } + [AliasAs("wp")] public Waypoint? Marker { get; set; } + [Query(Format = "0.0")] public double Amount { get; set; } + [Query(CollectionFormat.Multi)] public int[]? Tags { get; set; } + } + + public interface IGeneratedClient + { + [Get("/geo")] + Task Find([Query] GeoQuery query); + } + """; + + /// Verifies the query object flattens inline for both nullable and non-nullable parameters. + /// A task representing the asynchronous test. + [Test] + public async Task QueryObjectFlattensInline() + { + var result = Fixture.RunGenerator(QueryObjectSource, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(result.GeneratedSources[Hint]).DoesNotContain(ReflectiveFallback); + } + + /// Verifies a struct query object flattens inline through its non-null nested and collection properties. + /// A task representing the asynchronous test. + [Test] + public async Task StructQueryObjectFlattensInline() + { + var result = Fixture.RunGenerator(StructQueryObjectSource, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(result.GeneratedSources[Hint]).DoesNotContain(ReflectiveFallback); + } + + /// Verifies a query object with nullable and non-nullable dictionary properties (with nullable values) + /// expands each entry inline under the property key. + /// A task representing the asynchronous test. + [Test] + public async Task QueryObjectDictionaryPropertiesFlattenInline() + { + const string source = + """ + #nullable enable + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class DictionaryQuery + { + public Dictionary? Meta { get; set; } + public Dictionary Tags { get; set; } = new(); + } + + public interface IGeneratedClient + { + [Get("/d")] + Task Find([Query] DictionaryQuery query); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(result.GeneratedSources[Hint]).DoesNotContain(ReflectiveFallback); + } + + /// Verifies the default-formatting-local decision recurses into a nested object that needs it, and skips a + /// nested object plus a scalar that both render only through the formatter. + /// A task representing the asynchronous test. + [Test] + public async Task QueryObjectFormattingLocalDecisionCoversNestedAndFormatterOnly() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public enum Duplicated { First = 1, Alias = 1 } + + public sealed class FormatterOnlyInner { public Duplicated Inner { get; set; } } + + public sealed class FormatterOnlyQuery + { + public FormatterOnlyInner Nested { get; set; } = new(); + public Duplicated Extra { get; set; } + } + + public sealed class FormattedInner { public string Name { get; set; } = ""; } + + public sealed class FormattedQuery + { + public FormattedInner Nested { get; set; } = new(); + } + + public interface IGeneratedClient + { + [Get("/a")] + Task FindFormatterOnly([Query] FormatterOnlyQuery query); + + [Get("/b")] + Task FindFormatted([Query] FormattedQuery query); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(result.GeneratedSources[Hint]).DoesNotContain(ReflectiveFallback); + } + + /// Verifies query-object collection properties across every guard shape: a nullable reference collection of + /// duplicate-constant enums (formatter fallback), a serialize-null collection, and a non-nullable value-type + /// collection. + /// A task representing the asynchronous test. + [Test] + public async Task QueryObjectCollectionPropertiesCoverEveryGuardShape() + { + const string source = + """ + using System; + using System.Collections.Immutable; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public enum Duplicated { First = 1, Alias = 1 } + + public sealed class EnumCollectionQuery + { + public Duplicated[] Modes { get; set; } = Array.Empty(); + + [Query(SerializeNull = true)] + public int[] Optional { get; set; } = Array.Empty(); + + public ImmutableArray Fixed { get; set; } + } + + public interface IGeneratedClient + { + [Get("/e")] + Task Find([Query] EnumCollectionQuery query); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(result.GeneratedSources[Hint]).DoesNotContain(ReflectiveFallback); + } + + /// Verifies a value-type dictionary as both a query parameter and a query-object property flattens through + /// the unguarded (never-null) branch. + /// A task representing the asynchronous test. + [Test] + public async Task ValueTypeDictionaryFlattensThroughUnguardedBranch() + { + const string source = + """ + using System.Collections; + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public struct StringMap : IDictionary + { + public string this[string key] { get => throw null!; set => throw null!; } + public ICollection Keys => throw null!; + public ICollection Values => throw null!; + public int Count => throw null!; + public bool IsReadOnly => throw null!; + public void Add(string key, string value) => throw null!; + public void Add(KeyValuePair item) => throw null!; + public void Clear() => throw null!; + public bool Contains(KeyValuePair item) => throw null!; + public bool ContainsKey(string key) => throw null!; + public void CopyTo(KeyValuePair[] array, int arrayIndex) => throw null!; + public IEnumerator> GetEnumerator() => throw null!; + public bool Remove(string key) => throw null!; + public bool Remove(KeyValuePair item) => throw null!; + public bool TryGetValue(string key, out string value) => throw null!; + IEnumerator IEnumerable.GetEnumerator() => throw null!; + } + + public sealed class MapQuery + { + public StringMap Meta { get; set; } + } + + public interface IGeneratedClient + { + [Get("/m")] + Task FindMap([Query] StringMap map); + + [Get("/o")] + Task FindObject([Query] MapQuery query); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(result.GeneratedSources[Hint]).DoesNotContain(ReflectiveFallback); + } + + /// Verifies a [Query(Format)] on a non-simple property renders the whole value as a single pair + /// inline, matching the reflection builder's FormUrlEncodedParameterFormatter.Format(value, format) pass + /// (which skips flattening), instead of falling back. + /// A task representing the asynchronous test. + [Test] + public async Task QueryObjectFormatOnNonSimplePropertyRendersWholeValueInline() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class Inner { public string Value { get; set; } = ""; } + + public sealed class BadFormatQuery + { + [Query(Format = "x")] + public Inner Nested { get; set; } = new(); + } + + public interface IGeneratedClient + { + [Get("/b")] + Task Find([Query] BadFormatQuery query); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[Hint]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveFallback); + + // The whole value is formatted through the form formatter with the property's format, not flattened. + await Assert.That(generated).Contains("FormUrlEncodedParameterFormatter.Format("); + await Assert.That(generated).Contains(", \"x\")"); + } +} diff --git a/src/tests/Refit.GeneratorTests/QueryParameterTypeTests.cs b/src/tests/Refit.GeneratorTests/QueryParameterTypeTests.cs new file mode 100644 index 000000000..f238a655d --- /dev/null +++ b/src/tests/Refit.GeneratorTests/QueryParameterTypeTests.cs @@ -0,0 +1,590 @@ +// 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; + +/// Verifies which query-parameter shapes generated request building supports inline. +public sealed class QueryParameterTypeTests +{ + /// The generated client hint name. + private const string GeneratedClientHintName = "IGeneratedClient.g.cs"; + + /// The reflection request-builder call emitted when a method falls back. + private const string ReflectiveRequestBuilderCall = "BuildRestResultFuncForMethod"; + + /// The diagnostic id reported when a source-generation-only attribute is on a fallback method. + private const string SourceGenOnlyDiagnosticId = "RF007"; + + /// Verifies scalar query-parameter types generate inline query construction. + /// The query parameter type expression. + /// A task representing the asynchronous test. + [Test] + [Arguments("string")] + [Arguments("string?")] + [Arguments("bool")] + [Arguments("char")] + [Arguments("int")] + [Arguments("int?")] + [Arguments("long")] + [Arguments("double")] + [Arguments("decimal")] + [Arguments("System.Guid")] + [Arguments("System.DateTime")] + [Arguments("System.DateTimeOffset")] + [Arguments("System.DateOnly")] + [Arguments("System.TimeSpan")] + [Arguments("System.DayOfWeek")] + [Arguments("System.DayOfWeek?")] + [Arguments("System.Globalization.CultureInfo")] + public async Task ScalarQueryParameterGeneratesInline(string parameterType) + { + var generated = Generate($"[Get(\"/items\")] Task Get({parameterType} value);"); + + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies a nullable value-type query parameter uses the span-formattable fast write on its unwrapped + /// .Value after the null guard, rather than materializing an intermediate string. + /// A task representing the asynchronous test. + [Test] + public async Task NullableValueTypeQueryParameterUsesSpanFastWrite() + { + var generated = Generate("[Get(\"/items\")] Task Get(int? page);"); + + await Assert.That(generated).Contains(".AddFormattedPreEscapedKey("); + await Assert.That(generated).Contains("@page.Value"); + } + + /// Verifies collections of scalars generate inline query construction. + /// The query parameter type expression. + /// A task representing the asynchronous test. + [Test] + [Arguments("int[]")] + [Arguments("int?[]")] + [Arguments("string[]")] + [Arguments("System.Collections.Generic.List")] + [Arguments("System.Collections.Generic.IEnumerable")] + [Arguments("System.Collections.Generic.IReadOnlyList")] + public async Task ScalarCollectionQueryParameterGeneratesInline(string parameterType) + { + var generated = Generate($"[Get(\"/items\")] Task Get({parameterType} values);"); + + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies attributed query shapes generate inline query construction. + /// The interface member body source. + /// A task representing the asynchronous test. + [Test] + [Arguments("[Get(\"/i\")] Task Get([AliasAs(\"q\")] string value);")] + [Arguments("[Get(\"/i\")] Task Get([Query(Format = \"0.00\")] double value);")] + [Arguments("[Get(\"/i\")] Task Get([Query(CollectionFormat.Multi)] int[] values);")] + [Arguments("[Get(\"/i\")] Task Get([Query(TreatAsString = true)] object value);")] + [Arguments("[Get(\"/i\")] Task Get([QueryName] string flag);")] + [Arguments("[Get(\"/i\")] Task Get([QueryName] string[] flags);")] + [Arguments("[Get(\"/i\")] Task Get([Encoded] string value);")] + [Arguments("[Get(\"/i/{**rest}\")] Task Get([Encoded] string rest);")] + [Arguments("[Get(\"/i/{**rest}\")] Task Get(string rest);")] + [Arguments("[Post(\"/i\")] Task Post(System.IO.Stream body, string tag);")] + [Arguments("[Get(\"/i\")] Task Get([Property(\"key\")][Query] string value);")] + [Arguments("[Get(\"/signin\")] Task SignIn([AliasAs(\"login\")] string login, [AliasAs(\"tok\")] string token);")] + [Arguments("[Get(\"/signin?login={login}&tok={token}\")] Task SignIn(string login, string token);")] + public async Task SupportedQueryShapeGeneratesInline(string body) + { + var generated = Generate(body); + + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies unsupported query shapes fall back to the reflection request builder. + /// The interface member body source. + /// A task representing the asynchronous test. + [Test] + [Arguments("[Get(\"/i\")] Task Get(object value);")] + [Arguments("[Get(\"/i\")] Task Get(System.Collections.Generic.Dictionary map);")] + [Arguments("[Get(\"/i\")] Task Get([Query] System.Collections.Generic.IDictionary map);")] + [Arguments("[Post(\"/i\")] Task Post(object first, object second);")] + public async Task UnsupportedQueryShapeFallsBack(string body) + { + var generated = Generate(body); + + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + } + + /// Verifies a dictionary of simple keys and values expands inline instead of using reflection. + /// The interface member body source. + /// A task representing the asynchronous test. + /// + /// An object-valued dictionary keeps falling back: the reflection builder inspects each value's runtime type + /// to decide whether to recurse into it, which the generator cannot determine from the declared type. + /// + [Test] + [Arguments("[Get(\"/i\")] Task Get(System.Collections.Generic.Dictionary map);")] + [Arguments("[Get(\"/i\")] Task Get([Query] System.Collections.Generic.Dictionary map);")] + [Arguments("[Get(\"/i\")] Task Get(System.Collections.Generic.IDictionary map);")] + public async Task DictionaryQueryShapeGeneratesInline(string body) + { + var generated = Generate(body); + + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("GeneratedQueryStringBuilder"); + } + + /// Verifies a query object whose properties are collections of simple elements generates inline, + /// emitting both the default-formatter fast path and the customized-formatter slow path. + /// A task representing the asynchronous test. + [Test] + public async Task CollectionPropertyObjectGeneratesInline() + { + const string source = + """ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class Filter + { + public int[]? Ids { get; set; } + + [Query(CollectionFormat.Multi)] + public List? Tags { get; set; } + } + + public interface IGeneratedClient + { + [Get("/i")] + Task Get([Query] Filter filter); + } + """; + + var generated = Fixture.RunGenerator(source, generatedRequestBuilding: true) + .GeneratedSources[GeneratedClientHintName]; + + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("BeginCollection"); + await Assert.That(generated).Contains("AddFormattedCollectionProperty"); + } + + /// Verifies a query object with a nested concrete object property generates inline. + /// A task representing the asynchronous test. + [Test] + public async Task NestedObjectPropertyGeneratesInline() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class Address + { + public string? City { get; set; } + } + + public sealed class Filter + { + public string? Name { get; set; } + + public Address? Address { get; set; } + } + + public interface IGeneratedClient + { + [Get("/i")] + Task Get([Query] Filter filter); + } + """; + + var generated = Fixture.RunGenerator(source, generatedRequestBuilding: true) + .GeneratedSources[GeneratedClientHintName]; + + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies a self-referential (cyclic) query object falls back to the reflection request builder. + /// A task representing the asynchronous test. + [Test] + public async Task CyclicObjectPropertyFallsBack() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class Node + { + public string? Value { get; set; } + + public Node? Next { get; set; } + } + + public interface IGeneratedClient + { + [Get("/i")] + Task Get([Query] Node node); + } + """; + + var generated = Fixture.RunGenerator(source, generatedRequestBuilding: true) + .GeneratedSources[GeneratedClientHintName]; + + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + } + + /// Verifies a [QueryConverter] parameter generates inline by delegating to the converter, + /// even for an object-valued dictionary the generator cannot flatten from the declared type. + /// A task representing the asynchronous test. + [Test] + public async Task QueryConverterParameterGeneratesInline() + { + const string source = + """ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class MapConverter : IQueryConverter> + { + public void Flatten(Dictionary value, string keyPrefix, ref GeneratedQueryStringBuilder builder, RefitSettings settings) + { + } + } + + public interface IGeneratedClient + { + [Get("/i")] + Task Get([QueryConverter(typeof(MapConverter))] Dictionary filter); + } + """; + + var generated = Fixture.RunGenerator(source, generatedRequestBuilding: true) + .GeneratedSources[GeneratedClientHintName]; + + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains(".Flatten("); + await Assert.That(generated).Contains("new global::RefitGeneratorTest.MapConverter()"); + } + + /// Verifies [QueryConverter] on a method that cannot generate inline for another reason reports RF007. + /// A task representing the asynchronous test. + [Test] + public async Task QueryConverterOnFallbackMethodReportsError() + { + const string source = + """ + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class MapConverter : IQueryConverter> + { + public void Flatten(Dictionary value, string keyPrefix, ref GeneratedQueryStringBuilder builder, RefitSettings settings) + { + } + } + + public interface IGeneratedClient + { + [Get("/i")] + Task Get([QueryConverter(typeof(MapConverter))] Dictionary filter, object other); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + + var ids = result.GeneratorDiagnostics.Select(static diagnostic => diagnostic.Id).ToArray(); + await Assert.That(ids).Contains(SourceGenOnlyDiagnosticId); + } + + /// Verifies a type closing IEnumerable<T> over two element types falls back. + /// A task representing the asynchronous test. + [Test] + public async Task AmbiguousEnumerableQueryParameterFallsBack() + { + const string source = + """ + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IDualSequence : IEnumerable, IEnumerable { } + + public interface IGeneratedClient + { + [Get("/items")] + Task Get([Query] IDualSequence values); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + } + + /// Verifies a type closing IDictionary<TKey, TValue> more than once falls back. + /// A task representing the asynchronous test. + [Test] + public async Task AmbiguousDictionaryQueryParameterFallsBack() + { + const string source = + """ + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IDualMap : IDictionary, IDictionary { } + + public interface IGeneratedClient + { + [Get("/items")] + Task Get([Query] IDualMap filter); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + } + + /// Verifies a [QueryConverter(null)] with no resolvable converter type falls back. + /// A task representing the asynchronous test. + [Test] + public async Task QueryConverterWithNullTypeFallsBack() + { + const string source = + """ + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/i")] + Task Get([QueryConverter(null)] Dictionary filter); + } + """; + + var generated = Fixture.RunGenerator(source, generatedRequestBuilding: true) + .GeneratedSources[GeneratedClientHintName]; + + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + } + + /// Verifies a non-nullable dictionary query parameter flattens inline through the unguarded branch. + /// A task representing the asynchronous test. + [Test] + public async Task NonNullableDictionaryQueryParameterGeneratesInline() + { + const string source = + """ + #nullable enable + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/i")] + Task Get([Query] Dictionary filter); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies a compile-time [Query(Format)] on a plain enum emits a formatted numeric fallback. + /// A task representing the asynchronous test. + [Test] + public async Task EnumQueryWithFormatEmitsFormattedNumericFallback() + { + const string source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public enum Palette { Red, Green } + + public interface IGeneratedClient + { + [Get("/p")] + Task Get([Query(Format = "D")] Palette palette); + } + """; + + var generated = Fixture.RunGenerator(source, generatedRequestBuilding: true) + .GeneratedSources[GeneratedClientHintName]; + + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies enum-member override reading skips non-EnumMember attributes and a null-valued + /// [EnumMember] while a scalar enum query flattens inline. + /// A task representing the asynchronous test. + [Test] + public async Task EnumMemberOverrideReadingHandlesMissingAndNonEnumMemberAttributes() + { + const string source = + """ + using System; + using System.Runtime.Serialization; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public enum Season + { + [Obsolete] + Spring, + [EnumMember(Value = null)] + Summer, + [EnumMember(Value = "fall")] + Autumn, + } + + public interface IGeneratedClient + { + [Get("/s")] + Task Get([Query] Season season); + } + """; + + var generated = Fixture.RunGenerator(source, generatedRequestBuilding: true) + .GeneratedSources[GeneratedClientHintName]; + + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies an implicit (un-attributed) complex body on a body-capable method generates inline (issue #2190). + /// A task representing the asynchronous test. + [Test] + public async Task ImplicitComplexBodyGeneratesInline() + { + const string source = + """ + using System.Threading; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class AuthRequest + { + public string? User { get; set; } + } + + public interface IGeneratedClient + { + [Post("/v1/auth")] + Task AuthMePlease(AuthRequest request, CancellationToken token); + } + """; + + var generated = Fixture.RunGenerator(source, generatedRequestBuilding: true) + .GeneratedSources[GeneratedClientHintName]; + + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies un-attributed complex parameters become the implicit body on body-capable methods. + /// A task representing the asynchronous test. + [Test] + public async Task ImplicitBodyGeneratesInlineContent() + { + var generated = Generate("[Post(\"/i\")] Task Post(System.IO.Stream payload, string tag);"); + + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("CreateBodyContent"); + await Assert.That(generated).Contains("global::Refit.BodySerializationMethod.Serialized"); + } + + /// Verifies a source-generation-only attribute on a fallback method reports RF007. + /// The interface member body source. + /// A task representing the asynchronous test. + [Test] + [Arguments("[Get(\"/i\")] Task Get([QueryName] string flag, object other);")] + [Arguments("[Get(\"/i/{**rest}\")] Task Get([Encoded] int rest);")] + [Arguments("[Multipart][Post(\"/i\")] Task Post([QueryName] string flag, object payload);")] + public async Task SourceGenOnlyAttributeOnFallbackMethodReportsError(string body) + { + var result = Fixture.RunGenerator(BuildSource(body), generatedRequestBuilding: true); + + var ids = result.GeneratorDiagnostics.Select(static diagnostic => diagnostic.Id).ToArray(); + await Assert.That(ids).Contains(SourceGenOnlyDiagnosticId); + } + + /// Verifies RF007 also fires when generated request building is disabled entirely. + /// A task representing the asynchronous test. + [Test] + public async Task SourceGenOnlyAttributeWithBuildingDisabledReportsError() + { + var result = Fixture.RunGenerator( + BuildSource("[Get(\"/i\")] Task Get([QueryName] string flag);"), + generatedRequestBuilding: false); + + var ids = result.GeneratorDiagnostics.Select(static diagnostic => diagnostic.Id).ToArray(); + await Assert.That(ids).Contains(SourceGenOnlyDiagnosticId); + } + + /// Verifies RF007 does not fire for inline-eligible methods using the new attributes. + /// A task representing the asynchronous test. + [Test] + public async Task SourceGenOnlyAttributeOnInlineMethodReportsNothing() + { + var result = Fixture.RunGenerator( + BuildSource("[Get(\"/i\")] Task Get([QueryName] string flag, [Encoded] string v);"), + generatedRequestBuilding: true); + + var ids = result.GeneratorDiagnostics.Select(static diagnostic => diagnostic.Id).ToArray(); + await Assert.That(ids).DoesNotContain(SourceGenOnlyDiagnosticId); + } + + /// Runs the generator over an interface body and returns the generated client source. + /// The interface member body source. + /// The generated client source text. + private static string Generate(string body) => + Fixture.RunGenerator(BuildSource(body), generatedRequestBuilding: true) + .GeneratedSources[GeneratedClientHintName]; + + /// Wraps an interface body in a compilable Refit client source. + /// The interface member body source. + /// The full source string. + private static string BuildSource(string body) => + $$""" + using System; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + {{body}} + } + """; +} diff --git a/src/tests/Refit.GeneratorTests/QueryRequestBuildingLiveTests.cs b/src/tests/Refit.GeneratorTests/QueryRequestBuildingLiveTests.cs new file mode 100644 index 000000000..80be715ef --- /dev/null +++ b/src/tests/Refit.GeneratorTests/QueryRequestBuildingLiveTests.cs @@ -0,0 +1,681 @@ +// 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; + +/// +/// Compiles, loads and invokes generated query request building, asserting the final and its +/// parity with the reflection request builder for every query parameter shape that generates inline. +/// +public sealed class QueryRequestBuildingLiveTests +{ + /// A sample integer query value. + private const int SecondValue = 2; + + /// A sample page number. + private const int PageSeven = 7; + + /// A sample 64-bit identifier beyond the 32-bit range, exercising the long span-formatted fast write. + private const long LargeIdentifier = 9_000_000_000L; + + /// A sample document revision used by the dotted-path scenario. + private const int DocRevision = 7; + + /// A sample price formatted with two decimals. + private const double PriceFive = 5D; + + /// A sample raw double for TreatAsString. + private const double RawDouble = 1.5D; + + /// The enum-valued query method exercised across parity scenarios. + private const string SortedMethodName = "Sorted"; + + /// The multi-expanded collection query method exercised across parity scenarios. + private const string ExpandedMethodName = "Expanded"; + + /// The full name of the compiled scenario enum type. + private const string SearchSortTypeName = "Refit.LiveQuery.SearchSort"; + + /// A value with a space and a slash, exercising percent-encoding of both reserved characters. + private const string EscapableValue = "a b/c"; + + /// The lower bound of the formatted complex query-object property scenario. + private const int WindowMin = 1; + + /// The upper bound of the formatted complex query-object property scenario. + private const int WindowMax = 9; + + /// Csv-joined identifiers. + private static readonly int[] CsvIds = [1, 2, 3]; + + /// Multi-expanded identifiers. + private static readonly int[] ExpandIds = [1, 2]; + + /// Pipe-joined values. + private static readonly string[] PipeValues = ["a", "b"]; + + /// Identifiers expanded with the settings default collection format. + private static readonly List ListIds = [4, 5]; + + /// Repeated valueless flag values. + private static readonly string[] FlagValues = ["a", "b", "c"]; + + /// An empty identifier collection. + private static readonly int[] EmptyIds = []; + + /// A sample timestamp for invariant formatting parity. + private static readonly DateTimeOffset SampleTimestamp = new(2026, 7, 4, 12, 30, 0, TimeSpan.Zero); + + /// Verifies generated query URIs match the reflection builder for scalar shapes. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task ScalarQueryParametersMatchReflection() + { + using var harness = LiveQueryHarness.Create(); + + await harness.AssertParityAsync("Plain", [EscapableValue], "/base/search?q=a%20b%2Fc"); + await harness.AssertParityAsync("Alias", ["me", "beta"], "/base/signin?login=me&kind=beta"); + await harness.AssertParityAsync("Multiple", ["x", SecondValue, true], "/base/multi?a=x&b=2&c=True"); + await harness.AssertParityAsync("NullSkip", [null, "kept"], "/base/nullskip?b=kept"); + await harness.AssertParityAsync("Paged", [PageSeven], "/base/page?page=7"); + await harness.AssertParityAsync("Paged", [null], "/base/page"); + await harness.AssertParityAsync("Big", [LargeIdentifier], "/base/big?id=9000000000"); + await harness.AssertParityAsync("Templated", ["two"], "/base/tmpl?fixed=1&extra=two"); + } + + /// Verifies generated dotted {param.Property} path URIs match the reflection builder. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task DottedPathParametersMatchReflection() + { + using var harness = LiveQueryHarness.Create(); + + var info = harness.CreateApiValue("Refit.LiveQuery.RouteInfo", ("Slug", EscapableValue), ("Version", DocRevision)); + _ = await harness.AssertParityAsync("DottedPath", [info], "/base/docs/a%20b%2Fc/rev/7"); + + // Only Slug binds to the path; Version is a residual property flattened into the query, exactly as the + // reflection builder splits a path-bound object between the path and the query string. + _ = await harness.AssertParityAsync("DottedPathResidual", [info], "/base/tags/a%20b%2Fc?Version=7"); + } + + /// Verifies a multi-level dotted {order.Customer.Id} path binds the nested property chain and matches + /// the reflection builder: the top-level property is consumed by the path (residual sibling flattens into the query), + /// and a null intermediate renders an empty segment instead of throwing. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task NestedPathPropertyMatchesReflection() + { + using var harness = LiveQueryHarness.Create(); + + var customer = harness.CreateApiValue("Refit.LiveQuery.NestedCustomer", ("Id", EscapableValue)); + var order = harness.CreateApiValue("Refit.LiveQuery.NestedOrder", ("Customer", customer), ("Note", "hi")); + _ = await harness.AssertParityAsync("NestedPath", [order], "/base/orders/a%20b%2Fc?Note=hi"); + + // A null intermediate short-circuits the chain to an empty segment, matching the reflection builder's walk. + var missing = harness.CreateApiValue("Refit.LiveQuery.NestedOrder", ("Customer", null), ("Note", "x")); + _ = await harness.AssertParityAsync("NestedPath", [missing], "/base/orders/?Note=x"); + } + + /// Verifies a sealed ToString-only type in a {token} slot matches the reflection builder: the + /// generated inline path calls the same URL parameter formatter (ultimately ToString) the reflection path does. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task SealedToStringPathParameterMatchesReflection() + { + using var harness = LiveQueryHarness.Create(); + + var token = harness.CreateApiValue("Refit.LiveQuery.RouteToken", ("Value", EscapableValue)); + _ = await harness.AssertParityAsync("TokenPath", [token], "/base/token/a%20b%2Fc"); + } + + /// Verifies a [QueryUriFormat(UriFormat.Unescaped)] method matches the reflection builder: the whole + /// path and query is re-encoded with the attribute's format, decoding the escapes the query builder emitted. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task QueryUriFormatMatchesReflection() + { + using var harness = LiveQueryHarness.Create(); + + // The exact captured form depends on Uri normalization applied identically to both paths, so assert parity only. + _ = await harness.AssertParityAsync("UnescapedQuery", ["Select Id From Account"], null); + } + + /// Verifies a [Query(Format)] on a complex query-object property matches the reflection builder: the + /// whole value is rendered as a single pair through the form formatter, not flattened into its own properties. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task FormattedComplexQueryPropertyMatchesReflection() + { + using var harness = LiveQueryHarness.Create(); + + var bounds = harness.CreateApiValue("Refit.LiveQuery.Bounds", ("Min", WindowMin), ("Max", WindowMax)); + var query = harness.CreateApiValue("Refit.LiveQuery.RangeQuery", ("Window", bounds)); + _ = await harness.AssertParityAsync("RangeSearch", [query], "/base/range?Window=1..9"); + } + + /// Verifies a dictionary of a concrete (non-sealed) complex value type flattens each entry under the entry key, matching + /// the reflection builder's per-value nested map (key.Property=value). + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task DictionaryOfConcreteValuesMatchesReflection() + { + using var harness = LiveQueryHarness.Create(); + + var facet = harness.CreateApiValue("Refit.LiveQuery.Facet", ("Name", "blue"), ("Count", WindowMin)); + var facets = harness.CreateStringKeyedDictionary("Refit.LiveQuery.Facet", ("color", facet)); + _ = await harness.AssertParityAsync("Facets", [facets], "/base/facets?color.Name=blue&color.Count=1"); + } + + /// Verifies a custom HTTP QUERY verb attribute (a draft-standard body-carrying method) generates inline and + /// matches the reflection builder: the request uses the custom verb, carries an explicit body, and - since the verb is + /// not yet body-capable - flattens an un-attributed complex parameter into the query, all at parity. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task QueryVerbMatchesReflection() + { + const string queryVerb = "QUERY"; + using var harness = LiveQueryHarness.Create(); + + // An explicit [Body] on the QUERY verb: the generated request uses the custom verb and carries the body. + var payload = harness.CreateApiValue("Refit.LiveQuery.CreatePayload", ("Name", "report")); + var generated = await harness.InvokeGeneratedAsync("QueryDocuments", [payload], "/base/documents"); + await Assert.That(generated.Method.Method).IsEqualTo(queryVerb); + await Assert.That(harness.LastCapturedContent).IsNotNull(); + _ = await harness.AssertParityAsync("QueryDocuments", [payload], "/base/documents"); + + // The verb is not body-capable for an un-attributed complex parameter yet, so both paths flatten it into the query. + var bounds = harness.CreateApiValue("Refit.LiveQuery.Bounds", ("Min", WindowMin), ("Max", WindowMax)); + var filter = harness.CreateApiValue("Refit.LiveQuery.RangeQuery", ("Window", bounds)); + var rows = await harness.AssertParityAsync("QueryRows", [filter], "/base/rows?Window=1..9"); + await Assert.That(rows.Method.Method).IsEqualTo(queryVerb); + } + + /// Verifies generated query URIs match the reflection builder for formatted values. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task FormattedQueryParametersMatchReflection() + { + using var harness = LiveQueryHarness.Create(); + + await harness.AssertParityAsync("Formatted", [PriceFive], "/base/fmt?price=5.00"); + await harness.AssertParityAsync(SortedMethodName, [harness.CreateEnumValue(SearchSortTypeName, 0)], "/base/enum?sort=date-desc"); + await harness.AssertParityAsync(SortedMethodName, [harness.CreateEnumValue(SearchSortTypeName, 1)], "/base/enum?sort=Name"); + await harness.AssertParityAsync("Treated", [RawDouble], null); + await harness.AssertParityAsync("When", [SampleTimestamp], null); + } + + /// Verifies generated query URIs match the reflection builder for collection expansion. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task CollectionQueryParametersMatchReflection() + { + using var harness = LiveQueryHarness.Create(); + + await harness.AssertParityAsync("Csv", [CsvIds], "/base/csv?ids=1%2C2%2C3"); + await harness.AssertParityAsync(ExpandedMethodName, [ExpandIds], "/base/expand?ids=1&ids=2"); + await harness.AssertParityAsync("Pipes", [PipeValues], "/base/pipes?values=a%7Cb"); + await harness.AssertParityAsync("DefaultList", [ListIds], "/base/list?ids=4%2C5"); + await harness.AssertParityAsync("Csv", [EmptyIds], "/base/csv?ids="); + await harness.AssertParityAsync(ExpandedMethodName, [EmptyIds], "/base/expand"); + } + + /// Verifies a custom URL parameter formatter still runs for every generated value. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task CustomFormatterMatchesReflection() + { + var settings = new RefitSettings { UrlParameterFormatter = new UpperCaseUrlParameterFormatter() }; + using var harness = LiveQueryHarness.Create(settings); + + await harness.AssertParityAsync("Plain", ["abc"], "/base/search?q=ABC"); + await harness.AssertParityAsync(ExpandedMethodName, [ExpandIds], null); + await harness.AssertParityAsync(SortedMethodName, [harness.CreateEnumValue(SearchSortTypeName, 0)], "/base/enum?sort=DATE-DESC"); + } + + /// Verifies POST methods combine an implicit body with generated query parameters. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task ImplicitBodyWithQueryMatchesReflection() + { + using var harness = LiveQueryHarness.Create(); + + var payload = harness.CreateApiValue("Refit.LiveQuery.CreatePayload", ("Name", "Widget")); + _ = await harness.AssertParityAsync("Create", [payload, "new"], "/base/create?tag=new"); + + await Assert.That(harness.LastCapturedContent).IsNotNull(); + await Assert.That(harness.LastCapturedContent!).Contains("Widget"); + } + + /// Verifies source-generation-only query flags render as valueless segments. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task QueryNameFlagsRenderValuelessSegments() + { + using var harness = LiveQueryHarness.Create(); + + _ = await harness.InvokeGeneratedAsync("Flag", ["ready"], "/base/flags?ready"); + _ = await harness.InvokeGeneratedAsync("Flag", ["needs escape"], "/base/flags?needs%20escape"); + _ = await harness.InvokeGeneratedAsync("Flag", [null], "/base/flags"); + _ = await harness.InvokeGeneratedAsync("Flags", [FlagValues], "/base/flags/many?a&b&c"); + } + + /// Verifies source-generation-only encoded values pass through verbatim. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task EncodedValuesPassThroughVerbatim() + { + using var harness = LiveQueryHarness.Create(); + + _ = await harness.InvokeGeneratedAsync("EncodedQuery", ["a%2Fb%20c"], "/base/encq?v=a%2Fb%20c"); + _ = await harness.InvokeGeneratedAsync("EncodedPath", ["0001%40zoho.com"], "/base/encp/0001%40zoho.com"); + _ = await harness.InvokeGeneratedAsync( + "EncodedRoundTrip", + ["9000/events/3bf@zoho.com"], + "/base/cal/9000/events/3bf@zoho.com"); + } + + /// Formats every value through the default rules and upper-cases the result. + private sealed class UpperCaseUrlParameterFormatter : DefaultUrlParameterFormatter + { + /// + public override string? Format( + object? value, + System.Reflection.ICustomAttributeProvider attributeProvider, + Type type) => + base.Format(value, attributeProvider, type)?.ToUpperInvariant(); + } + + /// Hosts one compiled generated client plus the reflection builder for parity assertions. + /// The collectible load context holding the compiled assembly. + /// The capturing message handler. + /// The HTTP client shared by both request paths. + /// The compiled Refit interface type. + /// The generated client instance. + /// The reflection request builder for the compiled interface. + private sealed class LiveQueryHarness( + CollectibleAssemblyLoadContext context, + CapturingHandler handler, + HttpClient client, + Type interfaceType, + object generatedApi, + IRequestBuilder requestBuilder) : IDisposable + { + /// The base address the relative request URIs resolve against. + private const string BaseAddress = "https://example.test/base/"; + + /// The interface source compiled through the generator for every scenario. + private const string ApiSource = + """ + using System; + using System.Collections.Generic; + using System.Runtime.Serialization; + using System.Threading.Tasks; + using Refit; + + namespace Refit.LiveQuery; + + public enum SearchSort + { + [EnumMember(Value = "date-desc")] + DateDescending, + Name, + } + + public sealed class CreatePayload + { + public string? Name { get; set; } + } + + public sealed class RouteInfo + { + public string? Slug { get; set; } + + public int Version { get; set; } + } + + public sealed class NestedCustomer + { + public string? Id { get; set; } + } + + public sealed class NestedOrder + { + public NestedCustomer? Customer { get; set; } + + public string? Note { get; set; } + } + + public sealed class RouteToken + { + public string? Value { get; set; } + + public override string ToString() => Value ?? string.Empty; + } + + public sealed class Bounds + { + public int Min { get; set; } + + public int Max { get; set; } + + public override string ToString() => Min + ".." + Max; + } + + public sealed class RangeQuery + { + [Query(Format = "g")] + public Bounds? Window { get; set; } + } + + // Deliberately not sealed: exercises the concrete (non-sealed) declared-type flatten. The test value is not a + // subtype, so the declared-type flatten matches the reflection builder's runtime-type flatten exactly. + public class Facet + { + public string? Name { get; set; } + + public int Count { get; set; } + } + + // The HTTP QUERY method (currently a draft standard): a custom verb attribute carrying a body. + public sealed class QueryVerbAttribute : HttpMethodAttribute + { + public QueryVerbAttribute(string path) : base(path) { } + + public override System.Net.Http.HttpMethod Method => new System.Net.Http.HttpMethod("QUERY"); + } + + public interface ILiveQueryApi + { + [Get("/search")] + Task Plain(string q); + + [Get("/token/{token}")] + Task TokenPath(RouteToken token); + + [Get("/docs/{info.Slug}/rev/{info.Version}")] + Task DottedPath(RouteInfo info); + + [Get("/tags/{info.Slug}")] + Task DottedPathResidual(RouteInfo info); + + [Get("/orders/{order.Customer.Id}")] + Task NestedPath(NestedOrder order); + + [Get("/signin")] + Task Alias([AliasAs("login")] string user, [AliasAs("kind")] string kind); + + [Get("/multi")] + Task Multiple(string a, int b, bool c); + + [Get("/nullskip")] + Task NullSkip(string? a, string b); + + [Get("/fmt")] + Task Formatted([Query(Format = "0.00")] double price); + + [Get("/csv")] + Task Csv([Query(CollectionFormat.Csv)] int[] ids); + + [Get("/expand")] + Task Expanded([Query(CollectionFormat.Multi)] int[] ids); + + [Get("/pipes")] + Task Pipes([Query(CollectionFormat.Pipes)] string[] values); + + [Get("/list")] + Task DefaultList(List ids); + + [Get("/enum")] + Task Sorted(SearchSort sort); + + [Get("/page")] + Task Paged(int? page); + + [Get("/big")] + Task Big(long id); + + [Get("/treat")] + Task Treated([Query(TreatAsString = true)] double raw); + + [Get("/tmpl?fixed=1")] + Task Templated(string extra); + + [QueryUriFormat(UriFormat.Unescaped)] + [Get("/soql")] + Task UnescapedQuery(string q); + + [Get("/range")] + Task RangeSearch([Query] RangeQuery query); + + [Get("/facets")] + Task Facets(Dictionary facets); + + [QueryVerb("/documents")] + Task QueryDocuments([Body] CreatePayload body); + + [QueryVerb("/rows")] + Task QueryRows([Query] RangeQuery filter); + + [Get("/when")] + Task When(DateTimeOffset at); + + [Post("/create")] + Task Create(CreatePayload payload, string tag); + + [Get("/flags")] + Task Flag([QueryName] string flag); + + [Get("/flags/many")] + Task Flags([QueryName] string[] flags); + + [Get("/encq")] + Task EncodedQuery([Encoded] string v); + + [Get("/encp/{id}")] + Task EncodedPath([Encoded] string id); + + [Get("/cal/{**rest}")] + Task EncodedRoundTrip([Encoded] string rest); + } + """; + + /// Gets the body content captured for the most recent request, or null. + public string? LastCapturedContent => handler.LastContent; + + /// Compiles the scenario interface and creates the generated and reflection clients. + /// The Refit settings, or null for defaults. + /// The live harness. + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + public static LiveQueryHarness Create(RefitSettings? settings = null) + { + settings ??= new RefitSettings(); + var result = Fixture.RunGenerator(ApiSource, generatedRequestBuilding: true); + if (!result.CompilesWithoutErrors) + { + throw new InvalidOperationException( + "Generated compilation failed: " + string.Join(Environment.NewLine, result.CompilationErrors)); + } + + var (assembly, loadContext) = Fixture.EmitAndLoad(result); + var interfaceType = assembly.GetType("Refit.LiveQuery.ILiveQueryApi", throwOnError: true)!; + var generatedType = assembly + .GetTypes() + .Single(type => type.IsClass && interfaceType.IsAssignableFrom(type)); + + var handler = new CapturingHandler(); + var client = new HttpClient(handler) { BaseAddress = new(BaseAddress) }; + var requestBuilder = RequestBuilder.ForType(interfaceType, settings); + var generatedApi = Activator.CreateInstance(generatedType, [client, requestBuilder])!; + return new(loadContext, handler, client, interfaceType, generatedApi, requestBuilder); + } + + /// Creates a compiled scenario enum value from its underlying value. + /// The compiled enum type's full name. + /// The underlying enum value. + /// The boxed enum value. + [RequiresUnreferencedCode("Reflects over generated types and members.")] + public object CreateEnumValue(string typeName, int value) => + Enum.ToObject(interfaceType.Assembly.GetType(typeName, throwOnError: true)!, value); + + /// Creates an instance of a compiled scenario type with the given properties assigned. + /// The compiled type's full name. + /// The property name/value pairs to assign. + /// The created instance. + [RequiresUnreferencedCode("Reflects over generated types and members.")] + public object CreateApiValue(string typeName, params (string Name, object? Value)[] properties) + { + var type = interfaceType.Assembly.GetType(typeName, throwOnError: true)!; + var instance = Activator.CreateInstance(type)!; + foreach (var (name, value) in properties) + { + type.GetProperty(name)!.SetValue(instance, value); + } + + return instance; + } + + /// Creates a Dictionary<string, TValue> of a compiled scenario value type. + /// The compiled value type's full name. + /// The key/value entries to add. + /// The created dictionary. + [RequiresUnreferencedCode("Reflects over generated types and members.")] + [RequiresDynamicCode("Constructs a closed Dictionary type over the compiled scenario value type.")] + public object CreateStringKeyedDictionary(string valueTypeName, params (string Key, object? Value)[] entries) + { + var valueType = interfaceType.Assembly.GetType(valueTypeName, throwOnError: true)!; + var dictionaryType = typeof(Dictionary<,>).MakeGenericType(typeof(string), valueType); + var dictionary = Activator.CreateInstance(dictionaryType)!; + var add = dictionaryType.GetMethod("Add")!; + foreach (var (key, value) in entries) + { + _ = add.Invoke(dictionary, [key, value]); + } + + return dictionary; + } + + /// Invokes a method on the generated client and asserts the captured relative URI. + /// The interface method name. + /// The argument values. + /// The expected path and query, or null to skip the assertion. + /// The captured request. + [RequiresUnreferencedCode("Reflects over generated types and members.")] + public async Task InvokeGeneratedAsync( + string methodName, + object?[] args, + string? expectedPathAndQuery) + { + var task = (Task)interfaceType.GetMethod(methodName)!.Invoke(generatedApi, args)!; + await task.ConfigureAwait(false); + var request = handler.TakeLastRequest(); + if (expectedPathAndQuery is not null) + { + await Assert.That(request.RequestUri!.PathAndQuery).IsEqualTo(expectedPathAndQuery); + } + + return request; + } + + /// Invokes a method through both request paths and asserts the URIs are identical. + /// The interface method name. + /// The argument values. + /// The expected path and query, or null to only assert parity. + /// The request captured from the generated path. + [RequiresUnreferencedCode("Reflects over generated types and members.")] + [RequiresDynamicCode("Builds the reflection request delegate for parity comparison.")] + public async Task AssertParityAsync( + string methodName, + object?[] args, + string? expectedPathAndQuery) + { + var generatedRequest = await InvokeGeneratedAsync(methodName, args, expectedPathAndQuery).ConfigureAwait(false); + + var reflectionFunc = requestBuilder.BuildRestResultFuncForMethod(methodName); + var reflectionTask = (Task)reflectionFunc(client, args!)!; + await reflectionTask.ConfigureAwait(false); + var reflectionRequest = handler.TakeLastRequest(); + + await Assert.That(generatedRequest.Method).IsEqualTo(reflectionRequest.Method); + await Assert.That(generatedRequest.RequestUri!.AbsoluteUri) + .IsEqualTo(reflectionRequest.RequestUri!.AbsoluteUri); + return generatedRequest; + } + + /// + public void Dispose() + { + client.Dispose(); + handler.Dispose(); + context.Dispose(); + } + } + + /// Captures each outgoing request and returns a fixed JSON string response. + private sealed class CapturingHandler : HttpMessageHandler + { + /// The last request sent through the handler. + private HttpRequestMessage? _lastRequest; + + /// Gets the body content captured for the last request, or null. + public string? LastContent { get; private set; } + + /// Takes the last captured request, clearing the slot. + /// The captured request. + public HttpRequestMessage TakeLastRequest() + { + var request = _lastRequest ?? throw new InvalidOperationException("No request was captured."); + _lastRequest = null; + return request; + } + + /// + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + _lastRequest = request; + + // Streamed request bodies are disposed with the request, so snapshot the content here. + LastContent = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("\"done\"", Encoding.UTF8, "application/json") + }; + } + } +} diff --git a/src/tests/Refit.GeneratorTests/Refit.GeneratorTests.csproj b/src/tests/Refit.GeneratorTests/Refit.GeneratorTests.csproj index 5cb2fe07e..53844f1b9 100644 --- a/src/tests/Refit.GeneratorTests/Refit.GeneratorTests.csproj +++ b/src/tests/Refit.GeneratorTests/Refit.GeneratorTests.csproj @@ -20,7 +20,10 @@ + + + diff --git a/src/tests/Refit.GeneratorTests/ReflectionFallbackAnnotationTests.cs b/src/tests/Refit.GeneratorTests/ReflectionFallbackAnnotationTests.cs new file mode 100644 index 000000000..b357e86d7 --- /dev/null +++ b/src/tests/Refit.GeneratorTests/ReflectionFallbackAnnotationTests.cs @@ -0,0 +1,80 @@ +// 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; + +/// Verifies how the generator annotates methods that fall back to the reflection request builder: it mirrors +/// the interface member's [RequiresUnreferencedCode]/[RequiresDynamicCode] when present, and otherwise +/// suppresses the unactionable IL2026/IL3050 (issue #2200). +public sealed class ReflectionFallbackAnnotationTests +{ + /// A method with a dynamic query-map (object) parameter is not inline-eligible, so it falls back to reflection. + private const string UnannotatedSource = + """ + using System.Threading.Tasks; + using Refit; + + namespace ConsumerNs; + + public interface IFallbackApi + { + [Get("/thing")] + Task GetThing(object filters); + } + """; + + /// The same fallback method, but the interface member declares the trim/AOT annotations. + private const string AnnotatedSource = + """ + using System.Diagnostics.CodeAnalysis; + using System.Threading.Tasks; + using Refit; + + namespace ConsumerNs; + + public interface IFallbackApi + { + [Get("/thing")] + [RequiresUnreferencedCode("Uses the reflection request builder.")] + [RequiresDynamicCode("Uses the reflection request builder.")] + Task GetThing(object filters); + } + """; + + /// Verifies an unannotated fallback method suppresses the IL2026/IL3050 the consumer cannot act on. + /// A task representing the asynchronous test. + [Test] + public async Task UnannotatedFallbackMethodSuppressesTrimWarnings() + { + var generated = RunAndConcatenate(UnannotatedSource); + + await Assert.That(generated).Contains("\"IL2026\""); + await Assert.That(generated).Contains("\"IL3050\""); + await Assert.That(generated).DoesNotContain("RequiresUnreferencedCode("); + await Assert.That(generated).DoesNotContain("RequiresDynamicCode("); + } + + /// Verifies an annotated fallback method mirrors the interface's trim/AOT attributes rather than suppressing, + /// so it satisfies the interface/implementation annotation-matching rule and propagates the contract to callers. + /// A task representing the asynchronous test. + [Test] + public async Task AnnotatedFallbackMethodMirrorsTrimAttributes() + { + var generated = RunAndConcatenate(AnnotatedSource); + + await Assert.That(generated).Contains("global::System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("); + await Assert.That(generated).Contains("global::System.Diagnostics.CodeAnalysis.RequiresDynamicCode("); + await Assert.That(generated).DoesNotContain("\"IL2026\""); + await Assert.That(generated).DoesNotContain("\"IL3050\""); + } + + /// Runs the generator over the source and concatenates every generated source for text assertions. + /// The consumer interface source. + /// The concatenation of every generated source. + private static string RunAndConcatenate(string source) + { + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + return string.Join("\n", result.GeneratedSources.Values); + } +} diff --git a/src/tests/Refit.GeneratorTests/RelativePathResolutionLiveTests.cs b/src/tests/Refit.GeneratorTests/RelativePathResolutionLiveTests.cs new file mode 100644 index 000000000..57758b9ea --- /dev/null +++ b/src/tests/Refit.GeneratorTests/RelativePathResolutionLiveTests.cs @@ -0,0 +1,219 @@ +// 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.Reflection; +using System.Text; + +namespace Refit.GeneratorTests; + +/// +/// Compiles, loads and invokes generated request building for a no-leading-slash relative path, asserting it builds +/// inline and matches the reflection request builder: resolving against the base address under RFC 3986, and rejecting +/// the path under legacy resolution exactly as the reflection builder does. +/// +public sealed class RelativePathResolutionLiveTests +{ + /// The base address the relative request URIs resolve against; the sub-path makes RFC 3986 resolution observable. + private const string BaseAddress = "https://example.test/api/v1/"; + + /// The no-leading-slash relative-path method exercised across scenarios. + private const string RelativeMethod = "Relative"; + + /// The rooted-path method exercised across scenarios. + private const string RootedMethod = "Rooted"; + + /// The sample path-argument value. + private const string SampleId = "42"; + + /// The interface source compiled through the generator for every scenario. + private const string ApiSource = + """ + using System.Threading.Tasks; + using Refit; + + namespace Refit.LiveResolution; + + public interface IResolutionApi + { + [Get("/rooted/{id}")] + Task Rooted(string id); + + [Get("relative/{id}")] + Task Relative(string id); + } + """; + + /// Verifies a no-leading-slash path builds inline instead of falling back to the reflection request builder. + /// A task representing the asynchronous test. + [Test] + public async Task GeneratedInlinesNoLeadingSlashPath() + { + var generated = string.Concat(Fixture.RunGenerator(ApiSource, generatedRequestBuilding: true).GeneratedSources.Values); + + await Assert.That(generated).Contains("BuildRequestPath(\"relative/{id}\""); + await Assert.That(generated).DoesNotContain("BuildRestResultFuncForMethod(\"Relative\""); + } + + /// Verifies the generated no-leading-slash and rooted paths match the reflection builder under RFC 3986: the + /// relative path resolves against the base address's sub-path, and the rooted path replaces the whole path. + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Compares generated request building against the reflection request builder.")] + public async Task Rfc3986RelativePathMatchesReflection() + { + using var context = Compile(out var interfaceType, out var generatedType); + using var handler = new CapturingHandler(); + using var client = new HttpClient(handler) { BaseAddress = new(BaseAddress) }; + var requestBuilder = RequestBuilder.ForType(interfaceType, new RefitSettings { UrlResolution = UrlResolutionMode.Rfc3986 }); + var generatedApi = Activator.CreateInstance(generatedType, [client, requestBuilder])!; + + await AssertParityAsync(interfaceType, generatedApi, requestBuilder, client, handler, RelativeMethod, SampleId); + await AssertParityAsync(interfaceType, generatedApi, requestBuilder, client, handler, RootedMethod, SampleId); + + // The relative path resolved against the base sub-path; the rooted path replaced it. + var relative = await InvokeGeneratedAsync(interfaceType, generatedApi, handler, RelativeMethod, SampleId); + await Assert.That(relative.RequestUri!.AbsoluteUri).IsEqualTo("https://example.test/api/v1/relative/42"); + var rooted = await InvokeGeneratedAsync(interfaceType, generatedApi, handler, RootedMethod, SampleId); + await Assert.That(rooted.RequestUri!.AbsoluteUri).IsEqualTo("https://example.test/rooted/42"); + } + + /// Verifies the generated no-leading-slash path throws under legacy resolution, exactly as the reflection + /// request builder rejects it (both raise an ). + /// A task representing the asynchronous test. + [Test] + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + [RequiresDynamicCode("Builds the reflection request delegate for parity comparison.")] + public async Task LegacyRejectsNoLeadingSlashLikeReflection() + { + using var context = Compile(out var interfaceType, out var generatedType); + using var handler = new CapturingHandler(); + using var client = new HttpClient(handler) { BaseAddress = new(BaseAddress) }; + + // The generated client, built from settings alone, rejects the relative path under legacy resolution. + var generatedApi = Activator.CreateInstance(generatedType, [client, new RefitSettings()])!; + ArgumentException? generatedRejection = null; + try + { + _ = await InvokeGeneratedAsync(interfaceType, generatedApi, handler, RelativeMethod, SampleId); + } + catch (ArgumentException ex) + { + generatedRejection = ex; + } + + await Assert.That(generatedRejection).IsNotNull(); + + // The reflection request builder rejects the same interface under legacy resolution. + await Assert.That(() => RequestBuilder.ForType(interfaceType, new RefitSettings())) + .Throws(); + } + + /// Compiles the scenario interface and loads it into a collectible context. + /// The compiled Refit interface type. + /// The generated client type. + /// The collectible load context holding the compiled assembly. + [RequiresUnreferencedCode("Loads a generated assembly and reflects over generated types and members.")] + private static CollectibleAssemblyLoadContext Compile(out Type interfaceType, out Type generatedType) + { + var result = Fixture.RunGenerator(ApiSource, generatedRequestBuilding: true); + if (!result.CompilesWithoutErrors) + { + throw new InvalidOperationException( + "Generated compilation failed: " + string.Join(Environment.NewLine, result.CompilationErrors)); + } + + var (assembly, loadContext) = Fixture.EmitAndLoad(result); + interfaceType = assembly.GetType("Refit.LiveResolution.IResolutionApi", throwOnError: true)!; + var resolvedInterface = interfaceType; + generatedType = assembly.GetTypes().Single(type => type.IsClass && resolvedInterface.IsAssignableFrom(type)); + return loadContext; + } + + /// Invokes a method on the generated client and returns the captured request. + /// The compiled Refit interface type. + /// The generated client instance. + /// The capturing message handler. + /// The interface method name. + /// The path argument value. + /// The captured request. + [RequiresUnreferencedCode("Reflects over generated types and members.")] + private static async Task InvokeGeneratedAsync( + Type interfaceType, + object generatedApi, + CapturingHandler handler, + string methodName, + string id) + { + try + { + await ((Task)interfaceType.GetMethod(methodName)!.Invoke(generatedApi, [id])!).ConfigureAwait(false); + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + throw ex.InnerException; + } + + return handler.TakeLastRequest(); + } + + /// Invokes a method through both request paths and asserts the URIs are identical. + /// The compiled Refit interface type. + /// The generated client instance. + /// The reflection request builder. + /// The HTTP client shared by both paths. + /// The capturing message handler. + /// The interface method name. + /// The path argument value. + /// A task representing the asynchronous assertion. + [RequiresUnreferencedCode("Reflects over generated types and members.")] + [RequiresDynamicCode("Builds the reflection request delegate for parity comparison.")] + private static async Task AssertParityAsync( + Type interfaceType, + object generatedApi, + IRequestBuilder requestBuilder, + HttpClient client, + CapturingHandler handler, + string methodName, + string id) + { + var generatedRequest = await InvokeGeneratedAsync(interfaceType, generatedApi, handler, methodName, id).ConfigureAwait(false); + + var reflectionFunc = requestBuilder.BuildRestResultFuncForMethod(methodName); + await ((Task)reflectionFunc(client, [id])!).ConfigureAwait(false); + var reflectionRequest = handler.TakeLastRequest(); + + await Assert.That(generatedRequest.RequestUri!.AbsoluteUri).IsEqualTo(reflectionRequest.RequestUri!.AbsoluteUri); + } + + /// Captures every outgoing request and returns a fixed JSON string response. + private sealed class CapturingHandler : HttpMessageHandler + { + /// The last request sent through the handler. + private HttpRequestMessage? _lastRequest; + + /// Takes the last captured request, clearing the slot. + /// The captured request. + public HttpRequestMessage TakeLastRequest() + { + var request = _lastRequest ?? throw new InvalidOperationException("No request was captured."); + _lastRequest = null; + return request; + } + + /// + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + _lastRequest = request; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("\"done\"", Encoding.UTF8, "application/json") + }); + } + } +} diff --git a/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.FormBody.cs b/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.FormBody.cs new file mode 100644 index 000000000..23368438e --- /dev/null +++ b/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.FormBody.cs @@ -0,0 +1,208 @@ +// 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; + +/// Request-generation coverage for url-encoded and unrolled form-body emission branches. +public sealed partial class RequestGenerationCoverageTests +{ + /// Verifies every constructor and named argument shape of a form-body query attribute is parsed. + /// A task representing the asynchronous test. + [Test] + public async Task UrlEncodedFormBodyCoversAllQueryShapes() + { + const string Source = + """ + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public class SignupForm + { + [Query("d", "p", "fmt")] + public string WithFormat { get; set; } + + [Query(CollectionFormat.Multi)] + public List Roles { get; set; } + + [Query(Format = "F2")] + public double Amount { get; set; } + + [Query(CollectionFormat = CollectionFormat.Csv)] + public List Ids { get; set; } + + [Query(SerializeNull = true)] + public string Note { get; set; } + + [Query(TreatAsString = true)] + public string Treated { get; set; } + + [AliasAs(null)] + public string Aliased { get; set; } + + public static string Ignored { get; set; } + + public string this[int index] => index.ToString(); + + private string Secret { get; set; } + + public string HiddenGetter { private get; set; } + + public string WriteOnly { set { } } + } + + public interface IGeneratedClient + { + [Post("/signup")] + Task Signup([Body(BodySerializationMethod.UrlEncoded)] SignupForm form); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).Contains( + "global::Refit.FormField[]"); + await Assert.That(generated).Contains("body.@WithFormat"); + await Assert.That(generated).DoesNotContain("body.@Secret"); + await Assert.That(generated).DoesNotContain("body.@Ignored"); + } + + /// Verifies an unrolled scalar form body emits the empty-value branch for a nullable + /// [Query(SerializeNull = true)] field, alongside an unconditionally added value-type field. + /// A task representing the asynchronous test. + [Test] + public async Task UnrolledFormBodyEmitsSerializeNullEmptyBranch() + { + const string Source = + """ + #nullable enable + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class NoteForm + { + public int Count { get; set; } + + [Query(SerializeNull = true)] + public string? Note { get; set; } + } + + public interface IGeneratedClient + { + [Post("/notes")] + Task Submit([Body(BodySerializationMethod.UrlEncoded)] NoteForm form); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("string.Empty"); + } + + /// Verifies an unrolled form body whose only field renders through the formatter (an enum with duplicate + /// constants) declares no default-form-formatting branch and still generates inline. + /// A task representing the asynchronous test. + [Test] + public async Task UnrolledFormBodyWithFormatterOnlyFieldGeneratesInline() + { + const string Source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public enum Duplicated { First = 1, Alias = 1 } + + public sealed class ModeForm + { + public Duplicated Mode { get; set; } + } + + public interface IGeneratedClient + { + [Post("/modes")] + Task Submit([Body(BodySerializationMethod.UrlEncoded)] ModeForm form); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies form bodies of otherwise-ineligible type kinds keep the reflection content path. + /// The method signature exercising a specific ineligible body type kind. + /// A task representing the asynchronous test. + [Test] + [Arguments("Task Post([Body(BodySerializationMethod.UrlEncoded)] TBody body);")] + [Arguments("Task Post([Body(BodySerializationMethod.UrlEncoded)] System.Collections.Generic.List body);")] + [Arguments("Task Post([Body(BodySerializationMethod.UrlEncoded)] dynamic body);")] + [Arguments("Task Post([Body(BodySerializationMethod.UrlEncoded)] MissingBodyType body);")] + [Arguments("Task Post([Body(BodySerializationMethod.UrlEncoded)] int* body);")] + public async Task IneligibleFormBodyTypeKindsUseReflectionContent(string signature) + { + var source = + $$""" + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Post("/x")] + {{signature}} + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(generated).DoesNotContain("global::Refit.FormField<"); + } + + /// Verifies url-encoded form bodies covering nullable collection entries and escaped field names. + /// A task representing the asynchronous test. + [Test] + public async Task UrlEncodedFormBodyCoversNullableCollectionAndEscapedNames() + { + const string Source = + """ + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class Form + { + public List? Tags { get; set; } + [AliasAs("we\"ird\\name")] public string? Odd { get; set; } + public string? Plain { get; set; } + } + + public interface IGeneratedClient + { + [Post("/f")] + Task Submit([Body(BodySerializationMethod.UrlEncoded)] Form form); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + } +} diff --git a/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.Multipart.cs b/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.Multipart.cs new file mode 100644 index 000000000..b8aff0979 --- /dev/null +++ b/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.Multipart.cs @@ -0,0 +1,252 @@ +// 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; + +/// Request-generation coverage for inline multipart content emission branches. +public sealed partial class RequestGenerationCoverageTests +{ + /// Verifies a multipart method with statically-dispatchable parts builds its content inline. + /// A task representing the asynchronous test. + [Test] + public async Task MultipartMethodGeneratesInline() + { + const string Source = + """ + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Multipart] + [Post("/upload")] + Task Upload([AliasAs("file")] IEnumerable streams); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("new global::System.Net.Http.MultipartFormDataContent(\"----MyGreatBoundary\")"); + } + + /// Verifies a multipart method with an object-typed part still falls back to the reflective builder. + /// A task representing the asynchronous test. + [Test] + public async Task MultipartMethodWithObjectPartFallsBack() + { + const string Source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Multipart] + [Post("/upload")] + Task Upload(object payload); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + } + + /// Verifies a multipart method with an part adds the content directly and generates inline. + /// A task representing the asynchronous test. + [Test] + public async Task MultipartHttpContentPartGeneratesInline() + { + const string Source = + """ + using System.Net.Http; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Multipart] + [Post("/upload")] + Task Upload([AliasAs("content")] HttpContent content); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains(".Add(@content)"); + } + + /// Verifies a [Query] parameter inside a multipart method feeds the query string rather than a part. + /// A task representing the asynchronous test. + [Test] + public async Task MultipartQueryParameterFeedsQueryStringInline() + { + const string Source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Multipart] + [Post("/upload")] + Task Upload([Query] string filter, [AliasAs("file")] StreamPart part); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("MultipartFormDataContent"); + } + + /// Verifies multipart parts classified through the single-value and reference-enumerable element paths: + /// a nullable formattable, an array of strings, an enumerable of byte arrays, and a list of strings. + /// A task representing the asynchronous test. + [Test] + public async Task MultipartEnumerableAndNullableElementPartsGenerateInline() + { + const string Source = + """ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Multipart] + [Post("/a")] + Task UploadNullableId([AliasAs("id")] Guid? id); + + [Multipart] + [Post("/b")] + Task UploadTags([AliasAs("tag")] string[] tags); + + [Multipart] + [Post("/c")] + Task UploadBlobs([AliasAs("blob")] IEnumerable blobs); + + [Multipart] + [Post("/d")] + Task UploadNames([AliasAs("name")] List names); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies an explicit [Multipart(null)] boundary argument falls to the attribute default boundary. + /// A task representing the asynchronous test. + [Test] + public async Task MultipartNullBoundaryArgumentUsesDefaultBoundary() + { + const string Source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Multipart(null)] + [Post("/upload")] + Task Upload([AliasAs("file")] StreamPart part); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("new global::System.Net.Http.MultipartFormDataContent(\"----MyGreatBoundary\")"); + } + + /// Verifies an obsolete [AttachmentName] override supplies a multipart part's file name. + /// A task representing the asynchronous test. + [Test] + public async Task MultipartAttachmentNameOverridesFileName() + { + // AttachmentNameAttribute is obsolete but still parsed by the generator; it lives inside the generator-input + // source string, so no obsolete warning reaches the test project itself. + const string Source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Multipart] + [Post("/upload")] + Task Upload([AliasAs("blob")][AttachmentName("custom.bin")] byte[] data); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("\"custom.bin\""); + } + + /// Verifies multipart parts whose declared type is not statically dispatchable fall back to the reflection + /// builder: a [Query] object whose shape cannot flatten, a reference enumerable of an undispatchable element, + /// a value-type array, and a value-type enumerable. + /// The interface member body declaring the undispatchable part. + /// A task representing the asynchronous test. + [Test] + [Arguments("[Multipart][Post(\"/a\")] Task A([Query] object filter, [AliasAs(\"file\")] StreamPart part);")] + [Arguments("[Multipart][Post(\"/b\")] Task B([AliasAs(\"o\")] System.Collections.Generic.IEnumerable objs);")] + [Arguments("[Multipart][Post(\"/c\")] Task C([AliasAs(\"n\")] int[] nums);")] + [Arguments("[Multipart][Post(\"/d\")] Task D([AliasAs(\"n\")] System.Collections.Generic.IEnumerable nums);")] + public async Task MultipartUndispatchablePartsFallBack(string body) + { + var source = + $$""" + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + {{body}} + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + } +} diff --git a/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.Query.cs b/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.Query.cs new file mode 100644 index 000000000..85ef1c0ac --- /dev/null +++ b/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.Query.cs @@ -0,0 +1,458 @@ +// 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; + +/// Request-generation coverage for inline query-string emission branches. +public sealed partial class RequestGenerationCoverageTests +{ + /// Verifies enum query values across renamed, duplicate-valued, reused and collection shapes. + /// A task representing the asynchronous test. + [Test] + public async Task EnumQueryValuesGenerateInline() + { + const string Source = + """ + using System.Runtime.Serialization; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public enum Renamed + { + [EnumMember(Value = "one")] First, + [EnumMember] Second, + Third, + } + + public enum Duplicated + { + A = 1, + B = 1, + } + + public interface IGeneratedClient + { + [Get("/a")] Task Renamed1([Query] Renamed value); + [Get("/b")] Task Renamed2([Query] Renamed value); + [Get("/c")] Task Dup([Query] Duplicated value); + [Get("/d")] Task DupCollection([Query(CollectionFormat.Multi)] Duplicated[] values); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + } + + /// Verifies an enum whose [EnumMember(Value = null)] override is a null literal falls back to the + /// member name rather than reading a string override. + /// A task representing the asynchronous test. + [Test] + public async Task EnumMemberWithNullValueOverrideGeneratesInline() + { + const string Source = + """ + using System.Runtime.Serialization; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public enum NullOverride + { + [EnumMember(Value = null)] First, + Second, + } + + public interface IGeneratedClient + { + [Get("/a")] Task Get([Query] NullOverride value); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies non-nullable dictionary, converter and collection query parameters generate inline. + /// A task representing the asynchronous test. + [Test] + public async Task NonNullableQueryParameterShapesGenerateInline() + { + const string Source = + """ + #nullable enable + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class MapConverter : IQueryConverter> + { + public void Flatten(IDictionary value, string keyPrefix, ref GeneratedQueryStringBuilder builder, RefitSettings settings) + { + foreach (var entry in value) + { + builder.Add(keyPrefix + entry.Key, settings.UrlParameterFormatter.Format(entry.Value, typeof(object), typeof(object)), false); + } + } + } + + public interface IGeneratedClient + { + [Get("/d")] Task Dict(IDictionary query); + [Get("/c")] Task Converter([QueryConverter(typeof(MapConverter))] IDictionary filter); + [Get("/l")] Task Collection([Query(CollectionFormat.Multi)] int[] ids); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(result.GeneratedSources[GeneratedClientHintName]).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies value-type collection and converter query parameters reach the non-guarded emission path. + /// A task representing the asynchronous test. + [Test] + public async Task ValueTypeQueryParametersGenerateInline() + { + const string Source = + """ + #nullable enable + using System.Collections.Immutable; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public struct Pair { public int A { get; set; } public int B { get; set; } } + + public sealed class PairConverter : IQueryConverter + { + public void Flatten(Pair value, string keyPrefix, ref GeneratedQueryStringBuilder builder, RefitSettings settings) { } + } + + public interface IGeneratedClient + { + [Get("/l")] Task Collection([Query(CollectionFormat.Multi)] ImmutableArray ids); + [Get("/c")] Task Converter([QueryConverter(typeof(PairConverter))] Pair pair); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + } + + /// Verifies two parameters bound to the same converter type reuse a single cached converter field. + /// A task representing the asynchronous test. + [Test] + public async Task RepeatedQueryConverterTypeReusesField() + { + const string Source = + """ + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class MapConverter : IQueryConverter> + { + public void Flatten(IDictionary value, string keyPrefix, ref GeneratedQueryStringBuilder builder, RefitSettings settings) { } + } + + public interface IGeneratedClient + { + [Get("/a")] Task First([QueryConverter(typeof(MapConverter))] IDictionary a); + [Get("/b")] Task Second([QueryConverter(typeof(MapConverter))] IDictionary b); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + } + + /// Verifies a dictionary query parameter with a key prefix whose values are complex objects flattens each + /// value's properties under the prefixed entry key. + /// A task representing the asynchronous test. + [Test] + public async Task DictionaryQueryWithComplexValuesAndPrefixFlattensInline() + { + const string Source = + """ + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class Bounds + { + public int Min { get; set; } + + public int Max { get; set; } + } + + public interface IGeneratedClient + { + [Get("/search")] + Task Search([Query(".", "f")] IDictionary filters); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("_prefixed"); + } + + /// Verifies a query parameter is treated as a scalar and rendered inline. + /// A task representing the asynchronous test. + [Test] + public async Task UriQueryParameterGeneratesInline() + { + const string Source = + """ + using System; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/redirect")] + Task Get([Query] Uri target); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies dictionary query parameters with formattable keys and values render through the reflection-free + /// fast key/value paths, and string-keyed dictionaries render through the formatter-only path. + /// A task representing the asynchronous test. + [Test] + public async Task FormattableAndStringKeyedDictionaryQueriesGenerateInline() + { + const string Source = + """ + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/n")] Task Numbers(IDictionary map); + [Get("/s")] Task Strings(IDictionary map); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies value-type and nullable-value-type element collection queries render inline through both element + /// null-guard branches. + /// A task representing the asynchronous test. + [Test] + public async Task ValueAndNullableElementCollectionQueriesGenerateInline() + { + const string Source = + """ + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/v")] Task Values([Query(CollectionFormat.Multi)] int[] ids); + [Get("/n")] Task Nullables([Query(CollectionFormat.Multi)] List ids); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies an object query parameter with an explicit delimiter, and a converter parameter with a key + /// prefix and delimiter, both fold their delimiter into the composed keys inline. + /// A task representing the asynchronous test. + [Test] + public async Task ObjectDelimiterAndPrefixedConverterQueriesGenerateInline() + { + const string Source = + """ + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class Filter + { + public string? Name { get; set; } + + public int Age { get; set; } + } + + public sealed class FilterConverter : IQueryConverter + { + public void Flatten(Filter value, string keyPrefix, ref GeneratedQueryStringBuilder builder, RefitSettings settings) { } + } + + public interface IGeneratedClient + { + [Get("/o")] Task Object([Query("-")] Filter filter); + [Get("/c")] Task Converter([Query("-", "pfx")][QueryConverter(typeof(FilterConverter))] Filter filter); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies duplicate-constant enum keys, values and formats force the formatter-only key/value branches of + /// dictionary and object query emission: dictionaries keyed and valued by a duplicate-constant enum, a prefixed + /// scalar dictionary, a query object whose dictionary properties carry duplicate-constant enum keys and values, its + /// string and value-type collection properties, a formatted duplicate-enum scalar and a plain duplicate-enum scalar, + /// and a valueless [QueryName] flag. + /// A task representing the asynchronous test. + [Test] + public async Task DuplicateEnumDictionaryAndObjectQueriesUseFormatterOnlyBranches() + { + const string Source = + """ + using System.Collections.Generic; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public enum Dup { A = 1, B = 1 } + + public sealed class ObjQuery + { + public IDictionary ByEnumValue { get; set; } = new Dictionary(); + + public IDictionary ByEnumKey { get; set; } = new Dictionary(); + + public string[] Names { get; set; } = System.Array.Empty(); + + public int[] Numbers { get; set; } = System.Array.Empty(); + + public int?[] MaybeNumbers { get; set; } = System.Array.Empty(); + + [Query(Format = "D")] + public Dup FormattedFlag { get; set; } + + public Dup PlainFlag { get; set; } + } + + public interface IGeneratedClient + { + [Get("/a")] Task EnumKey(IDictionary map); + [Get("/b")] Task EnumValue(IDictionary map); + [Get("/c")] Task Prefixed([Query(".", "pfx")] IDictionary map); + [Get("/d")] Task Object(ObjQuery query); + [Get("/e")] Task Flag([QueryName] string tag); + [Get("/f")] Task Collection([Query(CollectionFormat.Multi)] string[] tags); + [Get("/g")] Task FlagCollection([QueryName] string[] tags); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies a query object flattens property-level branches: a nested object property whose child carries + /// a [JsonPropertyName] and a prefixed [Query], a nullable-struct nested property flattened through + /// .Value, a string-element collection, and a formatted scalar. + /// A task representing the asynchronous test. + [Test] + public async Task QueryObjectPropertyBranchesFlattenInline() + { + const string Source = + """ + #nullable enable + using System.Text.Json.Serialization; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class Inner + { + [JsonPropertyName("child_name")] + public string? Child { get; set; } + + [Query("-", "p")] + public string? Prefixed { get; set; } + } + + public struct Point + { + public int X { get; set; } + + public int Y { get; set; } + } + + public sealed class Outer + { + public Inner Nested { get; set; } = new(); + + public Point? Maybe { get; set; } + + public string[]? Names { get; set; } + + [Query(Format = "0.00")] + public double Amount { get; set; } + } + + public interface IGeneratedClient + { + [Get("/o")] + Task Find([Query] Outer query); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("child_name"); + } +} diff --git a/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.RequestTargets.cs b/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.RequestTargets.cs new file mode 100644 index 000000000..6d00a74e6 --- /dev/null +++ b/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.RequestTargets.cs @@ -0,0 +1,177 @@ +// 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; + +/// Request-generation coverage for HTTP verb resolution and method-targeting branches. +public sealed partial class RequestGenerationCoverageTests +{ + /// Verifies every built-in HTTP verb attribute resolves its verb and generates inline. + /// A task representing the asynchronous test. + [Test] + public async Task AllStandardHttpVerbsResolveInline() + { + const string Source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Delete("/a")] Task Delete(); + [Get("/b")] Task Get(); + [Head("/c")] Task Head(); + [Options("/d")] Task Options(); + [Patch("/e")] Task Patch(); + [Post("/f")] Task Post(); + [Put("/g")] Task Put(); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("HttpMethod.Delete"); + await Assert.That(generated).Contains("HttpMethod.Head"); + } + + /// Verifies a built-in HTTP verb attribute on a non-interface method is ignored by the syntax filter. + /// A task representing the asynchronous test. + [Test] + public async Task HttpAttributeOnClassMethodIsIgnored() + { + const string Source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public class NotAnInterface + { + [Get("/x")] + public Task Get() => Task.FromResult(string.Empty); + } + + public interface IGeneratedClient + { + [Get("/y")] + Task Get(); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.GeneratedSources.ContainsKey(GeneratedClientHintName)).IsTrue(); + await Assert.That(result.GeneratedSources.ContainsKey("NotAnInterface.g.cs")).IsFalse(); + } + + /// Verifies a built-in HTTP verb attribute applied only to non-interface methods produces no generated + /// client at all, exercising the syntax filter's rejection with no interface candidate present. + /// A task representing the asynchronous test. + [Test] + public async Task HttpAttributeOnlyOnClassMethodsProducesNoClient() + { + const string Source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public class OnlyClass + { + [Get("/x")] + public Task Get() => Task.FromResult(string.Empty); + + [Post("/y")] + public Task Post() => Task.FromResult(string.Empty); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.GeneratedSources.ContainsKey("OnlyClass.g.cs")).IsFalse(); + } + + /// Verifies an interface declared in the global namespace generates a stable stub inline. + /// A task representing the asynchronous test. + [Test] + public async Task GlobalNamespaceInterfaceGeneratesInline() + { + const string Source = + """ + using System.Threading.Tasks; + using Refit; + + public interface IGlobalClient + { + [Get("/global")] + Task Get(); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(result.GeneratedSources.ContainsKey("IGlobalClient.g.cs")).IsTrue(); + } + + /// Verifies a [Property] parameter that also carries [Query] but whose type cannot flatten + /// inline falls the method back to the reflection builder. + /// A task representing the asynchronous test. + [Test] + public async Task PropertyAndQueryParameterOfUnflattenableTypeFallsBack() + { + const string Source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/x")] + Task Get([Property("Trace")][Query] object value); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + } + + /// Verifies an [Authorize(null)] parameter falls back to the default authorization scheme. + /// A task representing the asynchronous test. + [Test] + public async Task AuthorizeWithNullSchemeUsesDefaultScheme() + { + const string Source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/a")] + Task Get([Authorize(null)] string token); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("\"Bearer \""); + } +} diff --git a/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.ReturnAndHttpMethod.cs b/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.ReturnAndHttpMethod.cs new file mode 100644 index 000000000..6a5202348 --- /dev/null +++ b/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.ReturnAndHttpMethod.cs @@ -0,0 +1,282 @@ +// 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.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Refit.Generator; + +namespace Refit.GeneratorTests; + +/// Request-generation coverage for return-type classification and custom HTTP method verb resolution. +public sealed partial class RequestGenerationCoverageTests +{ + /// The interface source exercising each statically-readable custom verb getter shape. + private const string CustomVerbGetterShapesSource = + """ + using System.Net.Http; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public abstract class ReportMethodBaseAttribute : HttpMethodAttribute + { + protected ReportMethodBaseAttribute(string path) : base(path) { } + + public override HttpMethod Method => new HttpMethod("REPORT"); + } + + public sealed class ReportAttribute : ReportMethodBaseAttribute + { + public ReportAttribute(string path) : base(path) { } + } + + // The leaf shadows the inherited Method property with a same-named method, so property lookup skips it and + // walks past to the base's readable Method override. + public sealed class WalkAttribute : ReportMethodBaseAttribute + { + public WalkAttribute(string path) : base(path) { } + + public new void Method() { } + } + + public sealed class PurgeAttribute : HttpMethodAttribute + { + public PurgeAttribute(string path) : base(path) { } + + public override HttpMethod Method { get => new HttpMethod("PURGE"); } + } + + public sealed class TraceMethodAttribute : HttpMethodAttribute + { + public TraceMethodAttribute(string path) : base(path) { } + + public override HttpMethod Method { get { return new HttpMethod("TRACE"); } } + } + + public interface IGeneratedClient + { + [Report("/report")] Task Report(); + + [Walk("/walk")] Task Walk(); + + [Purge("/purge")] Task Purge(); + + [TraceMethod("/trace")] Task Trace(); + } + """; + + /// Verifies the generic API response interface return type is classified as an API response. + /// A task representing the asynchronous test. + [Test] + public async Task GenericApiResponseInterfaceReturnIsClassified() + { + const string Source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/typed")] + Task> Get(); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies a method returning a non-named type is parsed without inline generation. + /// A task representing the asynchronous test. + [Test] + public async Task NonNamedReturnTypeIsParsed() + { + const string Source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/array")] + string[] GetArray(); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.GeneratedSources.ContainsKey(GeneratedClientHintName)).IsTrue(); + } + + /// Verifies the scalar classifier evaluates its full special-type pattern across representative types. + /// The path parameter type expression. + /// A task representing the asynchronous test. + [Test] + [Arguments("int")] + [Arguments("string")] + [Arguments("System.DateTime")] + [Arguments("System.Guid")] + [Arguments("object")] + public async Task ScalarClassifierEvaluatesSpecialTypePattern(string parameterType) + { + var source = + $$""" + using System; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/items/{value}")] + Task Get({{parameterType}} value); + } + """; + + var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); + + await Assert.That(result.GeneratedSources.ContainsKey(GeneratedClientHintName)).IsTrue(); + } + + /// Verifies a Refit-namespaced result type that is not an API response is classified accordingly. + /// A task representing the asynchronous test. + [Test] + public async Task RefitNonResponseReturnTypeIsNotClassifiedAsApiResponse() + { + const string Source = + """ + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IGeneratedClient + { + [Get("/exception")] + Task Get(); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.GeneratedSources.ContainsKey(GeneratedClientHintName)).IsTrue(); + } + + /// Verifies the HTTP method attribute lookup returns null when no matching attribute is present. + /// A task representing the asynchronous test. + [Test] + public async Task FindHttpMethodAttributeReturnsNullWithoutHttpAttribute() + { + const string Source = + """ + using System; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public interface IApi + { + [Obsolete] + Task Tagged(); + + Task Bare(); + } + """; + + var compilation = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(Source)); + var httpMethodBase = compilation.GetTypeByMetadataName("Refit.HttpMethodAttribute")!; + var api = compilation.GetTypeByMetadataName("RefitGeneratorTest.IApi")!; + var tagged = api.GetMembers("Tagged").OfType().First(); + var bare = api.GetMembers("Bare").OfType().First(); + + await Assert.That(Parser.FindHttpMethodAttribute(tagged, httpMethodBase)).IsNull(); + await Assert.That(Parser.FindHttpMethodAttribute(bare, httpMethodBase)).IsNull(); + } + + /// Verifies an unbalanced brace segment makes a path template unsupported for inline generation. + /// A task representing the asynchronous test. + [Test] + public async Task IsPathSupportedRejectsUnbalancedBraceSegment() + { + await Assert.That(Parser.IsPathSupported("/root/{open/leaf}")).IsFalse(); + await Assert.That(Parser.IsPathSupported("/root/{closed}/leaf")).IsTrue(); + } + + /// Verifies custom HTTP method attributes whose Method getter is statically readable resolve their + /// verbs inline: an inherited expression-bodied property, an expression-bodied accessor, and a block accessor. + /// A task representing the asynchronous test. + [Test] + public async Task CustomHttpMethodAttributeGetterShapesResolveVerbsInline() + { + var result = Fixture.RunGenerator(CustomVerbGetterShapesSource, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("new global::System.Net.Http.HttpMethod(\"REPORT\")"); + await Assert.That(generated).Contains("new global::System.Net.Http.HttpMethod(\"PURGE\")"); + await Assert.That(generated).Contains("new global::System.Net.Http.HttpMethod(\"TRACE\")"); + } + + /// Verifies custom HTTP method attributes whose verb is not statically readable fall back: a shadowing + /// non-HttpMethod Method property, and an auto-property getter with no readable body. + /// A task representing the asynchronous test. + [Test] + public async Task CustomHttpMethodAttributesWithUnreadableVerbsFallBack() + { + const string Source = + """ + using System.Net.Http; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public abstract class ShadowBaseAttribute : HttpMethodAttribute + { + protected ShadowBaseAttribute(string path) : base(path) { } + + public override HttpMethod Method => new HttpMethod("SHADOW"); + } + + public sealed class ShadowAttribute : ShadowBaseAttribute + { + public ShadowAttribute(string path) : base(path) { } + + public new string Method => "SHADOW"; + } + + public sealed class OpaqueAttribute : HttpMethodAttribute + { + public OpaqueAttribute(string path) : base(path) { } + + public override HttpMethod Method { get; } = new HttpMethod("OPAQUE"); + } + + public interface IGeneratedClient + { + [Shadow("/shadow")] Task Shadow(); + + [Opaque("/opaque")] Task Opaque(); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + } +} diff --git a/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.cs b/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.cs index 6530de82a..a8997f931 100644 --- a/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.cs +++ b/src/tests/Refit.GeneratorTests/RequestGenerationCoverageTests.cs @@ -2,15 +2,10 @@ // 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.Linq; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Refit.Generator; - namespace Refit.GeneratorTests; /// Focused tests that drive request parsing and inline emission branches to full coverage. -public sealed class RequestGenerationCoverageTests +public sealed partial class RequestGenerationCoverageTests { /// The generated implementation source hint name used by these tests. private const string GeneratedClientHintName = "IGeneratedClient.g.cs"; @@ -45,71 +40,6 @@ public interface IGeneratedClient await Assert.That(generated).Contains("BuildRequestPath"); } - /// Verifies every constructor and named argument shape of a form-body query attribute is parsed. - /// A task representing the asynchronous test. - [Test] - public async Task UrlEncodedFormBodyCoversAllQueryShapes() - { - const string Source = - """ - using System.Collections.Generic; - using System.Threading.Tasks; - using Refit; - - namespace RefitGeneratorTest; - - public class SignupForm - { - [Query("d", "p", "fmt")] - public string WithFormat { get; set; } - - [Query(CollectionFormat.Multi)] - public List Roles { get; set; } - - [Query(Format = "F2")] - public double Amount { get; set; } - - [Query(CollectionFormat = CollectionFormat.Csv)] - public List Ids { get; set; } - - [Query(SerializeNull = true)] - public string Note { get; set; } - - [Query(TreatAsString = true)] - public string Treated { get; set; } - - [AliasAs(null)] - public string Aliased { get; set; } - - public static string Ignored { get; set; } - - public string this[int index] => index.ToString(); - - private string Secret { get; set; } - - public string HiddenGetter { private get; set; } - - public string WriteOnly { set { } } - } - - public interface IGeneratedClient - { - [Post("/signup")] - Task Signup([Body(BodySerializationMethod.UrlEncoded)] SignupForm form); - } - """; - - var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); - var generated = result.GeneratedSources[GeneratedClientHintName]; - - await Assert.That(result.CompilesWithoutErrors).IsTrue(); - await Assert.That(generated).Contains( - "global::Refit.FormField[]"); - await Assert.That(generated).Contains("body.@WithFormat"); - await Assert.That(generated).DoesNotContain("body.@Secret"); - await Assert.That(generated).DoesNotContain("body.@Ignored"); - } - /// Verifies dynamic header parameters with valid, null, and whitespace names are parsed. /// A task representing the asynchronous test. [Test] @@ -257,49 +187,26 @@ public interface IGeneratedClient await Assert.That(generated).Contains("AliasAsAttribute(null)"); } - /// Verifies a method carrying an unsupported multipart attribute falls back to the reflective builder. + /// Verifies a non-[Encoded] round-trip catch-all path parameter of any type generates inline. /// A task representing the asynchronous test. [Test] - public async Task MultipartMethodFallsBack() + public async Task RoundTripCatchAllPathGeneratesInline() { const string Source = """ - using System.Collections.Generic; using System.Threading.Tasks; using Refit; namespace RefitGeneratorTest; - public interface IGeneratedClient + public readonly record struct RepoPath(string Value) { - [Multipart] - [Post("/upload")] - Task Upload([AliasAs("file")] IEnumerable streams); + public override string ToString() => Value; } - """; - - var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); - var generated = result.GeneratedSources[GeneratedClientHintName]; - - await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); - } - - /// Verifies the generic API response interface return type is classified as an API response. - /// A task representing the asynchronous test. - [Test] - public async Task GenericApiResponseInterfaceReturnIsClassified() - { - const string Source = - """ - using System.Threading.Tasks; - using Refit; - - namespace RefitGeneratorTest; public interface IGeneratedClient { - [Get("/typed")] - Task> Get(); + [Get("/repos/{**value}/contents")] Task Get(RepoPath value); } """; @@ -308,12 +215,13 @@ public interface IGeneratedClient await Assert.That(result.CompilesWithoutErrors).IsTrue(); await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("RoundTripEscapePath"); } - /// Verifies a method returning a non-named type is parsed without inline generation. + /// Verifies an [Authorize] parameter generates an inline Authorization header with its scheme. /// A task representing the asynchronous test. [Test] - public async Task NonNamedReturnTypeIsParsed() + public async Task AuthorizeParameterGeneratesInlineAuthorizationHeader() { const string Source = """ @@ -324,81 +232,25 @@ namespace RefitGeneratorTest; public interface IGeneratedClient { - [Get("/array")] - string[] GetArray(); + [Get("/a")] Task DefaultScheme([Authorize] string token); + [Get("/b")] Task ExplicitScheme([Authorize("Token")] string token); } """; var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); - - await Assert.That(result.GeneratedSources.ContainsKey(GeneratedClientHintName)).IsTrue(); - } - - /// Verifies the scalar classifier evaluates its full special-type pattern across representative types. - /// The path parameter type expression. - /// A task representing the asynchronous test. - [Test] - [Arguments("int")] - [Arguments("string")] - [Arguments("System.DateTime")] - [Arguments("System.Guid")] - [Arguments("object")] - public async Task ScalarClassifierEvaluatesSpecialTypePattern(string parameterType) - { - var source = - $$""" - using System; - using System.Threading.Tasks; - using Refit; - - namespace RefitGeneratorTest; - - public interface IGeneratedClient - { - [Get("/items/{value}")] - Task Get({{parameterType}} value); - } - """; - - var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); - - await Assert.That(result.GeneratedSources.ContainsKey(GeneratedClientHintName)).IsTrue(); - } - - /// Verifies form bodies of otherwise-ineligible type kinds keep the reflection content path. - /// The method signature exercising a specific ineligible body type kind. - /// A task representing the asynchronous test. - [Test] - [Arguments("Task Post([Body(BodySerializationMethod.UrlEncoded)] TBody body);")] - [Arguments("Task Post([Body(BodySerializationMethod.UrlEncoded)] dynamic body);")] - [Arguments("Task Post([Body(BodySerializationMethod.UrlEncoded)] MissingBodyType body);")] - [Arguments("Task Post([Body(BodySerializationMethod.UrlEncoded)] int* body);")] - public async Task IneligibleFormBodyTypeKindsUseReflectionContent(string signature) - { - var source = - $$""" - using System.Threading.Tasks; - using Refit; - - namespace RefitGeneratorTest; - - public interface IGeneratedClient - { - [Post("/x")] - {{signature}} - } - """; - - var result = Fixture.RunGenerator(source, generatedRequestBuilding: true); var generated = result.GeneratedSources[GeneratedClientHintName]; - await Assert.That(generated).DoesNotContain("global::Refit.FormField<"); + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + await Assert.That(generated).Contains("\"Authorization\""); + await Assert.That(generated).Contains("\"Bearer \""); + await Assert.That(generated).Contains("\"Token \""); } - /// Verifies a Refit-namespaced result type that is not an API response is classified accordingly. + /// Verifies a dotted path placeholder whose intermediate segment property does not exist falls back. /// A task representing the asynchronous test. [Test] - public async Task RefitNonResponseReturnTypeIsNotClassifiedAsApiResponse() + public async Task DottedPathWithMissingNestedPropertyFallsBack() { const string Source = """ @@ -407,56 +259,49 @@ public async Task RefitNonResponseReturnTypeIsNotClassifiedAsApiResponse() namespace RefitGeneratorTest; + public sealed class Inner { public string Slug { get; set; } } + + public sealed class Outer { public Inner Inner { get; set; } } + public interface IGeneratedClient { - [Get("/exception")] - Task Get(); + [Get("/x/{route.Inner.Missing}")] + Task Get(Outer route); } """; var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; - await Assert.That(result.GeneratedSources.ContainsKey(GeneratedClientHintName)).IsTrue(); + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); } - /// Verifies the HTTP method attribute lookup returns null when no matching attribute is present. + /// Verifies a dotted path placeholder whose final property is a complex type falls back. /// A task representing the asynchronous test. [Test] - public async Task FindHttpMethodAttributeReturnsNullWithoutHttpAttribute() + public async Task DottedPathWithComplexFinalPropertyFallsBack() { const string Source = """ - using System; using System.Threading.Tasks; using Refit; namespace RefitGeneratorTest; - public interface IApi - { - [Obsolete] - Task Tagged(); + public sealed class Inner { public string Slug { get; set; } } - Task Bare(); + public sealed class Outer { public Inner Inner { get; set; } } + + public interface IGeneratedClient + { + [Get("/x/{route.Inner}")] + Task Get(Outer route); } """; - var compilation = Fixture.CreateLibrary(CSharpSyntaxTree.ParseText(Source)); - var httpMethodBase = compilation.GetTypeByMetadataName("Refit.HttpMethodAttribute")!; - var api = compilation.GetTypeByMetadataName("RefitGeneratorTest.IApi")!; - var tagged = api.GetMembers("Tagged").OfType().First(); - var bare = api.GetMembers("Bare").OfType().First(); - - await Assert.That(Parser.FindHttpMethodAttribute(tagged, httpMethodBase)).IsNull(); - await Assert.That(Parser.FindHttpMethodAttribute(bare, httpMethodBase)).IsNull(); - } + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + var generated = result.GeneratedSources[GeneratedClientHintName]; - /// Verifies an unbalanced brace segment makes a path template unsupported for inline generation. - /// A task representing the asynchronous test. - [Test] - public async Task IsPathSupportedRejectsUnbalancedBraceSegment() - { - await Assert.That(Parser.IsPathSupported("/root/{open/leaf}")).IsFalse(); - await Assert.That(Parser.IsPathSupported("/root/{closed}/leaf")).IsTrue(); + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); } } diff --git a/src/tests/Refit.GeneratorTests/ReturnShapeInlineGenerationTests.cs b/src/tests/Refit.GeneratorTests/ReturnShapeInlineGenerationTests.cs new file mode 100644 index 000000000..2469ccba4 --- /dev/null +++ b/src/tests/Refit.GeneratorTests/ReturnShapeInlineGenerationTests.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. +namespace Refit.GeneratorTests; + +/// Generator tests covering which non-Task return shapes build their request inline versus falling back +/// to the reflective request builder. +public class ReturnShapeInlineGenerationTests +{ + /// 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-message construction emitted by inline request construction. + private const string NewHttpRequestMessage = "new global::System.Net.Http.HttpRequestMessage"; + + /// The inline streaming send call emitted for an IAsyncEnumerable return. + private const string StreamAsyncCall = "GeneratedRequestRunner.StreamAsync<"; + + /// The inline cold-observable send call emitted for an IObservable return. + private const string SendObservableCall = "GeneratedRequestRunner.SendObservable<"; + + /// Verifies an IAsyncEnumerable method is generated inline through StreamAsync, not the reflective builder. + /// A task representing the asynchronous test. + [Test] + public async Task IAsyncEnumerableUsesInlineStreamAsync() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/items")] + IAsyncEnumerable Stream(); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains(StreamAsyncCall); + await Assert.That(generated).Contains(NewHttpRequestMessage); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies an IAsyncEnumerable method falls back to the reflective builder when inline generation is off. + /// A task representing the asynchronous test. + [Test] + public async Task IAsyncEnumerableSwitchOffUsesReflectiveRequestBuilder() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/items")] + IAsyncEnumerable Stream(); + """, + GeneratedClientHintName, + generatedRequestBuilding: false); + + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + await Assert.That(generated).DoesNotContain(StreamAsyncCall); + } + + /// Verifies an IObservable method is generated inline through SendObservable, not the reflective builder. + /// A task representing the asynchronous test. + [Test] + public async Task ObservableUsesInlineSendObservable() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/items")] + IObservable Watch(); + """, + GeneratedClientHintName, + generatedRequestBuilding: true); + + await Assert.That(generated).Contains(SendObservableCall); + await Assert.That(generated).Contains(NewHttpRequestMessage); + await Assert.That(generated).DoesNotContain(ReflectiveRequestBuilderCall); + } + + /// Verifies an IObservable method falls back to the reflective builder when inline generation is off. + /// A task representing the asynchronous test. + [Test] + public async Task ObservableSwitchOffUsesReflectiveRequestBuilder() + { + var generated = Fixture.GenerateForBody( + """ + [Get("/items")] + IObservable Watch(); + """, + GeneratedClientHintName, + generatedRequestBuilding: false); + + await Assert.That(generated).Contains(ReflectiveRequestBuilderCall); + await Assert.That(generated).DoesNotContain(SendObservableCall); + } +} diff --git a/src/tests/Refit.GeneratorTests/ReturnTypeAdapterGenerationTests.cs b/src/tests/Refit.GeneratorTests/ReturnTypeAdapterGenerationTests.cs new file mode 100644 index 000000000..b77a2400c --- /dev/null +++ b/src/tests/Refit.GeneratorTests/ReturnTypeAdapterGenerationTests.cs @@ -0,0 +1,359 @@ +// 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; + +/// +/// Verifies that a method returning a type served by a registered IReturnTypeAdapter generates inline, +/// surfacing the declared return type through a deferred Adapt call over the source-generated send. +/// +public sealed class ReturnTypeAdapterGenerationTests +{ + /// The generated implementation source hint name. + private const string Hint = "IGeneratedClient.g.cs"; + + /// The reflective request-builder call emitted by fallback paths. + private const string ReflectiveFallback = "BuildRestResultFuncForMethod"; + + /// The deferred adapter invocation emitted for an adapter-backed return type. + private const string AdaptCall = ".Adapt("; + + /// Verifies an adapter-backed return type generates a deferred inline Adapt call. + /// A task representing the asynchronous test. + [Test] + public async Task AdapterBackedReturnTypeGeneratesInline() + { + const string Source = + """ + using System; + using System.Threading; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class Deferred + { + private readonly Func> _invoke; + public Deferred(Func> invoke) => _invoke = invoke; + public Task InvokeAsync(CancellationToken token) => _invoke(token); + } + + public sealed class DeferredAdapter : IReturnTypeAdapter, T> + { + public Deferred Adapt(Func> invoke) => new(invoke); + } + + public interface IGeneratedClient + { + [Get("/users/{id}")] + Deferred GetUser(int id); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(result.GeneratedSources[Hint]).DoesNotContain(ReflectiveFallback); + await Assert.That(result.GeneratedSources[Hint]).Contains(AdaptCall); + } + + /// Verifies an adapter whose wrapper reorders its type parameters generates inline, closing the adapter over + /// the reordered arguments (a successful compile proves the mapping is correct). + /// A task representing the asynchronous test. + [Test] + public async Task ReorderedAdapterBackedReturnTypeGeneratesInline() + { + const string Source = + """ + using System; + using System.Threading; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class Paired { } + + // The wrapper lists the adapter's parameters in reverse: Paired. A Paired return must + // bind T2 = string and T1 = int, closing the adapter as SwapAdapter with a result type of int. + public sealed class SwapAdapter : IReturnTypeAdapter, T1> + { + public Paired Adapt(Func> invoke) => new(); + } + + public interface IGeneratedClient + { + [Get("/pairs/{id}")] + Paired GetPair(int id); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(result.GeneratedSources[Hint]).DoesNotContain(ReflectiveFallback); + await Assert.That(result.GeneratedSources[Hint]).Contains(AdaptCall); + } + + /// Verifies a non-generic adapter surfaces its non-generic return type inline. + /// A task representing the asynchronous test. + [Test] + public async Task NonGenericAdapterBackedReturnTypeGeneratesInline() + { + const string Source = + """ + using System; + using System.Threading; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class Boxed + { + public int Value { get; init; } + } + + public sealed class BoxedAdapter : IReturnTypeAdapter + { + public Boxed Adapt(Func> invoke) => new(); + } + + public interface IGeneratedClient + { + [Get("/count")] + Boxed GetCount(); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(result.GeneratedSources[Hint]).DoesNotContain(ReflectiveFallback); + await Assert.That(result.GeneratedSources[Hint]).Contains(AdaptCall); + } + + /// + /// Verifies the adapter matcher rejects registered adapters that do not surface a method's return type: a generic + /// adapter with a different type-argument count and a non-generic adapter for a different return type. The method has + /// no matching adapter, so it falls back. + /// + /// A task representing the asynchronous test. + [Test] + public async Task ReturnTypeMatchingRejectsNonSurfacingAdapters() + { + const string Source = + """ + using System; + using System.Threading; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class Wrapped { } + + public sealed class WrappedAdapter : IReturnTypeAdapter, T> + { + public Wrapped Adapt(Func> invoke) => new(); + } + + public sealed class Boxed { } + + public sealed class BoxedAdapter : IReturnTypeAdapter + { + public Boxed Adapt(Func> invoke) => new(); + } + + public sealed class Pair { } + + public interface IGeneratedClient + { + [Get("/pair")] + Pair GetPair(); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + + // WrappedAdapter mismatches Pair's two-argument arity and BoxedAdapter is a different type, so no adapter + // surfaces Pair and the method falls back. + await Assert.That(result.GeneratedSources[Hint]).Contains(ReflectiveFallback); + } + + /// + /// Verifies the adapter type-argument matcher rejects wrappers whose arguments cannot bind the adapter's parameters + /// consistently: a concrete template argument that mismatches the return argument, a repeated type parameter bound to + /// unequal arguments, a concrete template argument that matches, and a type parameter left unbound. None of these + /// adapters surface a Wrapper<,> return, so every method falls back. + /// + /// A task representing the asynchronous test. + [Test] + public async Task AdapterTypeArgumentBindingRejectsNonSurfacingWrappers() + { + const string Source = + """ + using System; + using System.Threading; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class Wrapper { } + + // A partially-concrete wrapper: the first argument is a fixed 'int', the second is the adapter's TSecond. + // TFirst never appears in the wrapper, so it can never bind. + public sealed class ConcreteFirstAdapter : IReturnTypeAdapter, TSecond> + { + public Wrapper Adapt(Func> invoke) => new(); + } + + // A wrapper that repeats TSecond in both positions, so a return type must supply equal arguments to bind. + public sealed class RepeatAdapter : IReturnTypeAdapter, TSecond> + { + public Wrapper Adapt(Func> invoke) => new(); + } + + public interface IGeneratedClient + { + // ConcreteFirstAdapter's concrete 'int' mismatches 'string'; RepeatAdapter re-binds TSecond inconsistently. + [Get("/a")] Wrapper First(); + + // RepeatAdapter binds TSecond=string twice consistently, but TFirst is left unbound. + [Get("/b")] Wrapper Second(); + + // ConcreteFirstAdapter's concrete 'int' matches 'int', but TFirst is left unbound. + [Get("/c")] Wrapper Third(); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(result.GeneratedSources[Hint]).Contains(ReflectiveFallback); + } + + /// Verifies an adapter whose result type is itself a single-argument generic (that is not one of the + /// recognised async wrappers) resolves that result type unchanged and generates inline. + /// A task representing the asynchronous test. + [Test] + public async Task AdapterWithGenericResultTypeGeneratesInline() + { + const string Source = + """ + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class Deferred + { + private readonly Func> _invoke; + public Deferred(Func> invoke) => _invoke = invoke; + public Task InvokeAsync(CancellationToken token) => _invoke(token); + } + + public sealed class DeferredAdapter : IReturnTypeAdapter, T> + { + public Deferred Adapt(Func> invoke) => new(invoke); + } + + public interface IGeneratedClient + { + [Get("/values")] + Deferred> GetValues(); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(result.GeneratedSources[Hint]).DoesNotContain(ReflectiveFallback); + await Assert.That(result.GeneratedSources[Hint]).Contains(AdaptCall); + } + + /// Verifies an adapter whose declared TReturn is a bare type parameter (not a wrapper generic) does + /// not surface a generic return type, so the method falls back. + /// A task representing the asynchronous test. + [Test] + public async Task AdapterWithBareTypeParameterReturnDoesNotSurfaceGenericReturn() + { + const string Source = + """ + using System; + using System.Threading; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class Wrapped { } + + // TReturn is the bare type parameter T, not a wrapper generic, so it cannot match a Wrapped return. + public sealed class BareAdapter : IReturnTypeAdapter + { + public T Adapt(Func> invoke) => default!; + } + + public interface IGeneratedClient + { + [Get("/w")] + Wrapped Get(); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(result.GeneratedSources[Hint]).Contains(ReflectiveFallback); + } + + /// Verifies an adapter that also implements an unrelated interface still resolves its return-type adapter + /// interface, skipping the unrelated one while matching. + /// A task representing the asynchronous test. + [Test] + public async Task AdapterImplementingUnrelatedInterfaceStillMatchesReturnType() + { + const string Source = + """ + using System; + using System.Threading; + using System.Threading.Tasks; + using Refit; + + namespace RefitGeneratorTest; + + public sealed class Boxed + { + public int Value { get; init; } + } + + public sealed class BoxedAdapter : IDisposable, IReturnTypeAdapter + { + public Boxed Adapt(Func> invoke) => new(); + + public void Dispose() { } + } + + public interface IGeneratedClient + { + [Get("/count")] + Boxed GetCount(); + } + """; + + var result = Fixture.RunGenerator(Source, generatedRequestBuilding: true); + + await Assert.That(result.CompilesWithoutErrors).IsTrue(); + await Assert.That(result.GeneratedSources[Hint]).DoesNotContain(ReflectiveFallback); + await Assert.That(result.GeneratedSources[Hint]).Contains(AdaptCall); + } +} diff --git a/src/tests/Refit.GeneratorTests/_snapshots/.gitattributes b/src/tests/Refit.GeneratorTests/_snapshots/.gitattributes new file mode 100644 index 000000000..1b8544c8b --- /dev/null +++ b/src/tests/Refit.GeneratorTests/_snapshots/.gitattributes @@ -0,0 +1,6 @@ +# Verify's text comparer rejects any verified file that contains a carriage return +# ("Verified file must use \n line endings, but it contains a \r"). Pin the snapshots +# to LF so a Windows checkout keeps them as LF instead of rewriting them to CRLF. +# Everything else in this directory follows the repository default. +* text=auto +*.verified.* text eol=lf diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#Generated.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#Generated.g.verified.cs index 9c35c785e..f07c7ea7b 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#Generated.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#Generated.g.verified.cs @@ -26,10 +26,16 @@ internal static void Initialize() { global::Refit.RestService.RegisterGeneratedFactory( static (client, requestBuilder) => new global::Refit.Implementation.Generated.RefitTestsIGitHubApi(client, requestBuilder)); + global::Refit.RestService.RegisterGeneratedSettingsFactory( + static (client, settings) => new global::Refit.Implementation.Generated.RefitTestsIGitHubApi(client, settings)); global::Refit.RestService.RegisterGeneratedFactory( static (client, requestBuilder) => new global::Refit.Implementation.Generated.RefitTestsIGitHubApiDisposable(client, requestBuilder)); + global::Refit.RestService.RegisterGeneratedSettingsFactory( + static (client, settings) => new global::Refit.Implementation.Generated.RefitTestsIGitHubApiDisposable(client, settings)); global::Refit.RestService.RegisterGeneratedFactory( static (client, requestBuilder) => new global::Refit.Implementation.Generated.RefitTestsTestNestedINestedGitHubApi(client, requestBuilder)); + global::Refit.RestService.RegisterGeneratedSettingsFactory( + static (client, settings) => new global::Refit.Implementation.Generated.RefitTestsTestNestedINestedGitHubApi(client, settings)); } #endif } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApi.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApi.g.verified.cs index 63729e544..d607a1f00 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApi.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApi.g.verified.cs @@ -37,14 +37,23 @@ public RefitTestsIGitHubApi(global::System.Net.Http.HttpClient client, global::R _settings = requestBuilder.Settings; } + /// Initializes a new instance of the RefitTestsIGitHubApi class for generated-only execution. + /// The HTTP client used by the generated implementation. + /// The settings used by the generated implementation. + public RefitTestsIGitHubApi(global::System.Net.Http.HttpClient client, global::Refit.RefitSettings settings) + { + Client = client; + _settings = settings; + } /// Cached attribute provider for the generated GetUser method's userName parameter. - private static readonly global::Refit.GeneratedParameterAttributeProvider ______userNameAttributeProvider = new global::Refit.GeneratedParameterAttributeProvider(new global::System.Collections.Generic.Dictionary()); + private static readonly global::Refit.GeneratedParameterAttributeProvider ______userNameAttributeProvider = global::Refit.GeneratedParameterAttributeProvider.Empty; /// public global::System.Threading.Tasks.Task GetUser(string @userName) { var refitSettings = _settings; - var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{username}", refitSettings.AllowUnmatchedRouteParameters, ((7, 17), _settings.UrlParameterFormatter.Format(userName, ______userNameAttributeProvider, typeof(string)))), refitSettings.UrlResolution)); + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{username}", refitSettings.AllowUnmatchedRouteParameters, [((7, 17), refitUseDefaultFormatting ? (@userName) : refitSettings.UrlParameterFormatter.Format(@userName, ______userNameAttributeProvider, typeof(string)))]), refitSettings.UrlResolution)); #if NET6_0_OR_GREATER refitRequest.Version = refitSettings.Version; refitRequest.VersionPolicy = refitSettings.VersionPolicy; @@ -61,40 +70,73 @@ public RefitTestsIGitHubApi(global::System.Net.Http.HttpClient client, global::R global::System.Threading.CancellationToken.None); } + /// Cached attribute provider for the generated GetUserObservable method's userName parameter. + private static readonly global::Refit.GeneratedParameterAttributeProvider ______userNameAttributeProvider0 = global::Refit.GeneratedParameterAttributeProvider.Empty; - /// Cached parameter type array for the generated GetUserObservable method. - private static readonly global::System.Type[] ______typeParameters = new global::System.Type[] { typeof(string) }; /// public global::System.IObservable GetUserObservable(string @userName) { - var refitArguments = new object[] { @userName }; - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("GetUserObservable", ______typeParameters ); - - return (global::System.IObservable)refitFunc(this.Client, refitArguments); + var refitSettings = _settings; + global::System.Net.Http.HttpRequestMessage BuildRefitRequest() + { + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{username}", refitSettings.AllowUnmatchedRouteParameters, [((7, 17), refitUseDefaultFormatting ? (@userName) : refitSettings.UrlParameterFormatter.Format(@userName, ______userNameAttributeProvider0, typeof(string)))]), refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.SetHeader(refitRequest, "User-Agent", "Refit Integration Tests"); + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::Refit.Tests.IGitHubApi)); + return refitRequest; + } + return global::Refit.GeneratedRequestRunner.SendObservable( + this.Client, + BuildRefitRequest, + refitSettings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } + /// Cached attribute provider for the generated GetUserCamelCase method's userName parameter. + private static readonly global::Refit.GeneratedParameterAttributeProvider ______userNameAttributeProvider1 = global::Refit.GeneratedParameterAttributeProvider.Empty; - /// Cached parameter type array for the generated GetUserCamelCase method. - private static readonly global::System.Type[] ______typeParameters0 = new global::System.Type[] { typeof(string) }; /// public global::System.IObservable GetUserCamelCase(string @userName) { - var refitArguments = new object[] { @userName }; - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("GetUserCamelCase", ______typeParameters0 ); - - return (global::System.IObservable)refitFunc(this.Client, refitArguments); + var refitSettings = _settings; + global::System.Net.Http.HttpRequestMessage BuildRefitRequest() + { + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{userName}", refitSettings.AllowUnmatchedRouteParameters, [((7, 17), refitUseDefaultFormatting ? (@userName) : refitSettings.UrlParameterFormatter.Format(@userName, ______userNameAttributeProvider1, typeof(string)))]), refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.SetHeader(refitRequest, "User-Agent", "Refit Integration Tests"); + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::Refit.Tests.IGitHubApi)); + return refitRequest; + } + return global::Refit.GeneratedRequestRunner.SendObservable( + this.Client, + BuildRefitRequest, + refitSettings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } /// Cached attribute provider for the generated GetOrgMembers method's orgName parameter. - private static readonly global::Refit.GeneratedParameterAttributeProvider ______orgNameAttributeProvider = new global::Refit.GeneratedParameterAttributeProvider(new global::System.Collections.Generic.Dictionary()); + private static readonly global::Refit.GeneratedParameterAttributeProvider ______orgNameAttributeProvider = global::Refit.GeneratedParameterAttributeProvider.Empty; /// public global::System.Threading.Tasks.Task> GetOrgMembers(string @orgName, global::System.Threading.CancellationToken @cancellationToken) { var refitSettings = _settings; - var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/orgs/{orgname}/members", refitSettings.AllowUnmatchedRouteParameters, ((6, 15), _settings.UrlParameterFormatter.Format(orgName, ______orgNameAttributeProvider, typeof(string)))), refitSettings.UrlResolution)); + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/orgs/{orgname}/members", refitSettings.AllowUnmatchedRouteParameters, [((6, 15), refitUseDefaultFormatting ? (@orgName) : refitSettings.UrlParameterFormatter.Format(@orgName, ______orgNameAttributeProvider, typeof(string)))]), refitSettings.UrlResolution)); #if NET6_0_OR_GREATER refitRequest.Version = refitSettings.Version; refitRequest.VersionPolicy = refitSettings.VersionPolicy; @@ -111,17 +153,34 @@ public RefitTestsIGitHubApi(global::System.Net.Http.HttpClient client, global::R @cancellationToken); } + /// Cached attribute provider for the generated FindUsers method's q parameter. + private static readonly global::Refit.GeneratedParameterAttributeProvider ______qAttributeProvider = global::Refit.GeneratedParameterAttributeProvider.Empty; - /// Cached parameter type array for the generated FindUsers method. - private static readonly global::System.Type[] ______typeParameters1 = new global::System.Type[] { typeof(string) }; /// - public async global::System.Threading.Tasks.Task FindUsers(string @q) + public global::System.Threading.Tasks.Task FindUsers(string @q) { - var refitArguments = new object[] { @q }; - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("FindUsers", ______typeParameters1 ); - - return await ((global::System.Threading.Tasks.Task)refitFunc(this.Client, refitArguments)).ConfigureAwait(false); + var refitSettings = _settings; + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitQueryBuilder = new global::Refit.GeneratedQueryStringBuilder("/search/users", false); + if (@q != null) + { + refitQueryBuilder.AddPreEscapedKey("q", refitUseDefaultFormatting ? (@q) : refitSettings.UrlParameterFormatter.Format(@q, ______qAttributeProvider, typeof(string)), false); + } + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, refitQueryBuilder.Build(), refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.SetHeader(refitRequest, "User-Agent", "Refit Integration Tests"); + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::Refit.Tests.IGitHubApi)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + refitRequest, + refitSettings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } /// @@ -148,11 +207,26 @@ public RefitTestsIGitHubApi(global::System.Net.Http.HttpClient client, global::R /// public global::System.IObservable GetIndexObservable() { - var refitArguments = global::System.Array.Empty(); - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("GetIndexObservable", global::System.Array.Empty() ); - - return (global::System.IObservable)refitFunc(this.Client, refitArguments); + var refitSettings = _settings; + global::System.Net.Http.HttpRequestMessage BuildRefitRequest() + { + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, "/", refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.SetHeader(refitRequest, "User-Agent", "Refit Integration Tests"); + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::Refit.Tests.IGitHubApi)); + return refitRequest; + } + return global::Refit.GeneratedRequestRunner.SendObservable( + this.Client, + BuildRefitRequest, + refitSettings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } /// @@ -198,13 +272,14 @@ public RefitTestsIGitHubApi(global::System.Net.Http.HttpClient client, global::R } /// Cached attribute provider for the generated GetUserWithMetadata method's userName parameter. - private static readonly global::Refit.GeneratedParameterAttributeProvider ______userNameAttributeProvider0 = new global::Refit.GeneratedParameterAttributeProvider(new global::System.Collections.Generic.Dictionary()); + private static readonly global::Refit.GeneratedParameterAttributeProvider ______userNameAttributeProvider2 = global::Refit.GeneratedParameterAttributeProvider.Empty; /// public global::System.Threading.Tasks.Task> GetUserWithMetadata(string @userName, global::System.Threading.CancellationToken @cancellationToken) { var refitSettings = _settings; - var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{username}", refitSettings.AllowUnmatchedRouteParameters, ((7, 17), _settings.UrlParameterFormatter.Format(userName, ______userNameAttributeProvider0, typeof(string)))), refitSettings.UrlResolution)); + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{username}", refitSettings.AllowUnmatchedRouteParameters, [((7, 17), refitUseDefaultFormatting ? (@userName) : refitSettings.UrlParameterFormatter.Format(@userName, ______userNameAttributeProvider2, typeof(string)))]), refitSettings.UrlResolution)); #if NET6_0_OR_GREATER refitRequest.Version = refitSettings.Version; refitRequest.VersionPolicy = refitSettings.VersionPolicy; @@ -221,56 +296,114 @@ public RefitTestsIGitHubApi(global::System.Net.Http.HttpClient client, global::R @cancellationToken); } + /// Cached attribute provider for the generated GetUserObservableWithMetadata method's userName parameter. + private static readonly global::Refit.GeneratedParameterAttributeProvider ______userNameAttributeProvider3 = global::Refit.GeneratedParameterAttributeProvider.Empty; - /// Cached parameter type array for the generated GetUserObservableWithMetadata method. - private static readonly global::System.Type[] ______typeParameters2 = new global::System.Type[] { typeof(string) }; /// public global::System.IObservable> GetUserObservableWithMetadata(string @userName) { - var refitArguments = new object[] { @userName }; - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("GetUserObservableWithMetadata", ______typeParameters2 ); - - return (global::System.IObservable>)refitFunc(this.Client, refitArguments); + var refitSettings = _settings; + global::System.Net.Http.HttpRequestMessage BuildRefitRequest() + { + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{username}", refitSettings.AllowUnmatchedRouteParameters, [((7, 17), refitUseDefaultFormatting ? (@userName) : refitSettings.UrlParameterFormatter.Format(@userName, ______userNameAttributeProvider3, typeof(string)))]), refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.SetHeader(refitRequest, "User-Agent", "Refit Integration Tests"); + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::Refit.Tests.IGitHubApi)); + return refitRequest; + } + return global::Refit.GeneratedRequestRunner.SendObservable, global::Refit.Tests.User>( + this.Client, + BuildRefitRequest, + refitSettings, + true, + true, + false, + global::System.Threading.CancellationToken.None); } + /// Cached attribute provider for the generated GetUserIApiResponseObservableWithMetadata method's userName parameter. + private static readonly global::Refit.GeneratedParameterAttributeProvider ______userNameAttributeProvider4 = global::Refit.GeneratedParameterAttributeProvider.Empty; - /// Cached parameter type array for the generated GetUserIApiResponseObservableWithMetadata method. - private static readonly global::System.Type[] ______typeParameters3 = new global::System.Type[] { typeof(string) }; /// public global::System.IObservable> GetUserIApiResponseObservableWithMetadata(string @userName) { - var refitArguments = new object[] { @userName }; - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("GetUserIApiResponseObservableWithMetadata", ______typeParameters3 ); - - return (global::System.IObservable>)refitFunc(this.Client, refitArguments); + var refitSettings = _settings; + global::System.Net.Http.HttpRequestMessage BuildRefitRequest() + { + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{username}", refitSettings.AllowUnmatchedRouteParameters, [((7, 17), refitUseDefaultFormatting ? (@userName) : refitSettings.UrlParameterFormatter.Format(@userName, ______userNameAttributeProvider4, typeof(string)))]), refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.SetHeader(refitRequest, "User-Agent", "Refit Integration Tests"); + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::Refit.Tests.IGitHubApi)); + return refitRequest; + } + return global::Refit.GeneratedRequestRunner.SendObservable, global::Refit.Tests.User>( + this.Client, + BuildRefitRequest, + refitSettings, + true, + true, + false, + global::System.Threading.CancellationToken.None); } - - /// Cached parameter type array for the generated CreateUser method. - private static readonly global::System.Type[] ______typeParameters4 = new global::System.Type[] { typeof(global::Refit.Tests.User) }; /// - public async global::System.Threading.Tasks.Task CreateUser(global::Refit.Tests.User @user) + public global::System.Threading.Tasks.Task CreateUser(global::Refit.Tests.User @user) { - var refitArguments = new object[] { @user }; - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("CreateUser", ______typeParameters4 ); - - return await ((global::System.Threading.Tasks.Task)refitFunc(this.Client, refitArguments)).ConfigureAwait(false); + var refitSettings = _settings; + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Post, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, "/users", refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + refitRequest.Content = global::Refit.GeneratedRequestRunner.CreateBodyContent( + refitSettings, + @user, + global::Refit.BodySerializationMethod.Serialized, + !refitSettings.Buffered); + global::Refit.GeneratedRequestRunner.SetHeader(refitRequest, "User-Agent", "Refit Integration Tests"); + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::Refit.Tests.IGitHubApi)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + refitRequest, + refitSettings, + false, + true, + refitSettings.Buffered, + global::System.Threading.CancellationToken.None); } - - /// Cached parameter type array for the generated CreateUserWithMetadata method. - private static readonly global::System.Type[] ______typeParameters5 = new global::System.Type[] { typeof(global::Refit.Tests.User) }; /// - public async global::System.Threading.Tasks.Task> CreateUserWithMetadata(global::Refit.Tests.User @user) + public global::System.Threading.Tasks.Task> CreateUserWithMetadata(global::Refit.Tests.User @user) { - var refitArguments = new object[] { @user }; - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("CreateUserWithMetadata", ______typeParameters5 ); - - return await ((global::System.Threading.Tasks.Task>)refitFunc(this.Client, refitArguments)).ConfigureAwait(false); + var refitSettings = _settings; + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Post, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, "/users", refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + refitRequest.Content = global::Refit.GeneratedRequestRunner.CreateBodyContent( + refitSettings, + @user, + global::Refit.BodySerializationMethod.Serialized, + !refitSettings.Buffered); + global::Refit.GeneratedRequestRunner.SetHeader(refitRequest, "User-Agent", "Refit Integration Tests"); + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::Refit.Tests.IGitHubApi)); + return global::Refit.GeneratedRequestRunner.SendAsync, global::Refit.Tests.User>( + this.Client, + refitRequest, + refitSettings, + true, + true, + refitSettings.Buffered, + global::System.Threading.CancellationToken.None); } } } diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApiDisposable.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApiDisposable.g.verified.cs index 221793e49..8d0e2475f 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApiDisposable.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#IGitHubApiDisposable.g.verified.cs @@ -37,14 +37,30 @@ public RefitTestsIGitHubApiDisposable(global::System.Net.Http.HttpClient client, _settings = requestBuilder.Settings; } + /// Initializes a new instance of the RefitTestsIGitHubApiDisposable class for generated-only execution. + /// The HTTP client used by the generated implementation. + /// The settings used by the generated implementation. + public RefitTestsIGitHubApiDisposable(global::System.Net.Http.HttpClient client, global::Refit.RefitSettings settings) + { + Client = client; + _settings = settings; + } /// - public async global::System.Threading.Tasks.Task RefitMethod() + public global::System.Threading.Tasks.Task RefitMethod() { - var refitArguments = global::System.Array.Empty(); - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("RefitMethod", global::System.Array.Empty() ); - - await ((global::System.Threading.Tasks.Task)refitFunc(this.Client, refitArguments)).ConfigureAwait(false); + var refitSettings = _settings; + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, "whatever", refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::Refit.Tests.IGitHubApiDisposable)); + return global::Refit.GeneratedRequestRunner.SendVoidAsync( + this.Client, + refitRequest, + refitSettings, + false, + global::System.Threading.CancellationToken.None); } /// diff --git a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#INestedGitHubApi.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#INestedGitHubApi.g.verified.cs index 60944b232..7aaaedbd2 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#INestedGitHubApi.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/InterfaceStubGeneratorTests.FindInterfacesSmokeTest#INestedGitHubApi.g.verified.cs @@ -37,14 +37,23 @@ public RefitTestsTestNestedINestedGitHubApi(global::System.Net.Http.HttpClient c _settings = requestBuilder.Settings; } + /// Initializes a new instance of the RefitTestsTestNestedINestedGitHubApi class for generated-only execution. + /// The HTTP client used by the generated implementation. + /// The settings used by the generated implementation. + public RefitTestsTestNestedINestedGitHubApi(global::System.Net.Http.HttpClient client, global::Refit.RefitSettings settings) + { + Client = client; + _settings = settings; + } /// Cached attribute provider for the generated GetUser method's userName parameter. - private static readonly global::Refit.GeneratedParameterAttributeProvider ______userNameAttributeProvider = new global::Refit.GeneratedParameterAttributeProvider(new global::System.Collections.Generic.Dictionary()); + private static readonly global::Refit.GeneratedParameterAttributeProvider ______userNameAttributeProvider = global::Refit.GeneratedParameterAttributeProvider.Empty; /// public global::System.Threading.Tasks.Task GetUser(string @userName) { var refitSettings = _settings; - var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{username}", refitSettings.AllowUnmatchedRouteParameters, ((7, 17), _settings.UrlParameterFormatter.Format(userName, ______userNameAttributeProvider, typeof(string)))), refitSettings.UrlResolution)); + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{username}", refitSettings.AllowUnmatchedRouteParameters, [((7, 17), refitUseDefaultFormatting ? (@userName) : refitSettings.UrlParameterFormatter.Format(@userName, ______userNameAttributeProvider, typeof(string)))]), refitSettings.UrlResolution)); #if NET6_0_OR_GREATER refitRequest.Version = refitSettings.Version; refitRequest.VersionPolicy = refitSettings.VersionPolicy; @@ -61,40 +70,73 @@ public RefitTestsTestNestedINestedGitHubApi(global::System.Net.Http.HttpClient c global::System.Threading.CancellationToken.None); } + /// Cached attribute provider for the generated GetUserObservable method's userName parameter. + private static readonly global::Refit.GeneratedParameterAttributeProvider ______userNameAttributeProvider0 = global::Refit.GeneratedParameterAttributeProvider.Empty; - /// Cached parameter type array for the generated GetUserObservable method. - private static readonly global::System.Type[] ______typeParameters = new global::System.Type[] { typeof(string) }; /// public global::System.IObservable GetUserObservable(string @userName) { - var refitArguments = new object[] { @userName }; - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("GetUserObservable", ______typeParameters ); - - return (global::System.IObservable)refitFunc(this.Client, refitArguments); + var refitSettings = _settings; + global::System.Net.Http.HttpRequestMessage BuildRefitRequest() + { + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{username}", refitSettings.AllowUnmatchedRouteParameters, [((7, 17), refitUseDefaultFormatting ? (@userName) : refitSettings.UrlParameterFormatter.Format(@userName, ______userNameAttributeProvider0, typeof(string)))]), refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.SetHeader(refitRequest, "User-Agent", "Refit Integration Tests"); + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::Refit.Tests.TestNested.INestedGitHubApi)); + return refitRequest; + } + return global::Refit.GeneratedRequestRunner.SendObservable( + this.Client, + BuildRefitRequest, + refitSettings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } + /// Cached attribute provider for the generated GetUserCamelCase method's userName parameter. + private static readonly global::Refit.GeneratedParameterAttributeProvider ______userNameAttributeProvider1 = global::Refit.GeneratedParameterAttributeProvider.Empty; - /// Cached parameter type array for the generated GetUserCamelCase method. - private static readonly global::System.Type[] ______typeParameters0 = new global::System.Type[] { typeof(string) }; /// public global::System.IObservable GetUserCamelCase(string @userName) { - var refitArguments = new object[] { @userName }; - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("GetUserCamelCase", ______typeParameters0 ); - - return (global::System.IObservable)refitFunc(this.Client, refitArguments); + var refitSettings = _settings; + global::System.Net.Http.HttpRequestMessage BuildRefitRequest() + { + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{userName}", refitSettings.AllowUnmatchedRouteParameters, [((7, 17), refitUseDefaultFormatting ? (@userName) : refitSettings.UrlParameterFormatter.Format(@userName, ______userNameAttributeProvider1, typeof(string)))]), refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.SetHeader(refitRequest, "User-Agent", "Refit Integration Tests"); + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::Refit.Tests.TestNested.INestedGitHubApi)); + return refitRequest; + } + return global::Refit.GeneratedRequestRunner.SendObservable( + this.Client, + BuildRefitRequest, + refitSettings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } /// Cached attribute provider for the generated GetOrgMembers method's orgName parameter. - private static readonly global::Refit.GeneratedParameterAttributeProvider ______orgNameAttributeProvider = new global::Refit.GeneratedParameterAttributeProvider(new global::System.Collections.Generic.Dictionary()); + private static readonly global::Refit.GeneratedParameterAttributeProvider ______orgNameAttributeProvider = global::Refit.GeneratedParameterAttributeProvider.Empty; /// public global::System.Threading.Tasks.Task> GetOrgMembers(string @orgName) { var refitSettings = _settings; - var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/orgs/{orgname}/members", refitSettings.AllowUnmatchedRouteParameters, ((6, 15), _settings.UrlParameterFormatter.Format(orgName, ______orgNameAttributeProvider, typeof(string)))), refitSettings.UrlResolution)); + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/orgs/{orgname}/members", refitSettings.AllowUnmatchedRouteParameters, [((6, 15), refitUseDefaultFormatting ? (@orgName) : refitSettings.UrlParameterFormatter.Format(@orgName, ______orgNameAttributeProvider, typeof(string)))]), refitSettings.UrlResolution)); #if NET6_0_OR_GREATER refitRequest.Version = refitSettings.Version; refitRequest.VersionPolicy = refitSettings.VersionPolicy; @@ -111,17 +153,34 @@ public RefitTestsTestNestedINestedGitHubApi(global::System.Net.Http.HttpClient c global::System.Threading.CancellationToken.None); } + /// Cached attribute provider for the generated FindUsers method's q parameter. + private static readonly global::Refit.GeneratedParameterAttributeProvider ______qAttributeProvider = global::Refit.GeneratedParameterAttributeProvider.Empty; - /// Cached parameter type array for the generated FindUsers method. - private static readonly global::System.Type[] ______typeParameters1 = new global::System.Type[] { typeof(string) }; /// - public async global::System.Threading.Tasks.Task FindUsers(string @q) + public global::System.Threading.Tasks.Task FindUsers(string @q) { - var refitArguments = new object[] { @q }; - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("FindUsers", ______typeParameters1 ); - - return await ((global::System.Threading.Tasks.Task)refitFunc(this.Client, refitArguments)).ConfigureAwait(false); + var refitSettings = _settings; + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitQueryBuilder = new global::Refit.GeneratedQueryStringBuilder("/search/users", false); + if (@q != null) + { + refitQueryBuilder.AddPreEscapedKey("q", refitUseDefaultFormatting ? (@q) : refitSettings.UrlParameterFormatter.Format(@q, ______qAttributeProvider, typeof(string)), false); + } + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, refitQueryBuilder.Build(), refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.SetHeader(refitRequest, "User-Agent", "Refit Integration Tests"); + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::Refit.Tests.TestNested.INestedGitHubApi)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + refitRequest, + refitSettings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } /// @@ -148,11 +207,26 @@ public RefitTestsTestNestedINestedGitHubApi(global::System.Net.Http.HttpClient c /// public global::System.IObservable GetIndexObservable() { - var refitArguments = global::System.Array.Empty(); - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("GetIndexObservable", global::System.Array.Empty() ); - - return (global::System.IObservable)refitFunc(this.Client, refitArguments); + var refitSettings = _settings; + global::System.Net.Http.HttpRequestMessage BuildRefitRequest() + { + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, "/", refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.SetHeader(refitRequest, "User-Agent", "Refit Integration Tests"); + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::Refit.Tests.TestNested.INestedGitHubApi)); + return refitRequest; + } + return global::Refit.GeneratedRequestRunner.SendObservable( + this.Client, + BuildRefitRequest, + refitSettings, + 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 81fb52332..15f8fef0f 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 @@ -37,19 +37,37 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli _settings = requestBuilder.Settings; } + /// Initializes a new instance of the RefitGeneratorTestIGeneratedClient class for generated-only execution. + /// The HTTP client used by the generated implementation. + /// The settings used by the generated implementation. + public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient client, global::Refit.RefitSettings settings) + { + Client = client; + _settings = settings; + } /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() where T1 : class where T2 : unmanaged where T3 : struct where T4 : notnull where T5 : class, global::System.IDisposable, new() { - var refitArguments = global::System.Array.Empty(); - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty(), new global::System.Type[] { typeof(T1), typeof(T2), typeof(T3), typeof(T4), typeof(T5) } ); - - return await ((global::System.Threading.Tasks.Task)refitFunc(this.Client, refitArguments)).ConfigureAwait(false); + var refitSettings = _settings; + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, "/users", refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::RefitGeneratorTest.IGeneratedClient)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + refitRequest, + refitSettings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } /// 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 84fb5e16f..875788f2d 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 @@ -46,13 +46,14 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli _settings = settings; } /// Cached attribute provider for the generated Get method's user parameter. - private static readonly global::Refit.GeneratedParameterAttributeProvider ______userAttributeProvider = new global::Refit.GeneratedParameterAttributeProvider(new global::System.Collections.Generic.Dictionary()); + private static readonly global::Refit.GeneratedParameterAttributeProvider ______userAttributeProvider = global::Refit.GeneratedParameterAttributeProvider.Empty; /// public global::System.Threading.Tasks.Task Get(string? @user) { var refitSettings = _settings; - var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{user}", refitSettings.AllowUnmatchedRouteParameters, ((7, 13), _settings.UrlParameterFormatter.Format(user, ______userAttributeProvider, typeof(string)))), refitSettings.UrlResolution)); + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{user}", refitSettings.AllowUnmatchedRouteParameters, [((7, 13), refitUseDefaultFormatting ? (@user) : refitSettings.UrlParameterFormatter.Format(@user, ______userAttributeProvider, typeof(string)))]), refitSettings.UrlResolution)); #if NET6_0_OR_GREATER refitRequest.Version = refitSettings.Version; refitRequest.VersionPolicy = refitSettings.VersionPolicy; 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 6f52f05b1..e7fdbec96 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 @@ -46,13 +46,14 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli _settings = settings; } /// Cached attribute provider for the generated Get method's user parameter. - private static readonly global::Refit.GeneratedParameterAttributeProvider ______userAttributeProvider = new global::Refit.GeneratedParameterAttributeProvider(new global::System.Collections.Generic.Dictionary()); + private static readonly global::Refit.GeneratedParameterAttributeProvider ______userAttributeProvider = global::Refit.GeneratedParameterAttributeProvider.Empty; /// public global::System.Threading.Tasks.Task Get(int? @user) { var refitSettings = _settings; - var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{user}", refitSettings.AllowUnmatchedRouteParameters, ((7, 13), _settings.UrlParameterFormatter.Format(user, ______userAttributeProvider, typeof(int?)))), refitSettings.UrlResolution)); + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{user}", refitSettings.AllowUnmatchedRouteParameters, [((7, 13), refitUseDefaultFormatting ? (@user == null ? null : global::Refit.GeneratedRequestRunner.FormatInvariant(@user.Value, null)) : refitSettings.UrlParameterFormatter.Format(@user, ______userAttributeProvider, typeof(int?)))]), refitSettings.UrlResolution)); #if NET6_0_OR_GREATER refitRequest.Version = refitSettings.Version; refitRequest.VersionPolicy = refitSettings.VersionPolicy; 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 d1c17a64e..194c7bcc0 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 @@ -46,13 +46,14 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli _settings = settings; } /// Cached attribute provider for the generated Get method's user parameter. - private static readonly global::Refit.GeneratedParameterAttributeProvider ______userAttributeProvider = new global::Refit.GeneratedParameterAttributeProvider(new global::System.Collections.Generic.Dictionary()); + private static readonly global::Refit.GeneratedParameterAttributeProvider ______userAttributeProvider = global::Refit.GeneratedParameterAttributeProvider.Empty; /// public global::System.Threading.Tasks.Task Get(string @user) { var refitSettings = _settings; - var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{user}", refitSettings.AllowUnmatchedRouteParameters, ((7, 13), _settings.UrlParameterFormatter.Format(user, ______userAttributeProvider, typeof(string)))), refitSettings.UrlResolution)); + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{user}", refitSettings.AllowUnmatchedRouteParameters, [((7, 13), refitUseDefaultFormatting ? (@user) : refitSettings.UrlParameterFormatter.Format(@user, ______userAttributeProvider, typeof(string)))]), refitSettings.UrlResolution)); #if NET6_0_OR_GREATER refitRequest.Version = refitSettings.Version; refitRequest.VersionPolicy = refitSettings.VersionPolicy; 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 b9f666d6c..06d40102b 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 @@ -46,13 +46,14 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli _settings = settings; } /// Cached attribute provider for the generated Get method's user parameter. - private static readonly global::Refit.GeneratedParameterAttributeProvider ______userAttributeProvider = new global::Refit.GeneratedParameterAttributeProvider(new global::System.Collections.Generic.Dictionary()); + private static readonly global::Refit.GeneratedParameterAttributeProvider ______userAttributeProvider = global::Refit.GeneratedParameterAttributeProvider.Empty; /// public global::System.Threading.Tasks.Task Get(int @user) { var refitSettings = _settings; - var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{user}", refitSettings.AllowUnmatchedRouteParameters, ((7, 13), _settings.UrlParameterFormatter.Format(user, ______userAttributeProvider, typeof(int)))), refitSettings.UrlResolution)); + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, (refitUseDefaultFormatting ? global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{user}", refitSettings.AllowUnmatchedRouteParameters, (7, 13), @user) : global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{user}", refitSettings.AllowUnmatchedRouteParameters, [((7, 13), refitSettings.UrlParameterFormatter.Format(@user, ______userAttributeProvider, typeof(int)))])), refitSettings.UrlResolution)); #if NET6_0_OR_GREATER refitRequest.Version = refitSettings.Version; refitRequest.VersionPolicy = refitSettings.VersionPolicy; diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.ValueTypeRouteParameterInQueryWithAttribute#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.ValueTypeRouteParameterInQueryWithAttribute#IGeneratedClient.g.verified.cs index 3c36e583b..5dd447229 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.ValueTypeRouteParameterInQueryWithAttribute#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.ValueTypeRouteParameterInQueryWithAttribute#IGeneratedClient.g.verified.cs @@ -52,7 +52,8 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli public global::System.Threading.Tasks.Task Find(string? @q) { var refitSettings = _settings; - var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/todos?q={q}", refitSettings.AllowUnmatchedRouteParameters, ((9, 12), _settings.UrlParameterFormatter.Format(q, ______qAttributeProvider, typeof(string)))), refitSettings.UrlResolution)); + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/todos?q={q}", refitSettings.AllowUnmatchedRouteParameters, [((9, 12), refitUseDefaultFormatting ? (@q) : refitSettings.UrlParameterFormatter.Format(@q, ______qAttributeProvider, typeof(string)))]), refitSettings.UrlResolution)); #if NET6_0_OR_GREATER refitRequest.Version = refitSettings.Version; refitRequest.VersionPolicy = refitSettings.VersionPolicy; diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.ValueTypeRouteParameterInQueryWithoutAttribute#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.ValueTypeRouteParameterInQueryWithoutAttribute#IGeneratedClient.g.verified.cs index 8e4b7cac6..2bf5097de 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.ValueTypeRouteParameterInQueryWithoutAttribute#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.ValueTypeRouteParameterInQueryWithoutAttribute#IGeneratedClient.g.verified.cs @@ -46,13 +46,14 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli _settings = settings; } /// Cached attribute provider for the generated Find method's q parameter. - private static readonly global::Refit.GeneratedParameterAttributeProvider ______qAttributeProvider = new global::Refit.GeneratedParameterAttributeProvider(new global::System.Collections.Generic.Dictionary()); + private static readonly global::Refit.GeneratedParameterAttributeProvider ______qAttributeProvider = global::Refit.GeneratedParameterAttributeProvider.Empty; /// public global::System.Threading.Tasks.Task Find(string? @q) { var refitSettings = _settings; - var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/todos?q={q}", refitSettings.AllowUnmatchedRouteParameters, ((9, 12), _settings.UrlParameterFormatter.Format(q, ______qAttributeProvider, typeof(string)))), refitSettings.UrlResolution)); + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/todos?q={q}", refitSettings.AllowUnmatchedRouteParameters, [((9, 12), refitUseDefaultFormatting ? (@q) : refitSettings.UrlParameterFormatter.Format(@q, ______qAttributeProvider, typeof(string)))]), refitSettings.UrlResolution)); #if NET6_0_OR_GREATER refitRequest.Version = refitSettings.Version; refitRequest.VersionPolicy = refitSettings.VersionPolicy; diff --git a/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.ValueTypeRouteParameterWithAttribute#IGeneratedClient.g.verified.cs b/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.ValueTypeRouteParameterWithAttribute#IGeneratedClient.g.verified.cs index 5475971a5..42798aeed 100644 --- a/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.ValueTypeRouteParameterWithAttribute#IGeneratedClient.g.verified.cs +++ b/src/tests/Refit.GeneratorTests/_snapshots/ParameterTests.ValueTypeRouteParameterWithAttribute#IGeneratedClient.g.verified.cs @@ -52,7 +52,8 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli public global::System.Threading.Tasks.Task Get(int? @id) { var refitSettings = _settings; - var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/todos/{id}", refitSettings.AllowUnmatchedRouteParameters, ((7, 11), _settings.UrlParameterFormatter.Format(id, ______idAttributeProvider, typeof(int?)))), refitSettings.UrlResolution)); + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/todos/{id}", refitSettings.AllowUnmatchedRouteParameters, [((7, 11), refitUseDefaultFormatting ? (@id == null ? null : global::Refit.GeneratedRequestRunner.FormatInvariant(@id.Value, "00")) : refitSettings.UrlParameterFormatter.Format(@id, ______idAttributeProvider, typeof(int?)))]), refitSettings.UrlResolution)); #if NET6_0_OR_GREATER refitRequest.Version = refitSettings.Version; refitRequest.VersionPolicy = refitSettings.VersionPolicy; 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 4252d445b..d0baeaa73 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 @@ -37,15 +37,33 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli _settings = requestBuilder.Settings; } + /// Initializes a new instance of the RefitGeneratorTestIGeneratedClient class for generated-only execution. + /// The HTTP client used by the generated implementation. + /// The settings used by the generated implementation. + public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient client, global::Refit.RefitSettings settings) + { + Client = client; + _settings = settings; + } /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() where T : class, global::System.IDisposable, new() { - var refitArguments = global::System.Array.Empty(); - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty(), new global::System.Type[] { typeof(T) } ); - - return await ((global::System.Threading.Tasks.Task)refitFunc(this.Client, refitArguments)).ConfigureAwait(false); + var refitSettings = _settings; + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, "/users", refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::RefitGeneratorTest.IGeneratedClient)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + refitRequest, + refitSettings, + false, + true, + false, + global::System.Threading.CancellationToken.None); } } } 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 7558812dc..4962bd061 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 @@ -37,15 +37,33 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli _settings = requestBuilder.Settings; } + /// Initializes a new instance of the RefitGeneratorTestIGeneratedClient class for generated-only execution. + /// The HTTP client used by the generated implementation. + /// The settings used by the generated implementation. + public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient client, global::Refit.RefitSettings settings) + { + Client = client; + _settings = settings; + } /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() where T : struct { - var refitArguments = global::System.Array.Empty(); - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty(), new global::System.Type[] { typeof(T) } ); - - return await ((global::System.Threading.Tasks.Task)refitFunc(this.Client, refitArguments)).ConfigureAwait(false); + var refitSettings = _settings; + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, "/users", refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::RefitGeneratorTest.IGeneratedClient)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + refitRequest, + refitSettings, + 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 55050accd..960ea35a3 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 @@ -37,15 +37,33 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli _settings = requestBuilder.Settings; } + /// Initializes a new instance of the RefitGeneratorTestIGeneratedClient class for generated-only execution. + /// The HTTP client used by the generated implementation. + /// The settings used by the generated implementation. + public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient client, global::Refit.RefitSettings settings) + { + Client = client; + _settings = settings; + } /// - public async global::System.Threading.Tasks.Task Get() + public global::System.Threading.Tasks.Task Get() where T : unmanaged { - var refitArguments = global::System.Array.Empty(); - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("Get", global::System.Array.Empty(), new global::System.Type[] { typeof(T) } ); - - return await ((global::System.Threading.Tasks.Task)refitFunc(this.Client, refitArguments)).ConfigureAwait(false); + var refitSettings = _settings; + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, "/users", refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::RefitGeneratorTest.IGeneratedClient)); + return global::Refit.GeneratedRequestRunner.SendAsync( + this.Client, + refitRequest, + refitSettings, + 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 c7d4abca6..f6d80b094 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 @@ -37,17 +37,40 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli _settings = requestBuilder.Settings; } + /// Initializes a new instance of the RefitGeneratorTestIGeneratedClient class for generated-only execution. + /// The HTTP client used by the generated implementation. + /// The settings used by the generated implementation. + public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient client, global::Refit.RefitSettings settings) + { + Client = client; + _settings = settings; + } + /// Cached attribute provider for the generated GetUser method's user parameter. + private static readonly global::Refit.GeneratedParameterAttributeProvider ______userAttributeProvider = global::Refit.GeneratedParameterAttributeProvider.Empty; - /// Cached parameter type array for the generated GetUser method. - private static readonly global::System.Type[] ______typeParameters = new global::System.Type[] { typeof(string) }; /// public global::System.IObservable GetUser(string @user) { - var refitArguments = new object[] { @user }; - var refitRequestBuilder = _requestBuilder ?? throw new global::System.InvalidOperationException("This generated Refit method requires a request builder."); - var refitFunc = refitRequestBuilder.BuildRestResultFuncForMethod("GetUser", ______typeParameters ); - - return (global::System.IObservable)refitFunc(this.Client, refitArguments); + var refitSettings = _settings; + global::System.Net.Http.HttpRequestMessage BuildRefitRequest() + { + var refitUseDefaultFormatting = global::Refit.GeneratedRequestRunner.UsesDefaultUrlParameterFormatting(refitSettings); + var refitRequest = new global::System.Net.Http.HttpRequestMessage(global::System.Net.Http.HttpMethod.Get, global::Refit.GeneratedRequestRunner.BuildRelativeUri(this.Client, global::Refit.GeneratedRequestRunner.BuildRequestPath("/users/{user}", refitSettings.AllowUnmatchedRouteParameters, [((7, 13), refitUseDefaultFormatting ? (@user) : refitSettings.UrlParameterFormatter.Format(@user, ______userAttributeProvider, typeof(string)))]), refitSettings.UrlResolution)); + #if NET6_0_OR_GREATER + refitRequest.Version = refitSettings.Version; + refitRequest.VersionPolicy = refitSettings.VersionPolicy; + #endif + global::Refit.GeneratedRequestRunner.AddConfiguredRequestOptions(refitRequest, refitSettings, typeof(global::RefitGeneratorTest.IGeneratedClient)); + return refitRequest; + } + return global::Refit.GeneratedRequestRunner.SendObservable( + this.Client, + BuildRefitRequest, + refitSettings, + false, + false, + 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 0b5c0d8b1..cbbc43904 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 @@ -41,6 +41,10 @@ public RefitGeneratorTestIGeneratedClient(global::System.Net.Http.HttpClient cli /// Cached parameter type array for the generated GetUser method. private static readonly global::System.Type[] ______typeParameters = new global::System.Type[] { typeof(string) }; /// + #if NET5_0_OR_GREATER + [global::System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "This method's shape is not supported by generated request building and intentionally uses the reflection request builder; trimmed and Native AOT applications must use inline-eligible method shapes instead (Refit reports this at compile time).")] + [global::System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("AOT", "IL3050", Justification = "This method's shape is not supported by generated request building and intentionally uses the reflection request builder; trimmed and Native AOT applications must use inline-eligible method shapes instead (Refit reports this at compile time).")] + #endif public string GetUser(string @user) { var refitArguments = new object[] { @user }; diff --git a/src/tests/Refit.MockInterop.Tests/ApiResponseMockInteropTests.cs b/src/tests/Refit.MockInterop.Tests/ApiResponseMockInteropTests.cs index e5c0e499b..7266b1873 100644 --- a/src/tests/Refit.MockInterop.Tests/ApiResponseMockInteropTests.cs +++ b/src/tests/Refit.MockInterop.Tests/ApiResponseMockInteropTests.cs @@ -21,6 +21,10 @@ namespace Refit.MockInterop.Tests; "Performance", "CA1859:Use concrete types when possible for improved performance", Justification = "These tests read the stub through IApiResponse/IApiResponse on purpose; the concrete type would bypass the interface dispatch they exist to exercise.")] +[SuppressMessage( + "Performance", + "PSH1415:Hold the concrete type when the concrete type is what you have", + Justification = "These tests read the stub through IApiResponse/IApiResponse on purpose; the concrete type would bypass the interface dispatch they exist to exercise.")] public sealed class ApiResponseMockInteropTests { /// A non-empty content payload used by the content-narrowing tests. diff --git a/src/tests/Refit.MockInterop.Tests/Refit.MockInterop.Tests.csproj b/src/tests/Refit.MockInterop.Tests/Refit.MockInterop.Tests.csproj index c72a1306a..041763b88 100644 --- a/src/tests/Refit.MockInterop.Tests/Refit.MockInterop.Tests.csproj +++ b/src/tests/Refit.MockInterop.Tests/Refit.MockInterop.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/src/tests/Refit.Newtonsoft.Json.Tests/MultipartNewtonsoftTests.cs b/src/tests/Refit.Newtonsoft.Json.Tests/MultipartNewtonsoftTests.cs index 1252147a1..4241ccb99 100644 --- a/src/tests/Refit.Newtonsoft.Json.Tests/MultipartNewtonsoftTests.cs +++ b/src/tests/Refit.Newtonsoft.Json.Tests/MultipartNewtonsoftTests.cs @@ -88,8 +88,9 @@ public async Task MultipartUploadShouldWorkWithObjects( Asserts = async content => { var parts = content.ToList(); + const int expectedPartCount = 2; - await Assert.That(parts.Count).IsEqualTo(2); + await Assert.That(parts.Count).IsEqualTo(expectedPartCount); await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo("theObjects"); await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsNull(); diff --git a/src/tests/Refit.Newtonsoft.Json.Tests/Refit.Newtonsoft.Json.Tests.csproj b/src/tests/Refit.Newtonsoft.Json.Tests/Refit.Newtonsoft.Json.Tests.csproj index ba274c9f2..fc43fabee 100644 --- a/src/tests/Refit.Newtonsoft.Json.Tests/Refit.Newtonsoft.Json.Tests.csproj +++ b/src/tests/Refit.Newtonsoft.Json.Tests/Refit.Newtonsoft.Json.Tests.csproj @@ -38,6 +38,7 @@ + diff --git a/src/tests/Refit.Newtonsoft.Json.Tests/RestServiceIntegrationTests.Cancellation.cs b/src/tests/Refit.Newtonsoft.Json.Tests/RestServiceIntegrationTests.Cancellation.cs new file mode 100644 index 000000000..f00925723 --- /dev/null +++ b/src/tests/Refit.Newtonsoft.Json.Tests/RestServiceIntegrationTests.Cancellation.cs @@ -0,0 +1,187 @@ +// 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.Net; +using System.Text; +using Refit.Testing; + +namespace Refit.Tests; + +/// Integration tests covering how surfaces cancellation that occurs before the response is read. +public partial class RestServiceIntegrationTests +{ + /// Verifies a request canceled before the response is read surfaces the cancellation. + /// A task representing the asynchronous test. + [Test] + public async Task RequestCanceledBeforeResponseRead_WhenNoTransportExceptionFactory() + { + using var cts = new CancellationTokenSource(); + + var handler = new StubHttp + { + { + new RouteMatcher + { + Method = HttpMethod.Get, + Template = OrgMembersUrl, + Reusable = true + }, + Reply.From(req => + { + // Cancel the request + cts.Cancel(); + return new(HttpStatusCode.OK) + { + Content = new StringContent(OrgMembersJson, Encoding.UTF8, JsonMediaType) + }; + }) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl, new RefitSettings + { + ContentSerializer = new NewtonsoftJsonContentSerializer( + new() + { + ContractResolver = new SnakeCasePropertyNamesContractResolver() + }) + }); + + var result = await Assert.That( + () => (Task)fixture.GetOrgMembers(OrgName, cts.Token)).ThrowsExactly(); + + // Source generated requests directly return the SendAsync task so won't contain the caller stack frame. + await AssertStackTraceContains(nameof(GeneratedRequestRunner.SendAsync), result!.StackTrace); + } + + /// Verifies a request canceled before the response is read surfaces the cancellation. Providing a custom . + /// A task representing the asynchronous test. + [Test] + public async Task RequestCanceledBeforeResponseRead_WhenTransportExceptionFactory() + { + using var cts = new CancellationTokenSource(); + + var handler = new StubHttp + { + { + new RouteMatcher + { + Method = HttpMethod.Get, + Template = OrgMembersUrl, + Reusable = true + }, + Reply.From(req => + { + // Cancel the request + cts.Cancel(); + return new(HttpStatusCode.OK) + { + Content = new StringContent(OrgMembersJson, Encoding.UTF8, JsonMediaType) + }; + }) + }, + }; + var refitSettings = new RefitSettings + { + ContentSerializer = new NewtonsoftJsonContentSerializer( + new() + { + ContractResolver = new SnakeCasePropertyNamesContractResolver() + }) + }; + refitSettings.TransportExceptionFactory = (req, ex, _) => new ApiRequestException(req, req.Method, refitSettings, ex); + var fixture = handler.CreateClient(GitHubBaseUrl, refitSettings); + + var result = await Assert.That( + () => (Task)fixture.GetOrgMembers(OrgName, cts.Token)).ThrowsExactly(); + + await Assert.That(result!.InnerException).IsNotNull(); + await Assert.That(result.InnerException).IsTypeOf(); + + // Source generated requests directly return the SendAsync task so won't contain the caller stack frame. + await AssertStackTraceContains(nameof(GeneratedRequestRunner.SendAsync), result.StackTrace); + } + + /// Verifies cancellation before the response is read surfaces in the API response error. + /// A task representing the asynchronous test. + [Test] + public async Task RequestCanceledBeforeResponseReadWithIApiResponse_WhenNoTransportExceptionFactory() + { + using var cts = new CancellationTokenSource(); + + var handler = new StubHttp + { + { + new RouteMatcher + { + Method = HttpMethod.Get, + Template = "https://api.github.com/users/github", + Reusable = true + }, + Reply.From(req => + { + // Cancel the request + cts.Cancel(); + return new(HttpStatusCode.OK) + { + Content = new StringContent(OrgMembersJson, Encoding.UTF8, JsonMediaType) + }; + }) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl); + + await Assert.That( + () => (Task)fixture.GetUserWithMetadata(OrgName, cts.Token)).ThrowsExactly(); + } + + /// Verifies cancellation before the response is read surfaces in the API response error. Providing a custom . + /// A task representing the asynchronous test. + [Test] + public async Task RequestCanceledBeforeResponseReadWithIApiResponse_WhenTransportExceptionFactory() + { + using var cts = new CancellationTokenSource(); + + var handler = new StubHttp + { + { + new RouteMatcher + { + Method = HttpMethod.Get, + Template = "https://api.github.com/users/github", + Reusable = true + }, + Reply.From(req => + { + // Cancel the request + cts.Cancel(); + return new(HttpStatusCode.OK) + { + Content = new StringContent(OrgMembersJson, Encoding.UTF8, JsonMediaType) + }; + }) + }, + }; + var refitSettings = new RefitSettings + { + ContentSerializer = new NewtonsoftJsonContentSerializer( + new() + { + ContractResolver = new SnakeCasePropertyNamesContractResolver() + }) + }; + refitSettings.TransportExceptionFactory = (req, ex, _) => new ApiRequestException(req, req.Method, refitSettings, ex); + + var fixture = handler.CreateClient(GitHubBaseUrl, refitSettings); + + var result = await fixture.GetUserWithMetadata(OrgName, cts.Token); + + await Assert.That(result.IsReceived).IsFalse(); + await Assert.That(result.Error).IsNotNull(); + await Assert.That(result.HasRequestError(out var error)).IsTrue(); + await Assert.That(error!.InnerException).IsTypeOf(); + await Assert.That(result.HasResponseError(out _)).IsFalse(); + } +} diff --git a/src/tests/Refit.Newtonsoft.Json.Tests/RestServiceIntegrationTests.GitHub.cs b/src/tests/Refit.Newtonsoft.Json.Tests/RestServiceIntegrationTests.GitHub.cs index 88eb2e7f1..647b4a8c3 100644 --- a/src/tests/Refit.Newtonsoft.Json.Tests/RestServiceIntegrationTests.GitHub.cs +++ b/src/tests/Refit.Newtonsoft.Json.Tests/RestServiceIntegrationTests.GitHub.cs @@ -14,6 +14,9 @@ public partial class RestServiceIntegrationTests /// The HTTP header name used to assert header round-tripping. private const string CookieHeaderName = "Cookie"; + /// The HTTP header value used to assert header round-tripping. + private const string CookieHeaderValue = "Value"; + /// The base URL for the GitHub API used across the integration tests. private const string GitHubBaseUrl = "https://api.github.com"; @@ -51,7 +54,7 @@ public async Task HitTheGitHubUserApiAsApiResponse() Encoding.UTF8, JsonMediaType), }; - responseMessage.Headers.Add(CookieHeaderName, "Value"); + responseMessage.Headers.Add(CookieHeaderName, CookieHeaderValue); var handler = new StubHttp { @@ -164,7 +167,7 @@ public async Task HitTheGitHubUserApiAsObservableApiResponse() Encoding.UTF8, JsonMediaType), }; - responseMessage.Headers.Add(CookieHeaderName, "Value"); + responseMessage.Headers.Add(CookieHeaderName, CookieHeaderValue); var handler = new StubHttp { @@ -211,7 +214,7 @@ public async Task HitTheGitHubUserApiAsObservableIApiResponse() Encoding.UTF8, JsonMediaType), }; - responseMessage.Headers.Add(CookieHeaderName, "Value"); + responseMessage.Headers.Add(CookieHeaderName, CookieHeaderValue); var handler = new StubHttp { @@ -378,180 +381,6 @@ public async Task HitTheGitHubOrgMembersApiInParallel() await handler.VerifyAllCalledAsync(); } - /// Verifies a request canceled before the response is read surfaces the cancellation. - /// A task representing the asynchronous test. - [Test] - public async Task RequestCanceledBeforeResponseRead_WhenNoTransportExceptionFactory() - { - using var cts = new CancellationTokenSource(); - - var handler = new StubHttp - { - { - new RouteMatcher - { - Method = HttpMethod.Get, - Template = OrgMembersUrl, - Reusable = true - }, - Reply.From(req => - { - // Cancel the request - cts.Cancel(); - return new(HttpStatusCode.OK) - { - Content = new StringContent(OrgMembersJson, Encoding.UTF8, JsonMediaType) - }; - }) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl, new RefitSettings - { - ContentSerializer = new NewtonsoftJsonContentSerializer( - new() - { - ContractResolver = new SnakeCasePropertyNamesContractResolver() - }) - }); - - var result = await Assert.That( - () => (Task)fixture.GetOrgMembers(OrgName, cts.Token)).ThrowsExactly(); - - // Source generated requests directly return the SendAsync task so won't contain the caller stack frame. - await AssertStackTraceContains(nameof(GeneratedRequestRunner.SendAsync), result!.StackTrace); - } - - /// Verifies a request canceled before the response is read surfaces the cancellation. Providing a custom . - /// A task representing the asynchronous test. - [Test] - public async Task RequestCanceledBeforeResponseRead_WhenTransportExceptionFactory() - { - using var cts = new CancellationTokenSource(); - - var handler = new StubHttp - { - { - new RouteMatcher - { - Method = HttpMethod.Get, - Template = OrgMembersUrl, - Reusable = true - }, - Reply.From(req => - { - // Cancel the request - cts.Cancel(); - return new(HttpStatusCode.OK) - { - Content = new StringContent(OrgMembersJson, Encoding.UTF8, JsonMediaType) - }; - }) - }, - }; - var refitSettings = new RefitSettings - { - ContentSerializer = new NewtonsoftJsonContentSerializer( - new() - { - ContractResolver = new SnakeCasePropertyNamesContractResolver() - }) - }; - refitSettings.TransportExceptionFactory = (req, ex, _) => new ApiRequestException(req, req.Method, refitSettings, ex); - var fixture = handler.CreateClient(GitHubBaseUrl, refitSettings); - - var result = await Assert.That( - () => (Task)fixture.GetOrgMembers(OrgName, cts.Token)).ThrowsExactly(); - - await Assert.That(result!.InnerException).IsNotNull(); - await Assert.That(result.InnerException).IsTypeOf(); - - // Source generated requests directly return the SendAsync task so won't contain the caller stack frame. - await AssertStackTraceContains(nameof(GeneratedRequestRunner.SendAsync), result.StackTrace); - } - - /// Verifies cancellation before the response is read surfaces in the API response error. - /// A task representing the asynchronous test. - [Test] - public async Task RequestCanceledBeforeResponseReadWithIApiResponse_WhenNoTransportExceptionFactory() - { - using var cts = new CancellationTokenSource(); - - var handler = new StubHttp - { - { - new RouteMatcher - { - Method = HttpMethod.Get, - Template = "https://api.github.com/users/github", - Reusable = true - }, - Reply.From(req => - { - // Cancel the request - cts.Cancel(); - return new(HttpStatusCode.OK) - { - Content = new StringContent(OrgMembersJson, Encoding.UTF8, JsonMediaType) - }; - }) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl); - - await Assert.That( - () => (Task)fixture.GetUserWithMetadata(OrgName, cts.Token)).ThrowsExactly(); - } - - /// Verifies cancellation before the response is read surfaces in the API response error. Providing a custom . - /// A task representing the asynchronous test. - [Test] - public async Task RequestCanceledBeforeResponseReadWithIApiResponse_WhenTransportExceptionFactory() - { - using var cts = new CancellationTokenSource(); - - var handler = new StubHttp - { - { - new RouteMatcher - { - Method = HttpMethod.Get, - Template = "https://api.github.com/users/github", - Reusable = true - }, - Reply.From(req => - { - // Cancel the request - cts.Cancel(); - return new(HttpStatusCode.OK) - { - Content = new StringContent(OrgMembersJson, Encoding.UTF8, JsonMediaType) - }; - }) - }, - }; - var refitSettings = new RefitSettings - { - ContentSerializer = new NewtonsoftJsonContentSerializer( - new() - { - ContractResolver = new SnakeCasePropertyNamesContractResolver() - }) - }; - refitSettings.TransportExceptionFactory = (req, ex, _) => new ApiRequestException(req, req.Method, refitSettings, ex); - - var fixture = handler.CreateClient(GitHubBaseUrl, refitSettings); - - var result = await fixture.GetUserWithMetadata(OrgName, cts.Token); - - await Assert.That(result.IsReceived).IsFalse(); - await Assert.That(result.Error).IsNotNull(); - await Assert.That(result.HasRequestError(out var error)).IsTrue(); - await Assert.That(error!.InnerException).IsTypeOf(); - await Assert.That(result.HasResponseError(out _)).IsFalse(); - } - /// Verifies the GitHub user search API can be queried. /// A task representing the asynchronous test. [Test] diff --git a/src/tests/Refit.Newtonsoft.Json.Tests/RestServiceIntegrationTests.RequestBody.cs b/src/tests/Refit.Newtonsoft.Json.Tests/RestServiceIntegrationTests.RequestBody.cs index 8d9edfcf8..5e7f21173 100644 --- a/src/tests/Refit.Newtonsoft.Json.Tests/RestServiceIntegrationTests.RequestBody.cs +++ b/src/tests/Refit.Newtonsoft.Json.Tests/RestServiceIntegrationTests.RequestBody.cs @@ -13,6 +13,9 @@ namespace Refit.Tests; /// Newtonsoft-specific request/response body integration tests. public partial class RestServiceIntegrationTests { + /// The message token used as an error payload key and expected error entry. + private const string MessageToken = "message"; + /// Verifies error content can be read from error responses. /// A task representing the asynchronous test. [Test] @@ -26,7 +29,7 @@ public async Task CanGetDataOutOfErrorResponses() }, }; - var fixture = handler.CreateClient("https://api.github.com", new RefitSettings + var fixture = handler.CreateClient(GitHubBaseUrl, new RefitSettings { ContentSerializer = new NewtonsoftJsonContentSerializer( new() @@ -44,7 +47,7 @@ public async Task CanGetDataOutOfErrorResponses() await Assert.That(exception.StatusCode).IsEqualTo(HttpStatusCode.NotFound); var content = await exception.GetContentAsAsync>(); - await Assert.That(content!["message"]).IsEqualTo("Not Found"); + await Assert.That(content![MessageToken]).IsEqualTo("Not Found"); await Assert.That(content["documentation_url"]).IsNotNull(); } } @@ -62,7 +65,7 @@ public async Task ErrorsFromApiReturnErrorContent() }, }; - var fixture = handler.CreateClient("https://api.github.com", new RefitSettings + var fixture = handler.CreateClient(GitHubBaseUrl, new RefitSettings { ContentSerializer = new NewtonsoftJsonContentSerializer( new() @@ -74,12 +77,15 @@ public async Task ErrorsFromApiReturnErrorContent() var result = await Assert.That( () => (Task)fixture.CreateUser(new() { Name = "foo" })).ThrowsExactly(); - await AssertStackTraceContains(nameof(IGitHubApi.CreateUser), result!.StackTrace); + // Implicit-body methods now construct their request inline (no generated async frame in the stack), + // so assert the failing request's identity through the exception metadata instead. + await Assert.That(result!.HttpMethod).IsEqualTo(HttpMethod.Post); + await Assert.That(result.Uri!.AbsolutePath).IsEqualTo("/users"); var errors = await result.GetContentAsAsync(); await Assert.That(errors!.Errors).Contains("error1"); - await Assert.That(errors.Errors).Contains("message"); + await Assert.That(errors.Errors).Contains(MessageToken); await handler.VerifyAllCalledAsync(); } @@ -97,7 +103,7 @@ public async Task ErrorsFromApiReturnErrorContentWhenApiResponse() }, }; - var fixture = handler.CreateClient("https://api.github.com", new RefitSettings + var fixture = handler.CreateClient(GitHubBaseUrl, new RefitSettings { ContentSerializer = new NewtonsoftJsonContentSerializer( new() @@ -115,7 +121,7 @@ public async Task ErrorsFromApiReturnErrorContentWhenApiResponse() var errors = await error!.GetContentAsAsync(); await Assert.That(errors!.Errors).Contains("error1"); - await Assert.That(errors.Errors).Contains("message"); + await Assert.That(errors.Errors).Contains(MessageToken); await handler.VerifyAllCalledAsync(); } diff --git a/src/tests/Refit.Newtonsoft.Json.Tests/SerializedContentNewtonsoftTests.cs b/src/tests/Refit.Newtonsoft.Json.Tests/SerializedContentNewtonsoftTests.cs index ba14e8cfb..1fe7cf720 100644 --- a/src/tests/Refit.Newtonsoft.Json.Tests/SerializedContentNewtonsoftTests.cs +++ b/src/tests/Refit.Newtonsoft.Json.Tests/SerializedContentNewtonsoftTests.cs @@ -34,7 +34,7 @@ public async Task WhenARequestRequiresABodyThenItDoesNotDeadlock(Type contentSer var handler = new MockPushStreamContentHttpMessageHandler { - Asserts = async content => + Asserts = static async content => new StringContent(await content.ReadAsStringAsync().ConfigureAwait(false)) }; @@ -62,10 +62,13 @@ public async Task WhenARequestRequiresABodyThenItIsSerialized(Type contentSerial $"{contentSerializerType.FullName} does not implement {nameof(IHttpContentSerializer)}"); } + const int sampleCreatedYear = 1949; + const int sampleCreatedMonth = 9; + const int sampleCreatedDay = 16; var model = new User { Name = "Wile E. Coyote", - CreatedAt = new DateOnly(1949, 9, 16).ToString(), + CreatedAt = new DateOnly(sampleCreatedYear, sampleCreatedMonth, sampleCreatedDay).ToString(), Company = "ACME", }; @@ -224,7 +227,8 @@ await Assert.That(() => serializer.FromHttpContentAsync(content)) /// The original fixture task once it completes or the timeout elapses. private static async Task> RunTaskWithATimeLimit(Task fixtureTask) { - var circuitBreakerTask = Task.Delay(TimeSpan.FromSeconds(30)); + const int circuitBreakerTimeoutSeconds = 30; + var circuitBreakerTask = Task.Delay(TimeSpan.FromSeconds(circuitBreakerTimeoutSeconds)); await Task.WhenAny(fixtureTask, circuitBreakerTask); return fixtureTask; } diff --git a/src/tests/Refit.Testing.Tests/NetworkBehaviorTests.cs b/src/tests/Refit.Testing.Tests/NetworkBehaviorTests.cs index 017eaabaa..ca70702cf 100644 --- a/src/tests/Refit.Testing.Tests/NetworkBehaviorTests.cs +++ b/src/tests/Refit.Testing.Tests/NetworkBehaviorTests.cs @@ -17,7 +17,7 @@ public sealed class NetworkBehaviorTests [Test] public async Task FailureRateAlwaysThrows() { - var behavior = new NetworkBehavior { Delay = TimeSpan.Zero, FailurePercent = 1d, ErrorPercent = 0d }; + var behavior = new NetworkBehavior { Delay = TimeSpan.Zero, FailurePercent = 1D, ErrorPercent = 0D }; var handler = new StubHttp(behavior) { { @@ -37,8 +37,8 @@ public async Task ErrorRateReturnsErrorStatus() var behavior = new NetworkBehavior { Delay = TimeSpan.Zero, - FailurePercent = 0d, - ErrorPercent = 1d, + FailurePercent = 0D, + ErrorPercent = 1D, ErrorStatusCode = HttpStatusCode.ServiceUnavailable, }; var handler = new StubHttp(behavior) @@ -59,7 +59,7 @@ public async Task ErrorRateReturnsErrorStatus() [Test] public async Task NoFaultsYieldStubbedResponse() { - var behavior = new NetworkBehavior { Delay = TimeSpan.Zero, FailurePercent = 0d, ErrorPercent = 0d }; + var behavior = new NetworkBehavior { Delay = TimeSpan.Zero, FailurePercent = 0D, ErrorPercent = 0D }; var handler = new StubHttp(behavior) { { @@ -81,8 +81,9 @@ public async Task SeededDelaysAreReproducible() { const int samples = 5; const int seed = 123; - var a = new NetworkBehavior(seed) { Delay = TimeSpan.FromSeconds(1), Variance = 0.5d }; - var b = new NetworkBehavior(seed) { Delay = TimeSpan.FromSeconds(1), Variance = 0.5d }; + const double delayVariance = 0.5D; + var a = new NetworkBehavior(seed) { Delay = TimeSpan.FromSeconds(1), Variance = delayVariance }; + var b = new NetworkBehavior(seed) { Delay = TimeSpan.FromSeconds(1), Variance = delayVariance }; for (var i = 0; i < samples; i++) { @@ -95,8 +96,8 @@ public async Task SeededDelaysAreReproducible() [Test] public async Task ProbabilityBoundariesAreHonored() { - var always = new NetworkBehavior { FailurePercent = 1d, ErrorPercent = 1d }; - var never = new NetworkBehavior { FailurePercent = 0d, ErrorPercent = 0d }; + var always = new NetworkBehavior { FailurePercent = 1D, ErrorPercent = 1D }; + var never = new NetworkBehavior { FailurePercent = 0D, ErrorPercent = 0D }; await Assert.That(always.NextIsFailure()).IsTrue(); await Assert.That(always.NextIsError()).IsTrue(); diff --git a/src/tests/Refit.Testing.Tests/Refit.Testing.Tests.csproj b/src/tests/Refit.Testing.Tests/Refit.Testing.Tests.csproj index f19f8c9d4..dfdee0b44 100644 --- a/src/tests/Refit.Testing.Tests/Refit.Testing.Tests.csproj +++ b/src/tests/Refit.Testing.Tests/Refit.Testing.Tests.csproj @@ -15,6 +15,7 @@ + diff --git a/src/tests/Refit.Testing.Tests/RetryAndTimeoutTests.cs b/src/tests/Refit.Testing.Tests/RetryAndTimeoutTests.cs index 33339d5e1..102bd419f 100644 --- a/src/tests/Refit.Testing.Tests/RetryAndTimeoutTests.cs +++ b/src/tests/Refit.Testing.Tests/RetryAndTimeoutTests.cs @@ -112,12 +112,14 @@ public async Task ExhaustedRetriesSurfaceApiException() [Test] public async Task RequestSlowerThanClientTimeoutThrowsApiRequestException() { - var handler = CreateDelayedHandler(TimeSpan.FromSeconds(10)); + const int injectedDelaySeconds = 10; + const int clientTimeoutMilliseconds = 50; + var handler = CreateDelayedHandler(TimeSpan.FromSeconds(injectedDelaySeconds)); using var client = new HttpClient(handler) { BaseAddress = new(BaseUrl), - Timeout = TimeSpan.FromMilliseconds(50), + Timeout = TimeSpan.FromMilliseconds(clientTimeoutMilliseconds), }; var api = RestService.For(client); @@ -131,12 +133,13 @@ public async Task RequestSlowerThanClientTimeoutThrowsApiRequestException() [Test] public async Task RequestFasterThanClientTimeoutSucceeds() { + const int clientTimeoutSeconds = 30; var handler = CreateDelayedHandler(TimeSpan.FromMilliseconds(1)); using var client = new HttpClient(handler) { BaseAddress = new(BaseUrl), - Timeout = TimeSpan.FromSeconds(30), + Timeout = TimeSpan.FromSeconds(clientTimeoutSeconds), }; var api = RestService.For(client); @@ -154,9 +157,9 @@ private static StubHttp CreateDelayedHandler(TimeSpan delay) var behavior = new NetworkBehavior { Delay = delay, - Variance = 0d, - FailurePercent = 0d, - ErrorPercent = 0d, + Variance = 0D, + FailurePercent = 0D, + ErrorPercent = 0D, }; return new(behavior) diff --git a/src/tests/Refit.Testing.Tests/RouteTableFeatureTests.cs b/src/tests/Refit.Testing.Tests/RouteTableFeatureTests.cs index 89d173ef0..d46cdd57a 100644 --- a/src/tests/Refit.Testing.Tests/RouteTableFeatureTests.cs +++ b/src/tests/Refit.Testing.Tests/RouteTableFeatureTests.cs @@ -18,6 +18,9 @@ public sealed class RouteTableFeatureTests /// A sample user identifier exercised by the template tests. private const int SampleUserId = 7; + /// A sample user login exercised by the template tests. + private const string SampleLogin = "octocat"; + /// Verifies a {id} template placeholder matches any single path segment. /// A task representing the asynchronous test. [Test] @@ -27,7 +30,7 @@ public async Task TemplatePlaceholderMatchesPathSegment() { { Route.Get("/users/{id}"), - Reply.With(new User(SampleUserId, "octocat")) + Reply.With(new User(SampleUserId, SampleLogin)) }, }; @@ -35,7 +38,34 @@ public async Task TemplatePlaceholderMatchesPathSegment() var user = await api.GetUser(SampleUserId); await Assert.That(user.Id).IsEqualTo(SampleUserId); - await Assert.That(user.Login).IsEqualTo("octocat"); + await Assert.That(user.Login).IsEqualTo(SampleLogin); + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a route is tried only after specific routes, regardless of + /// where it is declared in the table, and is not required by . + /// A task representing the asynchronous test. + [Test] + public async Task FallbackRouteIsTriedLastRegardlessOfDeclarationOrder() + { + var handler = new StubHttp + { + // Declared first, yet must not shadow the specific route below. + { Route.Fallback(), Reply.Status(HttpStatusCode.Accepted) }, + { Route.Get("/users/{id}"), Reply.With(new User(SampleUserId, SampleLogin)) }, + }; + + var api = handler.CreateClient(BaseUrl); + + // The specific GET route wins over the earlier-declared fallback. + var user = await api.GetUser(SampleUserId); + await Assert.That(user.Login).IsEqualTo(SampleLogin); + + // A request no specific route matches falls through to the fallback. + var response = await api.CreateUserResponse(new NewUser("mona")); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Accepted); + + // The fallback is a background default, not a required expectation. await handler.VerifyAllCalledAsync(); } @@ -63,11 +93,12 @@ public async Task TypedReplyIsSerializedByClientSerializer() [Test] public async Task LastRequestBodyDeserializesSentPayload() { + const int createdUserId = 2; var handler = new StubHttp { { Route.Post("/users"), - Reply.With(new User(2, "created"), HttpStatusCode.Created) + Reply.With(new User(createdUserId, "created"), HttpStatusCode.Created) }, }; @@ -85,11 +116,12 @@ public async Task LastRequestBodyDeserializesSentPayload() [Test] public async Task TypedReplyCarriesStatusCode() { + const int createdUserId = 3; var handler = new StubHttp { { Route.Post("/users"), - Reply.With(new User(3, "created"), HttpStatusCode.Created) + Reply.With(new User(createdUserId, "created"), HttpStatusCode.Created) }, }; diff --git a/src/tests/Refit.Testing.Tests/StubHttpCoverageTests.cs b/src/tests/Refit.Testing.Tests/StubHttpCoverageTests.cs index 0c7580d4b..47843c45c 100644 --- a/src/tests/Refit.Testing.Tests/StubHttpCoverageTests.cs +++ b/src/tests/Refit.Testing.Tests/StubHttpCoverageTests.cs @@ -20,6 +20,12 @@ public sealed class StubHttpCoverageTests /// An index beyond the recorded requests, used to exercise range validation. private const int OutOfRangeIndex = 5; + /// A negative index, used to exercise the lower-bound range check. + private const int NegativeIndex = -1; + + /// A route template with a placeholder segment used by the template-matcher tests. + private const string PlaceholderRoute = "/a/{id}"; + /// Verifies each verb-specific factory matches its method. /// A task representing the asynchronous test. [Test] @@ -95,6 +101,19 @@ public async Task RequestBodyByIndexRoundTripsAndValidatesRange() await Assert.That(() => handler.RequestBodyAsync(OutOfRangeIndex)).ThrowsExactly(); } + /// Verifies a negative index into the recorded request bodies is rejected. + /// A task representing the asynchronous test. + [Test] + public async Task RequestBodyAsyncRejectsNegativeIndex() + { + var handler = new StubHttp { { Route.Post("/users"), Reply.With(new User(1, "created")) } }; + var api = handler.CreateClient(BaseUrl); + + _ = await api.CreateUser(new NewUser("bob")); + + await Assert.That(() => handler.RequestBodyAsync(NegativeIndex)).ThrowsExactly(); + } + /// Verifies throws when no request has been received. /// A task representing the asynchronous test. [Test] @@ -145,7 +164,7 @@ public async Task HandlerEnumeratesRoutes() [Test] public async Task TemplatePlaceholderRejectsEmptySegment() { - var handler = new StubHttp { { Route.Get("/a/{id}"), Reply.Status(HttpStatusCode.OK) } }; + var handler = new StubHttp { { Route.Get(PlaceholderRoute), Reply.Status(HttpStatusCode.OK) } }; await Assert.That(async () => _ = await SendAsync(handler, HttpMethod.Get, BaseUrl + "/a/")) .ThrowsExactly(); @@ -156,7 +175,7 @@ await Assert.That(async () => _ = await SendAsync(handler, HttpMethod.Get, BaseU [Test] public async Task TemplateRejectsSegmentCountMismatch() { - var handler = new StubHttp { { Route.Get("/a/{id}"), Reply.Status(HttpStatusCode.OK) } }; + var handler = new StubHttp { { Route.Get(PlaceholderRoute), Reply.Status(HttpStatusCode.OK) } }; await Assert.That(async () => _ = await SendAsync(handler, HttpMethod.Get, BaseUrl + "/a/1/extra")) .ThrowsExactly(); @@ -167,7 +186,7 @@ await Assert.That(async () => _ = await SendAsync(handler, HttpMethod.Get, BaseU [Test] public async Task TemplateRejectsLiteralMismatch() { - var handler = new StubHttp { { Route.Get("/a/{id}"), Reply.Status(HttpStatusCode.OK) } }; + var handler = new StubHttp { { Route.Get(PlaceholderRoute), Reply.Status(HttpStatusCode.OK) } }; await Assert.That(async () => _ = await SendAsync(handler, HttpMethod.Get, BaseUrl + "/b/1")) .ThrowsExactly(); diff --git a/src/tests/Refit.Testing.Tests/StubHttpTests.cs b/src/tests/Refit.Testing.Tests/StubHttpTests.cs index 1b49d338b..417ad5871 100644 --- a/src/tests/Refit.Testing.Tests/StubHttpTests.cs +++ b/src/tests/Refit.Testing.Tests/StubHttpTests.cs @@ -21,6 +21,12 @@ public sealed class StubHttpTests /// A reusable request URL used by ordering tests. private const string SeqUrl = "https://api/seq"; + /// A reusable request URL used by the any-method match test. + private const string AnyUrl = "https://api/any"; + + /// A query string appended to request URLs by query-matching tests. + private const string QuerySuffix = "?a=1&b=2"; + /// Verifies a method/URL match returns the configured JSON and satisfies verification. /// A task representing the asynchronous test. [Test] @@ -83,13 +89,13 @@ public async Task NullMethodMatchesAnyMethod() var handler = new StubHttp { { - new RouteMatcher { Template = "https://api/any", Reusable = true }, + new RouteMatcher { Template = AnyUrl, Reusable = true }, Reply.Status(HttpStatusCode.OK) }, }; - var get = await SendAsync(handler, HttpMethod.Get, "https://api/any"); - var post = await SendAsync(handler, HttpMethod.Post, "https://api/any"); + var get = await SendAsync(handler, HttpMethod.Get, AnyUrl); + var post = await SendAsync(handler, HttpMethod.Post, AnyUrl); await Assert.That(get.StatusCode).IsEqualTo(HttpStatusCode.OK); await Assert.That(post.StatusCode).IsEqualTo(HttpStatusCode.OK); @@ -126,7 +132,7 @@ public async Task UrlMatchesIgnoringQuery() }, }; - var response = await SendAsync(handler, HttpMethod.Get, ThingUrl + "?a=1&b=2"); + var response = await SendAsync(handler, HttpMethod.Get, ThingUrl + QuerySuffix); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); } @@ -144,7 +150,7 @@ public async Task PartialQueryMatchesSubset() }, }; - var response = await SendAsync(handler, HttpMethod.Get, QueryUrl + "?a=1&b=2"); + var response = await SendAsync(handler, HttpMethod.Get, QueryUrl + QuerySuffix); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); await handler.VerifyAllCalledAsync(); @@ -198,7 +204,7 @@ public async Task ExactQueryStringRejectsExtras() }, }; - await Assert.That(async () => _ = await SendAsync(handler, HttpMethod.Get, QueryUrl + "?a=1&b=2")) + await Assert.That(async () => _ = await SendAsync(handler, HttpMethod.Get, QueryUrl + QuerySuffix)) .ThrowsExactly(); } @@ -326,7 +332,7 @@ public async Task WherePredicateGatesMatch() var handler = new StubHttp { { - new RouteMatcher { Template = "*", Where = request => request.Headers.Contains("X-Flag"), Reusable = true }, + new RouteMatcher { Template = "*", Where = static request => request.Headers.Contains("X-Flag"), Reusable = true }, Reply.Status(HttpStatusCode.OK) }, }; diff --git a/src/tests/Refit.Tests/AdapterUser.cs b/src/tests/Refit.Tests/AdapterUser.cs new file mode 100644 index 000000000..d4612f0b5 --- /dev/null +++ b/src/tests/Refit.Tests/AdapterUser.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. + +namespace Refit.Tests; + +/// A sample response body for return-type adapter tests. +public sealed class AdapterUser +{ + /// Gets or sets the user id. + public int Id { get; set; } + + /// Gets or sets the user name. + public string? Name { get; set; } +} diff --git a/src/tests/Refit.Tests/AddressQuery.cs b/src/tests/Refit.Tests/AddressQuery.cs new file mode 100644 index 000000000..67659ae20 --- /dev/null +++ b/src/tests/Refit.Tests/AddressQuery.cs @@ -0,0 +1,16 @@ +// 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; + +/// A nested address object whose properties compose under the enclosing property's key. +public sealed class AddressQuery +{ + /// Gets or sets the city. + public string? City { get; set; } + + /// Gets or sets the aliased postal code. + [AliasAs("z")] + public string? Zip { get; set; } +} diff --git a/src/tests/Refit.Tests/AnotherModel.cs b/src/tests/Refit.Tests/AnotherModel.cs index 60310fd89..7ca263e0d 100644 --- a/src/tests/Refit.Tests/AnotherModel.cs +++ b/src/tests/Refit.Tests/AnotherModel.cs @@ -7,5 +7,5 @@ namespace Refit.Tests; public class AnotherModel { /// Gets or sets the array of string values. - public string[]? Foos { get; set; } + public string[]? Foos { get; init; } } diff --git a/src/tests/Refit.Tests/ApiExceptionTests.Content.cs b/src/tests/Refit.Tests/ApiExceptionTests.Content.cs new file mode 100644 index 000000000..460fa25de --- /dev/null +++ b/src/tests/Refit.Tests/ApiExceptionTests.Content.cs @@ -0,0 +1,251 @@ +// 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; + +/// Content deserialization, truncation, and error-body reading tests for . +public sealed partial class ApiExceptionTests +{ + /// Verifies the synchronous GetContentAs deserializes the buffered error body (#1591). + /// A task representing the asynchronous test. + [Test] + [SuppressMessage("Concurrency", "PSH1313:Call the async overload from an async method", Justification = "This test intentionally covers the synchronous content helper.")] + public async Task SyncGetContentAsDeserializesContent() + { + using var response = CreateErrorResponse(ValueJson); + var exception = await ApiException.Create( + response.RequestMessage!, + HttpMethod.Get, + response, + new()); + + var model = exception.GetContentAs(); + + await Assert.That(model!.Value).IsEqualTo(ExpectedValue); + } + + /// Verifies the synchronous GetContentAs returns default when there is no body (#1591). + /// A task representing the asynchronous test. + [Test] + [SuppressMessage("Concurrency", "PSH1313:Call the async overload from an async method", Justification = "This test intentionally covers the synchronous content helper.")] + public async Task SyncGetContentAsReturnsDefaultWhenNoContent() + { + using var response = CreateErrorResponse(" "); + var exception = await ApiException.Create( + response.RequestMessage!, + HttpMethod.Get, + response, + new()); + + await Assert.That(exception.GetContentAs()).IsNull(); + } + + /// Verifies TryGetContentAs returns the value inside an exception filter (#1591). + /// A task representing the asynchronous test. + [Test] + public async Task SyncTryGetContentAsReturnsTrueAndValue() + { + using var response = CreateErrorResponse(ValueJson); + var exception = await ApiException.Create( + response.RequestMessage!, + HttpMethod.Get, + response, + new()); + + var ok = exception.TryGetContentAs(out var model); + + await Assert.That(ok).IsTrue(); + await Assert.That(model!.Value).IsEqualTo(ExpectedValue); + } + + /// Verifies TryGetContentAs returns false for missing or malformed content (#1591). + /// A task representing the asynchronous test. + [Test] + public async Task SyncTryGetContentAsReturnsFalseForMissingOrInvalidContent() + { + using var empty = CreateErrorResponse(" "); + using var invalid = CreateErrorResponse("not json"); + var emptyException = await ApiException.Create(empty.RequestMessage!, HttpMethod.Get, empty, new()); + var invalidException = await ApiException.Create(invalid.RequestMessage!, HttpMethod.Get, invalid, new()); + + await Assert.That(emptyException.TryGetContentAs(out var missing)).IsFalse(); + await Assert.That(missing).IsNull(); + await Assert.That(invalidException.TryGetContentAs(out var broken)).IsFalse(); + await Assert.That(broken).IsNull(); + } + + /// Verifies the synchronous helpers behave when the serializer lacks sync support (#1591). + /// A task representing the asynchronous test. + [Test] + public async Task SyncGetContentAsWithoutSyncSerializerSupport() + { + using var response = CreateErrorResponse(ValueJson); + var settings = new RefitSettings(new AsyncOnlySerializer()); + var exception = await ApiException.Create( + response.RequestMessage!, + HttpMethod.Get, + response, + settings); + + await Assert.That(exception.TryGetContentAs(out _)).IsFalse(); + await Assert.That(exception.GetContentAs) + .ThrowsExactly(); + } + + /// Verifies the error body is truncated to the configured maximum length. + /// A task representing the asynchronous test. + [Test] + public async Task MaxExceptionContentLengthTruncatesErrorBody() + { + using var response = CreateErrorResponse(new string('a', LongBodyLength)); + var settings = new RefitSettings { MaxExceptionContentLength = MaxContentLength }; + + var exception = await ApiException.Create( + response.RequestMessage!, + HttpMethod.Get, + response, + settings); + + await Assert.That(exception.Content!.Length).IsEqualTo(MaxContentLength); + } + + /// Verifies an unset maximum length leaves the error body intact. + /// A task representing the asynchronous test. + [Test] + public async Task MaxExceptionContentLengthUnsetReadsFullBody() + { + using var response = CreateErrorResponse(new string('a', LongBodyLength)); + + var exception = await ApiException.Create( + response.RequestMessage!, + HttpMethod.Get, + response, + new()); + + await Assert.That(exception.Content!.Length).IsEqualTo(LongBodyLength); + } + + /// Verifies a zero maximum length yields an empty error body. + /// A task representing the asynchronous test. + [Test] + public async Task MaxExceptionContentLengthZeroReturnsEmptyContent() + { + const int bodyLength = 100; + using var response = CreateErrorResponse(new string('a', bodyLength)); + var settings = new RefitSettings { MaxExceptionContentLength = 0 }; + + var exception = await ApiException.Create( + response.RequestMessage!, + HttpMethod.Get, + response, + settings); + + await Assert.That(exception.Content).IsEqualTo(string.Empty); + } + + /// Verifies a maximum length larger than the body reads the entire body and stops at end of stream. + /// A task representing the asynchronous test. + [Test] + public async Task MaxExceptionContentLengthLargerThanBodyReadsEntireBody() + { + const int bodyLength = 10; + using var response = CreateErrorResponse(new string('a', bodyLength)); + var settings = new RefitSettings { MaxExceptionContentLength = LongBodyLength }; + + var exception = await ApiException.Create( + response.RequestMessage!, + HttpMethod.Get, + response, + settings); + + await Assert.That(exception.Content!.Length).IsEqualTo(bodyLength); + } + + /// Verifies the error body is still read when the response carries no content type. + /// A task representing the asynchronous test. + [Test] + public async Task CreateReadsContentWhenContentTypeMissing() + { + using var response = CreateErrorResponse("{\"Value\":1}"); + response.Content!.Headers.ContentType = null; + + var exception = await ApiException.Create( + response.RequestMessage!, + HttpMethod.Get, + response, + new()); + + await Assert.That(exception).IsTypeOf(); + await Assert.That(exception.Content).IsEqualTo("{\"Value\":1}"); + } + + /// Verifies the error body is read when the content read genuinely suspends asynchronously. + /// A task representing the asynchronous test. + [Test] + public async Task CreateReadsContentThatCompletesAsynchronously() + { + using var response = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + RequestMessage = new(HttpMethod.Get, ExampleUri), + Content = new AsyncReadContent("{\"Value\":7}") + }; + + var exception = await ApiException.Create( + response.RequestMessage!, + HttpMethod.Get, + response, + new()); + + await Assert.That(exception.Content).IsEqualTo("{\"Value\":7}"); + } + + /// Content whose read genuinely suspends, forcing the asynchronous completion path when the exception reads the body. + private sealed class AsyncReadContent : HttpContent + { + /// The UTF-8 payload bytes. + private readonly byte[] _payload; + + /// Initializes a new instance of the class. + /// The string payload. + public AsyncReadContent(string payload) => _payload = System.Text.Encoding.UTF8.GetBytes(payload); + + /// + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + await Task.Yield(); + await stream.WriteAsync(_payload.AsMemory()).ConfigureAwait(false); + } + + /// + protected override bool TryComputeLength(out long length) + { + length = _payload.Length; + return true; + } + } + + /// A serializer that only supports asynchronous deserialization (no sync capability). + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Mirrors the explicit type parameters of the wrapped IHttpContentSerializer contract.")] + private sealed class AsyncOnlySerializer : IHttpContentSerializer + { + /// The wrapped serializer that does the real work. + private readonly SystemTextJsonContentSerializer _inner = new(); + + /// + public HttpContent ToHttpContent(T item) => _inner.ToHttpContent(item); + + /// + public Task FromHttpContentAsync(HttpContent content, CancellationToken cancellationToken = default) => + _inner.FromHttpContentAsync(content, cancellationToken); + + /// + public string? GetFieldNameForProperty(System.Reflection.PropertyInfo propertyInfo) => + _inner.GetFieldNameForProperty(propertyInfo); + } +} diff --git a/src/tests/Refit.Tests/ApiExceptionTests.cs b/src/tests/Refit.Tests/ApiExceptionTests.cs index 0babc2668..5a2621b2b 100644 --- a/src/tests/Refit.Tests/ApiExceptionTests.cs +++ b/src/tests/Refit.Tests/ApiExceptionTests.cs @@ -9,7 +9,7 @@ namespace Refit.Tests; /// Tests for API exception factory and validation exception edge cases. -public sealed class ApiExceptionTests +public sealed partial class ApiExceptionTests { /// The example request URI reused across the exception tests. private const string ExampleUri = "https://example.test"; @@ -26,6 +26,9 @@ public sealed class ApiExceptionTests /// A JSON error body that deserializes to . private const string ValueJson = "{\"Value\":42}"; + /// The custom exception message reused across the constructor tests. + private const string CustomMessage = "custom"; + /// The deserialized value asserted by the content-helper tests. private const int ExpectedValue = 42; @@ -79,7 +82,7 @@ public async Task CreateHandlesMissingAndUnreadableContent() var noContentException = await ApiException.Create(request, HttpMethod.Get, noContentResponse, new()); var throwingContentException = await ApiException.Create( - "custom", + CustomMessage, request, HttpMethod.Get, throwingContentResponse, @@ -105,7 +108,7 @@ public async Task ConstructorsAndContentHelpersPreserveContext() response, settings); var derived = new DerivedApiException( - "custom", + CustomMessage, response.RequestMessage!, HttpMethod.Get, "content", @@ -127,7 +130,7 @@ public async Task ConstructorsAndContentHelpersPreserveContext() await Assert.That(model!.Value).IsEqualTo(ExpectedValue); await Assert.That(missing).IsNull(); - await Assert.That(derived.Message).IsEqualTo("custom"); + await Assert.That(derived.Message).IsEqualTo(CustomMessage); await Assert.That(derived.StatusCode).IsEqualTo(HttpStatusCode.Conflict); await Assert.That(emptyDerived.HasContent).IsFalse(); } @@ -143,7 +146,7 @@ public async Task ValidationApiExceptionConstructorsAndCreateGuards() await Assert.That(messageOnly.Message).IsEqualTo(MessageText); await Assert.That(withInner.InnerException).IsSameReferenceAs(inner); - await Assert.That(() => ValidationApiException.Create(null!)) + await Assert.That(static () => ValidationApiException.Create(null!)) .ThrowsExactly(); using var emptyResponse = CreateErrorResponse(" "); @@ -159,8 +162,7 @@ await Assert.That(() => ValidationApiException.Create(emptyException)) /// 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.")] + [SuppressMessage("Concurrency", "PSH1313:Call the async overload from an async method", Justification = "This test intentionally covers the synchronous compatibility factory.")] public async Task ValidationApiExceptionCreateDeserializesProblemDetails() { using var response = CreateErrorResponse( @@ -183,8 +185,7 @@ public async Task ValidationApiExceptionCreateDeserializesProblemDetails() /// Verifies the synchronous ValidationApiException factory hydrates problem detail extensions and scalar errors. /// 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.")] + [SuppressMessage("Concurrency", "PSH1313:Call the async overload from an async method", Justification = "This test intentionally covers the synchronous compatibility factory.")] public async Task ValidationApiExceptionCreateReadsExtensionsAndScalarErrors() { using var response = CreateErrorResponse( @@ -235,8 +236,7 @@ public async Task ValidationApiExceptionCreateReadsExtensionsAndScalarErrors() /// Verifies the synchronous ValidationApiException factory ignores malformed validation error bags. /// 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.")] + [SuppressMessage("Concurrency", "PSH1313:Call the async overload from an async method", Justification = "This test intentionally covers the synchronous compatibility factory.")] public async Task ValidationApiExceptionCreateIgnoresNonObjectErrors() { using var response = CreateErrorResponse( @@ -257,8 +257,7 @@ public async Task ValidationApiExceptionCreateIgnoresNonObjectErrors() /// Verifies the synchronous ValidationApiException factory rejects non-object 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.")] + [SuppressMessage("Concurrency", "PSH1313:Call the async overload from an async method", Justification = "This test intentionally covers the synchronous compatibility factory.")] public async Task ValidationApiExceptionCreateRejectsNonObjectProblemDetails() { using var response = CreateErrorResponse("[1,2,3]", ProblemJsonMediaType); @@ -279,6 +278,18 @@ public async Task ValidationApiExceptionInnerConstructorRejectsNullInnerExceptio await Assert.That(static () => new ValidationApiException(MessageText, null!)) .ThrowsExactly(); + /// Verifies the inner-exception request constructor rejects a null inner exception, since its message + /// seeds the base exception message. + /// A task representing the asynchronous test. + [Test] + public async Task ApiRequestExceptionInnerConstructorRejectsNullInnerException() + { + using var request = new HttpRequestMessage(HttpMethod.Get, ExampleUri); + + await Assert.That(() => new ApiRequestException(request, HttpMethod.Post, new(), null!)) + .ThrowsExactly(); + } + /// Verifies the problem+json media type is detected case-insensitively per RFC 7231 (#1702). /// The problem details media type with varied casing. /// A task representing the asynchronous test. @@ -301,94 +312,6 @@ public async Task CreateDetectsProblemJsonMediaTypeCaseInsensitively(string medi await Assert.That(exception).IsTypeOf(); } - /// Verifies the synchronous GetContentAs deserializes the buffered error body (#1591). - /// 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 content helper.")] - [SuppressMessage("Major Code Smell", "S6966:Awaitable method should be used", Justification = "This test intentionally covers the synchronous content helper.")] - public async Task SyncGetContentAsDeserializesContent() - { - using var response = CreateErrorResponse(ValueJson); - var exception = await ApiException.Create( - response.RequestMessage!, - HttpMethod.Get, - response, - new()); - - var model = exception.GetContentAs(); - - await Assert.That(model!.Value).IsEqualTo(ExpectedValue); - } - - /// Verifies the synchronous GetContentAs returns default when there is no body (#1591). - /// 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 content helper.")] - [SuppressMessage("Major Code Smell", "S6966:Awaitable method should be used", Justification = "This test intentionally covers the synchronous content helper.")] - public async Task SyncGetContentAsReturnsDefaultWhenNoContent() - { - using var response = CreateErrorResponse(" "); - var exception = await ApiException.Create( - response.RequestMessage!, - HttpMethod.Get, - response, - new()); - - await Assert.That(exception.GetContentAs()).IsNull(); - } - - /// Verifies TryGetContentAs returns the value inside an exception filter (#1591). - /// A task representing the asynchronous test. - [Test] - public async Task SyncTryGetContentAsReturnsTrueAndValue() - { - using var response = CreateErrorResponse(ValueJson); - var exception = await ApiException.Create( - response.RequestMessage!, - HttpMethod.Get, - response, - new()); - - var ok = exception.TryGetContentAs(out var model); - - await Assert.That(ok).IsTrue(); - await Assert.That(model!.Value).IsEqualTo(ExpectedValue); - } - - /// Verifies TryGetContentAs returns false for missing or malformed content (#1591). - /// A task representing the asynchronous test. - [Test] - public async Task SyncTryGetContentAsReturnsFalseForMissingOrInvalidContent() - { - using var empty = CreateErrorResponse(" "); - using var invalid = CreateErrorResponse("not json"); - var emptyException = await ApiException.Create(empty.RequestMessage!, HttpMethod.Get, empty, new()); - var invalidException = await ApiException.Create(invalid.RequestMessage!, HttpMethod.Get, invalid, new()); - - await Assert.That(emptyException.TryGetContentAs(out var missing)).IsFalse(); - await Assert.That(missing).IsNull(); - await Assert.That(invalidException.TryGetContentAs(out var broken)).IsFalse(); - await Assert.That(broken).IsNull(); - } - - /// Verifies the synchronous helpers behave when the serializer lacks sync support (#1591). - /// A task representing the asynchronous test. - [Test] - public async Task SyncGetContentAsWithoutSyncSerializerSupport() - { - using var response = CreateErrorResponse(ValueJson); - var settings = new RefitSettings(new AsyncOnlySerializer()); - var exception = await ApiException.Create( - response.RequestMessage!, - HttpMethod.Get, - response, - settings); - - await Assert.That(exception.TryGetContentAs(out _)).IsFalse(); - await Assert.That(exception.GetContentAs) - .ThrowsExactly(); - } - /// Verifies opt-in request content capture is surfaced on the exception (#1189). /// A task representing the asynchronous test. [Test] @@ -426,94 +349,6 @@ public async Task RequestContentAbsentWhenNotCaptured() await Assert.That(exception.RequestContent).IsNull(); } - /// Verifies the error body is truncated to the configured maximum length. - /// A task representing the asynchronous test. - [Test] - public async Task MaxExceptionContentLengthTruncatesErrorBody() - { - using var response = CreateErrorResponse(new string('a', LongBodyLength)); - var settings = new RefitSettings { MaxExceptionContentLength = MaxContentLength }; - - var exception = await ApiException.Create( - response.RequestMessage!, - HttpMethod.Get, - response, - settings); - - await Assert.That(exception.Content!.Length).IsEqualTo(MaxContentLength); - } - - /// Verifies an unset maximum length leaves the error body intact. - /// A task representing the asynchronous test. - [Test] - public async Task MaxExceptionContentLengthUnsetReadsFullBody() - { - using var response = CreateErrorResponse(new string('a', LongBodyLength)); - - var exception = await ApiException.Create( - response.RequestMessage!, - HttpMethod.Get, - response, - new()); - - await Assert.That(exception.Content!.Length).IsEqualTo(LongBodyLength); - } - - /// Verifies a zero maximum length yields an empty error body. - /// A task representing the asynchronous test. - [Test] - public async Task MaxExceptionContentLengthZeroReturnsEmptyContent() - { - using var response = CreateErrorResponse(new string('a', 100)); - var settings = new RefitSettings { MaxExceptionContentLength = 0 }; - - var exception = await ApiException.Create( - response.RequestMessage!, - HttpMethod.Get, - response, - settings); - - await Assert.That(exception.Content).IsEqualTo(string.Empty); - } - - /// Verifies the error body is still read when the response carries no content type. - /// A task representing the asynchronous test. - [Test] - public async Task CreateReadsContentWhenContentTypeMissing() - { - using var response = CreateErrorResponse("{\"Value\":1}"); - response.Content!.Headers.ContentType = null; - - var exception = await ApiException.Create( - response.RequestMessage!, - HttpMethod.Get, - response, - new()); - - await Assert.That(exception).IsTypeOf(); - await Assert.That(exception.Content).IsEqualTo("{\"Value\":1}"); - } - - /// Verifies the error body is read when the content read genuinely suspends asynchronously. - /// A task representing the asynchronous test. - [Test] - public async Task CreateReadsContentThatCompletesAsynchronously() - { - using var response = new HttpResponseMessage(HttpStatusCode.BadRequest) - { - RequestMessage = new(HttpMethod.Get, ExampleUri), - Content = new AsyncReadContent("{\"Value\":7}") - }; - - var exception = await ApiException.Create( - response.RequestMessage!, - HttpMethod.Get, - response, - new()); - - await Assert.That(exception.Content).IsEqualTo("{\"Value\":7}"); - } - /// Verifies a problem+json body is built into a validation exception even when the serializer suspends asynchronously. /// A task representing the asynchronous test. [Test] @@ -583,31 +418,6 @@ private static HttpResponseMessage CreateErrorResponse(string content, string? m return response; } - /// Content whose read genuinely suspends, forcing the asynchronous completion path when the exception reads the body. - private sealed class AsyncReadContent : HttpContent - { - /// The UTF-8 payload bytes. - private readonly byte[] _payload; - - /// Initializes a new instance of the class. - /// The string payload. - public AsyncReadContent(string payload) => _payload = System.Text.Encoding.UTF8.GetBytes(payload); - - /// - protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) - { - await Task.Yield(); - await stream.WriteAsync(_payload.AsMemory()).ConfigureAwait(false); - } - - /// - protected override bool TryComputeLength(out long length) - { - length = _payload.Length; - return true; - } - } - /// Content that throws when read as a string. private sealed class ThrowingReadContent : HttpContent { @@ -625,16 +435,16 @@ protected override bool TryComputeLength(out long length) /// 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", + "Design", + "SST1488:An exception type does not declare the standard constructors", Justification = "This test fixture exposes only the protected ApiException constructors under test.")] private sealed class DerivedApiException : ApiException { /// + [SuppressMessage( + "Design", + "SST1472:Signatures should not declare too many parameters", + Justification = "Mirrors the shipped protected ApiException constructor under test; grouping parameters would stop exercising the real 8-parameter surface.")] public DerivedApiException( string exceptionMessage, HttpRequestMessage message, @@ -670,32 +480,10 @@ public DerivedApiException( } } - /// A serializer that only supports asynchronous deserialization (no sync capability). - [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", - Justification = "Mirrors the explicit type parameters of the wrapped IHttpContentSerializer contract.")] - private sealed class AsyncOnlySerializer : IHttpContentSerializer - { - /// The wrapped serializer that does the real work. - private readonly SystemTextJsonContentSerializer _inner = new(); - - /// - public HttpContent ToHttpContent(T item) => _inner.ToHttpContent(item); - - /// - public Task FromHttpContentAsync(HttpContent content, CancellationToken cancellationToken = default) => - _inner.FromHttpContentAsync(content, cancellationToken); - - /// - public string? GetFieldNameForProperty(System.Reflection.PropertyInfo propertyInfo) => - _inner.GetFieldNameForProperty(propertyInfo); - } - /// A serializer that genuinely suspends before delegating, forcing the async deserialization path. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Mirrors the explicit type parameters of the wrapped IHttpContentSerializer contract.")] private sealed class AsyncYieldingSerializer : IHttpContentSerializer { diff --git a/src/tests/Refit.Tests/ApiResponseTests.cs b/src/tests/Refit.Tests/ApiResponseTests.cs index f08bc571c..e1c3a84c6 100644 --- a/src/tests/Refit.Tests/ApiResponseTests.cs +++ b/src/tests/Refit.Tests/ApiResponseTests.cs @@ -13,6 +13,12 @@ public sealed class ApiResponseTests /// The example request URI reused across the response tests. private const string ExampleUri = "https://example.test"; + /// The response body content reused across the payload tests. + private const string PayloadContent = "payload"; + + /// The content value reused across the successful-response tests. + private const string HelloContent = "hello"; + /// The expected length of the five-character body used by the narrowing tests. private const int ExpectedContentLength = 5; @@ -77,7 +83,7 @@ public async Task UnsuccessfulResponseEnsureSuccessThrowsCapturedError() IApiResponse asInterface = response; await Assert - .That(() => (Task)asInterface.EnsureSuccessStatusCodeAsync()) + .That(async () => await asInterface.EnsureSuccessStatusCodeAsync()) .ThrowsExactly(); } @@ -99,7 +105,7 @@ public async Task EnsureSuccessfulAsyncReturnsOnSuccessAndThrowsOnFailure() IApiResponse failInterface = failure; await Assert - .That(() => (Task)failInterface.EnsureSuccessfulAsync()) + .That(async () => await failInterface.EnsureSuccessfulAsync()) .ThrowsExactly(); } @@ -111,10 +117,10 @@ public async Task EnsureSuccessExtensionsRejectNullResponse() const IApiResponse nullResponse = null!; await Assert - .That(static () => (Task)nullResponse.EnsureSuccessStatusCodeAsync()) + .That(static async () => await nullResponse.EnsureSuccessStatusCodeAsync()) .ThrowsExactly(); await Assert - .That(static () => (Task)nullResponse.EnsureSuccessfulAsync()) + .That(static async () => await nullResponse.EnsureSuccessfulAsync()) .ThrowsExactly(); } @@ -129,7 +135,7 @@ public async Task EnsureSuccessThrowsFallbackWhenNoErrorCaptured() IApiResponse asInterface = response; await Assert - .That(() => (Task)asInterface.EnsureSuccessStatusCodeAsync()) + .That(async () => await asInterface.EnsureSuccessStatusCodeAsync()) .ThrowsExactly(); } @@ -162,7 +168,7 @@ public async Task MissingResponseReportsUnavailableState() 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()) + await Assert.That(async () => await response.EnsureSuccessStatusCodeAsync()) .ThrowsExactly(); } @@ -171,15 +177,15 @@ await Assert.That(() => (Task)response.EnsureSuccessStatusCodeAsync()) [Test] public async Task SuccessResponseExposesMetadataAndUsesFastEnsurePath() { - using var responseMessage = CreateResponse(HttpStatusCode.OK, "payload"); - using var response = new ApiResponse(responseMessage, "payload", new()); + using var responseMessage = CreateResponse(HttpStatusCode.OK, PayloadContent); + using var response = new ApiResponse(responseMessage, PayloadContent, 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.Content).IsEqualTo(PayloadContent); await Assert.That(response.Headers).IsNotNull(); await Assert.That(response.ContentHeaders).IsNotNull(); await Assert.That(response.IsSuccessStatusCode).IsTrue(); @@ -189,6 +195,19 @@ public async Task SuccessResponseExposesMetadataAndUsesFastEnsurePath() await Assert.That(response.Version).IsEqualTo(responseMessage.Version); } + /// Verifies the concrete ensure path builds a fresh exception when an unsuccessful response has no captured error. + /// A task representing the asynchronous test. + [Test] + public async Task ConcreteEnsureBuildsExceptionWhenNoErrorCaptured() + { + using var badResponse = CreateResponse(HttpStatusCode.BadRequest, "boom"); + using var response = new ApiResponse(badResponse, null, new()); + + // No captured error, so ThrowsApiExceptionAsync takes the `?? ApiException.Create(...)` branch. + await Assert.That(async () => await response.EnsureSuccessfulAsync()) + .ThrowsExactly(); + } + /// Verifies typed error helpers distinguish request and response errors. /// A task representing the asynchronous test. [Test] @@ -230,7 +249,7 @@ public async Task ErrorHelpersDistinguishRequestAndResponseErrors() 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()) + await Assert.That(async () => await responseErrorResponse.EnsureSuccessfulAsync()) .ThrowsExactly(); } @@ -307,7 +326,7 @@ static async Task AssertNarrows(IApiResponse covariant) [Test] public async Task IsSuccessfulWithContentNarrowsContent() { - using var response = new ApiResponse(CreateResponse(HttpStatusCode.OK, "x"), "hello", new()); + using var response = new ApiResponse(CreateResponse(HttpStatusCode.OK, "x"), HelloContent, new()); if (response.IsSuccessfulWithContent) { @@ -471,7 +490,8 @@ public async Task EmptyStringContentCountsAsContent() [Test] public async Task ValueTypeContentIsAlwaysPresent() { - using var success = new ApiResponse(CreateResponse(HttpStatusCode.OK, "5"), 5, new()); + const int bodyValue = 5; + using var success = new ApiResponse(CreateResponse(HttpStatusCode.OK, "5"), bodyValue, new()); await Assert.That(success.HasContent).IsTrue(); await Assert.That(success.IsSuccessfulWithContent).IsTrue(); @@ -486,7 +506,8 @@ public async Task ValueTypeContentIsAlwaysPresent() [Test] public async Task NullableValueTypeContentTracksNull() { - using var present = new ApiResponse(CreateResponse(HttpStatusCode.OK, "7"), 7, new()); + const int presentValue = 7; + using var present = new ApiResponse(CreateResponse(HttpStatusCode.OK, "7"), presentValue, new()); await Assert.That(present.HasContent).IsTrue(); await Assert.That(present.IsSuccessfulWithContent).IsTrue(); @@ -558,7 +579,7 @@ public async Task ContentSignalsSurviveDisposal() [Test] public async Task StatusCodeOrNullContentGuardStillNarrows() { - using var ok = new ApiResponse(CreateResponse(HttpStatusCode.OK, "x"), "hello", new()); + using var ok = new ApiResponse(CreateResponse(HttpStatusCode.OK, "x"), HelloContent, new()); if (!ok.IsSuccessStatusCode || ok.Content is null) { Assert.Fail("Expected a successful response with content."); @@ -582,7 +603,7 @@ public async Task StatusCodeOrNullContentGuardStillNarrows() [Test] public async Task SuccessfulOrHasContentGuardEqualsIsSuccessfulWithContent() { - using var ok = new ApiResponse(CreateResponse(HttpStatusCode.OK, "x"), "hello", new()); + using var ok = new ApiResponse(CreateResponse(HttpStatusCode.OK, "x"), HelloContent, new()); if (!ok.IsSuccessful || !ok.HasContent) { Assert.Fail("Expected a successful response with content."); diff --git a/src/tests/Refit.Tests/AuthenticatedClientHandlerTests.cs b/src/tests/Refit.Tests/AuthenticatedClientHandlerTests.cs index 2700e4daf..d4efaff05 100644 --- a/src/tests/Refit.Tests/AuthenticatedClientHandlerTests.cs +++ b/src/tests/Refit.Tests/AuthenticatedClientHandlerTests.cs @@ -33,6 +33,9 @@ public class AuthenticatedClientHandlerTests /// Authorization header value carrying the bearer token. private const string BearerTokenValue = "Bearer " + TokenValue; + /// Authorization header value carrying the refreshed bearer token. + private const string BearerTokenValue2 = "Bearer tokenValue2"; + /// Name of the HTTP user agent header. private const string UserAgentHeader = "User-Agent"; @@ -123,7 +126,7 @@ public interface IAuthenticatedServiceWithHeaders [Test] public async Task DefaultHandlerIsHttpClientHandler() { - var handler = new AuthenticatedHttpClientHandler(static (_, _) => Task.FromResult(string.Empty)); + var handler = new AuthenticatedHttpClientHandler(static (_, _) => new ValueTask(string.Empty)); await Assert.That(handler.InnerHandler).IsTypeOf(); } @@ -133,7 +136,7 @@ public async Task DefaultHandlerIsHttpClientHandler() [Test] public async Task DefaultHandlerIsNull() { - var handler = new AuthenticatedHttpClientHandler(null, static (_, _) => Task.FromResult(string.Empty)); + var handler = new AuthenticatedHttpClientHandler(null, static (_, _) => new ValueTask(string.Empty)); await Assert.That(handler.InnerHandler).IsNull(); } @@ -144,7 +147,7 @@ public async Task DefaultHandlerIsNull() public async Task ExplicitInnerHandlerIsAssigned() { using var innerHandler = new TestHttpMessageHandler(); - var handler = new AuthenticatedHttpClientHandler(innerHandler, static (_, _) => Task.FromResult(string.Empty)); + var handler = new AuthenticatedHttpClientHandler(innerHandler, static (_, _) => new ValueTask(string.Empty)); await Assert.That(handler.InnerHandler).IsSameReferenceAs(innerHandler); } @@ -155,7 +158,7 @@ public async Task ExplicitInnerHandlerIsAssigned() public async Task NullTokenGetterThrows() => await Assert .That(static () => new AuthenticatedHttpClientHandler( - (Func>)null!)) + (Func>)null!)) .ThrowsExactly(); /// Verifies unauthenticated calls do not send an authorization header. @@ -172,7 +175,7 @@ public async Task AuthenticatedHandlerIgnoresUnAuth() }; var fixture = handler.CreateClient(BaseUrl, new RefitSettings { - AuthorizationHeaderValueGetter = static (_, _) => Task.FromResult(TokenValue) + AuthorizationHeaderValueGetter = static (_, _) => new ValueTask(TokenValue) }); var result = await fixture.GetUnauthenticated(); @@ -196,7 +199,7 @@ public async Task AuthenticatedHandlerUsesAuth() }; var fixture = handler.CreateClient(BaseUrl, new RefitSettings { - AuthorizationHeaderValueGetter = static (_, _) => Task.FromResult(TokenValue) + AuthorizationHeaderValueGetter = static (_, _) => new ValueTask(TokenValue) }); var result = await fixture.GetAuthenticated(); @@ -297,14 +300,14 @@ public async Task AuthenticatedHandlerWithDuplicatedAuthorizationHeaderUsesAuth( { var expectedHeaders = new Dictionary { - { AuthorizationHeader, "Bearer tokenValue2" }, + { AuthorizationHeader, BearerTokenValue2 }, { UserAgentHeader, RefitValue }, { ForwardedForHeader, RefitValue } }; var headerCollectionHeaders = new Dictionary { - { AuthorizationHeader, "Bearer tokenValue2" }, + { AuthorizationHeader, BearerTokenValue2 }, { UserAgentHeader, RefitValue }, { ForwardedForHeader, RefitValue } }; @@ -337,7 +340,7 @@ public async Task AuthenticatedHandlerPostTokenInHeaderCollectionUsesAuth() var headers = new Dictionary { - { AuthorizationHeader, "Bearer tokenValue2" }, + { AuthorizationHeader, BearerTokenValue2 }, { "ThingId", id.ToString() } }; @@ -374,7 +377,7 @@ public async Task AuthentictedMethodFromBaseClassWithHeadersAttributeUsesAuth() }; var fixture = handler.CreateClient(BaseUrl, new RefitSettings { - AuthorizationHeaderValueGetter = static (_, _) => Task.FromResult(TokenValue) + AuthorizationHeaderValueGetter = static (_, _) => new ValueTask(TokenValue) }); var result = await fixture.GetThingFromBase(); @@ -398,7 +401,7 @@ public async Task AuthentictedMethodFromInheritedClassWithHeadersAttributeUsesAu }; var fixture = handler.CreateClient(BaseUrl, new RefitSettings { - AuthorizationHeaderValueGetter = static (_, _) => Task.FromResult(TokenValue) + AuthorizationHeaderValueGetter = static (_, _) => new ValueTask(TokenValue) }); var result = await fixture.GetInheritedThing(); @@ -424,7 +427,7 @@ await Assert.That(async () => { var fixture = handler.CreateClient(BaseUrl, new RefitSettings { - AuthorizationHeaderValueGetter = (_, _) => Task.FromResult(TokenValue) + AuthorizationHeaderValueGetter = static (_, _) => new ValueTask(TokenValue) }); await fixture.GetInheritedThing(); @@ -447,7 +450,7 @@ public async Task AuthorizationHeaderValueGetterIsUsedWhenSupplyingHttpClient() var settings = new RefitSettings { - AuthorizationHeaderValueGetter = static (_, _) => Task.FromResult(TokenValue) + AuthorizationHeaderValueGetter = static (_, _) => new ValueTask(TokenValue) }; var fixture = RestService.For(httpClient, settings); @@ -505,7 +508,7 @@ public async Task AuthorizationHeaderValueGetterDoesNotOverrideExplicitTokenWhen var settings = new RefitSettings { - AuthorizationHeaderValueGetter = static (_, _) => Task.FromResult("token-from-getter") + AuthorizationHeaderValueGetter = static (_, _) => new ValueTask("token-from-getter") }; var fixture = RestService.For(httpClient, settings); diff --git a/src/tests/Refit.Tests/BigObject.cs b/src/tests/Refit.Tests/BigObject.cs index 90d409ed8..b7398d693 100644 --- a/src/tests/Refit.Tests/BigObject.cs +++ b/src/tests/Refit.Tests/BigObject.cs @@ -8,5 +8,5 @@ namespace Refit.Tests; public class BigObject { /// Gets or sets the large binary payload. - public byte[]? BigData { get; set; } + public byte[]? BigData { get; init; } } diff --git a/src/tests/Refit.Tests/BodyPayload.cs b/src/tests/Refit.Tests/BodyPayload.cs new file mode 100644 index 000000000..db11037f1 --- /dev/null +++ b/src/tests/Refit.Tests/BodyPayload.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; + +/// A serializable body payload for the reflection body-serialization tests. +public sealed class BodyPayload +{ + /// Gets or sets the payload name. + public string? Name { get; set; } +} diff --git a/src/tests/Refit.Tests/BracketUrlParameterFormatter.cs b/src/tests/Refit.Tests/BracketUrlParameterFormatter.cs new file mode 100644 index 000000000..55a72d96c --- /dev/null +++ b/src/tests/Refit.Tests/BracketUrlParameterFormatter.cs @@ -0,0 +1,16 @@ +// 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; + +namespace Refit.Tests; + +/// A non-default URL parameter formatter that wraps each formatted value in brackets, used to prove the +/// generated slow path reproduces the reflection builder's two formatting passes for collection properties. +public sealed class BracketUrlParameterFormatter : IUrlParameterFormatter +{ + /// + public string? Format(object? value, ICustomAttributeProvider attributeProvider, Type type) => + value is null ? null : $"[{value}]"; +} diff --git a/src/tests/Refit.Tests/CachedRequestBuilderTests.cs b/src/tests/Refit.Tests/CachedRequestBuilderTests.cs index 4538f81ec..efa0cddee 100644 --- a/src/tests/Refit.Tests/CachedRequestBuilderTests.cs +++ b/src/tests/Refit.Tests/CachedRequestBuilderTests.cs @@ -127,8 +127,8 @@ private sealed class NullContentSerializer : IHttpContentSerializer /// [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "The method implements IHttpContentSerializer and must preserve the interface shape.")] public Task FromHttpContentAsync( HttpContent content, diff --git a/src/tests/Refit.Tests/CapturedMultipartPart.cs b/src/tests/Refit.Tests/CapturedMultipartPart.cs new file mode 100644 index 000000000..02fc46d88 --- /dev/null +++ b/src/tests/Refit.Tests/CapturedMultipartPart.cs @@ -0,0 +1,10 @@ +// 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; + +/// A multipart part captured while a request is in flight. +/// The content-disposition name of the part, if any. +/// The part body read as a string. +public sealed record CapturedMultipartPart(string? Name, string Body); diff --git a/src/tests/Refit.Tests/CollectionPropertyQueryObject.cs b/src/tests/Refit.Tests/CollectionPropertyQueryObject.cs new file mode 100644 index 000000000..96ff053f4 --- /dev/null +++ b/src/tests/Refit.Tests/CollectionPropertyQueryObject.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; + +/// A query object whose properties are collections of simple elements, flattened inline by the generator. +public sealed class CollectionPropertyQueryObject +{ + /// Gets or sets an integer collection joined with the default (CSV) collection format. + public int[]? Ids { get; init; } + + /// Gets or sets an enum collection rendered one key=value pair per element. + [Query(CollectionFormat.Multi)] + public IReadOnlyList? Tags { get; set; } + + /// Gets or sets an aliased string collection joined with the default (CSV) collection format. + [AliasAs("n")] + public string[]? Names { get; set; } +} diff --git a/src/tests/Refit.Tests/Crud/IDataCrudApi.cs b/src/tests/Refit.Tests/Crud/IDataCrudApi.cs index 9d80d7f6e..4b2020bdf 100644 --- a/src/tests/Refit.Tests/Crud/IDataCrudApi.cs +++ b/src/tests/Refit.Tests/Crud/IDataCrudApi.cs @@ -24,8 +24,8 @@ public interface IDataCrudApi /// The list of entities. [Get("")] [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters to enable type inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Intentional test fixture exercising Refit generation of generic methods with unused type parameters.")] [SuppressMessage( "StyleSharp", @@ -40,8 +40,8 @@ Task> ReadAll() /// The list of entities. [Get("")] [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters to enable type inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Intentional test fixture exercising Refit generation of generic methods with unused type parameters.")] [SuppressMessage( "StyleSharp", @@ -75,8 +75,8 @@ Task> ReadAll() /// A task that completes when the read finishes. [Get("")] [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters to enable type inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Intentional test fixture exercising Refit generation of generic methods with unused type parameters.")] [SuppressMessage( "StyleSharp", diff --git a/src/tests/Refit.Tests/DefaultInterfaceMethodTests.cs b/src/tests/Refit.Tests/DefaultInterfaceMethodTests.cs index acdc9b40a..32f609e1d 100644 --- a/src/tests/Refit.Tests/DefaultInterfaceMethodTests.cs +++ b/src/tests/Refit.Tests/DefaultInterfaceMethodTests.cs @@ -16,6 +16,9 @@ public class DefaultInterfaceMethodTests /// The base address and stub URL used across the tests. private const string HttpBinUrl = "https://httpbin.org/"; + /// The HTML media type returned by the stub responses. + private const string HtmlMediaType = "text/html"; + /// Verifies an internal interface member can be invoked through Refit. /// A task that represents the asynchronous operation. [Test] @@ -25,7 +28,7 @@ public async Task InternalInterfaceMemberTest() { { Route.Get(HttpBinUrl), - new StubResponse { Status = HttpStatusCode.OK, Text = "OK", ContentType = "text/html" } + new StubResponse { Status = HttpStatusCode.OK, Text = "OK", ContentType = HtmlMediaType } }, }; @@ -46,7 +49,7 @@ public async Task DimTest() { { Route.Get(HttpBinUrl), - new StubResponse { Status = HttpStatusCode.OK, Text = "OK", ContentType = "text/html" } + new StubResponse { Status = HttpStatusCode.OK, Text = "OK", ContentType = HtmlMediaType } }, }; @@ -67,7 +70,7 @@ public async Task InternalDimTest() { { Route.Get(HttpBinUrl), - new StubResponse { Status = HttpStatusCode.OK, Text = "OK", ContentType = "text/html" } + new StubResponse { Status = HttpStatusCode.OK, Text = "OK", ContentType = HtmlMediaType } }, }; diff --git a/src/tests/Refit.Tests/DefaultUrlParameterFormatterTests.cs b/src/tests/Refit.Tests/DefaultUrlParameterFormatterTests.cs index 1a504826d..b7371504a 100644 --- a/src/tests/Refit.Tests/DefaultUrlParameterFormatterTests.cs +++ b/src/tests/Refit.Tests/DefaultUrlParameterFormatterTests.cs @@ -2,15 +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; - namespace Refit.Tests; /// Tests for date, collection and enum formatting behaviour. -[SuppressMessage( - "Major Code Smell", - "S6566:Prefer using \"DateTimeOffset\" instead of \"DateTime\"", - Justification = "These tests intentionally exercise DateTime formatting; the asserted output and DTO property types are DateTime-specific.")] public class DefaultUrlParameterFormatterTests { /// A general date format registered for the DateTime formatter tests. @@ -288,12 +282,13 @@ public async Task RequestWithDateTimeDictionaryQueryParameter_ProducesCorrectQue var factory = fixture.BuildRequestFactoryForMethod( nameof(IDummyHttpApi.PostWithComplexTypeQuery)); + const int secondEntryKey = 2; var parameters = new DefaultUrlParameterFormatterTestRequest { DateTimeDictionary = new Dictionary { { 1, new(2023, 8, 21, 0, 0, 0, DateTimeKind.Unspecified) }, - { 2, new(2024, 8, 21, 0, 0, 0, DateTimeKind.Unspecified) }, + { secondEntryKey, new(2024, 8, 21, 0, 0, 0, DateTimeKind.Unspecified) }, }, }; @@ -317,12 +312,13 @@ public async Task RequestWithDateTimeKeyedDictionaryQueryParameter_ProducesCorre var factory = fixture.BuildRequestFactoryForMethod( nameof(IDummyHttpApi.PostWithComplexTypeQuery)); + const int secondEntryValue = 2; var parameters = new DefaultUrlParameterFormatterTestRequest { DateTimeKeyedDictionary = new Dictionary { { new(2023, 8, 21, 0, 0, 0, DateTimeKind.Unspecified), 1 }, - { new(2024, 8, 21, 0, 0, 0, DateTimeKind.Unspecified), 2 }, + { new(2024, 8, 21, 0, 0, 0, DateTimeKind.Unspecified), secondEntryValue }, }, }; @@ -381,10 +377,10 @@ private sealed class DefaultUrlParameterFormatterTestRequest public IEnumerable? DateTimeCollection { get; set; } /// Gets or sets a dictionary of DateTime values keyed by integer. - public IDictionary DateTimeDictionary { get; set; } = new Dictionary(); + public IDictionary DateTimeDictionary { get; init; } = new Dictionary(); /// Gets or sets a dictionary of integer values keyed by DateTime. - public IDictionary DateTimeKeyedDictionary { get; set; } = new Dictionary(); + public IDictionary DateTimeKeyedDictionary { get; init; } = new Dictionary(); } /// Request fixture exposing a flags enum query parameter. diff --git a/src/tests/Refit.Tests/DeferredCall.cs b/src/tests/Refit.Tests/DeferredCall.cs new file mode 100644 index 000000000..d31d33583 --- /dev/null +++ b/src/tests/Refit.Tests/DeferredCall.cs @@ -0,0 +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. + +namespace Refit.Tests; + +/// A deferred HTTP call surfaced by ; the request runs only when invoked. +/// The materialized result type. +public sealed class DeferredCall +{ + /// The deferred HTTP call. + private readonly Func> _invoke; + + /// Initializes a new instance of the class. + /// The deferred HTTP call. + public DeferredCall(Func> invoke) => _invoke = invoke; + + /// Runs the deferred HTTP call. + /// A token to cancel the request. + /// The materialized result. + public Task InvokeAsync(CancellationToken cancellationToken) => _invoke(cancellationToken); +} diff --git a/src/tests/Refit.Tests/DeferredCallAdapter.cs b/src/tests/Refit.Tests/DeferredCallAdapter.cs new file mode 100644 index 000000000..81ba77e6a --- /dev/null +++ b/src/tests/Refit.Tests/DeferredCallAdapter.cs @@ -0,0 +1,13 @@ +// 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; + +/// Surfaces a Refit method's result as a . +/// The materialized result type. +public sealed class DeferredCallAdapter : IReturnTypeAdapter, T> +{ + /// + public DeferredCall Adapt(Func> invoke) => new(invoke); +} diff --git a/src/tests/Refit.Tests/DeserializationExceptionFactoryTests.cs b/src/tests/Refit.Tests/DeserializationExceptionFactoryTests.cs index 7c7433e98..332f1f010 100644 --- a/src/tests/Refit.Tests/DeserializationExceptionFactoryTests.cs +++ b/src/tests/Refit.Tests/DeserializationExceptionFactoryTests.cs @@ -16,6 +16,9 @@ public class DeserializationExceptionFactoryTests /// The stub endpoint URL that returns a deserialized result. private const string GetWithResultUrl = "http://api/get-with-result"; + /// The non-integer response body used to trigger deserialization failures. + private const string NonIntResultContent = "non-int-result"; + /// Refit fixture interface returning a deserialized integer result. public interface IMyService { @@ -56,7 +59,7 @@ public async Task NoDeserializationExceptionFactory_WithUnsuccessfulDeserializat { { Route.Get(GetWithResultUrl), - new StubResponse { Status = HttpStatusCode.OK, Content = new StringContent("non-int-result") } + new StubResponse { Status = HttpStatusCode.OK, Content = new StringContent(NonIntResultContent) } }, }; var fixture = handler.CreateClient(BaseUrl); @@ -82,7 +85,7 @@ public async Task ProvideFactoryWhichReturnsNull_WithSuccessfulDeserialization() }; var fixture = handler.CreateClient(BaseUrl, new RefitSettings { - DeserializationExceptionFactory = static (_, _) => Task.FromResult(null) + DeserializationExceptionFactory = static (_, _) => new ValueTask((Exception?)null) }); var result = await fixture.GetWithResult(); @@ -101,12 +104,12 @@ public async Task ProvideFactoryWhichReturnsNull_WithUnsuccessfulDeserialization { { Route.Get(GetWithResultUrl), - new StubResponse { Status = HttpStatusCode.OK, Content = new StringContent("non-int-result") } + new StubResponse { Status = HttpStatusCode.OK, Content = new StringContent(NonIntResultContent) } }, }; var fixture = handler.CreateClient(BaseUrl, new RefitSettings { - DeserializationExceptionFactory = static (_, _) => Task.FromResult(null) + DeserializationExceptionFactory = static (_, _) => new ValueTask((Exception?)null) }); var result = await fixture.GetWithResult(); @@ -126,12 +129,12 @@ public async Task ProvideFactoryWhichReturnsException_WithUnsuccessfulDeserializ { { Route.Get(GetWithResultUrl), - new StubResponse { Status = HttpStatusCode.OK, Content = new StringContent("non-int-result") } + new StubResponse { Status = HttpStatusCode.OK, Content = new StringContent(NonIntResultContent) } }, }; var fixture = handler.CreateClient(BaseUrl, new RefitSettings { - DeserializationExceptionFactory = (_, _) => Task.FromResult(exception) + DeserializationExceptionFactory = (_, _) => new ValueTask(exception) }); var thrownException = await Assert.That(fixture.GetWithResult).ThrowsExactly(); @@ -156,7 +159,7 @@ public async Task ProvideFactoryWhichReturnsException_WithSuccessfulDeserializat }; var fixture = handler.CreateClient(BaseUrl, new RefitSettings { - DeserializationExceptionFactory = (_, _) => Task.FromResult(exception) + DeserializationExceptionFactory = (_, _) => new ValueTask(exception) }); var result = await fixture.GetWithResult(); diff --git a/src/tests/Refit.Tests/DictionaryObjectQueryConverter.cs b/src/tests/Refit.Tests/DictionaryObjectQueryConverter.cs new file mode 100644 index 000000000..8f3bece25 --- /dev/null +++ b/src/tests/Refit.Tests/DictionaryObjectQueryConverter.cs @@ -0,0 +1,29 @@ +// 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; + +/// Flattens an object-valued dictionary — a shape the generator cannot flatten from the declared +/// type — into one query pair per non-null entry, demonstrating the escape hatch. +public sealed class DictionaryObjectQueryConverter : IQueryConverter> +{ + /// + public void Flatten( + IDictionary value, + string keyPrefix, + ref GeneratedQueryStringBuilder builder, + RefitSettings settings) + { + foreach (var entry in value) + { + if (entry.Value is null) + { + continue; + } + + var formatted = settings.UrlParameterFormatter.Format(entry.Value, typeof(object), typeof(object)); + builder.Add(keyPrefix + entry.Key, formatted, false); + } + } +} diff --git a/src/tests/Refit.Tests/DictionaryPropertyQueryObject.cs b/src/tests/Refit.Tests/DictionaryPropertyQueryObject.cs new file mode 100644 index 000000000..cdb8806d1 --- /dev/null +++ b/src/tests/Refit.Tests/DictionaryPropertyQueryObject.cs @@ -0,0 +1,18 @@ +// 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; + +/// A query object with dictionary properties, whose entries expand under each property's key. +public sealed class DictionaryPropertyQueryObject +{ + /// Gets or sets a top-level scalar property. + public string? Name { get; set; } + + /// Gets a string-valued dictionary; its entries expand under this property's key. + public IDictionary Tags { get; } = new Dictionary(); + + /// Gets an integer-valued dictionary, exercising the fast-format value path. + public Dictionary Counts { get; } = new(); +} diff --git a/src/tests/Refit.Tests/DiscriminatedCircle.cs b/src/tests/Refit.Tests/DiscriminatedCircle.cs new file mode 100644 index 000000000..9809e1408 --- /dev/null +++ b/src/tests/Refit.Tests/DiscriminatedCircle.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. + +namespace Refit.Tests; + +/// A concrete shape used to exercise the derived-type-attribute signal in isolation. +public sealed class DiscriminatedCircle : IDiscriminatedShape +{ + /// + public string? Label { get; set; } + + /// Gets or sets the circle radius. + public int Radius { get; set; } +} diff --git a/src/tests/Refit.Tests/EnumHelpersTests.cs b/src/tests/Refit.Tests/EnumHelpersTests.cs index cfdfee97d..921525160 100644 --- a/src/tests/Refit.Tests/EnumHelpersTests.cs +++ b/src/tests/Refit.Tests/EnumHelpersTests.cs @@ -30,7 +30,7 @@ private enum MemberEnum } /// Signed byte-backed enum. - [SuppressMessage("Major Code Smell", "S4022:Enumerations should have Int32 storage", Justification = "This fixture verifies sbyte-backed enum handling.")] + [SuppressMessage("Design", "SST2313:Enums should use an allowed storage type", Justification = "This fixture verifies sbyte-backed enum handling.")] private enum SByteEnum : sbyte { /// Default value. @@ -41,7 +41,7 @@ private enum SByteEnum : sbyte } /// Byte-backed enum. - [SuppressMessage("Major Code Smell", "S4022:Enumerations should have Int32 storage", Justification = "This fixture verifies byte-backed enum handling.")] + [SuppressMessage("Design", "SST2313:Enums should use an allowed storage type", Justification = "This fixture verifies byte-backed enum handling.")] private enum ByteEnum : byte { /// Default value. @@ -52,7 +52,7 @@ private enum ByteEnum : byte } /// Signed 16-bit enum. - [SuppressMessage("Major Code Smell", "S4022:Enumerations should have Int32 storage", Justification = "This fixture verifies short-backed enum handling.")] + [SuppressMessage("Design", "SST2313:Enums should use an allowed storage type", Justification = "This fixture verifies short-backed enum handling.")] private enum Int16Enum : short { /// Default value. @@ -63,7 +63,7 @@ private enum Int16Enum : short } /// Unsigned 16-bit enum. - [SuppressMessage("Major Code Smell", "S4022:Enumerations should have Int32 storage", Justification = "This fixture verifies ushort-backed enum handling.")] + [SuppressMessage("Design", "SST2313:Enums should use an allowed storage type", Justification = "This fixture verifies ushort-backed enum handling.")] private enum UInt16Enum : ushort { /// Default value. @@ -84,7 +84,7 @@ private enum Int32Enum } /// Unsigned 32-bit enum. - [SuppressMessage("Major Code Smell", "S4022:Enumerations should have Int32 storage", Justification = "This fixture verifies uint-backed enum handling.")] + [SuppressMessage("Design", "SST2313:Enums should use an allowed storage type", Justification = "This fixture verifies uint-backed enum handling.")] private enum UInt32Enum : uint { /// Default value. @@ -95,7 +95,7 @@ private enum UInt32Enum : uint } /// Signed 64-bit enum. - [SuppressMessage("Major Code Smell", "S4022:Enumerations should have Int32 storage", Justification = "This fixture verifies long-backed enum handling.")] + [SuppressMessage("Design", "SST2313:Enums should use an allowed storage type", Justification = "This fixture verifies long-backed enum handling.")] private enum Int64Enum : long { /// Default value. @@ -106,7 +106,7 @@ private enum Int64Enum : long } /// Unsigned 64-bit enum. - [SuppressMessage("Major Code Smell", "S4022:Enumerations should have Int32 storage", Justification = "This fixture verifies ulong-backed enum handling.")] + [SuppressMessage("Design", "SST2313:Enums should use an allowed storage type", Justification = "This fixture verifies ulong-backed enum handling.")] private enum UInt64Enum : ulong { /// Default value. diff --git a/src/tests/Refit.Tests/ErrorResponse.cs b/src/tests/Refit.Tests/ErrorResponse.cs index 1baa56775..293316958 100644 --- a/src/tests/Refit.Tests/ErrorResponse.cs +++ b/src/tests/Refit.Tests/ErrorResponse.cs @@ -8,5 +8,5 @@ namespace Refit.Tests; public class ErrorResponse { /// Gets or sets the collection of error messages. - public string[]? Errors { get; set; } + public string[]? Errors { get; init; } } diff --git a/src/tests/Refit.Tests/ExceptionFactoryTests.cs b/src/tests/Refit.Tests/ExceptionFactoryTests.cs index acff9ae03..8c0e9963b 100644 --- a/src/tests/Refit.Tests/ExceptionFactoryTests.cs +++ b/src/tests/Refit.Tests/ExceptionFactoryTests.cs @@ -43,7 +43,7 @@ public async Task ProvideFactoryWhichAlwaysReturnsNull_WithResult() }; var fixture = handler.CreateClient(BaseAddress, new RefitSettings { - ExceptionFactory = static _ => Task.FromResult(null) + ExceptionFactory = static _ => new ValueTask((Exception?)null) }); var result = await fixture.GetWithResult(); @@ -67,7 +67,7 @@ public async Task ProvideFactoryWhichAlwaysReturnsNull_WithoutResult() }; var fixture = handler.CreateClient(BaseAddress, new RefitSettings { - ExceptionFactory = static _ => Task.FromResult(null) + ExceptionFactory = static _ => new ValueTask((Exception?)null) }); await fixture.PutWithoutResult(); @@ -90,7 +90,7 @@ public async Task ProvideFactoryWhichAlwaysReturnsException_WithResult() }; var fixture = handler.CreateClient(BaseAddress, new RefitSettings { - ExceptionFactory = _ => Task.FromResult(exception) + ExceptionFactory = _ => new ValueTask(exception) }); var thrownException = await Assert.That(() => (Task)fixture.GetWithResult()).ThrowsExactly(); @@ -114,7 +114,7 @@ public async Task ProvideFactoryWhichAlwaysReturnsException_WithoutResult() }; var fixture = handler.CreateClient(BaseAddress, new RefitSettings { - ExceptionFactory = _ => Task.FromResult(exception) + ExceptionFactory = _ => new ValueTask(exception) }); var thrownException = await Assert.That(fixture.PutWithoutResult).ThrowsExactly(); diff --git a/src/tests/Refit.Tests/ExplicitInterfaceRefitTests.cs b/src/tests/Refit.Tests/ExplicitInterfaceRefitTests.cs index 6a2b87992..cb87bba57 100644 --- a/src/tests/Refit.Tests/ExplicitInterfaceRefitTests.cs +++ b/src/tests/Refit.Tests/ExplicitInterfaceRefitTests.cs @@ -26,6 +26,9 @@ public class ExplicitInterfaceRefitTests /// Plain text content returned by the stubbed responses. private const string HelloContent = "hello"; + /// The plain text media type used by the stubbed responses. + private const string PlainTextMediaType = "text/plain"; + /// A simple interface whose member is reused by explicit-implementation fixtures. public interface IFoo { @@ -183,7 +186,7 @@ public async Task Sync_method_returns_HttpContent_without_disposing_response() { { Route.Get(ResourceUrl), - Reply.Text(HelloContent, "text/plain") + Reply.Text(HelloContent, PlainTextMediaType) }, }; var fixture = handler.CreateClient(BaseUrl); @@ -205,7 +208,7 @@ public async Task Sync_method_returns_Stream_without_disposing_response() { { Route.Get(ResourceUrl), - Reply.Text(HelloContent, "text/plain") + Reply.Text(HelloContent, PlainTextMediaType) }, }; var fixture = handler.CreateClient(BaseUrl); @@ -276,7 +279,7 @@ public async Task Sync_method_returns_raw_IApiResponse_on_success() { { Route.Get(ResourceUrl), - Reply.Text(HelloContent, "text/plain") + Reply.Text(HelloContent, PlainTextMediaType) }, }; var fixture = handler.CreateClient(BaseUrl); diff --git a/src/tests/Refit.Tests/FormValueMultimapTests.cs b/src/tests/Refit.Tests/FormValueMultimapTests.cs index 306c164da..3d3ab7301 100644 --- a/src/tests/Refit.Tests/FormValueMultimapTests.cs +++ b/src/tests/Refit.Tests/FormValueMultimapTests.cs @@ -13,6 +13,30 @@ namespace Refit.Tests; /// Tests for source loading and serialization behavior. public class FormValueMultimapTests { + /// The second element shared by the sample integer collections. + private const int SecondSampleInt = 2; + + /// Comma-delimited field value shared by the repeated-field tests. + private const string CommaDelimitedValue = "set1,set2"; + + /// Space-delimited field value shared by the repeated-field tests. + private const string SpaceDelimitedValue = "01 02"; + + /// Tab-delimited field value shared by the repeated-field tests. + private const string TabDelimitedValue = "0.10\t1.00"; + + /// Pipe-delimited boolean field value shared by the repeated-field tests. + private const string PipeDelimitedBooleanValue = "True|False"; + + /// The third element in the sample integer collection. + private const int ThirdSampleInt = 3; + + /// The first element in the sample double collection. + private const double FirstSampleDouble = 0.1; + + /// The second element in the sample double collection. + private const double SecondSampleDouble = 1.0; + /// The default settings used to construct the multimap under test. private readonly RefitSettings _settings = new(); @@ -89,22 +113,22 @@ public async Task LoadFromObjectWithCollections() { var source = new ObjectWithRepeatedFieldsTestClass { - A = [1, 2], + A = [1, SecondSampleInt], B = new HashSet { "set1", "set2" }, - C = [1, 2], - D = [0.1, 1.0], + C = [1, SecondSampleInt], + D = [FirstSampleDouble, SecondSampleDouble], E = [true, false] }; var expected = new List> { new("A", "01"), new("A", "02"), - new("B", "set1,set2"), - new("C", "01 02"), - new("D", "0.10\t1.00"), + new("B", CommaDelimitedValue), + new("C", SpaceDelimitedValue), + new("D", TabDelimitedValue), // The default behavior is to capitalize booleans. This is not a requirement. - new("E", "True|False") + new("E", PipeDelimitedBooleanValue) }; var actual = new FormValueMultimap(source, _settings); @@ -124,23 +148,23 @@ public async Task DefaultCollectionFormatCanBeSpecifiedInSettings_Multi() var source = new ObjectWithRepeatedFieldsTestClass { // Members have explicit CollectionFormat - A = [1, 2], + A = [1, SecondSampleInt], B = new HashSet { "set1", "set2" }, - C = [1, 2], - D = [0.1, 1.0], + C = [1, SecondSampleInt], + D = [FirstSampleDouble, SecondSampleDouble], E = [true, false], // Member has no explicit CollectionFormat - F = [1, 2, 3] + F = [1, SecondSampleInt, ThirdSampleInt] }; var expected = new List> { new("A", "01"), new("A", "02"), - new("B", "set1,set2"), - new("C", "01 02"), - new("D", "0.10\t1.00"), - new("E", "True|False"), + new("B", CommaDelimitedValue), + new("C", SpaceDelimitedValue), + new("D", TabDelimitedValue), + new("E", PipeDelimitedBooleanValue), new("F", "1"), new("F", "2"), new("F", "3"), @@ -168,23 +192,23 @@ public async Task DefaultCollectionFormatCanBeSpecifiedInSettings( var source = new ObjectWithRepeatedFieldsTestClass { // Members have explicit CollectionFormat - A = [1, 2], + A = [1, SecondSampleInt], B = new HashSet { "set1", "set2" }, - C = [1, 2], - D = [0.1, 1.0], + C = [1, SecondSampleInt], + D = [FirstSampleDouble, SecondSampleDouble], E = [true, false], // Member has no explicit CollectionFormat - F = [1, 2, 3] + F = [1, SecondSampleInt, ThirdSampleInt] }; var expected = new List> { new("A", "01"), new("A", "02"), - new("B", "set1,set2"), - new("C", "01 02"), - new("D", "0.10\t1.00"), - new("E", "True|False"), + new("B", CommaDelimitedValue), + new("C", SpaceDelimitedValue), + new("D", TabDelimitedValue), + new("E", PipeDelimitedBooleanValue), new("F", expectedFormat), }; @@ -247,7 +271,8 @@ public async Task ExcludesPropertiesWithInaccessibleGetters() Justification = "Verifies loading from an anonymous type; a tuple exposes Item1/Item2 fields, not reflectable named properties.")] public async Task LoadsFromAnonymousType() { - var source = new { foo = "bar", xyz = 123 }; + const int xyzValue = 123; + var source = new { foo = "bar", xyz = xyzValue }; var expected = new Dictionary { { "foo", "bar" }, { "xyz", "123" } }; @@ -289,7 +314,8 @@ public async Task UsesJsonPropertyAttribute() [Test] public async Task UsesQueryPropertyAttribute() { - var source = new AliasingTestClass { Frob = 4 }; + const int frobValue = 4; + var source = new AliasingTestClass { Frob = frobValue }; var target = new FormValueMultimap(source, _settings); @@ -418,11 +444,9 @@ public class ObjectWithUnknownCollectionFormat public class ClassWithInaccessibleGetters { /// Gets or sets the first value, whose getter is internal and therefore inaccessible to serialization. - [SuppressMessage("Design", "CA1044:Properties should not be write only", Justification = "Intentional inaccessible getter to verify FormValueMultimap excludes it.")] public string? A { internal get; set; } /// Gets or sets the second value, whose getter is private and therefore inaccessible to serialization. - [SuppressMessage("Design", "CA1044:Properties should not be write only", Justification = "Intentional inaccessible getter to verify FormValueMultimap excludes it.")] public string? B { private get; set; } /// Gets the concatenation of the two inaccessible values. diff --git a/src/tests/Refit.Tests/GeneratedFactoryApiClient.cs b/src/tests/Refit.Tests/GeneratedFactoryApiClient.cs index 8083f86c9..d4f4ede0f 100644 --- a/src/tests/Refit.Tests/GeneratedFactoryApiClient.cs +++ b/src/tests/Refit.Tests/GeneratedFactoryApiClient.cs @@ -26,5 +26,5 @@ internal sealed class GeneratedFactoryApiClient(HttpClient client, IRequestBuild public Task GetById(string id) => Task.CompletedTask; /// - public Task GetQuery(string id) => Task.CompletedTask; + public Task Search(object filter) => Task.CompletedTask; } diff --git a/src/tests/Refit.Tests/GeneratedFormColor.cs b/src/tests/Refit.Tests/GeneratedFormColor.cs new file mode 100644 index 000000000..33ef4fb92 --- /dev/null +++ b/src/tests/Refit.Tests/GeneratedFormColor.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. + +namespace Refit.Tests; + +/// An enum rendered by the generated form fast path. +public enum GeneratedFormColor +{ + /// The red colour. + Red, + + /// The green colour. + Green, +} diff --git a/src/tests/Refit.Tests/GeneratedFormData.cs b/src/tests/Refit.Tests/GeneratedFormData.cs index 67a0c7452..7b6a56b48 100644 --- a/src/tests/Refit.Tests/GeneratedFormData.cs +++ b/src/tests/Refit.Tests/GeneratedFormData.cs @@ -23,4 +23,18 @@ public class GeneratedFormData /// Gets or sets a nullable property serialized even when null. [Query(SerializeNull = true)] public string? Nullable { get; set; } + + /// Gets or sets an integer scalar rendered without boxing on the fast path. + public int Age { get; set; } + + /// Gets or sets an enum scalar rendered by the generated formatter. + public GeneratedFormColor Color { get; set; } + + /// Gets or sets a formatted numeric scalar. + [Query(Format = "0.00")] + public double Ratio { get; set; } + + /// Gets or sets a prefixed scalar composing its field name from the query prefix. + [Query("-", "addr")] + public string? City { get; set; } } diff --git a/src/tests/Refit.Tests/GeneratedQueryStringBuilderTests.cs b/src/tests/Refit.Tests/GeneratedQueryStringBuilderTests.cs new file mode 100644 index 000000000..230b57624 --- /dev/null +++ b/src/tests/Refit.Tests/GeneratedQueryStringBuilderTests.cs @@ -0,0 +1,239 @@ +// 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.Numerics; + +namespace Refit.Tests; + +/// Verifies the null-omission and collection-delimiter behavior of . +public sealed class GeneratedQueryStringBuilderTests +{ + /// The relative path shared by the builder fixtures. + private const string Path = "/x"; + + /// The query key shared by the builder fixtures. + private const string Key = "k"; + + /// The number of trailing zeros in the buffer-overflowing value, chosen to exceed both the 128-char stack + /// buffer and the first 256-char rented buffer so the format loop grows twice and returns the earlier rented buffer. + private const int LongValueZeroCount = 300; + + /// The base of the power used to build the buffer-overflowing value. + private const int PowerBase = 10; + + /// The number of trailing zeros in a value that overflows the 128-char stack buffer but fits the first + /// 256-char rented buffer, so the format loop grows exactly once. + private const int SingleGrowthZeroCount = 200; + + /// The relative path and key prefix of the rendered buffer-overflowing value. + private const string LongValueQueryPrefix = "/x?k=1"; + + /// Verifies a null value omits its parameter, leaving the path unchanged. + /// A task representing the asynchronous test. + [Test] + public async Task AddOmitsParameterForNullValue() + { + var result = AddValue(Key, null); + + await Assert.That(result).IsEqualTo(Path); + } + + /// Verifies a null flag name omits the flag, leaving the path unchanged. + /// A task representing the asynchronous test. + [Test] + public async Task AddFlagOmitsNullFlagName() + { + var result = AddNullFlag(); + + await Assert.That(result).IsEqualTo(Path); + } + + /// Verifies a non-null flag name is escaped and appended as a valueless flag. + /// A task representing the asynchronous test. + [Test] + public async Task AddFlagEscapesNonNullFlagName() + { + var builder = new GeneratedQueryStringBuilder(Path); + builder.AddFlag("a b", false); + + await Assert.That(builder.Build()).IsEqualTo("/x?a%20b"); + } + + /// Verifies a pre-encoded flag name is appended verbatim without escaping. + /// A task representing the asynchronous test. + [Test] + public async Task AddFlagAppendsPreEncodedFlagNameVerbatim() + { + var builder = new GeneratedQueryStringBuilder(Path); + builder.AddFlag("a+b", true); + + await Assert.That(builder.Build()).IsEqualTo("/x?a+b"); + } + + /// Verifies a space-separated collection joins its values with a space. + /// A task representing the asynchronous test. + [Test] + public async Task BeginCollectionJoinsSpaceSeparatedValues() + { + var result = JoinCollection(CollectionFormat.Ssv); + + await Assert.That(result).IsEqualTo("/x?k=a%20b"); + } + + /// Verifies a tab-separated collection joins its values with a tab. + /// A task representing the asynchronous test. + [Test] + public async Task BeginCollectionJoinsTabSeparatedValues() + { + var result = JoinCollection(CollectionFormat.Tsv); + + await Assert.That(result).IsEqualTo("/x?k=a%09b"); + } + + /// Verifies a pipe-separated collection joins its values with a pipe. + /// A task representing the asynchronous test. + [Test] + public async Task BeginCollectionJoinsPipeSeparatedValues() + { + var result = JoinCollection(CollectionFormat.Pipes); + + await Assert.That(result).IsEqualTo("/x?k=a%7Cb"); + } + + /// Verifies a comma-separated (default) collection joins its values with a comma. + /// A task representing the asynchronous test. + [Test] + public async Task BeginCollectionJoinsCommaSeparatedValues() + { + var result = JoinCollection(CollectionFormat.Csv); + + await Assert.That(result).IsEqualTo("/x?k=a%2Cb"); + } + + /// Verifies a span-formattable value rendered with an explicit compile-time format is padded per the format. + /// A task representing the asynchronous test. + [Test] + public async Task AddFormattedAppliesCompileTimeFormat() + { + const int value = 42; + var builder = new GeneratedQueryStringBuilder(Path); + builder.AddFormatted(Key, value, "D5", false); + + await Assert.That(builder.Build()).IsEqualTo("/x?k=00042"); + } + + /// Verifies a value rendered with an explicit compile-time format that overflows both the stack buffer and + /// the first rented buffer grows the rented buffer and still renders in full. + /// A task representing the asynchronous test. + [Test] + public async Task AddFormattedGrowsRentedBufferForLongFormattedValue() + { + var value = BigInteger.Pow(PowerBase, LongValueZeroCount); + var builder = new GeneratedQueryStringBuilder(Path); + builder.AddFormatted(Key, value, "R", false); + + var expected = LongValueQueryPrefix + new string('0', LongValueZeroCount); + await Assert.That(builder.Build()).IsEqualTo(expected); + } + + /// Verifies a formatted collection element that overflows the stack and first rented buffer grows the + /// rented buffer and still renders in full, exercising the unescaped join path of the format loop. + /// A task representing the asynchronous test. + [Test] + public async Task AddCollectionValueFormattedGrowsRentedBufferForLongValue() + { + var value = BigInteger.Pow(PowerBase, LongValueZeroCount); + var builder = new GeneratedQueryStringBuilder(Path); + builder.BeginCollection(Key, CollectionFormat.Csv, false); + builder.AddCollectionValueFormatted(value); + builder.EndCollection(); + + var expected = LongValueQueryPrefix + new string('0', LongValueZeroCount); + await Assert.That(builder.Build()).IsEqualTo(expected); + } + + /// Verifies a value that overflows the stack buffer but fits the first rented buffer grows the rented + /// buffer exactly once and still renders in full. + /// A task representing the asynchronous test. + [Test] + public async Task AddFormattedGrowsRentedBufferOnceForMidLengthValue() + { + var value = BigInteger.Pow(PowerBase, SingleGrowthZeroCount); + var builder = new GeneratedQueryStringBuilder(Path); + builder.AddFormatted(Key, value, null, false); + + var expected = LongValueQueryPrefix + new string('0', SingleGrowthZeroCount); + await Assert.That(builder.Build()).IsEqualTo(expected); + } + + /// Verifies a pre-escaped key with a null value omits the parameter, leaving the path unchanged. + /// A task representing the asynchronous test. + [Test] + public async Task AddPreEscapedKeyOmitsParameterForNullValue() + { + var builder = new GeneratedQueryStringBuilder(Path); + builder.AddPreEscapedKey(Key, null, false); + + await Assert.That(builder.Build()).IsEqualTo(Path); + } + + /// Verifies a span-formattable value is rendered straight into the query. + /// A task representing the asynchronous test. + [Test] + public async Task AddFormattedRendersSpanFormattableValue() + { + const int value = 42; + var builder = new GeneratedQueryStringBuilder(Path); + builder.AddFormatted(Key, value, null, false); + + await Assert.That(builder.Build()).IsEqualTo("/x?k=42"); + } + + /// Verifies a span-formattable value longer than the stack buffer and first rented buffer grows the rented + /// buffer twice and still renders in full. + /// A task representing the asynchronous test. + [Test] + public async Task AddFormattedGrowsRentedBufferForLongValue() + { + var value = BigInteger.Pow(PowerBase, LongValueZeroCount); + var builder = new GeneratedQueryStringBuilder(Path); + builder.AddFormatted(Key, value, null, false); + + var expected = LongValueQueryPrefix + new string('0', LongValueZeroCount); + await Assert.That(builder.Build()).IsEqualTo(expected); + } + + /// Appends a single value and returns the built relative path. + /// The query key. + /// The value, or null to omit the parameter. + /// The built relative path. + private static string AddValue(string name, string? value) + { + var builder = new GeneratedQueryStringBuilder(Path); + builder.Add(name, value, false); + return builder.Build(); + } + + /// Appends a null flag and returns the built relative path. + /// The built relative path. + private static string AddNullFlag() + { + var builder = new GeneratedQueryStringBuilder(Path); + builder.AddFlag(null, false); + return builder.Build(); + } + + /// Joins a two-element collection under the given format and returns the built relative path. + /// The resolved collection format. + /// The built relative path. + private static string JoinCollection(CollectionFormat collectionFormat) + { + var builder = new GeneratedQueryStringBuilder(Path); + builder.BeginCollection(Key, collectionFormat, false); + builder.AddCollectionValue("a"); + builder.AddCollectionValue("b"); + builder.EndCollection(); + return builder.Build(); + } +} diff --git a/src/tests/Refit.Tests/GeneratedRequestRunnerTests.BuildQueryKey.cs b/src/tests/Refit.Tests/GeneratedRequestRunnerTests.BuildQueryKey.cs new file mode 100644 index 000000000..7036d9940 --- /dev/null +++ b/src/tests/Refit.Tests/GeneratedRequestRunnerTests.BuildQueryKey.cs @@ -0,0 +1,99 @@ +// 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 composing flattened query-object keys via . +public partial class GeneratedRequestRunnerTests +{ + /// The CLR property name used by the query-key composition tests. + private const string QueryKeyClrName = "SortOrder"; + + /// Verifies the pristine default key formatter is detected so generated code can use constant keys. + /// A task that represents the asynchronous operation. + [Test] + public async Task UsesDefaultUrlParameterKeyFormattingIsTrueForPristineDefault() => + await Assert.That(GeneratedRequestRunner.UsesDefaultUrlParameterKeyFormatting(new RefitSettings())).IsTrue(); + + /// Verifies a custom key formatter disables the constant-key fast path. + /// A task that represents the asynchronous operation. + [Test] + public async Task UsesDefaultUrlParameterKeyFormattingIsFalseForCustomFormatter() + { + var settings = new RefitSettings { UrlParameterKeyFormatter = new UpperCaseKeyFormatter() }; + + await Assert.That(GeneratedRequestRunner.UsesDefaultUrlParameterKeyFormatting(settings)).IsFalse(); + } + + /// Verifies a subclass of the default key formatter is not treated as pristine, since it may override Format. + /// A task that represents the asynchronous operation. + [Test] + public async Task UsesDefaultUrlParameterKeyFormattingIsFalseForDerivedDefaultFormatter() + { + var settings = new RefitSettings { UrlParameterKeyFormatter = new DerivedKeyFormatter() }; + + await Assert.That(GeneratedRequestRunner.UsesDefaultUrlParameterKeyFormatting(settings)).IsFalse(); + } + + /// Verifies the CLR property name is the key when no alias, prefix, or custom formatter applies. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildQueryKeyUsesClrNameByDefault() => + await Assert.That(GeneratedRequestRunner.BuildQueryKey(new RefitSettings(), QueryKeyClrName, null, null)) + .IsEqualTo(QueryKeyClrName); + + /// Verifies the CLR property name passes through a custom key formatter. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildQueryKeyAppliesCustomKeyFormatter() + { + var settings = new RefitSettings { UrlParameterKeyFormatter = new UpperCaseKeyFormatter() }; + + await Assert.That(GeneratedRequestRunner.BuildQueryKey(settings, QueryKeyClrName, null, null)) + .IsEqualTo("SORTORDER"); + } + + /// Verifies an alias bypasses the key formatter entirely, matching the reflection request builder. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildQueryKeyAliasBypassesKeyFormatter() + { + var settings = new RefitSettings { UrlParameterKeyFormatter = new UpperCaseKeyFormatter() }; + + await Assert.That(GeneratedRequestRunner.BuildQueryKey(settings, QueryKeyClrName, "sort", null)) + .IsEqualTo("sort"); + } + + /// Verifies the compile-time prefix segment is prepended to the resolved name. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildQueryKeyPrependsPrefixSegment() => + await Assert.That(GeneratedRequestRunner.BuildQueryKey(new RefitSettings(), "Zip", null, "Addr.")) + .IsEqualTo("Addr.Zip"); + + /// Verifies the prefix segment is prepended to an alias as well as to a formatted CLR name. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildQueryKeyPrependsPrefixSegmentToAlias() => + await Assert.That(GeneratedRequestRunner.BuildQueryKey(new RefitSettings(), "Zip", "postcode", "Addr-")) + .IsEqualTo("Addr-postcode"); + + /// A key formatter that upper-cases keys, used to prove the formatter is consulted. + private sealed class UpperCaseKeyFormatter : IUrlParameterKeyFormatter + { + /// Formats the specified key. + /// The key. + /// The upper-cased key. + public string Format(string key) => key.ToUpperInvariant(); + } + + /// A subclass of the default key formatter, which must not be treated as the pristine default. + private sealed class DerivedKeyFormatter : DefaultUrlParameterKeyFormatter + { + /// Formats the specified key. + /// The key. + /// The reversed key. + public override string Format(string key) => new([.. key.Reverse()]); + } +} diff --git a/src/tests/Refit.Tests/GeneratedRequestRunnerTests.BuildRequestPath.cs b/src/tests/Refit.Tests/GeneratedRequestRunnerTests.BuildRequestPath.cs index 3b9b084fa..b4319659e 100644 --- a/src/tests/Refit.Tests/GeneratedRequestRunnerTests.BuildRequestPath.cs +++ b/src/tests/Refit.Tests/GeneratedRequestRunnerTests.BuildRequestPath.cs @@ -7,6 +7,9 @@ namespace Refit.Tests; /// Tests for building request paths with route parameters via . public partial class GeneratedRequestRunnerTests { + /// A single-parameter templated path reused across the BuildRequestPath fixtures. + private const string SingleParameterPath = "/n/{v}"; + /// Verifies BuildRequestPath returns a path with substituted parameters. /// The expected result. /// The templated path. @@ -27,10 +30,115 @@ public async Task BuildRequestPathReplacesParameters(string expectedResult, stri [Test] public async Task BuildRequestPathFailsOnParameterNotFound() => await Assert - .That(static () => GeneratedRequestRunner.BuildRequestPath("/user/{id}", false)) + .That(static () => + { + ((int startIdx, int endIdx) range, string? value)[] uriParams = []; + _ = GeneratedRequestRunner.BuildRequestPath("/user/{id}", false, uriParams); + }) .Throws() .WithMessage("URL /user/{id} has parameter {id}, but no method parameter matches", StringComparison.Ordinal); + /// Verifies the span-formattable overload writes an integer into the path without escaping. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildRequestPathWritesIntegerWithoutEscaping() + { + const int start = 7; + const int end = 11; + const int id = 42; + var result = GeneratedRequestRunner.BuildRequestPath("/users/{id}/posts", false, (start, end), id); + + await Assert.That(result).EqualTo("/users/42/posts"); + } + + /// Verifies the span-formattable overload renders a negative integer. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildRequestPathWritesNegativeInteger() + { + const int start = 3; + const int end = 6; + const long value = -7; + var result = GeneratedRequestRunner.BuildRequestPath(SingleParameterPath, false, (start, end), value); + + await Assert.That(result).EqualTo("/n/-7"); + } + +#if NET10_0_OR_GREATER + /// Verifies the escaping span-formattable overload percent-encodes reserved characters (net10+). + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildRequestPathEscapesSpanFormattableValue() + { + const int start = 4; + const int end = 10; + const double value = 1e21; + var result = GeneratedRequestRunner.BuildRequestPath("/at/{when}", false, (start, end), value, null); + + // 1e21 renders as "1E+21"; the '+' is URL-reserved and percent-encoded exactly as Uri.EscapeDataString would. + await Assert.That(result).EqualTo("/at/1E%2B21"); + } +#endif + + /// Verifies the span-formattable overload escapes a value that cannot format into the stack buffer. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildRequestPathEscapesUnformattableSpanValue() + { + const int start = 3; + const int end = 6; + var result = GeneratedRequestRunner.BuildRequestPath(SingleParameterPath, false, (start, end), new AlwaysUnformattableValue()); + + await Assert.That(result).IsEqualTo("/n/a%2Fb"); + } + + /// Verifies the format-taking escaping overload falls back to the string escaper when the value cannot + /// format into the stack buffer. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildRequestPathEscapesUnformattableSpanValueWithFormatOverload() + { + const int start = 3; + const int end = 6; + var result = GeneratedRequestRunner.BuildRequestPath(SingleParameterPath, false, (start, end), new AlwaysUnformattableValue(), null); + + await Assert.That(result).IsEqualTo("/n/a%2Fb"); + } + + /// Verifies the pre-encoded overload returns the template for an empty parameter set. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildRequestPathReturnsTemplateForEmptyPreEncodedParameters() + { + ((int startIdx, int endIdx) range, string? value, bool preEncoded)[] uriParams = []; + + var result = GeneratedRequestRunner.BuildRequestPath("/plain", true, uriParams); + + await Assert.That(result).IsEqualTo("/plain"); + } + + /// Verifies the pre-encoded overload appends a pre-encoded value verbatim while still escaping a + /// non-pre-encoded value in the same template. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildRequestPathAppendsPreEncodedValueVerbatimAndEscapesOthers() + { + // "/n/{v}/{w}" places {v} at [firstStart, firstEnd) and {w} at [secondStart, secondEnd). + const int firstStart = 3; + const int firstEnd = 6; + const int secondStart = 7; + const int secondEnd = 10; + ((int startIdx, int endIdx) range, string? value, bool preEncoded)[] uriParams = + [ + ((firstStart, firstEnd), "a b", false), + ((secondStart, secondEnd), "c d", true) + ]; + + var result = GeneratedRequestRunner.BuildRequestPath("/n/{v}/{w}", true, uriParams); + + await Assert.That(result).IsEqualTo("/n/a%20b/c d"); + } + /// Provides test data for . internal static class GeneratedRequestRunnerTestsDataSources { @@ -78,4 +186,18 @@ private static ((int start, int end) location, string? value)[] Bind(string temp return [.. located]; } } + + /// A span-formattable value whose always fails. + private sealed class AlwaysUnformattableValue : ISpanFormattable + { + /// + public string ToString(string? format, IFormatProvider? formatProvider) => "a/b"; + + /// + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + charsWritten = 0; + return false; + } + } } diff --git a/src/tests/Refit.Tests/GeneratedRequestRunnerTests.Collections.cs b/src/tests/Refit.Tests/GeneratedRequestRunnerTests.Collections.cs new file mode 100644 index 000000000..63ea366fd --- /dev/null +++ b/src/tests/Refit.Tests/GeneratedRequestRunnerTests.Collections.cs @@ -0,0 +1,216 @@ +// 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.Net; +using System.Text; + +namespace Refit.Tests; + +/// Tests for generated collection-property expansion, streaming cancellation linking and the descriptor +/// URL-encoded body overload. +public partial class GeneratedRequestRunnerTests +{ + /// Relative path shared by the formatted-collection-property fixtures. + private const string CollectionPropertyPath = "/p"; + + /// Query key shared by the formatted-collection-property fixtures. + private const string CollectionPropertyKey = "k"; + + /// The first value in the streamed JSON array body. + private const int StreamedFirstValue = 1; + + /// The second value in the streamed JSON array body. + private const int StreamedSecondValue = 2; + + /// The JSON array body returned by the streaming fixtures. + private const string JsonArrayBody = "[1,2]"; + + /// The media type of the streamed JSON array body. + private const string JsonArrayMediaType = "application/json"; + + /// The two-element collection shared by the formatted-collection-property fixtures. + private static readonly string[] CollectionElements = ["a", "b"]; + + /// The values expected from streaming the JSON array body. + private static readonly int?[] ExpectedStreamedValues = [StreamedFirstValue, StreamedSecondValue]; + + /// Verifies a null collection value appends nothing to the query. + /// A task that represents the asynchronous operation. + [Test] + public async Task AddFormattedCollectionPropertyIgnoresNullCollection() + { + var result = AddFormattedCollectionProperty(null, CollectionFormat.Csv); + + await Assert.That(result).IsEqualTo(CollectionPropertyPath); + } + + /// Verifies a customized-formatter collection joins values by the resolved delimiter. + /// The resolved collection format. + /// The expected relative path and query. + /// A task that represents the asynchronous operation. + [Test] + [Arguments(CollectionFormat.Ssv, "/p?k=a%20b")] + [Arguments(CollectionFormat.Tsv, "/p?k=a%09b")] + [Arguments(CollectionFormat.Pipes, "/p?k=a%7Cb")] + public async Task AddFormattedCollectionPropertyJoinsWithResolvedDelimiter(CollectionFormat collectionFormat, string expected) + { + var result = AddFormattedCollectionProperty(CollectionElements, collectionFormat); + + await Assert.That(result).IsEqualTo(expected); + } + + /// Verifies streaming links the method and consumer cancellation tokens when both can cancel. + /// A task that represents the asynchronous operation. + [Test] + public async Task StreamAsyncLinksMethodAndConsumerTokens() + { + var handler = new CapturingHandler( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonArrayBody, Encoding.UTF8, JsonArrayMediaType) + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + using var methodTokenSource = new CancellationTokenSource(); + using var consumerTokenSource = new CancellationTokenSource(); + var settings = new RefitSettings(new SystemTextJsonContentSerializer()); + + var values = new List(); + await foreach (var item in GeneratedRequestRunner + .StreamAsync(client, request, settings, methodTokenSource.Token) + .WithCancellation(consumerTokenSource.Token)) + { + values.Add(item); + } + + await Assert.That(values).IsCollectionEqualTo(ExpectedStreamedValues); + } + + /// Verifies streaming skips the linked cancellation source when the method's token cannot cancel, running + /// against the consumer token alone. + /// A task that represents the asynchronous operation. + [Test] + public async Task StreamAsyncSkipsLinkedSourceWhenMethodTokenCannotCancel() + { + var handler = new CapturingHandler( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonArrayBody, Encoding.UTF8, JsonArrayMediaType) + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + var settings = new RefitSettings(new SystemTextJsonContentSerializer()); + + var values = new List(); + await foreach (var item in GeneratedRequestRunner + .StreamAsync(client, request, settings, CancellationToken.None)) + { + values.Add(item); + } + + await Assert.That(values).IsCollectionEqualTo(ExpectedStreamedValues); + } + + /// Verifies streaming disposes the request and underlying response when enumeration stops early, exercising + /// the iterator's early-disposal path rather than natural completion. + /// A task that represents the asynchronous operation. + [Test] + public async Task StreamAsyncStopsAndDisposesWhenEnumerationStopsEarly() + { + var handler = new CapturingHandler( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonArrayBody, Encoding.UTF8, JsonArrayMediaType) + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + var settings = new RefitSettings(new SystemTextJsonContentSerializer()); + + await using var enumerator = GeneratedRequestRunner + .StreamAsync(client, request, settings, CancellationToken.None) + .GetAsyncEnumerator(); + + // Advance a single element, then let `await using` dispose the enumerator before the sequence completes. + var moved = await enumerator.MoveNextAsync(); + + await Assert.That(moved).IsTrue(); + await Assert.That(enumerator.Current).IsEqualTo(StreamedFirstValue); + } + + /// Verifies streaming surfaces the response error through the iterator, exercising its exceptional-exit + /// path where the request and linked source are still disposed. + /// A task that represents the asynchronous operation. + [Test] + public async Task StreamAsyncThrowsForUnsuccessfulResponse() + { + var handler = new CapturingHandler( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("boom") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + var settings = new RefitSettings(new SystemTextJsonContentSerializer()); + + await Assert + .That(async () => + { + await using var enumerator = GeneratedRequestRunner + .StreamAsync(client, request, settings, CancellationToken.None) + .GetAsyncEnumerator(); + await enumerator.MoveNextAsync(); + }) + .Throws(); + } + + /// Verifies the descriptor overload reuses already-created HTTP content. + /// A task that represents the asynchronous operation. + [Test] + public async Task CreateUrlEncodedBodyContentWithFieldsReusesHttpContent() + { + var settings = new RefitSettings(new SystemTextJsonContentSerializer()); + var content = new StringContent("content-body"); + + var result = GeneratedRequestRunner.CreateUrlEncodedBodyContent(settings, (HttpContent)content, []); + + await Assert.That(result).IsSameReferenceAs(content); + } + + /// Verifies the descriptor overload wraps stream bodies as stream content. + /// A task that represents the asynchronous operation. + [Test] + public async Task CreateUrlEncodedBodyContentWithFieldsUsesStreamContentForStreams() + { + var settings = new RefitSettings(new SystemTextJsonContentSerializer()); + await using var stream = new MemoryStream(StreamBodyBytes); + + var result = GeneratedRequestRunner.CreateUrlEncodedBodyContent(settings, (Stream)stream, []); + + await Assert.That(result).IsTypeOf(); + await Assert.That(await result.ReadAsStringAsync()).IsEqualTo(StreamBodyText); + } + + /// Appends a collection property through a customized formatter and returns the built relative path. + /// The collection value, or null. + /// The resolved collection format. + /// The built relative path. + private static string AddFormattedCollectionProperty(IEnumerable? values, CollectionFormat collectionFormat) + { + var settings = CreateSettings(); + var builder = new GeneratedQueryStringBuilder(CollectionPropertyPath); + GeneratedRequestRunner.AddFormattedCollectionProperty( + ref builder, + settings, + values, + CollectionPropertyKey, + collectionFormat, + false, + (typeof(string), typeof(string), typeof(string))); + return builder.Build(); + } +} diff --git a/src/tests/Refit.Tests/GeneratedRequestRunnerTests.RequestDispatch.cs b/src/tests/Refit.Tests/GeneratedRequestRunnerTests.RequestDispatch.cs new file mode 100644 index 000000000..b52245c9e --- /dev/null +++ b/src/tests/Refit.Tests/GeneratedRequestRunnerTests.RequestDispatch.cs @@ -0,0 +1,577 @@ +// 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; + +/// Request header, request-option and relative-URI assembly, and successful request dispatch with response materialization, for the generated request runtime helper. +public partial class GeneratedRequestRunnerTests +{ + /// The response body returned by the observable dispatch fixtures. + private const string ObservedResponseContent = "observed"; + + /// 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, RelativeResourcePath); + + GeneratedRequestRunner.SetHeader(request, TestHeaderName, "first"); + GeneratedRequestRunner.SetHeader(request, TestHeaderName, "second\r\nvalue"); + + await Assert.That(request.Headers.GetValues(TestHeaderName)).IsCollectionEqualTo(["secondvalue"]); + + GeneratedRequestRunner.SetHeader(request, TestHeaderName, null); + + await Assert.That(request.Headers.Contains(TestHeaderName)).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, RelativeResourcePath); + + GeneratedRequestRunner.SetHeader(request, "Content-Language", ContentLanguageValue); + + await Assert.That(request.Content).IsNotNull(); + await Assert.That(request.Content!.Headers.ContentLanguage).IsCollectionEqualTo([ContentLanguageValue]); + } + + /// 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, RelativeResourcePath) + { + Content = new StringContent("body") + }; + request.Content.Headers.ContentLanguage.Add(ContentLanguageValue); + + 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, RelativeResourcePath); + var headers = new Dictionary + { + [FirstHeaderName] = "one", + ["X-Second"] = "two" + }; + + GeneratedRequestRunner.SetHeader(request, FirstHeaderName, "original"); + GeneratedRequestRunner.AddHeaderCollection(request, null); + GeneratedRequestRunner.AddHeaderCollection(request, headers); + + await Assert.That(request.Headers.GetValues(FirstHeaderName)).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, RelativeResourcePath); + + GeneratedRequestRunner.AddRequestProperty(request, "number", RequestPropertyValue); + +#if NET6_0_OR_GREATER + await Assert.That(request.Options.TryGetValue(new HttpRequestOptionsKey("number"), out var value)) + .IsTrue(); + await Assert.That(value).IsEqualTo(RequestPropertyValue); +#else + await Assert.That(request.Properties["number"]).IsEqualTo(RequestPropertyValue); +#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, RelativeResourcePath); + var settings = new RefitSettings(new RecordingContentSerializer()) + { + HttpRequestMessageOptions = new() + { + ["configured"] = ConfiguredOptionValue + } + }; + + 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(ConfiguredOptionValue); + 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(ConfiguredOptionValue); + await Assert.That(request.Properties[HttpRequestMessageOptions.InterfaceType]) + .IsEqualTo(typeof(GeneratedRequestRunnerTests)); +#endif + } + + /// 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( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("response") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + 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( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("buffered") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Post, RelativeResourcePath) + { + 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, RelativeResourcePath); + var settings = CreateSettings(); + var exceptionFactoryCalled = false; + settings.ExceptionFactory = _ => + { + exceptionFactoryCalled = true; + return new ValueTask(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, RelativeResourcePath); + + 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( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("streamed-response") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + + 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(DeserializedResultValue) + }; + var handler = new CapturingHandler( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"value\":42}") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + + var result = await GeneratedRequestRunner.SendAsync( + client, + request, + CreateSettings(serializer), + isApiResponse: false, + shouldDisposeResponse: true, + bufferBody: false, + CancellationToken.None); + + await Assert.That(result).IsEqualTo(new(DeserializedResultValue)); + 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(DeserializedResultValue) + }; + var handler = new CapturingHandler( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent([]) + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + + 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 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(SuccessResultValue) + }; + var handler = new CapturingHandler( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"value\":123}") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + + 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(SuccessResultValue)); + await Assert.That(serializer.DeserializeCallCount).IsEqualTo(1); + } + + /// 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(BufferedResultValue) + }; + var handler = new CapturingHandler( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ThrowingLoadContent() + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + + var result = await GeneratedRequestRunner.SendAsync( + client, + request, + CreateSettings(serializer), + isApiResponse: false, + shouldDisposeResponse: true, + bufferBody: false, + CancellationToken.None); + + await Assert.That(result).IsEqualTo(new(BufferedResultValue)); + await Assert.That(serializer.DeserializeCallCount).IsEqualTo(1); + } + + /// Verifies BuildRelativeUri throws when the legacy mode has no base address. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildRelativeUriThrowsWhenBaseAddressMissing() + { + using var client = new HttpClient(); + + await Assert + .That(() => GeneratedRequestRunner.BuildRelativeUri(client, "/x", UrlResolutionMode.RefitLegacy)) + .ThrowsExactly(); + } + + /// Verifies BuildRelativeUri prepends the base path under legacy resolution. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildRelativeUriPrependsBasePathInLegacyMode() + { + using var client = new HttpClient { BaseAddress = new("http://foo/api/") }; + + var uri = GeneratedRequestRunner.BuildRelativeUri(client, "/x", UrlResolutionMode.RefitLegacy); + + await Assert.That(uri.OriginalString).IsEqualTo("/api/x"); + } + + /// Verifies BuildRelativeUri emits the relative path verbatim under RFC 3986 resolution. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildRelativeUriEmitsRelativePathInRfcMode() + { + using var client = new HttpClient(); + + var uri = GeneratedRequestRunner.BuildRelativeUri(client, "x", UrlResolutionMode.Rfc3986); + + await Assert.That(uri.OriginalString).IsEqualTo("x"); + } + + /// Verifies the query-format overload also emits the relative path verbatim under RFC 3986 resolution, + /// where the escaping format is irrelevant. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildRelativeUriWithQueryFormatEmitsRelativePathInRfcMode() + { + using var client = new HttpClient(); + + var uri = GeneratedRequestRunner.BuildRelativeUri(client, "x", UrlResolutionMode.Rfc3986, UriFormat.UriEscaped); + + await Assert.That(uri.OriginalString).IsEqualTo("x"); + } + + /// Verifies the query-format overload prepends the trimmed base path under legacy resolution. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildRelativeUriWithQueryFormatPrependsBasePathInLegacyMode() + { + using var client = new HttpClient { BaseAddress = new("http://foo/api/") }; + + var uri = GeneratedRequestRunner.BuildRelativeUri(client, "/x", UrlResolutionMode.RefitLegacy, UriFormat.UriEscaped); + + await Assert.That(uri.OriginalString).IsEqualTo("/api/x"); + } + + /// Verifies the query-format overload uses an empty base path for a root base address under legacy resolution. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildRelativeUriWithQueryFormatUsesEmptyBasePathForRootBaseAddress() + { + using var client = new HttpClient { BaseAddress = new("http://foo/") }; + + var uri = GeneratedRequestRunner.BuildRelativeUri(client, "/x", UrlResolutionMode.RefitLegacy, UriFormat.UriEscaped); + + await Assert.That(uri.OriginalString).IsEqualTo("/x"); + } + + /// Verifies the query-format overload throws when the legacy mode has no base address. + /// A task that represents the asynchronous operation. + [Test] + public async Task BuildRelativeUriWithQueryFormatThrowsWhenBaseAddressMissing() + { + using var client = new HttpClient(); + + await Assert + .That(() => GeneratedRequestRunner.BuildRelativeUri(client, "/x", UrlResolutionMode.RefitLegacy, UriFormat.UriEscaped)) + .ThrowsExactly(); + } + + /// Verifies a cold observable links the method's cancellation token with the per-subscription token when + /// both can cancel, and still yields the response. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendObservableLinksMethodAndSubscriptionCancellationTokens() + { + var handler = new CapturingHandler( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(ObservedResponseContent) + })); + using var client = CreateClient(handler); + using var methodTokenSource = new CancellationTokenSource(); + + var observable = GeneratedRequestRunner.SendObservable( + client, + static () => new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath), + CreateSettings(), + isApiResponse: false, + shouldDisposeResponse: true, + bufferBody: false, + methodTokenSource.Token); + + var result = await ObservableTestHelpers.Await(observable); + + await Assert.That(result).IsEqualTo(ObservedResponseContent); + } + + /// Verifies a cold observable skips the linked cancellation source when the method's token cannot cancel, + /// running against the per-subscription token alone, and still yields the response. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendObservableSkipsLinkedSourceWhenMethodTokenCannotCancel() + { + var handler = new CapturingHandler( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(ObservedResponseContent) + })); + using var client = CreateClient(handler); + + var observable = GeneratedRequestRunner.SendObservable( + client, + static () => new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath), + CreateSettings(), + isApiResponse: false, + shouldDisposeResponse: true, + bufferBody: false, + CancellationToken.None); + + var result = await ObservableTestHelpers.Await(observable); + + await Assert.That(result).IsEqualTo(ObservedResponseContent); + } + + /// Verifies EnsureResponseContent substitutes empty content when the response has none. + /// A task that represents the asynchronous operation. + [Test] + public async Task EnsureResponseContentSubstitutesEmptyWhenNull() + { + using var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = null }; + + var content = RequestExecutionHelpers.EnsureResponseContent(response); + + await Assert.That(await content.ReadAsStringAsync()).IsEqualTo(string.Empty); + } + + /// Verifies EnsureResponseContent returns the existing content untouched. + /// A task that represents the asynchronous operation. + [Test] + public async Task EnsureResponseContentReturnsExistingContent() + { + var original = new StringContent("hi"); + using var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = original }; + + var content = RequestExecutionHelpers.EnsureResponseContent(response); + + await Assert.That(ReferenceEquals(content, original)).IsTrue(); + } +} diff --git a/src/tests/Refit.Tests/GeneratedRequestRunnerTests.ResponseErrorHandling.cs b/src/tests/Refit.Tests/GeneratedRequestRunnerTests.ResponseErrorHandling.cs new file mode 100644 index 000000000..aa093e250 --- /dev/null +++ b/src/tests/Refit.Tests/GeneratedRequestRunnerTests.ResponseErrorHandling.cs @@ -0,0 +1,370 @@ +// 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.Net; + +namespace Refit.Tests; + +/// Send-path error, exception, cancellation, and base-address handling for the generated request runtime helper. +public partial class GeneratedRequestRunnerTests +{ + /// 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( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.Accepted) + { + Content = new StringContent("accepted") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Post, RelativeResourcePath) + { + Content = new StringContent("body") + }; + request.Headers.Authorization = new("Bearer"); + var exception = new InvalidOperationException("factory failure"); + var settings = CreateSettings(); + settings.AuthorizationHeaderValueGetter = static (_, _) => new ValueTask("token"); + settings.ExceptionFactory = _ => new ValueTask(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, RelativeResourcePath); + + 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 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( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("bad") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + var settings = CreateSettings(); + settings.ExceptionFactory = _ => new ValueTask(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( + static (_, _) => throw new HttpRequestException("network failure")); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + + 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( + static (_, _) => throw new HttpRequestException("network failure")); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + + 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 response factory errors without deserializing. + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncReturnsApiResponseWithResponseException() + { + var serializer = new RecordingContentSerializer + { + DeserializedValue = new GeneratedResult(SuccessResultValue) + }; + var handler = new CapturingHandler( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("bad") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + 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(BadContentMessage) + }; + var handler = new CapturingHandler( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("bad") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + var settings = CreateSettings(serializer); + settings.DeserializationExceptionFactory = static (_, _) => new ValueTask((Exception?)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(BadContentMessage) + }; + var handler = new CapturingHandler( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("bad") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + var replacement = new InvalidOperationException("replacement"); + var settings = CreateSettings(serializer); + settings.DeserializationExceptionFactory = (_, _) => new ValueTask(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(BadContentMessage) + }; + var handler = new CapturingHandler( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("bad") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + + 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( + static (_, _) => Task.FromResult( + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("cancelled") + })); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + 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 caller-requested cancellation during send is rethrown instead of being wrapped in . + /// A task that represents the asynchronous operation. + [Test] + public async Task SendAsyncRethrowsCancellationRequestedDuringSend() + { + using var tokenSource = new CancellationTokenSource(); + var handler = new CapturingHandler( + async (_, _) => + { + await tokenSource.CancelAsync(); + throw new OperationCanceledException(tokenSource.Token); + }); + using var client = CreateClient(handler); + using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + + await Assert + .That( + () => GeneratedRequestRunner.SendAsync( + client, + request, + CreateSettings(), + isApiResponse: false, + shouldDisposeResponse: true, + bufferBody: false, + tokenSource.Token)) + .Throws(); + } + + /// 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, RelativeResourcePath); + + 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"); + } +} diff --git a/src/tests/Refit.Tests/GeneratedRequestRunnerTests.TransportExceptionFactory.cs b/src/tests/Refit.Tests/GeneratedRequestRunnerTests.TransportExceptionFactory.cs index 7f635b207..000ea621b 100644 --- a/src/tests/Refit.Tests/GeneratedRequestRunnerTests.TransportExceptionFactory.cs +++ b/src/tests/Refit.Tests/GeneratedRequestRunnerTests.TransportExceptionFactory.cs @@ -13,7 +13,7 @@ public partial class GeneratedRequestRunnerTests public async Task SendAsyncWrapsCancellationWhenCallerTokenNotSignalled() { var handler = new CapturingHandler( - (_, _) => throw new TaskCanceledException("timeout")); + static (_, _) => throw new TaskCanceledException("timeout")); using var client = CreateClient(handler); using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); @@ -38,9 +38,9 @@ public async Task SendAsyncWrapsCancellationWhenCallerTokenNotSignalled() public async Task SendAsyncHonorsCustomTransportExceptionFactory() { var settings = CreateSettings(); - settings.TransportExceptionFactory = (_, _, _) => new InvalidOperationException("mapped by factory"); + settings.TransportExceptionFactory = static (_, _, _) => new InvalidOperationException("mapped by factory"); var handler = new CapturingHandler( - (_, _) => throw new HttpRequestException("boom")); + static (_, _) => throw new HttpRequestException("boom")); using var client = CreateClient(handler); using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); diff --git a/src/tests/Refit.Tests/GeneratedRequestRunnerTests.cs b/src/tests/Refit.Tests/GeneratedRequestRunnerTests.cs index a447e6631..d3119bb96 100644 --- a/src/tests/Refit.Tests/GeneratedRequestRunnerTests.cs +++ b/src/tests/Refit.Tests/GeneratedRequestRunnerTests.cs @@ -19,6 +19,18 @@ public partial class GeneratedRequestRunnerTests /// Header name used by the header-assignment tests. private const string TestHeaderName = "X-Test"; + /// Header name used by the header-collision tests. + private const string FirstHeaderName = "X-First"; + + /// Content-Language header value used by the content-header tests. + private const string ContentLanguageValue = "en-US"; + + /// Stream body payload shared by the stream-content tests. + private const string StreamBodyText = "stream-body"; + + /// Deserialization failure message shared by the error-path tests. + private const string BadContentMessage = "bad content"; + /// Sample body value for the unsupported serialization-mode test. private const int UnsupportedModeBodyValue = 42; @@ -43,6 +55,9 @@ public partial class GeneratedRequestRunnerTests /// Deserialized result value used by the buffering-failure test. private const int BufferedResultValue = 321; + /// UTF-8 encoded stream body payload shared by the stream-content tests. + private static readonly byte[] StreamBodyBytes = "stream-body"u8.ToArray(); + /// Verifies that already-created HTTP content is reused directly. /// A task that represents the asynchronous operation. [Test] @@ -65,7 +80,7 @@ public async Task CreateBodyContentReusesHttpContent() [Test] public async Task CreateBodyContentUsesStreamContentForStreamBodies() { - await using var stream = new MemoryStream("stream-body"u8.ToArray()); + await using var stream = new MemoryStream(StreamBodyBytes); var settings = CreateSettings(); var result = GeneratedRequestRunner.CreateBodyContent( @@ -75,7 +90,7 @@ public async Task CreateBodyContentUsesStreamContentForStreamBodies() streamBody: false); await Assert.That(result).IsTypeOf(); - await Assert.That(await result.ReadAsStringAsync()).IsEqualTo("stream-body"); + await Assert.That(await result.ReadAsStringAsync()).IsEqualTo(StreamBodyText); } /// Verifies that default string bodies are sent as literal string content. @@ -104,9 +119,10 @@ public async Task CreateBodyContentUsesSerializerForSerializedBodyModes() var serializer = new RecordingContentSerializer(); var settings = CreateSettings(serializer); + const int bodyValue = 42; var defaultContent = GeneratedRequestRunner.CreateBodyContent( settings, - 42, + bodyValue, BodySerializationMethod.Default, streamBody: false); var serializedContent = GeneratedRequestRunner.CreateBodyContent( @@ -181,14 +197,14 @@ public async Task CreateUrlEncodedBodyContentReusesHttpContent() public async Task CreateUrlEncodedBodyContentUsesStreamContentForStreams() { var settings = CreateSettings(); - await using var stream = new MemoryStream("stream-body"u8.ToArray()); + await using var stream = new MemoryStream(StreamBodyBytes); var result = GeneratedRequestRunner.CreateUrlEncodedBodyContent( settings, stream); await Assert.That(result).IsTypeOf(); - await Assert.That(await result.ReadAsStringAsync()).IsEqualTo("stream-body"); + await Assert.That(await result.ReadAsStringAsync()).IsEqualTo(StreamBodyText); } /// Verifies URL-encoded object bodies use the declared body type. @@ -353,822 +369,64 @@ await Assert .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, RelativeResourcePath); - - GeneratedRequestRunner.SetHeader(request, TestHeaderName, "first"); - GeneratedRequestRunner.SetHeader(request, TestHeaderName, "second\r\nvalue"); - - await Assert.That(request.Headers.GetValues(TestHeaderName)).IsCollectionEqualTo(["secondvalue"]); - - GeneratedRequestRunner.SetHeader(request, TestHeaderName, null); - - await Assert.That(request.Headers.Contains(TestHeaderName)).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, RelativeResourcePath); - - 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, RelativeResourcePath) - { - 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, RelativeResourcePath); - 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, RelativeResourcePath); - - GeneratedRequestRunner.AddRequestProperty(request, "number", RequestPropertyValue); - -#if NET6_0_OR_GREATER - await Assert.That(request.Options.TryGetValue(new HttpRequestOptionsKey("number"), out var value)) - .IsTrue(); - await Assert.That(value).IsEqualTo(RequestPropertyValue); -#else - await Assert.That(request.Properties["number"]).IsEqualTo(RequestPropertyValue); -#endif - } - - /// Verifies that configured request options and interface type metadata are applied. + /// Verifies the invariant formatter renders both a value-type and a reference-type formattable value. /// A task that represents the asynchronous operation. [Test] - public async Task AddConfiguredRequestOptionsAddsConfiguredValuesAndInterfaceType() + public async Task FormatInvariantFormatsValueAndReferenceTypes() { - using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); - var settings = new RefitSettings(new RecordingContentSerializer()) - { - HttpRequestMessageOptions = new() - { - ["configured"] = ConfiguredOptionValue - } - }; + const int value = 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(ConfiguredOptionValue); - 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(ConfiguredOptionValue); - await Assert.That(request.Properties[HttpRequestMessageOptions.InterfaceType]) - .IsEqualTo(typeof(GeneratedRequestRunnerTests)); -#endif + await Assert.That(GeneratedRequestRunner.FormatInvariant(value, "D3")).IsEqualTo("042"); + await Assert.That(GeneratedRequestRunner.FormatInvariant(new FormattableReference(), "custom")).IsEqualTo("custom"); } - /// Verifies that void requests apply generated auth headers and honor the exception factory. + /// Verifies CanUnrollForm accepts a plain object body and rejects the null, HttpContent, Stream, string and + /// dictionary bodies the reflection path special-cases. /// A task that represents the asynchronous operation. [Test] - public async Task SendVoidAsyncAppliesAuthorizationAndThrowsFactoryException() + public async Task CanUnrollFormAcceptsPlainObjectsAndRejectsSpecialShapes() { - 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, RelativeResourcePath) - { - 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(); + using var httpContent = new StringContent("x"); + await using var stream = new MemoryStream(); - await Assert.That(thrown).IsSameReferenceAs(exception); - await Assert.That(handler.AuthorizationParameter).IsEqualTo("token"); + await Assert.That(GeneratedRequestRunner.CanUnrollForm(new DeclaredFormBody())).IsTrue(); + await Assert.That(GeneratedRequestRunner.CanUnrollForm(null)).IsFalse(); + await Assert.That(GeneratedRequestRunner.CanUnrollForm(httpContent)).IsFalse(); + await Assert.That(GeneratedRequestRunner.CanUnrollForm(stream)).IsFalse(); + await Assert.That(GeneratedRequestRunner.CanUnrollForm("body")).IsFalse(); + await Assert.That(GeneratedRequestRunner.CanUnrollForm(new Dictionary())).IsFalse(); } - /// Verifies that void requests require a base address when using generated relative URIs. + /// Verifies the multipart serializer wraps a serialization failure in a descriptive argument exception for + /// both a typed part value and a null part value. /// A task that represents the asynchronous operation. [Test] - public async Task SendVoidAsyncRequiresBaseAddress() + public async Task SerializeMultipartPartWrapsSerializerFailure() { - using var client = new HttpClient(new CapturingHandler()); - using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); - - 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( - static (_, _) => Task.FromResult( - new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("response") - })); - using var client = CreateClient(handler); - using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); - 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( - static (_, _) => Task.FromResult( - new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("buffered") - })); - using var client = CreateClient(handler); - using var request = new HttpRequestMessage(HttpMethod.Post, RelativeResourcePath) - { - 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, RelativeResourcePath); - 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, RelativeResourcePath); - - 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( - static (_, _) => Task.FromResult( - new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("streamed-response") - })); - using var client = CreateClient(handler); - using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); - - 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(DeserializedResultValue) - }; - var handler = new CapturingHandler( - static (_, _) => Task.FromResult( - new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{\"value\":42}") - })); - using var client = CreateClient(handler); - using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); - - var result = await GeneratedRequestRunner.SendAsync( - client, - request, - CreateSettings(serializer), - isApiResponse: false, - shouldDisposeResponse: true, - bufferBody: false, - CancellationToken.None); - - await Assert.That(result).IsEqualTo(new(DeserializedResultValue)); - 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(DeserializedResultValue) - }; - var handler = new CapturingHandler( - static (_, _) => Task.FromResult( - new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent([]) - })); - using var client = CreateClient(handler); - using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); - - 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, RelativeResourcePath); - 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( - static (_, _) => throw new HttpRequestException("network failure")); - using var client = CreateClient(handler); - using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); - - 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, RelativeResourcePath); - - 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(SuccessResultValue) - }; - var handler = new CapturingHandler( - static (_, _) => Task.FromResult( - new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{\"value\":123}") - })); - using var client = CreateClient(handler); - using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); - - 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(SuccessResultValue)); - 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(SuccessResultValue) - }; - var handler = new CapturingHandler( - static (_, _) => Task.FromResult( - new HttpResponseMessage(HttpStatusCode.BadRequest) - { - Content = new StringContent("bad") - })); - using var client = CreateClient(handler); - using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); - 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( - static (_, _) => Task.FromResult( - new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("bad") - })); - using var client = CreateClient(handler); - using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); - var settings = CreateSettings(serializer); - settings.DeserializationExceptionFactory = static (_, _) => 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, RelativeResourcePath); - 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, RelativeResourcePath); - - 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, RelativeResourcePath); - 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 caller-requested cancellation during send is rethrown instead of being wrapped in . - /// A task that represents the asynchronous operation. - [Test] - public async Task SendAsyncRethrowsCancellationRequestedDuringSend() - { - using var tokenSource = new CancellationTokenSource(); - var handler = new CapturingHandler( - async (_, _) => - { - await tokenSource.CancelAsync(); - throw new OperationCanceledException(tokenSource.Token); - }); - using var client = CreateClient(handler); - using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); + var settings = new RefitSettings( + new RecordingContentSerializer { SerializeException = new NotSupportedException("boom") }); await Assert - .That( - () => GeneratedRequestRunner.SendAsync( - client, - request, - CreateSettings(), - isApiResponse: false, - shouldDisposeResponse: true, - bufferBody: false, - tokenSource.Token)) - .Throws(); - } - - /// 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(BufferedResultValue) - }; - var handler = new CapturingHandler( - static (_, _) => Task.FromResult( - new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ThrowingLoadContent() - })); - using var client = CreateClient(handler); - using var request = new HttpRequestMessage(HttpMethod.Get, RelativeResourcePath); - - var result = await GeneratedRequestRunner.SendAsync( - client, - request, - CreateSettings(serializer), - isApiResponse: false, - shouldDisposeResponse: true, - bufferBody: false, - CancellationToken.None); - - await Assert.That(result).IsEqualTo(new(BufferedResultValue)); - 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, RelativeResourcePath); - - 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"); - } - - /// Verifies BuildRelativeUri throws when the legacy mode has no base address. - /// A task that represents the asynchronous operation. - [Test] - public async Task BuildRelativeUriThrowsWhenBaseAddressMissing() - { - using var client = new HttpClient(); - + .That(() => GeneratedRequestRunner.SerializeMultipartPart(settings, new DeclaredFormBody(), "field")) + .ThrowsExactly(); await Assert - .That(() => GeneratedRequestRunner.BuildRelativeUri(client, "/x", UrlResolutionMode.RefitLegacy)) - .ThrowsExactly(); + .That(() => GeneratedRequestRunner.SerializeMultipartPart(settings, null, "field")) + .ThrowsExactly(); } - /// Verifies BuildRelativeUri prepends the base path under legacy resolution. + /// Verifies the catch-all path escaper substitutes an empty string when the formatter yields null for a + /// section, preserving the separators between the (now-empty) sections. /// A task that represents the asynchronous operation. [Test] - public async Task BuildRelativeUriPrependsBasePathInLegacyMode() + public async Task RoundTripEscapePathSubstitutesEmptyForNullFormattedSection() { - using var client = new HttpClient { BaseAddress = new("http://foo/api/") }; - - var uri = GeneratedRequestRunner.BuildRelativeUri(client, "/x", UrlResolutionMode.RefitLegacy); + var result = GeneratedRequestRunner.RoundTripEscapePath( + "a/b", + new NullUrlParameterFormatter(), + typeof(string), + typeof(string)); - await Assert.That(uri.OriginalString).IsEqualTo("/api/x"); - } - - /// Verifies BuildRelativeUri emits the relative path verbatim under RFC 3986 resolution. - /// A task that represents the asynchronous operation. - [Test] - public async Task BuildRelativeUriEmitsRelativePathInRfcMode() - { - using var client = new HttpClient(); - - var uri = GeneratedRequestRunner.BuildRelativeUri(client, "x", UrlResolutionMode.Rfc3986); - - await Assert.That(uri.OriginalString).IsEqualTo("x"); - } - - /// Verifies EnsureResponseContent substitutes empty content when the response has none. - /// A task that represents the asynchronous operation. - [Test] - public async Task EnsureResponseContentSubstitutesEmptyWhenNull() - { - using var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = null }; - - var content = RequestExecutionHelpers.EnsureResponseContent(response); - - await Assert.That(await content.ReadAsStringAsync()).IsEqualTo(string.Empty); - } - - /// Verifies EnsureResponseContent returns the existing content untouched. - /// A task that represents the asynchronous operation. - [Test] - public async Task EnsureResponseContentReturnsExistingContent() - { - var original = new StringContent("hi"); - using var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = original }; - - var content = RequestExecutionHelpers.EnsureResponseContent(response); - - await Assert.That(ReferenceEquals(content, original)).IsTrue(); + await Assert.That(result).IsEqualTo("/"); } /// Creates settings backed by the test serializer. @@ -1250,17 +508,25 @@ private sealed class RecordingContentSerializer : IHttpContentSerializer /// Gets the exception thrown from deserialization. public Exception? DeserializeException { get; init; } + /// Gets the exception thrown from serialization. + public Exception? SerializeException { get; init; } + /// public HttpContent ToHttpContent(T item) { SerializeCallCount++; + if (SerializeException is not null) + { + throw SerializeException; + } + return new StringContent($"serialized:{item}"); } /// [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "The method implements Refit's published serializer interface.")] public Task FromHttpContentAsync( HttpContent content, @@ -1297,6 +563,21 @@ protected override bool TryComputeLength(out long length) } } + /// A reference type that renders itself through its format string, exercising the reference-type path of + /// the invariant formatter. + private sealed class FormattableReference : IFormattable + { + /// + public string ToString(string? format, IFormatProvider? formatProvider) => format ?? "reference"; + } + + /// A URL parameter formatter whose Format always yields null. + private sealed class NullUrlParameterFormatter : IUrlParameterFormatter + { + /// + public string? Format(object? value, ICustomAttributeProvider attributeProvider, Type type) => null; + } + /// Declared form model used to verify generated URL-encoded bodies use compile-time metadata. private class DeclaredFormBody { diff --git a/src/tests/Refit.Tests/GeoPoint.cs b/src/tests/Refit.Tests/GeoPoint.cs new file mode 100644 index 000000000..b03fedbd3 --- /dev/null +++ b/src/tests/Refit.Tests/GeoPoint.cs @@ -0,0 +1,10 @@ +// 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; + +/// A nested value type whose scalar properties flatten under the enclosing property's key. +/// The latitude. +/// The longitude. +public readonly record struct GeoPoint(double Lat, double Lng); diff --git a/src/tests/Refit.Tests/GlobalSuppressions.cs b/src/tests/Refit.Tests/GlobalSuppressions.cs deleted file mode 100644 index b2b81302d..000000000 --- a/src/tests/Refit.Tests/GlobalSuppressions.cs +++ /dev/null @@ -1,18 +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; - -[assembly: SuppressMessage( - "CodeQuality", - "IDE0079:Remove unnecessary suppression", - Justification = "Test fixtures retain intentional suppressions that vary across target frameworks.", - Scope = "member", - Target = "T:Refit.Tests.RequestBuilderTests")] -[assembly: SuppressMessage( - "CodeQuality", - "IDE0079:Remove unnecessary suppression", - Justification = "Test fixtures retain intentional suppressions that vary across target frameworks.", - Scope = "member", - Target = "T:Refit.Tests.MultipartTests")] diff --git a/src/tests/Refit.Tests/HeaderCollectionMergeTests.cs b/src/tests/Refit.Tests/HeaderCollectionMergeTests.cs new file mode 100644 index 000000000..38ea10000 --- /dev/null +++ b/src/tests/Refit.Tests/HeaderCollectionMergeTests.cs @@ -0,0 +1,52 @@ +// 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; + +/// Exercises how a header-collection parameter merges into the pending header dictionary. +public sealed class HeaderCollectionMergeTests +{ + /// A header-collection-only method adds its entries to a freshly created header dictionary. + /// A task that represents the asynchronous operation. + [Test] + public async Task HeaderCollectionOnlyMethodCreatesHeaderDictionary() + { + var headers = new Dictionary + { + { "key1", "val1" }, + { "key2", "val2" } + }; + + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IHeaderCollectionMergeApi.PostWithOnlyHeaderCollection)); + var output = await factory([headers]); + + await Assert.That(output.Headers.Contains("key1")).IsTrue(); + await Assert.That(output.Headers.GetValues("key1").First()).IsEqualTo("val1"); + await Assert.That(output.Headers.Contains("key2")).IsTrue(); + await Assert.That(output.Headers.GetValues("key2").First()).IsEqualTo("val2"); + } + + /// A static header already present is merged with, not replaced by, the header collection. + /// A task that represents the asynchronous operation. + [Test] + public async Task HeaderCollectionMergesIntoExistingHeaderDictionary() + { + var headers = new Dictionary + { + { "key1", "val1" } + }; + + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IHeaderCollectionMergeApi.PostWithStaticHeaderAndCollection)); + var output = await factory([headers]); + + await Assert.That(output.Headers.Contains("X-Static")).IsTrue(); + await Assert.That(output.Headers.GetValues("X-Static").First()).IsEqualTo("static-value"); + await Assert.That(output.Headers.Contains("key1")).IsTrue(); + await Assert.That(output.Headers.GetValues("key1").First()).IsEqualTo("val1"); + } +} diff --git a/src/tests/Refit.Tests/Http/Client.cs b/src/tests/Refit.Tests/Http/Client.cs index 1ef558b20..b8158f1f5 100644 --- a/src/tests/Refit.Tests/Http/Client.cs +++ b/src/tests/Refit.Tests/Http/Client.cs @@ -4,6 +4,10 @@ namespace Refit.Tests.Http; /// An HTTP client fixture used to verify unique-name generation across namespaces and nested types. +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "RoslynCommonAnalyzers", + "SST1432:Mark the type as static", + Justification = "Passed to UniqueName.ForType as a type argument; C# forbids static types as type arguments, so this fixture cannot be static.")] public sealed class Client { /// A request fixture nested inside used to verify unique-name generation for nested types. diff --git a/src/tests/Refit.Tests/HttpClientFactoryExtensionsTests.ServiceCollection.cs b/src/tests/Refit.Tests/HttpClientFactoryExtensionsTests.ServiceCollection.cs new file mode 100644 index 000000000..1f720d7bf --- /dev/null +++ b/src/tests/Refit.Tests/HttpClientFactoryExtensionsTests.ServiceCollection.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. +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Refit.Tests; + +/// Tests for the AddRefitClient and AddKeyedRefitClient service-collection registration overloads. +public partial class HttpClientFactoryExtensionsTests +{ + /// Verifies that generic Refit clients registered for different interfaces receive unique client names. + /// A task that represents the asynchronous operation. + [Test] + public async Task GenericHttpClientsAreAssignedUniqueNames() + { + var services = new ServiceCollection(); + + var userClientName = services.AddRefitClient>().Name; + var roleClientName = services.AddRefitClient>().Name; + + await Assert.That(roleClientName).IsNotEqualTo(userClientName); + } + + /// Verifies that the keyed and non-keyed Refit registrations resolve to distinct services and settings. + /// A task that represents the asynchronous operation. + [Test] + public async Task HttpClientServicesAreDifferentThanKeyedServices() + { + var serviceCollection = new ServiceCollection(); + _ = serviceCollection.AddRefitClient(); + _ = serviceCollection.AddKeyedRefitClient(KeyedKey); + + var serviceProvider = serviceCollection.BuildServiceProvider(); + var nonKeyedService = serviceProvider.GetService(); + var keyedService = serviceProvider.GetKeyedService(KeyedKey); + + await Assert.That(nonKeyedService).IsNotNull(); + await Assert.That(keyedService).IsNotNull(); + await Assert.That(keyedService).IsNotSameReferenceAs(nonKeyedService); + + var nonKeyedSettings = serviceProvider.GetService>(); + var keyedSettings = serviceProvider.GetKeyedService>(KeyedKey); + await Assert.That(keyedSettings).IsNotSameReferenceAs(nonKeyedSettings); + + var nonKeyedRequestBuilder = serviceProvider.GetService>(); + var keyedRequestBuilder = serviceProvider.GetKeyedService>(KeyedKey); + await Assert.That(keyedRequestBuilder).IsNotSameReferenceAs(nonKeyedRequestBuilder); + } + + /// Verifies the generic overload registers the settings and request-builder services. + /// A task that represents the asynchronous operation. + [Test] + public async Task HttpClientServicesAreAddedCorrectlyGivenGenericArgument() + { + var serviceCollection = new ServiceCollection(); + _ = serviceCollection.AddRefitClient(); + await Assert.That(serviceCollection).Contains( + static z => z.ServiceType == typeof(SettingsFor)); + await Assert.That(serviceCollection).Contains( + static z => z.ServiceType == typeof(IRequestBuilder)); + } + + /// Verifies the overload registers the settings and request-builder 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 HttpClientServicesAreAddedCorrectlyGivenTypeArgument() + { + var serviceCollection = new ServiceCollection(); + _ = serviceCollection.AddRefitClient(typeof(IFooWithOtherAttribute)); + await Assert.That(serviceCollection).Contains( + static z => z.ServiceType == typeof(SettingsFor)); + await Assert.That(serviceCollection).Contains( + static z => z.ServiceType == typeof(IRequestBuilder)); + } + + /// Verifies the generic overload resolves a usable client instance. + /// A task that represents the asynchronous operation. + [Test] + public async Task HttpClientReturnsClientGivenGenericArgument() + { + var serviceCollection = new ServiceCollection(); + _ = serviceCollection.AddRefitClient(); + var serviceProvider = serviceCollection.BuildServiceProvider(); + await Assert.That(serviceProvider.GetService()).IsNotNull(); + } + + /// Verifies the overload resolves a usable client instance. + /// 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 HttpClientReturnsClientGivenTypeArgument() + { + var serviceCollection = new ServiceCollection(); + _ = serviceCollection.AddRefitClient(typeof(IFooWithOtherAttribute)); + var serviceProvider = serviceCollection.BuildServiceProvider(); + await Assert.That(serviceProvider.GetService()).IsNotNull(); + } + + /// Verifies the generic overload injects settings resolved from the service provider. + /// A task that represents the asynchronous operation. + [Test] + public async Task HttpClientSettingsAreInjectableGivenGenericArgument() + { + var serviceCollection = new ServiceCollection().Configure( + static o => o.Serializer = new(EmptySerializerOptions)); + _ = serviceCollection.AddRefitClient( + static _ => + new() + { + ContentSerializer = _.GetRequiredService< + IOptions>().Value.Serializer! + }); + var serviceProvider = serviceCollection.BuildServiceProvider(); + await Assert.That( + serviceProvider + .GetRequiredService>() + .Settings!.ContentSerializer).IsSameReferenceAs( + serviceProvider.GetRequiredService>().Value.Serializer); + } + + /// Verifies the overload injects settings resolved from the service provider. + /// 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 HttpClientSettingsAreInjectableGivenTypeArgument() + { + var serviceCollection = new ServiceCollection().Configure( + static o => o.Serializer = new(EmptySerializerOptions)); + _ = serviceCollection.AddRefitClient( + typeof(IFooWithOtherAttribute), + static _ => + new() + { + ContentSerializer = _.GetRequiredService< + IOptions>().Value.Serializer! + }); + var serviceProvider = serviceCollection.BuildServiceProvider(); + await Assert.That( + serviceProvider + .GetRequiredService>() + .Settings!.ContentSerializer).IsSameReferenceAs( + serviceProvider.GetRequiredService>().Value.Serializer); + } + + /// Verifies the generic overload uses settings supplied statically at registration time. + /// A task that represents the asynchronous operation. + [Test] + public async Task HttpClientSettingsCanBeProvidedStaticallyGivenGenericArgument() + { + var contentSerializer = new SystemTextJsonContentSerializer(EmptySerializerOptions); + var serviceCollection = new ServiceCollection(); + _ = serviceCollection.AddRefitClient( + new RefitSettings { ContentSerializer = contentSerializer }); + var serviceProvider = serviceCollection.BuildServiceProvider(); + await Assert.That( + serviceProvider + .GetRequiredService>() + .Settings!.ContentSerializer).IsSameReferenceAs(contentSerializer); + } + + /// Verifies the overload uses settings supplied statically at registration time. + /// 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 HttpClientSettingsCanBeProvidedStaticallyGivenTypeArgument() + { + var contentSerializer = new SystemTextJsonContentSerializer(EmptySerializerOptions); + var serviceCollection = new ServiceCollection(); + _ = serviceCollection.AddRefitClient( + typeof(IFooWithOtherAttribute), + new RefitSettings { ContentSerializer = contentSerializer }); + var serviceProvider = serviceCollection.BuildServiceProvider(); + await Assert.That( + serviceProvider + .GetRequiredService>() + .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(EmptySerializerOptions); + var settings = new RefitSettings { ContentSerializer = contentSerializer }; + var services = new ServiceCollection(); + + var builder = services.AddKeyedRefitClient( + ServiceKey, + settings, + ServiceClientName); + + await Assert.That(builder.Name).IsEqualTo(ServiceClientName); + + var serviceProvider = services.BuildServiceProvider(); + await Assert.That( + serviceProvider.GetRequiredKeyedService>(ServiceKey) + .Settings! + .ContentSerializer).IsSameReferenceAs(contentSerializer); + await Assert.That( + serviceProvider.GetRequiredKeyedService>(ServiceKey)) + .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( + static o => o.Serializer = new(EmptySerializerOptions)); + + var builder = services.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + ServiceKey, + static serviceProvider => new() + { + ContentSerializer = serviceProvider.GetRequiredService>().Value.Serializer! + }, + ServiceClientName); + + await Assert.That(builder.Name).IsEqualTo(ServiceClientName); + + var serviceProvider = services.BuildServiceProvider(); + await Assert.That( + serviceProvider.GetRequiredKeyedService>(ServiceKey) + .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(EmptySerializerOptions); + var settings = new RefitSettings { ContentSerializer = contentSerializer }; + var services = new ServiceCollection(); + + _ = services.AddKeyedRefitClient(typeof(IFooWithOtherAttribute), ServiceKey, settings); + + var serviceProvider = services.BuildServiceProvider(); + await Assert.That( + serviceProvider.GetRequiredKeyedService>(ServiceKey) + .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 AssertValidServicesRejectNullInterfaceAndSettings(services); + await AssertValidServicesRejectNullKeyedInterface(services); + + services = null!; + await AssertNullServicesRejectTypeAndGenericOverloads(services); + await AssertNullServicesRejectKeyedOverloads(services); + } + + /// 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, GenericSettingsName); + var genericFactory = services.AddRefitClient( + static _ => new(), + GenericFactoryName); + var typeSettings = services.AddRefitClient( + typeof(IFooWithOtherAttribute), + settings, + TypeSettingsName); + var typeFactory = services.AddRefitClient( + typeof(IFooWithOtherAttribute), + static _ => new(), + TypeFactoryName); + + await Assert.That(genericSettings.Name).IsEqualTo(GenericSettingsName); + await Assert.That(genericFactory.Name).IsEqualTo(GenericFactoryName); + await Assert.That(typeSettings.Name).IsEqualTo(TypeSettingsName); + await Assert.That(typeFactory.Name).IsEqualTo(TypeFactoryName); + } + + /// 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(EmptySerializerOptions); + var genericFactorySerializer = new SystemTextJsonContentSerializer(EmptySerializerOptions); + var typeNamedSerializer = new SystemTextJsonContentSerializer(EmptySerializerOptions); + var typeFactorySerializer = new SystemTextJsonContentSerializer(EmptySerializerOptions); + var services = new ServiceCollection(); + + var genericNoSettings = services.AddKeyedRefitClient("generic-none"); + _ = services.AddKeyedRefitClient( + GenericSettingsName, + new RefitSettings { ContentSerializer = genericSettingsSerializer }); + var genericNamedSettings = services.AddKeyedRefitClient( + "generic-settings-named", + new RefitSettings(), + "generic-settings-client"); + _ = services.AddKeyedRefitClient( + GenericFactoryName, + static _ => new()); + var genericNamedFactory = services.AddKeyedRefitClient( + "generic-factory-named", + _ => new() { ContentSerializer = genericFactorySerializer }, + "generic-factory-client"); + _ = services.AddKeyedRefitClient(typeof(IFooWithOtherAttribute), TypeNoneKey); + var typeNamedSettings = services.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + "type-settings-named", + new RefitSettings { ContentSerializer = typeNamedSerializer }, + "type-settings-client"); + _ = services.AddKeyedRefitClient( + typeof(IFooWithOtherAttribute), + TypeFactoryName, + _ => 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>(GenericSettingsName) + .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>(TypeFactoryName) + .Settings! + .ContentSerializer).IsSameReferenceAs(typeFactorySerializer); + await Assert.That( + serviceProvider.GetRequiredKeyedService>(TypeNoneKey)) + .IsNotNull(); + await Assert.That(serviceProvider.GetRequiredKeyedService(TypeNoneKey)).IsNotNull(); + } + + /// Verifies the generic (IServiceCollection, RefitSettings) overload still exists for binary compatibility. + /// A task that represents the asynchronous operation. + [Test] + public async Task AddRefitClient_ServiceCollectionGenericSettingsOverload_RemainsBinaryCompatible() + { + var method = typeof(HttpClientFactoryExtensions) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .SingleOrDefault(static method => + method.Name == nameof(HttpClientFactoryExtensions.AddRefitClient) + && method.IsGenericMethodDefinition + && method.GetGenericArguments().Length == 1 + && method.GetParameters() is + [ + { ParameterType: var servicesType }, + { ParameterType: var settingsType } + ] + && servicesType == typeof(IServiceCollection) + && settingsType == typeof(RefitSettings)); + + await Assert.That(method).IsNotNull(); + } + + /// Verifies the (IServiceCollection, Type, RefitSettings) overload still exists for binary compatibility. + /// A task that represents the asynchronous operation. + [Test] + public async Task AddRefitClient_ServiceCollectionTypeSettingsOverload_RemainsBinaryCompatible() + { + var method = typeof(HttpClientFactoryExtensions) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .SingleOrDefault(static method => + method.Name == nameof(HttpClientFactoryExtensions.AddRefitClient) + && !method.IsGenericMethodDefinition + && method.GetParameters() is + [ + { ParameterType: var servicesType }, + { ParameterType: var refitInterfaceType }, + { ParameterType: var settingsType } + ] + && servicesType == typeof(IServiceCollection) + && refitInterfaceType == typeof(Type) + && settingsType == typeof(RefitSettings)); + + await Assert.That(method).IsNotNull(); + } +} diff --git a/src/tests/Refit.Tests/HttpClientFactoryExtensionsTests.cs b/src/tests/Refit.Tests/HttpClientFactoryExtensionsTests.cs index bd00b10ef..daa739240 100644 --- a/src/tests/Refit.Tests/HttpClientFactoryExtensionsTests.cs +++ b/src/tests/Refit.Tests/HttpClientFactoryExtensionsTests.cs @@ -5,7 +5,7 @@ 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; @@ -48,357 +48,14 @@ public partial class HttpClientFactoryExtensionsTests /// The name of a pre-registered HTTP client honored by the generated Refit client. private const string MyHttpClientName = "MyHttpClient"; - /// Verifies that generic Refit clients registered for different interfaces receive unique client names. - /// A task that represents the asynchronous operation. - [Test] - public async Task GenericHttpClientsAreAssignedUniqueNames() - { - var services = new ServiceCollection(); - - var userClientName = services.AddRefitClient>().Name; - var roleClientName = services.AddRefitClient>().Name; - - await Assert.That(roleClientName).IsNotEqualTo(userClientName); - } - - /// Verifies that the keyed and non-keyed Refit registrations resolve to distinct services and settings. - /// A task that represents the asynchronous operation. - [Test] - public async Task HttpClientServicesAreDifferentThanKeyedServices() - { - var serviceCollection = new ServiceCollection(); - _ = serviceCollection.AddRefitClient(); - _ = serviceCollection.AddKeyedRefitClient(KeyedKey); - - var serviceProvider = serviceCollection.BuildServiceProvider(); - var nonKeyedService = serviceProvider.GetService(); - var keyedService = serviceProvider.GetKeyedService(KeyedKey); - - await Assert.That(nonKeyedService).IsNotNull(); - await Assert.That(keyedService).IsNotNull(); - await Assert.That(keyedService).IsNotSameReferenceAs(nonKeyedService); - - var nonKeyedSettings = serviceProvider.GetService>(); - var keyedSettings = serviceProvider.GetKeyedService>(KeyedKey); - await Assert.That(keyedSettings).IsNotSameReferenceAs(nonKeyedSettings); - - var nonKeyedRequestBuilder = serviceProvider.GetService>(); - var keyedRequestBuilder = serviceProvider.GetKeyedService>(KeyedKey); - await Assert.That(keyedRequestBuilder).IsNotSameReferenceAs(nonKeyedRequestBuilder); - } - - /// Verifies the generic overload registers the settings and request-builder services. - /// A task that represents the asynchronous operation. - [Test] - public async Task HttpClientServicesAreAddedCorrectlyGivenGenericArgument() - { - var serviceCollection = new ServiceCollection(); - _ = serviceCollection.AddRefitClient(); - await Assert.That(serviceCollection).Contains( - static z => z.ServiceType == typeof(SettingsFor)); - await Assert.That(serviceCollection).Contains( - static z => z.ServiceType == typeof(IRequestBuilder)); - } - - /// Verifies the overload registers the settings and request-builder 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 HttpClientServicesAreAddedCorrectlyGivenTypeArgument() - { - var serviceCollection = new ServiceCollection(); - _ = serviceCollection.AddRefitClient(typeof(IFooWithOtherAttribute)); - await Assert.That(serviceCollection).Contains( - static z => z.ServiceType == typeof(SettingsFor)); - await Assert.That(serviceCollection).Contains( - static z => z.ServiceType == typeof(IRequestBuilder)); - } - - /// Verifies the generic overload resolves a usable client instance. - /// A task that represents the asynchronous operation. - [Test] - public async Task HttpClientReturnsClientGivenGenericArgument() - { - var serviceCollection = new ServiceCollection(); - _ = serviceCollection.AddRefitClient(); - var serviceProvider = serviceCollection.BuildServiceProvider(); - await Assert.That(serviceProvider.GetService()).IsNotNull(); - } - - /// Verifies the overload resolves a usable client instance. - /// 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 HttpClientReturnsClientGivenTypeArgument() - { - var serviceCollection = new ServiceCollection(); - _ = serviceCollection.AddRefitClient(typeof(IFooWithOtherAttribute)); - var serviceProvider = serviceCollection.BuildServiceProvider(); - await Assert.That(serviceProvider.GetService()).IsNotNull(); - } - - /// Verifies the generic overload injects settings resolved from the service provider. - /// A task that represents the asynchronous operation. - [Test] - public async Task HttpClientSettingsAreInjectableGivenGenericArgument() - { - var serviceCollection = new ServiceCollection().Configure( - static o => o.Serializer = new(new())); - _ = serviceCollection.AddRefitClient( - static _ => - new() - { - ContentSerializer = _.GetRequiredService< - IOptions>().Value.Serializer! - }); - var serviceProvider = serviceCollection.BuildServiceProvider(); - await Assert.That( - serviceProvider - .GetRequiredService>() - .Settings!.ContentSerializer).IsSameReferenceAs( - serviceProvider.GetRequiredService>().Value.Serializer); - } - - /// Verifies the overload injects settings resolved from the service provider. - /// 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 HttpClientSettingsAreInjectableGivenTypeArgument() - { - var serviceCollection = new ServiceCollection().Configure( - static o => o.Serializer = new(new())); - _ = serviceCollection.AddRefitClient( - typeof(IFooWithOtherAttribute), - static _ => - new() - { - ContentSerializer = _.GetRequiredService< - IOptions>().Value.Serializer! - }); - var serviceProvider = serviceCollection.BuildServiceProvider(); - await Assert.That( - serviceProvider - .GetRequiredService>() - .Settings!.ContentSerializer).IsSameReferenceAs( - serviceProvider.GetRequiredService>().Value.Serializer); - } - - /// Verifies the generic overload uses settings supplied statically at registration time. - /// A task that represents the asynchronous operation. - [Test] - public async Task HttpClientSettingsCanBeProvidedStaticallyGivenGenericArgument() - { - var contentSerializer = new SystemTextJsonContentSerializer(new()); - var serviceCollection = new ServiceCollection(); - _ = serviceCollection.AddRefitClient( - new RefitSettings { ContentSerializer = contentSerializer }); - var serviceProvider = serviceCollection.BuildServiceProvider(); - await Assert.That( - serviceProvider - .GetRequiredService>() - .Settings!.ContentSerializer).IsSameReferenceAs(contentSerializer); - } - - /// Verifies the overload uses settings supplied statically at registration time. - /// 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 HttpClientSettingsCanBeProvidedStaticallyGivenTypeArgument() - { - var contentSerializer = new SystemTextJsonContentSerializer(new()); - var serviceCollection = new ServiceCollection(); - _ = serviceCollection.AddRefitClient( - typeof(IFooWithOtherAttribute), - new RefitSettings { ContentSerializer = contentSerializer }); - var serviceProvider = serviceCollection.BuildServiceProvider(); - await Assert.That( - serviceProvider - .GetRequiredService>() - .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( - ServiceKey, - settings, - ServiceClientName); - - await Assert.That(builder.Name).IsEqualTo(ServiceClientName); - - var serviceProvider = services.BuildServiceProvider(); - await Assert.That( - serviceProvider.GetRequiredKeyedService>(ServiceKey) - .Settings! - .ContentSerializer).IsSameReferenceAs(contentSerializer); - await Assert.That( - serviceProvider.GetRequiredKeyedService>(ServiceKey)) - .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( - static o => o.Serializer = new(new())); - - var builder = services.AddKeyedRefitClient( - typeof(IFooWithOtherAttribute), - ServiceKey, - static serviceProvider => new() - { - ContentSerializer = serviceProvider.GetRequiredService>().Value.Serializer! - }, - ServiceClientName); - - await Assert.That(builder.Name).IsEqualTo(ServiceClientName); - - var serviceProvider = services.BuildServiceProvider(); - await Assert.That( - serviceProvider.GetRequiredKeyedService>(ServiceKey) - .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), ServiceKey, settings); - - var serviceProvider = services.BuildServiceProvider(); - await Assert.That( - serviceProvider.GetRequiredKeyedService>(ServiceKey) - .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(); + /// Client name shared by the builder-matrix registration tests. + private const string BuilderMatrixName = "builder-matrix"; - await AssertValidServicesRejectNullInterfaceAndSettings(services); - await AssertValidServicesRejectNullKeyedInterface(services); + /// Header name asserted by the default-request-header tests. + private const string PoweredByHeaderName = "X-Powered-By"; - services = null!; - await AssertNullServicesRejectTypeAndGenericOverloads(services); - await AssertNullServicesRejectKeyedOverloads(services); - } - - /// 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, GenericSettingsName); - var genericFactory = services.AddRefitClient( - static _ => new(), - GenericFactoryName); - var typeSettings = services.AddRefitClient( - typeof(IFooWithOtherAttribute), - settings, - TypeSettingsName); - var typeFactory = services.AddRefitClient( - typeof(IFooWithOtherAttribute), - static _ => new(), - TypeFactoryName); - - await Assert.That(genericSettings.Name).IsEqualTo(GenericSettingsName); - await Assert.That(genericFactory.Name).IsEqualTo(GenericFactoryName); - await Assert.That(typeSettings.Name).IsEqualTo(TypeSettingsName); - await Assert.That(typeFactory.Name).IsEqualTo(TypeFactoryName); - } - - /// 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( - GenericSettingsName, - new RefitSettings { ContentSerializer = genericSettingsSerializer }); - var genericNamedSettings = services.AddKeyedRefitClient( - "generic-settings-named", - new RefitSettings(), - "generic-settings-client"); - _ = services.AddKeyedRefitClient( - GenericFactoryName, - static _ => new()); - var genericNamedFactory = services.AddKeyedRefitClient( - "generic-factory-named", - _ => new() { ContentSerializer = genericFactorySerializer }, - "generic-factory-client"); - _ = services.AddKeyedRefitClient(typeof(IFooWithOtherAttribute), TypeNoneKey); - var typeNamedSettings = services.AddKeyedRefitClient( - typeof(IFooWithOtherAttribute), - "type-settings-named", - new RefitSettings { ContentSerializer = typeNamedSerializer }, - "type-settings-client"); - _ = services.AddKeyedRefitClient( - typeof(IFooWithOtherAttribute), - TypeFactoryName, - _ => 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>(GenericSettingsName) - .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>(TypeFactoryName) - .Settings! - .ContentSerializer).IsSameReferenceAs(typeFactorySerializer); - await Assert.That( - serviceProvider.GetRequiredKeyedService>(TypeNoneKey)) - .IsNotNull(); - await Assert.That(serviceProvider.GetRequiredKeyedService(TypeNoneKey)).IsNotNull(); - } + /// Empty serializer options shared by the DI-registration fixtures, which never serialize through them. + private static readonly JsonSerializerOptions EmptySerializerOptions = new(); /// Verifies the generic overload keeps the existing named client and registers Refit services. /// A task that represents the asynchronous operation. @@ -423,7 +80,7 @@ await Assert.That( [Test] public async Task HttpClientBuilderGenericSettingsOverloadUsesStaticSettings() { - var contentSerializer = new SystemTextJsonContentSerializer(new()); + var contentSerializer = new SystemTextJsonContentSerializer(EmptySerializerOptions); var settings = new RefitSettings { ContentSerializer = contentSerializer }; var services = new ServiceCollection(); var builder = services.AddHttpClient("builder-settings"); @@ -443,7 +100,7 @@ await Assert.That( public async Task HttpClientBuilderGenericSettingsFactoryOverloadUsesServiceProvider() { var services = new ServiceCollection().Configure( - static o => o.Serializer = new(new())); + static o => o.Serializer = new(EmptySerializerOptions)); var builder = services.AddHttpClient("builder-settings-factory"); _ = builder.AddRefitClient( @@ -466,7 +123,7 @@ await Assert.That( [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 contentSerializer = new SystemTextJsonContentSerializer(EmptySerializerOptions); var settings = new RefitSettings { ContentSerializer = contentSerializer }; var services = new ServiceCollection(); var builder = services.AddHttpClient("builder-type-settings"); @@ -487,7 +144,7 @@ await Assert.That( public async Task HttpClientBuilderTypeSettingsFactoryOverloadUsesServiceProvider() { var services = new ServiceCollection().Configure( - static o => o.Serializer = new(new())); + static o => o.Serializer = new(EmptySerializerOptions)); var builder = services.AddHttpClient("builder-type-settings-factory"); _ = builder.AddRefitClient( @@ -529,7 +186,7 @@ await Assert.That( [Test] public async Task HttpClientBuilderKeyedGenericSettingsOverloadUsesStaticSettings() { - var contentSerializer = new SystemTextJsonContentSerializer(new()); + var contentSerializer = new SystemTextJsonContentSerializer(EmptySerializerOptions); var settings = new RefitSettings { ContentSerializer = contentSerializer }; var services = new ServiceCollection(); var builder = services.AddHttpClient("builder-keyed-settings"); @@ -550,7 +207,7 @@ await Assert.That( public async Task HttpClientBuilderKeyedTypeSettingsFactoryOverloadUsesServiceProvider() { var services = new ServiceCollection().Configure( - static o => o.Serializer = new(new())); + static o => o.Serializer = new(EmptySerializerOptions)); var builder = services.AddHttpClient("builder-keyed-type-settings-factory"); _ = builder.AddKeyedRefitClient( @@ -589,11 +246,11 @@ public async Task HttpClientBuilderOverloadsValidateRequiredArguments() [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 typeSettingsSerializer = new SystemTextJsonContentSerializer(EmptySerializerOptions); + var keyedTypeSettingsSerializer = new SystemTextJsonContentSerializer(EmptySerializerOptions); + var keyedGenericFactorySerializer = new SystemTextJsonContentSerializer(EmptySerializerOptions); var services = new ServiceCollection(); - var builder = services.AddHttpClient("builder-matrix"); + var builder = services.AddHttpClient(BuilderMatrixName); var typeBuilder = builder.AddRefitClient(typeof(IFooWithOtherAttribute)); _ = builder.AddRefitClient(static _ => new()); @@ -609,8 +266,8 @@ public async Task HttpClientBuilderOverloadMatrixRegistersServices() GenericFactoryName, _ => new() { ContentSerializer = keyedGenericFactorySerializer }); - await Assert.That(typeBuilder.Name).IsEqualTo("builder-matrix"); - await Assert.That(keyedTypeBuilder.Name).IsEqualTo("builder-matrix"); + await Assert.That(typeBuilder.Name).IsEqualTo(BuilderMatrixName); + await Assert.That(keyedTypeBuilder.Name).IsEqualTo(BuilderMatrixName); var serviceProvider = services.BuildServiceProvider(); await Assert.That( @@ -630,51 +287,6 @@ await Assert.That( .IsNotNull(); } - /// Verifies the generic (IServiceCollection, RefitSettings) overload still exists for binary compatibility. - /// A task that represents the asynchronous operation. - [Test] - public async Task AddRefitClient_ServiceCollectionGenericSettingsOverload_RemainsBinaryCompatible() - { - var method = typeof(HttpClientFactoryExtensions) - .GetMethods(BindingFlags.Public | BindingFlags.Static) - .SingleOrDefault(static method => - method.Name == nameof(HttpClientFactoryExtensions.AddRefitClient) - && method.IsGenericMethodDefinition - && method.GetGenericArguments().Length == 1 - && method.GetParameters() is - [ - { ParameterType: var servicesType }, - { ParameterType: var settingsType } - ] - && servicesType == typeof(IServiceCollection) - && settingsType == typeof(RefitSettings)); - - await Assert.That(method).IsNotNull(); - } - - /// Verifies the (IServiceCollection, Type, RefitSettings) overload still exists for binary compatibility. - /// A task that represents the asynchronous operation. - [Test] - public async Task AddRefitClient_ServiceCollectionTypeSettingsOverload_RemainsBinaryCompatible() - { - var method = typeof(HttpClientFactoryExtensions) - .GetMethods(BindingFlags.Public | BindingFlags.Static) - .SingleOrDefault(static method => - method.Name == nameof(HttpClientFactoryExtensions.AddRefitClient) - && !method.IsGenericMethodDefinition - && method.GetParameters() is - [ - { ParameterType: var servicesType }, - { ParameterType: var refitInterfaceType }, - { ParameterType: var settingsType } - ] - && servicesType == typeof(IServiceCollection) - && refitInterfaceType == typeof(Type) - && settingsType == typeof(RefitSettings)); - - await Assert.That(method).IsNotNull(); - } - /// Verifies a pre-registered named is honored by the generated Refit client. /// A task that represents the asynchronous operation. [Test] @@ -686,7 +298,7 @@ public async Task ProvidedHttpClientIsUsedAsNamedClient() _ = services.AddHttpClient(MyHttpClientName, client => { client.BaseAddress = baseUri; - client.DefaultRequestHeaders.Add("X-Powered-By", Environment.OSVersion.VersionString); + client.DefaultRequestHeaders.Add(PoweredByHeaderName, Environment.OSVersion.VersionString); }); var refitBuilder = services.AddRefitClient(settingsAction: null, MyHttpClientName); @@ -700,7 +312,7 @@ public async Task ProvidedHttpClientIsUsedAsNamedClient() await Assert.That(gitHubApi).IsNotNull(); await Assert.That(httpClient.BaseAddress).IsEqualTo(baseUri); await Assert.That(httpClient.DefaultRequestHeaders).Contains( - h => h.Key == "X-Powered-By" + static h => h.Key == PoweredByHeaderName && h.Value.Contains(Environment.OSVersion.VersionString)); } @@ -712,19 +324,19 @@ public async Task CoreRegistrationsRejectNullInputs() { var services = new ServiceCollection(); - await Assert.That(() => InvokeAddRefitClientCore(null!, typeof(IFooWithOtherAttribute), null, null)) + await Assert.That(static () => InvokeAddRefitClientCore(null!, typeof(IFooWithOtherAttribute), null, null)) .ThrowsExactly(); await Assert.That(() => InvokeAddRefitClientCore(services, null!, null, null)) .ThrowsExactly(); - await Assert.That(() => InvokeAddRefitClientCoreGeneric(null!, null, null)) + await Assert.That(static () => InvokeAddRefitClientCoreGeneric(null!, null, null)) .ThrowsExactly(); - await Assert.That(() => InvokeAddKeyedRefitClientCore(null!, typeof(IFooWithOtherAttribute), "key", null, null)) + await Assert.That(static () => 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)) + await Assert.That(static () => InvokeAddKeyedRefitClientCoreGeneric(null!, "key", null, null)) .ThrowsExactly(); await Assert.That(() => InvokeAddKeyedRefitClientCoreGeneric(services, null, null, null)) .ThrowsExactly(); @@ -741,7 +353,7 @@ public async Task NonKeyedRegistrationComposesConfiguredHandlerAndAuthorizationG new RefitSettings { HttpMessageHandlerFactory = () => recordingHandler, - AuthorizationHeaderValueGetter = static (_, _) => Task.FromResult("token") + AuthorizationHeaderValueGetter = static (_, _) => new ValueTask("token") }); var serviceProvider = services.BuildServiceProvider(); var client = serviceProvider.GetRequiredService().CreateClient(builder.Name); @@ -765,7 +377,7 @@ public async Task KeyedRegistrationComposesConfiguredHandlerAndAuthorizationGett new RefitSettings { HttpMessageHandlerFactory = () => recordingHandler, - AuthorizationHeaderValueGetter = static (_, _) => Task.FromResult("keyed-token") + AuthorizationHeaderValueGetter = static (_, _) => new ValueTask("keyed-token") }); var serviceProvider = services.BuildServiceProvider(); var client = serviceProvider.GetRequiredService().CreateClient(builder.Name); @@ -905,8 +517,8 @@ private static void InvokeAddRefitClientCore( /// The settings factory argument. /// The client name argument. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter is intentionally specified explicitly by the test caller.")] private static void InvokeAddRefitClientCoreGeneric( IServiceCollection services, @@ -936,8 +548,8 @@ private static void InvokeAddKeyedRefitClientCore( /// The settings factory argument. /// The client name argument. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Type parameter is intentionally specified explicitly by the test caller.")] private static void InvokeAddKeyedRefitClientCoreGeneric( IServiceCollection services, @@ -987,7 +599,7 @@ protected override Task SendAsync( { AuthorizationParameter = request.Headers.Authorization?.Parameter; RequestUri = request.RequestUri; - PoweredByHeader = request.Headers.TryGetValues("X-Powered-By", out var values) + PoweredByHeader = request.Headers.TryGetValues(PoweredByHeaderName, out var values) ? values.FirstOrDefault() : null; return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); diff --git a/src/tests/Refit.Tests/IAmInterfaceEWithNoRefit.cs b/src/tests/Refit.Tests/IAmInterfaceEWithNoRefit.cs index ae46ccdcc..a1b3f1ddc 100644 --- a/src/tests/Refit.Tests/IAmInterfaceEWithNoRefit.cs +++ b/src/tests/Refit.Tests/IAmInterfaceEWithNoRefit.cs @@ -14,10 +14,10 @@ public interface IAmInterfaceEWithNoRefit /// An arbitrary parameter. /// A task representing the operation. [SuppressMessage("Refit", "RF001", Justification = "Intentional non-Refit fixture used to verify generator diagnostics.")] - public Task DoSomething(T parameter); + Task DoSomething(T parameter); /// A non-Refit method; intentionally has no HTTP attribute. /// A task representing the operation. [SuppressMessage("Refit", "RF001", Justification = "Intentional non-Refit fixture used to verify generator diagnostics.")] - public Task DoSomethingElse(); + Task DoSomethingElse(); } diff --git a/src/tests/Refit.Tests/IApiBindPathToObject.cs b/src/tests/Refit.Tests/IApiBindPathToObject.cs index 92b965d93..d213e7d52 100644 --- a/src/tests/Refit.Tests/IApiBindPathToObject.cs +++ b/src/tests/Refit.Tests/IApiBindPathToObject.cs @@ -30,6 +30,13 @@ public interface IApiBindPathToObject [Get("/foos/{request.someProperty}/bar/{request.someProperty2}")] Task GetFooBars(PathBoundObjectWithQuery request); + /// Gets foo bars binding a generic request object's properties to the path, resolved at the runtime type. + /// The request type, known only at call time. + /// The request whose properties bind to the path. + /// A task that completes when the request finishes. + [Get("/foos/{request.someProperty}/bar/{request.someProperty2}")] + Task GetFooBarsGeneric(T request); + /// Gets foo bars where the path tokens use different casing from the property names. /// The request whose properties bind to the path. /// A task that completes when the request finishes. diff --git a/src/tests/Refit.Tests/IAuthenticatedCancellableMethods.cs b/src/tests/Refit.Tests/IAuthenticatedCancellableMethods.cs index 8365135fb..b7328e70f 100644 --- a/src/tests/Refit.Tests/IAuthenticatedCancellableMethods.cs +++ b/src/tests/Refit.Tests/IAuthenticatedCancellableMethods.cs @@ -13,8 +13,8 @@ public interface IAuthenticatedCancellableMethods /// A token used to cancel the request. /// A task that represents the asynchronous operation. [SuppressMessage( - "Major Code Smell", - "S2360:Optional parameters should not be used", + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", Justification = "The optional CancellationToken is the idiomatic Refit signature exercised by these tests.")] [Headers("Authorization: Bearer")] [Get("/foo")] diff --git a/src/tests/Refit.Tests/IAuthorizeHeaderApi.cs b/src/tests/Refit.Tests/IAuthorizeHeaderApi.cs new file mode 100644 index 000000000..5dfc6c6a4 --- /dev/null +++ b/src/tests/Refit.Tests/IAuthorizeHeaderApi.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. + +namespace Refit.Tests; + +/// A Refit interface with an authorization parameter, exercised through the reflection builder. +public interface IAuthorizeHeaderApi +{ + /// Sends a request carrying a bearer token. + /// The bearer token value. + /// The response body. + [Get("/secure")] + Task Get([Authorize("Bearer")] string token); +} diff --git a/src/tests/Refit.Tests/IBodySerializationApi.cs b/src/tests/Refit.Tests/IBodySerializationApi.cs new file mode 100644 index 000000000..c28b56d10 --- /dev/null +++ b/src/tests/Refit.Tests/IBodySerializationApi.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. + +namespace Refit.Tests; + +/// A Refit interface with a serialized body parameter, exercised through the reflection builder. +public interface IBodySerializationApi +{ + /// Posts a serialized body. + /// The body payload. + /// A task that completes when the request finishes. + [Post("/upload")] + Task Post([Body] BodyPayload payload); +} diff --git a/src/tests/Refit.Tests/IBufferedBodyApi.cs b/src/tests/Refit.Tests/IBufferedBodyApi.cs new file mode 100644 index 000000000..89c23cf95 --- /dev/null +++ b/src/tests/Refit.Tests/IBufferedBodyApi.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. + +namespace Refit.Tests; + +/// A Refit interface with a buffered body parameter, exercised through the reflection builder. +public interface IBufferedBodyApi +{ + /// Posts a buffered serialized body. + /// The body payload. + /// A task that completes when the request finishes. + [Post("/buffered")] + Task Post([Body(true)] BodyPayload payload); +} diff --git a/src/tests/Refit.Tests/ICancellableApi.cs b/src/tests/Refit.Tests/ICancellableApi.cs index 4a0b4167a..3dc6627bf 100644 --- a/src/tests/Refit.Tests/ICancellableApi.cs +++ b/src/tests/Refit.Tests/ICancellableApi.cs @@ -14,14 +14,20 @@ public interface ICancellableApi /// Gets with a cancellation token. /// A token used to cancel the request. /// A task that completes when the request finishes. - [SuppressMessage("Major Code Smell", "S2360:Optional parameters should not be used", Justification = "The optional CancellationToken is the idiomatic Refit signature this test verifies.")] + [SuppressMessage( + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", + Justification = "The optional CancellationToken is the idiomatic Refit signature this test verifies.")] [Get("/foo")] Task GetWithCancellation(CancellationToken token = default); /// Gets with a cancellation token and a string return value. /// A token used to cancel the request. /// The response body as a string. - [SuppressMessage("Major Code Smell", "S2360:Optional parameters should not be used", Justification = "The optional CancellationToken is the idiomatic Refit signature this test verifies.")] + [SuppressMessage( + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", + Justification = "The optional CancellationToken is the idiomatic Refit signature this test verifies.")] [Get("/foo")] Task GetWithCancellationAndReturn(CancellationToken token = default); diff --git a/src/tests/Refit.Tests/ICancellableMethods.cs b/src/tests/Refit.Tests/ICancellableMethods.cs index cd99b9f75..7d5411d7e 100644 --- a/src/tests/Refit.Tests/ICancellableMethods.cs +++ b/src/tests/Refit.Tests/ICancellableMethods.cs @@ -12,14 +12,20 @@ public interface ICancellableMethods /// Gets a resource supporting cancellation. /// A token used to cancel the request. /// A task that represents the asynchronous operation. - [SuppressMessage("Major Code Smell", "S2360:Optional parameters should not be used", Justification = "The optional CancellationToken is the idiomatic Refit signature this test verifies.")] + [SuppressMessage( + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", + Justification = "The optional CancellationToken is the idiomatic Refit signature this test verifies.")] [Get("/foo")] Task GetWithCancellation(CancellationToken token = default); /// Gets a resource supporting cancellation and returning content. /// A token used to cancel the request. /// A task that resolves to the response content. - [SuppressMessage("Major Code Smell", "S2360:Optional parameters should not be used", Justification = "The optional CancellationToken is the idiomatic Refit signature this test verifies.")] + [SuppressMessage( + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", + Justification = "The optional CancellationToken is the idiomatic Refit signature this test verifies.")] [Get("/foo")] Task GetWithCancellationAndReturn(CancellationToken token = default); @@ -27,7 +33,10 @@ public interface ICancellableMethods /// The identifier of the resource to fetch. /// An optional token used to cancel the request. /// A task that represents the asynchronous operation. - [SuppressMessage("Major Code Smell", "S2360:Optional parameters should not be used", Justification = "The optional CancellationToken is the idiomatic Refit signature this test verifies.")] + [SuppressMessage( + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", + Justification = "The optional CancellationToken is the idiomatic Refit signature this test verifies.")] [Get("/foo/{id}")] Task GetWithNullableCancellation(int id, CancellationToken? token = default); } diff --git a/src/tests/Refit.Tests/IConverterApi.cs b/src/tests/Refit.Tests/IConverterApi.cs new file mode 100644 index 000000000..8cf218d3c --- /dev/null +++ b/src/tests/Refit.Tests/IConverterApi.cs @@ -0,0 +1,28 @@ +// 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; + +/// An API whose query parameters are flattened by a hand-written . +public interface IConverterApi +{ + /// Flattens an object-valued dictionary through a converter. + /// The filter dictionary. + /// The response body. + [Get("/search")] + Task Search([QueryConverter(typeof(DictionaryObjectQueryConverter))] IDictionary filter); + + /// Flattens an object-valued dictionary under a parameter-level prefix. + /// The filter dictionary. + /// The response body. + [Get("/prefixed")] + Task SearchPrefixed( + [Query(".", "f")][QueryConverter(typeof(DictionaryObjectQueryConverter))] IDictionary filter); + + /// Flattens a POCO through the System.Text.Json interop converter. + /// The filter object. + /// The response body. + [Get("/stj")] + Task SearchStj([QueryConverter(typeof(SystemTextJsonQueryConverter))] StjFilter filter); +} diff --git a/src/tests/Refit.Tests/IDataMosApi.cs b/src/tests/Refit.Tests/IDataMosApi.cs index 1b361f17a..cd5b27578 100644 --- a/src/tests/Refit.Tests/IDataMosApi.cs +++ b/src/tests/Refit.Tests/IDataMosApi.cs @@ -14,8 +14,8 @@ public interface IDataMosApi /// The dataset rows. [Get("/datasets/{dataSet}/rows")] [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters to enable type inference", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Intentional test fixture exercising Refit generation of generic methods with unused type parameters.")] Task[]> GetDataSetItems() where TResultRow : class, new(); diff --git a/src/tests/Refit.Tests/IDeferredCallApi.cs b/src/tests/Refit.Tests/IDeferredCallApi.cs new file mode 100644 index 000000000..98545ec41 --- /dev/null +++ b/src/tests/Refit.Tests/IDeferredCallApi.cs @@ -0,0 +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. + +namespace Refit.Tests; + +/// A Refit interface whose method surfaces the custom return shape. +public interface IDeferredCallApi +{ + /// Gets a user by id as a deferred call. + /// The user id. + /// A deferred call producing the user. + [Get("/users/{id}")] + DeferredCall GetUser(int id); + + /// Gets a user by id as a deferred call, threading a method cancellation token. + /// The user id. + /// The cancellation token linked into the deferred invocation. + /// A deferred call producing the user. + [Get("/users/{id}")] + DeferredCall GetUserCancellable(int id, CancellationToken token); +} diff --git a/src/tests/Refit.Tests/IDiscriminatedShape.cs b/src/tests/Refit.Tests/IDiscriminatedShape.cs new file mode 100644 index 000000000..364515a2f --- /dev/null +++ b/src/tests/Refit.Tests/IDiscriminatedShape.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. + +using System.Text.Json.Serialization; + +namespace Refit.Tests; + +/// A polymorphic interface declaring a derived type but no . +[JsonDerivedType(typeof(DiscriminatedCircle), "circle")] +public interface IDiscriminatedShape +{ + /// Gets or sets the shape label shared by every derived shape. + string? Label { get; set; } +} diff --git a/src/tests/Refit.Tests/IDummyHttpApi.cs b/src/tests/Refit.Tests/IDummyHttpApi.cs index feb17f802..c7dbd1b73 100644 --- a/src/tests/Refit.Tests/IDummyHttpApi.cs +++ b/src/tests/Refit.Tests/IDummyHttpApi.cs @@ -591,8 +591,8 @@ Task QueryWithEnumerableFormattedAsPipes( /// The optional filters formatted as multiple values. /// A task that represents the asynchronous operation. [SuppressMessage( - "Major Code Smell", - "S2360:Optional parameters should not be used", + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", Justification = "Refit interface stub intentionally uses optional parameters to exercise default-value handling.")] [Get("/api/{id}")] Task QueryWithOptionalParameters( @@ -631,8 +631,8 @@ Task QueryWithOptionalParameters( /// The optional file metadata. /// A task whose result is the API response. [SuppressMessage( - "Major Code Smell", - "S2360:Optional parameters should not be used", + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", Justification = "Refit interface stub intentionally uses optional parameters to exercise default-value handling.")] [Multipart] [Post("/companies/{companyId}/{path}")] @@ -676,8 +676,8 @@ Task ComplexTypeQueryParameterLevelMulti( /// The optional filters formatted as multiple values. /// A task that represents the asynchronous operation. [SuppressMessage( - "Major Code Smell", - "S2360:Optional parameters should not be used", + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", Justification = "Refit interface stub intentionally uses optional parameters to exercise default-value handling.")] [Get("/api/{obj.someProperty}")] Task QueryWithOptionalParametersPathBoundObject( diff --git a/src/tests/Refit.Tests/IGeneratedFactoryApi.cs b/src/tests/Refit.Tests/IGeneratedFactoryApi.cs index 0ff15e4d3..c0defc250 100644 --- a/src/tests/Refit.Tests/IGeneratedFactoryApi.cs +++ b/src/tests/Refit.Tests/IGeneratedFactoryApi.cs @@ -20,9 +20,10 @@ public interface IGeneratedFactoryApi [Get("/generated/{id}")] Task GetById(string id); - /// Gets the generated endpoint with a query parameter. - /// The generated endpoint identifier. + /// Searches the generated endpoint; the dynamic query-map parameter keeps this interface on the + /// reflection request builder so no generated settings factory is registered for it. + /// A dynamic query-map filter that is not inline-eligible. /// A task that completes when the request finishes. [Get("/generated")] - Task GetQuery(string id); + Task Search(object filter); } diff --git a/src/tests/Refit.Tests/IGitHubApi.cs b/src/tests/Refit.Tests/IGitHubApi.cs index 3e60a2edb..a3795d1d6 100644 --- a/src/tests/Refit.Tests/IGitHubApi.cs +++ b/src/tests/Refit.Tests/IGitHubApi.cs @@ -38,8 +38,8 @@ public interface IGitHubApi /// A token used to cancel the request. /// The organization members. [SuppressMessage( - "Major Code Smell", - "S2360:Optional parameters should not be used", + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", Justification = "The optional CancellationToken is the idiomatic Refit signature and the generated-code snapshot depends on this exact single method shape.")] [Get("/orgs/{orgname}/members")] Task> GetOrgMembers(string orgName, CancellationToken cancellationToken = default); @@ -79,8 +79,8 @@ public interface IGitHubApi /// A token used to cancel the request. /// The API response, including status and headers. [SuppressMessage( - "Major Code Smell", - "S2360:Optional parameters should not be used", + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", Justification = "The optional CancellationToken is the idiomatic Refit signature and the generated-code snapshot depends on this exact single method shape.")] [Get("/users/{username}")] Task> GetUserWithMetadata(string userName, CancellationToken cancellationToken = default); diff --git a/src/tests/Refit.Tests/IHeaderBearingBase.cs b/src/tests/Refit.Tests/IHeaderBearingBase.cs new file mode 100644 index 000000000..788cf6e04 --- /dev/null +++ b/src/tests/Refit.Tests/IHeaderBearingBase.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. + +namespace Refit.Tests; + +/// A base interface carrying static headers, including a blank entry the parser must ignore. +[Headers("", "X-Base: base")] +public interface IHeaderBearingBase +{ + /// Sends a request declared on the header-bearing base interface. + /// The response body. + [Get("/base")] + Task BaseGet(); +} diff --git a/src/tests/Refit.Tests/IHeaderCollectionMergeApi.cs b/src/tests/Refit.Tests/IHeaderCollectionMergeApi.cs new file mode 100644 index 000000000..a45d1bb11 --- /dev/null +++ b/src/tests/Refit.Tests/IHeaderCollectionMergeApi.cs @@ -0,0 +1,24 @@ +// 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; + +/// Refit API surface exercising header-collection merge behavior. +public interface IHeaderCollectionMergeApi +{ + /// Posts using only a dynamic header collection as its header source. + /// The dynamic header collection. + /// The response body. + [Post("/merge")] + Task PostWithOnlyHeaderCollection( + [HeaderCollection] IDictionary headers); + + /// Posts using a static header alongside a dynamic header collection. + /// The dynamic header collection. + /// The response body. + [Headers("X-Static: static-value")] + [Post("/merge")] + Task PostWithStaticHeaderAndCollection( + [HeaderCollection] IDictionary headers); +} diff --git a/src/tests/Refit.Tests/IHttpBinApi.cs b/src/tests/Refit.Tests/IHttpBinApi.cs index 915e9a380..65fd9ffac 100644 --- a/src/tests/Refit.Tests/IHttpBinApi.cs +++ b/src/tests/Refit.Tests/IHttpBinApi.cs @@ -45,8 +45,8 @@ public interface IHttpBinApi /// The parameter to send as a query. /// The deserialized value. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "The Refit test intentionally exercises a generic method whose type parameter is supplied explicitly at the call site.")] [Get("/get?hardcoded=true")] Task GetQuery1([Query("_")] TParam param); diff --git a/src/tests/Refit.Tests/IImplementTheInterfaceAndUseRefit.cs b/src/tests/Refit.Tests/IImplementTheInterfaceAndUseRefit.cs index 852abf185..11688bcb2 100644 --- a/src/tests/Refit.Tests/IImplementTheInterfaceAndUseRefit.cs +++ b/src/tests/Refit.Tests/IImplementTheInterfaceAndUseRefit.cs @@ -12,10 +12,10 @@ public interface IImplementTheInterfaceAndUseRefit : IAmInterfaceEWithNoRefitThe parameter to send. /// A task representing the request. [Get("/doSomething")] - public new Task DoSomething(int parameter); + new Task DoSomething(int parameter); /// Issues a GET request, hiding the non-Refit base member with a Refit-attributed one. /// A task representing the request. [Get("/DoSomethingElse")] - public new Task DoSomethingElse(); + new Task DoSomethingElse(); } diff --git a/src/tests/Refit.Tests/IInheritedHeadersApi.cs b/src/tests/Refit.Tests/IInheritedHeadersApi.cs new file mode 100644 index 000000000..260c8aa31 --- /dev/null +++ b/src/tests/Refit.Tests/IInheritedHeadersApi.cs @@ -0,0 +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. + +namespace Refit.Tests; + +/// A Refit interface inheriting static headers from a base interface. +public interface IInheritedHeadersApi : IHeaderBearingBase +{ + /// Sends a request that inherits the base interface headers. + /// The response body. + [Get("/h")] + Task Get(); +} diff --git a/src/tests/Refit.Tests/IMessage.cs b/src/tests/Refit.Tests/IMessage.cs index 1bf4f6cbb..64e248964 100644 --- a/src/tests/Refit.Tests/IMessage.cs +++ b/src/tests/Refit.Tests/IMessage.cs @@ -6,6 +6,5 @@ namespace Refit.Tests; /// A marker interface used as a generic constraint by the generator test fixtures. -[SuppressMessage("Design", "CA1040:Avoid empty interfaces", Justification = "Intentional empty marker fixture interface used as a generic constraint for Refit tests.")] [SuppressMessage("Design", "SST1437:Add members to type or remove it", Justification = "Intentional empty marker fixture interface used as a generic constraint for Refit tests.")] public interface IMessage; diff --git a/src/tests/Refit.Tests/IMultipartPartRoutingApi.cs b/src/tests/Refit.Tests/IMultipartPartRoutingApi.cs new file mode 100644 index 000000000..07fda768f --- /dev/null +++ b/src/tests/Refit.Tests/IMultipartPartRoutingApi.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. + +namespace Refit.Tests; + +/// Refit API surface exercising multipart part versus query routing. +public interface IMultipartPartRoutingApi +{ + /// Uploads a plain string as a multipart part. + /// The multipart part value. + /// The HTTP response message. + [Multipart] + [Post("/plain")] + Task UploadPlainPart([AliasAs("file")] string file); + + /// Uploads a multipart part alongside a query-attributed argument. + /// The query-attributed argument routed to the query string. + /// The multipart part value. + /// The HTTP response message. + [Multipart] + [Post("/query")] + Task UploadWithQueryParam([Query] string tag, [AliasAs("file")] string file); + + /// Uploads a multipart part alongside an object whose property binds to the path. + /// The object whose binds to the path. + /// The multipart part value. + /// The HTTP response message. + [Multipart] + [Post("/object/{request.Id}")] + Task UploadWithPathBoundObject(MultipartRoutingRequest request, [AliasAs("file")] string file); +} diff --git a/src/tests/Refit.Tests/INestedRouteApi.cs b/src/tests/Refit.Tests/INestedRouteApi.cs new file mode 100644 index 000000000..71c6d8f6a --- /dev/null +++ b/src/tests/Refit.Tests/INestedRouteApi.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. + +namespace Refit.Tests; + +/// A Refit surface whose route binds a nested property chain of its request object. +public interface INestedRouteApi +{ + /// Gets an item identified by a nested request-object property. + /// The request whose nested property supplies the path value. + /// The response body. + [Get("/items/{request.inner.value}")] + Task GetByNestedValue(NestedRouteRequest request); +} diff --git a/src/tests/Refit.Tests/IPolymorphicShape.cs b/src/tests/Refit.Tests/IPolymorphicShape.cs new file mode 100644 index 000000000..858c339a2 --- /dev/null +++ b/src/tests/Refit.Tests/IPolymorphicShape.cs @@ -0,0 +1,16 @@ +// 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.Json.Serialization; + +namespace Refit.Tests; + +/// A polymorphic interface annotated with and a custom discriminator name. +[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")] +[JsonDerivedType(typeof(PolymorphicRectangle), "rectangle")] +public interface IPolymorphicShape +{ + /// Gets or sets the shape label shared by every derived shape. + string? Label { get; set; } +} diff --git a/src/tests/Refit.Tests/IQueryObjectApi.cs b/src/tests/Refit.Tests/IQueryObjectApi.cs new file mode 100644 index 000000000..38069e884 --- /dev/null +++ b/src/tests/Refit.Tests/IQueryObjectApi.cs @@ -0,0 +1,93 @@ +// 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; + +/// An API whose query objects are flattened inline by the source generator. +public interface IQueryObjectApi +{ + /// Flattens an explicitly marked query object. + /// The query object. + /// The response body. + [Get("/obj")] + Task FlattenObject([Query] SealedQueryObject query); + + /// Flattens a query object with no [Query] attribute on a body-less method. + /// The query object. + /// The response body. + [Get("/implied")] + Task FlattenImplied(SealedQueryObject query); + + /// Flattens a query object whose properties carry their own prefixes. + /// The query object. + /// The response body. + [Get("/prefixed")] + Task FlattenPrefixed(PrefixedSealedQueryObject query); + + /// Flattens a value-typed query object. + /// The query object. + /// The response body. + [Get("/struct")] + Task FlattenStruct(StructQueryObject query); + + /// Flattens a value-typed query object under a parameter-level prefix. + /// The query object. + /// The response body. + [Get("/paramprefix")] + Task FlattenParameterPrefix([Query(".", "root")] StructQueryObject query); + + /// Flattens a base-typed query object, whose declared properties are the only ones emitted. + /// The query object, which may be a derived instance. + /// The response body. + [Get("/declared")] + Task FlattenDeclared(BaseRecord query); + + /// Flattens a query object whose keys resolve through the serializer-aware naming precedence. + /// The query object. + /// The response body. + [Get("/json")] + Task FlattenJsonNamed([Query] JsonNamedQueryObject query); + + /// Flattens a query object whose properties are collections of simple elements. + /// The query object. + /// The response body. + [Get("/coll")] + Task FlattenCollections([Query] CollectionPropertyQueryObject query); + + /// Flattens a query object with a nested object property under a dotted key. + /// The query object. + /// The response body. + [Get("/nested")] + Task FlattenNested([Query] NestedQueryObject query); + + /// Flattens a query object with a nullable nested value-type property under a dotted key. + /// The query object. + /// The response body. + [Get("/nested/struct")] + Task FlattenNullableNestedStruct([Query] NullableNestedStructQueryObject query); + + /// Flattens a query object whose dictionary properties expand their entries under each property's key. + /// The query object. + /// The response body. + [Get("/dictprop")] + Task FlattenDictionaryProperty([Query] DictionaryPropertyQueryObject query); + + /// Expands a dictionary into one query pair per entry. + /// The dictionary. + /// The response body. + [Get("/dict")] + Task ExpandDictionary(IDictionary query); + + /// Expands a dictionary whose keys are enums rendered through their EnumMember value. + /// The dictionary. + /// The response body. + [Get("/dict/enum")] + Task ExpandEnumKeyedDictionary(Dictionary query); + + /// Expands a dictionary under a parameter-level prefix. + /// The dictionary. + /// The response body. + [Get("/dict/prefixed")] + Task ExpandPrefixedDictionary([Query(".", "root")] IDictionary query); +} diff --git a/src/tests/Refit.Tests/IQueryObjectTypesApi.cs b/src/tests/Refit.Tests/IQueryObjectTypesApi.cs new file mode 100644 index 000000000..8c4b019a3 --- /dev/null +++ b/src/tests/Refit.Tests/IQueryObjectTypesApi.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. + +namespace Refit.Tests; + +/// Refit API surface exercising query-object property type classification. +public interface IQueryObjectTypesApi +{ + /// Searches using a flattened query object. + /// The query object whose properties are flattened into the query string. + /// The response body. + [Get("/search")] + Task Search([Query] QueryObjectWithClassifiedTypes filters); +} diff --git a/src/tests/Refit.Tests/IRefitInterfaceWithStaticMethod.cs b/src/tests/Refit.Tests/IRefitInterfaceWithStaticMethod.cs index c6eff3f6a..4638e0784 100644 --- a/src/tests/Refit.Tests/IRefitInterfaceWithStaticMethod.cs +++ b/src/tests/Refit.Tests/IRefitInterfaceWithStaticMethod.cs @@ -12,7 +12,7 @@ public interface IRefitInterfaceWithStaticMethod #if NETCOREAPP3_1_OR_GREATER /// Creates an instance of the interface via a static factory method. /// A Refit-backed implementation of the interface. - public static IRefitInterfaceWithStaticMethod Create() => + static IRefitInterfaceWithStaticMethod Create() => RestService.For("http://foo/"); #endif diff --git a/src/tests/Refit.Tests/IRestMethodInfoTests.cs b/src/tests/Refit.Tests/IRestMethodInfoTests.cs index e122f1d4a..cc29b979a 100644 --- a/src/tests/Refit.Tests/IRestMethodInfoTests.cs +++ b/src/tests/Refit.Tests/IRestMethodInfoTests.cs @@ -27,6 +27,24 @@ public interface IRestMethodInfoTests [Get("/foo/{route}/{route.Visible}")] Task ConflictingObjectRoute(RouteObjectWithUnreadableProperty route); + /// Defines a route with a deep dotted placeholder whose root segment matches no method parameter. + /// The complex route parameter that does not match the placeholder root. + /// A task that represents the asynchronous operation. + [Get("/foo/{missing.Visible.Length}")] + Task DeepDottedRouteWithUnknownRootParameter(RouteObjectWithUnreadableProperty route); + + /// Defines a route with a deep dotted placeholder whose root segment is a value-type parameter. + /// The value-type parameter that cannot own a nested property chain. + /// A task that represents the asynchronous operation. + [Get("/foo/{count.Length.Value}")] + Task DeepDottedRouteWithValueTypeRootParameter(int count); + + /// Defines a route with a deep dotted placeholder whose final nested property does not exist. + /// The complex route parameter whose nested chain cannot be resolved. + /// A task that represents the asynchronous operation. + [Get("/foo/{route.Visible.Missing}")] + Task DeepDottedRouteWithUnknownNestedProperty(RouteObjectWithUnreadableProperty route); + /// Defines a route with duplicate authorize parameters. /// The first authorization value. /// The second authorization value. @@ -658,8 +676,8 @@ Task ImpliedComplexQueryType( /// The optional multi-value filter query parameter. /// A task that represents the asynchronous operation. [SuppressMessage( - "Major Code Smell", - "S2360:Optional parameters should not be used", + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", Justification = "Refit interface stub intentionally uses optional parameters to exercise default-value handling.")] [Get("/api/{id}")] Task MultipleQueryAttributes( @@ -675,8 +693,8 @@ Task MultipleQueryAttributes( /// The optional multi-value filter query parameter. /// A task that represents the asynchronous operation. [SuppressMessage( - "Major Code Smell", - "S2360:Optional parameters should not be used", + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", Justification = "Refit interface stub intentionally uses optional parameters to exercise default-value handling.")] [Get("/api/{id}")] Task NullableValues( diff --git a/src/tests/Refit.Tests/IRestServiceFallbackApi.cs b/src/tests/Refit.Tests/IRestServiceFallbackApi.cs new file mode 100644 index 000000000..b3a64b92e --- /dev/null +++ b/src/tests/Refit.Tests/IRestServiceFallbackApi.cs @@ -0,0 +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. + +namespace Refit.Tests; + +/// A Refit interface used only by the generated-factory fallback test. +public interface IRestServiceFallbackApi +{ + /// Sends a request. + /// The response body. + [Get("/")] + Task Get(); +} diff --git a/src/tests/Refit.Tests/IRfc3986RequestApi.cs b/src/tests/Refit.Tests/IRfc3986RequestApi.cs new file mode 100644 index 000000000..746678fe2 --- /dev/null +++ b/src/tests/Refit.Tests/IRfc3986RequestApi.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; + +/// Refit API surface exercising RFC 3986 request URI assembly. +public interface IRfc3986RequestApi +{ + /// Gets items with no query parameters. + /// The response body. + [Get("/items")] + Task GetWithoutQuery(); + + /// Gets items filtered by a single query parameter. + /// The search term appended to the query string. + /// The response body. + [Get("/items")] + Task GetWithQuery(string search); +} diff --git a/src/tests/Refit.Tests/IRoundTripNotString.cs b/src/tests/Refit.Tests/IRoundTripNotString.cs index fa3026b38..1c2fdcfd8 100644 --- a/src/tests/Refit.Tests/IRoundTripNotString.cs +++ b/src/tests/Refit.Tests/IRoundTripNotString.cs @@ -3,12 +3,12 @@ // See the LICENSE file in the project root for full license information. namespace Refit.Tests; -/// Fixture with a non-string round-tripping parameter, used to verify Refit rejects it. +/// Fixture with a non-string round-tripping parameter formatted via ToString, separators preserved. public interface IRoundTripNotString { /// Endpoint with a round-tripping parameter that is not a string. /// The round-tripping value. /// The response body. - [Get("/{**value}")] - Task GetValue(int value); + [Get("/repos/{**value}/contents")] + Task GetValue(RepoPath value); } diff --git a/src/tests/Refit.Tests/IRunscopeApi.cs b/src/tests/Refit.Tests/IRunscopeApi.cs index 7a22ca2a9..56a8b6bc9 100644 --- a/src/tests/Refit.Tests/IRunscopeApi.cs +++ b/src/tests/Refit.Tests/IRunscopeApi.cs @@ -142,4 +142,11 @@ Task UploadMixedObjects( [Multipart] [Post("/")] Task UploadHttpContent(HttpContent content); + + /// Uploads a collection of values, each added as its own multipart part. + /// The HTTP content values to upload. + /// The HTTP response message. + [Multipart] + [Post("/")] + Task UploadHttpContents(IEnumerable contents); } diff --git a/src/tests/Refit.Tests/IServiceWithoutNamespace.cs b/src/tests/Refit.Tests/IServiceWithoutNamespace.cs index 6375c5794..a6ba19829 100644 --- a/src/tests/Refit.Tests/IServiceWithoutNamespace.cs +++ b/src/tests/Refit.Tests/IServiceWithoutNamespace.cs @@ -7,8 +7,8 @@ /// Intentional fixture declared in the global namespace to test generation without a namespace. [SuppressMessage( - "Major Code Smell", - "S3903:Types should be defined in named namespaces", + "Design", + "SST2312:Types should be declared in a named namespace", Justification = "Fixture must stay in the global namespace; consumed as a source file by the generator no-namespace smoke test.")] [SuppressMessage( "Design", diff --git a/src/tests/Refit.Tests/IUseOverloadedGenericMethods.cs b/src/tests/Refit.Tests/IUseOverloadedGenericMethods.cs index 40e0c5db1..ec0bf42ff 100644 --- a/src/tests/Refit.Tests/IUseOverloadedGenericMethods.cs +++ b/src/tests/Refit.Tests/IUseOverloadedGenericMethods.cs @@ -45,8 +45,8 @@ public interface IUseOverloadedGenericMethods /// A value passed in the query string. /// The deserialized response payload. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Intentional Refit fixture: the explicit return type argument cannot be inferred and is exercised by the generator tests.")] [Get("/get")] Task Get(int someVal); @@ -57,8 +57,8 @@ public interface IUseOverloadedGenericMethods /// The input value passed in the query string. /// The deserialized response payload. [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "Intentional Refit fixture: the explicit return type argument cannot be inferred and is exercised by the generator tests.")] [Get("/get")] Task Get(TInput input); diff --git a/src/tests/Refit.Tests/JsonLinesContentTests.cs b/src/tests/Refit.Tests/JsonLinesContentTests.cs index 5d9384e90..2487ac99e 100644 --- a/src/tests/Refit.Tests/JsonLinesContentTests.cs +++ b/src/tests/Refit.Tests/JsonLinesContentTests.cs @@ -10,6 +10,9 @@ namespace Refit.Tests; /// Tests for and the generated JSON Lines body factory. public class JsonLinesContentTests { + /// Sample bytes used to verify that a stream body is wrapped in stream content. + private static readonly byte[] _sampleStreamBytes = [1, 2, 3]; + /// Verifies the constructor rejects a null item sequence. /// A task representing the asynchronous test. [Test] @@ -41,7 +44,7 @@ public async Task CreateJsonLinesBodyContentPassesThroughHttpContent() [Test] public async Task CreateJsonLinesBodyContentWrapsStream() { - await using var stream = new MemoryStream([1, 2, 3]); + await using var stream = new MemoryStream(_sampleStreamBytes); var content = GeneratedRequestRunner.CreateJsonLinesBodyContent(new(), stream); await Assert.That(content).IsTypeOf(); } diff --git a/src/tests/Refit.Tests/JsonLinesStreamTeardownTests.cs b/src/tests/Refit.Tests/JsonLinesStreamTeardownTests.cs new file mode 100644 index 000000000..592229031 --- /dev/null +++ b/src/tests/Refit.Tests/JsonLinesStreamTeardownTests.cs @@ -0,0 +1,59 @@ +// 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 System.IO; +using System.Threading.Tasks; + +namespace Refit.Tests; + +/// +/// Verifies the JSON Lines streaming deserializer returns its pooled buffer whether enumeration runs to completion +/// or the caller disposes early, exercising the reader's / teardown. +/// Drives directly so the buffered manual +/// reader path is enumerated without HTTP plumbing. +/// +public sealed class JsonLinesStreamTeardownTests +{ + /// Expected element id value 2 in streamed payloads. + private const int ExpectedId2 = 2; + + /// Expected element id value 3 in streamed payloads. + private const int ExpectedId3 = 3; + + /// A well-formed newline-delimited JSON payload. + private static readonly byte[] _jsonLinesPayload = "{\"id\":1}\n{\"id\":2}\n{\"id\":3}\n"u8.ToArray(); + + /// Verifies streaming to completion yields every value and returns the pooled buffer via the finally block. + /// A task representing the asynchronous test. + [Test] + public async Task FullEnumerationYieldsEveryLine() + { + var serializer = new SystemTextJsonContentSerializer(); + await using var stream = new MemoryStream(_jsonLinesPayload); + + var ids = new List(); + await foreach (var item in serializer.DeserializeStreamAsync(stream, StreamingContentFormat.JsonLines)) + { + ids.Add(item!.Id); + } + + await Assert.That(ids).IsEquivalentTo([1, ExpectedId2, ExpectedId3]); + } + + /// Verifies disposing after the first value tears down the suspended iterator, running its finally block. + /// A task representing the asynchronous test. + [Test] + public async Task DisposingAfterFirstLineRunsTheFinallyBlock() + { + var serializer = new SystemTextJsonContentSerializer(); + await using var stream = new MemoryStream(_jsonLinesPayload); + + var sequence = serializer.DeserializeStreamAsync(stream, StreamingContentFormat.JsonLines); + await using var enumerator = sequence.GetAsyncEnumerator(); + + await Assert.That(await enumerator.MoveNextAsync()).IsTrue(); + await Assert.That(enumerator.Current!.Id).IsEqualTo(1); + } +} diff --git a/src/tests/Refit.Tests/JsonNamedQueryObject.cs b/src/tests/Refit.Tests/JsonNamedQueryObject.cs new file mode 100644 index 000000000..90dcedfa0 --- /dev/null +++ b/src/tests/Refit.Tests/JsonNamedQueryObject.cs @@ -0,0 +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.Text.Json.Serialization; + +namespace Refit.Tests; + +/// A query object whose keys exercise the serializer-aware naming precedence for flattened properties. +public sealed class JsonNamedQueryObject +{ + /// Gets or sets a property named by . + [JsonPropertyName("json_name")] + public string? Named { get; set; } + + /// Gets or sets a property carrying both attributes; the name must win. + [AliasAs("alias_wins")] + [JsonPropertyName("json_loses")] + public string? Both { get; set; } + + /// Gets or sets a property with no naming attribute, which passes through the key formatter. + public string? Plain { get; set; } +} diff --git a/src/tests/Refit.Tests/MethodOverladTests.cs b/src/tests/Refit.Tests/MethodOverladTests.cs index 98e5aabc3..70aa88e11 100644 --- a/src/tests/Refit.Tests/MethodOverladTests.cs +++ b/src/tests/Refit.Tests/MethodOverladTests.cs @@ -22,6 +22,9 @@ public class MethodOverladTests /// The query parameter name exercised by the generic overloads. private const string ParamKey = "param"; + /// The HTTP status code requested from the stub to exercise the Forbidden response path. + private const int ForbiddenStatusCode = 403; + /// Verifies that non-generic Get overloads resolve to the correct request. /// A task representing the asynchronous test. [Test] @@ -42,7 +45,7 @@ public async Task BasicMethodOverloadTest() var fixture = handler.CreateClient(BaseUrl); var plainText = await fixture.Get(); - var resp = await fixture.Get(403); + var resp = await fixture.Get(ForbiddenStatusCode); await Assert.That(!string.IsNullOrWhiteSpace(plainText)).IsTrue(); await Assert.That(resp.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); @@ -82,7 +85,7 @@ public async Task GenericMethodOverloadTest2() var fixture = handler.CreateClient>(BaseUrl); - var resp = await fixture.Get(403); + var resp = await fixture.Get(ForbiddenStatusCode); await Assert.That(resp.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); } @@ -102,7 +105,8 @@ public async Task GenericMethodOverloadTest3() var fixture = handler.CreateClient>(BaseUrl); - var result = await fixture.Get(201); + const int someValue = 201; + var result = await fixture.Get(someValue); await Assert.That(result).IsEqualTo("some-T-value"); } @@ -122,7 +126,8 @@ public async Task GenericMethodOverloadTest4() var fixture = handler.CreateClient>(BaseUrl); - var result = await fixture.Get("foo", 99); + const int headerValue = 99; + var result = await fixture.Get("foo", headerValue); await Assert.That(result.Args![ParamKey]).IsEqualTo("foo"); } @@ -142,7 +147,8 @@ public async Task GenericMethodOverloadTest5() var fixture = handler.CreateClient>(BaseUrl); - var result = await fixture.Get(99, "foo"); + const int paramValue = 99; + var result = await fixture.Get(paramValue, "foo"); await Assert.That(result.Args![ParamKey]).IsEqualTo("99"); } @@ -162,7 +168,8 @@ public async Task GenericMethodOverloadTest6() var fixture = handler.CreateClient>(BaseUrl); - var result = await fixture.Get(99); + const int inputValue = 99; + var result = await fixture.Get(inputValue); await Assert.That(result).IsEqualTo("generic-output"); } diff --git a/src/tests/Refit.Tests/MultipartBoundaryResolutionTests.cs b/src/tests/Refit.Tests/MultipartBoundaryResolutionTests.cs new file mode 100644 index 000000000..a144db50f --- /dev/null +++ b/src/tests/Refit.Tests/MultipartBoundaryResolutionTests.cs @@ -0,0 +1,60 @@ +// 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; + +/// +/// Verifies how resolves the multipart boundary for a method: +/// an explicit boundary, the framework default boundary, and the empty boundary of a non-multipart method. +/// +public sealed class MultipartBoundaryResolutionTests +{ + /// The framework default multipart boundary applied when a method declares [Multipart] with no argument. + private const string DefaultBoundary = "----MyGreatBoundary"; + + /// The explicit boundary declared by . + private const string CustomBoundary = "-----SomeCustomBoundary"; + + /// A method declaring an explicit boundary uses that boundary verbatim. + /// A task that represents the asynchronous operation. + [Test] + public async Task ExplicitBoundaryIsTakenFromAttribute() + { + var input = typeof(IRunscopeApi); + var fixture = new RestMethodInfoInternal( + input, + input.GetMethods().First(static x => x.Name == nameof(IRunscopeApi.UploadStreamWithCustomBoundary))); + + await Assert.That(fixture.IsMultipart).IsTrue(); + await Assert.That(fixture.MultipartBoundary).IsEqualTo(CustomBoundary); + } + + /// A method declaring [Multipart] without an argument uses the framework default boundary. + /// A task that represents the asynchronous operation. + [Test] + public async Task DefaultBoundaryUsesFrameworkDefault() + { + var input = typeof(IRunscopeApi); + var fixture = new RestMethodInfoInternal( + input, + input.GetMethods().First(static x => x.Name == nameof(IRunscopeApi.UploadStream))); + + await Assert.That(fixture.IsMultipart).IsTrue(); + await Assert.That(fixture.MultipartBoundary).IsEqualTo(DefaultBoundary); + } + + /// A non-multipart method has an empty boundary. + /// A task that represents the asynchronous operation. + [Test] + public async Task NonMultipartMethodHasEmptyBoundary() + { + var input = typeof(IDummyHttpApi); + var fixture = new RestMethodInfoInternal( + input, + input.GetMethods().First(static x => x.Name == nameof(IDummyHttpApi.FetchSomeStuff))); + + await Assert.That(fixture.IsMultipart).IsFalse(); + await Assert.That(fixture.MultipartBoundary).IsEqualTo(string.Empty); + } +} diff --git a/src/tests/Refit.Tests/MultipartCapturingHttpMessageHandler.cs b/src/tests/Refit.Tests/MultipartCapturingHttpMessageHandler.cs new file mode 100644 index 000000000..5e0b31869 --- /dev/null +++ b/src/tests/Refit.Tests/MultipartCapturingHttpMessageHandler.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. + +using System.Net; +using System.Net.Http; + +namespace Refit.Tests; + +/// Captures the request URI and multipart parts of an in-flight request before they are disposed. +public sealed class MultipartCapturingHttpMessageHandler : HttpMessageHandler +{ + /// Gets the request URI captured from the last request. + public Uri? RequestUri { get; private set; } + + /// Gets the multipart parts captured from the last request. + public List Parts { get; } = []; + + /// Captures the request and returns an empty successful response. + /// The HTTP request message being sent. + /// The cancellation token for the request. + /// A successful response. + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestUri = request.RequestUri; + + if (request.Content is MultipartFormDataContent multipart) + { + foreach (var part in multipart) + { + var name = part.Headers.ContentDisposition?.Name; + var body = await part.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + Parts.Add(new(name, body)); + } + } + + return new(HttpStatusCode.OK) { Content = new StringContent("test") }; + } +} diff --git a/src/tests/Refit.Tests/MultipartFormattableValueFormattingTests.cs b/src/tests/Refit.Tests/MultipartFormattableValueFormattingTests.cs new file mode 100644 index 000000000..1d3189bc7 --- /dev/null +++ b/src/tests/Refit.Tests/MultipartFormattableValueFormattingTests.cs @@ -0,0 +1,48 @@ +// 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; + +/// Verifies how formattable multipart values behave when the form formatter returns null. +public sealed class MultipartFormattableValueFormattingTests +{ + /// A formatter returning null yields an empty multipart part rather than throwing. + /// A task that represents the asynchronous operation. + [Test] + public async Task NullFormatterYieldsEmptyPart() + { + var settings = new RefitSettings + { + FormUrlEncodedParameterFormatter = new NullFormattingFormUrlEncodedParameterFormatter() + }; + + var body = await CaptureIdPartBody(settings); + + await Assert.That(body).IsEqualTo(string.Empty); + } + + /// The default formatter yields a non-empty multipart part for a formattable value. + /// A task that represents the asynchronous operation. + [Test] + public async Task DefaultFormatterYieldsNonEmptyPart() + { + var body = await CaptureIdPartBody(new RefitSettings()); + + await Assert.That(body).IsNotEmpty(); + } + + /// Uploads a formattable Guid value and returns the captured body of the "id" multipart part. + /// The Refit settings that configure the form formatter. + /// The body of the "id" multipart part. + private static async Task CaptureIdPartBody(RefitSettings settings) + { + var fixture = new RequestBuilderImplementation(settings); + var func = fixture.BuildRestResultFuncForMethod(nameof(IRunscopeApi.UploadFormattableValues)); + var handler = new MultipartCapturingHttpMessageHandler(); + using var client = new HttpClient(handler) { BaseAddress = new("https://api/") }; + await (Task)func(client, [Guid.NewGuid(), DateTimeOffset.UnixEpoch])!; + + return handler.Parts.First(static part => part.Name == "id").Body; + } +} diff --git a/src/tests/Refit.Tests/MultipartPartRoutingTests.cs b/src/tests/Refit.Tests/MultipartPartRoutingTests.cs new file mode 100644 index 000000000..824984450 --- /dev/null +++ b/src/tests/Refit.Tests/MultipartPartRoutingTests.cs @@ -0,0 +1,64 @@ +// 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; + +/// Verifies how a multipart method routes each argument between a multipart part and the query string. +public sealed class MultipartPartRoutingTests +{ + /// The multipart part body shared by the routing fixtures. + private const string PartContent = "content"; + + /// A plain multipart argument is added as a multipart part. + /// A task that represents the asynchronous operation. + [Test] + public async Task PlainArgumentIsAddedAsMultipartPart() + { + var handler = await SendAsync(nameof(IMultipartPartRoutingApi.UploadPlainPart), [PartContent]); + + await Assert.That(handler.Parts).HasSingleItem(); + await Assert.That(handler.Parts[0].Name).IsEqualTo("file"); + await Assert.That(handler.RequestUri!.Query).IsEqualTo(string.Empty); + } + + /// A query-attributed argument on a multipart method is routed to the query string. + /// A task that represents the asynchronous operation. + [Test] + public async Task QueryAttributedArgumentIsRoutedToQuery() + { + var handler = await SendAsync(nameof(IMultipartPartRoutingApi.UploadWithQueryParam), ["mytag", PartContent]); + + await Assert.That(handler.Parts).HasSingleItem(); + await Assert.That(handler.Parts[0].Name).IsEqualTo("file"); + await Assert.That(handler.RequestUri!.Query).Contains("tag=mytag"); + } + + /// An object-property argument bound to the path is routed to the query string, not a multipart part. + /// A task that represents the asynchronous operation. + [Test] + public async Task PathBoundObjectPropertyArgumentIsRoutedToQuery() + { + var handler = await SendAsync( + nameof(IMultipartPartRoutingApi.UploadWithPathBoundObject), + [new MultipartRoutingRequest { Id = "abc" }, PartContent]); + + await Assert.That(handler.Parts).HasSingleItem(); + await Assert.That(handler.Parts[0].Name).IsEqualTo("file"); + await Assert.That(handler.RequestUri!.AbsolutePath).Contains("/object/abc"); + } + + /// Sends a request through a capturing handler and returns the handler that observed it. + /// The interface method to invoke. + /// The argument values for the call. + /// The handler that captured the request. + private static async Task SendAsync(string method, object[] args) + { + var fixture = new RequestBuilderImplementation(); + var func = fixture.BuildRestResultFuncForMethod(method); + var handler = new MultipartCapturingHttpMessageHandler(); + using var client = new HttpClient(handler) { BaseAddress = new("http://api/") }; + await (Task)func(client, args)!; + return handler; + } +} diff --git a/src/tests/Refit.Tests/MultipartRoutingRequest.cs b/src/tests/Refit.Tests/MultipartRoutingRequest.cs new file mode 100644 index 000000000..b48cebb05 --- /dev/null +++ b/src/tests/Refit.Tests/MultipartRoutingRequest.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; + +/// A request object whose identifier property binds to a multipart route path. +public sealed class MultipartRoutingRequest +{ + /// Gets or sets the identifier bound to the route path. + public string? Id { get; set; } +} diff --git a/src/tests/Refit.Tests/MultipartTests.PartUploads.cs b/src/tests/Refit.Tests/MultipartTests.PartUploads.cs new file mode 100644 index 000000000..1b75f803c --- /dev/null +++ b/src/tests/Refit.Tests/MultipartTests.PartUploads.cs @@ -0,0 +1,596 @@ +// 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.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using SystemTextJsonSerializer = System.Text.Json.JsonSerializer; + +namespace Refit.Tests; + +/// Tests covering Refit's multipart uploads of explicit part types, serialized objects and raw HTTP content. +public partial class MultipartTests +{ + /// Verifies a single stream part keeps its supplied file name and content type. + /// A task representing the asynchronous test. + [Test] + public async Task MultipartUploadShouldWorkWithStreamPart() + { + var handler = new MockHttpMessageHandler + { + Asserts = static async content => + { + var parts = content.ToList(); + + await Assert.That(parts).HasSingleItem(); + + await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo(StreamName); + await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsEqualTo(StreamPartFileName); + await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(PdfMediaType); + + await using var str = await parts[0].ReadAsStreamAsync(); + await using var src = GetTestFileStream(TestFilePath); + await Assert.That(StreamsEqual(src, str)).IsTrue(); + } + }; + + var settings = new RefitSettings { HttpMessageHandlerFactory = () => handler }; + + await using var stream = GetTestFileStream(TestFilePath); + var fixture = RestService.For(BaseAddress, settings); + await fixture.UploadStreamPart( + new(stream, StreamPartFileName, PdfMediaType)); + } + + /// Verifies a stream part with a named multipart uses the supplied name. + /// A task representing the asynchronous test. + [Test] + public async Task MultipartUploadShouldWorkWithStreamPartWithNamedMultipart() + { + var handler = new MockHttpMessageHandler + { + Asserts = static async content => + { + var parts = content.ToList(); + + await Assert.That(parts).HasSingleItem(); + + await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo("test-stream"); + await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsEqualTo(StreamPartFileName); + await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(PdfMediaType); + + await using var str = await parts[0].ReadAsStreamAsync(); + await using var src = GetTestFileStream(TestFilePath); + await Assert.That(StreamsEqual(src, str)).IsTrue(); + } + }; + + var settings = new RefitSettings { HttpMessageHandlerFactory = () => handler }; + + await using var stream = GetTestFileStream(TestFilePath); + var fixture = RestService.For(BaseAddress, settings); + await fixture.UploadStreamPart( + new(stream, StreamPartFileName, PdfMediaType, "test-stream")); + } + + /// Verifies a stream part can be combined with query parameters. + /// A task representing the asynchronous test. + [Test] + public async Task MultipartUploadShouldWorkWithStreamPartAndQuery() + { + var handler = new MockHttpMessageHandler + { + RequestAsserts = static async request => await Assert.That(request.RequestUri!.Query).IsEqualTo("?Property1=test&Property2=test2"), + Asserts = static async content => + { + var parts = content.ToList(); + + await Assert.That(parts).HasSingleItem(); + + await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo(StreamName); + await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsEqualTo(StreamPartFileName); + await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(PdfMediaType); + + await using var str = await parts[0].ReadAsStreamAsync(); + await using var src = GetTestFileStream(TestFilePath); + await Assert.That(StreamsEqual(src, str)).IsTrue(); + } + }; + + var settings = new RefitSettings { HttpMessageHandlerFactory = () => handler }; + + await using var stream = GetTestFileStream(TestFilePath); + var fixture = RestService.For(BaseAddress, settings); + await fixture.UploadStreamPart( + new() { Property1 = "test", Property2 = "test2" }, + new(stream, StreamPartFileName, PdfMediaType)); + } + + /// Verifies a byte array part keeps its supplied alias, file name and content type. + /// A task representing the asynchronous test. + [Test] + public async Task MultipartUploadShouldWorkWithByteArrayPart() + { + var handler = new MockHttpMessageHandler + { + Asserts = static async content => + { + var parts = content.ToList(); + + await Assert.That(parts).HasSingleItem(); + + await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo("ByteArrayPartParamAlias"); + await Assert + .That(parts[0].Headers.ContentDisposition!.FileName) + .IsEqualTo("test-bytearraypart.pdf"); + await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(PdfMediaType); + + await using var str = await parts[0].ReadAsStreamAsync(); + await using var src = GetTestFileStream(TestFilePath); + await Assert.That(StreamsEqual(src, str)).IsTrue(); + } + }; + + var settings = new RefitSettings { HttpMessageHandlerFactory = () => handler }; + + await using var stream = GetTestFileStream(TestFilePath); + using var reader = new BinaryReader(stream); + var bytes = reader.ReadBytes((int)stream.Length); + + var fixture = RestService.For(BaseAddress, settings); + await fixture.UploadBytesPart( + new(bytes, "test-bytearraypart.pdf", PdfMediaType)); + } + + /// Verifies a collection of file parts plus an extra part keep their names and content types. + /// A task representing the asynchronous test. + [Test] + public async Task MultipartUploadShouldWorkWithFileInfoPart() + { + var fileName = CreateTempFile(); + + var handler = new MockHttpMessageHandler + { + Asserts = AssertFileInfoParts + }; + + var settings = new RefitSettings { HttpMessageHandlerFactory = () => handler }; + + try + { + await using var stream = GetTestFileStream(TestFilePath); + await using var outStream = File.OpenWrite(fileName); + await stream.CopyToAsync(outStream); + await outStream.FlushAsync(); + outStream.Close(); + + var fixture = RestService.For(BaseAddress, settings); + await fixture.UploadFileInfoPart( + [ + new( + new(fileName), + "test-fileinfopart.pdf", + PdfMediaType), + new( + new(fileName), + "test-fileinfopart2.pdf") + ], + new( + new(fileName), + fileName: "additionalfile.pdf", + contentType: PdfMediaType)); + } + finally + { + File.Delete(fileName); + } + } + + /// Verifies a single object is serialized to multipart content by each serializer. + /// The serializer type to exercise. + /// The expected media type produced by the serializer. + /// A task representing the asynchronous test. + [Test] + [Arguments(typeof(SystemTextJsonContentSerializer), JsonMediaType)] + [Arguments(typeof(XmlContentSerializer), "application/xml")] + public async Task MultipartUploadShouldWorkWithAnObject( + Type contentSerializerType, + string mediaType) + { + if (Activator.CreateInstance(contentSerializerType) is not IHttpContentSerializer serializer) + { + throw new ArgumentException( + $"{contentSerializerType.FullName} does not implement {nameof(IHttpContentSerializer)}"); + } + + var model1 = new ModelObject { Property1 = Model1Property1, Property2 = Model1Property2 }; + + var handler = new MockHttpMessageHandler + { + Asserts = async content => + { + var parts = content.ToList(); + + await Assert.That(parts).HasSingleItem(); + + await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo("theObject"); + await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsNull(); + await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(mediaType); + var result0 = await serializer + .FromHttpContentAsync(parts[0]) + .ConfigureAwait(false); + await Assert.That(result0!.Property1).IsEqualTo(model1.Property1); + await Assert.That(result0!.Property2).IsEqualTo(model1.Property2); + } + }; + + var settings = new RefitSettings + { + HttpMessageHandlerFactory = () => handler, + ContentSerializer = serializer + }; + + var fixture = RestService.For(BaseAddress, settings); + 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()); + + 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. + /// A task representing the asynchronous test. + [Test] + [Arguments(typeof(SystemTextJsonContentSerializer), JsonMediaType)] + [Arguments(typeof(XmlContentSerializer), "application/xml")] + public async Task MultipartUploadShouldWorkWithObjects( + Type contentSerializerType, + string mediaType) + { + if (Activator.CreateInstance(contentSerializerType) is not IHttpContentSerializer serializer) + { + throw new ArgumentException( + $"{contentSerializerType.FullName} does not implement {nameof(IHttpContentSerializer)}"); + } + + var model1 = new ModelObject { Property1 = Model1Property1, Property2 = Model1Property2 }; + + var model2 = new ModelObject { Property1 = "M2.prop1" }; + + var handler = new MockHttpMessageHandler + { + Asserts = async content => + { + var parts = content.ToList(); + + const int expectedPartCount = 2; + + await Assert.That(parts.Count).IsEqualTo(expectedPartCount); + + await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo(TheObjectsName); + await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsNull(); + await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(mediaType); + var result0 = await serializer + .FromHttpContentAsync(parts[0]) + .ConfigureAwait(false); + await Assert.That(result0!.Property1).IsEqualTo(model1.Property1); + await Assert.That(result0!.Property2).IsEqualTo(model1.Property2); + + await Assert.That(parts[1].Headers.ContentDisposition!.Name).IsEqualTo(TheObjectsName); + await Assert.That(parts[1].Headers.ContentDisposition!.FileName).IsNull(); + await Assert.That(parts[1].Headers.ContentType!.MediaType).IsEqualTo(mediaType); + var result1 = await serializer + .FromHttpContentAsync(parts[1]) + .ConfigureAwait(false); + await Assert.That(result1!.Property1).IsEqualTo(model2.Property1); + await Assert.That(result1!.Property2).IsEqualTo(model2.Property2); + } + }; + + var settings = new RefitSettings + { + HttpMessageHandlerFactory = () => handler, + ContentSerializer = serializer + }; + + var fixture = RestService.For(BaseAddress, settings); + await fixture.UploadJsonObjects([model1, model2]); + } + + /// Verifies a mixture of object, file, enum, string and integer parts are uploaded correctly. + /// A task representing the asynchronous test. + [Test] + public async Task MultipartUploadShouldWorkWithMixedTypes() + { + var fileName = CreateTempFile(); + var name = Path.GetFileName(fileName); + + var model1 = new ModelObject { Property1 = Model1Property1, Property2 = Model1Property2 }; + + var model2 = new ModelObject { Property1 = "M2.prop1" }; + + var anotherModel = new AnotherModel { Foos = ["bar1", "bar2"] }; + + var handler = new MockHttpMessageHandler + { + Asserts = content => AssertMixedParts(content, model1, model2, name) + }; + + var settings = new RefitSettings { HttpMessageHandlerFactory = () => handler }; + + try + { + await using var stream = GetTestFileStream(TestFilePath); + await using var outStream = File.OpenWrite(fileName); + await stream.CopyToAsync(outStream); + await outStream.FlushAsync(); + outStream.Close(); + + var fixture = RestService.For(BaseAddress, settings); + await fixture.UploadMixedObjects( + [model1, model2], + anotherModel, + new(fileName), + AnEnum.Val2, + "frob", + ExpectedIntValue); + } + finally + { + File.Delete(fileName); + } + } + + /// Verifies arbitrary HTTP content is uploaded preserving its disposition and type. + /// A task representing the asynchronous test. + [Test] + public async Task MultipartUploadShouldWorkWithHttpContent() + { + var httpContent = new StringContent("some text", Encoding.ASCII, CustomMediaType); + httpContent.Headers.ContentDisposition = new("attachment") + { + Name = "myName", + FileName = "myFileName", + }; + + var handler = new MockHttpMessageHandler + { + Asserts = static async content => + { + var parts = content.ToList(); + + await Assert.That(parts).HasSingleItem(); + + await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo("myName"); + await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsEqualTo("myFileName"); + await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(CustomMediaType); + var result0 = await parts[0].ReadAsStringAsync().ConfigureAwait(false); + await Assert.That(result0).IsEqualTo("some text"); + } + }; + + var settings = new RefitSettings { HttpMessageHandlerFactory = () => handler }; + + var fixture = RestService.For(BaseAddress, settings); + await fixture.UploadHttpContent(httpContent); + } + + /// Verifies each element of an of is added directly as + /// its own multipart part when the request is built through the reflection request builder. + /// A task representing the asynchronous test. + [Test] + public async Task MultipartHttpContentCollectionAddsEachItemThroughReflectionBuilder() + { + var first = new StringContent("first", Encoding.UTF8, CustomMediaType); + var second = new StringContent("second", Encoding.UTF8, CustomMediaType); + + // The parts are captured while the request is in flight because the HttpClient disposes the multipart content + // (clearing its child parts) once the send completes. + List? capturedParts = null; + var handler = new MockHttpMessageHandler + { + Asserts = content => + { + capturedParts = content.ToList(); + return Task.CompletedTask; + } + }; + + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRestResultFuncForMethod(nameof(IRunscopeApi.UploadHttpContents)); + using var client = new HttpClient(handler) { BaseAddress = new(BaseAddress) }; + + var task = (Task)factory(client, [new List { first, second }])!; + await task; + + const int expectedPartCount = 2; + await Assert.That(capturedParts!.Count).IsEqualTo(expectedPartCount); + await Assert.That(capturedParts).Contains(first); + await Assert.That(capturedParts).Contains(second); + } + + /// Verifies an element of an enumerable multipart parameter that the serializer cannot serialize is wrapped in + /// a descriptive argument exception, and that the request message is disposed and the failure rethrown when request + /// building fails, exercised through the reflection request builder. + /// A task representing the asynchronous test. + [Test] + public async Task MultipartUnserializableCollectionItemThroughReflectionBuilderThrowsArgumentException() + { + var settings = new RefitSettings { ContentSerializer = new ThrowingContentSerializer() }; + var fixture = new RequestBuilderImplementation(settings); + var factory = fixture.BuildRequestFactoryForMethod(nameof(IRunscopeApi.UploadJsonObjects)); + + Task Build() => factory([new List { new() }]); + + var exception = await Assert.That(Build).ThrowsExactly(); + + await Assert.That(exception!.Message).Contains("Unexpected parameter type", StringComparison.Ordinal); + await Assert.That(exception.InnerException).IsTypeOf(); + } + + /// Verifies the constructor rejects a null file name. + /// A task representing the asynchronous test. + [Test] + public async Task MultiPartConstructorShouldThrowArgumentNullExceptionWhenNoFileName() => + await Assert + .That(static () => _ = new ByteArrayPart([], null!, PdfMediaType)) + .ThrowsExactly(); + + /// Verifies the constructor rejects a null file info. + /// A task representing the asynchronous test. + [Test] + public async Task FileInfoPartConstructorShouldThrowArgumentNullExceptionWhenNoFileInfo() => + await Assert + .That(static () => _ = new FileInfoPart(null!, "file.pdf", PdfMediaType)) + .ThrowsExactly(); + + /// Asserts the parts produced by the file-info multipart upload keep their names and content types. + /// The multipart content observed by the handler. + /// A task representing the assertion work. + private static async Task AssertFileInfoParts(MultipartFormDataContent content) + { + var parts = content.ToList(); + + const int expectedPartCount = 3; + const int additionalFilePartIndex = 2; + + await Assert.That(parts.Count).IsEqualTo(expectedPartCount); + + await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo(FileInfosName); + await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsEqualTo("test-fileinfopart.pdf"); + await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(PdfMediaType); + await using (var str = await parts[0].ReadAsStreamAsync()) + await using (var src = GetTestFileStream(TestFilePath)) + { + await Assert.That(StreamsEqual(src, str)).IsTrue(); + } + + await Assert.That(parts[1].Headers.ContentDisposition!.Name).IsEqualTo(FileInfosName); + await Assert + .That(parts[1].Headers.ContentDisposition!.FileName) + .IsEqualTo("test-fileinfopart2.pdf"); + await Assert.That(parts[1].Headers.ContentType).IsNull(); + await using (var str = await parts[1].ReadAsStreamAsync()) + await using (var src = GetTestFileStream(TestFilePath)) + { + await Assert.That(StreamsEqual(src, str)).IsTrue(); + } + + await Assert.That(parts[additionalFilePartIndex].Headers.ContentDisposition!.Name).IsEqualTo("anotherFile"); + await Assert.That(parts[additionalFilePartIndex].Headers.ContentDisposition!.FileName).IsEqualTo("additionalfile.pdf"); + await Assert.That(parts[additionalFilePartIndex].Headers.ContentType!.MediaType).IsEqualTo(PdfMediaType); + await using (var str = await parts[additionalFilePartIndex].ReadAsStreamAsync()) + await using (var src = GetTestFileStream(TestFilePath)) + { + await Assert.That(StreamsEqual(src, str)).IsTrue(); + } + } + + /// Asserts the parts produced by the mixed-types multipart upload. + /// The multipart content observed by the handler. + /// The first expected model object. + /// The second expected model object. + /// The expected file-part file name. + /// A task representing the assertion work. + private static async Task AssertMixedParts(MultipartFormDataContent content, ModelObject model1, ModelObject model2, string name) + { + const int expectedPartCount = 7; + const int anotherModelPartIndex = 2; + const int filePartIndex = 3; + const int enumPartIndex = 4; + const int stringPartIndex = 5; + const int intPartIndex = 6; + const int expectedFooCount = 2; + + var parts = content.ToList(); + + await Assert.That(parts.Count).IsEqualTo(expectedPartCount); + + await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo(TheObjectsName); + await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsNull(); + await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(JsonMediaType); + var result0 = await DeserializeMultipartPart(parts[0]); + await Assert.That(result0!.Property1).IsEqualTo(model1.Property1); + await Assert.That(result0!.Property2).IsEqualTo(model1.Property2); + + await Assert.That(parts[1].Headers.ContentDisposition!.Name).IsEqualTo(TheObjectsName); + await Assert.That(parts[1].Headers.ContentDisposition!.FileName).IsNull(); + await Assert.That(parts[1].Headers.ContentType!.MediaType).IsEqualTo(JsonMediaType); + var result1 = await DeserializeMultipartPart(parts[1]); + await Assert.That(result1!.Property1).IsEqualTo(model2.Property1); + await Assert.That(result1!.Property2).IsEqualTo(model2.Property2); + + await Assert.That(parts[anotherModelPartIndex].Headers.ContentDisposition!.Name).IsEqualTo("anotherModel"); + await Assert.That(parts[anotherModelPartIndex].Headers.ContentDisposition!.FileName).IsNull(); + await Assert.That(parts[anotherModelPartIndex].Headers.ContentType!.MediaType).IsEqualTo(JsonMediaType); + var result2 = await DeserializeMultipartPart(parts[anotherModelPartIndex]); + await Assert.That(result2!.Foos!.Length).IsEqualTo(expectedFooCount); + await Assert.That(result2!.Foos![0]).IsEqualTo("bar1"); + await Assert.That(result2!.Foos![1]).IsEqualTo("bar2"); + + await Assert.That(parts[filePartIndex].Headers.ContentDisposition!.Name).IsEqualTo("aFile"); + await Assert.That(parts[filePartIndex].Headers.ContentDisposition!.FileName).IsEqualTo(name); + await Assert.That(parts[filePartIndex].Headers.ContentType).IsNull(); + await using (var str = await parts[filePartIndex].ReadAsStreamAsync()) + await using (var src = GetTestFileStream(TestFilePath)) + { + await Assert.That(StreamsEqual(src, str)).IsTrue(); + } + + await Assert.That(parts[enumPartIndex].Headers.ContentDisposition!.Name).IsEqualTo("anEnum"); + await Assert.That(parts[enumPartIndex].Headers.ContentDisposition!.FileName).IsNull(); + await Assert.That(parts[enumPartIndex].Headers.ContentType!.MediaType).IsEqualTo(JsonMediaType); + var result4 = await DeserializeMultipartPart(parts[enumPartIndex]); + await Assert.That(result4).IsEqualTo(AnEnum.Val2); + + await Assert.That(parts[stringPartIndex].Headers.ContentDisposition!.Name).IsEqualTo("aString"); + await Assert.That(parts[stringPartIndex].Headers.ContentDisposition!.FileName).IsNull(); + await Assert.That(parts[stringPartIndex].Headers.ContentType!.MediaType).IsEqualTo(PlainTextMediaType); + await Assert.That(parts[stringPartIndex].Headers.ContentType!.CharSet).IsEqualTo(Utf8CharSet); + await Assert.That(await parts[stringPartIndex].ReadAsStringAsync()).IsEqualTo("frob"); + + await Assert.That(parts[intPartIndex].Headers.ContentDisposition!.Name).IsEqualTo("anInt"); + await Assert.That(parts[intPartIndex].Headers.ContentDisposition!.FileName).IsNull(); + await Assert.That(parts[intPartIndex].Headers.ContentType!.MediaType).IsEqualTo(JsonMediaType); + var result6 = await DeserializeMultipartPart(parts[intPartIndex]); + await Assert.That(result6).IsEqualTo(ExpectedIntValue); + } + + /// Deserializes a multipart part's string content with the default serializer options. + /// The type to deserialize the part into. + /// The multipart part to read. + /// The deserialized value. + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The type argument selects the deserialization target and cannot be inferred from the content parameter.")] + private static async Task DeserializeMultipartPart(HttpContent part) => + SystemTextJsonSerializer.Deserialize( + await part.ReadAsStringAsync().ConfigureAwait(false), + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); +} diff --git a/src/tests/Refit.Tests/MultipartTests.cs b/src/tests/Refit.Tests/MultipartTests.cs index 809f6b21b..796710676 100644 --- a/src/tests/Refit.Tests/MultipartTests.cs +++ b/src/tests/Refit.Tests/MultipartTests.cs @@ -9,15 +9,12 @@ using System.Net; using System.Net.Http; using System.Reflection; -using System.Text; using System.Threading; using System.Threading.Tasks; -using SystemTextJsonSerializer = System.Text.Json.JsonSerializer; - namespace Refit.Tests; /// Tests covering Refit's multipart form upload support. -public class MultipartTests +public partial class MultipartTests { /// The base address used by the multipart test clients. private const string BaseAddress = "https://api/"; @@ -31,6 +28,9 @@ public class MultipartTests /// The JSON media type used by multipart parts. private const string JsonMediaType = "application/json"; + /// A custom media type used to verify explicit content types survive multipart assembly. + private const string CustomMediaType = "application/custom"; + /// The multipart name used for stream parts. private const string StreamName = "stream"; @@ -43,6 +43,18 @@ public class MultipartTests /// The file name used for stream parts. private const string StreamPartFileName = "test-streampart.pdf"; + /// The plain text media type asserted for string multipart parts. + private const string PlainTextMediaType = "text/plain"; + + /// The character set asserted for string multipart parts. + private const string Utf8CharSet = "utf-8"; + + /// The first property value of the sample model object. + private const string Model1Property1 = "M1.prop1"; + + /// The second property value of the sample model object. + private const string Model1Property2 = "M1.prop2"; + /// The expected integer value uploaded in the mixed-types test. private const int ExpectedIntValue = 42; @@ -53,7 +65,7 @@ public async Task MultipartUploadShouldWorkWithStream() { var handler = new MockHttpMessageHandler { - Asserts = async content => + Asserts = static async content => { var parts = content.ToList(); @@ -82,7 +94,7 @@ public async Task MultipartUploadShouldWorkWithStreamAndCustomBoundary() { var handler = new MockHttpMessageHandler { - Asserts = async content => + Asserts = static async content => { var parts = content.ToList(); @@ -108,7 +120,7 @@ public async Task MultipartUploadShouldWorkWithStreamAndCustomBoundary() var input = typeof(IRunscopeApi); var methodFixture = new RestMethodInfoInternal( input, - input.GetMethods().First(x => x.Name == "UploadStreamWithCustomBoundary")); + input.GetMethods().First(static x => x.Name == "UploadStreamWithCustomBoundary")); await Assert.That(methodFixture.MultipartBoundary).IsEqualTo("-----SomeCustomBoundary"); } @@ -119,7 +131,7 @@ public async Task MultipartUploadShouldWorkWithByteArray() { var handler = new MockHttpMessageHandler { - Asserts = async content => + Asserts = static async content => { var parts = content.ToList(); @@ -158,7 +170,10 @@ public async Task MultipartUploadShouldWorkWithFileInfo() { var parts = content.ToList(); - await Assert.That(parts.Count).IsEqualTo(3); + const int expectedPartCount = 3; + const int additionalFilePartIndex = 2; + + await Assert.That(parts.Count).IsEqualTo(expectedPartCount); await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo(FileInfosName); await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsEqualTo(name); @@ -178,10 +193,10 @@ public async Task MultipartUploadShouldWorkWithFileInfo() await Assert.That(StreamsEqual(src, str)).IsTrue(); } - await Assert.That(parts[2].Headers.ContentDisposition!.Name).IsEqualTo("anotherFile"); - await Assert.That(parts[2].Headers.ContentDisposition!.FileName).IsEqualTo(name); - await Assert.That(parts[2].Headers.ContentType).IsNull(); - await using (var str = await parts[2].ReadAsStreamAsync()) + await Assert.That(parts[additionalFilePartIndex].Headers.ContentDisposition!.Name).IsEqualTo("anotherFile"); + await Assert.That(parts[additionalFilePartIndex].Headers.ContentDisposition!.FileName).IsEqualTo(name); + await Assert.That(parts[additionalFilePartIndex].Headers.ContentType).IsNull(); + await using (var str = await parts[additionalFilePartIndex].ReadAsStreamAsync()) await using (var src = GetTestFileStream(TestFilePath)) { await Assert.That(StreamsEqual(src, str)).IsTrue(); @@ -219,7 +234,7 @@ public async Task MultipartUploadShouldWorkWithString() var handler = new MockHttpMessageHandler { - Asserts = async content => + Asserts = static async content => { var parts = content.ToList(); @@ -227,8 +242,8 @@ public async Task MultipartUploadShouldWorkWithString() await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo("SomeStringAlias"); await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsNull(); - await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo("text/plain"); - await Assert.That(parts[0].Headers.ContentType!.CharSet).IsEqualTo("utf-8"); + await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(PlainTextMediaType); + await Assert.That(parts[0].Headers.ContentType!.CharSet).IsEqualTo(Utf8CharSet); var str = await parts[0].ReadAsStringAsync(); await Assert.That(str).IsEqualTo(text); } @@ -254,7 +269,9 @@ public async Task MultipartUploadShouldWorkWithFormattableValues() { var parts = content.ToList(); - await Assert.That(parts.Count).IsEqualTo(2); + const int expectedPartCount = 2; + + await Assert.That(parts.Count).IsEqualTo(expectedPartCount); await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo("id"); var idText = await parts[0].ReadAsStringAsync(); @@ -281,7 +298,7 @@ public async Task MultipartFormattableValueWithNullFormatterIsEmpty() { var handler = new MockHttpMessageHandler { - Asserts = async content => + Asserts = static async content => { var parts = content.ToList(); await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo("id"); @@ -310,23 +327,29 @@ public async Task MultipartUploadShouldWorkWithHeaderAndRequestProperty() var handler = new MockHttpMessageHandler { - RequestAsserts = async message => + RequestAsserts = static async message => { + // The source-generated inline path attaches the interface type and the [Property] option, but not the + // reflection-only RestMethodInfo option, so it carries one fewer request option than the reflection + // builder (which additionally stores its RestMethodInfoInternal). This is generated-path behavior + // shared by every method, not specific to multipart. + const int expectedRequestPropertyCount = 2; + await Assert.That(message.Headers.Authorization!.ToString()).IsEqualTo(someHeader); #if NET6_0_OR_GREATER - await Assert.That(message.Options.Count()).IsEqualTo(3); + await Assert.That(message.Options.Count()).IsEqualTo(expectedRequestPropertyCount); await Assert .That(((IDictionary)message.Options)["SomeProperty"]) .IsEqualTo(someProperty); #endif #pragma warning disable CS0618 // Type or member is obsolete - await Assert.That(message.Properties.Count).IsEqualTo(3); + await Assert.That(message.Properties.Count).IsEqualTo(expectedRequestPropertyCount); await Assert.That(message.Properties["SomeProperty"]).IsEqualTo(someProperty); #pragma warning restore CS0618 // Type or member is obsolete }, - Asserts = async content => + Asserts = static async content => { var parts = content.ToList(); @@ -334,8 +357,8 @@ await Assert await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo("SomeStringAlias"); await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsNull(); - await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo("text/plain"); - await Assert.That(parts[0].Headers.ContentType!.CharSet).IsEqualTo("utf-8"); + await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(PlainTextMediaType); + await Assert.That(parts[0].Headers.ContentType!.CharSet).IsEqualTo(Utf8CharSet); var str = await parts[0].ReadAsStringAsync(); await Assert.That(str).IsEqualTo(text); } @@ -350,438 +373,6 @@ await fixture.UploadStringWithHeaderAndRequestProperty( text); } - /// Verifies a single stream part keeps its supplied file name and content type. - /// A task representing the asynchronous test. - [Test] - public async Task MultipartUploadShouldWorkWithStreamPart() - { - var handler = new MockHttpMessageHandler - { - Asserts = async content => - { - var parts = content.ToList(); - - await Assert.That(parts).HasSingleItem(); - - await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo(StreamName); - await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsEqualTo(StreamPartFileName); - await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(PdfMediaType); - - await using var str = await parts[0].ReadAsStreamAsync(); - await using var src = GetTestFileStream(TestFilePath); - await Assert.That(StreamsEqual(src, str)).IsTrue(); - } - }; - - var settings = new RefitSettings { HttpMessageHandlerFactory = () => handler }; - - await using var stream = GetTestFileStream(TestFilePath); - var fixture = RestService.For(BaseAddress, settings); - await fixture.UploadStreamPart( - new(stream, StreamPartFileName, PdfMediaType)); - } - - /// Verifies a stream part with a named multipart uses the supplied name. - /// A task representing the asynchronous test. - [Test] - public async Task MultipartUploadShouldWorkWithStreamPartWithNamedMultipart() - { - var handler = new MockHttpMessageHandler - { - Asserts = async content => - { - var parts = content.ToList(); - - await Assert.That(parts).HasSingleItem(); - - await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo("test-stream"); - await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsEqualTo(StreamPartFileName); - await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(PdfMediaType); - - await using var str = await parts[0].ReadAsStreamAsync(); - await using var src = GetTestFileStream(TestFilePath); - await Assert.That(StreamsEqual(src, str)).IsTrue(); - } - }; - - var settings = new RefitSettings { HttpMessageHandlerFactory = () => handler }; - - await using var stream = GetTestFileStream(TestFilePath); - var fixture = RestService.For(BaseAddress, settings); - await fixture.UploadStreamPart( - new(stream, StreamPartFileName, PdfMediaType, "test-stream")); - } - - /// Verifies a stream part can be combined with query parameters. - /// A task representing the asynchronous test. - [Test] - public async Task MultipartUploadShouldWorkWithStreamPartAndQuery() - { - var handler = new MockHttpMessageHandler - { - RequestAsserts = async request => await Assert.That(request.RequestUri!.Query).IsEqualTo("?Property1=test&Property2=test2"), - Asserts = async content => - { - var parts = content.ToList(); - - await Assert.That(parts).HasSingleItem(); - - await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo(StreamName); - await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsEqualTo(StreamPartFileName); - await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(PdfMediaType); - - await using var str = await parts[0].ReadAsStreamAsync(); - await using var src = GetTestFileStream(TestFilePath); - await Assert.That(StreamsEqual(src, str)).IsTrue(); - } - }; - - var settings = new RefitSettings { HttpMessageHandlerFactory = () => handler }; - - await using var stream = GetTestFileStream(TestFilePath); - var fixture = RestService.For(BaseAddress, settings); - await fixture.UploadStreamPart( - new() { Property1 = "test", Property2 = "test2" }, - new(stream, StreamPartFileName, PdfMediaType)); - } - - /// Verifies a byte array part keeps its supplied alias, file name and content type. - /// A task representing the asynchronous test. - [Test] - public async Task MultipartUploadShouldWorkWithByteArrayPart() - { - var handler = new MockHttpMessageHandler - { - Asserts = async content => - { - var parts = content.ToList(); - - await Assert.That(parts).HasSingleItem(); - - await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo("ByteArrayPartParamAlias"); - await Assert - .That(parts[0].Headers.ContentDisposition!.FileName) - .IsEqualTo("test-bytearraypart.pdf"); - await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(PdfMediaType); - - await using var str = await parts[0].ReadAsStreamAsync(); - await using var src = GetTestFileStream(TestFilePath); - await Assert.That(StreamsEqual(src, str)).IsTrue(); - } - }; - - var settings = new RefitSettings { HttpMessageHandlerFactory = () => handler }; - - await using var stream = GetTestFileStream(TestFilePath); - using var reader = new BinaryReader(stream); - var bytes = reader.ReadBytes((int)stream.Length); - - var fixture = RestService.For(BaseAddress, settings); - await fixture.UploadBytesPart( - new(bytes, "test-bytearraypart.pdf", PdfMediaType)); - } - - /// Verifies a collection of file parts plus an extra part keep their names and content types. - /// A task representing the asynchronous test. - [Test] - public async Task MultipartUploadShouldWorkWithFileInfoPart() - { - var fileName = CreateTempFile(); - - var handler = new MockHttpMessageHandler - { - Asserts = async content => - { - var parts = content.ToList(); - - await Assert.That(parts.Count).IsEqualTo(3); - - await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo(FileInfosName); - await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsEqualTo("test-fileinfopart.pdf"); - await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(PdfMediaType); - await using (var str = await parts[0].ReadAsStreamAsync()) - await using (var src = GetTestFileStream(TestFilePath)) - { - await Assert.That(StreamsEqual(src, str)).IsTrue(); - } - - await Assert.That(parts[1].Headers.ContentDisposition!.Name).IsEqualTo(FileInfosName); - await Assert - .That(parts[1].Headers.ContentDisposition!.FileName) - .IsEqualTo("test-fileinfopart2.pdf"); - await Assert.That(parts[1].Headers.ContentType).IsNull(); - await using (var str = await parts[1].ReadAsStreamAsync()) - await using (var src = GetTestFileStream(TestFilePath)) - { - await Assert.That(StreamsEqual(src, str)).IsTrue(); - } - - await Assert.That(parts[2].Headers.ContentDisposition!.Name).IsEqualTo("anotherFile"); - await Assert.That(parts[2].Headers.ContentDisposition!.FileName).IsEqualTo("additionalfile.pdf"); - await Assert.That(parts[2].Headers.ContentType!.MediaType).IsEqualTo(PdfMediaType); - await using (var str = await parts[2].ReadAsStreamAsync()) - await using (var src = GetTestFileStream(TestFilePath)) - { - await Assert.That(StreamsEqual(src, str)).IsTrue(); - } - } - }; - - var settings = new RefitSettings { HttpMessageHandlerFactory = () => handler }; - - try - { - await using var stream = GetTestFileStream(TestFilePath); - await using var outStream = File.OpenWrite(fileName); - await stream.CopyToAsync(outStream); - await outStream.FlushAsync(); - outStream.Close(); - - var fixture = RestService.For(BaseAddress, settings); - await fixture.UploadFileInfoPart( - [ - new( - new(fileName), - "test-fileinfopart.pdf", - PdfMediaType), - new( - new(fileName), - "test-fileinfopart2.pdf") - ], - new( - new(fileName), - fileName: "additionalfile.pdf", - contentType: PdfMediaType)); - } - finally - { - File.Delete(fileName); - } - } - - /// Verifies a single object is serialized to multipart content by each serializer. - /// The serializer type to exercise. - /// The expected media type produced by the serializer. - /// A task representing the asynchronous test. - [Test] - [Arguments(typeof(SystemTextJsonContentSerializer), JsonMediaType)] - [Arguments(typeof(XmlContentSerializer), "application/xml")] - public async Task MultipartUploadShouldWorkWithAnObject( - Type contentSerializerType, - string mediaType) - { - if (Activator.CreateInstance(contentSerializerType) is not IHttpContentSerializer serializer) - { - throw new ArgumentException( - $"{contentSerializerType.FullName} does not implement {nameof(IHttpContentSerializer)}"); - } - - var model1 = new ModelObject { Property1 = "M1.prop1", Property2 = "M1.prop2" }; - - var handler = new MockHttpMessageHandler - { - Asserts = async content => - { - var parts = content.ToList(); - - await Assert.That(parts).HasSingleItem(); - - await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo("theObject"); - await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsNull(); - await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(mediaType); - var result0 = await serializer - .FromHttpContentAsync(parts[0]) - .ConfigureAwait(false); - await Assert.That(result0!.Property1).IsEqualTo(model1.Property1); - await Assert.That(result0!.Property2).IsEqualTo(model1.Property2); - } - }; - - var settings = new RefitSettings - { - HttpMessageHandlerFactory = () => handler, - ContentSerializer = serializer - }; - - var fixture = RestService.For(BaseAddress, settings); - 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()); - - 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. - /// A task representing the asynchronous test. - [Test] - [Arguments(typeof(SystemTextJsonContentSerializer), JsonMediaType)] - [Arguments(typeof(XmlContentSerializer), "application/xml")] - public async Task MultipartUploadShouldWorkWithObjects( - Type contentSerializerType, - string mediaType) - { - if (Activator.CreateInstance(contentSerializerType) is not IHttpContentSerializer serializer) - { - throw new ArgumentException( - $"{contentSerializerType.FullName} does not implement {nameof(IHttpContentSerializer)}"); - } - - var model1 = new ModelObject { Property1 = "M1.prop1", Property2 = "M1.prop2" }; - - var model2 = new ModelObject { Property1 = "M2.prop1" }; - - var handler = new MockHttpMessageHandler - { - Asserts = async content => - { - var parts = content.ToList(); - - await Assert.That(parts.Count).IsEqualTo(2); - - await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo(TheObjectsName); - await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsNull(); - await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(mediaType); - var result0 = await serializer - .FromHttpContentAsync(parts[0]) - .ConfigureAwait(false); - await Assert.That(result0!.Property1).IsEqualTo(model1.Property1); - await Assert.That(result0!.Property2).IsEqualTo(model1.Property2); - - await Assert.That(parts[1].Headers.ContentDisposition!.Name).IsEqualTo(TheObjectsName); - await Assert.That(parts[1].Headers.ContentDisposition!.FileName).IsNull(); - await Assert.That(parts[1].Headers.ContentType!.MediaType).IsEqualTo(mediaType); - var result1 = await serializer - .FromHttpContentAsync(parts[1]) - .ConfigureAwait(false); - await Assert.That(result1!.Property1).IsEqualTo(model2.Property1); - await Assert.That(result1!.Property2).IsEqualTo(model2.Property2); - } - }; - - var settings = new RefitSettings - { - HttpMessageHandlerFactory = () => handler, - ContentSerializer = serializer - }; - - var fixture = RestService.For(BaseAddress, settings); - await fixture.UploadJsonObjects([model1, model2]); - } - - /// Verifies a mixture of object, file, enum, string and integer parts are uploaded correctly. - /// A task representing the asynchronous test. - [Test] - public async Task MultipartUploadShouldWorkWithMixedTypes() - { - var fileName = CreateTempFile(); - var name = Path.GetFileName(fileName); - - var model1 = new ModelObject { Property1 = "M1.prop1", Property2 = "M1.prop2" }; - - var model2 = new ModelObject { Property1 = "M2.prop1" }; - - var anotherModel = new AnotherModel { Foos = ["bar1", "bar2"] }; - - var handler = new MockHttpMessageHandler - { - Asserts = content => AssertMixedParts(content, model1, model2, name) - }; - - var settings = new RefitSettings { HttpMessageHandlerFactory = () => handler }; - - try - { - await using var stream = GetTestFileStream(TestFilePath); - await using var outStream = File.OpenWrite(fileName); - await stream.CopyToAsync(outStream); - await outStream.FlushAsync(); - outStream.Close(); - - var fixture = RestService.For(BaseAddress, settings); - await fixture.UploadMixedObjects( - [model1, model2], - anotherModel, - new(fileName), - AnEnum.Val2, - "frob", - ExpectedIntValue); - } - finally - { - File.Delete(fileName); - } - } - - /// Verifies arbitrary HTTP content is uploaded preserving its disposition and type. - /// A task representing the asynchronous test. - [Test] - public async Task MultipartUploadShouldWorkWithHttpContent() - { - var httpContent = new StringContent("some text", Encoding.ASCII, "application/custom"); - httpContent.Headers.ContentDisposition = new("attachment") - { - Name = "myName", - FileName = "myFileName", - }; - - var handler = new MockHttpMessageHandler - { - Asserts = async content => - { - var parts = content.ToList(); - - await Assert.That(parts).HasSingleItem(); - - await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo("myName"); - await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsEqualTo("myFileName"); - await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo("application/custom"); - var result0 = await parts[0].ReadAsStringAsync().ConfigureAwait(false); - await Assert.That(result0).IsEqualTo("some text"); - } - }; - - var settings = new RefitSettings { HttpMessageHandlerFactory = () => handler }; - - var fixture = RestService.For(BaseAddress, settings); - await fixture.UploadHttpContent(httpContent); - } - - /// Verifies the constructor rejects a null file name. - /// A task representing the asynchronous test. - [Test] - public async Task MultiPartConstructorShouldThrowArgumentNullExceptionWhenNoFileName() => - await Assert - .That(static () => _ = new ByteArrayPart([], null!, PdfMediaType)) - .ThrowsExactly(); - - /// Verifies the constructor rejects a null file info. - /// A task representing the asynchronous test. - [Test] - public async Task FileInfoPartConstructorShouldThrowArgumentNullExceptionWhenNoFileInfo() => - await Assert - .That(static () => _ = new FileInfoPart(null!, "file.pdf", PdfMediaType)) - .ThrowsExactly(); - /// Loads an embedded test resource as a stream. /// The relative path of the embedded resource. /// A stream over the embedded resource contents. @@ -812,86 +403,6 @@ internal static Stream GetTestFileStream(string relativeFilePath) $"Unable to find resource for path \"{relativeFilePath}\". Resource named \"{fullName}\" was not found in assembly."); } - /// Asserts the parts produced by the mixed-types multipart upload. - /// The multipart content observed by the handler. - /// The first expected model object. - /// The second expected model object. - /// The expected file-part file name. - /// A task representing the assertion work. - private static async Task AssertMixedParts(MultipartFormDataContent content, ModelObject model1, ModelObject model2, string name) - { - const int expectedPartCount = 7; - const int anotherModelPartIndex = 2; - const int filePartIndex = 3; - const int enumPartIndex = 4; - const int stringPartIndex = 5; - const int intPartIndex = 6; - const int expectedFooCount = 2; - - var parts = content.ToList(); - - await Assert.That(parts.Count).IsEqualTo(expectedPartCount); - - await Assert.That(parts[0].Headers.ContentDisposition!.Name).IsEqualTo(TheObjectsName); - await Assert.That(parts[0].Headers.ContentDisposition!.FileName).IsNull(); - await Assert.That(parts[0].Headers.ContentType!.MediaType).IsEqualTo(JsonMediaType); - var result0 = SystemTextJsonSerializer.Deserialize( - await parts[0].ReadAsStringAsync().ConfigureAwait(false), - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - await Assert.That(result0!.Property1).IsEqualTo(model1.Property1); - await Assert.That(result0!.Property2).IsEqualTo(model1.Property2); - - await Assert.That(parts[1].Headers.ContentDisposition!.Name).IsEqualTo(TheObjectsName); - await Assert.That(parts[1].Headers.ContentDisposition!.FileName).IsNull(); - await Assert.That(parts[1].Headers.ContentType!.MediaType).IsEqualTo(JsonMediaType); - var result1 = SystemTextJsonSerializer.Deserialize( - await parts[1].ReadAsStringAsync().ConfigureAwait(false), - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - await Assert.That(result1!.Property1).IsEqualTo(model2.Property1); - await Assert.That(result1!.Property2).IsEqualTo(model2.Property2); - - await Assert.That(parts[anotherModelPartIndex].Headers.ContentDisposition!.Name).IsEqualTo("anotherModel"); - await Assert.That(parts[anotherModelPartIndex].Headers.ContentDisposition!.FileName).IsNull(); - await Assert.That(parts[anotherModelPartIndex].Headers.ContentType!.MediaType).IsEqualTo(JsonMediaType); - var result2 = SystemTextJsonSerializer.Deserialize( - await parts[anotherModelPartIndex].ReadAsStringAsync().ConfigureAwait(false), - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - await Assert.That(result2!.Foos!.Length).IsEqualTo(expectedFooCount); - await Assert.That(result2!.Foos![0]).IsEqualTo("bar1"); - await Assert.That(result2!.Foos![1]).IsEqualTo("bar2"); - - await Assert.That(parts[filePartIndex].Headers.ContentDisposition!.Name).IsEqualTo("aFile"); - await Assert.That(parts[filePartIndex].Headers.ContentDisposition!.FileName).IsEqualTo(name); - await Assert.That(parts[filePartIndex].Headers.ContentType).IsNull(); - await using (var str = await parts[filePartIndex].ReadAsStreamAsync()) - await using (var src = GetTestFileStream(TestFilePath)) - { - await Assert.That(StreamsEqual(src, str)).IsTrue(); - } - - await Assert.That(parts[enumPartIndex].Headers.ContentDisposition!.Name).IsEqualTo("anEnum"); - await Assert.That(parts[enumPartIndex].Headers.ContentDisposition!.FileName).IsNull(); - await Assert.That(parts[enumPartIndex].Headers.ContentType!.MediaType).IsEqualTo(JsonMediaType); - var result4 = SystemTextJsonSerializer.Deserialize( - await parts[enumPartIndex].ReadAsStringAsync().ConfigureAwait(false), - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - await Assert.That(result4).IsEqualTo(AnEnum.Val2); - - await Assert.That(parts[stringPartIndex].Headers.ContentDisposition!.Name).IsEqualTo("aString"); - await Assert.That(parts[stringPartIndex].Headers.ContentDisposition!.FileName).IsNull(); - await Assert.That(parts[stringPartIndex].Headers.ContentType!.MediaType).IsEqualTo("text/plain"); - await Assert.That(parts[stringPartIndex].Headers.ContentType!.CharSet).IsEqualTo("utf-8"); - await Assert.That(await parts[stringPartIndex].ReadAsStringAsync()).IsEqualTo("frob"); - - await Assert.That(parts[intPartIndex].Headers.ContentDisposition!.Name).IsEqualTo("anInt"); - await Assert.That(parts[intPartIndex].Headers.ContentDisposition!.FileName).IsNull(); - await Assert.That(parts[intPartIndex].Headers.ContentType!.MediaType).IsEqualTo(JsonMediaType); - var result6 = SystemTextJsonSerializer.Deserialize( - await parts[intPartIndex].ReadAsStringAsync().ConfigureAwait(false), - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - await Assert.That(result6).IsEqualTo(ExpectedIntValue); - } - /// Creates an empty temporary file with a random, non-predictable name and returns its path. /// The full path to the newly created temporary file. private static string CreateTempFile() @@ -982,8 +493,8 @@ public HttpContent ToHttpContent(T item) => /// [SuppressMessage( - "Major Code Smell", - "S4018:Generic methods should provide type parameters", + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", Justification = "The method implements Refit's published serializer interface.")] public Task FromHttpContentAsync( HttpContent content, diff --git a/src/tests/Refit.Tests/NestedQueryObject.cs b/src/tests/Refit.Tests/NestedQueryObject.cs new file mode 100644 index 000000000..7d0ab5fee --- /dev/null +++ b/src/tests/Refit.Tests/NestedQueryObject.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. + +namespace Refit.Tests; + +/// A query object with a nested object property, flattened recursively under a dotted key. +public sealed class NestedQueryObject +{ + /// Gets or sets a top-level scalar property. + public string? Name { get; set; } + + /// Gets or sets a nested object whose properties compose under this property's key. + public AddressQuery? Address { get; set; } +} diff --git a/src/tests/Refit.Tests/NestedRouteInner.cs b/src/tests/Refit.Tests/NestedRouteInner.cs new file mode 100644 index 000000000..8025b6a69 --- /dev/null +++ b/src/tests/Refit.Tests/NestedRouteInner.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; + +/// The nested object whose property binds to a route path. +public sealed class NestedRouteInner +{ + /// Gets or sets the value bound into the route path. + public string? Value { get; set; } +} diff --git a/src/tests/Refit.Tests/NestedRoutePropertyBindingTests.cs b/src/tests/Refit.Tests/NestedRoutePropertyBindingTests.cs new file mode 100644 index 000000000..6cb640ddd --- /dev/null +++ b/src/tests/Refit.Tests/NestedRoutePropertyBindingTests.cs @@ -0,0 +1,24 @@ +// 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; + +/// Verifies a multi-segment route placeholder ({param.inner.value}) binds through a nested property +/// chain on a request object, exercising the reflection request builder's nested-property-chain resolver. +public sealed class NestedRoutePropertyBindingTests +{ + /// Verifies a nested property chain placeholder resolves to the nested property value in the request path. + /// A task that represents the asynchronous operation. + [Test] + public async Task NestedPropertyChainBindsToRoutePath() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod(nameof(INestedRouteApi.GetByNestedValue)); + var request = new NestedRouteRequest { Inner = new NestedRouteInner { Value = "abc" } }; + + var output = await factory([request]); + + await Assert.That(output.RequestUri!.ToString()).Contains("/items/abc"); + } +} diff --git a/src/tests/Refit.Tests/NestedRouteRequest.cs b/src/tests/Refit.Tests/NestedRouteRequest.cs new file mode 100644 index 000000000..e7918c6a9 --- /dev/null +++ b/src/tests/Refit.Tests/NestedRouteRequest.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; + +/// A request object exposing a nested object whose property binds to a route path. +public sealed class NestedRouteRequest +{ + /// Gets or sets the nested object supplying the route value. + public NestedRouteInner? Inner { get; set; } +} diff --git a/src/tests/Refit.Tests/NonStreamingContentSerializer.cs b/src/tests/Refit.Tests/NonStreamingContentSerializer.cs index 14c9f5597..75eb70e4d 100644 --- a/src/tests/Refit.Tests/NonStreamingContentSerializer.cs +++ b/src/tests/Refit.Tests/NonStreamingContentSerializer.cs @@ -20,7 +20,10 @@ public sealed class NonStreamingContentSerializer : IHttpContentSerializer public HttpContent ToHttpContent(T item) => _inner.ToHttpContent(item); /// - [SuppressMessage("Major Code Smell", "S4018:Generic methods should provide type parameters", Justification = "Type parameter intentionally specified explicitly by callers.")] + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "Type parameter intentionally specified explicitly by callers.")] public Task FromHttpContentAsync(HttpContent content, CancellationToken cancellationToken = default) => _inner.FromHttpContentAsync(content, cancellationToken); diff --git a/src/tests/Refit.Tests/NullFormattingFormUrlEncodedParameterFormatter.cs b/src/tests/Refit.Tests/NullFormattingFormUrlEncodedParameterFormatter.cs new file mode 100644 index 000000000..81932fb28 --- /dev/null +++ b/src/tests/Refit.Tests/NullFormattingFormUrlEncodedParameterFormatter.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. + +namespace Refit.Tests; + +/// A form Url-encoded parameter formatter that always formats values as null. +public sealed class NullFormattingFormUrlEncodedParameterFormatter : IFormUrlEncodedParameterFormatter +{ + /// Formats the value as null. + /// The value to format. + /// The format string. + /// Always . + public string? Format(object? value, string? formatString) => null; +} diff --git a/src/tests/Refit.Tests/NullableNestedStructQueryObject.cs b/src/tests/Refit.Tests/NullableNestedStructQueryObject.cs new file mode 100644 index 000000000..268953b89 --- /dev/null +++ b/src/tests/Refit.Tests/NullableNestedStructQueryObject.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. + +namespace Refit.Tests; + +/// A query object with a nullable nested value-type property, flattened inline through .Value. +public sealed class NullableNestedStructQueryObject +{ + /// Gets or sets a top-level scalar property. + public string? Name { get; set; } + + /// Gets or sets a nullable nested value type; when non-null its properties compose under this key. + public GeoPoint? Location { get; set; } +} diff --git a/src/tests/Refit.Tests/ObservableRequestCancellationTests.cs b/src/tests/Refit.Tests/ObservableRequestCancellationTests.cs new file mode 100644 index 000000000..acd6f3812 --- /dev/null +++ b/src/tests/Refit.Tests/ObservableRequestCancellationTests.cs @@ -0,0 +1,57 @@ +// 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; + +/// +/// Verifies the observable request adapter's cancellation-token resolution: a method that declares a +/// cancellation token uses the supplied token, while a method without one uses . +/// +public sealed class ObservableRequestCancellationTests +{ + /// The base address used to build the request messages. + private const string BaseAddress = "http://api/"; + + /// The identifier passed to the cancellation-token-free observable method. + private const int PatchId = 7; + + /// An observable method declaring a cancellation token completes using the supplied token. + /// A task that represents the asynchronous operation. + [Test] + public async Task ObservableWithCancellationTokenUsesSuppliedToken() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRestResultFuncForMethod(nameof(IObservableCancellableMethods.GetWithCancellation)); + var handler = new TestHttpMessageHandler(); + + using var cts = new CancellationTokenSource(); + var observable = (IObservable)factory( + new(handler) { BaseAddress = new(BaseAddress) }, + ["value", cts.Token])!; + + var result = await ObservableTestHelpers.Await(observable); + + await Assert.That(result).IsEqualTo("test"); + await Assert.That(handler.RequestMessage!.RequestUri!.ToString()).IsEqualTo("http://api/value/value"); + } + + /// An observable method without a cancellation token completes using . + /// A task that represents the asynchronous operation. + [Test] + public async Task ObservableWithoutCancellationTokenUsesNone() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRestResultFuncForMethod(nameof(IDummyHttpApi.PatchSomething)); + var handler = new TestHttpMessageHandler(); + + var observable = (IObservable)factory( + new(handler) { BaseAddress = new(BaseAddress) }, + [PatchId, "body"])!; + + var result = await ObservableTestHelpers.Await(observable); + + await Assert.That(result).IsEqualTo("test"); + await Assert.That(handler.RequestMessage!.RequestUri!.ToString()).IsEqualTo("http://api/foo/bar/7"); + } +} diff --git a/src/tests/Refit.Tests/ParameterFragmentTests.cs b/src/tests/Refit.Tests/ParameterFragmentTests.cs new file mode 100644 index 000000000..a4040d325 --- /dev/null +++ b/src/tests/Refit.Tests/ParameterFragmentTests.cs @@ -0,0 +1,60 @@ +// 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; + +/// Verifies the fragment-kind predicates of . +public sealed class ParameterFragmentTests +{ + /// An arbitrary parameter index used when building fragments. + private const int ArgumentIndex = 2; + + /// An arbitrary property index used when building object-property fragments. + private const int PropertyIndex = 0; + + /// Verifies a dynamic route fragment is classified as a dynamic route and nothing else. + /// A task representing the asynchronous test. + [Test] + public async Task DynamicRouteFragmentIsClassifiedAsDynamicRoute() + { + var fragment = ParameterFragment.Dynamic(ArgumentIndex); + + await Assert.That(fragment.IsDynamicRoute).IsTrue(); + await Assert.That(fragment.IsConstant).IsFalse(); + await Assert.That(fragment.IsObjectProperty).IsFalse(); + } + + /// Verifies constant and object-property fragments are not classified as dynamic routes. + /// A task representing the asynchronous test. + [Test] + public async Task ConstantAndObjectPropertyFragmentsAreNotDynamicRoutes() + { + await Assert.That(ParameterFragment.Constant("segment").IsDynamicRoute).IsFalse(); + await Assert.That(ParameterFragment.DynamicObject(ArgumentIndex, PropertyIndex).IsDynamicRoute).IsFalse(); + } + + /// Verifies a constant fragment (no argument index) is not classified as an object property, exercising + /// the negative-argument-index short-circuit of the object-property predicate. + /// A task representing the asynchronous test. + [Test] + public async Task ConstantFragmentIsNotObjectProperty() + { + // ArgumentIndex is negative for a constant, so the first condition short-circuits false. + await Assert.That(ParameterFragment.Constant("segment").IsObjectProperty).IsFalse(); + } + + /// Verifies an object-property fragment (both a parameter and a property index) is classified as an + /// object property and nothing else. + /// A task representing the asynchronous test. + [Test] + public async Task ObjectPropertyFragmentIsClassifiedAsObjectProperty() + { + var fragment = ParameterFragment.DynamicObject(ArgumentIndex, PropertyIndex); + + // ArgumentIndex >= 0 and PropertyIndex >= 0, so IsObjectProperty is true. + await Assert.That(fragment.IsObjectProperty).IsTrue(); + await Assert.That(fragment.IsConstant).IsFalse(); + await Assert.That(fragment.IsDynamicRoute).IsFalse(); + } +} diff --git a/src/tests/Refit.Tests/Person.cs b/src/tests/Refit.Tests/Person.cs index f6e8d5ba1..7986fc975 100644 --- a/src/tests/Refit.Tests/Person.cs +++ b/src/tests/Refit.Tests/Person.cs @@ -2,19 +2,15 @@ // 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; /// A test object exercising query serialization of non-public getters. public class Person { /// Gets or sets the first name. - [SuppressMessage("Design", "CA1044:Properties should not be write only", Justification = "Intentional private getters to verify Refit query serialization of objects with non-public getters.")] public string? FirstName { private get; set; } /// Gets or sets the last name. - [SuppressMessage("Design", "CA1044:Properties should not be write only", Justification = "Intentional private getters to verify Refit query serialization of objects with non-public getters.")] public string? LastName { private get; set; } /// Gets the full name. diff --git a/src/tests/Refit.Tests/PolymorphicContentSerializationTests.cs b/src/tests/Refit.Tests/PolymorphicContentSerializationTests.cs new file mode 100644 index 000000000..57fbdfc66 --- /dev/null +++ b/src/tests/Refit.Tests/PolymorphicContentSerializationTests.cs @@ -0,0 +1,49 @@ +// 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.Json.Serialization; + +namespace Refit.Tests; + +/// +/// Verifies that keeps the declared interface type when serializing a +/// value whose declared type is configured for polymorphic serialization, instead of switching to the runtime type. +/// The declared type must be an interface or abstract type for the serializer to consult its polymorphism signals. +/// +public sealed class PolymorphicContentSerializationTests +{ + /// The rectangle width serialized by the polymorphic-attribute fixture. + private const int RectangleWidth = 5; + + /// The circle radius serialized by the derived-type-attribute fixture. + private const int CircleRadius = 3; + + /// Verifies a declared interface annotated with is serialized polymorphically. + /// A task representing the asynchronous test. + [Test] + public async Task JsonPolymorphicAttributeKeepsDeclaredType() + { + var serializer = new SystemTextJsonContentSerializer(); + + using var content = serializer.ToHttpContent(new PolymorphicRectangle { Width = RectangleWidth }); + var body = await content.ReadAsStringAsync(); + + await Assert.That(body).Contains("\"kind\":\"rectangle\""); + await Assert.That(body).Contains("\"width\":5"); + } + + /// Verifies a declared interface carrying only is serialized polymorphically. + /// A task representing the asynchronous test. + [Test] + public async Task JsonDerivedTypeAttributeWithoutPolymorphicKeepsDeclaredType() + { + var serializer = new SystemTextJsonContentSerializer(); + + using var content = serializer.ToHttpContent(new DiscriminatedCircle { Radius = CircleRadius }); + var body = await content.ReadAsStringAsync(); + + await Assert.That(body).Contains("\"$type\":\"circle\""); + await Assert.That(body).Contains("\"radius\":3"); + } +} diff --git a/src/tests/Refit.Tests/PolymorphicRectangle.cs b/src/tests/Refit.Tests/PolymorphicRectangle.cs new file mode 100644 index 000000000..73bd6789f --- /dev/null +++ b/src/tests/Refit.Tests/PolymorphicRectangle.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. + +namespace Refit.Tests; + +/// A concrete shape used to exercise the polymorphic-attribute signal. +public sealed class PolymorphicRectangle : IPolymorphicShape +{ + /// + public string? Label { get; set; } + + /// Gets or sets the rectangle width. + public int Width { get; set; } +} diff --git a/src/tests/Refit.Tests/PooledBufferWriterTests.cs b/src/tests/Refit.Tests/PooledBufferWriterTests.cs index 90b585aaf..7fe39df5f 100644 --- a/src/tests/Refit.Tests/PooledBufferWriterTests.cs +++ b/src/tests/Refit.Tests/PooledBufferWriterTests.cs @@ -7,8 +7,9 @@ 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.")] +[SuppressMessage("Concurrency", "PSH1313:Call the async overload from an async method", Justification = "These tests intentionally exercise the synchronous Stream overrides.")] +[SuppressMessage("Performance", "PSH1313:Blocking call should be awaited", Justification = "These tests intentionally exercise the synchronous Stream overrides.")] +[SuppressMessage("Performance", "PSH1314:Use the Memory-based ReadAsync overload", Justification = "These tests intentionally exercise the byte-array Stream overrides.")] public class PooledBufferWriterTests { /// A sample byte marker with the value two. @@ -86,7 +87,7 @@ await Assert.That(() => writer.Advance(PooledBufferWriter.DefaultSize + 1)) public async Task DetachedStreamReadByteStopsAtUsedLength() { using var writer = new PooledBufferWriter(); - var span = writer.GetSpan(2); + var span = writer.GetSpan(TwoByteCount); span[0] = MarkerByteTen; span[1] = MarkerByteTwenty; writer.Advance(TwoByteCount); @@ -103,7 +104,7 @@ public async Task DetachedStreamReadByteStopsAtUsedLength() [Test] public async Task DetachedStreamReadValidatesArguments() { - using var writer = CreateWriter(1, 2, 3); + using var writer = CreateWriter(1, MarkerByteTwo, MarkerByteThree); await using var stream = writer.DetachStream(); var buffer = new byte[2]; @@ -115,10 +116,9 @@ public async Task DetachedStreamReadValidatesArguments() /// 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); + using var writer = CreateWriter(1, MarkerByteTwo, MarkerByteThree); await using var stream = writer.DetachStream(); var buffer = new byte[2]; @@ -141,7 +141,7 @@ public async Task DetachedStreamReportsLengthPositionAndPartialReads() [Test] public async Task DetachedStreamUnsupportedOperationsThrow() { - using var writer = CreateWriter(1, 2, 3); + using var writer = CreateWriter(1, MarkerByteTwo, MarkerByteThree); await using var stream = writer.DetachStream(); await Assert.That(stream.CanRead).IsTrue(); @@ -158,7 +158,7 @@ public async Task DetachedStreamUnsupportedOperationsThrow() [Test] public async Task DetachedStreamAsyncMethodsHonorCancellation() { - using var writer = CreateWriter(1, 2, 3); + using var writer = CreateWriter(1, MarkerByteTwo, MarkerByteThree); await using var stream = writer.DetachStream(); using var cancellationTokenSource = new CancellationTokenSource(); await cancellationTokenSource.CancelAsync(); @@ -214,14 +214,14 @@ await Assert.That(() => stream.ReadAsync(new byte[1].AsMemory()).AsTask()) [Test] public async Task DetachedStreamSpanReadStopsAtLength() { - using var writer = CreateWriter(1, 2, 3); + using var writer = CreateWriter(1, MarkerByteTwo, MarkerByteThree); await using var stream = writer.DetachStream(); var buffer = new byte[4]; const int thirdElementIndex = 2; const int fourthElementIndex = 3; - var firstRead = stream.Read(buffer.AsSpan(0, 2)); - var secondRead = await stream.ReadAsync(buffer.AsMemory(2, 2)); + var firstRead = stream.Read(buffer.AsSpan(0, TwoByteCount)); + var secondRead = await stream.ReadAsync(buffer.AsMemory(thirdElementIndex, TwoByteCount)); var thirdRead = stream.Read(buffer); await Assert.That(firstRead).IsEqualTo(TwoByteCount); diff --git a/src/tests/Refit.Tests/PrefixedSealedQueryObject.cs b/src/tests/Refit.Tests/PrefixedSealedQueryObject.cs new file mode 100644 index 000000000..8e5a01772 --- /dev/null +++ b/src/tests/Refit.Tests/PrefixedSealedQueryObject.cs @@ -0,0 +1,18 @@ +// 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; + +/// A sealed query object whose properties carry their own prefix and delimiter. +public sealed class PrefixedSealedQueryObject +{ + /// Gets or sets a prefixed scalar keyed as addr-Zip. + [Query("-", "addr")] + public string? Zip { get; set; } + + /// Gets or sets a prefixed, aliased scalar keyed as addr-cty. + [Query("-", "addr")] + [AliasAs("cty")] + public string? City { get; set; } +} diff --git a/src/tests/Refit.Tests/ProblemDetailsErrorValueReadingTests.cs b/src/tests/Refit.Tests/ProblemDetailsErrorValueReadingTests.cs new file mode 100644 index 000000000..9f3f5978c --- /dev/null +++ b/src/tests/Refit.Tests/ProblemDetailsErrorValueReadingTests.cs @@ -0,0 +1,82 @@ +// 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; +using System.Threading.Tasks; + +namespace Refit.Tests; + +/// +/// Verifies how reads non-string RFC 7807 values: a validation error message +/// that is not a JSON string is preserved as raw text, and a plain (non date-time) string extension member is read +/// back as its string value. +/// +public sealed class ProblemDetailsErrorValueReadingTests +{ + /// The example request URI reused across the tests. + private const string ExampleUri = "https://example.test"; + + /// The problem-details media type reused across the tests. + private const string ProblemJsonMediaType = "application/problem+json"; + + /// Verifies a non-string validation error value is preserved as its raw JSON text. + /// A task representing the asynchronous test. + [Test] + [SuppressMessage("Concurrency", "PSH1313:Call the async overload from an async method", Justification = "This test intentionally covers the synchronous compatibility factory.")] + public async Task NonStringErrorMessageIsPreservedAsRawJson() + { + using var response = CreateErrorResponse( + "{\"title\":\"invalid\",\"errors\":{\"Payload\":[{\"nested\":\"value\"}]}}", + ProblemJsonMediaType); + 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!.Errors["Payload"][0]).IsEqualTo("{\"nested\":\"value\"}"); + } + + /// Verifies a plain (non date-time) string extension member is read back as its string value. + /// A task representing the asynchronous test. + [Test] + [SuppressMessage("Concurrency", "PSH1313:Call the async overload from an async method", Justification = "This test intentionally covers the synchronous compatibility factory.")] + public async Task PlainStringExtensionIsReadAsString() + { + using var response = CreateErrorResponse( + "{\"title\":\"invalid\",\"note\":\"plain-text\"}", + ProblemJsonMediaType); + 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!.Extensions["note"]).IsEqualTo("plain-text"); + } + + /// Creates a bad-request error response carrying the given problem-details body. + /// The response content. + /// The response media type. + /// The response message. + private static HttpResponseMessage CreateErrorResponse(string content, string mediaType) + { + var response = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + RequestMessage = new(HttpMethod.Get, ExampleUri), + Content = new StringContent(content) + }; + + response.Content.Headers.ContentType = new(mediaType); + return response; + } +} diff --git a/src/tests/Refit.Tests/QueryConverterTests.cs b/src/tests/Refit.Tests/QueryConverterTests.cs new file mode 100644 index 000000000..c2b0d65f9 --- /dev/null +++ b/src/tests/Refit.Tests/QueryConverterTests.cs @@ -0,0 +1,111 @@ +// 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.Json; +using System.Text.Json.Serialization.Metadata; + +namespace Refit.Tests; + +/// Verifies a flattens a parameter through an . +public class QueryConverterTests +{ + /// The base address used by every generated client under test. + private const string BaseAddress = "http://api/"; + + /// An age value used to prove non-string entries format correctly. + private const int Age = 42; + + /// A count value used to prove numeric properties format correctly. + private const int CountValue = 3; + + /// Reflection-backed serializer options shared by the interop-converter flattening fixture. + private static readonly JsonSerializerOptions ReflectionSerializerOptions = + new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + + /// Verifies a converter flattens an object-valued dictionary the generator cannot flatten itself. + /// A task that represents the asynchronous operation. + [Test] + public async Task ConverterFlattensObjectValuedDictionary() + { + IDictionary filter = new Dictionary + { + ["name"] = "ada", + ["age"] = Age, + ["skip"] = null! + }; + + var generated = await SendAsync(api => api.Search(filter)); + + await Assert.That(generated).IsEqualTo("/search?name=ada&age=42"); + } + + /// Verifies the parameter-level [Query(Prefix)] is passed to the converter as the key prefix. + /// A task that represents the asynchronous operation. + [Test] + public async Task ConverterReceivesParameterPrefix() + { + IDictionary filter = new Dictionary { ["a"] = 1 }; + + var generated = await SendAsync(api => api.SearchPrefixed(filter)); + + await Assert.That(generated).IsEqualTo("/prefixed?f.a=1"); + } + + /// Verifies a null converter argument emits no query string. + /// A task that represents the asynchronous operation. + [Test] + public async Task NullConverterArgumentEmitsNoQueryString() + { + var generated = await SendAsync(static api => api.Search(null!)); + + await Assert.That(generated).IsEqualTo("/search"); + } + + /// Verifies the System.Text.Json interop converter flattens a POCO, honoring JSON names and nesting. + /// A task that represents the asynchronous operation. + [Test] + public async Task SystemTextJsonConverterFlattensRegisteredType() + { + var settings = new RefitSettings + { + ContentSerializer = new SystemTextJsonContentSerializer(ReflectionSerializerOptions) + }; + var filter = new StjFilter { Query = "ada", Count = CountValue, Sub = new StjNested { City = "wien" } }; + + var generated = await SendAsync(settings, api => api.SearchStj(filter)); + + await Assert.That(generated).IsEqualTo("/stj?q=ada&Count=3&Sub.City=wien"); + } + + /// Verifies the attribute exposes the converter type passed to its constructor. + /// A task that represents the asynchronous operation. + [Test] + public async Task AttributeExposesConverterType() + { + var attribute = new QueryConverterAttribute(typeof(DictionaryObjectQueryConverter)); + + await Assert.That(attribute.ConverterType).IsEqualTo(typeof(DictionaryObjectQueryConverter)); + } + + /// Sends one request through the generated client and returns the relative URI it produced. + /// The interface method to invoke. + /// The generated request's path and query. + private static Task SendAsync(Func> call) => + SendAsync(new RefitSettings(), call); + + /// Sends one request through the generated client with the given settings and returns its relative URI. + /// The settings to build the client with. + /// The interface method to invoke. + /// The generated request's path and query. + private static async Task SendAsync(RefitSettings settings, Func> call) + { + var handler = new TestHttpMessageHandler(); + using var client = new HttpClient(handler) { BaseAddress = new(BaseAddress) }; + var api = RestService.ForGenerated(client, settings); + + _ = await call(api); + + return handler.RequestMessage!.RequestUri!.PathAndQuery; + } +} diff --git a/src/tests/Refit.Tests/QueryObjectFlatteningTests.cs b/src/tests/Refit.Tests/QueryObjectFlatteningTests.cs new file mode 100644 index 000000000..3bbf120f9 --- /dev/null +++ b/src/tests/Refit.Tests/QueryObjectFlatteningTests.cs @@ -0,0 +1,448 @@ +// 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; + +/// +/// Verifies that a query object flattened inline by the source generator produces exactly the same URI as the +/// reflection request builder, which walks the same properties at runtime. +/// +public class QueryObjectFlatteningTests +{ + /// The base address used by every generated client under test. + private const string BaseAddress = "http://api/"; + + /// The identifier assigned to the value-typed query object. + private const int StructId = 3; + + /// Verifies an explicitly marked query object flattens to the reflection builder's query string. + /// A task that represents the asynchronous operation. + [Test] + public async Task ExplicitQueryObjectFlattensLikeReflection() + { + var query = CreateSealedQueryObject(); + + await AssertParityAsync( + "/obj?Name=ada&n=7&Price=5.00&Kept=&Sort=date-desc", + api => api.FlattenObject(query), + new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenObject)), + query); + } + + /// Verifies an unattributed query object on a body-less method flattens like the reflection builder. + /// A task that represents the asynchronous operation. + [Test] + public async Task ImpliedQueryObjectFlattensLikeReflection() + { + var query = CreateSealedQueryObject(); + + await AssertParityAsync( + "/implied?Name=ada&n=7&Price=5.00&Kept=&Sort=date-desc", + api => api.FlattenImplied(query), + new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenImplied)), + query); + } + + /// Verifies property-level prefixes and aliases compose exactly as the reflection builder composes them. + /// A task that represents the asynchronous operation. + [Test] + public async Task PrefixedPropertiesFlattenLikeReflection() + { + var query = new PrefixedSealedQueryObject { Zip = "1010", City = "Wien" }; + + await AssertParityAsync( + "/prefixed?addr-Zip=1010&addr-cty=Wien", + api => api.FlattenPrefixed(query), + new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenPrefixed)), + query); + } + + /// Verifies a value-typed query object flattens like the reflection builder. + /// A task that represents the asynchronous operation. + [Test] + public async Task StructQueryObjectFlattensLikeReflection() + { + var query = new StructQueryObject { Id = StructId, Tag = "a b" }; + + await AssertParityAsync( + "/struct?Id=3&Tag=a%20b", + api => api.FlattenStruct(query), + new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenStruct)), + query); + } + + /// Verifies a parameter-level prefix is prepended to every flattened property key. + /// A task that represents the asynchronous operation. + [Test] + public async Task ParameterPrefixFlattensLikeReflection() + { + var query = new StructQueryObject { Id = StructId, Tag = "x" }; + + await AssertParityAsync( + "/paramprefix?root.Id=3&root.Tag=x", + api => api.FlattenParameterPrefix(query), + new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenParameterPrefix)), + query); + } + + /// + /// Verifies a derived instance passed through a base-typed parameter contributes only the declared type's + /// properties, and that this is exactly where generated request building diverges from reflection. + /// + /// A task that represents the asynchronous operation. + [Test] + public async Task DerivedInstanceFlattensDeclaredPropertiesOnly() + { + var query = new DerivedRecordWithProperty("queryName"); + + var generated = await SendGeneratedAsync(new RefitSettings(), api => api.FlattenDeclared(query)); + var reflected = await new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenDeclared))([query]); + + // The generator sees BaseRecord and emits only its Value property. + await Assert.That(generated).IsEqualTo("/declared?Value=value"); + + // The reflection builder walks the runtime type and additionally emits the derived Name property. + await Assert.That(reflected.RequestUri!.PathAndQuery).IsEqualTo("/declared?Name=queryName&Value=value"); + } + + /// + /// Verifies flattened property keys resolve with the same serializer-aware precedence the reflection builder uses: + /// [AliasAs] wins, then the content serializer's field name ([JsonPropertyName] for the default + /// System.Text.Json serializer), then the key formatter. + /// + /// A task that represents the asynchronous operation. + [Test] + public async Task JsonNamedPropertiesFlattenLikeReflection() + { + var query = new JsonNamedQueryObject { Named = "a", Both = "b", Plain = "c" }; + + await AssertParityAsync( + "/json?json_name=a&alias_wins=b&Plain=c", + api => api.FlattenJsonNamed(query), + new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenJsonNamed)), + query); + } + + /// + /// Verifies that disabling falls both + /// builders back to the pre-V14 naming: [JsonPropertyName] is ignored and the CLR name is used, while + /// [AliasAs] still wins. + /// + /// A task that represents the asynchronous operation. + [Test] + public async Task JsonNamedPropertiesFallBackToClrNamesWhenDisabled() + { + var settings = new RefitSettings { HonorContentSerializerPropertyNamesInQuery = false }; + var query = new JsonNamedQueryObject { Named = "a", Both = "b", Plain = "c" }; + + var generated = await SendGeneratedAsync(settings, api => api.FlattenJsonNamed(query)); + var reflected = await new RequestBuilderImplementation(settings) + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenJsonNamed))([query]); + + await Assert.That(generated).IsEqualTo("/json?Named=a&alias_wins=b&Plain=c"); + await Assert.That(generated).IsEqualTo(reflected.RequestUri!.PathAndQuery); + } + + /// Verifies collection-valued properties flatten to repeated/joined keys exactly as the reflection builder does. + /// A task that represents the asynchronous operation. + [Test] + public async Task CollectionPropertiesFlattenLikeReflection() + { + var query = new CollectionPropertyQueryObject + { + Ids = [0, 1], + Tags = [QuerySort.DateDescending, QuerySort.Name], + Names = ["a", "b"] + }; + + await AssertParityAsync( + "/coll?Ids=0%2C1&Tags=date-desc&Tags=Name&n=a%2Cb", + api => api.FlattenCollections(query), + new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenCollections)), + query); + } + + /// + /// Verifies that with a customized the generated slow path reproduces the + /// reflection builder's two formatting passes for collection properties: each element is formatted, joined (or + /// kept separate under Multi), then formatted again. + /// + /// A task that represents the asynchronous operation. + [Test] + public async Task CollectionPropertiesWithCustomFormatterMatchReflection() + { + var settings = new RefitSettings { UrlParameterFormatter = new BracketUrlParameterFormatter() }; + var query = new CollectionPropertyQueryObject + { + Ids = [0, 1], + Tags = [QuerySort.Name], + Names = ["a"] + }; + + var generated = await SendGeneratedAsync(settings, api => api.FlattenCollections(query)); + var reflected = await new RequestBuilderImplementation(settings) + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenCollections))([query]); + + await Assert.That(generated).IsEqualTo(reflected.RequestUri!.PathAndQuery); + } + + /// Verifies a nested object property flattens recursively under a dotted key, exactly as reflection does. + /// A task that represents the asynchronous operation. + [Test] + public async Task NestedObjectFlattensLikeReflection() + { + var query = new NestedQueryObject { Name = "ada", Address = new AddressQuery { City = "wien", Zip = "1010" } }; + + await AssertParityAsync( + "/nested?Name=ada&Address.City=wien&Address.z=1010", + api => api.FlattenNested(query), + new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenNested)), + query); + } + + /// Verifies a custom key formatter is applied to every nested key segment, matching the reflection builder. + /// A task that represents the asynchronous operation. + [Test] + public async Task NestedObjectKeysUseCustomKeyFormatter() + { + var settings = new RefitSettings { UrlParameterKeyFormatter = new CamelCaseUrlParameterKeyFormatter() }; + var query = new NestedQueryObject { Name = "ada", Address = new AddressQuery { City = "wien", Zip = "1010" } }; + + var generated = await SendGeneratedAsync(settings, api => api.FlattenNested(query)); + var reflected = await new RequestBuilderImplementation(settings) + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenNested))([query]); + + await Assert.That(generated).IsEqualTo("/nested?name=ada&address.city=wien&address.z=1010"); + await Assert.That(generated).IsEqualTo(reflected.RequestUri!.PathAndQuery); + } + + /// Verifies a null nested object contributes no query pairs, matching the reflection builder. + /// A task that represents the asynchronous operation. + [Test] + public async Task NullNestedObjectEmitsNoNestedKeys() + { + var query = new NestedQueryObject { Name = "ada", Address = null }; + + await AssertParityAsync( + "/nested?Name=ada", + api => api.FlattenNested(query), + new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenNested)), + query); + } + + /// Verifies a non-null nullable nested value type flattens exactly like the reflection builder. + /// A task that represents the asynchronous operation. + [Test] + public async Task NullableNestedStructWithValueFlattensLikeReflection() + { + const double Lat = 1.5; + const double Lng = 2.5; + var query = new NullableNestedStructQueryObject { Name = "here", Location = new GeoPoint(Lat, Lng) }; + + var generated = await SendGeneratedAsync(new RefitSettings(), api => api.FlattenNullableNestedStruct(query)); + var reflected = await new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenNullableNestedStruct))([query]); + + await Assert.That(generated).IsEqualTo(reflected.RequestUri!.PathAndQuery); + } + + /// Verifies a null nullable nested value type contributes no query pairs, matching reflection. + /// A task that represents the asynchronous operation. + [Test] + public async Task NullableNestedStructWhenNullOmittedLikeReflection() + { + var query = new NullableNestedStructQueryObject { Name = "here", Location = null }; + + var generated = await SendGeneratedAsync(new RefitSettings(), api => api.FlattenNullableNestedStruct(query)); + var reflected = await new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenNullableNestedStruct))([query]); + + await Assert.That(generated).IsEqualTo(reflected.RequestUri!.PathAndQuery); + } + + /// Verifies dictionary properties inside a query object expand exactly like the reflection builder. + /// A task that represents the asynchronous operation. + [Test] + public async Task DictionaryPropertyExpandsLikeReflection() + { + const int Count = 10; + var query = new DictionaryPropertyQueryObject { Name = "root" }; + query.Tags["a"] = "1"; + query.Tags["b"] = "2"; + query.Counts["x"] = Count; + + var generated = await SendGeneratedAsync(new RefitSettings(), api => api.FlattenDictionaryProperty(query)); + var reflected = await new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenDictionaryProperty))([query]); + + await Assert.That(generated).IsEqualTo(reflected.RequestUri!.PathAndQuery); + } + + /// Verifies a dictionary expands to one query pair per entry, exactly as the reflection builder does. + /// A task that represents the asynchronous operation. + [Test] + public async Task DictionaryExpandsLikeReflection() + { + IDictionary query = new Dictionary + { + ["key0"] = "a b", + ["key1"] = "two" + }; + + await AssertParityAsync( + "/dict?key0=a%20b&key1=two", + api => api.ExpandDictionary(query), + new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.ExpandDictionary)), + query); + } + + /// Verifies an enum-keyed dictionary renders its keys through the enum member value. + /// A task that represents the asynchronous operation. + [Test] + public async Task EnumKeyedDictionaryExpandsLikeReflection() + { + const int firstValue = 10; + const int secondValue = 20; + var query = new Dictionary + { + [QuerySort.DateDescending] = firstValue, + [QuerySort.Name] = secondValue + }; + + await AssertParityAsync( + "/dict/enum?date-desc=10&Name=20", + api => api.ExpandEnumKeyedDictionary(query), + new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.ExpandEnumKeyedDictionary)), + query); + } + + /// Verifies a parameter-level prefix is prepended to every dictionary key. + /// A task that represents the asynchronous operation. + [Test] + public async Task PrefixedDictionaryExpandsLikeReflection() + { + IDictionary query = new Dictionary { ["a"] = "1" }; + + await AssertParityAsync( + "/dict/prefixed?root.a=1", + api => api.ExpandPrefixedDictionary(query), + new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.ExpandPrefixedDictionary)), + query); + } + + /// Verifies a dictionary entry with a null value is omitted, matching the reflection builder. + /// A task that represents the asynchronous operation. + [Test] + public async Task DictionaryOmitsNullValuedEntries() + { + IDictionary query = new Dictionary + { + ["kept"] = "yes", + ["dropped"] = null! + }; + + await AssertParityAsync( + "/dict?kept=yes", + api => api.ExpandDictionary(query), + new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.ExpandDictionary)), + query); + } + + /// Verifies a custom key formatter is applied to unaliased property names only. + /// A task that represents the asynchronous operation. + [Test] + public async Task CustomKeyFormatterAppliesToUnaliasedPropertiesOnly() + { + var settings = new RefitSettings { UrlParameterKeyFormatter = new CamelCaseUrlParameterKeyFormatter() }; + var query = new PrefixedSealedQueryObject { Zip = "1010", City = "Wien" }; + + var generated = await SendGeneratedAsync(settings, api => api.FlattenPrefixed(query)); + var reflected = await new RequestBuilderImplementation(settings) + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenPrefixed))([query]); + + await Assert.That(generated).IsEqualTo("/prefixed?addr-zip=1010&addr-cty=Wien"); + await Assert.That(generated).IsEqualTo(reflected.RequestUri!.PathAndQuery); + } + + /// Verifies a null query object emits no query pairs at all. + /// A task that represents the asynchronous operation. + [Test] + public async Task NullQueryObjectEmitsNoQueryString() + { + var generated = await SendGeneratedAsync(new RefitSettings(), static api => api.FlattenObject(null!)); + var reflected = await new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IQueryObjectApi.FlattenObject))([null!]); + + await Assert.That(generated).IsEqualTo("/obj"); + await Assert.That(generated).IsEqualTo(reflected.RequestUri!.PathAndQuery); + } + + /// Builds the canonical query object used by the flattening tests. + /// A populated query object. + private static SealedQueryObject CreateSealedQueryObject() + { + const int number = 7; + const double price = 5D; + + return new() + { + Name = "ada", + Number = number, + Price = price, + Kept = null, + Skipped = null, + Sort = QuerySort.DateDescending, + Hidden = "hidden", + Secret = "secret" + }; + } + + /// Sends one request through the source-generated client and returns the relative URI it produced. + /// The settings to build the client with. + /// The interface method to invoke. + /// The generated request's path and query. + private static async Task SendGeneratedAsync(RefitSettings settings, Func> call) + { + var handler = new TestHttpMessageHandler(); + using var client = new HttpClient(handler) { BaseAddress = new(BaseAddress) }; + var api = RestService.ForGenerated(client, settings); + + _ = await call(api); + + return handler.RequestMessage!.RequestUri!.PathAndQuery; + } + + /// Asserts the generated and reflection request builders produce the same expected relative URI. + /// The expected path and query. + /// The interface method to invoke on the generated client. + /// The reflection builder's request factory for the same method. + /// The single argument passed to the reflection factory. + /// A task that represents the asynchronous operation. + private static async Task AssertParityAsync( + string expectedPathAndQuery, + Func> call, + Func> reflectionFactory, + object argument) + { + var generated = await SendGeneratedAsync(new RefitSettings(), call); + var reflected = await reflectionFactory([argument]); + + await Assert.That(generated).IsEqualTo(expectedPathAndQuery); + await Assert.That(reflected.RequestUri!.PathAndQuery).IsEqualTo(expectedPathAndQuery); + } +} diff --git a/src/tests/Refit.Tests/QueryObjectPropertyTypeClassificationTests.cs b/src/tests/Refit.Tests/QueryObjectPropertyTypeClassificationTests.cs new file mode 100644 index 000000000..e2c5fc4e6 --- /dev/null +++ b/src/tests/Refit.Tests/QueryObjectPropertyTypeClassificationTests.cs @@ -0,0 +1,72 @@ +// 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.Globalization; + +namespace Refit.Tests; + +/// Flattens a query object whose properties span the classifier's simple and formattable types. +public sealed class QueryObjectPropertyTypeClassificationTests +{ + /// The nullable-number value flattened by the classification fixture. + private const int OptionalNumberValue = 42; + + /// The plain-number value flattened by the classification fixture. + private const int NumberValue = 7; + + /// Each classified property type is flattened into the query string. + /// A task that represents the asynchronous operation. + [Test] + public async Task ClassifiedPropertyTypesAreFlattenedIntoQuery() + { + var filters = new QueryObjectWithClassifiedTypes + { + Text = "hello", + Flag = true, + Symbol = 'x', + Link = new("http://example/resource"), + Culture = CultureInfo.InvariantCulture, + OptionalNumber = OptionalNumberValue, + Number = NumberValue + }; + + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod(nameof(IQueryObjectTypesApi.Search)); + var output = await factory([filters]); + + var query = output.RequestUri!.Query; + + await Assert.That(query).Contains("Text=hello"); + await Assert.That(query).Contains("Flag=True"); + await Assert.That(query).Contains("Symbol=x"); + await Assert.That(query).Contains("Link="); + await Assert.That(query).Contains("Culture="); + await Assert.That(query).Contains("OptionalNumber=42"); + await Assert.That(query).Contains("Number=7"); + } + + /// A null nullable value-type property is omitted from the query string. + /// A task that represents the asynchronous operation. + [Test] + public async Task NullNullablePropertyIsOmitted() + { + var filters = new QueryObjectWithClassifiedTypes + { + Text = "hello", + Flag = false, + Symbol = 'y', + OptionalNumber = null, + Number = 1 + }; + + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod(nameof(IQueryObjectTypesApi.Search)); + var output = await factory([filters]); + + var query = output.RequestUri!.Query; + + await Assert.That(query).Contains("Number=1"); + await Assert.That(query).DoesNotContain("OptionalNumber"); + } +} diff --git a/src/tests/Refit.Tests/QueryObjectWithClassifiedTypes.cs b/src/tests/Refit.Tests/QueryObjectWithClassifiedTypes.cs new file mode 100644 index 000000000..1cd05aca2 --- /dev/null +++ b/src/tests/Refit.Tests/QueryObjectWithClassifiedTypes.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. + +using System.Globalization; + +namespace Refit.Tests; + +/// A query object whose property types span the classifier's simple and formattable cases. +public sealed class QueryObjectWithClassifiedTypes +{ + /// Gets or sets a string property. + public string? Text { get; set; } + + /// Gets or sets a boolean property. + public bool Flag { get; set; } + + /// Gets or sets a character property. + public char Symbol { get; set; } + + /// Gets or sets a property. + public Uri? Link { get; set; } + + /// Gets or sets a property. + public CultureInfo? Culture { get; set; } + + /// Gets or sets a nullable value-type property. + public int? OptionalNumber { get; set; } + + /// Gets or sets a plain formattable value-type property. + public int Number { get; set; } +} diff --git a/src/tests/Refit.Tests/QuerySort.cs b/src/tests/Refit.Tests/QuerySort.cs new file mode 100644 index 000000000..b3df1b316 --- /dev/null +++ b/src/tests/Refit.Tests/QuerySort.cs @@ -0,0 +1,18 @@ +// 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.Runtime.Serialization; + +namespace Refit.Tests; + +/// Sort order used by the query-object flattening tests. +public enum QuerySort +{ + /// Newest first, renamed by . + [EnumMember(Value = "date-desc")] + DateDescending, + + /// Alphabetical, rendered as the member name. + Name +} diff --git a/src/tests/Refit.Tests/Refit.Tests.csproj b/src/tests/Refit.Tests/Refit.Tests.csproj index 7520b5f3b..fee34822d 100644 --- a/src/tests/Refit.Tests/Refit.Tests.csproj +++ b/src/tests/Refit.Tests/Refit.Tests.csproj @@ -32,5 +32,6 @@ + diff --git a/src/tests/Refit.Tests/RefitSettingsTests.cs b/src/tests/Refit.Tests/RefitSettingsTests.cs index 52dbb17ac..4e1d60d85 100644 --- a/src/tests/Refit.Tests/RefitSettingsTests.cs +++ b/src/tests/Refit.Tests/RefitSettingsTests.cs @@ -16,7 +16,7 @@ public async Task Can_CreateRefitSettings_WithoutException() var urlParameterKeyFormatter = new CamelCaseUrlParameterKeyFormatter(); var formUrlEncodedParameterFormatter = new DefaultFormUrlEncodedParameterFormatter(); - var exception = CaptureException(() => new RefitSettings()); + var exception = CaptureException(static () => new RefitSettings()); await Assert.That(exception).IsNull(); exception = CaptureException(() => new RefitSettings(contentSerializer)); @@ -42,6 +42,16 @@ public async Task Can_CreateRefitSettings_WithoutException() await Assert.That(exception).IsNull(); } + /// Verifies the full constructor rejects a null content serializer. + /// A task that represents the asynchronous operation. + [Test] + public async Task Constructor_RejectsNullContentSerializer() + { + var exception = CaptureException(static () => new RefitSettings(null!, null, null, null)); + + await Assert.That(exception).IsTypeOf(); + } + /// Invokes a factory and returns any exception it throws, otherwise null. /// The factory whose construction is being verified. /// The thrown exception, or null when the factory completed successfully. diff --git a/src/tests/Refit.Tests/ReflectionPropertyHelpersTests.cs b/src/tests/Refit.Tests/ReflectionPropertyHelpersTests.cs new file mode 100644 index 000000000..78bb4271c --- /dev/null +++ b/src/tests/Refit.Tests/ReflectionPropertyHelpersTests.cs @@ -0,0 +1,52 @@ +// 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; + +namespace Refit.Tests; + +/// Verifies returns only the properties that can be read through a +/// public getter, which the reflection request builder relies on when flattening query objects. +public sealed class ReflectionPropertyHelpersTests +{ + /// Verifies a readable public property is kept while a non-public-getter property is skipped. + /// A task representing the asynchronous test. + [Test] + public async Task GetReadablePublicInstancePropertiesKeepsPubliclyReadableProperties() + { + var names = ReflectionPropertyHelpers + .GetReadablePublicInstanceProperties(typeof(PropertyShapes)) + .Select(static property => property.Name) + .ToArray(); + + await Assert.That(names).Contains("Readable"); + await Assert.That(names).DoesNotContain("NonPublicGetter"); + } + + /// Verifies a public property that cannot be read is excluded. exposes the + /// public write-only IdealProcessor and ProcessorAffinity properties (their metadata is present on + /// every platform), so reflecting over the type exercises the not-readable branch. + /// A task representing the asynchronous test. + [Test] + public async Task GetReadablePublicInstancePropertiesExcludesWriteOnlyProperties() + { + var names = ReflectionPropertyHelpers + .GetReadablePublicInstanceProperties(typeof(ProcessThread)) + .Select(static property => property.Name) + .ToArray(); + + await Assert.That(names).DoesNotContain("IdealProcessor"); + await Assert.That(names).DoesNotContain("ProcessorAffinity"); + } + + /// A type whose properties span the readable and non-public-getter shapes. + private sealed class PropertyShapes + { + /// Gets or sets a readable public property that is returned. + public string? Readable { get; set; } + + /// Gets or sets a property whose getter is non-public, so it is skipped. + public string? NonPublicGetter { internal get; set; } + } +} diff --git a/src/tests/Refit.Tests/ReflectionRequestBuildingTests.cs b/src/tests/Refit.Tests/ReflectionRequestBuildingTests.cs new file mode 100644 index 000000000..7114d7bab --- /dev/null +++ b/src/tests/Refit.Tests/ReflectionRequestBuildingTests.cs @@ -0,0 +1,113 @@ +// 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; + +/// Verifies request-building paths of the reflection request builder that only the reflection path reaches: +/// authorization parameters, synchronous and streaming body serialization, buffered bodies, round-tripping null path +/// values and header parsing from base interfaces. +public sealed class ReflectionRequestBuildingTests +{ + /// The base address used when building request URIs. + private const string BaseUrl = "http://api/"; + + /// The serialized JSON body shared by the body-serialization fixtures. + private const string SerializedBody = "{\"name\":\"x\"}"; + + /// Verifies an authorization parameter contributes the Authorization header. + /// A task representing the asynchronous test. + [Test] + public async Task AuthorizeParameterAddsAuthorizationHeader() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod(nameof(IAuthorizeHeaderApi.Get)); + + var output = await factory(["abc"]); + + await Assert.That(output.Headers.Authorization!.ToString()).IsEqualTo("Bearer abc"); + } + + /// Verifies a buffered body-serialization mode serializes the body synchronously. + /// A task representing the asynchronous test. + [Test] + public async Task BufferedSerializationModeSerializesBodySynchronously() + { + var settings = new RefitSettings { RequestBodySerialization = RequestBodySerializationMode.Buffered }; + var fixture = new RequestBuilderImplementation(settings); + + var handler = await fixture.RunRequest(nameof(IBodySerializationApi.Post))([new BodyPayload { Name = "x" }]); + + await Assert.That(handler.SendContent).IsEqualTo(SerializedBody); + } + + /// Verifies a streamed body-serialization mode serializes the body as streaming content. + /// A task representing the asynchronous test. + [Test] + public async Task StreamedSerializationModeSerializesBodyAsStream() + { + var settings = new RefitSettings { RequestBodySerialization = RequestBodySerializationMode.Streamed }; + var fixture = new RequestBuilderImplementation(settings); + + var handler = await fixture.RunRequest(nameof(IBodySerializationApi.Post))([new BodyPayload { Name = "x" }]); + + await Assert.That(handler.SendContent).IsEqualTo(SerializedBody); + } + + /// Verifies a buffered body attribute assigns the serialized content directly. + /// A task representing the asynchronous test. + [Test] + public async Task BufferedBodyAttributeAssignsSerializedContentDirectly() + { + var fixture = new RequestBuilderImplementation(); + + var handler = await fixture.RunRequest(nameof(IBufferedBodyApi.Post))([new BodyPayload { Name = "x" }]); + + await Assert.That(handler.SendContent).IsEqualTo(SerializedBody); + } + + /// Verifies a round-tripping path parameter formats a null value as an empty segment. + /// A task representing the asynchronous test. + [Test] + public async Task RoundTrippingPathParameterFormatsNullAsEmpty() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod(nameof(IRoundTrippingNullString.GetValue)); + + var output = await factory([null!]); + + var uri = new Uri(new(BaseUrl), output.RequestUri!); + await Assert.That(uri.PathAndQuery).IsEqualTo("/"); + } + + /// Verifies headers declared on a base interface are applied and blank entries are ignored. + /// A task representing the asynchronous test. + [Test] + public async Task HeadersFromBaseInterfaceAreAppliedAndBlankEntriesIgnored() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod(nameof(IInheritedHeadersApi.Get)); + + var output = await factory([]); + + await Assert.That(output.Headers.GetValues("X-Base")).IsCollectionEqualTo(["base"]); + } + + /// Verifies RFC 3986 resolution splits a route template's inline query string from its path and merges it + /// with the dynamic query parameters. + /// A task representing the asynchronous test. + [Test] + public async Task Rfc3986ResolutionMergesInlineTemplateQueryWithQueryParameters() + { + const int pageNumber = 3; + var settings = new RefitSettings { UrlResolution = UrlResolutionMode.Rfc3986 }; + var fixture = new RequestBuilderImplementation(settings); + var factory = fixture.BuildRequestFactoryForMethod(nameof(IRfcUrlResolutionApi.GetValuesWithQuery)); + + var output = await factory([pageNumber]); + + // The reflection builder assigns a relative URI ("values?active=true&page=3") that the HttpClient resolves + // against the base address once the request is sent, so the captured request carries the merged absolute URI. + await Assert.That(output.RequestUri!.AbsoluteUri).IsEqualTo("http://api/values?active=true&page=3"); + } +} diff --git a/src/tests/Refit.Tests/ReflectionTests.cs b/src/tests/Refit.Tests/ReflectionTests.cs index da186ab0a..bb50b75e1 100644 --- a/src/tests/Refit.Tests/ReflectionTests.cs +++ b/src/tests/Refit.Tests/ReflectionTests.cs @@ -222,28 +222,30 @@ public async Task QueryPropertyParameterShouldBeExpectedReflection() await formatter.AssertNoOutstandingAssertions(); } - /// Verifies a derived record's properties are formatted as query parameters with the expected metadata. + /// Verifies the reflection request builder flattens the runtime type's properties, not the declared type's. /// A task that represents the asynchronous test. + /// + /// The reflection builder calls value.GetType(), so a passed through a + /// parameter contributes the derived Name property too. Generated request building + /// flattens the declared type instead, the way the System.Text.Json source generator does; see + /// . This test drives the reflection builder directly so it keeps covering + /// that path even though the generated client no longer routes this method through it. + /// [Test] public async Task DerivedQueryPropertyParameterShouldBeExpectedReflection() { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = BaseUrlWithSlash, ExactQueryParams = [("Name", "queryName"), ("Value", "value")] }, - Reply.Json(nameof(IBasicApi.GetPropertyQuery)) - }, - }; - var methodInfo = typeof(IBasicApi).GetMethod(nameof(IBasicApi.GetPropertyQuery))!; var parameterInfo = methodInfo.GetParameters()[0]; var formatter = new TestUrlFormatter( [parameterInfo, parameterInfo], [typeof(BaseRecord), typeof(BaseRecord)]); - var service = handler.CreateClient(BaseUrl, new RefitSettings { UrlParameterFormatter = formatter }); + var settings = new RefitSettings { UrlParameterFormatter = formatter }; + + var request = await new RequestBuilderImplementation(settings) + .BuildRequestFactoryForMethod(nameof(IBasicApi.GetPropertyQuery))([new DerivedRecordWithProperty("queryName")]); - await service.GetPropertyQuery(new DerivedRecordWithProperty("queryName")); + await Assert.That(request.RequestUri!.PathAndQuery).IsEqualTo("/?Name=queryName&Value=value"); await formatter.AssertNoOutstandingAssertions(); } @@ -298,17 +300,15 @@ public async Task EnumerableQueryParameterShouldBeExpectedReflection() /// Verifies a record's enumerable property is formatted as a query parameter with the expected metadata. /// A task that represents the asynchronous test. + /// + /// The reflection builder formats each element with the property's provider and declared type, then formats the + /// joined string again with the parameter's provider and type. Generated request building now flattens a collection + /// property inline (see ), so this test drives the reflection builder + /// directly to keep covering the two-pass contract even though the generated client no longer routes it through it. + /// [Test] public async Task EnumerablePropertyQueryParameterShouldBeExpectedReflection() { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = BaseUrlWithSlash, ExactQueryParams = [("Enumerable", "0,1")] }, - Reply.Json(nameof(IBasicApi.GetEnumerablePropertyQuery)) - }, - }; - var methodInfo = typeof(IBasicApi).GetMethod(nameof(IBasicApi.GetEnumerablePropertyQuery))!; var parameterInfo = methodInfo.GetParameters()[0]; var propertyInfo = typeof(MyEnumerableParams).GetProperties()[0]; @@ -316,9 +316,11 @@ public async Task EnumerablePropertyQueryParameterShouldBeExpectedReflection() var formatter = new TestUrlFormatter( [propertyInfo, propertyInfo, parameterInfo], [typeof(int[]), typeof(int[]), typeof(MyEnumerableParams)]); - var service = handler.CreateClient(BaseUrl, new RefitSettings { UrlParameterFormatter = formatter }); + var settings = new RefitSettings { UrlParameterFormatter = formatter }; + + _ = await new RequestBuilderImplementation(settings) + .BuildRequestFactoryForMethod(nameof(IBasicApi.GetEnumerablePropertyQuery))([new MyEnumerableParams([0, 1])]); - await service.GetEnumerablePropertyQuery(new([0, 1])); await formatter.AssertNoOutstandingAssertions(); } @@ -348,7 +350,8 @@ public async Task QueryDictionaryParameterShouldBeExpectedReflection() ]); var service = handler.CreateClient(BaseUrl, new RefitSettings { UrlParameterFormatter = formatter }); - var dict = new Dictionary { { "key0", 1 }, { "key1", 2 } }; + const int key1Value = 2; + var dict = new Dictionary { { "key0", 1 }, { "key1", key1Value } }; await service.GetDictionaryQuery(dict); await formatter.AssertNoOutstandingAssertions(); } @@ -427,7 +430,8 @@ public async Task OverloadedWithTwoGenericParameterShouldBeExpectedReflection() }, }; - var methodInfo = typeof(IGenericMethod).GetMethod(nameof(IGenericMethod.GetGenericParameter), 2, [Type.MakeGenericMethodParameter(0), Type.MakeGenericMethodParameter(1)])!; + const int genericParameterCount = 2; + var methodInfo = typeof(IGenericMethod).GetMethod(nameof(IGenericMethod.GetGenericParameter), genericParameterCount, [Type.MakeGenericMethodParameter(0), Type.MakeGenericMethodParameter(1)])!; methodInfo = methodInfo.MakeGenericMethod(typeof(string), typeof(long)); var parameterInfo1 = methodInfo.GetParameters()[0]; var parameterInfo2 = methodInfo.GetParameters()[1]; diff --git a/src/tests/Refit.Tests/RepoPath.cs b/src/tests/Refit.Tests/RepoPath.cs new file mode 100644 index 000000000..a7339ecd7 --- /dev/null +++ b/src/tests/Refit.Tests/RepoPath.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; + +/// A string-wrapper value whose text contains path separators, used to exercise non-string round-tripping. +/// The wrapped path text (for example some/repo). +public readonly record struct RepoPath(string Value) +{ + /// + public override string ToString() => Value; +} diff --git a/src/tests/Refit.Tests/RequestBuilderTests.Dictionaries.cs b/src/tests/Refit.Tests/RequestBuilderTests.Dictionaries.cs index 0b6e785c0..076a618ed 100644 --- a/src/tests/Refit.Tests/RequestBuilderTests.Dictionaries.cs +++ b/src/tests/Refit.Tests/RequestBuilderTests.Dictionaries.cs @@ -20,6 +20,12 @@ public partial class RequestBuilderTests /// The identifier value reused across path and query segments. private const string TheId = "theId"; + /// The title argument reused across the dictionary query tests. + private const string TitleValue = "title"; + + /// The identifier rendered into the "/api/{id}" path across the optional-parameter query tests. + private const int ResourceId = 123; + /// A query string with an array can be formatted by attribute. /// A task that represents the asynchronous operation. [Test] @@ -163,7 +169,9 @@ public async Task QueryStringWithEnumerablesCanBeFormattedEnumerable() var factory = fixture.BuildRequestFactoryForMethod("QueryWithEnumerable"); - var list = new List { 1, 2, 3 }; + const int secondSampleValue = 2; + const int thirdSampleValue = 3; + var list = new List { 1, secondSampleValue, thirdSampleValue }; var output = await factory([list]); @@ -264,7 +272,8 @@ public async Task TestNullableQueryStringParams(string expectedQuery) { var fixture = new RequestBuilderImplementation(); var factory = fixture.BuildRequestFactoryForMethod("QueryWithOptionalParameters"); - var output = await factory([123, "title", 999, new Foo(), _stringArrayAb]); + const int optionalId = 999; + var output = await factory([ResourceId, TitleValue, optionalId, new Foo(), _stringArrayAb]); var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo(expectedQuery); @@ -279,7 +288,7 @@ public async Task TestNullableQueryStringParamsWithANull(string expectedQuery) { var fixture = new RequestBuilderImplementation(); var factory = fixture.BuildRequestFactoryForMethod("QueryWithOptionalParameters"); - var output = await factory([123, "title", null!, null!, _stringArrayAb]); + var output = await factory([ResourceId, TitleValue, null!, null!, _stringArrayAb]); var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo(expectedQuery); @@ -297,8 +306,8 @@ public async Task TestNullableQueryStringParamsWithANullAndPathBoundObject(strin "QueryWithOptionalParametersPathBoundObject"); var output = await factory( [ - new PathBoundObject { SomeProperty = 123, SomeProperty2 = "test" }, - "title", + new PathBoundObject { SomeProperty = ResourceId, SomeProperty2 = "test" }, + TitleValue, null!, _stringArrayAb ]); @@ -326,7 +335,8 @@ public async Task DefaultParameterFormatterIsInvariant() var fixture = new RequestBuilderImplementation(settings); var factory = fixture.BuildRequestFactoryForMethod("FetchSomeStuff"); - var output = await factory([5.4]); + const double fractionalValue = 5.4; + var output = await factory([fractionalValue]); var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/5.4"); @@ -345,9 +355,10 @@ public async Task ICanPostAValueTypeIfIWantYoureNotTheBossOfMe() { var fixture = new RequestBuilderImplementation(); var factory = fixture.RunRequest("PostAValueType", "true"); + const int valueTypeId = 7; var guid = Guid.NewGuid(); - var expected = string.Format("\"{0}\"", guid); - var output = await factory([7, guid]); + var expected = "\"" + guid + "\""; + var output = await factory([valueTypeId, guid]); await Assert.That(output.SendContent).IsEqualTo(expected); } @@ -394,7 +405,8 @@ public async Task MultipartPostWithAliasAndHeader() var sp = new StreamPart(file, "aFile"); - var output = await factory([42, "aPath", sp, "theAuth", false, "theMeta"]); + const int companyId = 42; + var output = await factory([companyId, "aPath", sp, "theAuth", false, "theMeta"]); var uri = new Uri(new(ApiBaseUrl), output.RequestMessage!.RequestUri!); @@ -411,7 +423,8 @@ public async Task PostBlobByteWithAlias() var factory = fixture.BuildRequestFactoryForMethod( nameof(IDummyHttpApi.Blob_Post_Byte)); - var bytes = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + const int sampleByteCount = 10; + var bytes = Enumerable.Range(1, sampleByteCount).Select(static i => (byte)i).ToArray(); var bap = new ByteArrayPart(bytes, "theBytes"); @@ -526,7 +539,8 @@ public async Task DictionaryQueryWithNumericKeyProducesCorrectQueryString() var factory = fixture.BuildRequestFactoryForMethod( nameof(IDummyHttpApi.QueryWithDictionaryWithNumericKey)); - var dict = new Dictionary { { 1, Value1 }, { 2, Value2 }, }; + const int secondNumericKey = 2; + var dict = new Dictionary { { 1, Value1 }, { secondNumericKey, Value2 }, }; var output = await factory([dict]); var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); diff --git a/src/tests/Refit.Tests/RequestBuilderTests.Headers.cs b/src/tests/Refit.Tests/RequestBuilderTests.Headers.cs new file mode 100644 index 000000000..7528bb163 --- /dev/null +++ b/src/tests/Refit.Tests/RequestBuilderTests.Headers.cs @@ -0,0 +1,313 @@ +// 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 HTTP header population. +public partial class RequestBuilderTests +{ + /// Hardcoded headers appear in the request headers. + /// A task that represents the asynchronous operation. + [Test] + public async Task HardcodedHeadersShouldBeInHeaders() + { + var fixture = new RequestBuilderImplementation(); + + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.FetchSomeStuffWithHardcodedHeaders)); + var output = await factory([SampleId]); + + await Assert.That(output.Headers.Contains(UserAgentHeaderName)).IsTrue().Because(UserAgentHeaderReason); + await Assert.That(output.Headers.UserAgent.ToString()).IsEqualTo(RefitTestClientUserAgent); + await Assert.That(output.Headers.Contains(ApiVersionHeaderName)).IsTrue().Because(ApiVersionHeaderReason); + await Assert.That(output.Headers.GetValues(ApiVersionHeaderName).Single()).IsEqualTo("2"); + await Assert.That(output.Headers.Contains(AcceptHeaderName)).IsTrue().Because(AcceptHeaderReason); + await Assert.That(output.Headers.Accept.ToString()).IsEqualTo(JsonContentType); + } + + /// Empty hardcoded headers appear in the request headers. + /// A task that represents the asynchronous operation. + [Test] + public async Task EmptyHardcodedHeadersShouldBeInHeaders() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + "FetchSomeStuffWithEmptyHardcodedHeader"); + var output = await factory([SampleId]); + + await Assert.That(output.Headers.Contains(UserAgentHeaderName)).IsTrue().Because(UserAgentHeaderReason); + await Assert.That(output.Headers.UserAgent.ToString()).IsEqualTo(RefitTestClientUserAgent); + await Assert.That(output.Headers.Contains(ApiVersionHeaderName)).IsTrue().Because(ApiVersionHeaderReason); + await Assert.That(output.Headers.GetValues(ApiVersionHeaderName).Single()).IsEqualTo(string.Empty); + } + + /// Null hardcoded headers are not present in the request headers. + /// A task that represents the asynchronous operation. + [Test] + public async Task NullHardcodedHeadersShouldNotBeInHeaders() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + "FetchSomeStuffWithNullHardcodedHeader"); + var output = await factory([SampleId]); + + await Assert.That(output.Headers.Contains(UserAgentHeaderName)).IsTrue().Because(UserAgentHeaderReason); + await Assert.That(output.Headers.UserAgent.ToString()).IsEqualTo(RefitTestClientUserAgent); + await Assert.That(output.Headers.Contains(ApiVersionHeaderName)).IsFalse().Because(ApiVersionHeaderReason); + } + + /// Content headers can be hardcoded. + /// A task that represents the asynchronous operation. + [Test] + public async Task ContentHeadersCanBeHardcoded() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + "PostSomeStuffWithHardCodedContentTypeHeader"); + var output = await factory([SampleId, "stuff"]); + + await Assert.That(output.Content!.Headers.Contains("Content-Type")).IsTrue().Because("Content headers include Content-Type header"); + await Assert.That(output.Content!.Headers.ContentType!.ToString()).IsEqualTo("literally/anything"); + } + + /// Non-canonical content type header casing is not duplicated. + /// A task that represents the asynchronous operation. + [Test] + public async Task NonCanonicalContentTypeHeaderCasingIsNotDuplicated() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + "PostSomeStuffWithNonCanonicalContentTypeHeader"); + var output = await factory([SampleId, "stuff"]); + + await Assert.That(output.Content!.Headers.ContentType!.ToString()).IsEqualTo("application/soap+xml"); + } + + /// A dynamic header appears in the request headers. + /// A task that represents the asynchronous operation. + [Test] + public async Task DynamicHeaderShouldBeInHeaders() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod("FetchSomeStuffWithDynamicHeader"); + var output = await factory([SampleId, "Basic RnVjayB5ZWFoOmhlYWRlcnMh"]); + + await Assert.That(output.Headers.Authorization).IsNotNull(); + await Assert.That(output.Headers.Authorization!.Parameter).IsEqualTo("RnVjayB5ZWFoOmhlYWRlcnMh"); + } + + /// A custom dynamic header appears in the request headers. + /// A task that represents the asynchronous operation. + [Test] + public async Task CustomDynamicHeaderShouldBeInHeaders() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod("FetchSomeStuffWithCustomHeader"); + var output = await factory([SampleId, JoyCatEmojiValue]); + + await Assert.That(output.Headers.Contains(EmojiHeaderName)).IsTrue().Because(EmojiHeaderReason); + await Assert.That(output.Headers.GetValues(EmojiHeaderName).First()).IsEqualTo(JoyCatEmojiValue); + } + + /// An empty dynamic header appears in the request headers. + /// A task that represents the asynchronous operation. + [Test] + public async Task EmptyDynamicHeaderShouldBeInHeaders() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod("FetchSomeStuffWithCustomHeader"); + var output = await factory([SampleId, string.Empty]); + + await Assert.That(output.Headers.Contains(EmojiHeaderName)).IsTrue().Because(EmojiHeaderReason); + await Assert.That(output.Headers.GetValues(EmojiHeaderName).First()).IsEqualTo(string.Empty); + } + + /// A null dynamic header is not present in the request headers. + /// A task that represents the asynchronous operation. + [Test] + public async Task NullDynamicHeaderShouldNotBeInHeaders() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod("FetchSomeStuffWithDynamicHeader"); + var output = await factory([SampleId, null!]); + + await Assert.That(output.Headers.Authorization).IsNull(); + } + + /// A path member used as a custom dynamic header appears in the request headers. + /// A task that represents the asynchronous operation. + [Test] + public async Task PathMemberAsCustomDynamicHeaderShouldBeInHeaders() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + "FetchSomeStuffWithPathMemberInCustomHeader"); + var output = await factory([SampleId, JoyCatEmojiValue]); + + await Assert.That(output.Headers.Contains("X-PathMember")).IsTrue().Because("Headers include X-PathMember header"); + await Assert.That(output.Headers.GetValues("X-PathMember").First()).IsEqualTo("6"); + } + + /// Custom headers are added to the request headers only. + /// A task that represents the asynchronous operation. + [Test] + public async Task AddCustomHeadersToRequestHeadersOnly() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod("PostSomeStuffWithCustomHeader"); + var output = await factory([SampleId, new { Foo = "bar" }, ":smile_cat:"]); + + await Assert.That(output.Headers.Contains(ApiVersionHeaderName)).IsTrue().Because(ApiVersionHeaderReason); + await Assert.That(output.Headers.Contains(EmojiHeaderName)).IsTrue().Because(EmojiHeaderReason); + await Assert.That(output.Content!.Headers.Contains(ApiVersionHeaderName)).IsFalse().Because("Content headers include Api-Version header"); + await Assert.That(output.Content!.Headers.Contains(EmojiHeaderName)).IsFalse().Because("Content headers include X-Emoji header"); + } + + /// A header collection appears in the request headers. + /// The name of the interface method to build. + /// A task that represents the asynchronous operation. + [Test] + [Arguments(nameof(IDummyHttpApi.FetchSomeStuffWithDynamicHeaderCollection))] + [Arguments(nameof(IDummyHttpApi.DeleteSomeStuffWithDynamicHeaderCollection))] + [Arguments(nameof(IDummyHttpApi.PutSomeStuffWithDynamicHeaderCollection))] + [Arguments(nameof(IDummyHttpApi.PostSomeStuffWithDynamicHeaderCollection))] + [Arguments(nameof(IDummyHttpApi.PatchSomeStuffWithDynamicHeaderCollection))] + public async Task HeaderCollectionShouldBeInHeaders(string interfaceMethodName) + { + var headerCollection = new Dictionary + { + { "key1", "val1" }, + { "key2", "val2" } + }; + + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod(interfaceMethodName); + var output = await factory([SampleId, headerCollection]); + + await Assert.That(output.Headers.Contains(UserAgentHeaderName)).IsTrue().Because(UserAgentHeaderReason); + await Assert.That(output.Headers.GetValues(UserAgentHeaderName).First()).IsEqualTo(RefitTestClientUserAgent); + await Assert.That(output.Headers.Contains(ApiVersionHeaderName)).IsTrue().Because(ApiVersionHeaderReason); + await Assert.That(output.Headers.GetValues(ApiVersionHeaderName).First()).IsEqualTo("1"); + + await Assert.That(output.Headers.Contains(AuthorizationHeaderName)).IsTrue().Because(AuthorizationHeaderReason); + await Assert.That(output.Headers.GetValues(AuthorizationHeaderName).First()).IsEqualTo("SRSLY aHR0cDovL2kuaW1ndXIuY29tL0NGRzJaLmdpZg=="); + await Assert.That(output.Headers.Contains(AcceptHeaderName)).IsTrue().Because(AcceptHeaderReason); + await Assert.That(output.Headers.GetValues(AcceptHeaderName).First()).IsEqualTo(JsonContentType); + + await Assert.That(output.Headers.Contains("key1")).IsTrue().Because("Headers include key1 header"); + await Assert.That(output.Headers.GetValues("key1").First()).IsEqualTo("val1"); + await Assert.That(output.Headers.Contains("key2")).IsTrue().Because("Headers include key2 header"); + await Assert.That(output.Headers.GetValues("key2").First()).IsEqualTo("val2"); + } + + /// The last write wins for a header collection combined with a dynamic header. + /// A task that represents the asynchronous operation. + [Test] + public async Task LastWriteWinsWhenHeaderCollectionAndDynamicHeader() + { + const string authHeader = "LetMeIn"; + const int id = 6; + var headerCollection = new Dictionary + { + { AuthorizationHeaderName, "OpenSesame" } + }; + + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.FetchSomeStuffWithDynamicHeaderCollectionAndDynamicHeader)); + var output = await factory([id, authHeader, headerCollection]); + + await Assert.That(output.Headers.Contains(AuthorizationHeaderName)).IsTrue().Because(AuthorizationHeaderReason); + await Assert.That(output.Headers.GetValues(AuthorizationHeaderName).First()).IsEqualTo("OpenSesame"); + + fixture = new(); + factory = fixture.BuildRequestFactoryForMethod( + nameof( + IDummyHttpApi.FetchSomeStuffWithDynamicHeaderCollectionAndDynamicHeaderOrderFlipped)); + output = await factory([id, headerCollection, authHeader]); + + await Assert.That(output.Headers.Contains(AuthorizationHeaderName)).IsTrue().Because(AuthorizationHeaderReason); + await Assert.That(output.Headers.GetValues(AuthorizationHeaderName).First()).IsEqualTo(authHeader); + } + + /// A null header collection does not blow up. + /// The name of the interface method to build. + /// A task that represents the asynchronous operation. + [Test] + [Arguments(nameof(IDummyHttpApi.FetchSomeStuffWithDynamicHeaderCollection))] + [Arguments(nameof(IDummyHttpApi.DeleteSomeStuffWithDynamicHeaderCollection))] + [Arguments(nameof(IDummyHttpApi.PutSomeStuffWithDynamicHeaderCollection))] + [Arguments(nameof(IDummyHttpApi.PostSomeStuffWithDynamicHeaderCollection))] + [Arguments(nameof(IDummyHttpApi.PatchSomeStuffWithDynamicHeaderCollection))] + public async Task NullHeaderCollectionDoesntBlowUp(string interfaceMethodName) + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod(interfaceMethodName); + var output = await factory([SampleId, null!]); + + await Assert.That(output.Headers.Contains(UserAgentHeaderName)).IsTrue().Because(UserAgentHeaderReason); + await Assert.That(output.Headers.GetValues(UserAgentHeaderName).First()).IsEqualTo(RefitTestClientUserAgent); + await Assert.That(output.Headers.Contains(ApiVersionHeaderName)).IsTrue().Because(ApiVersionHeaderReason); + await Assert.That(output.Headers.GetValues(ApiVersionHeaderName).First()).IsEqualTo("1"); + + await Assert.That(output.Headers.Contains(AuthorizationHeaderName)).IsTrue().Because(AuthorizationHeaderReason); + await Assert.That(output.Headers.GetValues(AuthorizationHeaderName).First()).IsEqualTo("SRSLY aHR0cDovL2kuaW1ndXIuY29tL0NGRzJaLmdpZg=="); + await Assert.That(output.Headers.Contains(AcceptHeaderName)).IsTrue().Because(AcceptHeaderReason); + await Assert.That(output.Headers.GetValues(AcceptHeaderName).First()).IsEqualTo(JsonContentType); + } + + /// A header collection can unset headers. + /// A task that represents the asynchronous operation. + [Test] + public async Task HeaderCollectionCanUnsetHeaders() + { + var headerCollection = new Dictionary + { + { AuthorizationHeaderName, string.Empty }, + { ApiVersionHeaderName, null! } + }; + + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.FetchSomeStuffWithDynamicHeaderCollection)); + var output = await factory([SampleId, headerCollection]); + + await Assert.That(!output.Headers.Contains(ApiVersionHeaderName)).IsTrue().Because("Headers does not include Api-Version header"); + + await Assert.That(output.Headers.Contains(AuthorizationHeaderName)).IsTrue().Because(AuthorizationHeaderReason); + await Assert.That(output.Headers.GetValues(AuthorizationHeaderName).First()).IsEqualTo(string.Empty); + } + + /// A dynamic authorization header and content do not blow up. + /// A task that represents the asynchronous operation. + [Test] + public async Task DontBlowUpWithDynamicAuthorizationHeaderAndContent() + { + const int id = 7; + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod("PutSomeContentWithAuthorization"); + var output = await factory( + [id, new { Octocat = "Dunetocat" }, "Basic RnVjayB5ZWFoOmhlYWRlcnMh"]); + + await Assert.That(output.Headers.Authorization).IsNotNull(); + await Assert.That(output.Headers.Authorization!.Parameter).IsEqualTo("RnVjayB5ZWFoOmhlYWRlcnMh"); + } + + /// A dynamic content type is honoured. + /// A task that represents the asynchronous operation. + [Test] + public async Task SuchFlexibleContentTypeWow() + { + const int id = 7; + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + "PutSomeStuffWithDynamicContentType"); + var output = await factory( + [id, "such \"refit\" is \"amaze\" wow", "text/dson"]); + + await Assert.That(output.Content).IsNotNull(); + await Assert.That(output.Content!.Headers.ContentType).IsNotNull(); + await Assert.That(output.Content!.Headers.ContentType!.MediaType).IsEqualTo("text/dson"); + } +} diff --git a/src/tests/Refit.Tests/RequestBuilderTests.Helpers.cs b/src/tests/Refit.Tests/RequestBuilderTests.Helpers.cs index 284d64341..7c516b3e0 100644 --- a/src/tests/Refit.Tests/RequestBuilderTests.Helpers.cs +++ b/src/tests/Refit.Tests/RequestBuilderTests.Helpers.cs @@ -26,6 +26,12 @@ public partial class RequestBuilderTests /// The query value the test formatter renders as null. private const string NullFormattedQueryValue = "nulled"; + /// The byte array {1, 2} used as sample multipart byte-array-part content. + private static readonly byte[] _byteArray12 = [1, 2]; + + /// The integer array {1, 2} wrapped in a non-generic collection for the query-map guard test. + private static readonly int[] _intArray12 = [1, 2]; + /// Skips dictionary entries whose value is null. /// A task that represents the asynchronous operation. [Test] @@ -182,7 +188,7 @@ public async Task MultipartItemWithAnExplicitNameAndEmptyFileNameUsesTheDerivedF var fixture = new RequestBuilderImplementation(); var factory = fixture.RunRequest(nameof(IDummyHttpApi.Blob_Post_Byte)); - var output = await factory(["dir/file", new ByteArrayPart([1, 2], string.Empty, name: "custom")]); + var output = await factory(["dir/file", new ByteArrayPart(_byteArray12, string.Empty, name: "custom")]); await Assert.That(output.SendContent).Contains("custom"); } @@ -206,7 +212,7 @@ public async Task UndeclaredBodySerializationMethodLeavesTheBodyUnserialized() [Test] public async Task DoNotConvertToQueryMapIsFalseForANonGenericEnumerable() { - var nonGenericSequence = new ArrayList { 1, 2 }; + var nonGenericSequence = new ArrayList(_intArray12); await Assert.That(RequestBuilderImplementation.DoNotConvertToQueryMap(nonGenericSequence)).IsFalse(); } diff --git a/src/tests/Refit.Tests/RequestBuilderTests.Queries.cs b/src/tests/Refit.Tests/RequestBuilderTests.Queries.cs index 6d17ef5ef..4cf646e95 100644 --- a/src/tests/Refit.Tests/RequestBuilderTests.Queries.cs +++ b/src/tests/Refit.Tests/RequestBuilderTests.Queries.cs @@ -40,55 +40,20 @@ public partial class RequestBuilderTests /// The dynamic request property key asserted by the property tests. private const string SomePropertyKey = "SomeProperty"; - /// Hardcoded headers appear in the request headers. - /// A task that represents the asynchronous operation. - [Test] - public async Task HardcodedHeadersShouldBeInHeaders() - { - var fixture = new RequestBuilderImplementation(); + /// The emoji query value asserted by the header-encoding tests. + private const string JoyCatEmojiValue = ":joy_cat:"; - var factory = fixture.BuildRequestFactoryForMethod( - nameof(IDummyHttpApi.FetchSomeStuffWithHardcodedHeaders)); - var output = await factory([6]); - - await Assert.That(output.Headers.Contains(UserAgentHeaderName)).IsTrue().Because(UserAgentHeaderReason); - await Assert.That(output.Headers.UserAgent.ToString()).IsEqualTo(RefitTestClientUserAgent); - await Assert.That(output.Headers.Contains(ApiVersionHeaderName)).IsTrue().Because(ApiVersionHeaderReason); - await Assert.That(output.Headers.GetValues(ApiVersionHeaderName).Single()).IsEqualTo("2"); - await Assert.That(output.Headers.Contains(AcceptHeaderName)).IsTrue().Because("Headers include Accept header"); - await Assert.That(output.Headers.Accept.ToString()).IsEqualTo("application/json"); - } - - /// Empty hardcoded headers appear in the request headers. - /// A task that represents the asynchronous operation. - [Test] - public async Task EmptyHardcodedHeadersShouldBeInHeaders() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - "FetchSomeStuffWithEmptyHardcodedHeader"); - var output = await factory([6]); + /// Assertion reason describing the presence of the X-Emoji header. + private const string EmojiHeaderReason = "Headers include X-Emoji header"; - await Assert.That(output.Headers.Contains(UserAgentHeaderName)).IsTrue().Because(UserAgentHeaderReason); - await Assert.That(output.Headers.UserAgent.ToString()).IsEqualTo(RefitTestClientUserAgent); - await Assert.That(output.Headers.Contains(ApiVersionHeaderName)).IsTrue().Because(ApiVersionHeaderReason); - await Assert.That(output.Headers.GetValues(ApiVersionHeaderName).Single()).IsEqualTo(string.Empty); - } + /// Assertion reason describing the presence of the Accept header. + private const string AcceptHeaderReason = "Headers include Accept header"; - /// Null hardcoded headers are not present in the request headers. - /// A task that represents the asynchronous operation. - [Test] - public async Task NullHardcodedHeadersShouldNotBeInHeaders() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - "FetchSomeStuffWithNullHardcodedHeader"); - var output = await factory([6]); + /// The JSON content type asserted by the Accept-header tests. + private const string JsonContentType = "application/json"; - await Assert.That(output.Headers.Contains(UserAgentHeaderName)).IsTrue().Because(UserAgentHeaderReason); - await Assert.That(output.Headers.UserAgent.ToString()).IsEqualTo(RefitTestClientUserAgent); - await Assert.That(output.Headers.Contains(ApiVersionHeaderName)).IsFalse().Because(ApiVersionHeaderReason); - } + /// The numeric value assigned to the Bar field of the URL-encoded body samples. + private const int SampleBarValue = 100; /// Reads string content with metadata. /// A task that represents the asynchronous operation. @@ -106,7 +71,7 @@ public async Task ReadStringContentWithMetadata() { BaseAddress = new(ApiBaseUrlWithSlash) }, - [42])!; + [AlternateSampleId])!; var result = await task; await Assert.That(result.Headers).IsNotNull(); @@ -118,33 +83,6 @@ public async Task ReadStringContentWithMetadata() await Assert.That(result.Content).IsEqualTo("test"); } - /// Content headers can be hardcoded. - /// A task that represents the asynchronous operation. - [Test] - public async Task ContentHeadersCanBeHardcoded() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - "PostSomeStuffWithHardCodedContentTypeHeader"); - var output = await factory([6, "stuff"]); - - await Assert.That(output.Content!.Headers.Contains("Content-Type")).IsTrue().Because("Content headers include Content-Type header"); - await Assert.That(output.Content!.Headers.ContentType!.ToString()).IsEqualTo("literally/anything"); - } - - /// Non-canonical content type header casing is not duplicated. - /// A task that represents the asynchronous operation. - [Test] - public async Task NonCanonicalContentTypeHeaderCasingIsNotDuplicated() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - "PostSomeStuffWithNonCanonicalContentTypeHeader"); - var output = await factory([6, "stuff"]); - - await Assert.That(output.Content!.Headers.ContentType!.ToString()).IsEqualTo("application/soap+xml"); - } - /// A parameter with property and query attributes is added to the query. /// A task that represents the asynchronous operation. [Test] @@ -153,7 +91,7 @@ public async Task ParameterWithPropertyAndQueryAttributesIsAddedToQuery() var fixture = new RequestBuilderImplementation(); var factory = fixture.BuildRequestFactoryForMethod( "FetchSomeStuffWithPropertyAndQuery"); - var output = await factory([6, "value1"]); + var output = await factory([SampleId, "value1"]); var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/6?someValue=value1"); @@ -188,201 +126,6 @@ public async Task EmptyQueryFormatSerializesComplexValueViaToString() await Assert.That(uri.PathAndQuery).IsEqualTo("/info?size=medium"); } - /// A dynamic header appears in the request headers. - /// A task that represents the asynchronous operation. - [Test] - public async Task DynamicHeaderShouldBeInHeaders() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod("FetchSomeStuffWithDynamicHeader"); - var output = await factory([6, "Basic RnVjayB5ZWFoOmhlYWRlcnMh"]); - - await Assert.That(output.Headers.Authorization).IsNotNull(); - await Assert.That(output.Headers.Authorization!.Parameter).IsEqualTo("RnVjayB5ZWFoOmhlYWRlcnMh"); - } - - /// A custom dynamic header appears in the request headers. - /// A task that represents the asynchronous operation. - [Test] - public async Task CustomDynamicHeaderShouldBeInHeaders() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod("FetchSomeStuffWithCustomHeader"); - var output = await factory([6, ":joy_cat:"]); - - await Assert.That(output.Headers.Contains(EmojiHeaderName)).IsTrue().Because("Headers include X-Emoji header"); - await Assert.That(output.Headers.GetValues(EmojiHeaderName).First()).IsEqualTo(":joy_cat:"); - } - - /// An empty dynamic header appears in the request headers. - /// A task that represents the asynchronous operation. - [Test] - public async Task EmptyDynamicHeaderShouldBeInHeaders() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod("FetchSomeStuffWithCustomHeader"); - var output = await factory([6, string.Empty]); - - await Assert.That(output.Headers.Contains(EmojiHeaderName)).IsTrue().Because("Headers include X-Emoji header"); - await Assert.That(output.Headers.GetValues(EmojiHeaderName).First()).IsEqualTo(string.Empty); - } - - /// A null dynamic header is not present in the request headers. - /// A task that represents the asynchronous operation. - [Test] - public async Task NullDynamicHeaderShouldNotBeInHeaders() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod("FetchSomeStuffWithDynamicHeader"); - var output = await factory([6, null!]); - - await Assert.That(output.Headers.Authorization).IsNull(); - } - - /// A path member used as a custom dynamic header appears in the request headers. - /// A task that represents the asynchronous operation. - [Test] - public async Task PathMemberAsCustomDynamicHeaderShouldBeInHeaders() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - "FetchSomeStuffWithPathMemberInCustomHeader"); - var output = await factory([6, ":joy_cat:"]); - - await Assert.That(output.Headers.Contains("X-PathMember")).IsTrue().Because("Headers include X-PathMember header"); - await Assert.That(output.Headers.GetValues("X-PathMember").First()).IsEqualTo("6"); - } - - /// Custom headers are added to the request headers only. - /// A task that represents the asynchronous operation. - [Test] - public async Task AddCustomHeadersToRequestHeadersOnly() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod("PostSomeStuffWithCustomHeader"); - var output = await factory([6, new { Foo = "bar" }, ":smile_cat:"]); - - await Assert.That(output.Headers.Contains(ApiVersionHeaderName)).IsTrue().Because(ApiVersionHeaderReason); - await Assert.That(output.Headers.Contains(EmojiHeaderName)).IsTrue().Because("Headers include X-Emoji header"); - await Assert.That(output.Content!.Headers.Contains(ApiVersionHeaderName)).IsFalse().Because("Content headers include Api-Version header"); - await Assert.That(output.Content!.Headers.Contains(EmojiHeaderName)).IsFalse().Because("Content headers include X-Emoji header"); - } - - /// A header collection appears in the request headers. - /// The name of the interface method to build. - /// A task that represents the asynchronous operation. - [Test] - [Arguments(nameof(IDummyHttpApi.FetchSomeStuffWithDynamicHeaderCollection))] - [Arguments(nameof(IDummyHttpApi.DeleteSomeStuffWithDynamicHeaderCollection))] - [Arguments(nameof(IDummyHttpApi.PutSomeStuffWithDynamicHeaderCollection))] - [Arguments(nameof(IDummyHttpApi.PostSomeStuffWithDynamicHeaderCollection))] - [Arguments(nameof(IDummyHttpApi.PatchSomeStuffWithDynamicHeaderCollection))] - public async Task HeaderCollectionShouldBeInHeaders(string interfaceMethodName) - { - var headerCollection = new Dictionary - { - { "key1", "val1" }, - { "key2", "val2" } - }; - - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod(interfaceMethodName); - var output = await factory([6, headerCollection]); - - await Assert.That(output.Headers.Contains(UserAgentHeaderName)).IsTrue().Because(UserAgentHeaderReason); - await Assert.That(output.Headers.GetValues(UserAgentHeaderName).First()).IsEqualTo(RefitTestClientUserAgent); - await Assert.That(output.Headers.Contains(ApiVersionHeaderName)).IsTrue().Because(ApiVersionHeaderReason); - await Assert.That(output.Headers.GetValues(ApiVersionHeaderName).First()).IsEqualTo("1"); - - await Assert.That(output.Headers.Contains(AuthorizationHeaderName)).IsTrue().Because(AuthorizationHeaderReason); - await Assert.That(output.Headers.GetValues(AuthorizationHeaderName).First()).IsEqualTo("SRSLY aHR0cDovL2kuaW1ndXIuY29tL0NGRzJaLmdpZg=="); - await Assert.That(output.Headers.Contains(AcceptHeaderName)).IsTrue().Because("Headers include Accept header"); - await Assert.That(output.Headers.GetValues(AcceptHeaderName).First()).IsEqualTo("application/json"); - - await Assert.That(output.Headers.Contains("key1")).IsTrue().Because("Headers include key1 header"); - await Assert.That(output.Headers.GetValues("key1").First()).IsEqualTo("val1"); - await Assert.That(output.Headers.Contains("key2")).IsTrue().Because("Headers include key2 header"); - await Assert.That(output.Headers.GetValues("key2").First()).IsEqualTo("val2"); - } - - /// The last write wins for a header collection combined with a dynamic header. - /// A task that represents the asynchronous operation. - [Test] - public async Task LastWriteWinsWhenHeaderCollectionAndDynamicHeader() - { - const string authHeader = "LetMeIn"; - const int id = 6; - var headerCollection = new Dictionary - { - { AuthorizationHeaderName, "OpenSesame" } - }; - - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - nameof(IDummyHttpApi.FetchSomeStuffWithDynamicHeaderCollectionAndDynamicHeader)); - var output = await factory([id, authHeader, headerCollection]); - - await Assert.That(output.Headers.Contains(AuthorizationHeaderName)).IsTrue().Because(AuthorizationHeaderReason); - await Assert.That(output.Headers.GetValues(AuthorizationHeaderName).First()).IsEqualTo("OpenSesame"); - - fixture = new(); - factory = fixture.BuildRequestFactoryForMethod( - nameof( - IDummyHttpApi.FetchSomeStuffWithDynamicHeaderCollectionAndDynamicHeaderOrderFlipped)); - output = await factory([id, headerCollection, authHeader]); - - await Assert.That(output.Headers.Contains(AuthorizationHeaderName)).IsTrue().Because(AuthorizationHeaderReason); - await Assert.That(output.Headers.GetValues(AuthorizationHeaderName).First()).IsEqualTo(authHeader); - } - - /// A null header collection does not blow up. - /// The name of the interface method to build. - /// A task that represents the asynchronous operation. - [Test] - [Arguments(nameof(IDummyHttpApi.FetchSomeStuffWithDynamicHeaderCollection))] - [Arguments(nameof(IDummyHttpApi.DeleteSomeStuffWithDynamicHeaderCollection))] - [Arguments(nameof(IDummyHttpApi.PutSomeStuffWithDynamicHeaderCollection))] - [Arguments(nameof(IDummyHttpApi.PostSomeStuffWithDynamicHeaderCollection))] - [Arguments(nameof(IDummyHttpApi.PatchSomeStuffWithDynamicHeaderCollection))] - public async Task NullHeaderCollectionDoesntBlowUp(string interfaceMethodName) - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod(interfaceMethodName); - var output = await factory([6, null!]); - - await Assert.That(output.Headers.Contains(UserAgentHeaderName)).IsTrue().Because(UserAgentHeaderReason); - await Assert.That(output.Headers.GetValues(UserAgentHeaderName).First()).IsEqualTo(RefitTestClientUserAgent); - await Assert.That(output.Headers.Contains(ApiVersionHeaderName)).IsTrue().Because(ApiVersionHeaderReason); - await Assert.That(output.Headers.GetValues(ApiVersionHeaderName).First()).IsEqualTo("1"); - - await Assert.That(output.Headers.Contains(AuthorizationHeaderName)).IsTrue().Because(AuthorizationHeaderReason); - await Assert.That(output.Headers.GetValues(AuthorizationHeaderName).First()).IsEqualTo("SRSLY aHR0cDovL2kuaW1ndXIuY29tL0NGRzJaLmdpZg=="); - await Assert.That(output.Headers.Contains(AcceptHeaderName)).IsTrue().Because("Headers include Accept header"); - await Assert.That(output.Headers.GetValues(AcceptHeaderName).First()).IsEqualTo("application/json"); - } - - /// A header collection can unset headers. - /// A task that represents the asynchronous operation. - [Test] - public async Task HeaderCollectionCanUnsetHeaders() - { - var headerCollection = new Dictionary - { - { AuthorizationHeaderName, string.Empty }, - { ApiVersionHeaderName, null! } - }; - - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - nameof(IDummyHttpApi.FetchSomeStuffWithDynamicHeaderCollection)); - var output = await factory([6, headerCollection]); - - await Assert.That(!output.Headers.Contains(ApiVersionHeaderName)).IsTrue().Because("Headers does not include Api-Version header"); - - await Assert.That(output.Headers.Contains(AuthorizationHeaderName)).IsTrue().Because(AuthorizationHeaderReason); - await Assert.That(output.Headers.GetValues(AuthorizationHeaderName).First()).IsEqualTo(string.Empty); - } - /// Dynamic request properties appear in the request properties. /// The name of the interface method to build. /// A task that represents the asynchronous operation. @@ -397,7 +140,7 @@ public async Task DynamicRequestPropertiesShouldBeInProperties(string interfaceM var someProperty = new object(); var fixture = new RequestBuilderImplementation(); var factory = fixture.BuildRequestFactoryForMethod(interfaceMethodName); - var output = await factory([6, someProperty]); + var output = await factory([SampleId, someProperty]); #if NET6_0_OR_GREATER await Assert.That(output.Options).IsNotEmpty(); @@ -509,7 +252,7 @@ public async Task DynamicRequestPropertiesWithDefaultKeysShouldBeInProperties() var fixture = new RequestBuilderImplementation(); var factory = fixture.BuildRequestFactoryForMethod( nameof(IDummyHttpApi.FetchSomeStuffWithDynamicRequestPropertyWithoutKey)); - var output = await factory([6, someProperty, someOtherProperty]); + var output = await factory([SampleId, someProperty, someOtherProperty]); #if NET6_0_OR_GREATER await Assert.That(output.Options).IsNotEmpty(); @@ -534,7 +277,7 @@ public async Task DynamicRequestPropertiesWithDuplicateKeyShouldOverwritePreviou var fixture = new RequestBuilderImplementation(); var factory = fixture.BuildRequestFactoryForMethod( nameof(IDummyHttpApi.FetchSomeStuffWithDynamicRequestPropertyWithDuplicateKey)); - var output = await factory([6, someProperty, someOtherProperty]); + var output = await factory([SampleId, someProperty, someOtherProperty]); const int expectedPropertyCount = 3; @@ -600,42 +343,12 @@ public async Task HttpClientShouldNotPrefixEmptyAbsolutePathToTheRequestUri() var task = (Task)factory( new(testHttpMessageHandler) { BaseAddress = new(ApiBaseUrlWithSlash) }, - [42])!; + [AlternateSampleId])!; await task; await Assert.That(testHttpMessageHandler.RequestMessage!.RequestUri!.ToString()).IsEqualTo("http://api/foo/bar/42"); } - /// A dynamic authorization header and content do not blow up. - /// A task that represents the asynchronous operation. - [Test] - public async Task DontBlowUpWithDynamicAuthorizationHeaderAndContent() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod("PutSomeContentWithAuthorization"); - var output = await factory( - [7, new { Octocat = "Dunetocat" }, "Basic RnVjayB5ZWFoOmhlYWRlcnMh"]); - - await Assert.That(output.Headers.Authorization).IsNotNull(); - await Assert.That(output.Headers.Authorization!.Parameter).IsEqualTo("RnVjayB5ZWFoOmhlYWRlcnMh"); - } - - /// A dynamic content type is honoured. - /// A task that represents the asynchronous operation. - [Test] - public async Task SuchFlexibleContentTypeWow() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - "PutSomeStuffWithDynamicContentType"); - var output = await factory( - [7, "such \"refit\" is \"amaze\" wow", "text/dson"]); - - await Assert.That(output.Content).IsNotNull(); - await Assert.That(output.Content!.Headers.ContentType).IsNotNull(); - await Assert.That(output.Content!.Headers.ContentType!.MediaType).IsEqualTo("text/dson"); - } - /// Body content gets URL encoded. /// A task that represents the asynchronous operation. [Test] @@ -645,10 +358,10 @@ public async Task BodyContentGetsUrlEncoded() var factory = fixture.RunRequest("PostSomeUrlEncodedStuff"); var output = await factory( [ - 6, + SampleId, // Baz is intentionally blank to verify empty values are preserved rather than stripped. - new UrlEncodedBody(Foo: "Something", Bar: 100, Baz: string.Empty) + new UrlEncodedBody(Foo: "Something", Bar: SampleBarValue, Baz: string.Empty) ]); await Assert.That(output.SendContent).IsEqualTo("Foo=Something&Bar=100&Baz="); @@ -664,10 +377,10 @@ public async Task BodyContentGetsUrlEncodedWithCollectionFormat() var factory = fixture.RunRequest("PostSomeUrlEncodedStuff"); var output = await factory( [ - 6, + SampleId, // Baz is intentionally blank to verify empty values are preserved rather than stripped. - new UrlEncodedBodyWithCollection(Foo: "Something", Bar: 100, FooBar: _intArray57, Baz: string.Empty) + new UrlEncodedBodyWithCollection(Foo: "Something", Bar: SampleBarValue, FooBar: _intArray57, Baz: string.Empty) ]); await Assert.That(output.SendContent).IsEqualTo("Foo=Something&Bar=100&FooBar=5%2C7&Baz="); @@ -678,12 +391,13 @@ public async Task BodyContentGetsUrlEncodedWithCollectionFormat() [Test] public async Task FormFieldGetsAliased() { + const int readablePropertyValue = 99; var fixture = new RequestBuilderImplementation(); var factory = fixture.RunRequest("PostSomeAliasedUrlEncodedStuff"); var output = await factory( [ - 6, - new SomeRequestData { ReadablePropertyName = 99 } + SampleId, + new SomeRequestData { ReadablePropertyName = readablePropertyValue } ]); await Assert.That(output.SendContent).IsEqualTo("rpn=99"); @@ -694,6 +408,7 @@ public async Task FormFieldGetsAliased() [Test] public async Task CustomParmeterFormatter() { + const int id = 5; var settings = new RefitSettings { UrlParameterFormatter = new TestUrlParameterFormatter("custom-parameter") @@ -701,7 +416,7 @@ public async Task CustomParmeterFormatter() var fixture = new RequestBuilderImplementation(settings); var factory = fixture.BuildRequestFactoryForMethod("FetchSomeStuff"); - var output = await factory([5]); + var output = await factory([id]); var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/custom-parameter"); diff --git a/src/tests/Refit.Tests/RequestBuilderTests.UrlConstruction.cs b/src/tests/Refit.Tests/RequestBuilderTests.UrlConstruction.cs new file mode 100644 index 000000000..99ae65773 --- /dev/null +++ b/src/tests/Refit.Tests/RequestBuilderTests.UrlConstruction.cs @@ -0,0 +1,262 @@ +// 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; + +/// Tests for URL path and query-string construction. +public partial class RequestBuilderTests +{ + /// A hardcoded query parameter appears in the URL. + /// A task that represents the asynchronous operation. + [Test] + public async Task HardcodedQueryParamShouldBeInUrl() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + "FetchSomeStuffWithHardcodedQueryParameter"); + var output = await factory([SampleId]); + + var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); + await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/6?baz=bamf"); + } + + /// Parameterized query parameters appear in the URL. + /// A task that represents the asynchronous operation. + [Test] + public async Task ParameterizedQueryParamsShouldBeInUrl() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + "FetchSomeStuffWithHardcodedAndOtherQueryParameters"); + var output = await factory([SampleId, "foo"]); + + var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); + await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/6?baz=bamf&search_for=foo"); + } + + /// A parameter that appears more than once is rendered each time. + /// A task that represents the asynchronous operation. + [Test] + public async Task ParameterizedValuesShouldBeInUrlMoreThanOnce() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.SomeApiThatUsesParameterMoreThanOnceInTheUrl)); + var output = await factory([SampleId]); + + var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); + await Assert.That(uri.PathAndQuery).IsEqualTo("/api/foo/6/file_6?query=6"); + } + + /// Round-tripping parameterized query parameters appear in the URL. + /// The path segment value to round-trip. + /// The expected resulting path and query. + /// A task that represents the asynchronous operation. + [Test] + [Arguments("aaa/bbb", "/foo/bar/aaa/bbb/1")] + [Arguments("aaa/bbb/ccc", "/foo/bar/aaa/bbb/ccc/1")] + [Arguments("aaa", "/foo/bar/aaa/1")] + [Arguments("aa a/bb-b", "/foo/bar/aa%20a/bb-b/1")] + public async Task RoundTrippingParameterizedQueryParamsShouldBeInUrl( + string path, + string expectedQuery) + { + var fixture = new RequestBuilderImplementation(); + + var factory = fixture.BuildRequestFactoryForMethod( + "FetchSomeStuffWithRoundTrippingParam"); + var output = await factory([path, 1]); + + var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); + await Assert.That(uri.PathAndQuery).IsEqualTo(expectedQuery); + } + + /// Null query parameters render as blank in the URL. + /// A task that represents the asynchronous operation. + [Test] + [UnconditionalSuppressMessage( + "SingleFile", + "IL3000:Avoid accessing Assembly file path when publishing as a single file", + Justification = "Test reads the on-disk assembly path to build a FileInfo argument; never run as a single-file app.")] + public async Task ParameterizedNullQueryParamsShouldBeBlankInUrl() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod("PostWithQueryStringParameters"); + var output = await factory( + [new FileInfo(typeof(RequestBuilderTests).Assembly.Location), null!]); + + var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); + await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?name="); + } + + /// Parameters are put as an explicit query string. + /// A task that represents the asynchronous operation. + [Test] + public async Task ParametersShouldBePutAsExplicitQueryString() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.QueryWithExplicitParameters)); + var output = await factory(["value1", "value2"]); + + var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); + + await Assert.That(uri.PathAndQuery).IsEqualTo("/query?q1=value1&q2=value2"); + } + + /// A query parameter is formatted using its format attribute. + /// A task that represents the asynchronous operation. + [Test] + public async Task QueryParamShouldFormat() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod("FetchSomeStuffWithQueryFormat"); + var output = await factory([SampleId]); + + var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); + await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/6.0"); + } + + /// Parameterized query parameters appear in the URL with values encoded. + /// A task that represents the asynchronous operation. + [Test] + public async Task ParameterizedQueryParamsShouldBeInUrlAndValuesEncoded() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + "FetchSomeStuffWithHardcodedAndOtherQueryParameters"); + var output = await factory([SampleId, "push!=pull&push"]); + + var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); + + await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/6?baz=bamf&search_for=push%21%3Dpull%26push"); + } + + /// Mixed replacement and query values are encoded in the URL. + /// A task that represents the asynchronous operation. + [Test] + public async Task ParameterizedQueryParamsShouldBeInUrlAndValuesEncodedWhenMixedReplacementAndQuery() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + FetchVoidQueryAliasMethodName); + var output = await factory(["6 & 7/8", ExampleEmailValue, PushNotPullValue]); + + var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); + + await Assert.That(uri.PathAndQuery).IsEqualTo("/void/6%20%26%207%2F8/path?a=test%40example.com&b=push%21%3Dpull"); + } + + /// Query parameters with a path delimiter are encoded. + /// A task that represents the asynchronous operation. + [Test] + public async Task QueryParamWithPathDelimiterShouldBeEncoded() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + FetchVoidQueryAliasMethodName); + var output = await factory(["6/6", ExampleEmailValue, PushNotPullValue]); + + var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); + + await Assert.That(uri.PathAndQuery).IsEqualTo("/void/6%2F6/path?a=test%40example.com&b=push%21%3Dpull"); + } + + /// Query parameters ending in double quotes are not truncated. + /// A task that represents the asynchronous operation. + [Test] + public async Task QueryParamWhichEndsInDoubleQuotesShouldNotBeTruncated() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + "FetchSomeStuffWithDoubleQuotesInUrl"); + var output = await factory([AlternateSampleId]); + + var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); + + await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?q=app_metadata.id%3A%2242%22"); + } + + /// Captures the last character when a route ends with a constant. + /// The name of the interface method to build. + /// The trailing constant character expected in the URL. + /// The substring expected to appear in the URL. + /// A task that represents the asynchronous operation. + [Test] + [Arguments("GetWithTrainingParenthesis", ")", "/foo/bar/(1)")] + [Arguments("GetWithTrailingSlash", "/", "/foo/bar/1/")] + public async Task ShouldCaptureLastCharacterWhenRouteEndsWithConstant(string methodToTest, string constantChar, string contains) + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + methodToTest); + var output = await factory(["1"]); + + var uri = new Uri(new(ApiBaseUrlWithSlash), output.RequestUri!); + + await Assert.That(uri.PathAndQuery).EndsWith(constantChar); + await Assert.That(uri.PathAndQuery).Contains(contains); + } + + /// Mixed replacement and query values are encoded for a bad id. + /// A task that represents the asynchronous operation. + [Test] + public async Task ParameterizedQueryParamsShouldBeInUrlAndValuesEncodedWhenMixedReplacementAndQueryBadId() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + FetchVoidQueryAliasMethodName); + var output = await factory(["6", ExampleEmailValue, PushNotPullValue]); + + var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); + + await Assert.That(uri.PathAndQuery).IsEqualTo("/void/6/path?a=test%40example.com&b=push%21%3Dpull"); + } + + /// Non-formattable query parameters are included in the URL. + /// A task that represents the asynchronous operation. + [Test] + public async Task NonFormattableQueryParamsShouldBeIncluded() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + "FetchSomeStuffWithNonFormattableQueryParams"); + var output = await factory([true, 'x']); + + var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); + + await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?b=True&c=x"); + } + + /// Multiple parameters in the same segment are generated correctly. + /// A task that represents the asynchronous operation. + [Test] + public async Task MultipleParametersInTheSameSegmentAreGeneratedProperly() + { + const int segmentWidth = 1024; + const int segmentHeight = 768; + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + "FetchSomethingWithMultipleParametersPerSegment"); + var output = await factory([SampleId, segmentWidth, segmentHeight]); + + var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); + await Assert.That(uri.PathAndQuery).IsEqualTo("/6/1024x768/foo"); + } + + /// Verifies a simple string path parameter is formatted with the expected metadata. + /// A task that represents the asynchronous test. + [Test] + public async Task UrlParameterShouldWorkWithGeneratedCode() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod(nameof(IBasicApi.GetParam)); + var output = await factory([nameof(IBasicApi.GetParam)]); + + var uri = new Uri(new("http://api"), output.RequestUri!); + await Assert.That(uri.PathAndQuery).IsEqualTo($"/{nameof(IBasicApi.GetParam)}"); + } +} diff --git a/src/tests/Refit.Tests/RequestBuilderTests.cs b/src/tests/Refit.Tests/RequestBuilderTests.cs index 225340391..2b265c099 100644 --- a/src/tests/Refit.Tests/RequestBuilderTests.cs +++ b/src/tests/Refit.Tests/RequestBuilderTests.cs @@ -18,9 +18,30 @@ public partial class RequestBuilderTests /// The name of the cancellable GET method exercised by the cancellation tests. private const string GetWithCancellationMethod = "GetWithCancellation"; + /// The single positional argument reused by the dynamic-invocation tests. + private const string ValueArgument = "value"; + + /// The name of the void method with a query alias exercised by the escaping tests. + private const string FetchVoidQueryAliasMethodName = "FetchSomeStuffWithVoidAndQueryAlias"; + + /// A sample email query argument exercised by the escaping tests. + private const string ExampleEmailValue = "test@example.com"; + + /// A sample query argument containing reserved characters exercised by the escaping tests. + private const string PushNotPullValue = "push!=pull"; + + /// The canonical sample route id passed as the first argument to most request-builder tests. + private const int SampleId = 6; + + /// An alternate sample route id used where a test needs an id distinct from . + private const int AlternateSampleId = 42; + /// The integer array {1, 2, 3} used as query test data. private static readonly int[] _intArray123 = [1, 2, 3]; + /// The byte array {1, 2, 3} used as sample stream request-body content. + private static readonly byte[] _byteArray123 = [1, 2, 3]; + /// The integer array {5, 7} used as query test data. private static readonly int[] _intArray57 = [5, 7]; @@ -92,7 +113,7 @@ public async Task MethodsWithNullableCancellationTokenShouldBuildRequest() var factory = fixture.RunRequest("GetWithNullableCancellation"); using var cts = new CancellationTokenSource(); - var output = await factory([42, cts.Token]); + var output = await factory([AlternateSampleId, cts.Token]); var uri = new Uri(new(ApiBaseUrl), output.RequestMessage!.RequestUri!); await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/42"); @@ -110,7 +131,7 @@ public async Task MethodsWithNullableCancellationTokenShouldCancelWhenRequested( using var cts = new CancellationTokenSource(); await cts.CancelAsync(); - var output = await factory([42, cts.Token]); + var output = await factory([AlternateSampleId, cts.Token]); await Assert.That(output.CancellationToken.IsCancellationRequested).IsTrue(); } @@ -158,7 +179,7 @@ public async Task AuthorizationHeaderValueGetterReceivesMethodCancellationToken( AuthorizationHeaderValueGetter = (_, cancellationToken) => { observedCancellationToken = cancellationToken; - return Task.FromResult("tokenValue"); + return new ValueTask("tokenValue"); } }; @@ -240,7 +261,7 @@ public async Task StreamRequestBodyUsesStreamContent() var fixture = new RequestBuilderImplementation(); var factory = fixture.BuildRequestFactoryForMethod(nameof(IStreamApi.PostStream)); - await using var stream = new MemoryStream([1, 2, 3]); + await using var stream = new MemoryStream(_byteArray123); var request = await factory([stream]); await Assert.That(request.Content).IsTypeOf(); @@ -396,7 +417,7 @@ public async Task ValueTaskMethodsShouldWork() { BaseAddress = new(ApiBaseUrlWithSlash) }, - ["value"])!; + [ValueArgument])!; var result = await valueTask; @@ -419,7 +440,7 @@ public async Task ValueTaskApiResponseMethodsShouldWork() { BaseAddress = new(ApiBaseUrlWithSlash) }, - ["value"])!; + [ValueArgument])!; using var response = await valueTask; @@ -447,7 +468,7 @@ public async Task ObservableMethodsWithCancellationTokenShouldCancelWhenRequeste { BaseAddress = new(ApiBaseUrlWithSlash) }, - ["value", cts.Token])!; + [ValueArgument, cts.Token])!; await Assert.That(() => (Task)ObservableTestHelpers.Await(observable)).ThrowsExactly(); await Assert.That(testHttpMessageHandler.RequestMessage!.RequestUri!.ToString()).IsEqualTo("http://api/value/value"); @@ -509,253 +530,4 @@ public async Task MethodsThatDontHaveAnHttpMethodShouldFail() await Assert.That(shouldDie).IsFalse(); } } - - /// A hardcoded query parameter appears in the URL. - /// A task that represents the asynchronous operation. - [Test] - public async Task HardcodedQueryParamShouldBeInUrl() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - "FetchSomeStuffWithHardcodedQueryParameter"); - var output = await factory([6]); - - var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); - await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/6?baz=bamf"); - } - - /// Parameterized query parameters appear in the URL. - /// A task that represents the asynchronous operation. - [Test] - public async Task ParameterizedQueryParamsShouldBeInUrl() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - "FetchSomeStuffWithHardcodedAndOtherQueryParameters"); - var output = await factory([6, "foo"]); - - var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); - await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/6?baz=bamf&search_for=foo"); - } - - /// A parameter that appears more than once is rendered each time. - /// A task that represents the asynchronous operation. - [Test] - public async Task ParameterizedValuesShouldBeInUrlMoreThanOnce() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - nameof(IDummyHttpApi.SomeApiThatUsesParameterMoreThanOnceInTheUrl)); - var output = await factory([6]); - - var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); - await Assert.That(uri.PathAndQuery).IsEqualTo("/api/foo/6/file_6?query=6"); - } - - /// Round-tripping parameterized query parameters appear in the URL. - /// The path segment value to round-trip. - /// The expected resulting path and query. - /// A task that represents the asynchronous operation. - [Test] - [Arguments("aaa/bbb", "/foo/bar/aaa/bbb/1")] - [Arguments("aaa/bbb/ccc", "/foo/bar/aaa/bbb/ccc/1")] - [Arguments("aaa", "/foo/bar/aaa/1")] - [Arguments("aa a/bb-b", "/foo/bar/aa%20a/bb-b/1")] - public async Task RoundTrippingParameterizedQueryParamsShouldBeInUrl( - string path, - string expectedQuery) - { - var fixture = new RequestBuilderImplementation(); - - var factory = fixture.BuildRequestFactoryForMethod( - "FetchSomeStuffWithRoundTrippingParam"); - var output = await factory([path, 1]); - - var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); - await Assert.That(uri.PathAndQuery).IsEqualTo(expectedQuery); - } - - /// Null query parameters render as blank in the URL. - /// A task that represents the asynchronous operation. - [Test] - [UnconditionalSuppressMessage( - "SingleFile", - "IL3000:Avoid accessing Assembly file path when publishing as a single file", - Justification = "Test reads the on-disk assembly path to build a FileInfo argument; never run as a single-file app.")] - public async Task ParameterizedNullQueryParamsShouldBeBlankInUrl() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod("PostWithQueryStringParameters"); - var output = await factory( - [new FileInfo(typeof(RequestBuilderTests).Assembly.Location), null!]); - - var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); - await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?name="); - } - - /// Parameters are put as an explicit query string. - /// A task that represents the asynchronous operation. - [Test] - public async Task ParametersShouldBePutAsExplicitQueryString() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - nameof(IDummyHttpApi.QueryWithExplicitParameters)); - var output = await factory(["value1", "value2"]); - - var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); - - await Assert.That(uri.PathAndQuery).IsEqualTo("/query?q1=value1&q2=value2"); - } - - /// A query parameter is formatted using its format attribute. - /// A task that represents the asynchronous operation. - [Test] - public async Task QueryParamShouldFormat() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod("FetchSomeStuffWithQueryFormat"); - var output = await factory([6]); - - var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); - await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/6.0"); - } - - /// Parameterized query parameters appear in the URL with values encoded. - /// A task that represents the asynchronous operation. - [Test] - public async Task ParameterizedQueryParamsShouldBeInUrlAndValuesEncoded() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - "FetchSomeStuffWithHardcodedAndOtherQueryParameters"); - var output = await factory([6, "push!=pull&push"]); - - var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); - - await Assert.That(uri.PathAndQuery).IsEqualTo("/foo/bar/6?baz=bamf&search_for=push%21%3Dpull%26push"); - } - - /// Mixed replacement and query values are encoded in the URL. - /// A task that represents the asynchronous operation. - [Test] - public async Task ParameterizedQueryParamsShouldBeInUrlAndValuesEncodedWhenMixedReplacementAndQuery() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - "FetchSomeStuffWithVoidAndQueryAlias"); - var output = await factory(["6 & 7/8", "test@example.com", "push!=pull"]); - - var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); - - await Assert.That(uri.PathAndQuery).IsEqualTo("/void/6%20%26%207%2F8/path?a=test%40example.com&b=push%21%3Dpull"); - } - - /// Query parameters with a path delimiter are encoded. - /// A task that represents the asynchronous operation. - [Test] - public async Task QueryParamWithPathDelimiterShouldBeEncoded() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - "FetchSomeStuffWithVoidAndQueryAlias"); - var output = await factory(["6/6", "test@example.com", "push!=pull"]); - - var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); - - await Assert.That(uri.PathAndQuery).IsEqualTo("/void/6%2F6/path?a=test%40example.com&b=push%21%3Dpull"); - } - - /// Query parameters ending in double quotes are not truncated. - /// A task that represents the asynchronous operation. - [Test] - public async Task QueryParamWhichEndsInDoubleQuotesShouldNotBeTruncated() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - "FetchSomeStuffWithDoubleQuotesInUrl"); - var output = await factory([42]); - - var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); - - await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?q=app_metadata.id%3A%2242%22"); - } - - /// Captures the last character when a route ends with a constant. - /// The name of the interface method to build. - /// The trailing constant character expected in the URL. - /// The substring expected to appear in the URL. - /// A task that represents the asynchronous operation. - [Test] - [Arguments("GetWithTrainingParenthesis", ")", "/foo/bar/(1)")] - [Arguments("GetWithTrailingSlash", "/", "/foo/bar/1/")] - public async Task ShouldCaptureLastCharacterWhenRouteEndsWithConstant(string methodToTest, string constantChar, string contains) - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - methodToTest); - var output = await factory(["1"]); - - var uri = new Uri(new(ApiBaseUrlWithSlash), output.RequestUri!); - - await Assert.That(uri.PathAndQuery).EndsWith(constantChar); - await Assert.That(uri.PathAndQuery).Contains(contains); - } - - /// Mixed replacement and query values are encoded for a bad id. - /// A task that represents the asynchronous operation. - [Test] - public async Task ParameterizedQueryParamsShouldBeInUrlAndValuesEncodedWhenMixedReplacementAndQueryBadId() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - "FetchSomeStuffWithVoidAndQueryAlias"); - var output = await factory(["6", "test@example.com", "push!=pull"]); - - var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); - - await Assert.That(uri.PathAndQuery).IsEqualTo("/void/6/path?a=test%40example.com&b=push%21%3Dpull"); - } - - /// Non-formattable query parameters are included in the URL. - /// A task that represents the asynchronous operation. - [Test] - public async Task NonFormattableQueryParamsShouldBeIncluded() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - "FetchSomeStuffWithNonFormattableQueryParams"); - var output = await factory([true, 'x']); - - var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); - - await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?b=True&c=x"); - } - - /// Multiple parameters in the same segment are generated correctly. - /// A task that represents the asynchronous operation. - [Test] - public async Task MultipleParametersInTheSameSegmentAreGeneratedProperly() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - "FetchSomethingWithMultipleParametersPerSegment"); - var output = await factory([6, 1024, 768]); - - var uri = new Uri(new(ApiBaseUrl), output.RequestUri!); - await Assert.That(uri.PathAndQuery).IsEqualTo("/6/1024x768/foo"); - } - - /// Verifies a simple string path parameter is formatted with the expected metadata. - /// A task that represents the asynchronous test. - [Test] - public async Task UrlParameterShouldWorkWithGeneratedCode() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod(nameof(IBasicApi.GetParam)); - var output = await factory([nameof(IBasicApi.GetParam)]); - - var uri = new Uri(new("http://api"), output.RequestUri!); - await Assert.That(uri.PathAndQuery).IsEqualTo($"/{nameof(IBasicApi.GetParam)}"); - } } diff --git a/src/tests/Refit.Tests/ResponseTests.ErrorHandling.cs b/src/tests/Refit.Tests/ResponseTests.ErrorHandling.cs new file mode 100644 index 000000000..07cc51aac --- /dev/null +++ b/src/tests/Refit.Tests/ResponseTests.ErrorHandling.cs @@ -0,0 +1,285 @@ +// 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.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Refit.Testing; + +namespace Refit.Tests; + +/// Tests covering how failed and error responses surface their content and exceptions. +public sealed partial class ResponseTests +{ + /// Verifies that IsSuccessful returns false on a success status code when there is a deserialization error. + /// A task representing the asynchronous test. + [Test] + public async Task When_SerializationErrorOnSuccessStatusCode_IsSuccessful_ShouldReturnFalse() + { + var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(InvalidJsonContent) + }; + + var handler = new StubHttp + { + { + Route.Get(GetApiResponseTestObjectUrl), + Reply.From(req => expectedResponse) + }, + }; + var fixture = RestService.For(BaseAddress, handler.ToSettings()); + + using var response = await fixture.GetApiResponseTestObject(); + + await Assert.That(response!.IsReceived).IsTrue(); + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + await Assert.That(response.IsSuccessful).IsFalse(); + await Assert.That(response.Error).IsNotNull(); + } + + /// Verifies that EnsureSuccessStatusCodeAsync does not throw an ApiException on a success status code when there is a deserialization error. + /// A task representing the asynchronous test. + [Test] + public async Task When_SerializationErrorOnSuccessStatusCode_EnsureSuccesStatusCodeAsync_DoNotThrowApiException() + { + var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(InvalidJsonContent) + }; + + var handler = new StubHttp + { + { + Route.Get(GetApiResponseTestObjectUrl), + Reply.From(req => expectedResponse) + }, + }; + var fixture = RestService.For(BaseAddress, handler.ToSettings()); + + using var response = await fixture.GetApiResponseTestObject(); + await response!.EnsureSuccessStatusCodeAsync(); + + await Assert.That(response.IsReceived).IsTrue(); + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + await Assert.That(response.IsSuccessful).IsFalse(); + await Assert.That(response.Error).IsNotNull(); + } + + /// Verifies that EnsureSuccessfulAsync throws an ApiException on a success status code when there is a deserialization error. + /// A task representing the asynchronous test. + [Test] + public async Task When_SerializationErrorOnSuccessStatusCode_EnsureSuccessfulAsync_ThrowsApiException() + { + var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(InvalidJsonContent) + }; + + var handler = new StubHttp + { + { + Route.Get(GetApiResponseTestObjectUrl), + Reply.From(req => expectedResponse) + }, + }; + var fixture = RestService.For(BaseAddress, handler.ToSettings()); + + using var response = await fixture.GetApiResponseTestObject(); + var actualException = await Assert.That(async () => await response!.EnsureSuccessfulAsync()).ThrowsExactly(); + + await Assert.That(response!.IsReceived).IsTrue(); + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + await Assert.That(response.IsSuccessful).IsFalse(); + await Assert.That(actualException).IsNotNull(); + await Assert.That(actualException!.InnerException).IsTypeOf(); + } + + /// Verifies that a Bad Request with empty content surfaces as an ApiException. + /// A task representing the asynchronous test. + [Test] + public async Task BadRequestWithEmptyContent_ShouldReturnApiException() + { + var expectedResponse = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(HelloWorldContent) + }; + expectedResponse.Content.Headers.Clear(); + + var handler = new StubHttp + { + { + Route.Get(AliasTestUrl), + Reply.From(req => expectedResponse) + }, + }; + var fixture = RestService.For(BaseAddress, handler.ToSettings()); + + var actualException = await Assert.That(fixture.GetTestObject).ThrowsExactly(); + + await Assert.That(actualException!.Content).IsNotNull(); + await Assert.That(actualException.Content).IsEqualTo(HelloWorldContent); + } + + /// Verifies that a Bad Request with empty content surfaces through the ApiResponse error. + /// A task representing the asynchronous test. + [Test] + public async Task BadRequestWithEmptyContent_ShouldReturnApiResponse() + { + var expectedResponse = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(HelloWorldContent) + }; + expectedResponse.Content.Headers.Clear(); + + var handler = new StubHttp + { + { + Route.Get($"{BaseAddress}/{nameof(IMyAliasService.GetApiResponseTestObject)}"), + Reply.From(req => expectedResponse) + }, + }; + var fixture = RestService.For(BaseAddress, handler.ToSettings()); + + var apiResponse = await fixture.GetApiResponseTestObject(); + + await Assert.That(apiResponse).IsNotNull(); + await Assert.That(apiResponse!.Error).IsNotNull(); + await Assert.That(apiResponse.HasResponseError(out var error)).IsTrue(); + await Assert.That(error!.Content).IsNotNull(); + await Assert.That(error.Content).IsEqualTo(HelloWorldContent); + } + + /// Verifies that a Bad Request with string content surfaces through the IApiResponse error. + /// A task representing the asynchronous test. + [Test] + public async Task BadRequestWithStringContent_ShouldReturnIApiResponse() + { + var expectedResponse = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(HelloWorldContent) + }; + expectedResponse.Content.Headers.Clear(); + + var handler = new StubHttp + { + { + Route.Get($"{BaseAddress}/{nameof(IMyAliasService.GetIApiResponse)}"), + Reply.From(req => expectedResponse) + }, + }; + var fixture = RestService.For(BaseAddress, handler.ToSettings()); + + var apiResponse = await fixture.GetIApiResponse(); + + await Assert.That(apiResponse).IsNotNull(); + await Assert.That(apiResponse.Error).IsNotNull(); + await Assert.That(apiResponse.HasResponseError(out var error)).IsTrue(); + await Assert.That(error!.Content).IsNotNull(); + await Assert.That(error.Content).IsEqualTo(HelloWorldContent); + } + + /// Verifies that a Bad Request with string content surfaces through the ValueTask IApiResponse error. + /// A task representing the asynchronous test. + [Test] + public async Task BadRequestWithStringContent_ShouldReturnValueTaskIApiResponse() + { + var expectedResponse = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent(HelloWorldContent) + }; + expectedResponse.Content.Headers.Clear(); + + var handler = new StubHttp + { + { + Route.Get($"{BaseAddress}/{nameof(IMyAliasService.GetValueTaskIApiResponse)}"), + Reply.From(req => expectedResponse) + }, + }; + var fixture = RestService.For(BaseAddress, handler.ToSettings()); + + var apiResponse = await fixture.GetValueTaskIApiResponse(); + + await Assert.That(apiResponse).IsNotNull(); + await Assert.That(apiResponse.Error).IsNotNull(); + await Assert.That(apiResponse.HasResponseError(out var error)).IsTrue(); + await Assert.That(error!.Content).IsNotNull(); + await Assert.That(error.Content).IsEqualTo(HelloWorldContent); + } + + /// Verifies that an HTML response on a JSON endpoint surfaces as an ApiException. + /// A task representing the asynchronous test. + [Test] + public async Task WithHtmlResponse_ShouldReturnApiException() + { + const string htmlResponse = "Hello world"; + var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(htmlResponse) + }; + expectedResponse.Content.Headers.Clear(); + + var handler = new StubHttp + { + { + Route.Get(AliasTestUrl), + Reply.From(req => expectedResponse) + }, + }; + var fixture = RestService.For(BaseAddress, handler.ToSettings()); + + var actualException = await Assert.That(fixture.GetTestObject).ThrowsExactly(); + + await Assert.That(actualException!.InnerException).IsTypeOf(); + await Assert.That(actualException.Content).IsNotNull(); + await Assert.That(actualException.Content).IsEqualTo(htmlResponse); + } + + /// Verifies that an HTML response on a JSON endpoint surfaces through the ApiResponse error. + /// A task representing the asynchronous test. + [Test] + public async Task WithHtmlResponse_ShouldReturnApiResponse() + { + const string htmlResponse = "Hello world"; + var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(htmlResponse) + }; + expectedResponse.Content.Headers.Clear(); + + var handler = new StubHttp + { + { + Route.Get($"{BaseAddress}/{nameof(IMyAliasService.GetApiResponseTestObject)}"), + Reply.From(req => expectedResponse) + }, + }; + var fixture = RestService.For(BaseAddress, handler.ToSettings()); + + var apiResponse = await fixture.GetApiResponseTestObject(); + + await Assert.That(apiResponse!.Error).IsNotNull(); + await Assert.That(apiResponse.Error!.InnerException).IsTypeOf(); + await Assert.That(apiResponse.HasResponseError(out var error)).IsTrue(); + await Assert.That(error!.Content).IsNotNull(); + await Assert.That(error.Content).IsEqualTo(htmlResponse); + } + + /// Verifies that the exception factory throws a clear exception when the request message is missing. + /// A task representing the asynchronous test. + [Test] + public async Task ExceptionFactory_WithoutRequestMessage_ThrowsClearException() + { + var settings = new RefitSettings(); + + using var response = new HttpResponseMessage(HttpStatusCode.BadRequest); + + // RequestMessage is left null, as a hand-rolled test HttpMessageHandler often does. + var ex = await Assert.That(async () => await settings.ExceptionFactory(response)).ThrowsExactly(); + + await Assert.That(ex!.Message).Contains("RequestMessage", StringComparison.Ordinal); + } +} diff --git a/src/tests/Refit.Tests/ResponseTests.cs b/src/tests/Refit.Tests/ResponseTests.cs index 98159dbae..b3c401835 100644 --- a/src/tests/Refit.Tests/ResponseTests.cs +++ b/src/tests/Refit.Tests/ResponseTests.cs @@ -16,7 +16,7 @@ namespace Refit.Tests; /// Tests covering response deserialization, error handling, and exception hydration behavior. -public sealed class ResponseTests +public sealed partial class ResponseTests { /// Base address shared by the response-handling fixtures. private const string BaseAddress = "http://api"; @@ -30,6 +30,12 @@ public sealed class ResponseTests /// Media type used for RFC 7807 problem-details responses. private const string ProblemJsonMediaType = "application/problem+json"; + /// The JSON media type reused across the content-type tests. + private const string JsonMediaType = "application/json"; + + /// The invalid JSON response body used to trigger deserialization failures. + private const string InvalidJsonContent = "Invalid JSON"; + /// Expected problem-details detail value. private const string DetailValue = "detail"; @@ -57,6 +63,10 @@ public sealed class ResponseTests /// Problem detail entries for the second validation problem fixture. private static readonly string[] _field2Problems = ["Problem2"]; + /// Case-sensitive serializer options shared by the alias mapping fixture. + private static readonly JsonSerializerOptions _caseSensitiveSerializerOptions = + new() { PropertyNameCaseInsensitive = false }; + /// Refit service used to exercise alias and response handling. public interface IMyAliasService { @@ -223,8 +233,7 @@ public async Task ValidationApiExceptionUsesConfiguredContentSerializer() }, }; var localFixture = localHandler.CreateClient(BaseAddress, new RefitSettings( - new SystemTextJsonContentSerializer( - new() { PropertyNameCaseInsensitive = false }))); + new SystemTextJsonContentSerializer(_caseSensitiveSerializerOptions))); var actualException = await Assert.That(localFixture.GetTestObject).ThrowsExactly(); @@ -269,7 +278,7 @@ public async Task When_BadRequest_EnsureSuccessStatusCodeAsync_ThrowsValidationE var fixture = RestService.For(BaseAddress, handler.ToSettings()); using var response = await fixture.GetApiResponseTestObject(); - var actualException = await Assert.That(() => (Task)response!.EnsureSuccessStatusCodeAsync()).ThrowsExactly(); + var actualException = await Assert.That(async () => await response!.EnsureSuccessStatusCodeAsync()).ThrowsExactly(); await Assert.That(actualException!.Content).IsNotNull(); await Assert.That(actualException.Content!.Detail).IsEqualTo(DetailValue); @@ -281,90 +290,6 @@ public async Task When_BadRequest_EnsureSuccessStatusCodeAsync_ThrowsValidationE await Assert.That(actualException.Content.Type).IsEqualTo("type"); } - /// Verifies that IsSuccessful returns false on a success status code when there is a deserialization error. - /// A task representing the asynchronous test. - [Test] - public async Task When_SerializationErrorOnSuccessStatusCode_IsSuccessful_ShouldReturnFalse() - { - var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("Invalid JSON") - }; - - var handler = new StubHttp - { - { - Route.Get(GetApiResponseTestObjectUrl), - Reply.From(req => expectedResponse) - }, - }; - var fixture = RestService.For(BaseAddress, handler.ToSettings()); - - using var response = await fixture.GetApiResponseTestObject(); - - await Assert.That(response!.IsReceived).IsTrue(); - await Assert.That(response.IsSuccessStatusCode).IsTrue(); - await Assert.That(response.IsSuccessful).IsFalse(); - await Assert.That(response.Error).IsNotNull(); - } - - /// Verifies that EnsureSuccessStatusCodeAsync does not throw an ApiException on a success status code when there is a deserialization error. - /// A task representing the asynchronous test. - [Test] - public async Task When_SerializationErrorOnSuccessStatusCode_EnsureSuccesStatusCodeAsync_DoNotThrowApiException() - { - var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("Invalid JSON") - }; - - var handler = new StubHttp - { - { - Route.Get(GetApiResponseTestObjectUrl), - Reply.From(req => expectedResponse) - }, - }; - var fixture = RestService.For(BaseAddress, handler.ToSettings()); - - using var response = await fixture.GetApiResponseTestObject(); - await response!.EnsureSuccessStatusCodeAsync(); - - await Assert.That(response.IsReceived).IsTrue(); - await Assert.That(response.IsSuccessStatusCode).IsTrue(); - await Assert.That(response.IsSuccessful).IsFalse(); - await Assert.That(response.Error).IsNotNull(); - } - - /// Verifies that EnsureSuccessfulAsync throws an ApiException on a success status code when there is a deserialization error. - /// A task representing the asynchronous test. - [Test] - public async Task When_SerializationErrorOnSuccessStatusCode_EnsureSuccessfulAsync_ThrowsApiException() - { - var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("Invalid JSON") - }; - - var handler = new StubHttp - { - { - Route.Get(GetApiResponseTestObjectUrl), - Reply.From(req => expectedResponse) - }, - }; - var fixture = RestService.For(BaseAddress, handler.ToSettings()); - - using var response = await fixture.GetApiResponseTestObject(); - var actualException = await Assert.That(() => (Task)response!.EnsureSuccessfulAsync()).ThrowsExactly(); - - await Assert.That(response!.IsReceived).IsTrue(); - await Assert.That(response.IsSuccessStatusCode).IsTrue(); - await Assert.That(response.IsSuccessful).IsFalse(); - await Assert.That(actualException).IsNotNull(); - await Assert.That(actualException!.InnerException).IsTypeOf(); - } - /// Verifies that ProblemDetails extensions are hydrated when present on the response. /// A task representing the asynchronous test. [Test] @@ -377,7 +302,7 @@ public async Task WhenProblemDetailsResponseContainsExtensions_ShouldHydrateExte Title: TitleValue, Type: "type", Foo: "bar", - Baz: 123.5d); + Baz: 123.5D); var expectedResponse = new HttpResponseMessage(HttpStatusCode.BadRequest) { @@ -446,7 +371,7 @@ public async Task WithNonSeekableStream_UsingSystemTextJsonContentSerializer() { Headers = { - ContentType = new("application/json") + ContentType = new(JsonMediaType) { CharSet = Encoding.UTF8.WebName } @@ -455,7 +380,7 @@ public async Task WithNonSeekableStream_UsingSystemTextJsonContentSerializer() var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK) { Content = httpContent }; - expectedResponse.Content.Headers.ContentType = new("application/json"); + expectedResponse.Content.Headers.ContentType = new(JsonMediaType); expectedResponse.StatusCode = HttpStatusCode.OK; var localHandler = new StubHttp @@ -495,7 +420,7 @@ public async Task DeserializationFailureWithNonSeekableStream_PopulatesApiExcept contentStream.CanGetLength = false; var httpContent = new StreamContent(contentStream); - httpContent.Headers.ContentType = new("application/json"); + httpContent.Headers.ContentType = new(JsonMediaType); var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK) { Content = httpContent }; @@ -554,119 +479,6 @@ public async Task XmlDeserializationWithNonSeekableStream_DoesNotThrowStreamCons await Assert.That(result.Identifier).IsEqualTo("abc-123"); } - /// Verifies that a Bad Request with empty content surfaces as an ApiException. - /// A task representing the asynchronous test. - [Test] - public async Task BadRequestWithEmptyContent_ShouldReturnApiException() - { - var expectedResponse = new HttpResponseMessage(HttpStatusCode.BadRequest) - { - Content = new StringContent(HelloWorldContent) - }; - expectedResponse.Content.Headers.Clear(); - - var handler = new StubHttp - { - { - Route.Get(AliasTestUrl), - Reply.From(req => expectedResponse) - }, - }; - var fixture = RestService.For(BaseAddress, handler.ToSettings()); - - var actualException = await Assert.That(fixture.GetTestObject).ThrowsExactly(); - - await Assert.That(actualException!.Content).IsNotNull(); - await Assert.That(actualException.Content).IsEqualTo(HelloWorldContent); - } - - /// Verifies that a Bad Request with empty content surfaces through the ApiResponse error. - /// A task representing the asynchronous test. - [Test] - public async Task BadRequestWithEmptyContent_ShouldReturnApiResponse() - { - var expectedResponse = new HttpResponseMessage(HttpStatusCode.BadRequest) - { - Content = new StringContent(HelloWorldContent) - }; - expectedResponse.Content.Headers.Clear(); - - var handler = new StubHttp - { - { - Route.Get($"{BaseAddress}/{nameof(IMyAliasService.GetApiResponseTestObject)}"), - Reply.From(req => expectedResponse) - }, - }; - var fixture = RestService.For(BaseAddress, handler.ToSettings()); - - var apiResponse = await fixture.GetApiResponseTestObject(); - - await Assert.That(apiResponse).IsNotNull(); - await Assert.That(apiResponse!.Error).IsNotNull(); - await Assert.That(apiResponse.HasResponseError(out var error)).IsTrue(); - await Assert.That(error!.Content).IsNotNull(); - await Assert.That(error.Content).IsEqualTo(HelloWorldContent); - } - - /// Verifies that a Bad Request with string content surfaces through the IApiResponse error. - /// A task representing the asynchronous test. - [Test] - public async Task BadRequestWithStringContent_ShouldReturnIApiResponse() - { - var expectedResponse = new HttpResponseMessage(HttpStatusCode.BadRequest) - { - Content = new StringContent(HelloWorldContent) - }; - expectedResponse.Content.Headers.Clear(); - - var handler = new StubHttp - { - { - Route.Get($"{BaseAddress}/{nameof(IMyAliasService.GetIApiResponse)}"), - Reply.From(req => expectedResponse) - }, - }; - var fixture = RestService.For(BaseAddress, handler.ToSettings()); - - var apiResponse = await fixture.GetIApiResponse(); - - await Assert.That(apiResponse).IsNotNull(); - await Assert.That(apiResponse.Error).IsNotNull(); - await Assert.That(apiResponse.HasResponseError(out var error)).IsTrue(); - await Assert.That(error!.Content).IsNotNull(); - await Assert.That(error.Content).IsEqualTo(HelloWorldContent); - } - - /// Verifies that a Bad Request with string content surfaces through the ValueTask IApiResponse error. - /// A task representing the asynchronous test. - [Test] - public async Task BadRequestWithStringContent_ShouldReturnValueTaskIApiResponse() - { - var expectedResponse = new HttpResponseMessage(HttpStatusCode.BadRequest) - { - Content = new StringContent(HelloWorldContent) - }; - expectedResponse.Content.Headers.Clear(); - - var handler = new StubHttp - { - { - Route.Get($"{BaseAddress}/{nameof(IMyAliasService.GetValueTaskIApiResponse)}"), - Reply.From(req => expectedResponse) - }, - }; - var fixture = RestService.For(BaseAddress, handler.ToSettings()); - - var apiResponse = await fixture.GetValueTaskIApiResponse(); - - await Assert.That(apiResponse).IsNotNull(); - await Assert.That(apiResponse.Error).IsNotNull(); - await Assert.That(apiResponse.HasResponseError(out var error)).IsTrue(); - await Assert.That(error!.Content).IsNotNull(); - await Assert.That(error.Content).IsEqualTo(HelloWorldContent); - } - /// Verifies that ValidationApiException hydrates the base ApiException Content. /// A task representing the asynchronous test. [Test] @@ -701,79 +513,6 @@ public async Task ValidationApiException_HydratesBaseContent() await Assert.That(actualBaseException!.Content).IsEqualTo(expectedContent); } - /// Verifies that an HTML response on a JSON endpoint surfaces as an ApiException. - /// A task representing the asynchronous test. - [Test] - public async Task WithHtmlResponse_ShouldReturnApiException() - { - const string htmlResponse = "Hello world"; - var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(htmlResponse) - }; - expectedResponse.Content.Headers.Clear(); - - var handler = new StubHttp - { - { - Route.Get(AliasTestUrl), - Reply.From(req => expectedResponse) - }, - }; - var fixture = RestService.For(BaseAddress, handler.ToSettings()); - - var actualException = await Assert.That(fixture.GetTestObject).ThrowsExactly(); - - await Assert.That(actualException!.InnerException).IsTypeOf(); - await Assert.That(actualException.Content).IsNotNull(); - await Assert.That(actualException.Content).IsEqualTo(htmlResponse); - } - - /// Verifies that an HTML response on a JSON endpoint surfaces through the ApiResponse error. - /// A task representing the asynchronous test. - [Test] - public async Task WithHtmlResponse_ShouldReturnApiResponse() - { - const string htmlResponse = "Hello world"; - var expectedResponse = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(htmlResponse) - }; - expectedResponse.Content.Headers.Clear(); - - var handler = new StubHttp - { - { - Route.Get($"{BaseAddress}/{nameof(IMyAliasService.GetApiResponseTestObject)}"), - Reply.From(req => expectedResponse) - }, - }; - var fixture = RestService.For(BaseAddress, handler.ToSettings()); - - var apiResponse = await fixture.GetApiResponseTestObject(); - - await Assert.That(apiResponse!.Error).IsNotNull(); - await Assert.That(apiResponse.Error!.InnerException).IsTypeOf(); - await Assert.That(apiResponse.HasResponseError(out var error)).IsTrue(); - await Assert.That(error!.Content).IsNotNull(); - await Assert.That(error.Content).IsEqualTo(htmlResponse); - } - - /// Verifies that the exception factory throws a clear exception when the request message is missing. - /// A task representing the asynchronous test. - [Test] - public async Task ExceptionFactory_WithoutRequestMessage_ThrowsClearException() - { - var settings = new RefitSettings(); - - using var response = new HttpResponseMessage(HttpStatusCode.BadRequest); - - // RequestMessage is left null, as a hand-rolled test HttpMessageHandler often does. - var ex = await Assert.That(() => settings.ExceptionFactory(response)).ThrowsExactly(); - - await Assert.That(ex!.Message).Contains("RequestMessage", StringComparison.Ordinal); - } - /// Problem-details payload carrying extension members for the extension-hydration test. /// The problem detail. /// The problem instance. diff --git a/src/tests/Refit.Tests/RestMethodInfoTests.HeaderCollection.cs b/src/tests/Refit.Tests/RestMethodInfoTests.HeaderCollection.cs index 22180e835..4324e7cc4 100644 --- a/src/tests/Refit.Tests/RestMethodInfoTests.HeaderCollection.cs +++ b/src/tests/Refit.Tests/RestMethodInfoTests.HeaderCollection.cs @@ -2,8 +2,6 @@ // 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; - namespace Refit.Tests; /// Tests for header-collection and property parsing. @@ -193,7 +191,7 @@ public async Task DynamicHeaderCollectionShouldWorkWithDynamicHeader(string inte input .GetMethods() .First( - x => + static x => x.Name == nameof( IRestMethodInfoTests.FetchSomeStuffWithDynamicHeaderCollectionAndDynamicHeaderOrderFlipped))); @@ -492,165 +490,6 @@ public async Task DynamicRequestPropertiesWithDuplicateKeysDontBlowUp() await Assert.That(fixture.PropertyParameterMap[ParameterIndexTwo]).IsEqualTo(SomePropertyKey); } - /// Verifies value-type body parameters do not throw when buffered. - /// A task that represents the asynchronous operation. - [Test] - public async Task ValueTypesDontBlowUpBuffered() - { - var input = typeof(IRestMethodInfoTests); - var fixture = new RestMethodInfoInternal( - input, - input - .GetMethods() - .First(static x => x.Name == nameof(IRestMethodInfoTests.OhYeahValueTypes))); - await Assert.That(fixture.ParameterMap[0].Name).IsEqualTo("id"); - await Assert.That(fixture.ParameterMap[0].Type).IsEqualTo(ParameterType.Normal); - await Assert.That(fixture.QueryParameterMap).IsEmpty(); - await Assert.That(fixture.BodyParameterInfo!.Item1).IsEqualTo(BodySerializationMethod.Default); - await Assert.That(fixture.BodyParameterInfo.Item2).IsTrue(); // buffered default - await Assert.That(fixture.BodyParameterInfo.Item3).IsEqualTo(1); - - await Assert.That(fixture.ReturnResultType).IsEqualTo(typeof(bool)); - } - - /// Verifies value-type body parameters do not throw when unbuffered. - /// A task that represents the asynchronous operation. - [Test] - public async Task ValueTypesDontBlowUpUnBuffered() - { - var input = typeof(IRestMethodInfoTests); - var fixture = new RestMethodInfoInternal( - input, - input - .GetMethods() - .First(static x => x.Name == nameof(IRestMethodInfoTests.OhYeahValueTypesUnbuffered))); - await Assert.That(fixture.ParameterMap[0].Name).IsEqualTo("id"); - await Assert.That(fixture.ParameterMap[0].Type).IsEqualTo(ParameterType.Normal); - await Assert.That(fixture.QueryParameterMap).IsEmpty(); - await Assert.That(fixture.BodyParameterInfo!.Item1).IsEqualTo(BodySerializationMethod.Default); - await Assert.That(fixture.BodyParameterInfo.Item2).IsFalse(); // unbuffered specified - await Assert.That(fixture.BodyParameterInfo.Item3).IsEqualTo(1); - - await Assert.That(fixture.ReturnResultType).IsEqualTo(typeof(bool)); - } - - /// Verifies a stream pull method parses its body parameter correctly. - /// A task that represents the asynchronous operation. - [Test] - public async Task StreamMethodPullWorks() - { - var input = typeof(IRestMethodInfoTests); - var fixture = new RestMethodInfoInternal( - input, - input - .GetMethods() - .First(static x => x.Name == nameof(IRestMethodInfoTests.PullStreamMethod))); - await Assert.That(fixture.ParameterMap[0].Name).IsEqualTo("id"); - await Assert.That(fixture.ParameterMap[0].Type).IsEqualTo(ParameterType.Normal); - await Assert.That(fixture.QueryParameterMap).IsEmpty(); - await Assert.That(fixture.BodyParameterInfo!.Item1).IsEqualTo(BodySerializationMethod.Default); - await Assert.That(fixture.BodyParameterInfo.Item2).IsTrue(); - await Assert.That(fixture.BodyParameterInfo.Item3).IsEqualTo(1); - - await Assert.That(fixture.ReturnResultType).IsEqualTo(typeof(bool)); - } - - /// Verifies a method returning a non-generic task resolves its return type. - /// A task that represents the asynchronous operation. - [Test] - public async Task ReturningTaskShouldWork() - { - var input = typeof(IRestMethodInfoTests); - var fixture = new RestMethodInfoInternal( - input, - input.GetMethods().First(static x => x.Name == nameof(IRestMethodInfoTests.VoidPost))); - await Assert.That(fixture.ParameterMap[0].Name).IsEqualTo("id"); - await Assert.That(fixture.ParameterMap[0].Type).IsEqualTo(ParameterType.Normal); - - await Assert.That(fixture.ReturnType).IsEqualTo(typeof(Task)); - await Assert.That(fixture.ReturnResultType).IsEqualTo(typeof(void)); - } - - /// Verifies a synchronous method throws an argument exception. - /// A task that represents the asynchronous operation. - [Test] - public async Task SyncMethodsShouldThrow() - { - var shouldDie = true; - - try - { - var input = typeof(IRestMethodInfoTests); - _ = new RestMethodInfoInternal( - input, - input - .GetMethods() - .First(static x => x.Name == nameof(IRestMethodInfoTests.AsyncOnlyBuddy))); - } - catch (ArgumentException) - { - shouldDie = false; - } - - await Assert.That(shouldDie).IsFalse(); - } - - /// Verifies the patch attribute sets the HTTP method to PATCH. - /// A task that represents the asynchronous operation. - [Test] - public async Task UsingThePatchAttributeSetsTheCorrectMethod() - { - var input = typeof(IRestMethodInfoTests); - var fixture = new RestMethodInfoInternal( - input, - input.GetMethods().First(static x => x.Name == nameof(IRestMethodInfoTests.PatchSomething))); - - await Assert.That(fixture.HttpMethod.Method).IsEqualTo("PATCH"); - } - - /// Verifies the options attribute sets the HTTP method to OPTIONS. - /// A task that represents the asynchronous operation. - [Test] - public async Task UsingOptionsAttribute() - { - var input = typeof(IRestMethodInfoTests); - var fixture = new RestMethodInfoInternal( - input, - input.GetMethods().First(static x => x.Name == nameof(IDummyHttpApi.SendOptions))); - - await Assert.That(fixture.HttpMethod.Method).IsEqualTo("OPTIONS"); - } - - /// Verifies the api response flag is set for a method returning an API response. - /// A task that represents the asynchronous operation. - [Test] - public async Task ApiResponseShouldBeSet() - { - var input = typeof(IRestMethodInfoTests); - var fixture = new RestMethodInfoInternal( - input, - input - .GetMethods() - .First(static x => x.Name == nameof(IRestMethodInfoTests.PostReturnsApiResponse))); - - await Assert.That(fixture.IsApiResponse).IsTrue(); - } - - /// Verifies the api response flag is not set for a method returning a non-API response. - /// A task that represents the asynchronous operation. - [Test] - public async Task ApiResponseShouldNotBeSet() - { - var input = typeof(IRestMethodInfoTests); - var fixture = new RestMethodInfoInternal( - input, - input - .GetMethods() - .First(static x => x.Name == nameof(IRestMethodInfoTests.PostReturnsNonApiResponse))); - - await Assert.That(fixture.IsApiResponse).IsFalse(); - } - /// Verifies parameter mapping with a header, query parameter and array query parameter. /// A task that represents the asynchronous operation. [Test] @@ -672,56 +511,4 @@ public async Task ParameterMappingWithHeaderQueryParamAndQueryArrayParam() await Assert.That(fixture.HeaderParameterMap).HasSingleItem(); await Assert.That(fixture.PropertyParameterMap).HasSingleItem(); } - - /// Verifies a generic return type that is not a task or observable throws. - /// A task that represents the asynchronous operation. - [Test] - public async Task GenericReturnTypeIsNotTaskOrObservableShouldThrow() - { - var input = typeof(IRestMethodInfoTests); - await Assert.That( - () => - new RestMethodInfoInternal( - input, - input - .GetMethods() - .First( - x => x.Name == nameof(IRestMethodInfoTests.InvalidGenericReturnType)))).ThrowsExactly(); - } - - /// Verifies an internal sync generic return type sets the deserialized type to the return type. - /// A task that represents the asynchronous operation. - [Test] - public async Task InternalSyncGenericReturnTypeSetsDeserializedTypeToReturnType() - { - var input = typeof(IInternalSyncGenericReturnTypeApi); - var fixture = new RestMethodInfoInternal( - input, - input - .GetTypeInfo() - .DeclaredMethods - .First(static x => x.Name == nameof(IInternalSyncGenericReturnTypeApi.GetValues))); - - await Assert.That(fixture.ReturnType).IsEqualTo(typeof(List)); - await Assert.That(fixture.ReturnResultType).IsEqualTo(typeof(List)); - await Assert.That(fixture.DeserializedResultType).IsEqualTo(typeof(List)); - } - - /// Verifies an internal sync IApiResponse generic return type sets the deserialized type to the generic argument. - /// A task that represents the asynchronous operation. - [Test] - public async Task InternalSyncIApiResponseGenericReturnTypeSetsDeserializedTypeToGenericArgument() - { - var input = typeof(IInternalSyncGenericApiResponseReturnTypeApi); - var fixture = new RestMethodInfoInternal( - input, - input - .GetTypeInfo() - .DeclaredMethods - .First(static x => x.Name == nameof(IInternalSyncGenericApiResponseReturnTypeApi.GetResponse))); - - await Assert.That(fixture.ReturnType).IsEqualTo(typeof(IApiResponse)); - await Assert.That(fixture.ReturnResultType).IsEqualTo(typeof(IApiResponse)); - await Assert.That(fixture.DeserializedResultType).IsEqualTo(typeof(string)); - } } diff --git a/src/tests/Refit.Tests/RestMethodInfoTests.QueryAndBodyParameters.cs b/src/tests/Refit.Tests/RestMethodInfoTests.QueryAndBodyParameters.cs new file mode 100644 index 000000000..9cde6a095 --- /dev/null +++ b/src/tests/Refit.Tests/RestMethodInfoTests.QueryAndBodyParameters.cs @@ -0,0 +1,358 @@ +// 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 query and body parameter parsing. +public partial class RestMethodInfoTests +{ + /// Verifies a method with too many complex types throws. + /// A task that represents the asynchronous operation. + [Test] + public async Task TooManyComplexTypesThrows() + { + var input = typeof(IRestMethodInfoTests); + + await Assert.That(() => + { + _ = new RestMethodInfoInternal( + input, + input + .GetMethods() + .First(static x => x.Name == nameof(IRestMethodInfoTests.TooManyComplexTypes))); + }).ThrowsExactly(); + } + + /// Verifies a method with many complex types maps the body parameter. + /// A task that represents the asynchronous operation. + [Test] + public async Task ManyComplexTypes() + { + var input = typeof(IRestMethodInfoTests); + var fixture = new RestMethodInfoInternal( + input, + input + .GetMethods() + .First(static x => x.Name == nameof(IRestMethodInfoTests.ManyComplexTypes))); + + await Assert.That(fixture.QueryParameterMap).HasSingleItem(); + await Assert.That(fixture.BodyParameterInfo).IsNotNull(); + await Assert.That(fixture.BodyParameterInfo!.Item3).IsEqualTo(1); + } + + /// Verifies the body parameter is detected by default for put, post and patch. + /// The name of the interface method under test. + /// A task that represents the asynchronous operation. + [Test] + [Arguments(nameof(IRestMethodInfoTests.PutWithBodyDetected))] + [Arguments(nameof(IRestMethodInfoTests.PostWithBodyDetected))] + [Arguments(nameof(IRestMethodInfoTests.PatchWithBodyDetected))] + public async Task DefaultBodyParameterDetected(string interfaceMethodName) + { + var input = typeof(IRestMethodInfoTests); + var fixture = new RestMethodInfoInternal( + input, + input.GetMethods().First(x => x.Name == interfaceMethodName)); + + await Assert.That(fixture.QueryParameterMap).IsEmpty(); + await Assert.That(fixture.BodyParameterInfo).IsNotNull(); + } + + /// Verifies the body parameter is not inferred for a GET method. + /// A task that represents the asynchronous operation. + [Test] + public async Task DefaultBodyParameterNotDetectedForGet() + { + var input = typeof(IRestMethodInfoTests); + var fixture = new RestMethodInfoInternal( + input, + input + .GetMethods() + .First(static x => x.Name == nameof(IRestMethodInfoTests.GetWithBodyDetected))); + + await Assert.That(fixture.QueryParameterMap).HasSingleItem(); + await Assert.That(fixture.BodyParameterInfo).IsNull(); + } + + /// Verifies a dictionary query parameter maps to a single query entry. + /// A task that represents the asynchronous operation. + [Test] + public async Task PostWithDictionaryQueryParameter() + { + var input = typeof(IRestMethodInfoTests); + var fixture = new RestMethodInfoInternal( + input, + input + .GetMethods() + .First(static x => x.Name == nameof(IRestMethodInfoTests.PostWithDictionaryQuery))); + + await Assert.That(fixture.QueryParameterMap).HasSingleItem(); + await Assert.That(fixture.BodyParameterInfo).IsNull(); + } + + /// Verifies an object query parameter produces a single query parameter value. + /// A task that represents the asynchronous operation. + [Test] + public async Task PostWithObjectQueryParameterHasSingleQueryParameterValue() + { + var input = typeof(IRestMethodInfoTests); + var fixtureParams = new RestMethodInfoInternal( + input, + input + .GetMethods() + .First(static x => x.Name == nameof(IRestMethodInfoTests.PostWithComplexTypeQuery))); + + await Assert.That(fixtureParams.QueryParameterMap).HasSingleItem(); + await Assert.That(fixtureParams.QueryParameterMap[0]).IsEqualTo("queryParams"); + await Assert.That(fixtureParams.BodyParameterInfo).IsNull(); + } + + /// Verifies a CultureInfo query parameter does not cause a stack overflow. + /// A task that represents the asynchronous operation. + [Test] + public async Task CultureInfoQueryParameterDoesNotStackOverflow() + { + var fixture = new RequestBuilderImplementation(); + + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.QueryWithCultureInfo)); + + var output = await factory([new System.Globalization.CultureInfo("en-US")]); + + var uri = new Uri(new(BaseAddressUri), output.RequestUri!); + + await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?culture=en-US"); + } + + /// Verifies a base address with a trailing slash does not produce a double slash. + /// A task that represents the asynchronous operation. + [Test] + public async Task BaseAddressWithTrailingSlashDoesNotProduceDoubleSlash() + { + var fixture = new RequestBuilderImplementation(); + + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.FetchSomeStuff), + baseAddress: "http://api/v1/"); + + const int stuffId = 42; + var output = await factory([stuffId]); + + var uri = new Uri(new(BaseAddressUri), output.RequestUri!); + + await Assert.That(uri.AbsolutePath).IsEqualTo("/v1/foo/bar/42"); + } + + /// Verifies a derived path-bound object does not duplicate the path property as a query. + /// The expected request path and query string. + /// A task that represents the asynchronous operation. + [Test] + [Arguments("/api/123?SomeProperty2=test&text=title&filters=A&filters=B")] + public async Task DerivedPathBoundObjectDoesNotDuplicatePathPropertyAsQuery(string expectedQuery) + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + "QueryWithOptionalParametersPathBoundObject"); + const int pathBoundId = 123; + var output = await factory( + [ + new DerivedPathBoundObject { SomeProperty = pathBoundId, SomeProperty2 = "test" }, + "title", + null!, + _filterValues + ]); + + var uri = new Uri(new(BaseAddressUri), output.RequestUri!); + await Assert.That(uri.PathAndQuery).IsEqualTo(expectedQuery); + } + + /// Verifies an object query parameter produces the correct query string. + /// A task that represents the asynchronous operation. + [Test] + public async Task PostWithObjectQueryParameterHasCorrectQuerystring() + { + var fixture = new RequestBuilderImplementation(); + + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.PostWithComplexTypeQuery)); + + var param = new ComplexQueryObject { TestAlias1 = "one", TestAlias2 = "two" }; + + var output = await factory([param]); + + var uri = new Uri(new(BaseAddressUri), output.RequestUri!); + + await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?test-query-alias=one&TestAlias2=two"); + } + + /// Verifies an object query parameter skips ignored properties. + /// A task that represents the asynchronous operation. + [Test] + public async Task PostWithObjectQueryParameterSkipsIgnoredProperties() + { + var fixture = new RequestBuilderImplementation(); + + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.PostWithComplexTypeQuery)); + + var param = new ComplexQueryObject + { + TestAlias1 = "one", + InternalUseOnlyIgnoredByDataMember = "nope", + InternalUseOnlyIgnoredBySystemTextJson = "nope" + }; + + var output = await factory([param]); + + await Assert.That(output.RequestUri!.PathAndQuery).IsEqualTo("/foo?test-query-alias=one"); + } + + /// Verifies an enum list query parameter uses the multi collection format. + /// A task that represents the asynchronous operation. + [Test] + public async Task PostWithObjectQueryParameterWithEnumList_Multi() + { + var fixture = new RequestBuilderImplementation(); + + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.PostWithComplexTypeQuery)); + + var param = new ComplexQueryObject + { + EnumCollectionMulti = [TestEnum.A, TestEnum.B] + }; + + var output = await factory([param]); + + await Assert.That(output.RequestUri!.PathAndQuery).IsEqualTo( + "/foo?listOfEnumMulti=A&listOfEnumMulti=B"); + } + + /// Verifies an object list with provided enum values uses the multi collection format. + /// A task that represents the asynchronous operation. + [Test] + public async Task PostWithObjectQueryParameterWithObjectListWithProvidedEnumValues_Multi() + { + var fixture = new RequestBuilderImplementation(); + + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.PostWithComplexTypeQuery)); + + var param = new ComplexQueryObject + { + ObjectCollectionMulti = [TestEnum.A, TestEnum.B] + }; + + var output = await factory([param]); + + await Assert.That(output.RequestUri!.PathAndQuery).IsEqualTo( + "/foo?ObjectCollectionMulti=A&ObjectCollectionMulti=B"); + } + + /// Verifies an enum list query parameter uses the CSV collection format. + /// A task that represents the asynchronous operation. + [Test] + public async Task PostWithObjectQueryParameterWithEnumList_Csv() + { + var fixture = new RequestBuilderImplementation(); + + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.PostWithComplexTypeQuery)); + + var param = new ComplexQueryObject + { + EnumCollectionCsv = [TestEnum.A, TestEnum.B] + }; + + var output = await factory([param]); + + await Assert.That(output.RequestUri!.PathAndQuery).IsEqualTo("/foo?EnumCollectionCsv=A%2CB"); + } + + /// Verifies an object list with provided enum values uses the CSV collection format. + /// A task that represents the asynchronous operation. + [Test] + public async Task PostWithObjectQueryParameterWithObjectListWithProvidedEnumValues_Csv() + { + var fixture = new RequestBuilderImplementation(); + + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.PostWithComplexTypeQuery)); + + var param = new ComplexQueryObject + { + ObjectCollectionCcv = [TestEnum.A, TestEnum.B] + }; + + var output = await factory([param]); + + await Assert.That(output.RequestUri!.PathAndQuery).IsEqualTo("/foo?listOfObjectsCsv=A%2CB"); + } + + /// Verifies an object query parameter with an inner collection produces the correct query string. + /// A task that represents the asynchronous operation. + [Test] + public async Task ObjectQueryParameterWithInnerCollectionHasCorrectQuerystring() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.ComplexTypeQueryWithInnerCollection)); + + var param = new ComplexQueryObject { TestCollection = _testCollectionValues }; + var output = await factory([param]); + var uri = new Uri(new(BaseAddressUri), output.RequestUri!); + + await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?TestCollection=1%2C2%2C3"); + } + + /// Verifies an object query parameter without a query attribute honors the multi collection format. + /// A task that represents the asynchronous operation. + [Test] + public async Task ObjectQueryParameterWithoutQueryAttributeHonorsMultiCollectionFormat() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.ComplexTypeQueryWithoutQueryAttribute)); + + var param = new ComplexQueryObject + { + EnumCollectionMulti = [TestEnum.A, TestEnum.B] + }; + var output = await factory([param]); + var uri = new Uri(new(BaseAddressUri), output.RequestUri!); + + await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?listOfEnumMulti=A&listOfEnumMulti=B"); + } + + /// Verifies a parameter-level collection format applies to inner collections without their own query attribute. + /// A task that represents the asynchronous operation. + [Test] + public async Task ParameterLevelCollectionFormatAppliesToInnerCollectionsWithoutOwnQueryAttribute() + { + var fixture = new RequestBuilderImplementation(); + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IDummyHttpApi.ComplexTypeQueryParameterLevelMulti)); + + var param = new ComplexQueryObject { TestCollection = _testCollectionValues }; + var output = await factory([param]); + var uri = new Uri(new(BaseAddressUri), output.RequestUri!); + + await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?TestCollection=1&TestCollection=2&TestCollection=3"); + } + + /// Verifies multiple query attributes with nulls produce the expected number of query entries. + /// A task that represents the asynchronous operation. + [Test] + public async Task MultipleQueryAttributesWithNulls() + { + const int expectedQueryParameterCount = 3; + var input = typeof(IRestMethodInfoTests); + var fixtureParams = new RestMethodInfoInternal( + input, + input + .GetMethods() + .First(static x => x.Name == nameof(IRestMethodInfoTests.MultipleQueryAttributes))); + + await Assert.That(fixtureParams.QueryParameterMap.Count).IsEqualTo(expectedQueryParameterCount); + } +} diff --git a/src/tests/Refit.Tests/RestMethodInfoTests.ReturnTypeResolution.cs b/src/tests/Refit.Tests/RestMethodInfoTests.ReturnTypeResolution.cs new file mode 100644 index 000000000..8da8ac042 --- /dev/null +++ b/src/tests/Refit.Tests/RestMethodInfoTests.ReturnTypeResolution.cs @@ -0,0 +1,222 @@ +// 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; + +namespace Refit.Tests; + +/// Tests for return type, HTTP method and body serialization resolution. +public partial class RestMethodInfoTests +{ + /// Verifies value-type body parameters do not throw when buffered. + /// A task that represents the asynchronous operation. + [Test] + public async Task ValueTypesDontBlowUpBuffered() + { + var input = typeof(IRestMethodInfoTests); + var fixture = new RestMethodInfoInternal( + input, + input + .GetMethods() + .First(static x => x.Name == nameof(IRestMethodInfoTests.OhYeahValueTypes))); + await Assert.That(fixture.ParameterMap[0].Name).IsEqualTo("id"); + await Assert.That(fixture.ParameterMap[0].Type).IsEqualTo(ParameterType.Normal); + await Assert.That(fixture.QueryParameterMap).IsEmpty(); + await Assert.That(fixture.BodyParameterInfo!.Item1).IsEqualTo(BodySerializationMethod.Default); + await Assert.That(fixture.BodyParameterInfo.Item2).IsTrue(); // buffered default + await Assert.That(fixture.BodyParameterInfo.Item3).IsEqualTo(1); + + await Assert.That(fixture.ReturnResultType).IsEqualTo(typeof(bool)); + } + + /// Verifies value-type body parameters do not throw when unbuffered. + /// A task that represents the asynchronous operation. + [Test] + public async Task ValueTypesDontBlowUpUnBuffered() + { + var input = typeof(IRestMethodInfoTests); + var fixture = new RestMethodInfoInternal( + input, + input + .GetMethods() + .First(static x => x.Name == nameof(IRestMethodInfoTests.OhYeahValueTypesUnbuffered))); + await Assert.That(fixture.ParameterMap[0].Name).IsEqualTo("id"); + await Assert.That(fixture.ParameterMap[0].Type).IsEqualTo(ParameterType.Normal); + await Assert.That(fixture.QueryParameterMap).IsEmpty(); + await Assert.That(fixture.BodyParameterInfo!.Item1).IsEqualTo(BodySerializationMethod.Default); + await Assert.That(fixture.BodyParameterInfo.Item2).IsFalse(); // unbuffered specified + await Assert.That(fixture.BodyParameterInfo.Item3).IsEqualTo(1); + + await Assert.That(fixture.ReturnResultType).IsEqualTo(typeof(bool)); + } + + /// Verifies a stream pull method parses its body parameter correctly. + /// A task that represents the asynchronous operation. + [Test] + public async Task StreamMethodPullWorks() + { + var input = typeof(IRestMethodInfoTests); + var fixture = new RestMethodInfoInternal( + input, + input + .GetMethods() + .First(static x => x.Name == nameof(IRestMethodInfoTests.PullStreamMethod))); + await Assert.That(fixture.ParameterMap[0].Name).IsEqualTo("id"); + await Assert.That(fixture.ParameterMap[0].Type).IsEqualTo(ParameterType.Normal); + await Assert.That(fixture.QueryParameterMap).IsEmpty(); + await Assert.That(fixture.BodyParameterInfo!.Item1).IsEqualTo(BodySerializationMethod.Default); + await Assert.That(fixture.BodyParameterInfo.Item2).IsTrue(); + await Assert.That(fixture.BodyParameterInfo.Item3).IsEqualTo(1); + + await Assert.That(fixture.ReturnResultType).IsEqualTo(typeof(bool)); + } + + /// Verifies a method returning a non-generic task resolves its return type. + /// A task that represents the asynchronous operation. + [Test] + public async Task ReturningTaskShouldWork() + { + var input = typeof(IRestMethodInfoTests); + var fixture = new RestMethodInfoInternal( + input, + input.GetMethods().First(static x => x.Name == nameof(IRestMethodInfoTests.VoidPost))); + await Assert.That(fixture.ParameterMap[0].Name).IsEqualTo("id"); + await Assert.That(fixture.ParameterMap[0].Type).IsEqualTo(ParameterType.Normal); + + await Assert.That(fixture.ReturnType).IsEqualTo(typeof(Task)); + await Assert.That(fixture.ReturnResultType).IsEqualTo(typeof(void)); + } + + /// Verifies a synchronous method throws an argument exception. + /// A task that represents the asynchronous operation. + [Test] + public async Task SyncMethodsShouldThrow() + { + var shouldDie = true; + + try + { + var input = typeof(IRestMethodInfoTests); + _ = new RestMethodInfoInternal( + input, + input + .GetMethods() + .First(static x => x.Name == nameof(IRestMethodInfoTests.AsyncOnlyBuddy))); + } + catch (ArgumentException) + { + shouldDie = false; + } + + await Assert.That(shouldDie).IsFalse(); + } + + /// Verifies the patch attribute sets the HTTP method to PATCH. + /// A task that represents the asynchronous operation. + [Test] + public async Task UsingThePatchAttributeSetsTheCorrectMethod() + { + var input = typeof(IRestMethodInfoTests); + var fixture = new RestMethodInfoInternal( + input, + input.GetMethods().First(static x => x.Name == nameof(IRestMethodInfoTests.PatchSomething))); + + await Assert.That(fixture.HttpMethod.Method).IsEqualTo("PATCH"); + } + + /// Verifies the options attribute sets the HTTP method to OPTIONS. + /// A task that represents the asynchronous operation. + [Test] + public async Task UsingOptionsAttribute() + { + var input = typeof(IRestMethodInfoTests); + var fixture = new RestMethodInfoInternal( + input, + input.GetMethods().First(static x => x.Name == nameof(IDummyHttpApi.SendOptions))); + + await Assert.That(fixture.HttpMethod.Method).IsEqualTo("OPTIONS"); + } + + /// Verifies the api response flag is set for a method returning an API response. + /// A task that represents the asynchronous operation. + [Test] + public async Task ApiResponseShouldBeSet() + { + var input = typeof(IRestMethodInfoTests); + var fixture = new RestMethodInfoInternal( + input, + input + .GetMethods() + .First(static x => x.Name == nameof(IRestMethodInfoTests.PostReturnsApiResponse))); + + await Assert.That(fixture.IsApiResponse).IsTrue(); + } + + /// Verifies the api response flag is not set for a method returning a non-API response. + /// A task that represents the asynchronous operation. + [Test] + public async Task ApiResponseShouldNotBeSet() + { + var input = typeof(IRestMethodInfoTests); + var fixture = new RestMethodInfoInternal( + input, + input + .GetMethods() + .First(static x => x.Name == nameof(IRestMethodInfoTests.PostReturnsNonApiResponse))); + + await Assert.That(fixture.IsApiResponse).IsFalse(); + } + + /// Verifies a generic return type that is not a task or observable throws. + /// A task that represents the asynchronous operation. + [Test] + public async Task GenericReturnTypeIsNotTaskOrObservableShouldThrow() + { + var input = typeof(IRestMethodInfoTests); + await Assert.That( + () => + new RestMethodInfoInternal( + input, + input + .GetMethods() + .First( + static x => x.Name == nameof(IRestMethodInfoTests.InvalidGenericReturnType)))).ThrowsExactly(); + } + + /// Verifies an internal sync generic return type sets the deserialized type to the return type. + /// A task that represents the asynchronous operation. + [Test] + public async Task InternalSyncGenericReturnTypeSetsDeserializedTypeToReturnType() + { + var input = typeof(IInternalSyncGenericReturnTypeApi); + var fixture = new RestMethodInfoInternal( + input, + input + .GetTypeInfo() + .DeclaredMethods + .First(static x => x.Name == nameof(IInternalSyncGenericReturnTypeApi.GetValues))); + + await Assert.That(fixture.ReturnType).IsEqualTo(typeof(List)); + await Assert.That(fixture.ReturnResultType).IsEqualTo(typeof(List)); + await Assert.That(fixture.DeserializedResultType).IsEqualTo(typeof(List)); + } + + /// Verifies an internal sync IApiResponse generic return type sets the deserialized type to the generic argument. + /// A task that represents the asynchronous operation. + [Test] + public async Task InternalSyncIApiResponseGenericReturnTypeSetsDeserializedTypeToGenericArgument() + { + var input = typeof(IInternalSyncGenericApiResponseReturnTypeApi); + var fixture = new RestMethodInfoInternal( + input, + input + .GetTypeInfo() + .DeclaredMethods + .First(static x => x.Name == nameof(IInternalSyncGenericApiResponseReturnTypeApi.GetResponse))); + + await Assert.That(fixture.ReturnType).IsEqualTo(typeof(IApiResponse)); + await Assert.That(fixture.ReturnResultType).IsEqualTo(typeof(IApiResponse)); + await Assert.That(fixture.DeserializedResultType).IsEqualTo(typeof(string)); + } +} diff --git a/src/tests/Refit.Tests/RestMethodInfoTests.cs b/src/tests/Refit.Tests/RestMethodInfoTests.cs index 049d26721..1c9c056d3 100644 --- a/src/tests/Refit.Tests/RestMethodInfoTests.cs +++ b/src/tests/Refit.Tests/RestMethodInfoTests.cs @@ -15,353 +15,8 @@ public partial class RestMethodInfoTests /// Filter values used by path-bound object query tests. private static readonly string[] _filterValues = ["A", "B"]; - /// Verifies a method with too many complex types throws. - /// A task that represents the asynchronous operation. - [Test] - public async Task TooManyComplexTypesThrows() - { - var input = typeof(IRestMethodInfoTests); - - await Assert.That(() => - { - _ = new RestMethodInfoInternal( - input, - input - .GetMethods() - .First(x => x.Name == nameof(IRestMethodInfoTests.TooManyComplexTypes))); - }).ThrowsExactly(); - } - - /// Verifies a method with many complex types maps the body parameter. - /// A task that represents the asynchronous operation. - [Test] - public async Task ManyComplexTypes() - { - var input = typeof(IRestMethodInfoTests); - var fixture = new RestMethodInfoInternal( - input, - input - .GetMethods() - .First(static x => x.Name == nameof(IRestMethodInfoTests.ManyComplexTypes))); - - await Assert.That(fixture.QueryParameterMap).HasSingleItem(); - await Assert.That(fixture.BodyParameterInfo).IsNotNull(); - await Assert.That(fixture.BodyParameterInfo!.Item3).IsEqualTo(1); - } - - /// Verifies the body parameter is detected by default for put, post and patch. - /// The name of the interface method under test. - /// A task that represents the asynchronous operation. - [Test] - [Arguments(nameof(IRestMethodInfoTests.PutWithBodyDetected))] - [Arguments(nameof(IRestMethodInfoTests.PostWithBodyDetected))] - [Arguments(nameof(IRestMethodInfoTests.PatchWithBodyDetected))] - public async Task DefaultBodyParameterDetected(string interfaceMethodName) - { - var input = typeof(IRestMethodInfoTests); - var fixture = new RestMethodInfoInternal( - input, - input.GetMethods().First(x => x.Name == interfaceMethodName)); - - await Assert.That(fixture.QueryParameterMap).IsEmpty(); - await Assert.That(fixture.BodyParameterInfo).IsNotNull(); - } - - /// Verifies the body parameter is not inferred for a GET method. - /// A task that represents the asynchronous operation. - [Test] - public async Task DefaultBodyParameterNotDetectedForGet() - { - var input = typeof(IRestMethodInfoTests); - var fixture = new RestMethodInfoInternal( - input, - input - .GetMethods() - .First(static x => x.Name == nameof(IRestMethodInfoTests.GetWithBodyDetected))); - - await Assert.That(fixture.QueryParameterMap).HasSingleItem(); - await Assert.That(fixture.BodyParameterInfo).IsNull(); - } - - /// Verifies a dictionary query parameter maps to a single query entry. - /// A task that represents the asynchronous operation. - [Test] - public async Task PostWithDictionaryQueryParameter() - { - var input = typeof(IRestMethodInfoTests); - var fixture = new RestMethodInfoInternal( - input, - input - .GetMethods() - .First(static x => x.Name == nameof(IRestMethodInfoTests.PostWithDictionaryQuery))); - - await Assert.That(fixture.QueryParameterMap).HasSingleItem(); - await Assert.That(fixture.BodyParameterInfo).IsNull(); - } - - /// Verifies an object query parameter produces a single query parameter value. - /// A task that represents the asynchronous operation. - [Test] - public async Task PostWithObjectQueryParameterHasSingleQueryParameterValue() - { - var input = typeof(IRestMethodInfoTests); - var fixtureParams = new RestMethodInfoInternal( - input, - input - .GetMethods() - .First(static x => x.Name == nameof(IRestMethodInfoTests.PostWithComplexTypeQuery))); - - await Assert.That(fixtureParams.QueryParameterMap).HasSingleItem(); - await Assert.That(fixtureParams.QueryParameterMap[0]).IsEqualTo("queryParams"); - await Assert.That(fixtureParams.BodyParameterInfo).IsNull(); - } - - /// Verifies a CultureInfo query parameter does not cause a stack overflow. - /// A task that represents the asynchronous operation. - [Test] - public async Task CultureInfoQueryParameterDoesNotStackOverflow() - { - var fixture = new RequestBuilderImplementation(); - - var factory = fixture.BuildRequestFactoryForMethod( - nameof(IDummyHttpApi.QueryWithCultureInfo)); - - var output = await factory([new System.Globalization.CultureInfo("en-US")]); - - var uri = new Uri(new(BaseAddressUri), output.RequestUri!); - - await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?culture=en-US"); - } - - /// Verifies a base address with a trailing slash does not produce a double slash. - /// A task that represents the asynchronous operation. - [Test] - public async Task BaseAddressWithTrailingSlashDoesNotProduceDoubleSlash() - { - var fixture = new RequestBuilderImplementation(); - - var factory = fixture.BuildRequestFactoryForMethod( - nameof(IDummyHttpApi.FetchSomeStuff), - baseAddress: "http://api/v1/"); - - var output = await factory([42]); - - var uri = new Uri(new(BaseAddressUri), output.RequestUri!); - - await Assert.That(uri.AbsolutePath).IsEqualTo("/v1/foo/bar/42"); - } - - /// Verifies a derived path-bound object does not duplicate the path property as a query. - /// The expected request path and query string. - /// A task that represents the asynchronous operation. - [Test] - [Arguments("/api/123?SomeProperty2=test&text=title&filters=A&filters=B")] - public async Task DerivedPathBoundObjectDoesNotDuplicatePathPropertyAsQuery(string expectedQuery) - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - "QueryWithOptionalParametersPathBoundObject"); - var output = await factory( - [ - new DerivedPathBoundObject { SomeProperty = 123, SomeProperty2 = "test" }, - "title", - null!, - _filterValues - ]); - - var uri = new Uri(new(BaseAddressUri), output.RequestUri!); - await Assert.That(uri.PathAndQuery).IsEqualTo(expectedQuery); - } - - /// Verifies an object query parameter produces the correct query string. - /// A task that represents the asynchronous operation. - [Test] - public async Task PostWithObjectQueryParameterHasCorrectQuerystring() - { - var fixture = new RequestBuilderImplementation(); - - var factory = fixture.BuildRequestFactoryForMethod( - nameof(IDummyHttpApi.PostWithComplexTypeQuery)); - - var param = new ComplexQueryObject { TestAlias1 = "one", TestAlias2 = "two" }; - - var output = await factory([param]); - - var uri = new Uri(new(BaseAddressUri), output.RequestUri!); - - await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?test-query-alias=one&TestAlias2=two"); - } - - /// Verifies an object query parameter skips ignored properties. - /// A task that represents the asynchronous operation. - [Test] - public async Task PostWithObjectQueryParameterSkipsIgnoredProperties() - { - var fixture = new RequestBuilderImplementation(); - - var factory = fixture.BuildRequestFactoryForMethod( - nameof(IDummyHttpApi.PostWithComplexTypeQuery)); - - var param = new ComplexQueryObject - { - TestAlias1 = "one", - InternalUseOnlyIgnoredByDataMember = "nope", - InternalUseOnlyIgnoredBySystemTextJson = "nope" - }; - - var output = await factory([param]); - - await Assert.That(output.RequestUri!.PathAndQuery).IsEqualTo("/foo?test-query-alias=one"); - } - - /// Verifies an enum list query parameter uses the multi collection format. - /// A task that represents the asynchronous operation. - [Test] - public async Task PostWithObjectQueryParameterWithEnumList_Multi() - { - var fixture = new RequestBuilderImplementation(); - - var factory = fixture.BuildRequestFactoryForMethod( - nameof(IDummyHttpApi.PostWithComplexTypeQuery)); - - var param = new ComplexQueryObject - { - EnumCollectionMulti = [TestEnum.A, TestEnum.B] - }; - - var output = await factory([param]); - - await Assert.That(output.RequestUri!.PathAndQuery).IsEqualTo( - "/foo?listOfEnumMulti=A&listOfEnumMulti=B"); - } - - /// Verifies an object list with provided enum values uses the multi collection format. - /// A task that represents the asynchronous operation. - [Test] - public async Task PostWithObjectQueryParameterWithObjectListWithProvidedEnumValues_Multi() - { - var fixture = new RequestBuilderImplementation(); - - var factory = fixture.BuildRequestFactoryForMethod( - nameof(IDummyHttpApi.PostWithComplexTypeQuery)); - - var param = new ComplexQueryObject - { - ObjectCollectionMulti = [TestEnum.A, TestEnum.B] - }; - - var output = await factory([param]); - - await Assert.That(output.RequestUri!.PathAndQuery).IsEqualTo( - "/foo?ObjectCollectionMulti=A&ObjectCollectionMulti=B"); - } - - /// Verifies an enum list query parameter uses the CSV collection format. - /// A task that represents the asynchronous operation. - [Test] - public async Task PostWithObjectQueryParameterWithEnumList_Csv() - { - var fixture = new RequestBuilderImplementation(); - - var factory = fixture.BuildRequestFactoryForMethod( - nameof(IDummyHttpApi.PostWithComplexTypeQuery)); - - var param = new ComplexQueryObject - { - EnumCollectionCsv = [TestEnum.A, TestEnum.B] - }; - - var output = await factory([param]); - - await Assert.That(output.RequestUri!.PathAndQuery).IsEqualTo("/foo?EnumCollectionCsv=A%2CB"); - } - - /// Verifies an object list with provided enum values uses the CSV collection format. - /// A task that represents the asynchronous operation. - [Test] - public async Task PostWithObjectQueryParameterWithObjectListWithProvidedEnumValues_Csv() - { - var fixture = new RequestBuilderImplementation(); - - var factory = fixture.BuildRequestFactoryForMethod( - nameof(IDummyHttpApi.PostWithComplexTypeQuery)); - - var param = new ComplexQueryObject - { - ObjectCollectionCcv = [TestEnum.A, TestEnum.B] - }; - - var output = await factory([param]); - - await Assert.That(output.RequestUri!.PathAndQuery).IsEqualTo("/foo?listOfObjectsCsv=A%2CB"); - } - - /// Verifies an object query parameter with an inner collection produces the correct query string. - /// A task that represents the asynchronous operation. - [Test] - public async Task ObjectQueryParameterWithInnerCollectionHasCorrectQuerystring() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - nameof(IDummyHttpApi.ComplexTypeQueryWithInnerCollection)); - - var param = new ComplexQueryObject { TestCollection = [1, 2, 3] }; - var output = await factory([param]); - var uri = new Uri(new(BaseAddressUri), output.RequestUri!); - - await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?TestCollection=1%2C2%2C3"); - } - - /// Verifies an object query parameter without a query attribute honors the multi collection format. - /// A task that represents the asynchronous operation. - [Test] - public async Task ObjectQueryParameterWithoutQueryAttributeHonorsMultiCollectionFormat() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - nameof(IDummyHttpApi.ComplexTypeQueryWithoutQueryAttribute)); - - var param = new ComplexQueryObject - { - EnumCollectionMulti = [TestEnum.A, TestEnum.B] - }; - var output = await factory([param]); - var uri = new Uri(new(BaseAddressUri), output.RequestUri!); - - await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?listOfEnumMulti=A&listOfEnumMulti=B"); - } - - /// Verifies a parameter-level collection format applies to inner collections without their own query attribute. - /// A task that represents the asynchronous operation. - [Test] - public async Task ParameterLevelCollectionFormatAppliesToInnerCollectionsWithoutOwnQueryAttribute() - { - var fixture = new RequestBuilderImplementation(); - var factory = fixture.BuildRequestFactoryForMethod( - nameof(IDummyHttpApi.ComplexTypeQueryParameterLevelMulti)); - - var param = new ComplexQueryObject { TestCollection = [1, 2, 3] }; - var output = await factory([param]); - var uri = new Uri(new(BaseAddressUri), output.RequestUri!); - - await Assert.That(uri.PathAndQuery).IsEqualTo("/foo?TestCollection=1&TestCollection=2&TestCollection=3"); - } - - /// Verifies multiple query attributes with nulls produce the expected number of query entries. - /// A task that represents the asynchronous operation. - [Test] - public async Task MultipleQueryAttributesWithNulls() - { - const int expectedQueryParameterCount = 3; - var input = typeof(IRestMethodInfoTests); - var fixtureParams = new RestMethodInfoInternal( - input, - input - .GetMethods() - .First(static x => x.Name == nameof(IRestMethodInfoTests.MultipleQueryAttributes))); - - await Assert.That(fixtureParams.QueryParameterMap.Count).IsEqualTo(expectedQueryParameterCount); - } + /// Sample integer collection used to exercise inner-collection query formatting. + private static readonly int[] _testCollectionValues = [1, 2, 3]; /// Verifies a method with a garbage path throws an argument exception. /// A task that represents the asynchronous operation. @@ -429,6 +84,21 @@ public async Task ParameterMappingSmokeTest() await Assert.That(fixture.BodyParameterInfo).IsNull(); } + /// Verifies enabling unmatched route parameters short-circuits the route-binding leniency flag before it + /// consults whether the method is a generic definition. + /// A task that represents the asynchronous operation. + [Test] + public async Task AllowUnmatchedRouteParametersEnablesLenientRouteBinding() + { + var input = typeof(IRestMethodInfoTests); + var fixture = new RestMethodInfoInternal( + input, + input.GetMethods().First(static x => x.Name == nameof(IRestMethodInfoTests.FetchSomeStuff)), + new RefitSettings { AllowUnmatchedRouteParameters = true }); + + await Assert.That(fixture.ParameterMap[0].Name).IsEqualTo("id"); + } + /// Verifies parameter mapping when the same id appears in a few places. /// A task that represents the asynchronous operation. [Test] @@ -489,26 +159,6 @@ public async Task ParameterMappingWithRoundTrippingSmokeTest() await Assert.That(fixture.BodyParameterInfo).IsNull(); } - /// Verifies a non-string round-tripping parameter throws an argument exception. - /// A task that represents the asynchronous operation. - [Test] - public async Task ParameterMappingWithNonStringRoundTrippingShouldThrow() - { - var input = typeof(IRestMethodInfoTests); - await Assert.That(() => - { - _ = new RestMethodInfoInternal( - input, - input - .GetMethods() - .First( - x => - x.Name - == nameof( - IRestMethodInfoTests.FetchSomeStuffWithNonStringRoundTrippingParam))); - }).ThrowsExactly(); - } - /// Verifies parameter mapping with a query parameter. /// A task that represents the asynchronous operation. [Test] @@ -701,7 +351,7 @@ public async Task DynamicHeadersShouldWork() public async Task ConstructorGuardsAndMissingHttpMethodThrow() { var input = typeof(IRestMethodInfoTests); - var method = input.GetMethods().First(x => x.Name == nameof(IRestMethodInfoTests.FetchSomeStuff)); + var method = input.GetMethods().First(static x => x.Name == nameof(IRestMethodInfoTests.FetchSomeStuff)); var missingInput = typeof(IMissingHttpMethodApi); var missingHttpMethod = missingInput.GetMethod(nameof(IMissingHttpMethodApi.MethodWithoutHttpMethod))!; @@ -719,7 +369,7 @@ public async Task ConstructorGuardsAndMissingHttpMethodThrow() public async Task PathContainingNewlineThrows() { var input = typeof(IRestMethodInfoTests); - var method = input.GetMethods().First(x => x.Name == nameof(IRestMethodInfoTests.NewlinePath)); + var method = input.GetMethods().First(static x => x.Name == nameof(IRestMethodInfoTests.NewlinePath)); await Assert.That(() => new RestMethodInfoInternal(input, method)) .ThrowsExactly(); @@ -748,7 +398,44 @@ await Assert.That(fixture.ParameterMap[0].ParameterProperties[0].PropertyInfo.Na public async Task ObjectRouteBindingConflictingDirectAndPropertyMatchThrows() { var input = typeof(IRestMethodInfoTests); - var method = input.GetMethods().First(x => x.Name == nameof(IRestMethodInfoTests.ConflictingObjectRoute)); + var method = input.GetMethods().First(static x => x.Name == nameof(IRestMethodInfoTests.ConflictingObjectRoute)); + + await Assert.That(() => new RestMethodInfoInternal(input, method)) + .ThrowsExactly(); + } + + /// Verifies a deep dotted route placeholder whose root segment matches no parameter is rejected. + /// A task that represents the asynchronous operation. + [Test] + public async Task DeepDottedRouteWithUnknownRootParameterThrows() + { + var input = typeof(IRestMethodInfoTests); + var method = input.GetMethods().First(static x => x.Name == nameof(IRestMethodInfoTests.DeepDottedRouteWithUnknownRootParameter)); + + await Assert.That(() => new RestMethodInfoInternal(input, method)) + .ThrowsExactly(); + } + + /// Verifies a deep dotted route placeholder rooted on a value-type parameter is rejected, because a + /// value-type parameter cannot own a nested property chain. + /// A task that represents the asynchronous operation. + [Test] + public async Task DeepDottedRouteWithValueTypeRootParameterThrows() + { + var input = typeof(IRestMethodInfoTests); + var method = input.GetMethods().First(static x => x.Name == nameof(IRestMethodInfoTests.DeepDottedRouteWithValueTypeRootParameter)); + + await Assert.That(() => new RestMethodInfoInternal(input, method)) + .ThrowsExactly(); + } + + /// Verifies a deep dotted route placeholder whose final nested property does not exist is rejected. + /// A task that represents the asynchronous operation. + [Test] + public async Task DeepDottedRouteWithUnknownNestedPropertyThrows() + { + var input = typeof(IRestMethodInfoTests); + var method = input.GetMethods().First(static x => x.Name == nameof(IRestMethodInfoTests.DeepDottedRouteWithUnknownNestedProperty)); await Assert.That(() => new RestMethodInfoInternal(input, method)) .ThrowsExactly(); @@ -760,7 +447,7 @@ public async Task ObjectRouteBindingConflictingDirectAndPropertyMatchThrows() public async Task DuplicateAuthorizeParametersThrow() { var input = typeof(IRestMethodInfoTests); - var method = input.GetMethods().First(x => x.Name == nameof(IRestMethodInfoTests.DuplicateAuthorize)); + var method = input.GetMethods().First(static x => x.Name == nameof(IRestMethodInfoTests.DuplicateAuthorize)); await Assert.That(() => new RestMethodInfoInternal(input, method)) .ThrowsExactly(); diff --git a/src/tests/Refit.Tests/RestMethodParameterPropertyTests.cs b/src/tests/Refit.Tests/RestMethodParameterPropertyTests.cs new file mode 100644 index 000000000..8b1795fdc --- /dev/null +++ b/src/tests/Refit.Tests/RestMethodParameterPropertyTests.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.Tests; + +/// Verifies the public constructors of . +public sealed class RestMethodParameterPropertyTests +{ + /// Verifies the single-property constructor exposes a one-element chain whose final link is the property. + /// A task representing the asynchronous test. + [Test] + public async Task SinglePropertyConstructorExposesOneElementChain() + { + var property = typeof(Sample).GetProperty(nameof(Sample.Id))!; + + var parameterProperty = new RestMethodParameterProperty("id", property); + + await Assert.That(parameterProperty.Name).IsEqualTo("id"); + await Assert.That(parameterProperty.PropertyInfo).IsSameReferenceAs(property); + await Assert.That(parameterProperty.PropertyChain).IsEquivalentTo([property]); + } + + /// Verifies the chain constructor keeps the chain and surfaces its last link as the bound property. + /// A task representing the asynchronous test. + [Test] + public async Task ChainConstructorSurfacesLastLinkAsBoundProperty() + { + var outer = typeof(Sample).GetProperty(nameof(Sample.Inner))!; + var inner = typeof(Inner).GetProperty(nameof(Inner.Id))!; + + var parameterProperty = new RestMethodParameterProperty("inner.id", [outer, inner]); + + await Assert.That(parameterProperty.Name).IsEqualTo("inner.id"); + await Assert.That(parameterProperty.PropertyInfo).IsSameReferenceAs(inner); + await Assert.That(parameterProperty.PropertyChain).IsEquivalentTo([outer, inner]); + } + + /// A model whose properties supply real metadata for the constructor fixtures. + private sealed class Sample + { + /// The identifier value assigned to . + private const int IdValue = 7; + + /// Gets or sets the identifier bound by a single-level chain. + public int Id { get; set; } = IdValue; + + /// Gets or sets the nested object bound by a multi-level chain. + public Inner? Inner { get; set; } = new(); + } + + /// A nested model exercised by the chain constructor. + private sealed class Inner + { + /// The identifier value assigned to . + private const int IdValue = 11; + + /// Gets or sets the nested identifier bound as the final chain link. + public int Id { get; set; } = IdValue; + } +} diff --git a/src/tests/Refit.Tests/RestServiceExceptions.cs b/src/tests/Refit.Tests/RestServiceExceptions.cs index bddc79370..f380a7aba 100644 --- a/src/tests/Refit.Tests/RestServiceExceptions.cs +++ b/src/tests/Refit.Tests/RestServiceExceptions.cs @@ -37,12 +37,16 @@ public async Task InvalidHeaderCollectionTypeShouldThrow() await AssertExceptionContains("HeaderCollection parameter of type", exception!); } - /// Verifies that a URL not starting with a slash throws. + /// Verifies that a URL not starting with a slash throws under legacy resolution. Generated request building + /// validates the leading slash when the request is built rather than when the client is created, so the throw + /// surfaces on the first call instead of from . /// A task representing the asynchronous test. [Test] public async Task UrlDoesntStartWithSlashShouldThrow() { - var exception = await Assert.That(static () => RestService.For(BaseAddress)).ThrowsExactly(); + var api = RestService.For(BaseAddress); + Func call = () => api.GetValue(); + var exception = await Assert.That(call).ThrowsExactly(); await AssertExceptionContains("must start with '/' and be of the form", exception!); } @@ -55,13 +59,34 @@ public async Task UrlContainsCrlfShouldThrow() await AssertExceptionContains("must not contain CR or LF characters", exception!); } - /// Verifies that a non-string round-tripping parameter throws. + /// Verifies that a non-string round-tripping parameter is formatted via ToString, preserving its + /// / separators as path structure (issue #2046). /// A task representing the asynchronous test. [Test] - public async Task RoundTripParameterNotStringShouldThrow() + public async Task RoundTripParameterNotStringFormatsAndPreservesSlashes() { - var exception = await Assert.That(static () => RestService.For(BaseAddress)).ThrowsExactly(); - await AssertExceptionContains("has round-tripping parameter", exception!); + var request = await new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IRoundTripNotString.GetValue))([new RepoPath("some/repo")]); + + await Assert.That(request.RequestUri!.PathAndQuery).IsEqualTo("/repos/some/repo/contents"); + } + + /// Verifies the source-generated non-string round-trip path matches the reflection builder, escaping + /// each segment while preserving the separators. + /// A task representing the asynchronous test. + [Test] + public async Task RoundTripNotStringGeneratedMatchesReflection() + { + var repoPath = new RepoPath("some/repo dir"); + using var handler = new TestHttpMessageHandler(); + using var client = new HttpClient(handler) { BaseAddress = new(BaseAddress) }; + _ = await RestService.ForGenerated(client).GetValue(repoPath); + var generatedUri = handler.RequestMessage!.RequestUri!.PathAndQuery; + + var reflected = await new RequestBuilderImplementation() + .BuildRequestFactoryForMethod(nameof(IRoundTripNotString.GetValue))([repoPath]); + + await Assert.That(generatedUri).IsEqualTo(reflected.RequestUri!.PathAndQuery); } /// Verifies that a round-tripping parameter name with leading whitespace throws. @@ -69,8 +94,11 @@ public async Task RoundTripParameterNotStringShouldThrow() [Test] public async Task RoundTripWithLeadingWhitespaceShouldThrow() { - var exception = await Assert.That(static () => RestService.For(BaseAddress)).ThrowsExactly(); - await AssertExceptionContains("has parameter **path, but no method parameter matches", exception!); + // The whitespace-damaged placeholder binds no parameter, so the interface generates inline and the + // unmatched-placeholder check now runs when the request is built rather than at client creation. + var service = RestService.For(BaseAddress); + var exception = await Assert.That(() => (Task)service.GetValue("value")).ThrowsExactly(); + await AssertExceptionContains("has parameter { **path}, but no method parameter matches", exception!); } /// Verifies that a round-tripping parameter name with trailing whitespace throws. @@ -78,8 +106,11 @@ public async Task RoundTripWithLeadingWhitespaceShouldThrow() [Test] public async Task RoundTripWithTrailingWhitespaceShouldThrow() { - var exception = await Assert.That(static () => RestService.For(BaseAddress)).ThrowsExactly(); - await AssertExceptionContains("has parameter ** path, but no method parameter matches", exception!); + // The whitespace-damaged placeholder binds no parameter, so the interface generates inline and the + // unmatched-placeholder check now runs when the request is built rather than at client creation. + var service = RestService.For(BaseAddress); + var exception = await Assert.That(() => (Task)service.GetValue("value")).ThrowsExactly(); + await AssertExceptionContains("has parameter {** path}, but no method parameter matches", exception!); } /// Verifies that an invalid parameter substitution token throws when invoked. @@ -107,7 +138,10 @@ public async Task InvalidFragmentParamSubstitutionShouldThrow() [Test] public async Task UrlNoMatchingParameterShouldThrow() { - var exception = await Assert.That(static () => RestService.For(BaseAddress)).ThrowsExactly(); + // With no parameters at all the interface generates inline, so the unmatched-placeholder check + // now runs when the request is built rather than at client creation. + var service = RestService.For(BaseAddress); + var exception = await Assert.That(() => (Task)service.GetValue()).ThrowsExactly(); await AssertExceptionContains("but no method parameter matches", exception!); } diff --git a/src/tests/Refit.Tests/RestServiceFallbackApiStub.cs b/src/tests/Refit.Tests/RestServiceFallbackApiStub.cs new file mode 100644 index 000000000..f6919b6f0 --- /dev/null +++ b/src/tests/Refit.Tests/RestServiceFallbackApiStub.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; + +/// A hand-written implementation returned by the fallback settings factory under test. +public sealed class RestServiceFallbackApiStub : IRestServiceFallbackApi +{ + /// + public Task Get() => Task.FromResult(string.Empty); +} diff --git a/src/tests/Refit.Tests/RestServiceGeneratedFactoryFallbackTests.cs b/src/tests/Refit.Tests/RestServiceGeneratedFactoryFallbackTests.cs new file mode 100644 index 000000000..675d8459e --- /dev/null +++ b/src/tests/Refit.Tests/RestServiceGeneratedFactoryFallbackTests.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. + +namespace Refit.Tests; + +/// Verifies falls back to the type-keyed +/// generated settings factory when the strongly typed holder for the interface is unset. +public sealed class RestServiceGeneratedFactoryFallbackTests +{ + /// Verifies the type-keyed settings factory is used when the generic holder is null. + /// A task representing the asynchronous test. + [Test] + public async Task ForUsesTypeKeyedSettingsFactoryWhenGenericHolderIsUnset() + { + var stub = new RestServiceFallbackApiStub(); + RestService.RegisterGeneratedSettingsFactory((_, _) => stub); + + // Clear only the strongly typed holder so For falls through to the type-keyed dictionary registered above. + RestService.GeneratedSettingsFactory.Factory = null; + + using var client = new HttpClient { BaseAddress = new("http://api/") }; + var api = RestService.For(client, new RefitSettings()); + + await Assert.That(api).IsSameReferenceAs(stub); + + // The same fallback path defaults the settings when none are supplied. + var apiWithDefaultSettings = RestService.For(client, (RefitSettings?)null); + + await Assert.That(apiWithDefaultSettings).IsSameReferenceAs(stub); + } +} diff --git a/src/tests/Refit.Tests/RestServiceIntegrationTests.Inheritance.cs b/src/tests/Refit.Tests/RestServiceIntegrationTests.Inheritance.cs index 41b04aead..a1a8fd926 100644 --- a/src/tests/Refit.Tests/RestServiceIntegrationTests.Inheritance.cs +++ b/src/tests/Refit.Tests/RestServiceIntegrationTests.Inheritance.cs @@ -39,6 +39,21 @@ public partial class RestServiceIntegrationTests /// Second sample nullable-collection query value. private const int CollectionValueFour = 4; + /// Constructor parameter name asserted by the host-URL validation tests. + private const string HostUrlParamName = "hostUrl"; + + /// Assertion failure message used when an expected exception is not thrown. + private const string ExceptionNotThrownMessage = "Exception not thrown."; + + /// Dictionary/query key for the first-name value shared across the request tests. + private const string FirstNameKey = "FirstName"; + + /// Dictionary/query key for the last-name value shared across the request tests. + private const string LastNameKey = "LastName"; + + /// Sample street address value shared across the request tests. + private const string HomeStreetAddress = "HomeStreet 99"; + /// Verifies a generic method can return multiple response shapes. /// A task representing the asynchronous test. [Test] @@ -66,9 +81,9 @@ public async Task GenericMethodTest() var myParams = new Dictionary { - ["FirstName"] = "John", - ["LastName"] = RamboLastName, - ["Address"] = (Zip: 9999, Street: "HomeStreet 99") + [FirstNameKey] = "John", + [LastNameKey] = RamboLastName, + ["Address"] = (Zip: 9999, Street: HomeStreetAddress) }; var fixture = RestService.For, int>>( @@ -228,9 +243,9 @@ public async Task DictionaryDynamicQueryParametersTest() var myParams = new Dictionary { - ["FirstName"] = "John", - ["LastName"] = RamboLastName, - ["Address"] = (Zip: 9999, Street: "HomeStreet 99") + [FirstNameKey] = "John", + [LastNameKey] = RamboLastName, + ["Address"] = (Zip: 9999, Street: HomeStreetAddress) }; var fixture = RestService.For, int>>( @@ -239,8 +254,8 @@ public async Task DictionaryDynamicQueryParametersTest() var resp = await fixture.GetQuery(myParams); - await Assert.That(resp.Args!["FirstName"]).IsEqualTo("John"); - await Assert.That(resp.Args["LastName"]).IsEqualTo(RamboLastName); + await Assert.That(resp.Args![FirstNameKey]).IsEqualTo("John"); + await Assert.That(resp.Args[LastNameKey]).IsEqualTo(RamboLastName); await Assert.That(resp.Args["Address_Zip"]).IsEqualTo("9999"); } @@ -266,7 +281,7 @@ public async Task ComplexDynamicQueryparametersTestWithIncludeParameterName() { FirstName = "John", LastName = RamboLastName, - Address = new() { Postcode = 9999, Street = "HomeStreet 99" }, + Address = new() { Postcode = PostcodeValue, Street = HomeStreetAddress }, }; var fixture = RestService.For>( @@ -373,11 +388,11 @@ public async Task ShouldThrowArgumentExceptionIfHostUrlIsNull() } catch (ArgumentException ex) { - await Assert.That(ex.ParamName).IsEqualTo("hostUrl"); + await Assert.That(ex.ParamName).IsEqualTo(HostUrlParamName); return; } - Assert.Fail("Exception not thrown."); + Assert.Fail(ExceptionNotThrownMessage); } /// Verifies an empty host URL throws an . @@ -391,11 +406,11 @@ public async Task ShouldThrowArgumentExceptionIfHostUrlIsEmpty() } catch (ArgumentException ex) { - await Assert.That(ex.ParamName).IsEqualTo("hostUrl"); + await Assert.That(ex.ParamName).IsEqualTo(HostUrlParamName); return; } - Assert.Fail("Exception not thrown."); + Assert.Fail(ExceptionNotThrownMessage); } /// Verifies a whitespace host URL throws an . @@ -409,11 +424,11 @@ public async Task ShouldThrowArgumentExceptionIfHostUrlIsWhitespace() } catch (ArgumentException ex) { - await Assert.That(ex.ParamName).IsEqualTo("hostUrl"); + await Assert.That(ex.ParamName).IsEqualTo(HostUrlParamName); return; } - Assert.Fail("Exception not thrown."); + Assert.Fail(ExceptionNotThrownMessage); } /// Verifies the non-generic create overload returns a usable instance. @@ -432,326 +447,6 @@ public async Task NonGenericCreate() await Assert.That(expectedBaseAddress).IsEqualTo(fixture.Client.BaseAddress!.AbsoluteUri); } - /// Verifies an empty query string produces an empty query. - /// A task representing the asynchronous test. - [Test] - public async Task EmptyQueryShouldBeEmpty() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooQueryUrl, ExactQuery = string.Empty }, - Reply.Status(HttpStatusCode.OK) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl); - - await fixture.EmptyQuery(); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a whitespace-only query string produces an empty query. - /// A task representing the asynchronous test. - [Test] - public async Task WhiteSpaceQueryShouldBeEmpty() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooQueryUrl, ExactQuery = string.Empty }, - Reply.Status(HttpStatusCode.OK) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl); - - await fixture.WhiteSpaceQuery(); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a query string with an empty key produces an empty query. - /// A task representing the asynchronous test. - [Test] - public async Task EmptyQueryKeyShouldBeEmpty() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooQueryUrl, ExactQuery = string.Empty }, - Reply.Status(HttpStatusCode.OK) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl); - - await fixture.EmptyQueryKey(); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a query string with an empty value is preserved. - /// A task representing the asynchronous test. - [Test] - public async Task EmptyQueryValueShouldNotBeEmpty() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = "https://github.com/foo?key=", ExactQuery = "key=" }, - Reply.Status(HttpStatusCode.OK) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl); - - await fixture.EmptyQueryValue(); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a query string with empty key and value produces an empty query. - /// A task representing the asynchronous test. - [Test] - public async Task EmptyQueryKeyAndValueShouldBeEmpty() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooQueryUrl, ExactQuery = string.Empty }, - Reply.Status(HttpStatusCode.OK) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl); - - await fixture.EmptyQueryKeyAndValue(); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies unescaped query characters are escaped. - /// A task representing the asynchronous test. - [Test] - public async Task UnescapedQueryShouldBeEscaped() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooUrl, ExactQuery = "key%2C=value%2C&key1%28=value1%28" }, - Reply.Status(HttpStatusCode.OK) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl); - - await fixture.UnescapedQuery(); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies already-escaped query characters stay escaped. - /// A task representing the asynchronous test. - [Test] - public async Task EscapedQueryShouldStillBeEscaped() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooUrl, ExactQuery = "key%2C=value%2C&key1%28=value1%28" }, - Reply.Status(HttpStatusCode.OK) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl); - - await fixture.EscapedQuery(); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a parameter-mapped query produces the expected query string. - /// A task representing the asynchronous test. - [Test] - public async Task ParameterMappedQueryShouldWork() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooUrl, ExactQuery = "key1=value1" }, - Reply.Status(HttpStatusCode.OK) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl); - - await fixture.ParameterMappedQuery("key1", "value1"); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a parameter-mapped query escapes its values. - /// A task representing the asynchronous test. - [Test] - public async Task ParameterMappedQueryShouldEscape() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooUrl, ExactQuery = "key1%2C=value1%2C" }, - Reply.Status(HttpStatusCode.OK) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl); - - await fixture.ParameterMappedQuery("key1,", "value1,"); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a nullable integer collection query produces the expected query string. - /// A task representing the asynchronous test. - [Test] - public async Task NullableIntCollectionQuery() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooUrl, ExactQuery = "values=3%2C4%2C" }, - Reply.Status(HttpStatusCode.OK) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl); - - await fixture.NullableIntCollectionQuery([CollectionValueThree, CollectionValueFour, null]); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a URL fragment is stripped before sending. - /// A task representing the asynchronous test. - [Test] - public async Task ShouldStripFragment() - { - var handler = new StubHttp - { - { - Route.Get(GitHubFooUrl), - Reply.Status(HttpStatusCode.OK) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl); - - await fixture.Fragment(); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies an empty URL fragment is stripped before sending. - /// A task representing the asynchronous test. - [Test] - public async Task ShouldStripEmptyFragment() - { - var handler = new StubHttp - { - { - Route.Get(GitHubFooUrl), - Reply.Status(HttpStatusCode.OK) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl); - - await fixture.EmptyFragment(); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies multiple URL fragments are stripped before sending. - /// A task representing the asynchronous test. - [Test] - public async Task ShouldStripManyFragments() - { - var handler = new StubHttp - { - { - Route.Get(GitHubFooUrl), - Reply.Status(HttpStatusCode.OK) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl); - - await fixture.ManyFragments(); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a parameter-based URL fragment is stripped before sending. - /// A task representing the asynchronous test. - [Test] - public async Task ShouldStripParameterFragment() - { - var handler = new StubHttp - { - { - Route.Get(GitHubFooUrl), - Reply.Status(HttpStatusCode.OK) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl); - - await fixture.ParameterFragment("ignore"); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a fragment after a query string is stripped while the query is kept. - /// A task representing the asynchronous test. - [Test] - public async Task ShouldStripFragmentAfterQuery() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooUrl, ExactQuery = "key=value" }, - Reply.Status(HttpStatusCode.OK) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl); - - await fixture.FragmentAfterQuery(); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a query string after a fragment marker is stripped. - /// A task representing the asynchronous test. - [Test] - public async Task ShouldStripQueryAfterFragment() - { - var handler = new StubHttp - { - { - Route.Get(GitHubFooUrl), - Reply.Status(HttpStatusCode.OK) - }, - }; - - var fixture = handler.CreateClient(GitHubBaseUrl); - - await fixture.QueryAfterFragment(); - - await handler.VerifyAllCalledAsync(); - } - /// Verifies a task is canceled when cancellation is requested. /// A task representing the asynchronous test. [Test] diff --git a/src/tests/Refit.Tests/RestServiceIntegrationTests.QueryAndFragment.cs b/src/tests/Refit.Tests/RestServiceIntegrationTests.QueryAndFragment.cs new file mode 100644 index 000000000..f114b7437 --- /dev/null +++ b/src/tests/Refit.Tests/RestServiceIntegrationTests.QueryAndFragment.cs @@ -0,0 +1,331 @@ +// 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.Net; +using Refit.Testing; + +namespace Refit.Tests; + +/// Integration tests covering query-string edge cases and URL fragment stripping. +public partial class RestServiceIntegrationTests +{ + /// Verifies an empty query string produces an empty query. + /// A task representing the asynchronous test. + [Test] + public async Task EmptyQueryShouldBeEmpty() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooQueryUrl, ExactQuery = string.Empty }, + Reply.Status(HttpStatusCode.OK) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl); + + await fixture.EmptyQuery(); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a whitespace-only query string produces an empty query. + /// A task representing the asynchronous test. + [Test] + public async Task WhiteSpaceQueryShouldBeEmpty() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooQueryUrl, ExactQuery = string.Empty }, + Reply.Status(HttpStatusCode.OK) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl); + + await fixture.WhiteSpaceQuery(); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a query string with an empty key produces an empty query. + /// A task representing the asynchronous test. + [Test] + public async Task EmptyQueryKeyShouldBeEmpty() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooQueryUrl, ExactQuery = string.Empty }, + Reply.Status(HttpStatusCode.OK) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl); + + await fixture.EmptyQueryKey(); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a query string with an empty value is preserved. + /// A task representing the asynchronous test. + [Test] + public async Task EmptyQueryValueShouldNotBeEmpty() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = "https://github.com/foo?key=", ExactQuery = "key=" }, + Reply.Status(HttpStatusCode.OK) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl); + + await fixture.EmptyQueryValue(); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a query string with empty key and value produces an empty query. + /// A task representing the asynchronous test. + [Test] + public async Task EmptyQueryKeyAndValueShouldBeEmpty() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooQueryUrl, ExactQuery = string.Empty }, + Reply.Status(HttpStatusCode.OK) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl); + + await fixture.EmptyQueryKeyAndValue(); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies unescaped query characters are escaped. + /// A task representing the asynchronous test. + [Test] + public async Task UnescapedQueryShouldBeEscaped() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooUrl, ExactQuery = "key%2C=value%2C&key1%28=value1%28" }, + Reply.Status(HttpStatusCode.OK) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl); + + await fixture.UnescapedQuery(); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies already-escaped query characters stay escaped. + /// A task representing the asynchronous test. + [Test] + public async Task EscapedQueryShouldStillBeEscaped() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooUrl, ExactQuery = "key%2C=value%2C&key1%28=value1%28" }, + Reply.Status(HttpStatusCode.OK) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl); + + await fixture.EscapedQuery(); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a parameter-mapped query produces the expected query string. + /// A task representing the asynchronous test. + [Test] + public async Task ParameterMappedQueryShouldWork() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooUrl, ExactQuery = "key1=value1" }, + Reply.Status(HttpStatusCode.OK) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl); + + await fixture.ParameterMappedQuery("key1", "value1"); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a parameter-mapped query escapes its values. + /// A task representing the asynchronous test. + [Test] + public async Task ParameterMappedQueryShouldEscape() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooUrl, ExactQuery = "key1%2C=value1%2C" }, + Reply.Status(HttpStatusCode.OK) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl); + + await fixture.ParameterMappedQuery("key1,", "value1,"); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a nullable integer collection query produces the expected query string. + /// A task representing the asynchronous test. + [Test] + public async Task NullableIntCollectionQuery() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooUrl, ExactQuery = "values=3%2C4%2C" }, + Reply.Status(HttpStatusCode.OK) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl); + + await fixture.NullableIntCollectionQuery([CollectionValueThree, CollectionValueFour, null]); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a URL fragment is stripped before sending. + /// A task representing the asynchronous test. + [Test] + public async Task ShouldStripFragment() + { + var handler = new StubHttp + { + { + Route.Get(GitHubFooUrl), + Reply.Status(HttpStatusCode.OK) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl); + + await fixture.Fragment(); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies an empty URL fragment is stripped before sending. + /// A task representing the asynchronous test. + [Test] + public async Task ShouldStripEmptyFragment() + { + var handler = new StubHttp + { + { + Route.Get(GitHubFooUrl), + Reply.Status(HttpStatusCode.OK) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl); + + await fixture.EmptyFragment(); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies multiple URL fragments are stripped before sending. + /// A task representing the asynchronous test. + [Test] + public async Task ShouldStripManyFragments() + { + var handler = new StubHttp + { + { + Route.Get(GitHubFooUrl), + Reply.Status(HttpStatusCode.OK) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl); + + await fixture.ManyFragments(); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a parameter-based URL fragment is stripped before sending. + /// A task representing the asynchronous test. + [Test] + public async Task ShouldStripParameterFragment() + { + var handler = new StubHttp + { + { + Route.Get(GitHubFooUrl), + Reply.Status(HttpStatusCode.OK) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl); + + await fixture.ParameterFragment("ignore"); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a fragment after a query string is stripped while the query is kept. + /// A task representing the asynchronous test. + [Test] + public async Task ShouldStripFragmentAfterQuery() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = GitHubFooUrl, ExactQuery = "key=value" }, + Reply.Status(HttpStatusCode.OK) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl); + + await fixture.FragmentAfterQuery(); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a query string after a fragment marker is stripped. + /// A task representing the asynchronous test. + [Test] + public async Task ShouldStripQueryAfterFragment() + { + var handler = new StubHttp + { + { + Route.Get(GitHubFooUrl), + Reply.Status(HttpStatusCode.OK) + }, + }; + + var fixture = handler.CreateClient(GitHubBaseUrl); + + await fixture.QueryAfterFragment(); + + await handler.VerifyAllCalledAsync(); + } +} diff --git a/src/tests/Refit.Tests/RestServiceIntegrationTests.RequestBody.cs b/src/tests/Refit.Tests/RestServiceIntegrationTests.RequestBody.cs index bc51b3cc3..c07547cbd 100644 --- a/src/tests/Refit.Tests/RestServiceIntegrationTests.RequestBody.cs +++ b/src/tests/Refit.Tests/RestServiceIntegrationTests.RequestBody.cs @@ -19,6 +19,12 @@ public partial class RestServiceIntegrationTests /// A sample value routed through a form field named 'Password' to exercise field-descriptor serialization. private const string SensitiveFormValue = "secret"; + /// A sample integer form value exercising the reflection-free numeric fast path. + private const int SampleFormAge = 42; + + /// A sample formatted numeric form value; rendered as 1.50 via [Query(Format = "0.00")]. + private const double SampleFormRatio = 1.5; + /// Base URL for the httpbin.org request-bin exchanges. private const string HttpBinBaseUrl = "http://httpbin.org/"; @@ -37,6 +43,21 @@ public partial class RestServiceIntegrationTests /// Sample "Other" collection numeric query value. private const int OtherQueryNumber = 12_345; + /// The X-Refit header value passed as the integer type argument in httpbin exchanges. + private const int XRefitHeaderValue = 99; + + /// The sample postal code used in complex query-parameter exchanges. + private const int PostcodeValue = 9999; + + /// The custom header name exchanged with httpbin in the header tests. + private const string RefitHeaderName = "X-Refit"; + + /// Stub endpoint URL used by the no-body POST tests. + private const string HttpBinPostUrl = "http://httpbin.org/1h3a5jm1"; + + /// The error response body returned by the bad-request stub. + private const string BadErrorJson = "{\"error\":\"bad\"}"; + /// Verifies the npmjs registry can be queried. /// A task representing the asynchronous test. [Test] @@ -66,7 +87,7 @@ public async Task PostToRequestBin() var handler = new StubHttp { { - Route.Post("http://httpbin.org/1h3a5jm1"), + Route.Post(HttpBinPostUrl), Reply.Status(HttpStatusCode.OK) }, }; @@ -198,11 +219,11 @@ public async Task PostToRequestBinWithGenerics() var handler = new StubHttp { { - Route.Post("http://httpbin.org/1h3a5jm1"), + Route.Post(HttpBinPostUrl), Reply.Status(HttpStatusCode.OK) }, { - Route.Post("http://httpbin.org/1h3a5jm1"), + Route.Post(HttpBinPostUrl), Reply.Status(HttpStatusCode.OK) }, }; @@ -288,9 +309,11 @@ public async Task UseMethodWithArgumentsParameter() [Test] public async Task CanSerializeBigData() { + const int bigDataByteCount = 800_000; + const int byteModulus = 256; var bigObject = new BigObject { - BigData = [.. Enumerable.Range(0, 800_000).Select(x => (byte)(x % 256))] + BigData = [.. Enumerable.Range(0, bigDataByteCount).Select(static x => (byte)(x % byteModulus))] }; var handler = new StubHttp @@ -362,18 +385,18 @@ public async Task GenericsWork() var handler = new StubHttp { { - new RouteMatcher { Method = HttpMethod.Get, Template = "http://httpbin.org/get", Query = [("param", "foo")], Headers = [("X-Refit", "99")] }, + new RouteMatcher { Method = HttpMethod.Get, Template = "http://httpbin.org/get", Query = [("param", "foo")], Headers = [(RefitHeaderName, "99")] }, Reply.Json("{\"url\": \"http://httpbin.org/get?param=foo\", \"args\": {\"param\": \"foo\"}, \"headers\":{\"X-Refit\":\"99\"}}") }, }; var fixture = handler.CreateClient>("http://httpbin.org/get"); - var result = await fixture.Get("foo", 99); + var result = await fixture.Get("foo", XRefitHeaderValue); await Assert.That(result.Url).IsEqualTo("http://httpbin.org/get?param=foo"); await Assert.That(result.Args!["param"]).IsEqualTo("foo"); - await Assert.That(result.Headers!["X-Refit"]).IsEqualTo("99"); + await Assert.That(result.Headers![RefitHeaderName]).IsEqualTo("99"); await handler.VerifyAllCalledAsync(); } @@ -417,7 +440,7 @@ public async Task SimpleDynamicQueryparametersTest() var handler = new StubHttp { { - new RouteMatcher { Method = HttpMethod.Get, Template = "https://httpbin.org/get", Headers = [("X-Refit", "99")] }, + new RouteMatcher { Method = HttpMethod.Get, Template = HttpBinGetUrl, Headers = [(RefitHeaderName, "99")] }, Reply.Json("{\"url\": \"https://httpbin.org/get?FirstName=John&LastName=Rambo\", \"args\": {\"FirstName\": \"John\", \"lName\": \"Rambo\"}}") }, }; @@ -427,12 +450,12 @@ public async Task SimpleDynamicQueryparametersTest() var myParams = new MySimpleQueryParams { FirstName = "John", LastName = RamboLastName }; var fixture = RestService.For>( - "https://httpbin.org/get", + HttpBinGetUrl, settings); - var resp = await fixture.Get(myParams, 99); + var resp = await fixture.Get(myParams, XRefitHeaderValue); - await Assert.That(resp.Args!["FirstName"]).IsEqualTo("John"); + await Assert.That(resp.Args![FirstNameKey]).IsEqualTo("John"); await Assert.That(resp.Args["lName"]).IsEqualTo(RamboLastName); } @@ -444,7 +467,7 @@ public async Task ComplexDynamicQueryparametersTest() var handler = new StubHttp { { - Route.Get("https://httpbin.org/get"), + Route.Get(HttpBinGetUrl), Reply.Json("{\"url\": \"https://httpbin.org/get?hardcoded=true&FirstName=John&LastName=Rambo" + "&Addr_Zip=9999&Addr_Street=HomeStreet 99&MetaData_Age=99&MetaData_Initials=JR" + "&MetaData_Birthday=10%2F31%2F1918 4%3A21%3A16 PM&Other=12345" @@ -463,7 +486,7 @@ public async Task ComplexDynamicQueryparametersTest() { FirstName = "John", LastName = RamboLastName, - Address = new() { Postcode = 9999, Street = "HomeStreet 99" }, + Address = new() { Postcode = PostcodeValue, Street = "HomeStreet 99" }, }; myParams.MetaData.Add("Age", MetaDataAge); @@ -480,7 +503,7 @@ public async Task ComplexDynamicQueryparametersTest() var resp = await fixture.GetQuery(myParams); - await Assert.That(resp.Args!["FirstName"]).IsEqualTo("John"); + await Assert.That(resp.Args![FirstNameKey]).IsEqualTo("John"); await Assert.That(resp.Args["LastName"]).IsEqualTo(RamboLastName); await Assert.That(resp.Args["Addr_Zip"]).IsEqualTo("9999"); } @@ -512,7 +535,7 @@ public async Task ComplexPostDynamicQueryparametersTest() { FirstName = "John", LastName = RamboLastName, - Address = new() { Postcode = 9999, Street = "HomeStreet 99" }, + Address = new() { Postcode = PostcodeValue, Street = "HomeStreet 99" }, }; myParams.MetaData.Add("Age", MetaDataAge); @@ -529,7 +552,7 @@ public async Task ComplexPostDynamicQueryparametersTest() var resp = await fixture.PostQuery(myParams); - await Assert.That(resp.Args!["FirstName"]).IsEqualTo("John"); + await Assert.That(resp.Args![FirstNameKey]).IsEqualTo("John"); await Assert.That(resp.Args["LastName"]).IsEqualTo(RamboLastName); await Assert.That(resp.Args["Addr_Zip"]).IsEqualTo("9999"); } @@ -546,7 +569,17 @@ public async Task PostGeneratedUrlEncodedFormUsesFieldDescriptors() { Method = HttpMethod.Post, Template = "http://foo/form", - FormData = [("user_name", "bob"), ("pwd", "secret"), ("Plain", "x"), ("Nullable", string.Empty)], + FormData = + [ + ("user_name", "bob"), + ("pwd", "secret"), + ("Plain", "x"), + ("Nullable", string.Empty), + ("Age", "42"), + ("Color", "Green"), + ("Ratio", "1.50"), + ("addr-City", "NYC"), + ], }, Reply.Json("\"ok\"") }, @@ -561,6 +594,10 @@ public async Task PostGeneratedUrlEncodedFormUsesFieldDescriptors() Password = SensitiveFormValue, Plain = "x", Nullable = null, + Age = SampleFormAge, + Color = GeneratedFormColor.Green, + Ratio = SampleFormRatio, + City = "NYC", }); await handler.VerifyAllCalledAsync(); @@ -575,7 +612,7 @@ public async Task CaptureRequestContentExposesSentBodyOnApiException() { { Route.Post(HttpBinFooUrl), - Reply.Json("{\"error\":\"bad\"}", HttpStatusCode.BadRequest) + Reply.Json(BadErrorJson, HttpStatusCode.BadRequest) }, }; @@ -602,7 +639,7 @@ public async Task CaptureRequestContentExposesSentBodyOnNonVoidResponse() { { Route.Post(HttpBinFooUrl), - Reply.Json("{\"error\":\"bad\"}", HttpStatusCode.BadRequest) + Reply.Json(BadErrorJson, HttpStatusCode.BadRequest) }, }; @@ -629,7 +666,7 @@ public async Task RequestContentNotCapturedByDefault() { { Route.Post(HttpBinFooUrl), - Reply.Json("{\"error\":\"bad\"}", HttpStatusCode.BadRequest) + Reply.Json(BadErrorJson, HttpStatusCode.BadRequest) }, }; diff --git a/src/tests/Refit.Tests/RestServiceIntegrationTests.RequestUrlConstruction.cs b/src/tests/Refit.Tests/RestServiceIntegrationTests.RequestUrlConstruction.cs new file mode 100644 index 000000000..4388eedb1 --- /dev/null +++ b/src/tests/Refit.Tests/RestServiceIntegrationTests.RequestUrlConstruction.cs @@ -0,0 +1,604 @@ +// 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.Net; +using Refit.Testing; + +namespace Refit.Tests; + +/// Integration tests covering how path-bound objects and formatted parameters are mapped into the request URL. +public partial class RestServiceIntegrationTests +{ + /// Verifies a path-bound object maps its properties into the path. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithPathBoundObject() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = FoosBarBarNoneUrl, ExactQuery = string.Empty }, + Reply.Json("Ok") + }, + }; + + var fixture = handler.CreateClient(BaseUrl); + + await fixture.GetFooBars( + new PathBoundObject { SomeProperty = 1, SomeProperty2 = BarNoneValue }); + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a generic path-bound parameter binds dotted {obj.Prop} placeholders against the + /// runtime type instead of throwing at client creation (issue #1743). + /// A task representing the asynchronous test. + [Test] + public async Task GetWithGenericPathBoundObject() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = FoosBarBarNoneUrl, ExactQuery = string.Empty }, + Reply.Json("Ok") + }, + }; + + var fixture = handler.CreateClient(BaseUrl); + + await fixture.GetFooBarsGeneric( + new PathBoundObject { SomeProperty = 1, SomeProperty2 = BarNoneValue }); + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a long path-bound value is mapped into the path. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithLongPathBoundObject() + { + const int pathRepeatCount = 1000; + var longPathString = string.Concat(Enumerable.Repeat(BarNoneValue, pathRepeatCount)); + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = $"http://foo/foos/12345/bar/{longPathString}", ExactQuery = string.Empty }, + Reply.Json("Ok") + }, + }; + + var fixture = handler.CreateClient(BaseUrl); + + await fixture.GetFooBars( + new PathBoundObject { SomeProperty = LargeFooId, SomeProperty2 = longPathString }); + await handler.VerifyAllCalledAsync(); + } + + /// Verifies path tokens with different casing still bind to the object's properties. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithPathBoundObjectDifferentCasing() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = FoosBarBarNoneUrl, ExactQuery = string.Empty }, + Reply.Json("Ok") + }, + }; + + var fixture = handler.CreateClient(BaseUrl); + + await fixture.GetFooBarsWithDifferentCasing( + new() { SomeProperty = 1, SomeProperty2 = BarNoneValue }); + await handler.VerifyAllCalledAsync(); + } + + /// Verifies an explicit parameter combines with a path-bound object. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithPathBoundObjectAndParameter() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/foos/myId/22/bar/bart", ExactQuery = string.Empty }, + Reply.Json("Ok") + }, + }; + + var fixture = handler.CreateClient(BaseUrl); + + await fixture.GetBarsByFoo( + "myId", + new() { SomeProperty = FooId, SomeProperty2 = "bart" }); + await handler.VerifyAllCalledAsync(); + } + + /// Verifies an explicit parameter takes precedence over a path-bound property. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithPathBoundObjectAndParameterParameterPrecedence() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/foos/chooseMe/bar/barNone", ExactQueryParams = [("SomeProperty", "1")] }, + Reply.Json("Ok") + }, + }; + + var fixture = handler.CreateClient(BaseUrl); + + await fixture.GetFooBars( + new() { SomeProperty = 1, SomeProperty2 = BarNoneValue }, + "chooseMe"); + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a derived path-bound object maps its properties into the path. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithPathBoundDerivedObject() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/foos/1/bar/test", ExactQueryParams = [(SomeProperty2Key, BarNoneValue)] }, + Reply.Json("Ok") + }, + }; + + var fixture = handler.CreateClient(BaseUrl); + + await fixture.GetFooBarsDerived( + new() + { + SomeProperty = 1, + SomeProperty2 = BarNoneValue, + SomeProperty3 = "test" + }); + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a derived object passed as its base type does not duplicate the bound property. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithDerivedObjectAsBaseType() + { + // see https://github.com/reactiveui/refit/issues/1882: a property bound to the path must not also be emitted + // as a query parameter. SomeProperty binds to the path and so is excluded from the query. The generated + // request builder flattens the declared (base) type's properties, so SomeProperty2 flattens into the query + // while the derived-only SomeProperty3 does not contribute through a base-typed parameter — mirroring how the + // System.Text.Json source generator treats a declared type. + var handler = new StubHttp + { + { + new RouteMatcher + { + Method = HttpMethod.Get, + Template = "http://foo/foos/1/bar", + ExactQueryParams = [(SomeProperty2Key, BarNoneValue)], + }, + Reply.Json("Ok") + }, + }; + + var fixture = handler.CreateClient(BaseUrl); + + await fixture.GetBarsByFoo( + new PathBoundDerivedObject + { + SomeProperty = 1, + SomeProperty2 = BarNoneValue, + SomeProperty3 = "test" + }); + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a path-bound object combines with an explicit query parameter. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithPathBoundObjectAndQueryParameter() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/foos/22/bar", ExactQueryParams = [(SomeProperty2Key, "bart")] }, + Reply.Json("Ok") + }, + }; + + var fixture = handler.CreateClient(BaseUrl); + + await fixture.GetBarsByFoo( + new() { SomeProperty = FooId, SomeProperty2 = "bart" }); + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a POST binds a path object alongside a body. + /// A task representing the asynchronous test. + [Test] + public async Task PostFooBarPathBoundObject() + { + var handler = new StubHttp + { + { + Route.Post("http://foo/foos/22/bar/bart"), + Reply.Json("Ok") + }, + }; + + var fixture = handler.CreateClient(BaseUrl); + + await fixture.PostFooBar( + new() { SomeProperty = FooId, SomeProperty2 = "bart" }, + new { }); + await handler.VerifyAllCalledAsync(); + } + + /// Verifies path-bound collection values respect the configured URL formatter. + /// A task representing the asynchronous test. + [Test] + public async Task PathBoundObjectsRespectFormatter() + { + var handler = new StubHttp + { + { + Route.Get("http://foo/foos/22%2C23"), + Reply.Json("Ok") + }, + }; + + var settings = new RefitSettings + { + HttpMessageHandlerFactory = () => handler, + UrlParameterFormatter = new TestEnumerableUrlParameterFormatter() + }; + var fixture = RestService.For(BaseUrl, settings); + + await fixture.GetFoos( + new() + { + Values = [FooId, SecondFooValue] + }); + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a path-bound object combines with a query property. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithPathBoundObjectAndQuery() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = FoosBarBarNoneUrl, ExactQuery = "SomeQuery=test" }, + Reply.Json("Ok") + }, + }; + + var fixture = handler.CreateClient(BaseUrl); + + await fixture.GetFooBars( + new PathBoundObjectWithQuery + { + SomeProperty = 1, + SomeProperty2 = BarNoneValue, + SomeQuery = "test" + }); + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a path-bound query property uses its custom format. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithPathBoundObjectAndQueryWithFormat() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/foo", ExactQuery = "SomeQueryWithFormat=2020-03-05T13:55:00Z" }, + Reply.Json("Ok") + }, + }; + + var fixture = handler.CreateClient(BaseUrl); + + await fixture.GetBarsWithCustomQueryFormat( + new() + { + SomeQueryWithFormat = new DateTimeOffset(2020, 03, 05, 13, 55, 00, TimeSpan.Zero).UtcDateTime + }); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a path-bound object combines with a query object body. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithPathBoundObjectAndQueryObject() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Post, Template = FoosBarBarNoneUrl, ExactQuery = "Property1=test&Property2=test2" }, + Reply.Json("Ok") + }, + }; + + var fixture = handler.CreateClient(BaseUrl); + + await fixture.PostFooBar( + new() { SomeProperty = 1, SomeProperty2 = BarNoneValue }, + new() { Property1 = "test", Property2 = "test2" }); + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a multipart POST binds a path object and uploads a stream part. + /// A task representing the asynchronous test. + [Test] + public async Task PostFooBarPathMultipart() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Post, Template = Foos22BarBarUrl, ExactQuery = string.Empty }, + Reply.Json("Ok") + }, + }; + + var fixture = handler.CreateClient(BaseUrl); + + await using var stream = GetTestFileStream(TestFilePath); + await fixture.PostFooBarStreamPart( + new PathBoundObject { SomeProperty = FooId, SomeProperty2 = "bar" }, + new(stream, TestFileName, PdfMediaType)); + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a multipart POST binds a path-and-query object and uploads a stream part. + /// A task representing the asynchronous test. + [Test] + public async Task PostFooBarPathQueryMultipart() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Post, Template = Foos22BarBarUrl, ExactQuery = "SomeQuery=test" }, + Reply.Json("Ok") + }, + }; + + var fixture = handler.CreateClient(BaseUrl); + + await using var stream = GetTestFileStream(TestFilePath); + await fixture.PostFooBarStreamPart( + new PathBoundObjectWithQuery + { + SomeProperty = FooId, + SomeProperty2 = "bar", + SomeQuery = "test" + }, + new(stream, TestFileName, PdfMediaType)); + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a multipart POST binds a path object, query object, and stream part. + /// A task representing the asynchronous test. + [Test] + public async Task PostFooBarPathQueryObjectMultipart() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Post, Template = Foos22BarBarUrl, ExactQuery = "Property1=test&Property2=test2" }, + Reply.Json("Ok") + }, + }; + + var fixture = handler.CreateClient(BaseUrl); + + await using var stream = GetTestFileStream(TestFilePath); + await fixture.PostFooBarStreamPart( + new() { SomeProperty = FooId, SomeProperty2 = "bar" }, + new() { Property1 = "test", Property2 = "test2" }, + new(stream, TestFileName, PdfMediaType)); + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a decimal query parameter is formatted correctly. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithDecimal() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/withDecimal", ExactQueryParams = [(DecimalQueryKey, "3.456")] }, + Reply.Json("Ok") + }, + }; + var fixture = handler.CreateClient(BaseUrl); + + const decimal val = 3.456M; + + _ = await fixture.GetWithDecimal(val); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a decimal query parameter is formatted correctly. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithDecimalGenerated() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/withDecimal", ExactQueryParams = [(DecimalQueryKey, "3.456")] }, + Reply.Json("Ok") + }, + }; + var fixture = handler.CreateClient(BaseUrl); + + const decimal val = 3.456M; + + _ = await fixture.GetWithDecimalGenerated(val); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a path parameter is formatted correctly. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithPathParameterGenerated() + { + var handler = new StubHttp + { + { Route.Get("http://foo/bar"), Reply.Json("Ok") }, + }; + var fixture = handler.CreateGeneratedClient(BaseUrl); + + _ = await fixture.GetPath("bar"); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a DateOnly path parameter is formatted and substituted by the generated client. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithDateOnlyPathParameterGenerated() + { + Uri? captured = null; + var handler = new StubHttp + { + { + new RouteMatcher { Template = "*", Reusable = true }, + Reply.From(request => + { + captured = request.RequestUri; + return new(HttpStatusCode.OK) { Content = new StringContent("Ok") }; + }) + }, + }; + var fixture = handler.CreateGeneratedClient(BaseUrl); + + const int year = 2024; + const int day = 2; + var date = new DateOnly(year, 1, day); + _ = await fixture.GetDateOnlyPath(date); + + var expected = ((IFormattable)date).ToString(null, System.Globalization.CultureInfo.InvariantCulture); + await Assert.That(captured).IsNotNull(); + await Assert.That(Uri.UnescapeDataString(captured!.ToString())) + .IsEqualTo($"http://foo/events/{expected}"); + } + + /// Verifies a query parameter is formatted correctly. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithQueryParameterGenerated() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/", ExactQueryParams = [("q", "bar")] }, + Reply.Json("Ok") + }, + }; + var fixture = handler.CreateGeneratedClient(BaseUrl); + + _ = await fixture.GetQuery("bar"); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies an aliased query parameter is formatted correctly. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithAliasedQueryParameterGenerated() + { + var handler = new StubHttp + { + { + new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/", ExactQueryParams = [("q", "bar")] }, + Reply.Json("Ok") + }, + }; + var fixture = handler.CreateGeneratedClient(BaseUrl); + + _ = await fixture.GetQueryAlias("bar"); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a URL with multiple query parameters is formatted correctly. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithMultipleParametersGenerated() + { + const int id = 1; + const int width = 800; + const int height = 600; + + var handler = new StubHttp + { + { Route.Get("http://foo/1/800x600/foo"), Reply.Json("Ok") }, + }; + var fixture = handler.CreateGeneratedClient(BaseUrl); + + _ = await fixture.FetchSomethingWithMultipleParametersPerSegment(id, width, height); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a URL with multiple repeated query parameters is formatted correctly. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithMultipleRepeatedParametersGenerated() + { + const int id = 1; + const int size = 300; + + var handler = new StubHttp + { + { Route.Get("http://foo/1/300x300/foo"), Reply.Json("Ok") }, + }; + var fixture = handler.CreateGeneratedClient(BaseUrl); + + _ = await fixture.FetchSomethingWithMultipleRepeatedParametersPerSegment(id, size); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a URL with a nullable parameters is formatted correctly. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithNullableParameterGenerated() + { + var handler = new StubHttp + { + { Route.Get("http://foo/a//b"), Reply.Json("Ok") }, + }; + var fixture = handler.CreateGeneratedClient(BaseUrl); + + _ = await fixture.GetNullableParam(null); + + await handler.VerifyAllCalledAsync(); + } + + /// Verifies a round trip URL with a nullable parameter. + /// A task representing the asynchronous test. + [Test] + public async Task GetWithNullRoundTripParameterGenerated() + { + var handler = new StubHttp + { + { Route.Get(BaseUrlWithSlash), Reply.Json("Ok") }, + }; + var fixture = handler.CreateClient(BaseUrl); + + _ = await fixture.GetValue(null); + + await handler.VerifyAllCalledAsync(); + } +} diff --git a/src/tests/Refit.Tests/RestServiceIntegrationTests.cs b/src/tests/Refit.Tests/RestServiceIntegrationTests.cs index ed745cbaa..46b8f36b9 100644 --- a/src/tests/Refit.Tests/RestServiceIntegrationTests.cs +++ b/src/tests/Refit.Tests/RestServiceIntegrationTests.cs @@ -39,6 +39,27 @@ public partial class RestServiceIntegrationTests /// Sample large path-bound foo identifier. private const int LargeFooId = 12_345; + /// Stub endpoint URL returning a value body. + private const string FooValueUrl = "http://foo/value"; + + /// Stub endpoint URL used by the no-body request tests. + private const string FooNobodyUrl = "http://foo/nobody"; + + /// Query key asserted by the dynamic query-parameter tests. + private const string SomeProperty2Key = "SomeProperty2"; + + /// Stub endpoint URL used by the nested-path POST tests. + private const string Foos22BarBarUrl = "http://foo/foos/22/bar/bar"; + + /// Relative path to the sample PDF used by the file-upload tests. + private const string TestFilePath = "Test Files/Test.pdf"; + + /// File name used for the sample PDF multipart part. + private const string TestFileName = "Test.pdf"; + + /// Media type used for the sample PDF multipart part. + private const string PdfMediaType = "application/pdf"; + /// JSON serializer options that apply the camelCase property naming policy. private static readonly JsonSerializerOptions _camelCaseJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; @@ -304,7 +325,7 @@ public async Task ValueTaskMethodsShouldWork() var handler = new StubHttp { { - Route.Get("http://foo/value"), + Route.Get(FooValueUrl), Reply.Text("test", "text/plain") }, }; @@ -359,8 +380,13 @@ public async Task UnmatchedRouteParameterIsLeftVerbatimWhenAllowed() /// Verifies an unmatched route placeholder still throws by default. /// A task representing the asynchronous test. [Test] - public async Task UnmatchedRouteParameterStillThrowsByDefault() => - await Assert.That(static () => RestService.For(BaseUrl)).ThrowsExactly(); + public async Task UnmatchedRouteParameterStillThrowsByDefault() + { + // With no parameters at all the interface generates inline, so the unmatched-placeholder check + // now runs when the request is built rather than at client creation. + var service = RestService.For(BaseUrl); + _ = await Assert.That(() => (Task)service.GetValue()).ThrowsExactly(); + } /// Verifies methods returning a of an API response work. /// A task representing the asynchronous test. @@ -370,7 +396,7 @@ public async Task ValueTaskApiResponseMethodsShouldWork() var handler = new StubHttp { { - Route.Get("http://foo/value"), + Route.Get(FooValueUrl), Reply.Text("test", "text/plain") }, }; @@ -381,7 +407,7 @@ public async Task ValueTaskApiResponseMethodsShouldWork() await Assert.That(response.IsSuccessStatusCode).IsTrue(); await Assert.That(response.Content).IsEqualTo("test"); await Assert.That(response.RequestMessage!.Method).IsEqualTo(HttpMethod.Get); - await Assert.That(response.RequestMessage.RequestUri?.ToString()).IsEqualTo("http://foo/value"); + await Assert.That(response.RequestMessage.RequestUri?.ToString()).IsEqualTo(FooValueUrl); await handler.VerifyAllCalledAsync(); } @@ -396,7 +422,7 @@ public async Task CanAddContentHeadersToPostWithoutBody() new RouteMatcher { Method = HttpMethod.Post, - Template = "http://foo/nobody", + Template = FooNobodyUrl, Headers = [("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")], Body = string.Empty, Where = static r => r.Content?.Headers.ContentLength == 0, @@ -562,378 +588,6 @@ public async Task GetWithNoParametersTestTrailingSlashInBase() await handler.VerifyAllCalledAsync(); } - /// Verifies a path-bound object maps its properties into the path. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithPathBoundObject() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = FoosBarBarNoneUrl, ExactQuery = string.Empty }, - Reply.Json("Ok") - }, - }; - - var fixture = handler.CreateClient(BaseUrl); - - await fixture.GetFooBars( - new PathBoundObject { SomeProperty = 1, SomeProperty2 = BarNoneValue }); - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a long path-bound value is mapped into the path. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithLongPathBoundObject() - { - var longPathString = string.Concat(Enumerable.Repeat(BarNoneValue, 1000)); - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = $"http://foo/foos/12345/bar/{longPathString}", ExactQuery = string.Empty }, - Reply.Json("Ok") - }, - }; - - var fixture = handler.CreateClient(BaseUrl); - - await fixture.GetFooBars( - new PathBoundObject { SomeProperty = LargeFooId, SomeProperty2 = longPathString }); - await handler.VerifyAllCalledAsync(); - } - - /// Verifies path tokens with different casing still bind to the object's properties. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithPathBoundObjectDifferentCasing() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = FoosBarBarNoneUrl, ExactQuery = string.Empty }, - Reply.Json("Ok") - }, - }; - - var fixture = handler.CreateClient(BaseUrl); - - await fixture.GetFooBarsWithDifferentCasing( - new() { SomeProperty = 1, SomeProperty2 = BarNoneValue }); - await handler.VerifyAllCalledAsync(); - } - - /// Verifies an explicit parameter combines with a path-bound object. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithPathBoundObjectAndParameter() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/foos/myId/22/bar/bart", ExactQuery = string.Empty }, - Reply.Json("Ok") - }, - }; - - var fixture = handler.CreateClient(BaseUrl); - - await fixture.GetBarsByFoo( - "myId", - new() { SomeProperty = FooId, SomeProperty2 = "bart" }); - await handler.VerifyAllCalledAsync(); - } - - /// Verifies an explicit parameter takes precedence over a path-bound property. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithPathBoundObjectAndParameterParameterPrecedence() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/foos/chooseMe/bar/barNone", ExactQueryParams = [("SomeProperty", "1")] }, - Reply.Json("Ok") - }, - }; - - var fixture = handler.CreateClient(BaseUrl); - - await fixture.GetFooBars( - new() { SomeProperty = 1, SomeProperty2 = BarNoneValue }, - "chooseMe"); - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a derived path-bound object maps its properties into the path. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithPathBoundDerivedObject() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/foos/1/bar/test", ExactQueryParams = [("SomeProperty2", BarNoneValue)] }, - Reply.Json("Ok") - }, - }; - - var fixture = handler.CreateClient(BaseUrl); - - await fixture.GetFooBarsDerived( - new() - { - SomeProperty = 1, - SomeProperty2 = BarNoneValue, - SomeProperty3 = "test" - }); - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a derived object passed as its base type does not duplicate the bound property. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithDerivedObjectAsBaseType() - { - // see https://github.com/reactiveui/refit/issues/1882: a property bound to the - // path must not also be emitted as a query parameter when a derived instance is - // passed for a base-typed parameter. - var handler = new StubHttp - { - { - new RouteMatcher - { - Method = HttpMethod.Get, - Template = "http://foo/foos/1/bar", - ExactQueryParams = [("SomeProperty3", "test"), ("SomeProperty2", BarNoneValue)], - }, - Reply.Json("Ok") - }, - }; - - var fixture = handler.CreateClient(BaseUrl); - - await fixture.GetBarsByFoo( - new PathBoundDerivedObject - { - SomeProperty = 1, - SomeProperty2 = BarNoneValue, - SomeProperty3 = "test" - }); - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a path-bound object combines with an explicit query parameter. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithPathBoundObjectAndQueryParameter() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/foos/22/bar", ExactQueryParams = [("SomeProperty2", "bart")] }, - Reply.Json("Ok") - }, - }; - - var fixture = handler.CreateClient(BaseUrl); - - await fixture.GetBarsByFoo( - new() { SomeProperty = FooId, SomeProperty2 = "bart" }); - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a POST binds a path object alongside a body. - /// A task representing the asynchronous test. - [Test] - public async Task PostFooBarPathBoundObject() - { - var handler = new StubHttp - { - { - Route.Post("http://foo/foos/22/bar/bart"), - Reply.Json("Ok") - }, - }; - - var fixture = handler.CreateClient(BaseUrl); - - await fixture.PostFooBar( - new() { SomeProperty = FooId, SomeProperty2 = "bart" }, - new { }); - await handler.VerifyAllCalledAsync(); - } - - /// Verifies path-bound collection values respect the configured URL formatter. - /// A task representing the asynchronous test. - [Test] - public async Task PathBoundObjectsRespectFormatter() - { - var handler = new StubHttp - { - { - Route.Get("http://foo/foos/22%2C23"), - Reply.Json("Ok") - }, - }; - - var settings = new RefitSettings - { - HttpMessageHandlerFactory = () => handler, - UrlParameterFormatter = new TestEnumerableUrlParameterFormatter() - }; - var fixture = RestService.For(BaseUrl, settings); - - await fixture.GetFoos( - new() - { - Values = [FooId, SecondFooValue] - }); - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a path-bound object combines with a query property. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithPathBoundObjectAndQuery() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = FoosBarBarNoneUrl, ExactQuery = "SomeQuery=test" }, - Reply.Json("Ok") - }, - }; - - var fixture = handler.CreateClient(BaseUrl); - - await fixture.GetFooBars( - new PathBoundObjectWithQuery - { - SomeProperty = 1, - SomeProperty2 = BarNoneValue, - SomeQuery = "test" - }); - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a path-bound query property uses its custom format. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithPathBoundObjectAndQueryWithFormat() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/foo", ExactQuery = "SomeQueryWithFormat=2020-03-05T13:55:00Z" }, - Reply.Json("Ok") - }, - }; - - var fixture = handler.CreateClient(BaseUrl); - - await fixture.GetBarsWithCustomQueryFormat( - new() - { - SomeQueryWithFormat = new DateTimeOffset(2020, 03, 05, 13, 55, 00, TimeSpan.Zero).UtcDateTime - }); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a path-bound object combines with a query object body. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithPathBoundObjectAndQueryObject() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Post, Template = FoosBarBarNoneUrl, ExactQuery = "Property1=test&Property2=test2" }, - Reply.Json("Ok") - }, - }; - - var fixture = handler.CreateClient(BaseUrl); - - await fixture.PostFooBar( - new() { SomeProperty = 1, SomeProperty2 = BarNoneValue }, - new() { Property1 = "test", Property2 = "test2" }); - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a multipart POST binds a path object and uploads a stream part. - /// A task representing the asynchronous test. - [Test] - public async Task PostFooBarPathMultipart() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Post, Template = "http://foo/foos/22/bar/bar", ExactQuery = string.Empty }, - Reply.Json("Ok") - }, - }; - - var fixture = handler.CreateClient(BaseUrl); - - await using var stream = GetTestFileStream("Test Files/Test.pdf"); - await fixture.PostFooBarStreamPart( - new PathBoundObject { SomeProperty = FooId, SomeProperty2 = "bar" }, - new(stream, "Test.pdf", "application/pdf")); - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a multipart POST binds a path-and-query object and uploads a stream part. - /// A task representing the asynchronous test. - [Test] - public async Task PostFooBarPathQueryMultipart() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Post, Template = "http://foo/foos/22/bar/bar", ExactQuery = "SomeQuery=test" }, - Reply.Json("Ok") - }, - }; - - var fixture = handler.CreateClient(BaseUrl); - - await using var stream = GetTestFileStream("Test Files/Test.pdf"); - await fixture.PostFooBarStreamPart( - new PathBoundObjectWithQuery - { - SomeProperty = FooId, - SomeProperty2 = "bar", - SomeQuery = "test" - }, - new(stream, "Test.pdf", "application/pdf")); - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a multipart POST binds a path object, query object, and stream part. - /// A task representing the asynchronous test. - [Test] - public async Task PostFooBarPathQueryObjectMultipart() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Post, Template = "http://foo/foos/22/bar/bar", ExactQuery = "Property1=test&Property2=test2" }, - Reply.Json("Ok") - }, - }; - - var fixture = handler.CreateClient(BaseUrl); - - await using var stream = GetTestFileStream("Test Files/Test.pdf"); - await fixture.PostFooBarStreamPart( - new() { SomeProperty = FooId, SomeProperty2 = "bar" }, - new() { Property1 = "test", Property2 = "test2" }, - new(stream, "Test.pdf", "application/pdf")); - await handler.VerifyAllCalledAsync(); - } - /// Verifies content is not automatically added to a GET request. /// A task representing the asynchronous test. [Test] @@ -942,7 +596,7 @@ public async Task DoesntAddAutoAddContentToGetRequest() var handler = new StubHttp { { - new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/nobody", Where = static r => r.Content is null }, + new RouteMatcher { Method = HttpMethod.Get, Template = FooNobodyUrl, Where = static r => r.Content is null }, Reply.Json("Ok") }, }; @@ -961,7 +615,7 @@ public async Task DoesntAddAutoAddContentToHeadRequest() var handler = new StubHttp { { - new RouteMatcher { Method = HttpMethod.Head, Template = "http://foo/nobody", Where = static r => r.Content is null }, + new RouteMatcher { Method = HttpMethod.Head, Template = FooNobodyUrl, Where = static r => r.Content is null }, Reply.Json("Ok") }, }; @@ -972,201 +626,6 @@ public async Task DoesntAddAutoAddContentToHeadRequest() await handler.VerifyAllCalledAsync(); } - /// Verifies a decimal query parameter is formatted correctly. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithDecimal() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/withDecimal", ExactQueryParams = [(DecimalQueryKey, "3.456")] }, - Reply.Json("Ok") - }, - }; - var fixture = handler.CreateClient(BaseUrl); - - const decimal val = 3.456M; - - _ = await fixture.GetWithDecimal(val); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a decimal query parameter is formatted correctly. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithDecimalGenerated() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/withDecimal", ExactQueryParams = [(DecimalQueryKey, "3.456")] }, - Reply.Json("Ok") - }, - }; - var fixture = handler.CreateClient(BaseUrl); - - const decimal val = 3.456M; - - _ = await fixture.GetWithDecimalGenerated(val); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a path parameter is formatted correctly. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithPathParameterGenerated() - { - var handler = new StubHttp - { - { Route.Get("http://foo/bar"), Reply.Json("Ok") }, - }; - var fixture = handler.CreateGeneratedClient(BaseUrl); - - _ = await fixture.GetPath("bar"); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a DateOnly path parameter is formatted and substituted by the generated client. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithDateOnlyPathParameterGenerated() - { - Uri? captured = null; - var handler = new StubHttp - { - { - new RouteMatcher { Template = "*", Reusable = true }, - Reply.From(request => - { - captured = request.RequestUri; - return new(HttpStatusCode.OK) { Content = new StringContent("Ok") }; - }) - }, - }; - var fixture = handler.CreateGeneratedClient(BaseUrl); - - var date = new DateOnly(2024, 1, 2); - _ = await fixture.GetDateOnlyPath(date); - - var expected = ((IFormattable)date).ToString(null, System.Globalization.CultureInfo.InvariantCulture); - await Assert.That(captured).IsNotNull(); - await Assert.That(Uri.UnescapeDataString(captured!.ToString())) - .IsEqualTo($"http://foo/events/{expected}"); - } - - /// Verifies a query parameter is formatted correctly. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithQueryParameterGenerated() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/", ExactQueryParams = [("q", "bar")] }, - Reply.Json("Ok") - }, - }; - var fixture = handler.CreateGeneratedClient(BaseUrl); - - _ = await fixture.GetQuery("bar"); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies an aliased query parameter is formatted correctly. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithAliasedQueryParameterGenerated() - { - var handler = new StubHttp - { - { - new RouteMatcher { Method = HttpMethod.Get, Template = "http://foo/", ExactQueryParams = [("q", "bar")] }, - Reply.Json("Ok") - }, - }; - var fixture = handler.CreateGeneratedClient(BaseUrl); - - _ = await fixture.GetQueryAlias("bar"); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a URL with multiple query parameters is formatted correctly. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithMultipleParametersGenerated() - { - const int id = 1; - const int width = 800; - const int height = 600; - - var handler = new StubHttp - { - { Route.Get("http://foo/1/800x600/foo"), Reply.Json("Ok") }, - }; - var fixture = handler.CreateGeneratedClient(BaseUrl); - - _ = await fixture.FetchSomethingWithMultipleParametersPerSegment(id, width, height); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a URL with multiple repeated query parameters is formatted correctly. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithMultipleRepeatedParametersGenerated() - { - const int id = 1; - const int size = 300; - - var handler = new StubHttp - { - { Route.Get("http://foo/1/300x300/foo"), Reply.Json("Ok") }, - }; - var fixture = handler.CreateGeneratedClient(BaseUrl); - - _ = await fixture.FetchSomethingWithMultipleRepeatedParametersPerSegment(id, size); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a URL with a nullable parameters is formatted correctly. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithNullableParameterGenerated() - { - var handler = new StubHttp - { - { Route.Get("http://foo/a//b"), Reply.Json("Ok") }, - }; - var fixture = handler.CreateGeneratedClient(BaseUrl); - - _ = await fixture.GetNullableParam(null); - - await handler.VerifyAllCalledAsync(); - } - - /// Verifies a round trip URL with a nullable parameter. - /// A task representing the asynchronous test. - [Test] - public async Task GetWithNullRoundTripParameterGenerated() - { - var handler = new StubHttp - { - { Route.Get(BaseUrlWithSlash), Reply.Json("Ok") }, - }; - var fixture = handler.CreateClient(BaseUrl); - - _ = await fixture.GetValue(null); - - await handler.VerifyAllCalledAsync(); - } - /// Opens the embedded test resource at the given relative path as a stream. /// The path of the embedded resource relative to the assembly root. /// A stream over the matching embedded resource. diff --git a/src/tests/Refit.Tests/ReturnTypeAdapterResolverTests.cs b/src/tests/Refit.Tests/ReturnTypeAdapterResolverTests.cs new file mode 100644 index 000000000..50aff9b01 --- /dev/null +++ b/src/tests/Refit.Tests/ReturnTypeAdapterResolverTests.cs @@ -0,0 +1,397 @@ +// 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; + +/// Verifies the metadata-only matching rules of that the reflection +/// request builder relies on to surface custom return types through a registered . +public sealed class ReturnTypeAdapterResolverTests +{ + /// Verifies a closed adapter surfaces its result type and ignores its non-adapter interfaces. + /// A task representing the asynchronous test. + [Test] + public async Task ClosedAdapterMatchingReturnTypeSurfacesResultType() + { + var matched = ReturnTypeAdapterResolver.TryResolveResultType( + typeof(AdapterShape), + [typeof(ClosedShapeAdapter)], + out var resultType); + + await Assert.That(matched).IsTrue(); + await Assert.That(resultType).IsEqualTo(typeof(string)); + } + + /// Verifies a closed adapter whose declared return type differs is not matched. + /// A task representing the asynchronous test. + [Test] + public async Task ClosedAdapterWithDifferentReturnTypeIsNotMatched() + { + var matched = ReturnTypeAdapterResolver.TryResolveResultType( + typeof(Wrapped), + [typeof(ClosedShapeAdapter)], + out var resultType); + + await Assert.That(matched).IsFalse(); + await Assert.That(resultType).IsNull(); + } + + /// Verifies a closed adapter that also implements a non-adapter interface skips that interface during + /// matching: the non-adapter interface is evaluated and rejected, and the mismatched return type is not matched. + /// A task representing the asynchronous test. + [Test] + public async Task ClosedAdapterWithNonAdapterInterfaceSkipsNonAdapterInterface() + { + // The return type does not match the adapter's declared return type, so no interface causes an early match. + // Every implemented interface (including the non-adapter IDisposable) is inspected and skipped. + var matched = ReturnTypeAdapterResolver.TryResolveResultType( + typeof(Wrapped), + [typeof(ClosedShapeAdapterWithMarker)], + out var resultType); + + await Assert.That(matched).IsFalse(); + await Assert.That(resultType).IsNull(); + } + + /// Verifies a null adapter entry is skipped without matching. + /// A task representing the asynchronous test. + [Test] + public async Task NullAdapterEntryIsSkipped() + { + var matched = ReturnTypeAdapterResolver.TryResolveResultType( + typeof(AdapterShape), + [null!], + out var resultType); + + await Assert.That(matched).IsFalse(); + await Assert.That(resultType).IsNull(); + } + + /// Verifies an open generic adapter surfaces the wrapped result type positionally. + /// A task representing the asynchronous test. + [Test] + public async Task OpenGenericAdapterSurfacesWrappedResultType() + { + var matched = ReturnTypeAdapterResolver.TryResolveResultType( + typeof(Wrapped), + [typeof(WrappedAdapter<>)], + out var resultType); + + await Assert.That(matched).IsTrue(); + await Assert.That(resultType).IsEqualTo(typeof(AdapterUser)); + } + + /// Verifies an open generic adapter whose wrapper reorders the adapter's type parameters is matched, closing + /// the adapter over the reordered arguments and surfacing the correctly mapped result type. + /// A task representing the asynchronous test. + [Test] + public async Task OpenGenericAdapterSurfacesReorderedWrappedResultType() + { + // SwappedAdapter : IReturnTypeAdapter, T1>, so a Paired return binds + // T2 = AdapterUser and T1 = int; the surfaced result type is T1 = int. + var matched = ReturnTypeAdapterResolver.TryResolveResultType( + typeof(Paired), + [typeof(SwappedAdapter<,>)], + out var resultType); + + await Assert.That(matched).IsTrue(); + await Assert.That(resultType).IsEqualTo(typeof(int)); + } + + /// Verifies an open generic adapter is not matched against a non-generic return type. + /// A task representing the asynchronous test. + [Test] + public async Task OpenGenericAdapterRejectsNonGenericReturnType() + { + var matched = ReturnTypeAdapterResolver.TryResolveResultType( + typeof(string), + [typeof(WrappedAdapter<>)], + out _); + + await Assert.That(matched).IsFalse(); + } + + /// Verifies an open generic adapter is not matched when the return type arity differs. + /// A task representing the asynchronous test. + [Test] + public async Task OpenGenericAdapterRejectsMismatchedArity() + { + var matched = ReturnTypeAdapterResolver.TryResolveResultType( + typeof(Dictionary), + [typeof(WrappedAdapter<>)], + out _); + + await Assert.That(matched).IsFalse(); + } + + /// Verifies non-adapter interfaces and a mismatched declared return shape are both skipped. + /// A task representing the asynchronous test. + [Test] + public async Task OpenGenericAdapterSkipsNonAdapterInterfacesAndMismatchedShape() + { + var matched = ReturnTypeAdapterResolver.TryResolveResultType( + typeof(List), + [typeof(MarkerAdapter<>)], + out _); + + await Assert.That(matched).IsFalse(); + } + + /// Verifies a result type constructed over the adapter's type parameter is not matched during metadata + /// resolution because closing it needs runtime instantiation. + /// A task representing the asynchronous test. + [Test] + public async Task OpenGenericAdapterWithConstructedResultTypeIsNotMatched() + { + var matched = ReturnTypeAdapterResolver.TryResolveResultType( + typeof(Wrapped), + [typeof(ContainingResultAdapter<>)], + out _); + + await Assert.That(matched).IsFalse(); + } + + /// Verifies an open generic adapter whose declared return shape is non-generic is not matched, because a + /// non-generic template return cannot bind the return type's arguments positionally. + /// A task representing the asynchronous test. + [Test] + public async Task OpenGenericAdapterWithNonGenericTemplateReturnIsNotMatched() + { + // The return type is generic with matching arity, so mapping is attempted; the adapter's template return + // (AdapterShape) is non-generic, so the argument mapping fails immediately. + var matched = ReturnTypeAdapterResolver.TryResolveResultType( + typeof(Wrapped), + [typeof(NonGenericTemplateReturnAdapter<>)], + out var resultType); + + await Assert.That(matched).IsFalse(); + await Assert.That(resultType).IsNull(); + } + + /// Verifies a concrete, fully-closed result type on a generic adapter is surfaced verbatim. + /// A task representing the asynchronous test. + [Test] + public async Task OpenGenericAdapterWithConcreteResultTypeSurfacesItVerbatim() + { + var matched = ReturnTypeAdapterResolver.TryResolveResultType( + typeof(Wrapped), + [typeof(ConcreteResultAdapter<>)], + out var resultType); + + await Assert.That(matched).IsTrue(); + await Assert.That(resultType).IsEqualTo(typeof(string)); + } + + /// Verifies an adapter whose declared return shape closes over a concrete argument is not matched. + /// A task representing the asynchronous test. + [Test] + public async Task OpenGenericAdapterWithNonPositionalReturnShapeIsNotMatched() + { + var matched = ReturnTypeAdapterResolver.TryResolveResultType( + typeof(Wrapped), + [typeof(PositionalMismatchAdapter<>)], + out _); + + await Assert.That(matched).IsFalse(); + } + + /// Verifies the closed adapter type resolves to the registered adapter itself. + /// A task representing the asynchronous test. + [Test] + public async Task ResolveClosedAdapterTypeReturnsClosedAdapter() + { + var adapterType = ReturnTypeAdapterResolver.ResolveClosedAdapterType( + typeof(AdapterShape), + [typeof(ClosedShapeAdapter)]); + + await Assert.That(adapterType).IsEqualTo(typeof(ClosedShapeAdapter)); + } + + /// Verifies the closed adapter type is closed over the return type's arguments for a generic adapter. + /// A task representing the asynchronous test. + [Test] + public async Task ResolveClosedAdapterTypeClosesGenericAdapter() + { + var adapterType = ReturnTypeAdapterResolver.ResolveClosedAdapterType( + typeof(Wrapped), + [typeof(WrappedAdapter<>)]); + + await Assert.That(adapterType).IsEqualTo(typeof(WrappedAdapter)); + } + + /// Verifies resolving a closed adapter type yields null when no adapter matches. + /// A task representing the asynchronous test. + [Test] + public async Task ResolveClosedAdapterTypeReturnsNullWhenNoAdapterMatches() + { + var adapterType = ReturnTypeAdapterResolver.ResolveClosedAdapterType( + typeof(string), + [typeof(ClosedShapeAdapter)]); + + await Assert.That(adapterType).IsNull(); + } + + /// Verifies a generic adapter whose wrapper pins a concrete argument is rejected when the return type's + /// argument at that position differs, so the concrete-versus-return comparison fails the whole bind. + /// A task representing the asynchronous test. + [Test] + public async Task OpenGenericAdapterWithConcreteWrapperArgumentRejectsDifferingReturnArgument() + { + // PositionalMismatchAdapter : IReturnTypeAdapter, T>. Binding a Wrapped return keeps the + // arity match but the wrapper's concrete int argument cannot bind the return's string argument, so the map fails. + var matched = ReturnTypeAdapterResolver.TryResolveResultType( + typeof(Wrapped), + [typeof(PositionalMismatchAdapter<>)], + out var resultType); + + await Assert.That(matched).IsFalse(); + await Assert.That(resultType).IsNull(); + } + + /// Verifies a generic adapter whose wrapper repeats a type parameter enforces a consistent binding: the same + /// return argument at both positions is accepted, while a differing argument rejects the bind. + /// A task representing the asynchronous test. + [Test] + public async Task OpenGenericAdapterWithRepeatedWrapperParameterRequiresConsistentBinding() + { + // RepeatedWrapperAdapter : IReturnTypeAdapter, TResult>. Both wrapper + // positions bind the same parameter, so the second position re-checks the first binding for consistency. Because + // TResult never appears in the wrapper, neither binding closes the adapter, so both resolve to no match. The value + // lies in exercising the consistent (Paired) and inconsistent (Paired) outcomes of that re-check. + var consistent = ReturnTypeAdapterResolver.TryResolveResultType( + typeof(Paired), + [typeof(RepeatedWrapperAdapter<,>)], + out _); + + var inconsistent = ReturnTypeAdapterResolver.TryResolveResultType( + typeof(Paired), + [typeof(RepeatedWrapperAdapter<,>)], + out _); + + await Assert.That(consistent).IsFalse(); + await Assert.That(inconsistent).IsFalse(); + } + + /// A non-generic return shape surfaced by a closed adapter; no interface method returns it, so the + /// generator never references the adapter. + private sealed class AdapterShape + { + /// Gets the shape id. + public int Id { get; init; } + } + + /// A single-parameter generic return shape used by the generic adapters; no interface method returns it, + /// so the generator never references the adapters. + /// The wrapped value type. + private sealed class Wrapped + { + /// Gets the wrapped value. + public T? Value { get; init; } + } + + /// A closed adapter surfacing as a string. + private sealed class ClosedShapeAdapter : IReturnTypeAdapter + { + /// + public AdapterShape Adapt(Func> invoke) => new(); + } + + /// A closed adapter surfacing as a string that also implements a non-adapter + /// interface, so matching must skip the non-adapter interface. + private sealed class ClosedShapeAdapterWithMarker : IReturnTypeAdapter, IDisposable + { + /// + public AdapterShape Adapt(Func> invoke) => new(); + + /// + public void Dispose() + { + } + } + + /// An open generic adapter whose declared return shape is a non-generic type, so it cannot map a generic + /// return type's arguments positionally. + /// The unused result type parameter. + private sealed class NonGenericTemplateReturnAdapter : IReturnTypeAdapter + { + /// + public AdapterShape Adapt(Func> invoke) => new(); + } + + /// An open generic adapter surfacing Wrapped<T> as T. + /// The wrapped result type. + private sealed class WrappedAdapter : IReturnTypeAdapter, T> + { + /// + public Wrapped Adapt(Func> invoke) => new(); + } + + /// An open generic adapter that also implements a non-adapter interface and declares a return shape that + /// does not match a plain generic return type. + /// The wrapped result type. + private sealed class MarkerAdapter : IReturnTypeAdapter, T>, IDisposable + { + /// + public Wrapped Adapt(Func> invoke) => new(); + + /// + public void Dispose() + { + } + } + + /// An open generic adapter whose result type is a concrete, fully-closed type. + /// The wrapped return-shape type parameter. + private sealed class ConcreteResultAdapter : IReturnTypeAdapter, string> + { + /// + public Wrapped Adapt(Func> invoke) => new(); + } + + /// An open generic adapter whose result type is itself constructed over the type parameter. + /// The wrapped result element type. + private sealed class ContainingResultAdapter : IReturnTypeAdapter, List> + { + /// + public Wrapped Adapt(Func>> invoke) => new(); + } + + /// An open generic adapter whose declared return shape closes over a concrete argument, not its parameter. + /// The unused wrapped result type parameter. + private sealed class PositionalMismatchAdapter : IReturnTypeAdapter, T> + { + /// + public Wrapped Adapt(Func> invoke) => new(); + } + + /// A two-parameter generic return shape used by the reordered adapter. + /// The first wrapped value type. + /// The second wrapped value type. + private sealed class Paired + { + /// Gets the first wrapped value. + public TFirst? First { get; init; } + + /// Gets the second wrapped value. + public TSecond? Second { get; init; } + } + + /// An open generic adapter whose wrapper reorders the adapter's type parameters. + /// The result type parameter, appearing second in the wrapper. + /// The type parameter appearing first in the wrapper. + private sealed class SwappedAdapter : IReturnTypeAdapter, T1> + { + /// + public Paired Adapt(Func> invoke) => new(); + } + + /// An open generic adapter whose wrapper binds a single type parameter to both of its positions, so matching + /// re-checks the second position against the first binding for consistency. + /// The type parameter bound at both wrapper positions. + /// The result type parameter, absent from the wrapper. + private sealed class RepeatedWrapperAdapter : IReturnTypeAdapter, TResult> + { + /// + public Paired Adapt(Func> invoke) => new(); + } +} diff --git a/src/tests/Refit.Tests/ReturnTypeAdapterTests.cs b/src/tests/Refit.Tests/ReturnTypeAdapterTests.cs new file mode 100644 index 000000000..a9e9af4a9 --- /dev/null +++ b/src/tests/Refit.Tests/ReturnTypeAdapterTests.cs @@ -0,0 +1,115 @@ +// 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; + +namespace Refit.Tests; + +/// Verifies both the reflection and generated request builders surface a custom return type through a +/// registered or discovered (issue #2165). +public sealed class ReturnTypeAdapterTests +{ + /// The id of the sample user returned by the stub handler. + private const int UserId = 7; + + /// The name of the sample user returned by the stub handler. + private const string UserName = "Ada"; + + /// The number of sends expected after the deferred call is invoked twice. + private const int SendsAfterTwoInvocations = 2; + + /// The sample user response body carrying and . + private const string UserJson = """{"id":7,"name":"Ada"}"""; + + /// The JSON media type of the sample user response. + private const string JsonMediaType = "application/json"; + + /// The base address of the stub HTTP client. + private const string ClientBaseAddress = "https://api.example.com"; + + /// Verifies the reflection builder surfaces a custom return type from a runtime-registered adapter, + /// defers the request until invoked, and rebuilds the request on each invocation. + /// A task representing the asynchronous test. + [Test] + public async Task ReflectionPathAdaptsCustomReturnTypeThroughSeam() + { + var handler = new TestHttpMessageHandler + { + ContentFactory = static () => new StringContent(UserJson, Encoding.UTF8, JsonMediaType), + }; + var client = new HttpClient(handler) { BaseAddress = new(ClientBaseAddress) }; + + var settings = new RefitSettings(); + settings.ReturnTypeAdapters.Add(typeof(DeferredCallAdapter<>)); + + // Build through the reflection request builder directly so the generated inline path is not taken. + var builder = new RequestBuilderImplementation(settings); + var invoke = builder.BuildRestResultFuncForMethod(nameof(IDeferredCallApi.GetUser)); + var deferred = (DeferredCall)invoke(client, [UserId])!; + + // The adapter surfaces the call synchronously, so nothing is sent until it is invoked. + await Assert.That(handler.MessagesSent).IsEqualTo(0); + + var user = await deferred.InvokeAsync(CancellationToken.None); + await Assert.That(handler.MessagesSent).IsEqualTo(1); + await Assert.That(user!.Id).IsEqualTo(UserId); + await Assert.That(user.Name).IsEqualTo(UserName); + + // The reflection builder rebuilds the request on each invocation, so the deferred call re-runs. + _ = await deferred.InvokeAsync(CancellationToken.None); + await Assert.That(handler.MessagesSent).IsEqualTo(SendsAfterTwoInvocations); + } + + /// Verifies the reflection adapter delegate threads the method's own cancellation token into the deferred + /// invocation when the adapted method declares one. + /// A task representing the asynchronous test. + [Test] + public async Task ReflectionPathAdapterThreadsMethodCancellationToken() + { + var handler = new TestHttpMessageHandler + { + ContentFactory = static () => new StringContent(UserJson, Encoding.UTF8, JsonMediaType), + }; + var client = new HttpClient(handler) { BaseAddress = new(ClientBaseAddress) }; + + var settings = new RefitSettings(); + settings.ReturnTypeAdapters.Add(typeof(DeferredCallAdapter<>)); + + var builder = new RequestBuilderImplementation(settings); + var invoke = builder.BuildRestResultFuncForMethod(nameof(IDeferredCallApi.GetUserCancellable)); + + using var methodTokenSource = new CancellationTokenSource(); + var deferred = (DeferredCall)invoke(client, [UserId, methodTokenSource.Token])!; + + var user = await deferred.InvokeAsync(CancellationToken.None); + await Assert.That(handler.MessagesSent).IsEqualTo(1); + await Assert.That(user!.Id).IsEqualTo(UserId); + } + + /// Verifies the generated inline path surfaces a compile-time-discovered adapter's custom return type and + /// defers the request until invoked, without any runtime adapter registration. + /// A task representing the asynchronous test. + [Test] + public async Task GeneratedPathAdaptsCustomReturnTypeThroughSeam() + { + var handler = new TestHttpMessageHandler + { + ContentFactory = static () => new StringContent(UserJson, Encoding.UTF8, JsonMediaType), + }; + var client = new HttpClient(handler) { BaseAddress = new(ClientBaseAddress) }; + + // No adapter is registered on the settings: the generated code discovered it at compile time. If this used the + // reflection builder it would throw for the unrecognized synchronous return type, so success proves the + // generated inline path handled the adapter. + var api = RestService.For(client, new RefitSettings()); + + var deferred = api.GetUser(UserId); + await Assert.That(handler.MessagesSent).IsEqualTo(0); + + var user = await deferred.InvokeAsync(CancellationToken.None); + await Assert.That(handler.MessagesSent).IsEqualTo(1); + await Assert.That(user!.Id).IsEqualTo(UserId); + await Assert.That(user.Name).IsEqualTo(UserName); + } +} diff --git a/src/tests/Refit.Tests/Rfc3986RequestUriAssemblyTests.cs b/src/tests/Refit.Tests/Rfc3986RequestUriAssemblyTests.cs new file mode 100644 index 000000000..c785d6220 --- /dev/null +++ b/src/tests/Refit.Tests/Rfc3986RequestUriAssemblyTests.cs @@ -0,0 +1,37 @@ +// 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; + +/// Verifies request URI assembly under . +public sealed class Rfc3986RequestUriAssemblyTests +{ + /// A method without query parameters yields a bare relative path. + /// A task that represents the asynchronous operation. + [Test] + public async Task MethodWithoutQueryParametersYieldsBarePath() + { + var fixture = new RequestBuilderImplementation( + new RefitSettings { UrlResolution = UrlResolutionMode.Rfc3986 }); + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IRfc3986RequestApi.GetWithoutQuery)); + var output = await factory([]); + + await Assert.That(output.RequestUri!.ToString()).IsEqualTo("http://api/items"); + } + + /// A method with a query parameter appends a built query string. + /// A task that represents the asynchronous operation. + [Test] + public async Task MethodWithQueryParametersAppendsQueryString() + { + var fixture = new RequestBuilderImplementation( + new RefitSettings { UrlResolution = UrlResolutionMode.Rfc3986 }); + var factory = fixture.BuildRequestFactoryForMethod( + nameof(IRfc3986RequestApi.GetWithQuery)); + var output = await factory(["abc"]); + + await Assert.That(output.RequestUri!.ToString()).IsEqualTo("http://api/items?search=abc"); + } +} diff --git a/src/tests/Refit.Tests/RootObject.cs b/src/tests/Refit.Tests/RootObject.cs index 868ce7f54..c5d3a39bd 100644 --- a/src/tests/Refit.Tests/RootObject.cs +++ b/src/tests/Refit.Tests/RootObject.cs @@ -2,13 +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; - namespace Refit.Tests; /// A deserialization fixture mirroring the npmjs registry document used by the Refit tests. -[SuppressMessage("Minor Code Smell", "S100:Methods and properties should be named in PascalCase", Justification = "Property names intentionally mirror the snake-cased npmjs JSON payload under test.")] -[SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Property names intentionally mirror the snake-cased npmjs JSON payload under test.")] public class RootObject { /// Gets or sets the document identifier (the npmjs _id field). diff --git a/src/tests/Refit.Tests/RouteObjectWithUnreadableProperty.cs b/src/tests/Refit.Tests/RouteObjectWithUnreadableProperty.cs index c88e34cd4..23ec5320d 100644 --- a/src/tests/Refit.Tests/RouteObjectWithUnreadableProperty.cs +++ b/src/tests/Refit.Tests/RouteObjectWithUnreadableProperty.cs @@ -2,15 +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; - 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. diff --git a/src/tests/Refit.Tests/SealedQueryObject.cs b/src/tests/Refit.Tests/SealedQueryObject.cs new file mode 100644 index 000000000..6a5eedb48 --- /dev/null +++ b/src/tests/Refit.Tests/SealedQueryObject.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.Runtime.Serialization; + +namespace Refit.Tests; + +/// A sealed query object exercising every flattening rule the generator implements. +public sealed class SealedQueryObject +{ + /// Gets or sets a plain scalar rendered under its CLR name. + public string? Name { get; set; } + + /// Gets or sets a scalar renamed by an alias, which bypasses the key formatter. + [AliasAs("n")] + public int Number { get; set; } + + /// Gets or sets a scalar rendered through the form-url-encoded formatter's format string. + [Query(Format = "0.00")] + public double Price { get; set; } + + /// Gets or sets a null-valued scalar that opts in to being emitted as a bare key=. + [Query(SerializeNull = true)] + public string? Kept { get; set; } + + /// Gets or sets a null-valued scalar that is omitted from the query string. + public string? Skipped { get; set; } + + /// Gets or sets an enum rendered through its value. + public QuerySort Sort { get; set; } + + /// Gets or sets a property excluded from the query string by an ignore attribute. + [IgnoreDataMember] + public string? Hidden { get; set; } + + /// Gets or sets a non-public property, which has no public getter and so is never flattened. + internal string? Secret { get; set; } +} diff --git a/src/tests/Refit.Tests/SerializedContentTests.ValueConversion.cs b/src/tests/Refit.Tests/SerializedContentTests.ValueConversion.cs new file mode 100644 index 000000000..3901a482f --- /dev/null +++ b/src/tests/Refit.Tests/SerializedContentTests.ValueConversion.cs @@ -0,0 +1,688 @@ +// 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.Diagnostics.CodeAnalysis; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using SystemTextJsonSerializer = System.Text.Json.JsonSerializer; + +namespace Refit.Tests; + +/// Verifies how the System.Text.Json content serializer converts JSON values and enums: object-value inference, number handling, and enum serialization and deserialization. +public partial class SerializedContentTests +{ + /// Verifies that boolean JSON object values are inferred as . + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_InferBooleanObjectValues() + { + var result = SystemTextJsonSerializer.Deserialize( + """{"value":true}""", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(await Assert.That(result!.Value).IsTypeOf()).IsTrue(); + } + +#if NET10_0_OR_GREATER + /// Verifies duplicate JSON properties are rejected by the default serializer options. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_RejectDuplicateProperties() + { + var options = SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions(); + + await Assert + .That(() => SystemTextJsonSerializer.Deserialize("""{"value":1,"value":2}""", options)) + .ThrowsExactly(); + } + +#endif + + /// 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] + public async Task SystemTextJsonContentSerializer_DefaultOptions_InferIntegralObjectValues() + { + var result = SystemTextJsonSerializer.Deserialize( + """{"value":42}""", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(await Assert.That(result!.Value).IsTypeOf()).IsEqualTo(ExpectedIntegralValue); + } + + /// Verifies that floating-point JSON object values are inferred as . + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_InferFloatingPointObjectValues() + { + var result = SystemTextJsonSerializer.Deserialize( + """{"value":42.5}""", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(await Assert.That(result!.Value).IsTypeOf()).IsEqualTo(ExpectedFloatingPointValue); + } + + /// Verifies the default options read numeric properties from JSON strings. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_ReadNumbersFromString() + { + var result = SystemTextJsonSerializer.Deserialize( + """{"id":"123","amount":"9.99"}""", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(result!.Id).IsEqualTo(ExpectedId); + await Assert.That(result.Amount).IsEqualTo(ExpectedAmount); + } + + /// Verifies the default options still write numbers as JSON numbers, not strings. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_WriteNumbersAsNumbers() + { + var json = SystemTextJsonSerializer.Serialize( + new NumberContainer { Id = ExpectedId, Amount = ExpectedAmount }, + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(json).Contains("123", StringComparison.Ordinal); + await Assert.That(json).DoesNotContain("\"123\"", StringComparison.Ordinal); + } + + /// Verifies the default options expose . + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_NumberHandlingAllowsReadingFromString() => + await Assert + .That(SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions().NumberHandling) + .IsEqualTo(JsonNumberHandling.AllowReadingFromString); + + /// Verifies the fast-path options omit the settings that disable the System.Text.Json fast-path. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_FastPathOptions_AreFastPathEligible() + { + var options = SystemTextJsonContentSerializer.GetFastPathJsonSerializerOptions(); + + await Assert.That(options.Converters).IsEmpty(); + await Assert.That(options.NumberHandling).IsEqualTo(JsonNumberHandling.Strict); + await Assert.That(options.ReferenceHandler).IsNull(); + await Assert.That(options.Encoder).IsNull(); + await Assert.That(options.PropertyNamingPolicy).IsEqualTo(JsonNamingPolicy.CamelCase); + } + + /// Verifies that ISO date JSON object values are inferred as . + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_InferDateObjectValues() + { + var result = SystemTextJsonSerializer.Deserialize( + """{"value":"2024-01-02T03:04:05Z"}""", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(await Assert.That(result!.Value).IsTypeOf()) + .IsEqualTo(new(ExpectedYear, 1, ExpectedDay, ExpectedHour, ExpectedMinute, ExpectedSecond, DateTimeKind.Utc)); + } + + /// Verifies that string JSON object values are inferred as . + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_InferStringObjectValues() + { + var result = SystemTextJsonSerializer.Deserialize( + """{"value":"Road Runner"}""", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(await Assert.That(result!.Value).IsTypeOf()).IsEqualTo(RoadRunnerName); + } + + /// Verifies that nested JSON object values are deserialized as . + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializeObjectValuesAsJsonElements() + { + var result = SystemTextJsonSerializer.Deserialize( + """{"value":{"company":"ACME"}}""", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That((await Assert.That(result!.Value).IsTypeOf()).ValueKind) + .IsEqualTo(JsonValueKind.Object); + } + + /// Verifies that enum object values are serialized using camelCase. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_SerializeObjectEnumValuesAsCamelCase() + { + var json = SystemTextJsonSerializer.Serialize( + new ObjectValueContainer { Value = CamelCaseEnum.ValueOne }, + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(json).IsEqualTo("""{"value":"valueOne"}"""); + } + + /// Verifies that null object values are serialized as JSON null. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_SerializeNullObjectValuesAsJsonNull() + { + var json = SystemTextJsonSerializer.Serialize( + new ObjectValueContainer { Value = null }, + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(json).IsEqualTo("""{"value":null}"""); + } + + /// Verifies that the configured type info resolver is used when serializing object values. + /// A task that represents the asynchronous test operation. + [Test] + [SuppressMessage( + "Performance", + "PSH1416:Cache the serializer options instead of building them per call", + Justification = "The options embed a per-test resolver instance, so they cannot be cached in a shared static field.")] + public async Task SystemTextJsonContentSerializer_DefaultOptions_UseResolverWhenSerializingObjectValues() + { + var resolver = new TrackingTypeInfoResolver(ObjectValueContainerJsonSerializerContext.Default); + var options = new JsonSerializerOptions( + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()) + { + TypeInfoResolver = resolver + }; + + var json = SystemTextJsonSerializer.Serialize( + new ObjectValueContainer { Value = RoadRunnerName }, + options); + + await Assert.That(json).IsEqualTo("""{"value":"Road Runner"}"""); + await Assert.That(resolver.RequestedTypes).Contains(typeof(string)); + } + + /// Verifies that camelCase enum values are deserialized correctly. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializeCamelCaseEnumValues() + { + var result = SystemTextJsonSerializer.Deserialize( + "\"valueOne\"", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(result).IsEqualTo(CamelCaseEnum.ValueOne); + } + + /// Verifies that enum values are deserialized case-insensitively. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializeCaseInsensitiveEnumValues() + { + var result = SystemTextJsonSerializer.Deserialize( + "\"VALUEONE\"", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(result).IsEqualTo(CamelCaseEnum.ValueOne); + } + + /// Verifies that already-lowercase enum values are deserialized correctly. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializeLowercaseEnumValues() + { + var result = SystemTextJsonSerializer.Deserialize( + "\"alreadyLowercase\"", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(result).IsEqualTo(CamelCaseEnum.alreadyLowercase); + } + + /// Verifies that numeric enum values are deserialized correctly. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializeNumericEnumValues() + { + var result = SystemTextJsonSerializer.Deserialize( + "2", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(result).IsEqualTo(CamelCaseEnum.alreadyLowercase); + } + + /// Verifies that unsigned numeric enum values are deserialized correctly. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializeUnsignedNumericEnumValues() + { + var result = SystemTextJsonSerializer.Deserialize( + "18446744073709551615", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(result).IsEqualTo(UnsignedCamelCaseEnum.Large); + } + + /// Verifies that JSON null deserializes to a null nullable enum. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializeNullNullableEnumValues() + { + var result = SystemTextJsonSerializer.Deserialize( + "null", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(result).IsNull(); + } + + /// Verifies that an empty string deserializes to a null nullable enum. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializeEmptyNullableEnumValues() + { + var result = SystemTextJsonSerializer.Deserialize( + "\"\"", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(result).IsNull(); + } + + /// 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() => + await Assert.That( + static () => 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() => + await Assert.That( + static () => 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() => + await Assert.That( + static () => 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() => + await Assert.That( + static () => SystemTextJsonSerializer.Deserialize( + "true", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions())) + .ThrowsExactly(); + + /// Verifies that undefined enum values are serialized as numbers. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_SerializeUndefinedEnumValuesAsNumbers() + { + var json = SystemTextJsonSerializer.Serialize( + (CamelCaseEnum)UndefinedEnumValue, + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(json).IsEqualTo("999"); + } + + /// Verifies that undefined unsigned enum values are serialized as unsigned numbers. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_SerializeUndefinedUnsignedEnumValuesAsNumbers() + { + var json = SystemTextJsonSerializer.Serialize( + (UnsignedCamelCaseEnum)UndefinedUnsignedEnumValue, + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(json).IsEqualTo("9223372036854775808"); + } + + /// 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)UndefinedEnumValue] = "unknown" + }, + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(json).IsEqualTo("""{"999":"unknown"}"""); + } + + /// Verifies undefined unsigned enum dictionary keys are serialized with unsigned numeric property names. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_SerializeUndefinedUnsignedEnumDictionaryKeysAsNumbers() + { + var json = SystemTextJsonSerializer.Serialize( + new Dictionary + { + [(UnsignedCamelCaseEnum)UndefinedUnsignedEnumValue] = "unknown" + }, + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(json).IsEqualTo("""{"9223372036854775808":"unknown"}"""); + } + + /// Verifies that lowercase enum names are serialized unchanged. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_SerializeLowercaseEnumNamesUnchanged() + { + var json = SystemTextJsonSerializer.Serialize( + CamelCaseEnum.alreadyLowercase, + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(json).IsEqualTo("\"alreadyLowercase\""); + } + + /// Verifies that enum values are deserialized across a variety of casings. + /// The JSON string value to deserialize. + /// A task that represents the asynchronous test operation. + [Test] + [Arguments("vAlUeOnE")] + [Arguments("ValueOne")] + [Arguments("VALUEONE")] + [Arguments("valueone")] + public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializesEnumValuesWithVariousCasings( + string jsonValue) + { + var result = SystemTextJsonSerializer.Deserialize( + $"\"{jsonValue}\"", + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + await Assert.That(result).IsEqualTo(CamelCaseEnum.ValueOne); + } + + /// Verifies that an exact-case match takes priority over case-insensitive matching when members differ only by case. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DefaultOptions_ExactCaseMatchTakesPriorityOverCaseInsensitiveWhenMembersDifferByCase() + { + // When enum has members whose names differ only by case, the exact serialized form + // (camelCase) should be used first (case-sensitive), falling back to case-insensitive only + // for inputs that do not exactly match any known serialized form. + // CaseDifferentMembers.Alpha serializes to "alpha" (camelCase), + // CaseDifferentMembers.ALPHA serializes to "aLPHA" (camelCase). + // Exact-match lookups must correctly disambiguate these. + var options = SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions(); + + await Assert.That( + SystemTextJsonSerializer.Deserialize("\"alpha\"", options)) + .IsEqualTo(CaseDifferentMembers.Alpha); + await Assert.That( + SystemTextJsonSerializer.Deserialize("\"aLPHA\"", options)) + .IsEqualTo(CaseDifferentMembers.ALPHA); + + // Field names are also accepted via exact match. + await Assert.That( + SystemTextJsonSerializer.Deserialize("\"Alpha\"", options)) + .IsEqualTo(CaseDifferentMembers.Alpha); + await Assert.That( + SystemTextJsonSerializer.Deserialize("\"ALPHA\"", options)) + .IsEqualTo(CaseDifferentMembers.ALPHA); + } + + /// Verifies that source-generated metadata is used when provided to the serializer. + /// A task that represents the asynchronous test operation. + [Test] + [SuppressMessage( + "Performance", + "PSH1416:Cache the serializer options instead of building them per call", + Justification = "The options embed a per-test tracking resolver instance, so they cannot be cached in a shared static field.")] + public async Task SystemTextJsonContentSerializer_UsesSourceGeneratedMetadataWhenProvided() + { + var resolver = new TrackingTypeInfoResolver(SerializedContentJsonSerializerContext.Default); + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + TypeInfoResolver = resolver + }; + var serializer = new SystemTextJsonContentSerializer(options); + var model = new User + { + Name = RoadRunnerName, + Company = "ACME", + CreatedAt = "1949-09-17" + }; + + var content = serializer.ToHttpContent(model); + var roundTrip = await serializer.FromHttpContentAsync(content); + + await Assert.That(roundTrip).IsNotNull(); + await Assert.That(roundTrip!.Name).IsEqualTo(model.Name); + await Assert.That(roundTrip.Company).IsEqualTo(model.Company); + await Assert.That(roundTrip.CreatedAt).IsEqualTo(model.CreatedAt); + await Assert.That(resolver.RequestedTypes).Contains(typeof(User)); + } + + /// Verifies the synchronous DeserializeFromString uses source-generated metadata when provided (#1591). + /// A task that represents the asynchronous test operation. + [Test] + [SuppressMessage( + "Performance", + "PSH1416:Cache the serializer options instead of building them per call", + Justification = "The options embed a per-test tracking resolver instance, so they cannot be cached in a shared static field.")] + public async Task SystemTextJsonContentSerializer_DeserializeFromString_UsesSourceGeneratedMetadata() + { + var resolver = new TrackingTypeInfoResolver(SerializedContentJsonSerializerContext.Default); + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + TypeInfoResolver = resolver + }; + var serializer = new SystemTextJsonContentSerializer(options); + + var roundTrip = serializer.DeserializeFromString("{\"name\":\"Road Runner\"}"); + + await Assert.That(roundTrip).IsNotNull(); + await Assert.That(roundTrip!.Name).IsEqualTo(RoadRunnerName); + await Assert.That(resolver.RequestedTypes).Contains(typeof(User)); + } + + /// Verifies the synchronous DeserializeFromString uses reflection metadata by default (#1591). + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_DeserializeFromString_UsesReflectionByDefault() + { + var serializer = new SystemTextJsonContentSerializer(); + + var roundTrip = serializer.DeserializeFromString("{\"name\":\"Road Runner\"}"); + + await Assert.That(roundTrip).IsNotNull(); + await Assert.That(roundTrip!.Name).IsEqualTo(RoadRunnerName); + } + + /// Verifies that enum dictionary keys round-trip through the serializer. + /// A task that represents the asynchronous test operation. + [Test] + public async Task SystemTextJsonContentSerializer_RoundTripsEnumDictionaryKeys() + { + var serializer = new SystemTextJsonContentSerializer(); + + var source = new Dictionary + { + [CamelCaseEnum.ValueOne] = FirstEnumValue, + [CamelCaseEnum.alreadyLowercase] = "second" + }; + + var content = serializer.ToHttpContent(source); + var serialized = await content.ReadAsStringAsync(); + + var roundTrip = await serializer.FromHttpContentAsync>( + new StringContent(serialized, Encoding.UTF8, JsonMediaType)); + + await Assert.That(roundTrip).IsNotNull(); + await Assert.That(roundTrip![CamelCaseEnum.ValueOne]).IsEqualTo(FirstEnumValue); + 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. + [Test] + public async Task SystemTextJsonContentSerializer_SupportsJsonStringEnumMemberName() + { + var serializer = new SystemTextJsonContentSerializer( + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); + + var content = serializer.ToHttpContent( + new EnumMemberNameEnvelope { Status = EnumMemberNameStatus.TotallyReady }); + var serialized = await content.ReadAsStringAsync(); + var roundTrip = await serializer.FromHttpContentAsync( + new StringContent("{\"status\":\"totally-ready\"}", Encoding.UTF8, JsonMediaType)); + + await Assert.That(serialized).Contains("totally-ready"); + await Assert.That(roundTrip).IsNotNull(); + await Assert.That(roundTrip!.Status).IsEqualTo(EnumMemberNameStatus.TotallyReady); + } + + /// Verifies that RestService uses the default enum converter with JsonStringEnumMemberName. + /// A task that represents the asynchronous test operation. + [Test] + public async Task RestService_UsesDefaultEnumConverterWithJsonStringEnumMemberName() + { + var settings = new RefitSettings( + new SystemTextJsonContentSerializer( + SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions())) + { + HttpMessageHandlerFactory = static () => new StubHttpMessageHandler(static _ => + Task.FromResult( + new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent( + "{\"status\":\"totally-ready\"}", + Encoding.UTF8, + JsonMediaType) + })) + }; + + var api = RestService.For(BaseAddress, settings); + var result = await api.GetStatusAsync(); + + await Assert.That(result.Status).IsEqualTo(EnumMemberNameStatus.TotallyReady); + } + + /// Verifies that the default serializer honors JsonStringEnumMemberName with an attributed converter. + /// A task that represents the asynchronous test operation. + [Test] + public async Task RestService_DefaultSystemTextJsonSerializerHonorsJsonStringEnumMemberNameWithAttributedConverter() + { + var serializedBody = string.Empty; + var settings = new RefitSettings(new SystemTextJsonContentSerializer()) + { + HttpMessageHandlerFactory = () => new StubHttpMessageHandler(async request => + { + serializedBody = await request.Content!.ReadAsStringAsync(); + return new(System.Net.HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, JsonMediaType) + }; + }) + }; + + var api = RestService.For(BaseAddress, settings); + await api.PostColorAsync(new() { Color = EnumMemberNameColor.Green }); + + await Assert.That(serializedBody).IsEqualTo("""{"color":"GREEN"}"""); + } +#endif + + /// 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 = (JsonConverter)options.GetConverter( + typeof(CamelCaseEnum?)); + var reader = new Utf8JsonReader("""{"":"empty","valueOne":"first"}"""u8); + _ = 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(); + + // The nullable converter intentionally supports null keys by writing an empty property name. + converter.WriteAsPropertyName(writer, null!, options); + writer.WriteStringValue("empty"); + converter.WriteAsPropertyName(writer, CamelCaseEnum.ValueOne, options); + writer.WriteStringValue(FirstEnumValue); + 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 = (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()); + } +} diff --git a/src/tests/Refit.Tests/SerializedContentTests.cs b/src/tests/Refit.Tests/SerializedContentTests.cs index 5f0ceba81..51cebe1b8 100644 --- a/src/tests/Refit.Tests/SerializedContentTests.cs +++ b/src/tests/Refit.Tests/SerializedContentTests.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for full license information. using System; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Net; using System.Net.Http; using System.Reflection; @@ -13,8 +12,6 @@ using System.Text.Json.Serialization.Metadata; using System.Threading; using System.Threading.Tasks; -using SystemTextJsonSerializer = System.Text.Json.JsonSerializer; - namespace Refit.Tests; /// Tests that verify how Refit serializes request bodies and deserializes responses across content serializers. @@ -32,6 +29,12 @@ public partial class SerializedContentTests /// A sample weapon name reused across polymorphic body serialization assertions. private const string PhotonName = "Photon"; + /// The serialized JSON body asserted across the polymorphic serialization tests. + private const string PhotonJson = """{"name":"Photon"}"""; + + /// The first enum dictionary value reused across the camel-case serialization tests. + private const string FirstEnumValue = "first"; + /// The expected inferred integral value for object-value inference tests. private const long ExpectedIntegralValue = 42L; @@ -42,7 +45,7 @@ public partial class SerializedContentTests private const int ExpectedId = 123; /// The expected amount parsed from a numeric JSON string. - private const decimal ExpectedAmount = 9.99m; + private const decimal ExpectedAmount = 9.99M; /// The expected year component of the inferred date value. private const int ExpectedYear = 2024; @@ -59,6 +62,32 @@ public partial class SerializedContentTests /// The expected second component of the inferred date value. private const int ExpectedSecond = 5; + /// The year component of the sample created-at date used in body-serialization round-trips. + private const int SampleCreatedYear = 1949; + + /// The month component of the sample created-at date used in body-serialization round-trips. + private const int SampleCreatedMonth = 9; + + /// The day component of the sample created-at date used in body-serialization round-trips. + private const int SampleCreatedDay = 16; + + /// An undefined signed enum value used to verify serialization of unknown enum members. + private const int UndefinedEnumValue = 999; + + /// An undefined unsigned enum value (2^63) used to verify serialization of unknown unsigned enum members. + private const ulong UndefinedUnsignedEnumValue = 9_223_372_036_854_775_808UL; + + /// The circuit-breaker timeout, in seconds, that bounds a body-serialization task run. + private const int CircuitBreakerTimeoutSeconds = 30; + + /// Serializer options carrying the source-generated polymorphic context, shared by the declared-base body test. + private static readonly JsonSerializerOptions PolymorphicBaseSerializerOptions = + new(JsonSerializerDefaults.Web) { TypeInfoResolver = PolymorphicRequestJsonSerializerContext.Default }; + + /// Reflection-backed serializer options shared by the interface-body runtime-type test. + private static readonly JsonSerializerOptions ReflectionSerializerOptions = + new(JsonSerializerDefaults.Web) { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + #if NET9_0_OR_GREATER /// Status enum used to verify JsonStringEnumMemberName handling. public enum EnumMemberNameStatus @@ -119,10 +148,6 @@ private enum CaseDifferentMembers } /// Marker request type used to verify serialization when the declared type is an interface. - [SuppressMessage( - "Design", - "CA1040:Avoid empty interfaces", - Justification = "Intentional empty fixture interface used to verify Refit serialization when the declared type is an interface.")] [SuppressMessage( "Design", "SST1437:Add members to type or remove it", @@ -207,7 +232,7 @@ public async Task WhenARequestRequiresABodyThenItDoesNotDeadlock(Type contentSer var handler = new MockPushStreamContentHttpMessageHandler { - Asserts = async content => + Asserts = static async content => new StringContent(await content.ReadAsStringAsync().ConfigureAwait(false)) }; @@ -239,7 +264,7 @@ public async Task WhenARequestRequiresABodyThenItIsSerialized(Type contentSerial var model = new User { Name = "Wile E. Coyote", - CreatedAt = new DateOnly(1949, 9, 16).ToString(), + CreatedAt = new DateOnly(SampleCreatedYear, SampleCreatedMonth, SampleCreatedDay).ToString(), Company = "ACME", }; @@ -365,504 +390,6 @@ public async Task SystemTextJsonContentSerializer_GetFieldNameForProperty_Throws await Assert.That(exception!.ParamName).IsEqualTo("propertyInfo"); } - /// Verifies that boolean JSON object values are inferred as . - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_InferBooleanObjectValues() - { - var result = SystemTextJsonSerializer.Deserialize( - """{"value":true}""", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(await Assert.That(result!.Value).IsTypeOf()).IsTrue(); - } - -#if NET10_0_OR_GREATER - /// Verifies duplicate JSON properties are rejected by the default serializer options. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_RejectDuplicateProperties() - { - var options = SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions(); - - await Assert - .That(() => SystemTextJsonSerializer.Deserialize("""{"value":1,"value":2}""", options)) - .ThrowsExactly(); - } - -#endif - - /// 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] - public async Task SystemTextJsonContentSerializer_DefaultOptions_InferIntegralObjectValues() - { - var result = SystemTextJsonSerializer.Deserialize( - """{"value":42}""", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(await Assert.That(result!.Value).IsTypeOf()).IsEqualTo(ExpectedIntegralValue); - } - - /// Verifies that floating-point JSON object values are inferred as . - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_InferFloatingPointObjectValues() - { - var result = SystemTextJsonSerializer.Deserialize( - """{"value":42.5}""", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(await Assert.That(result!.Value).IsTypeOf()).IsEqualTo(ExpectedFloatingPointValue); - } - - /// Verifies the default options read numeric properties from JSON strings. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_ReadNumbersFromString() - { - var result = SystemTextJsonSerializer.Deserialize( - """{"id":"123","amount":"9.99"}""", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(result!.Id).IsEqualTo(ExpectedId); - await Assert.That(result.Amount).IsEqualTo(ExpectedAmount); - } - - /// Verifies the default options still write numbers as JSON numbers, not strings. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_WriteNumbersAsNumbers() - { - var json = SystemTextJsonSerializer.Serialize( - new NumberContainer { Id = 123, Amount = 9.99m }, - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(json).Contains("123", StringComparison.Ordinal); - await Assert.That(json).DoesNotContain("\"123\"", StringComparison.Ordinal); - } - - /// Verifies the default options expose . - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_NumberHandlingAllowsReadingFromString() => - await Assert - .That(SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions().NumberHandling) - .IsEqualTo(JsonNumberHandling.AllowReadingFromString); - - /// Verifies the fast-path options omit the settings that disable the System.Text.Json fast-path. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_FastPathOptions_AreFastPathEligible() - { - var options = SystemTextJsonContentSerializer.GetFastPathJsonSerializerOptions(); - - await Assert.That(options.Converters).IsEmpty(); - await Assert.That(options.NumberHandling).IsEqualTo(JsonNumberHandling.Strict); - await Assert.That(options.ReferenceHandler).IsNull(); - await Assert.That(options.Encoder).IsNull(); - await Assert.That(options.PropertyNamingPolicy).IsEqualTo(JsonNamingPolicy.CamelCase); - } - - /// Verifies that ISO date JSON object values are inferred as . - /// A task that represents the asynchronous test operation. - [Test] - [SuppressMessage( - "Major Code Smell", - "S6566:Prefer using \"DateTimeOffset\" instead of \"DateTime\"", - Justification = "The serializer under test infers and returns a DateTime; the expected value must match that type.")] - public async Task SystemTextJsonContentSerializer_DefaultOptions_InferDateObjectValues() - { - var result = SystemTextJsonSerializer.Deserialize( - """{"value":"2024-01-02T03:04:05Z"}""", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(await Assert.That(result!.Value).IsTypeOf()) - .IsEqualTo(new(ExpectedYear, 1, ExpectedDay, ExpectedHour, ExpectedMinute, ExpectedSecond, DateTimeKind.Utc)); - } - - /// Verifies that string JSON object values are inferred as . - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_InferStringObjectValues() - { - var result = SystemTextJsonSerializer.Deserialize( - """{"value":"Road Runner"}""", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(await Assert.That(result!.Value).IsTypeOf()).IsEqualTo(RoadRunnerName); - } - - /// Verifies that nested JSON object values are deserialized as . - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializeObjectValuesAsJsonElements() - { - var result = SystemTextJsonSerializer.Deserialize( - """{"value":{"company":"ACME"}}""", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That((await Assert.That(result!.Value).IsTypeOf()).ValueKind) - .IsEqualTo(JsonValueKind.Object); - } - - /// Verifies that enum object values are serialized using camelCase. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_SerializeObjectEnumValuesAsCamelCase() - { - var json = SystemTextJsonSerializer.Serialize( - new ObjectValueContainer { Value = CamelCaseEnum.ValueOne }, - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(json).IsEqualTo("""{"value":"valueOne"}"""); - } - - /// Verifies that null object values are serialized as JSON null. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_SerializeNullObjectValuesAsJsonNull() - { - var json = SystemTextJsonSerializer.Serialize( - new ObjectValueContainer { Value = null }, - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(json).IsEqualTo("""{"value":null}"""); - } - - /// Verifies that the configured type info resolver is used when serializing object values. - /// A task that represents the asynchronous test operation. - [Test] - [SuppressMessage( - "Performance", - "CA1869:Cache and reuse 'JsonSerializerOptions' instances", - Justification = "The options embed a per-test resolver instance, so they cannot be cached in a shared static field.")] - public async Task SystemTextJsonContentSerializer_DefaultOptions_UseResolverWhenSerializingObjectValues() - { - var resolver = new TrackingTypeInfoResolver(ObjectValueContainerJsonSerializerContext.Default); - var options = new JsonSerializerOptions( - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()) - { - TypeInfoResolver = resolver - }; - - var json = SystemTextJsonSerializer.Serialize( - new ObjectValueContainer { Value = RoadRunnerName }, - options); - - await Assert.That(json).IsEqualTo("""{"value":"Road Runner"}"""); - await Assert.That(resolver.RequestedTypes).Contains(typeof(string)); - } - - /// Verifies that camelCase enum values are deserialized correctly. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializeCamelCaseEnumValues() - { - var result = SystemTextJsonSerializer.Deserialize( - "\"valueOne\"", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(result).IsEqualTo(CamelCaseEnum.ValueOne); - } - - /// Verifies that enum values are deserialized case-insensitively. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializeCaseInsensitiveEnumValues() - { - var result = SystemTextJsonSerializer.Deserialize( - "\"VALUEONE\"", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(result).IsEqualTo(CamelCaseEnum.ValueOne); - } - - /// Verifies that already-lowercase enum values are deserialized correctly. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializeLowercaseEnumValues() - { - var result = SystemTextJsonSerializer.Deserialize( - "\"alreadyLowercase\"", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(result).IsEqualTo(CamelCaseEnum.alreadyLowercase); - } - - /// Verifies that numeric enum values are deserialized correctly. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializeNumericEnumValues() - { - var result = SystemTextJsonSerializer.Deserialize( - "2", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(result).IsEqualTo(CamelCaseEnum.alreadyLowercase); - } - - /// Verifies that unsigned numeric enum values are deserialized correctly. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializeUnsignedNumericEnumValues() - { - var result = SystemTextJsonSerializer.Deserialize( - "18446744073709551615", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(result).IsEqualTo(UnsignedCamelCaseEnum.Large); - } - - /// Verifies that JSON null deserializes to a null nullable enum. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializeNullNullableEnumValues() - { - var result = SystemTextJsonSerializer.Deserialize( - "null", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(result).IsNull(); - } - - /// Verifies that an empty string deserializes to a null nullable enum. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializeEmptyNullableEnumValues() - { - var result = SystemTextJsonSerializer.Deserialize( - "\"\"", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(result).IsNull(); - } - - /// 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() => - await Assert.That( - static () => 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() => - await Assert.That( - static () => 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() => - await Assert.That( - static () => 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() => - await Assert.That( - static () => SystemTextJsonSerializer.Deserialize( - "true", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions())) - .ThrowsExactly(); - - /// Verifies that undefined enum values are serialized as numbers. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_SerializeUndefinedEnumValuesAsNumbers() - { - var json = SystemTextJsonSerializer.Serialize( - (CamelCaseEnum)999, - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(json).IsEqualTo("999"); - } - - /// Verifies that undefined unsigned enum values are serialized as unsigned numbers. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_SerializeUndefinedUnsignedEnumValuesAsNumbers() - { - var json = SystemTextJsonSerializer.Serialize( - (UnsignedCamelCaseEnum)9_223_372_036_854_775_808UL, - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(json).IsEqualTo("9223372036854775808"); - } - - /// 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 undefined unsigned enum dictionary keys are serialized with unsigned numeric property names. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_SerializeUndefinedUnsignedEnumDictionaryKeysAsNumbers() - { - var json = SystemTextJsonSerializer.Serialize( - new Dictionary - { - [(UnsignedCamelCaseEnum)9_223_372_036_854_775_808UL] = "unknown" - }, - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(json).IsEqualTo("""{"9223372036854775808":"unknown"}"""); - } - - /// Verifies that lowercase enum names are serialized unchanged. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_SerializeLowercaseEnumNamesUnchanged() - { - var json = SystemTextJsonSerializer.Serialize( - CamelCaseEnum.alreadyLowercase, - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(json).IsEqualTo("\"alreadyLowercase\""); - } - - /// Verifies that enum values are deserialized across a variety of casings. - /// The JSON string value to deserialize. - /// A task that represents the asynchronous test operation. - [Test] - [Arguments("vAlUeOnE")] - [Arguments("ValueOne")] - [Arguments("VALUEONE")] - [Arguments("valueone")] - public async Task SystemTextJsonContentSerializer_DefaultOptions_DeserializesEnumValuesWithVariousCasings( - string jsonValue) - { - var result = SystemTextJsonSerializer.Deserialize( - $"\"{jsonValue}\"", - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - await Assert.That(result).IsEqualTo(CamelCaseEnum.ValueOne); - } - - /// Verifies that an exact-case match takes priority over case-insensitive matching when members differ only by case. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DefaultOptions_ExactCaseMatchTakesPriorityOverCaseInsensitiveWhenMembersDifferByCase() - { - // When enum has members whose names differ only by case, the exact serialized form - // (camelCase) should be used first (case-sensitive), falling back to case-insensitive only - // for inputs that do not exactly match any known serialized form. - // CaseDifferentMembers.Alpha serializes to "alpha" (camelCase), - // CaseDifferentMembers.ALPHA serializes to "aLPHA" (camelCase). - // Exact-match lookups must correctly disambiguate these. - var options = SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions(); - - await Assert.That( - SystemTextJsonSerializer.Deserialize("\"alpha\"", options)) - .IsEqualTo(CaseDifferentMembers.Alpha); - await Assert.That( - SystemTextJsonSerializer.Deserialize("\"aLPHA\"", options)) - .IsEqualTo(CaseDifferentMembers.ALPHA); - - // Field names are also accepted via exact match. - await Assert.That( - SystemTextJsonSerializer.Deserialize("\"Alpha\"", options)) - .IsEqualTo(CaseDifferentMembers.Alpha); - await Assert.That( - SystemTextJsonSerializer.Deserialize("\"ALPHA\"", options)) - .IsEqualTo(CaseDifferentMembers.ALPHA); - } - - /// Verifies that source-generated metadata is used when provided to the serializer. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_UsesSourceGeneratedMetadataWhenProvided() - { - var resolver = new TrackingTypeInfoResolver(SerializedContentJsonSerializerContext.Default); - var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) - { - TypeInfoResolver = resolver - }; - var serializer = new SystemTextJsonContentSerializer(options); - var model = new User - { - Name = RoadRunnerName, - Company = "ACME", - CreatedAt = "1949-09-17" - }; - - var content = serializer.ToHttpContent(model); - var roundTrip = await serializer.FromHttpContentAsync(content); - - await Assert.That(roundTrip).IsNotNull(); - await Assert.That(roundTrip!.Name).IsEqualTo(model.Name); - await Assert.That(roundTrip.Company).IsEqualTo(model.Company); - await Assert.That(roundTrip.CreatedAt).IsEqualTo(model.CreatedAt); - await Assert.That(resolver.RequestedTypes).Contains(typeof(User)); - } - - /// Verifies the synchronous DeserializeFromString uses source-generated metadata when provided (#1591). - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DeserializeFromString_UsesSourceGeneratedMetadata() - { - var resolver = new TrackingTypeInfoResolver(SerializedContentJsonSerializerContext.Default); - var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) - { - TypeInfoResolver = resolver - }; - var serializer = new SystemTextJsonContentSerializer(options); - - var roundTrip = serializer.DeserializeFromString("{\"name\":\"Road Runner\"}"); - - await Assert.That(roundTrip).IsNotNull(); - await Assert.That(roundTrip!.Name).IsEqualTo(RoadRunnerName); - await Assert.That(resolver.RequestedTypes).Contains(typeof(User)); - } - - /// Verifies the synchronous DeserializeFromString uses reflection metadata by default (#1591). - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_DeserializeFromString_UsesReflectionByDefault() - { - var serializer = new SystemTextJsonContentSerializer(); - - var roundTrip = serializer.DeserializeFromString("{\"name\":\"Road Runner\"}"); - - await Assert.That(roundTrip).IsNotNull(); - await Assert.That(roundTrip!.Name).IsEqualTo(RoadRunnerName); - } - /// Verifies that RestService can use source-generated System.Text.Json metadata. /// A task that represents the asynchronous test operation. [Test] @@ -904,11 +431,7 @@ public async Task RestService_SerializesBodyUsingDeclaredPolymorphicBaseType() { string? serializedBody = null; var settings = new RefitSettings( - new SystemTextJsonContentSerializer( - new(JsonSerializerDefaults.Web) - { - TypeInfoResolver = PolymorphicRequestJsonSerializerContext.Default - })) + new SystemTextJsonContentSerializer(PolymorphicBaseSerializerOptions)) { HttpMessageHandlerFactory = () => new StubHttpMessageHandler(async request => { @@ -936,7 +459,7 @@ public async Task RestService_SerializesBodyUsingResolverPolymorphismMetadata() string? serializedBody = null; var resolver = new DefaultJsonTypeInfoResolver(); resolver.Modifiers.Add( - typeInfo => + static typeInfo => { if (typeInfo.Type != typeof(ResolverPolymorphicRequest)) { @@ -999,7 +522,7 @@ public async Task RestService_SerializesBodyUsingRuntimeTypeWhenDeclaredTypeIsIn await api.CreateWeapon(new InterfaceLaserWeaponRequest { Name = PhotonName }); await Assert.That(serializedBody).IsNotNull(); - await Assert.That(serializedBody).IsEqualTo("""{"name":"Photon"}"""); + await Assert.That(serializedBody).IsEqualTo(PhotonJson); } /// Verifies resolver-backed options use runtime metadata when an interface body has no polymorphism metadata. @@ -1009,11 +532,7 @@ public async Task RestService_SerializesInterfaceBodyUsingRuntimeTypeWithResolve { string? serializedBody = null; var settings = new RefitSettings( - new SystemTextJsonContentSerializer( - new(JsonSerializerDefaults.Web) - { - TypeInfoResolver = new DefaultJsonTypeInfoResolver() - })) + new SystemTextJsonContentSerializer(ReflectionSerializerOptions)) { HttpMessageHandlerFactory = () => new StubHttpMessageHandler(async request => { @@ -1029,7 +548,7 @@ public async Task RestService_SerializesInterfaceBodyUsingRuntimeTypeWithResolve await api.CreateWeapon(new InterfaceLaserWeaponRequest { Name = PhotonName }); await Assert.That(serializedBody).IsNotNull(); - await Assert.That(serializedBody).IsEqualTo("""{"name":"Photon"}"""); + await Assert.That(serializedBody).IsEqualTo(PhotonJson); } /// Verifies that a request body uses the runtime type when the declared type is abstract. @@ -1054,7 +573,7 @@ public async Task RestService_SerializesBodyUsingRuntimeTypeWhenDeclaredTypeIsAb await api.CreateWeapon(new AbstractLaserWeaponRequest { Name = PhotonName }); await Assert.That(serializedBody).IsNotNull(); - await Assert.That(serializedBody).IsEqualTo("""{"name":"Photon"}"""); + await Assert.That(serializedBody).IsEqualTo(PhotonJson); } /// Verifies that a bare object is serialized as empty JSON. @@ -1070,188 +589,20 @@ public async Task SystemTextJsonContentSerializer_SerializesBareObjectAsEmptyJso await Assert.That(serialized).IsEqualTo("{}"); } - /// Verifies that enum dictionary keys round-trip through the serializer. - /// A task that represents the asynchronous test operation. - [Test] - public async Task SystemTextJsonContentSerializer_RoundTripsEnumDictionaryKeys() - { - var serializer = new SystemTextJsonContentSerializer(); - - var source = new Dictionary - { - [CamelCaseEnum.ValueOne] = "first", - [CamelCaseEnum.alreadyLowercase] = "second" - }; - - var content = serializer.ToHttpContent(source); - var serialized = await content.ReadAsStringAsync(); - - var roundTrip = await serializer.FromHttpContentAsync>( - new StringContent(serialized, Encoding.UTF8, JsonMediaType)); - - await Assert.That(roundTrip).IsNotNull(); - await Assert.That(roundTrip![CamelCaseEnum.ValueOne]).IsEqualTo("first"); - 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. - [Test] - public async Task SystemTextJsonContentSerializer_SupportsJsonStringEnumMemberName() - { - var serializer = new SystemTextJsonContentSerializer( - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions()); - - var content = serializer.ToHttpContent( - new EnumMemberNameEnvelope { Status = EnumMemberNameStatus.TotallyReady }); - var serialized = await content.ReadAsStringAsync(); - var roundTrip = await serializer.FromHttpContentAsync( - new StringContent("{\"status\":\"totally-ready\"}", Encoding.UTF8, JsonMediaType)); - - await Assert.That(serialized).Contains("totally-ready"); - await Assert.That(roundTrip).IsNotNull(); - await Assert.That(roundTrip!.Status).IsEqualTo(EnumMemberNameStatus.TotallyReady); - } - - /// Verifies that RestService uses the default enum converter with JsonStringEnumMemberName. - /// A task that represents the asynchronous test operation. - [Test] - public async Task RestService_UsesDefaultEnumConverterWithJsonStringEnumMemberName() - { - var settings = new RefitSettings( - new SystemTextJsonContentSerializer( - SystemTextJsonContentSerializer.GetDefaultJsonSerializerOptions())) - { - HttpMessageHandlerFactory = static () => new StubHttpMessageHandler(static _ => - Task.FromResult( - new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent( - "{\"status\":\"totally-ready\"}", - Encoding.UTF8, - JsonMediaType) - })) - }; - - var api = RestService.For(BaseAddress, settings); - var result = await api.GetStatusAsync(); - - await Assert.That(result.Status).IsEqualTo(EnumMemberNameStatus.TotallyReady); - } - - /// Verifies that the default serializer honors JsonStringEnumMemberName with an attributed converter. - /// A task that represents the asynchronous test operation. - [Test] - public async Task RestService_DefaultSystemTextJsonSerializerHonorsJsonStringEnumMemberNameWithAttributedConverter() - { - var serializedBody = string.Empty; - var settings = new RefitSettings(new SystemTextJsonContentSerializer()) - { - HttpMessageHandlerFactory = () => new StubHttpMessageHandler(async request => - { - serializedBody = await request.Content!.ReadAsStringAsync(); - return new(HttpStatusCode.OK) - { - Content = new StringContent("{}", Encoding.UTF8, JsonMediaType) - }; - }) - }; - - var api = RestService.For(BaseAddress, settings); - await api.PostColorAsync(new() { Color = EnumMemberNameColor.Green }); - - await Assert.That(serializedBody).IsEqualTo("""{"color":"GREEN"}"""); - } -#endif - /// Runs the task to completion or until the timeout occurs. /// The fixture task to run within the time limit. /// The original fixture task once it completes or the timeout elapses. private static async Task> RunTaskWithATimeLimit(Task fixtureTask) { - var circuitBreakerTask = Task.Delay(TimeSpan.FromSeconds(30)); + var circuitBreakerTask = Task.Delay(TimeSpan.FromSeconds(CircuitBreakerTimeoutSeconds)); await Task.WhenAny(fixtureTask, circuitBreakerTask); 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 = (JsonConverter)options.GetConverter( - typeof(CamelCaseEnum?)); - var reader = new Utf8JsonReader("""{"":"empty","valueOne":"first"}"""u8); - _ = 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(); - - // The nullable converter intentionally supports null keys by writing an empty property name. - converter.WriteAsPropertyName(writer, null!, options); - 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 = (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")] - public abstract class CreateWeaponRequest + public class CreateWeaponRequest { /// Gets or sets the weapon name. public string? Name { get; set; } @@ -1268,7 +619,7 @@ public sealed class InterfaceLaserWeaponRequest : InterfaceCreateWeaponRequest } /// Base request whose polymorphism metadata is supplied by a resolver in tests. - public abstract class ResolverPolymorphicRequest + public class ResolverPolymorphicRequest { /// Gets or sets the weapon name. public string? Name { get; set; } @@ -1278,17 +629,25 @@ public abstract class ResolverPolymorphicRequest public sealed class ResolverLaserWeaponRequest : ResolverPolymorphicRequest; /// Marker abstract request used to verify serialization when the declared type is abstract. - [SuppressMessage( - "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; + public abstract class AbstractCreateWeaponRequest + { + /// Names the concrete weapon kind; a contract member the serializer never emits because it ignores methods. + /// The concrete weapon discriminator. + public abstract string DescribeWeapon(); + + /// Returns the request's type name; a concrete member so this stays an abstract base class rather than an interface. + /// The type name of the request. + public override string ToString() => nameof(AbstractCreateWeaponRequest); + } /// Concrete request derived from . public sealed class AbstractLaserWeaponRequest : AbstractCreateWeaponRequest { /// Gets or sets the weapon name. public string? Name { get; set; } + + /// + public override string DescribeWeapon() => "laser"; } #if NET9_0_OR_GREATER diff --git a/src/tests/Refit.Tests/StjFilter.cs b/src/tests/Refit.Tests/StjFilter.cs new file mode 100644 index 000000000..1ef82214f --- /dev/null +++ b/src/tests/Refit.Tests/StjFilter.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. +using System.Text.Json.Serialization; + +namespace Refit.Tests; + +/// A filter flattened by in the converter tests. +public sealed class StjFilter +{ + /// Gets or sets a value renamed by System.Text.Json. + [JsonPropertyName("q")] + public string? Query { get; set; } + + /// Gets or sets a numeric value. + public int Count { get; set; } + + /// Gets or sets a nested object flattened under a dotted key. + public StjNested? Sub { get; set; } +} diff --git a/src/tests/Refit.Tests/StjNested.cs b/src/tests/Refit.Tests/StjNested.cs new file mode 100644 index 000000000..4813afbd0 --- /dev/null +++ b/src/tests/Refit.Tests/StjNested.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; + +/// A nested filter object flattened under a dotted key by . +public sealed class StjNested +{ + /// Gets or sets the city. + public string? City { get; set; } +} diff --git a/src/tests/Refit.Tests/StreamingResponseTests.cs b/src/tests/Refit.Tests/StreamingResponseTests.cs index b8fab570a..1441d1f5d 100644 --- a/src/tests/Refit.Tests/StreamingResponseTests.cs +++ b/src/tests/Refit.Tests/StreamingResponseTests.cs @@ -20,6 +20,12 @@ public class StreamingResponseTests /// The base address used by the streaming test clients. private const string BaseUrl = "http://foo"; + /// A single-element JSON array body used by the streaming tests. + private const string SingleItemJson = "[{\"id\":2}]"; + + /// A three-element JSON array body used by the streaming tests. + private const string ThreeItemJson = "[{\"id\":1},{\"id\":2},{\"id\":3}]"; + /// Expected element id value 2 in streamed payloads. private const int ExpectedId2 = 2; @@ -49,7 +55,7 @@ public class StreamingResponseTests [Test] public async Task StreamsJsonArrayElements() { - var fixture = CreateFixture(JsonMediaType, "[{\"id\":1},{\"id\":2},{\"id\":3}]"); + var fixture = CreateFixture(JsonMediaType, ThreeItemJson); var ids = new List(); await foreach (var item in fixture.GetArray()) @@ -169,7 +175,7 @@ await Assert [Test] public async Task HonorsCancellationToken() { - var fixture = CreateFixture(JsonMediaType, "[{\"id\":1},{\"id\":2},{\"id\":3}]"); + var fixture = CreateFixture(JsonMediaType, ThreeItemJson); using var cts = new CancellationTokenSource(); await cts.CancelAsync(); @@ -209,7 +215,8 @@ public async Task StreamsJsonArrayWithSourceGenContext() [Test] public async Task StreamsJsonLinesWithSourceGenContextAndReaderEdges() { - var bigGap = new string(' ', 5000); + const int bufferExceedingGapLength = 5000; + var bigGap = new string(' ', bufferExceedingGapLength); // Includes a whitespace-only line (skipped), a line larger than the read buffer, CRLF endings, and no final newline. var payload = "{\"id\":1}\r\n \r\n{" + bigGap + "\"id\":2}\r\n{\"id\":3}"; @@ -232,7 +239,7 @@ public async Task StreamsJsonLinesWithSourceGenContextAndReaderEdges() [Test] public async Task JsonArrayStreamDisposesEnumeratorEarly() { - var fixture = CreateFixture(JsonMediaType, "[{\"id\":1},{\"id\":2},{\"id\":3}]"); + var fixture = CreateFixture(JsonMediaType, ThreeItemJson); await using var enumerator = fixture.GetArray().GetAsyncEnumerator(); @@ -302,7 +309,7 @@ public async Task StreamsViaReflectionPathWithCancellationToken() [Test] public async Task ReflectionRequestBuilderStreamsAsyncEnumerable() { - var (builder, client) = CreateReflectionBuilder("[{\"id\":2}]"); + var (builder, client) = CreateReflectionBuilder(SingleItemJson); var func = builder.BuildRestResultFuncForMethod(nameof(IStreamingApi.GetArray)); var ids = new List(); @@ -366,7 +373,7 @@ await Assert.That(() => EnumerateAsync((IAsyncEnumerable)func(clien [Test] public async Task ReflectionRequestBuilderStreamingDisposesWithoutMoving() { - var (builder, client) = CreateReflectionBuilder("[{\"id\":2}]"); + var (builder, client) = CreateReflectionBuilder(SingleItemJson); var func = builder.BuildRestResultFuncForMethod(nameof(IStreamingApi.GetArray)); var sequence = (IAsyncEnumerable)func(client, [])!; @@ -381,7 +388,7 @@ public async Task ReflectionRequestBuilderStreamingDisposesWithoutMoving() [Test] public async Task ReflectionRequestBuilderStreamingPropagatesCancellation() { - var (builder, client) = CreateReflectionBuilder("[{\"id\":2}]"); + var (builder, client) = CreateReflectionBuilder(SingleItemJson); var func = builder.BuildRestResultFuncForMethod(nameof(IStreamingApi.GetArrayCancellable)); using var cancellationTokenSource = new CancellationTokenSource(); await cancellationTokenSource.CancelAsync(); diff --git a/src/tests/Refit.Tests/StringHelpersTests.cs b/src/tests/Refit.Tests/StringHelpersTests.cs new file mode 100644 index 000000000..364a10947 --- /dev/null +++ b/src/tests/Refit.Tests/StringHelpersTests.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. + +namespace Refit.Tests; + +/// Verifies the slice-escaping helper of . +public sealed class StringHelpersTests +{ + /// The start index of the slice escaped by the fixture. + private const int SliceStart = 2; + + /// The length of the slice escaped by the fixture. + private const int SliceLength = 3; + + /// The initial capacity of the value string builder under test. + private const int BuilderInitialCapacity = 16; + + /// Verifies the slice overload escapes only the requested span of the value. + /// A task representing the asynchronous test. + [Test] + public async Task EscapeDataStringEscapesRequestedSlice() + { + var result = StringHelpers.EscapeDataString("ab c/d", SliceStart, SliceLength); + + await Assert.That(result).IsEqualTo("%20c%2F"); + } + + /// Verifies the in-place span escaper defers a non-ASCII value to the framework escaper, matching + /// 's UTF-8 percent-encoding. + /// A task representing the asynchronous test. + [Test] + public async Task AppendUriDataEscapedEscapesNonAsciiValue() + { + var target = new ValueStringBuilder(BuilderInitialCapacity); + StringHelpers.AppendUriDataEscaped(ref target, "café".AsSpan()); + + // ToString returns the rented buffer to the pool. + await Assert.That(target.ToString()).IsEqualTo("caf%C3%A9"); + } + + /// Verifies the in-place span escaper classifies every RFC 3986 unreserved character range: an + /// upper-case letter, a lower-case letter, a digit, the four punctuation unreserved characters, and a + /// reserved character (the space) that percent-encodes. The chosen characters straddle each range boundary so + /// both outcomes of every classifier comparison are exercised, matching . + /// A task representing the asynchronous test. + [Test] + public async Task AppendUriDataEscapedPreservesUnreservedCharactersAndEscapesReserved() + { + // 'M' (upper), 'z' (past 'Z', lower), '~' (past 'z', unreserved punctuation), '0' (below 'A', digit), + // '-' (below '0'), '.', '_', and ' ' (unreserved on neither side -> percent-encoded). + const string unreservedAndSpace = "Mz~0-._ "; + var target = new ValueStringBuilder(BuilderInitialCapacity); + StringHelpers.AppendUriDataEscaped(ref target, unreservedAndSpace.AsSpan()); + + var escaped = target.ToString(); + await Assert.That(escaped).IsEqualTo("Mz~0-._%20"); + await Assert.That(escaped).IsEqualTo(Uri.EscapeDataString(unreservedAndSpace)); + } + + /// Verifies the in-place span escaper matches across the whole + /// printable ASCII range, straddling every RFC 3986 unreserved-character range boundary so both outcomes of each + /// classifier comparison are exercised. + /// A task representing the asynchronous test. + [Test] + public async Task AppendUriDataEscapedMatchesFrameworkEscaperAcrossPrintableAscii() + { + var printableAscii = string.Concat(Enumerable.Range(0x20, 0x7F - 0x20).Select(static c => (char)c)); + var target = new ValueStringBuilder(BuilderInitialCapacity); + StringHelpers.AppendUriDataEscaped(ref target, printableAscii.AsSpan()); + + await Assert.That(target.ToString()).IsEqualTo(Uri.EscapeDataString(printableAscii)); + } +} diff --git a/src/tests/Refit.Tests/StructQueryObject.cs b/src/tests/Refit.Tests/StructQueryObject.cs new file mode 100644 index 000000000..7f8578f6f --- /dev/null +++ b/src/tests/Refit.Tests/StructQueryObject.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. + +namespace Refit.Tests; + +/// A value-typed query object, which is flattened inline because it can have no derived type. +public readonly record struct StructQueryObject +{ + /// Gets the identifier. + public int Id { get; init; } + + /// Gets the tag. + public string? Tag { get; init; } +} diff --git a/src/tests/Refit.Tests/SynchronousBodySerializationTests.cs b/src/tests/Refit.Tests/SynchronousBodySerializationTests.cs index 009d9cc81..b1899c7b2 100644 --- a/src/tests/Refit.Tests/SynchronousBodySerializationTests.cs +++ b/src/tests/Refit.Tests/SynchronousBodySerializationTests.cs @@ -20,7 +20,8 @@ public class SynchronousBodySerializationTests [Test] public async Task BufferedInlinePathSerializesBufferedJson() { - var capture = await PostAsync(RequestBodySerializationMode.Buffered, static api => api.PostItem(new() { Id = 42 })); + const int itemId = 42; + var capture = await PostAsync(RequestBodySerializationMode.Buffered, static api => api.PostItem(new() { Id = itemId })); await Assert.That(capture.MediaType).IsEqualTo(JsonMediaType); await Assert.That(capture.ContentLength).IsNotNull(); @@ -32,7 +33,8 @@ public async Task BufferedInlinePathSerializesBufferedJson() [Test] public async Task BufferedReflectionPathSerializesBufferedJson() { - var capture = await PostAsync(RequestBodySerializationMode.Buffered, static api => api.PostItemReflected(new StreamItem { Id = 7 })); + const int itemId = 7; + var capture = await PostAsync(RequestBodySerializationMode.Buffered, static api => api.PostItemReflected(new StreamItem { Id = itemId })); await Assert.That(capture.MediaType).IsEqualTo(JsonMediaType); await Assert.That(capture.ContentLength).IsNotNull(); @@ -44,7 +46,8 @@ public async Task BufferedReflectionPathSerializesBufferedJson() [Test] public async Task StreamedInlinePathSerializesWithoutContentLength() { - var capture = await PostAsync(RequestBodySerializationMode.Streamed, static api => api.PostItem(new() { Id = 99 })); + const int itemId = 99; + var capture = await PostAsync(RequestBodySerializationMode.Streamed, static api => api.PostItem(new() { Id = itemId })); await Assert.That(capture.MediaType).IsEqualTo(JsonMediaType); await Assert.That(capture.ContentLength).IsNull(); @@ -56,7 +59,8 @@ public async Task StreamedInlinePathSerializesWithoutContentLength() [Test] public async Task StreamedReflectionPathSerializesWithoutContentLength() { - var capture = await PostAsync(RequestBodySerializationMode.Streamed, static api => api.PostItemReflected(new StreamItem { Id = 13 })); + const int itemId = 13; + var capture = await PostAsync(RequestBodySerializationMode.Streamed, static api => api.PostItemReflected(new StreamItem { Id = itemId })); await Assert.That(capture.MediaType).IsEqualTo(JsonMediaType); await Assert.That(capture.ContentLength).IsNull(); @@ -68,9 +72,10 @@ public async Task StreamedReflectionPathSerializesWithoutContentLength() [Test] public async Task SourceGenBufferedSerializesBody() { + const int itemId = 21; var capture = await PostAsync( RequestBodySerializationMode.Buffered, - static api => api.PostItem(new() { Id = 21 }), + static api => api.PostItem(new() { Id = itemId }), new SystemTextJsonContentSerializer(StreamingJsonContext.Default.Options)); await Assert.That(capture.ContentLength).IsNotNull(); @@ -82,9 +87,10 @@ public async Task SourceGenBufferedSerializesBody() [Test] public async Task SourceGenStreamedSerializesBody() { + const int itemId = 34; var capture = await PostAsync( RequestBodySerializationMode.Streamed, - static api => api.PostItem(new() { Id = 34 }), + static api => api.PostItem(new() { Id = itemId }), new SystemTextJsonContentSerializer(StreamingJsonContext.Default.Options)); await Assert.That(capture.ContentLength).IsNull(); diff --git a/src/tests/Refit.Tests/SystemTextJsonQueryConverterTests.cs b/src/tests/Refit.Tests/SystemTextJsonQueryConverterTests.cs new file mode 100644 index 000000000..951e81d82 --- /dev/null +++ b/src/tests/Refit.Tests/SystemTextJsonQueryConverterTests.cs @@ -0,0 +1,197 @@ +// 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.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace Refit.Tests; + +/// Verifies the edge cases of : null values, an incompatible +/// serializer, getter-less properties, collections and the recursion cap. +public sealed class SystemTextJsonQueryConverterTests +{ + /// The relative path the converter appends its query onto. + private const string Path = "/x"; + + /// The name of the property whose getter is removed by the resolver modifier. + private const string HiddenPropertyName = "Hidden"; + + /// The depth of the self-referential chain used to trip the recursion cap. + private const int OverflowDepth = 40; + + /// Verifies a null value appends nothing. + /// A task representing the asynchronous test. + [Test] + public async Task NullValueAppendsNothing() + { + var result = Flatten(null, StjSettings()); + + await Assert.That(result).IsEqualTo(Path); + } + + /// Verifies flattening throws when the configured serializer is not a System.Text.Json serializer. + /// A task representing the asynchronous test. + [Test] + public async Task NonSystemTextJsonSerializerThrows() + { + var settings = new RefitSettings(new NonJsonSerializer()); + var filter = new StjFilter { Query = "ada" }; + + await Assert + .That(() => + { + var builder = new GeneratedQueryStringBuilder(Path); + new SystemTextJsonQueryConverter().Flatten(filter, string.Empty, ref builder, settings); + }) + .Throws(); + } + + /// Verifies a property without a getter is skipped while readable ones are emitted. + /// A task representing the asynchronous test. + [Test] + public async Task PropertyWithoutGetterIsSkipped() + { + var result = Flatten(new GetterlessProbe { Readable = "r", Hidden = "ignored" }, GetterlessSettings()); + + await Assert.That(result).IsEqualTo("/x?Readable=r"); + } + + /// Verifies a collection property is expanded, omitting null elements under the multi format. + /// A task representing the asynchronous test. + [Test] + public async Task CollectionPropertyIsExpanded() + { + var settings = StjSettings(); + settings.CollectionFormat = CollectionFormat.Multi; + + var result = Flatten(new CollectionProbe { Tags = ["a", null, "b"] }, settings); + + await Assert.That(result).IsEqualTo("/x?Tags=a&Tags=b"); + } + + /// Verifies the recursion cap stops before appending a property nested beyond the limit. + /// A task representing the asynchronous test. + [Test] + public async Task RecursionCapStopsAtMaxDepth() + { + var result = Flatten(BuildChain(OverflowDepth), StjSettings()); + + await Assert.That(result).IsEqualTo(Path); + } + + /// Flattens a value with a fresh builder and returns the built relative path. + /// The declared parameter type. + /// The value to flatten, or null. + /// The settings supplying the serializer and formatter. + /// The built relative path. + private static string Flatten(T? value, RefitSettings settings) + { + var builder = new GeneratedQueryStringBuilder(Path); + new SystemTextJsonQueryConverter().Flatten(value!, string.Empty, ref builder, settings); + return builder.Build(); + } + + /// Builds settings backed by a reflection-based System.Text.Json serializer. + /// The configured settings. + private static RefitSettings StjSettings() => SettingsFor(new DefaultJsonTypeInfoResolver()); + + /// Builds settings whose resolver removes the getter of the property. + /// The configured settings. + private static RefitSettings GetterlessSettings() + { + var resolver = new DefaultJsonTypeInfoResolver(); + resolver.Modifiers.Add(static typeInfo => + { + if (typeInfo.Type != typeof(GetterlessProbe)) + { + return; + } + + foreach (var property in typeInfo.Properties) + { + if (property.Name == HiddenPropertyName) + { + property.Get = null; + } + } + }); + + return SettingsFor(resolver); + } + + /// Builds settings backed by a System.Text.Json serializer using the given type-info resolver. + /// The type-info resolver to use. + /// The configured settings. + private static RefitSettings SettingsFor(IJsonTypeInfoResolver resolver) => + new() + { + ContentSerializer = new SystemTextJsonContentSerializer( + new JsonSerializerOptions { TypeInfoResolver = resolver }) + }; + + /// Builds a self-referential chain whose deepest node carries a leaf value. + /// The number of nested nodes to create. + /// The head of the chain. + private static RecursiveNode BuildChain(int depth) + { + var node = new RecursiveNode { Leaf = "deep" }; + for (var i = 0; i < depth; i++) + { + node = new RecursiveNode { Next = node }; + } + + return node; + } + + /// A probe whose property has its getter removed by a resolver modifier. + private sealed class GetterlessProbe + { + /// Gets or sets the readable property. + public string? Readable { get; set; } + + /// Gets or sets the property whose getter is removed before flattening. + public string? Hidden { get; set; } + } + + /// A probe with a collection property flattened by the converter. + private sealed class CollectionProbe + { + /// Gets or sets the collection of tags. + [SuppressMessage( + "Design", + "SST2305:A mutable collection property declares a caller-visible setter", + Justification = "The converter walks the deserialized shape; a settable collection matches System.Text.Json models.")] + public List? Tags { get; set; } + } + + /// A self-referential node used to exercise the converter's recursion cap. + private sealed class RecursiveNode + { + /// Gets or sets the next node in the chain. + public RecursiveNode? Next { get; set; } + + /// Gets or sets the leaf value. + public string? Leaf { get; set; } + } + + /// A minimal non-System.Text.Json serializer used to trip the converter's serializer guard. + private sealed class NonJsonSerializer : IHttpContentSerializer + { + /// + public HttpContent ToHttpContent(T item) => new StringContent(string.Empty); + + /// + [SuppressMessage( + "Design", + "SST2307:Generic method type parameters should be inferable from the parameters", + Justification = "The method implements Refit's published serializer interface.")] + 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/TestHttpMessageHandler.cs b/src/tests/Refit.Tests/TestHttpMessageHandler.cs index e9c046c9c..305275c0b 100644 --- a/src/tests/Refit.Tests/TestHttpMessageHandler.cs +++ b/src/tests/Refit.Tests/TestHttpMessageHandler.cs @@ -12,7 +12,10 @@ public class TestHttpMessageHandler : HttpMessageHandler { /// Initializes a new instance of the class. /// The canned response content body. - [SuppressMessage("Major Code Smell", "S2360:Optional parameters should not be used", Justification = "Test handler ctor convenience default; preserves existing call sites.")] + [SuppressMessage( + "Design", + "SST2309:An externally visible member declares an optional parameter, so callers bake in the default", + Justification = "Test handler ctor convenience default; preserves existing call sites.")] public TestHttpMessageHandler(string content = "test") { Content = new StringContent(content); diff --git a/src/tests/Refit.Tests/ThisIsDumbButMightHappen.cs b/src/tests/Refit.Tests/ThisIsDumbButMightHappen.cs index 946ff0d84..1895d7686 100644 --- a/src/tests/Refit.Tests/ThisIsDumbButMightHappen.cs +++ b/src/tests/Refit.Tests/ThisIsDumbButMightHappen.cs @@ -10,8 +10,8 @@ public static class ThisIsDumbButMightHappen { /// A constant string referenced from a Refit attribute argument. [SuppressMessage( - "Minor Code Smell", - "S2339:Public constant members should not be used", + "Design", + "SST2311:Visible constants should be static readonly", Justification = "Must remain a const because it is consumed as a Refit attribute argument, which requires a compile-time constant.")] public const string PeopleDoWeirdStuff = "But we don't let them"; } diff --git a/src/tests/Refit.Tests/UrlResolutionModeTests.cs b/src/tests/Refit.Tests/UrlResolutionModeTests.cs index ce89fff7b..a74b7e363 100644 --- a/src/tests/Refit.Tests/UrlResolutionModeTests.cs +++ b/src/tests/Refit.Tests/UrlResolutionModeTests.cs @@ -30,7 +30,8 @@ public async Task Rfc3986AppendsRelativePathToBasePath() [Test] public async Task Rfc3986ExpandsDynamicSegment() { - var captured = await CaptureRfcRequestAsync(BaseAddress, static api => api.GetUser(7)); + const int userId = 7; + var captured = await CaptureRfcRequestAsync(BaseAddress, static api => api.GetUser(userId)); await Assert.That(captured!.AbsoluteUri).IsEqualTo("http://foo/api/v1/users/7"); } @@ -48,15 +49,21 @@ public async Task Rfc3986LeadingSlashReplacesBasePath() [Test] public async Task Rfc3986PreservesQueryParameters() { - var captured = await CaptureRfcRequestAsync(BaseAddress, static api => api.GetValuesWithQuery(3)); + const int pageNumber = 3; + var captured = await CaptureRfcRequestAsync(BaseAddress, static api => api.GetValuesWithQuery(pageNumber)); await Assert.That(captured!.AbsoluteUri).IsEqualTo("http://foo/api/v1/values?active=true&page=3"); } - /// Verifies a leading-slash-less route still throws under the default legacy mode. + /// Verifies a leading-slash-less route still throws under the default legacy mode. Generated request building + /// validates the leading slash when the request is built, so the throw surfaces on the first call. /// A task representing the asynchronous test. [Test] - public async Task LegacyModeRejectsLeadingSlashlessRoute() => - await Assert.That(static () => RestService.For(BaseAddress)).ThrowsExactly(); + public async Task LegacyModeRejectsLeadingSlashlessRoute() + { + var api = RestService.For(BaseAddress); + Func call = () => api.GetValuesRelative(); + _ = await Assert.That(call).ThrowsExactly(); + } /// Invokes a call under and returns the captured request URI. /// The client base address. diff --git a/src/tests/Refit.Tests/ValueStringBuilderTests.cs b/src/tests/Refit.Tests/ValueStringBuilderTests.cs index e66bb35a0..4671c1a07 100644 --- a/src/tests/Refit.Tests/ValueStringBuilderTests.cs +++ b/src/tests/Refit.Tests/ValueStringBuilderTests.cs @@ -7,6 +7,9 @@ namespace Refit.Tests; /// Tests for allocation-conscious internal helper types. public sealed class ValueStringBuilderTests { + /// A multi-item sample sequence used to exercise the many-element peek branch. + private static readonly int[] MultiItemSequence = [1, 2]; + /// Verifies common append and insert operations on a stack-backed builder. /// A task representing the asynchronous test. [Test] @@ -93,6 +96,17 @@ public async Task BuilderGrowthBranchesAreCoveredPerOverload() await Assert.That(text).IsEqualTo("bba|abc|ab|abb|abc|ab"); } + /// Verifies growing a second time returns the previously rented pooled buffer. + /// A task representing the asynchronous test. + [Test] + public async Task GrowingTwiceReturnsThePreviousPooledBuffer() + { + const int totalLength = 20; + var text = BuildAcrossTwoGrowths(totalLength); + + await Assert.That(text).IsEqualTo(new string('a', totalLength)); + } + /// Verifies the enumerable peek helper distinguishes empty, single, and multi-item sequences. /// A task representing the asynchronous test. [Test] @@ -101,7 +115,7 @@ public async Task TryGetSingleReportsEmptySingleAndMany() const int singleSampleValue = 42; var emptyState = Array.Empty().TryGetSingle(out var emptyValue); var singleState = new[] { singleSampleValue }.TryGetSingle(out var singleValue); - var manyState = new[] { 1, 2 }.TryGetSingle(out var manyValue); + var manyState = MultiItemSequence.TryGetSingle(out var manyValue); await Assert.That(emptyState).IsEqualTo(EnumerablePeek.Empty); await Assert.That(emptyValue).IsEqualTo(0); @@ -156,6 +170,25 @@ public async Task CloseGenericMethodKeyComparesMethodAndTypes() await Assert.That(value.Equals(differentType)).IsFalse(); } + /// Appends across two growths so the second growth returns the buffer rented by the first. + /// The total number of characters to append. + /// The built string. + private static string BuildAcrossTwoGrowths(int totalLength) + { + const int firstAppendLength = 6; + var builder = new ValueStringBuilder(stackalloc char[4]); + try + { + builder.Append(new string('a', firstAppendLength)); + builder.Append(new string('a', totalLength - firstAppendLength)); + return builder.ToString(); + } + finally + { + builder.Dispose(); + } + } + /// Builds a string using append, insert, span, and indexer operations. /// The built string. private static string BuildInsertedString() @@ -186,6 +219,8 @@ private static string BuildInsertedString() /// A summary of the builder state before disposal. private static (string Text, int Length, int Capacity, string Suffix, string Middle, char Terminator) BuildSpanSummary() { + const int suffixStart = 2; + const int middleLength = 3; var builder = new ValueStringBuilder(stackalloc char[2]); try { @@ -195,8 +230,8 @@ private static (string Text, int Length, int Capacity, string Suffix, string Mid _ = builder.GetPinnableReference(terminate: true); var text = builder.AsSpan().ToString(); - var suffix = builder.AsSpan(2).ToString(); - var middle = builder.AsSpan(1, 3).ToString(); + var suffix = builder.AsSpan(suffixStart).ToString(); + var middle = builder.AsSpan(1, middleLength).ToString(); var terminator = builder.RawChars[builder.Length]; var length = builder.Length; var capacity = builder.Capacity; @@ -253,7 +288,8 @@ private static (bool Success, int CharsWritten) TryCopyToSmallDestination() /// The built text and capacity. private static (string Text, int Capacity) BuildWithPooledInitialCapacity() { - var builder = new ValueStringBuilder(2); + const int initialCapacity = 2; + var builder = new ValueStringBuilder(initialCapacity); try { builder.Append(null); diff --git a/src/tests/Refit.Tests/XmlContentSerializerTests.cs b/src/tests/Refit.Tests/XmlContentSerializerTests.cs index 5ef1a3029..44d3af062 100644 --- a/src/tests/Refit.Tests/XmlContentSerializerTests.cs +++ b/src/tests/Refit.Tests/XmlContentSerializerTests.cs @@ -152,7 +152,7 @@ public async Task GuardsAndXmlFieldNamesShouldWork() { var sut = new XmlContentSerializer(); - await Assert.That(() => new XmlContentSerializer(null!)).ThrowsExactly(); + await Assert.That(static () => 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))!)) @@ -239,10 +239,6 @@ public async Task DeserializeRejectsExternalEntityPayload() /// 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() => new() {