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