diff --git a/Src/Support/Google.Apis.Core/ApplicationContext.cs b/Src/Support/Google.Apis.Core/ApplicationContext.cs
index d316446bae7..e9207ac105e 100644
--- a/Src/Support/Google.Apis.Core/ApplicationContext.cs
+++ b/Src/Support/Google.Apis.Core/ApplicationContext.cs
@@ -19,13 +19,33 @@ limitations under the License.
namespace Google
{
- /// Defines the context in which this library runs. It allows setting up custom loggers.
+ /// Defines the context in which this library runs. It allows setting up custom loggers and performance options.
public static class ApplicationContext
{
private static ILogger logger;
// For testing
- internal static void Reset() => logger = null;
+ internal static void Reset()
+ {
+ logger = null;
+ EnableReflectionCache = false;
+ }
+
+ ///
+ /// Gets or sets whether to enable reflection result caching for request parameter properties.
+ ///
+ ///
+ ///
+ /// When enabled, lookups for request parameter
+ /// properties are cached per request type, eliminating repeated reflection overhead.
+ ///
+ ///
+ /// Default is false. Set to true early in application startup before making
+ /// any API requests. This setting is intended for applications that make many requests and
+ /// where reflection overhead has been identified as a bottleneck.
+ ///
+ ///
+ public static bool EnableReflectionCache { get; set; }
/// Returns the logger used within this application context.
/// It creates a if no logger was registered previously
diff --git a/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterCollection.cs b/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterCollection.cs
index c3c0d85ec3c..7a7f3f9d65f 100644
--- a/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterCollection.cs
+++ b/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterCollection.cs
@@ -17,6 +17,7 @@ limitations under the License.
using System;
using System.Collections;
using System.Collections.Generic;
+using System.Linq;
using Google.Apis.Util;
@@ -142,23 +143,16 @@ public static ParameterCollection FromQueryString(string qs)
///
public static ParameterCollection FromDictionary(IDictionary dictionary)
{
+ // Convert to typed dictionary (defaulting to Query) so we can reuse the shared expansion logic.
+ var typedDict = dictionary.ToDictionary(
+ kvp => kvp.Key,
+ kvp => new ParameterValue(RequestParameterType.Query, kvp.Value));
+
+ // Expand any enumerable values into repeated parameters.
var collection = new ParameterCollection();
- foreach (KeyValuePair pair in dictionary)
+ foreach (var param in ParameterUtils.ExpandParametersWithTypes(typedDict))
{
- // Try parsing the value of the pair as an enumerable.
- var valueAsEnumerable = pair.Value as IEnumerable;
- if (!(pair.Value is string) && valueAsEnumerable != null)
- {
- foreach (var value in valueAsEnumerable)
- {
- collection.Add(pair.Key, Util.Utilities.ConvertToString(value));
- }
- }
- else
- {
- // Otherwise just convert it to a string.
- collection.Add(pair.Key, pair.Value == null ? null : Util.Utilities.ConvertToString(pair.Value));
- }
+ collection.Add(param.Name, param.Value);
}
return collection;
}
diff --git a/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterUtils.cs b/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterUtils.cs
index d57c27b6a0a..2c1d58ec3b5 100644
--- a/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterUtils.cs
+++ b/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterUtils.cs
@@ -15,6 +15,7 @@ limitations under the License.
*/
using System;
+using System.Collections;
using System.Collections.Generic;
using System.Net.Http;
using System.Linq;
@@ -38,7 +39,7 @@ public static class ParameterUtils
/// attribute.
///
///
- /// A request object which contains properties with
+ /// A request object which contains properties with
/// attribute. Those properties will be serialized
/// to the returned .
///
@@ -61,17 +62,40 @@ public static FormUrlEncodedContent CreateFormUrlEncodedContent(object request)
/// attribute.
///
///
- /// A request object which contains properties with
+ /// A request object which contains properties with
/// attribute. Those properties will be set
/// in the output dictionary.
///
public static IDictionary CreateParameterDictionary(object request)
{
- var dict = new Dictionary();
+ // Use the typed implementation, then drop type information to preserve the legacy return type.
+ return CreateParameterDictionaryWithTypes(request)
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Value);
+ }
+
+ ///
+ /// Creates a parameter dictionary with type information by using reflection to iterate over all properties with
+ /// attribute.
+ ///
+ ///
+ /// A request object which contains properties with
+ /// attribute. Those properties will be set
+ /// in the output dictionary along with their parameter type.
+ ///
+ ///
+ /// Thrown when multiple properties set the same parameter name to non-null values.
+ ///
+ ///
+ /// A dictionary where the key is the parameter name and the value is a ParameterValue containing type and value.
+ ///
+ private static IDictionary CreateParameterDictionaryWithTypes(object request)
+ {
+ var dict = new Dictionary();
IterateParameters(request, (type, name, value) =>
{
- if (dict.TryGetValue(name, out var existingValue))
+ if (dict.TryGetValue(name, out var existingEntry))
{
+ var existingValue = existingEntry.Value;
// Repeated enum query parameters end up with two properties: a single
// one, and a Repeatable (where the T is always non-nullable, whether or not the parameter
// is optional). If both properties are set, we fail. Note that this delegate is called
@@ -81,7 +105,7 @@ public static IDictionary CreateParameterDictionary(object reque
if (existingValue is null && value is object)
{
// Overwrite null value with non-null value
- dict[name] = value;
+ dict[name] = new ParameterValue(type, value);
}
else if (value is null)
{
@@ -89,14 +113,14 @@ public static IDictionary CreateParameterDictionary(object reque
}
else
{
- // Throw if we see a second null value
+ // Throw if we see a second non-null value
throw new InvalidOperationException(
$"The query parameter '{name}' is set by multiple properties. For repeated enum query parameters, ensure that only one property is set to a non-null value.");
}
}
else
{
- dict.Add(name, value);
+ dict.Add(name, new ParameterValue(type, value));
}
});
return dict;
@@ -108,8 +132,8 @@ public static IDictionary CreateParameterDictionary(object reque
///
/// The request builder
///
- /// A request object which contains properties with
- /// attribute. Those properties will be set in the
+ /// A request object which contains properties with
+ /// attribute. Those properties will be set in the
/// given request builder object
///
public static void InitParameters(RequestBuilder builder, object request)
@@ -120,6 +144,70 @@ public static void InitParameters(RequestBuilder builder, object request)
});
}
+ ///
+ /// Sets request parameters in the given builder with all properties with the
+ /// attribute
+ /// by expanding values into multiple parameters.
+ ///
+ /// The request builder
+ ///
+ /// A request object which contains properties with
+ /// attribute. Those properties will be set in the
+ /// given request builder object
+ ///
+ public static void InitParametersWithExpansion(RequestBuilder builder, object request)
+ {
+ // Use typed methods to preserve RequestParameterType information
+ var parametersWithTypes = CreateParameterDictionaryWithTypes(request);
+
+ // Expand and add all parameters to the builder with their correct types
+ foreach (var param in ExpandParametersWithTypes(parametersWithTypes))
+ {
+ builder.AddParameter(param.Type, param.Name, param.Value);
+ }
+ }
+
+ ///
+ /// Expands a dictionary of typed parameters into a sequence of instances.
+ ///
+ ///
+ /// If a parameter value implements (and is not a ), it is expanded into
+ /// multiple instances with the same name and .
+ /// This supports repeatable parameters represented as (which is ) and other
+ /// enumerable values.
+ ///
+ ///
+ /// A dictionary where the key is the parameter name and the value is a containing both
+ /// the parameter type and raw value.
+ ///
+ ///
+ /// An enumerable of instances, with enumerable values expanded into repeated parameters.
+ ///
+ internal static IEnumerable ExpandParametersWithTypes(IDictionary dictionary)
+ {
+ foreach (var pair in dictionary)
+ {
+ var paramType = pair.Value.Type;
+ var value = pair.Value.Value;
+ var name = pair.Key;
+
+ // Try parsing the value as an enumerable.
+ var valueAsEnumerable = value as IEnumerable;
+ if (!(value is string) && valueAsEnumerable != null)
+ {
+ foreach (var elem in valueAsEnumerable)
+ {
+ yield return new TypedParameter(paramType, name, Utilities.ConvertToString(elem));
+ }
+ }
+ else
+ {
+ // Otherwise just convert it to a string.
+ yield return new TypedParameter(paramType, name, Utilities.ConvertToString(value));
+ }
+ }
+ }
+
///
/// Iterates over all properties in the request
/// object and invokes the specified action for each of them.
@@ -128,18 +216,11 @@ public static void InitParameters(RequestBuilder builder, object request)
/// An action to invoke which gets the parameter type, name and its value
private static void IterateParameters(object request, Action action)
{
- // Use reflection to build the parameter dictionary.
- foreach (PropertyInfo property in request.GetType().GetProperties(BindingFlags.Instance |
- BindingFlags.Public))
+ // Use ReflectionCache to avoid repeated reflection + attribute lookup on every call.
+ foreach (var propertyWithAttribute in ReflectionCache.GetRequestParameterProperties(request.GetType()))
{
- // Retrieve the RequestParameterAttribute.
- RequestParameterAttribute attribute =
- property.GetCustomAttributes(typeof(RequestParameterAttribute), false).FirstOrDefault() as
- RequestParameterAttribute;
- if (attribute == null)
- {
- continue;
- }
+ var property = propertyWithAttribute.Property;
+ var attribute = propertyWithAttribute.Attribute;
// Get the name of this parameter from the attribute, if it doesn't exist take a lower-case variant of
// property name.
diff --git a/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterValue.cs b/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterValue.cs
new file mode 100644
index 00000000000..10861ecdb55
--- /dev/null
+++ b/Src/Support/Google.Apis.Core/Requests/Parameters/ParameterValue.cs
@@ -0,0 +1,48 @@
+/*
+Copyright 2026 Google Inc
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+using Google.Apis.Util;
+
+namespace Google.Apis.Requests.Parameters
+{
+ ///
+ /// Represents a parameter value together with its metadata.
+ ///
+ ///
+ /// contains the raw CLR value (prior to any string conversion for the wire format).
+ ///
+ internal readonly struct ParameterValue
+ {
+ ///
+ /// Gets the parameter type (Path, Query, etc.)
+ ///
+ public RequestParameterType Type { get; }
+
+ ///
+ /// Gets the parameter value.
+ ///
+ public object Value { get; }
+
+ ///
+ /// Constructs a new parameter value with type.
+ ///
+ public ParameterValue(RequestParameterType type, object value)
+ {
+ Type = type;
+ Value = value;
+ }
+ }
+}
diff --git a/Src/Support/Google.Apis.Core/Requests/Parameters/TypedParameter.cs b/Src/Support/Google.Apis.Core/Requests/Parameters/TypedParameter.cs
new file mode 100644
index 00000000000..c67f88104a0
--- /dev/null
+++ b/Src/Support/Google.Apis.Core/Requests/Parameters/TypedParameter.cs
@@ -0,0 +1,46 @@
+/*
+Copyright 2026 Google Inc
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+using Google.Apis.Util;
+
+namespace Google.Apis.Requests.Parameters
+{
+ ///
+ /// Represents a parameter together with its metadata.
+ ///
+ ///
+ /// is the string value to be sent on the wire (after conversion via Utilities.ConvertToString).
+ ///
+ internal readonly struct TypedParameter
+ {
+ /// Gets the parameter type (Path, Query, etc.)
+ public RequestParameterType Type { get; }
+
+ /// Gets the parameter name.
+ public string Name { get; }
+
+ /// Gets the parameter value.
+ public string Value { get; }
+
+ /// Constructs a new typed parameter.
+ public TypedParameter(RequestParameterType type, string name, string value)
+ {
+ Type = type;
+ Name = name;
+ Value = value;
+ }
+ }
+}
diff --git a/Src/Support/Google.Apis.Core/Util/PropertyWithAttribute.cs b/Src/Support/Google.Apis.Core/Util/PropertyWithAttribute.cs
new file mode 100644
index 00000000000..5bdfb7bd346
--- /dev/null
+++ b/Src/Support/Google.Apis.Core/Util/PropertyWithAttribute.cs
@@ -0,0 +1,65 @@
+/*
+Copyright 2026 Google Inc
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+using System.Reflection;
+
+namespace Google.Apis.Util
+{
+ ///
+ /// Pairs a with its associated .
+ ///
+ ///
+ /// Instances of this struct are produced by
+ /// and consumed by ParameterUtils when building request URLs and form bodies. Only properties
+ /// that are decorated with are represented; properties without
+ /// the attribute are filtered out before any value is created.
+ ///
+ public readonly struct PropertyWithAttribute
+ {
+ ///
+ /// Gets the for the request parameter property.
+ ///
+ ///
+ /// The that describes the request parameter property on the request type.
+ ///
+ public PropertyInfo Property { get; }
+
+ ///
+ /// Gets the applied to .
+ ///
+ ///
+ /// The that annotates , providing the
+ /// parameter name and used when serializing the request.
+ ///
+ ///
+ /// This value is never null on instances returned by
+ /// ; properties without the attribute
+ /// are excluded from the results.
+ ///
+ public RequestParameterAttribute Attribute { get; }
+
+ ///
+ /// Initializes a new instance of .
+ ///
+ /// The of the request parameter property.
+ /// The applied to .
+ public PropertyWithAttribute(PropertyInfo property, RequestParameterAttribute attribute)
+ {
+ Property = property;
+ Attribute = attribute;
+ }
+ }
+}
diff --git a/Src/Support/Google.Apis.Core/Util/ReflectionCache.cs b/Src/Support/Google.Apis.Core/Util/ReflectionCache.cs
new file mode 100644
index 00000000000..8951fe40586
--- /dev/null
+++ b/Src/Support/Google.Apis.Core/Util/ReflectionCache.cs
@@ -0,0 +1,112 @@
+/*
+Copyright 2026 Google Inc
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+using System;
+using System.Collections.Concurrent;
+using System.Linq;
+using System.Reflection;
+using Google;
+
+namespace Google.Apis.Util
+{
+ ///
+ /// Provides cached reflection results for request parameter discovery.
+ ///
+ ///
+ ///
+ /// This class is thread-safe. The internal cache uses ,
+ /// which allows concurrent reads and writes without external locking.
+ ///
+ ///
+ /// Caching is opt-in: set to true
+ /// at application startup to activate it. By default, reflection results are recomputed on every call
+ /// to preserve the existing no-overhead-at-rest behavior.
+ ///
+ ///
+ /// When caching is enabled, each unique request type incurs a one-time reflection cost. Subsequent calls
+ /// for the same type return the cached array directly, eliminating
+ /// per-call reflection and attribute-lookup overhead.
+ ///
+ ///
+ /// The cache is intentionally unbounded, but in practice it is finite: entries are keyed by the concrete
+ /// request types that carry -decorated properties. The set of such
+ /// types in any application is small and fixed at compile time.
+ ///
+ ///
+ ///
+ /// Enable caching once at application startup, before issuing any API requests:
+ ///
+ /// // Enable caching at application startup
+ /// ApplicationContext.EnableReflectionCache = true;
+ ///
+ /// // The cache is used automatically by ParameterUtils
+ /// // (no further configuration required)
+ ///
+ ///
+ public static partial class ReflectionCache
+ {
+ ///
+ /// Cache of properties filtered by RequestParameterAttribute.
+ /// Key is the type, value is an array of PropertyWithAttribute structs.
+ ///
+ ///
+ /// This cache is intentionally unbounded; it is keyed by request types
+ /// annotated with RequestParameterAttribute, which are expected to be
+ /// a finite set in normal usage.
+ ///
+ private static readonly ConcurrentDictionary RequestParameterPropertiesCache =
+ new ConcurrentDictionary();
+
+ ///
+ /// Returns the set of -decorated properties for the specified
+ /// request type.
+ ///
+ /// The request type whose parameter properties should be returned.
+ ///
+ /// An array of values, each pairing a
+ /// with its .
+ /// Only properties that carry the attribute are included; properties without it are omitted.
+ ///
+ ///
+ /// When is true, the result is
+ /// stored in an internal and returned on subsequent
+ /// calls without re-executing reflection. When the setting is false (the default), reflection
+ /// is performed on every invocation.
+ ///
+ public static PropertyWithAttribute[] GetRequestParameterProperties(Type type)
+ {
+ // Only use cache if explicitly enabled by user
+ if (ApplicationContext.EnableReflectionCache)
+ {
+ return RequestParameterPropertiesCache.GetOrAdd(type, ComputeProperties);
+ }
+
+ // Default behavior: compute properties without caching
+ return ComputeProperties(type);
+ }
+
+ ///
+ /// Computes the request parameter properties for a given type using reflection.
+ ///
+ private static PropertyWithAttribute[] ComputeProperties(Type type)
+ {
+ return type.GetProperties(BindingFlags.Instance | BindingFlags.Public)
+ .Select(prop => new PropertyWithAttribute(prop, prop.GetCustomAttribute(inherit: false)))
+ .Where(pwa => pwa.Attribute != null)
+ .ToArray();
+ }
+ }
+}
diff --git a/Src/Support/Google.Apis.Tests/Apis/Requests/Parameters/ParameterUtilsTest.cs b/Src/Support/Google.Apis.Tests/Apis/Requests/Parameters/ParameterUtilsTest.cs
index e44eadb6c92..da3a7ff15be 100644
--- a/Src/Support/Google.Apis.Tests/Apis/Requests/Parameters/ParameterUtilsTest.cs
+++ b/Src/Support/Google.Apis.Tests/Apis/Requests/Parameters/ParameterUtilsTest.cs
@@ -14,6 +14,7 @@ You may obtain a copy of the License at
limitations under the License.
*/
+using Google;
using Google.Apis.Requests;
using Google.Apis.Requests.Parameters;
using Google.Apis.Util;
@@ -130,5 +131,261 @@ public void RepeatableEnum_BothPropertiesSet()
};
Assert.Throws(() => ParameterUtils.CreateParameterDictionary(request));
}
+
+ // Tests for InitParametersWithExpansion
+
+ private class TestRequestWithScalars
+ {
+ [RequestParameter("name", RequestParameterType.Query)]
+ public string Name { get; set; }
+
+ [RequestParameter("id", RequestParameterType.Path)]
+ public int Id { get; set; }
+
+ [RequestParameter("nullparam", RequestParameterType.Query)]
+ public string NullParam { get; set; }
+ }
+
+ [Fact]
+ public void InitParametersWithExpansion_ScalarParameters()
+ {
+ var request = new TestRequestWithScalars
+ {
+ Name = "test",
+ Id = 123,
+ NullParam = null
+ };
+ var builder = new RequestBuilder
+ {
+ BaseUri = new Uri("https://example.com/api"),
+ Path = "items/{id}" // Path template for path parameter
+ };
+
+ ParameterUtils.InitParametersWithExpansion(builder, request);
+
+ // Verify parameters were added by building the URI
+ var uri = builder.BuildUri();
+ Assert.Contains("name=test", uri.AbsoluteUri);
+ Assert.Contains("items/123", uri.AbsoluteUri); // Path parameter replaces {id}
+ // Null parameter should not be in the URI
+ Assert.DoesNotContain("nullparam", uri.AbsoluteUri);
+ }
+
+ private class TestRequestWithRepeatable
+ {
+ [RequestParameter("part", RequestParameterType.Query)]
+ public Repeatable Part { get; set; }
+ }
+
+ [Fact]
+ public void InitParametersWithExpansion_RepeatableParameters()
+ {
+ var request = new TestRequestWithRepeatable
+ {
+ Part = new[] { "snippet", "contentDetails" }
+ };
+ var builder = new RequestBuilder { BaseUri = new Uri("https://example.com/api") };
+
+ ParameterUtils.InitParametersWithExpansion(builder, request);
+
+ // Should expand to multiple parameters with the same name
+ var uri = builder.BuildUri();
+ Assert.Contains("part=snippet", uri.AbsoluteUri);
+ Assert.Contains("part=contentDetails", uri.AbsoluteUri);
+ }
+
+ private class TestRequestWithDateTime
+ {
+ [RequestParameter("time", RequestParameterType.Query)]
+ public DateTime? MinTime { get; set; }
+ }
+
+ [Fact]
+ public void InitParametersWithExpansion_DateTimeConversion()
+ {
+ var request = new TestRequestWithDateTime
+ {
+ MinTime = new DateTime(2023, 1, 15, 10, 30, 0, DateTimeKind.Utc)
+ };
+ var builder = new RequestBuilder { BaseUri = new Uri("https://example.com/api") };
+
+ ParameterUtils.InitParametersWithExpansion(builder, request);
+
+ // DateTime should be converted to RFC3339 format
+ var uri = builder.BuildUri();
+ Assert.Contains("time=", uri.AbsoluteUri);
+ Assert.Contains("2023-01-15", uri.AbsoluteUri);
+ }
+
+ private class TestRequestWithEnum
+ {
+ [RequestParameter("mode", RequestParameterType.Query)]
+ public Repeatable ModeList { get; set; }
+ }
+
+ [Fact]
+ public void InitParametersWithExpansion_EnumConversion()
+ {
+ var request = new TestRequestWithEnum
+ {
+ ModeList = new[] { FileMode.Open, FileMode.Append }
+ };
+ var builder = new RequestBuilder { BaseUri = new Uri("https://example.com/api") };
+
+ ParameterUtils.InitParametersWithExpansion(builder, request);
+
+ // Should expand enum list and convert each value using StringValue attribute
+ var uri = builder.BuildUri();
+ Assert.Contains("mode=Open", uri.AbsoluteUri);
+ Assert.Contains("mode=Append", uri.AbsoluteUri);
+ }
+
+ private class TestRequestWithBoolean
+ {
+ [RequestParameter("flag", RequestParameterType.Query)]
+ public bool Flag { get; set; }
+ }
+
+ [Fact]
+ public void InitParametersWithExpansion_BooleanConversion()
+ {
+ var request = new TestRequestWithBoolean { Flag = true };
+ var builder = new RequestBuilder { BaseUri = new Uri("https://example.com/api") };
+
+ ParameterUtils.InitParametersWithExpansion(builder, request);
+
+ // Boolean should be lowercase
+ var uri = builder.BuildUri();
+ Assert.Contains("flag=true", uri.AbsoluteUri);
+ }
+
+ [Fact]
+ public void InitParametersWithExpansion_CacheUsage()
+ {
+ var originalState = ApplicationContext.EnableReflectionCache;
+ try
+ {
+ // Arrange - explicitly enable cache
+ ApplicationContext.EnableReflectionCache = true;
+
+ var request1 = new TestRequestWithScalars { Name = "test1", Id = 1 };
+ var request2 = new TestRequestWithScalars { Name = "test2", Id = 2 };
+ var builder1 = new RequestBuilder { BaseUri = new Uri("https://example.com/api") };
+ var builder2 = new RequestBuilder { BaseUri = new Uri("https://example.com/api") };
+
+ // First call - cache is populated
+ ParameterUtils.InitParametersWithExpansion(builder1, request1);
+ var properties1 = Google.Apis.Util.ReflectionCache.GetRequestParameterProperties(typeof(TestRequestWithScalars));
+
+ // Second call - should reuse cached PropertyInfo
+ ParameterUtils.InitParametersWithExpansion(builder2, request2);
+ var properties2 = Google.Apis.Util.ReflectionCache.GetRequestParameterProperties(typeof(TestRequestWithScalars));
+
+ // Verify same PropertyInfo instances are returned (object reference equality)
+ Assert.Equal(properties1.Length, properties2.Length);
+ for (int i = 0; i < properties1.Length; i++)
+ {
+ Assert.Same(properties1[i].Property, properties2[i].Property);
+ Assert.Same(properties1[i].Attribute, properties2[i].Attribute);
+ }
+ }
+ finally
+ {
+ ApplicationContext.EnableReflectionCache = originalState;
+ }
+ }
+
+ ///
+ /// Regression test for null handling in enumerable expansion.
+ /// Tests that null elements in an enumerable produce the same result as Utilities.ConvertToString(null).
+ /// This test does not hard-code expectations - it dynamically tests against the actual behavior.
+ ///
+ [Fact]
+ public void InitParametersWithExpansion_NullElementsInEnumerable()
+ {
+ // Get the expected behavior from Utilities.ConvertToString(null) - don't hard-code!
+ string expectedNullValue = Utilities.ConvertToString(null);
+
+ var request = new TestRequestWithRepeatable
+ {
+ Part = new[] { "value1", null, "value2" }
+ };
+ var builder = new RequestBuilder { BaseUri = new Uri("https://example.com/api") };
+
+ ParameterUtils.InitParametersWithExpansion(builder, request);
+
+ // Build the URI and check the result
+ var uri = builder.BuildUri();
+ var query = uri.Query;
+
+ // Count how many "part=" occurrences there are
+ int partCount = System.Text.RegularExpressions.Regex.Matches(query, "part=").Count;
+
+ // Verify behavior matches what Utilities.ConvertToString(null) produces
+ if (expectedNullValue == null)
+ {
+ // If ConvertToString(null) returns null, the null element should be filtered out
+ // by RequestBuilder (which doesn't add null-valued parameters)
+ // So we should only see 2 parameters: "value1" and "value2"
+ Assert.Equal(2, partCount);
+ Assert.Contains("part=value1", query);
+ Assert.Contains("part=value2", query);
+ }
+ else if (expectedNullValue == "")
+ {
+ // If ConvertToString(null) returns empty string, we should see 3 parameters
+ // including one with an empty value
+ Assert.Equal(3, partCount);
+ Assert.Contains("part=value1", query);
+ Assert.Contains("part=value2", query);
+ // One of the "part=" should have empty value (part=&)
+ }
+ else
+ {
+ // If ConvertToString(null) returns some other string, we should see 3 parameters
+ // with that string value
+ Assert.Equal(3, partCount);
+ Assert.Contains("part=value1", query);
+ Assert.Contains("part=value2", query);
+ Assert.Contains($"part={Uri.EscapeDataString(expectedNullValue)}", query);
+ }
+ }
+
+ [Theory]
+ [InlineData(false)] // Cache disabled (default)
+ [InlineData(true)] // Cache enabled
+ public void IterateParameters_WorksWithBothCacheModes(bool enableCache)
+ {
+ // Arrange
+ var originalState = ApplicationContext.EnableReflectionCache;
+ try
+ {
+ ApplicationContext.EnableReflectionCache = enableCache;
+ var request = new TestRequestUrl()
+ {
+ FirstParam = "firstOne",
+ SecondParam = "secondOne",
+ ParamsCollection = new List>{
+ new KeyValuePair("customParam1","customVal1"),
+ new KeyValuePair("customParam2","customVal2")
+ }
+ };
+
+ // Act
+ var result = request.Build().AbsoluteUri;
+
+ // Assert - behavior should be identical regardless of cache setting
+ Assert.Contains("first_query_param=firstOne", result);
+ Assert.Contains("second_query_param=secondOne", result);
+ Assert.Contains("customParam1=customVal1", result);
+ Assert.Contains("customParam2=customVal2", result);
+ Assert.DoesNotContain("query_param_attribute_name", result);
+ }
+ finally
+ {
+ // Restore original state
+ ApplicationContext.EnableReflectionCache = originalState;
+ }
+ }
}
-}
\ No newline at end of file
+}
diff --git a/Src/Support/Google.Apis.Tests/Apis/Upload/ResumableUploadTest.MultiChunk.cs b/Src/Support/Google.Apis.Tests/Apis/Upload/ResumableUploadTest.MultiChunk.cs
index ef71117f9bf..95d9d64ff1a 100644
--- a/Src/Support/Google.Apis.Tests/Apis/Upload/ResumableUploadTest.MultiChunk.cs
+++ b/Src/Support/Google.Apis.Tests/Apis/Upload/ResumableUploadTest.MultiChunk.cs
@@ -577,6 +577,47 @@ public void TestUploadWithQueryAndPathParameters()
}
}
+ private class TestResumableUploadWithRepeatableParameters : TestResumableUpload
+ {
+ public TestResumableUploadWithRepeatableParameters(IClientService service, string path, string method, Stream stream,
+ string contentType, int chunkSize)
+ : base(service, path, method, stream, contentType, chunkSize) { }
+
+ [RequestParameter("id", RequestParameterType.Path)]
+ public int Id { get; set; }
+
+ [RequestParameter("part", RequestParameterType.Query)]
+ public Repeatable Part { get; set; }
+
+ [RequestParameter("tag", RequestParameterType.Query)]
+ public Repeatable Tag { get; set; }
+ }
+
+ ///
+ /// Uploader correctly adds repeatable (IEnumerable) query parameters to initial server call.
+ ///
+ [Fact]
+ public void TestUploadWithRepeatableParameters()
+ {
+ var id = 456;
+ // Repeatable parameters should be sent multiple times with the same key
+ var pathAndQuery = $"testPath/{id}?uploadType=resumable&part=snippet&part=contentDetails&tag=important&tag=urgent";
+ using (var server = new MultiChunkQueriedServer(_server, pathAndQuery))
+ using (var service = new MockClientService(server.HttpPrefix))
+ {
+ var content = new MemoryStream(UploadTestBytes);
+ var uploader = new TestResumableUploadWithRepeatableParameters(service, "testPath/{id}", "POST", content, "text/plain", 100)
+ {
+ Id = id,
+ Part = new[] { "snippet", "contentDetails" },
+ Tag = new[] { "important", "urgent" }
+ };
+ var progress = uploader.Upload();
+ Assert.Equal(UploadStatus.Completed, progress.Status);
+ Assert.Equal(6, server.Requests.Count);
+ }
+ }
+
/// A mock request object.
private class TestRequest : IEquatable
{
diff --git a/Src/Support/Google.Apis.Tests/Apis/Utils/ReflectionCacheTest.cs b/Src/Support/Google.Apis.Tests/Apis/Utils/ReflectionCacheTest.cs
new file mode 100644
index 00000000000..bcc790281ce
--- /dev/null
+++ b/Src/Support/Google.Apis.Tests/Apis/Utils/ReflectionCacheTest.cs
@@ -0,0 +1,179 @@
+/*
+Copyright 2026 Google Inc
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+using Google;
+using Google.Apis.Util;
+using System;
+using System.Linq;
+using Xunit;
+
+namespace Google.Apis.Tests.Apis.Utils
+{
+ /// Tests for .
+ public class ReflectionCacheTest : IDisposable
+ {
+ private readonly bool _originalCacheState;
+
+ public ReflectionCacheTest()
+ {
+ // Save original state to restore after each test
+ _originalCacheState = ApplicationContext.EnableReflectionCache;
+ }
+
+ public void Dispose()
+ {
+ // Restore original state after each test
+ ApplicationContext.EnableReflectionCache = _originalCacheState;
+ }
+ private class TestClass
+ {
+ [RequestParameter("test_param", RequestParameterType.Query)]
+ public string TestProperty { get; set; }
+
+ [RequestParameter("another_param", RequestParameterType.Path)]
+ public int AnotherProperty { get; set; }
+
+ public string PropertyWithoutAttribute { get; set; }
+ }
+
+ [Fact]
+ public void GetRequestParameterPropertiesWithAttribute_ReturnsPropertiesAndAttributes()
+ {
+ // Arrange - cache disabled (default behavior)
+ ApplicationContext.EnableReflectionCache = false;
+
+ // Act
+ var propertiesWithAttributes = ReflectionCache.GetRequestParameterProperties(typeof(TestClass));
+
+ // Assert
+ Assert.NotNull(propertiesWithAttributes);
+ Assert.Equal(2, propertiesWithAttributes.Length);
+
+ // Check that both property and attribute are returned
+ var testProperty = propertiesWithAttributes.Single(p => p.Property.Name == "TestProperty");
+ Assert.NotNull(testProperty.Property);
+ Assert.NotNull(testProperty.Attribute);
+ Assert.Equal("test_param", testProperty.Attribute.Name);
+ Assert.Equal(RequestParameterType.Query, testProperty.Attribute.Type);
+
+ var anotherProperty = propertiesWithAttributes.Single(p => p.Property.Name == "AnotherProperty");
+ Assert.NotNull(anotherProperty.Property);
+ Assert.NotNull(anotherProperty.Attribute);
+ Assert.Equal("another_param", anotherProperty.Attribute.Name);
+ Assert.Equal(RequestParameterType.Path, anotherProperty.Attribute.Type);
+
+ var isPropertyWithoutAttributeExists = propertiesWithAttributes.Any(p => p.Property.Name == "PropertyWithoutAttribute");
+ Assert.False(isPropertyWithoutAttributeExists);
+ }
+
+ [Fact]
+ public void GetRequestParameterPropertiesWithAttribute_CachesResults()
+ {
+ // Arrange - explicitly enable cache
+ ApplicationContext.EnableReflectionCache = true;
+
+ // Act - Call twice
+ var result1 = ReflectionCache.GetRequestParameterProperties(typeof(TestClass));
+ var result2 = ReflectionCache.GetRequestParameterProperties(typeof(TestClass));
+
+ // Assert - Should return the same array instance (cached)
+ Assert.Same(result1, result2);
+
+ // Assert - Individual PropertyInfo and Attribute instances should be the same
+ for (int i = 0; i < result1.Length; i++)
+ {
+ Assert.Same(result1[i].Property, result2[i].Property);
+ Assert.Same(result1[i].Attribute, result2[i].Attribute);
+ }
+ }
+
+ [Fact]
+ public void GetRequestParameterProperties_ReturnsOnlyPropertiesWithAttribute()
+ {
+ // Arrange - cache disabled (default behavior)
+ ApplicationContext.EnableReflectionCache = false;
+
+ // Act
+ var properties = ReflectionCache.GetRequestParameterProperties(typeof(TestClass));
+ // Assert
+ Assert.NotNull(properties);
+ Assert.Equal(2, properties.Length);
+ Assert.Contains(properties, p => p.Property.Name == "TestProperty");
+ Assert.Contains(properties, p => p.Property.Name == "AnotherProperty");
+ Assert.DoesNotContain(properties, p => p.Property.Name == "PropertyWithoutAttribute");
+ }
+
+ [Fact]
+ public void GetRequestParameterPropertiesWithAttribute_RegressionTest_NoNewInstancesCreated()
+ {
+ // This regression test ensures that repeated calls with cache enabled don't create new PropertyInfo or Attribute instances.
+
+ // Arrange - explicitly enable cache
+ ApplicationContext.EnableReflectionCache = true;
+
+ // Act - Get properties multiple times
+ var result1 = ReflectionCache.GetRequestParameterProperties(typeof(TestClass));
+ var result2 = ReflectionCache.GetRequestParameterProperties(typeof(TestClass));
+ var result3 = ReflectionCache.GetRequestParameterProperties(typeof(TestClass));
+
+ // Assert - All calls should return the exact same array instance
+ Assert.Same(result1, result2);
+ Assert.Same(result2, result3);
+
+ // Assert - Individual PropertyInfo and Attribute objects should be the same instances
+ for (int i = 0; i < result1.Length; i++)
+ {
+ Assert.Same(result1[i].Property, result2[i].Property);
+ Assert.Same(result2[i].Property, result3[i].Property);
+ Assert.Same(result1[i].Attribute, result2[i].Attribute);
+ Assert.Same(result2[i].Attribute, result3[i].Attribute);
+ }
+ }
+
+ [Fact]
+ public void GetRequestParameterProperties_WithCacheDisabled_ReturnsDifferentInstanceOnSecondCall()
+ {
+ // Arrange
+ ApplicationContext.EnableReflectionCache = false;
+
+ // Act
+ var firstCall = ReflectionCache.GetRequestParameterProperties(typeof(TestClass));
+ var secondCall = ReflectionCache.GetRequestParameterProperties(typeof(TestClass));
+
+ // Assert - should be different array instances when not caching
+ Assert.NotSame(firstCall, secondCall);
+
+ // But content should be equivalent
+ Assert.Equal(firstCall.Length, secondCall.Length);
+ }
+
+ [Fact]
+ public void GetRequestParameterProperties_DefaultBehaviorIsNotCached()
+ {
+ // Arrange - don't set EnableReflectionCache, use default (false)
+ // (IDisposable restores the original state, so this tests a fresh-default scenario
+ // only when run as the first test; checking NotSame is sufficient)
+ ApplicationContext.EnableReflectionCache = false;
+
+ // Act
+ var firstCall = ReflectionCache.GetRequestParameterProperties(typeof(TestClass));
+ var secondCall = ReflectionCache.GetRequestParameterProperties(typeof(TestClass));
+
+ // Assert - default should be no caching
+ Assert.NotSame(firstCall, secondCall);
+ }
+ }
+}
diff --git a/Src/Support/Google.Apis/Upload/ResumableUpload.cs b/Src/Support/Google.Apis/Upload/ResumableUpload.cs
index 9dd3f75bfa1..579f4c1fc48 100644
--- a/Src/Support/Google.Apis/Upload/ResumableUpload.cs
+++ b/Src/Support/Google.Apis/Upload/ResumableUpload.cs
@@ -18,6 +18,7 @@ limitations under the License.
using Google.Apis.Json;
using Google.Apis.Logging;
using Google.Apis.Requests;
+using Google.Apis.Requests.Parameters;
using Google.Apis.Responses;
using Google.Apis.Services;
using Google.Apis.Testing;
@@ -1180,35 +1181,11 @@ private HttpRequestMessage CreateInitializeRequest()
///
private void SetAllPropertyValues(RequestBuilder requestBuilder)
{
- Type myType = this.GetType();
- var properties = myType.GetProperties();
-
- foreach (var property in properties)
- {
- var attribute = Utilities.GetCustomAttribute(property);
-
- if (attribute != null)
- {
- string name = attribute.Name ?? property.Name.ToLowerInvariant();
- object value = property.GetValue(this, null);
- if (value != null)
- {
- var valueAsEnumerable = value as IEnumerable;
- if (!(value is string) && valueAsEnumerable != null)
- {
- foreach (var elem in valueAsEnumerable)
- {
- requestBuilder.AddParameter(attribute.Type, name, Utilities.ConvertToString(elem));
- }
- }
- else
- {
- // Otherwise just convert it to a string.
- requestBuilder.AddParameter(attribute.Type, name, Utilities.ConvertToString(value));
- }
- }
- }
- }
+ // Use InitParametersWithExpansion which:
+ // 1. Uses ReflectionCache to avoid PropertyInfo regeneration (performance)
+ // 2. Expands IEnumerable/Repeatable parameters correctly (functionality)
+ // Note: Using the expansion variant ensures mismatched library versions fail at compile time
+ ParameterUtils.InitParametersWithExpansion(requestBuilder, this);
}
#endregion Upload Implementation