Skip to content

Commit

Permalink
feat: Introduce API Key support into ClientBuilderBase
Browse files Browse the repository at this point in the history
The intention is to make it easy for APIs which *do* support API
keys to expose that, without providing misleading properties to
other client builders.
  • Loading branch information
jskeet committed Jan 20, 2024
1 parent 275c98f commit efb0bdd
Show file tree
Hide file tree
Showing 2 changed files with 176 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
* Copyright 2023 Google LLC All Rights Reserved.
* Use of this source code is governed by a BSD-style
* license that can be found in the LICENSE file or at
* https://developers.google.com/open-source/licenses/bsd
*/

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Threading;
using Xunit;
using Grpc.Core;

namespace Google.Api.Gax.Grpc.Tests;

public partial class ClientBuilderBaseTest
{
public class GetEffectiveSettingsTest
{
[Fact]
public void NoApiKey_NoSettings()
{
var builder = new SimpleBuilder(null);
var effectiveSettings = builder.GetEffectiveSettings(null);
Assert.Null(effectiveSettings);
}

[Fact]
public void ApiKey_NoSettings()
{
var builder = new SimpleBuilder("apikey1");
var effectiveSettings = builder.GetEffectiveSettings(null);
Assert.NotNull(effectiveSettings);
AssertApiKeyHeader(effectiveSettings.CallSettings, "apikey1");
}

[Fact]
public void NoApiKey_WithSettings()
{
var builder = new SimpleBuilder("apikey1");
var originalCallSettings = CallSettings.FromExpiration(Expiration.FromTimeout(TimeSpan.FromSeconds(5)));
var settings = new SimpleSettings { CallSettings = originalCallSettings };
var effectiveSettings = builder.GetEffectiveSettings(settings);
Assert.Same(effectiveSettings, settings);
Assert.NotSame(originalCallSettings, settings.CallSettings);
}

[Fact]
public void ApiKey_WithSettings()
{
var builder = new SimpleBuilder("apikey2");
var originalCallSettings = CallSettings.FromExpiration(Expiration.FromTimeout(TimeSpan.FromSeconds(5)));
var settings = new SimpleSettings
{
CallSettings = originalCallSettings,
RpcCallSettings = CallSettings.FromHeader("x", "y")
};
var clone = settings.Clone();
var effectiveSettings = builder.GetEffectiveSettings(clone);
// We modify the clone, which is why we need to clone in the first place.
Assert.Same(effectiveSettings, clone);

// The CallSettings should have been merged, but the original ones left untouched.
Assert.NotSame(originalCallSettings, effectiveSettings.CallSettings);
AssertApiKeyHeader(effectiveSettings.CallSettings, "apikey2");
Assert.Equal(settings.CallSettings.Expiration, effectiveSettings.CallSettings.Expiration);
// The RpcCallSettings should be as it was
Assert.Same(settings.RpcCallSettings, effectiveSettings.RpcCallSettings);
}

private void AssertApiKeyHeader(CallSettings callSettings, string expectedApiKey)
{
Assert.NotNull(callSettings);
var metadata = new Metadata();
callSettings.HeaderMutation(metadata);
Assert.Equal(1, metadata.Count);
var entry = metadata[0];
Assert.Equal(ClientBuilderBase<string>.ApiKeyHeader, entry.Key);
Assert.False(entry.IsBinary);
Assert.Equal(expectedApiKey, entry.Value);
}

private class SimpleBuilder : ClientBuilderBase<string>
{
internal SimpleBuilder(string apiKey) : base(TestServiceMetadata.TestService) =>
ApiKey = apiKey;

public SimpleSettings GetEffectiveSettings(SimpleSettings settings) => base.GetEffectiveSettings<SimpleSettings>(settings);

public new GrpcChannelOptions GetChannelOptions() => base.GetChannelOptions();
public override string Build() => throw new NotImplementedException();
public override Task<string> BuildAsync(CancellationToken cancellationToken = default) => throw new NotImplementedException();
protected override ChannelPool GetChannelPool() => throw new NotImplementedException();
}

/// <summary>
/// Simple example representative of a generated settings class.
/// </summary>
private partial class SimpleSettings : ServiceSettingsBase
{
/// <summary>Get a new instance of the default <see cref="SimpleSettings"/>.</summary>
/// <returns>A new instance of the default <see cref="SimpleSettings"/>.</returns>
public static SimpleSettings GetDefault() => new SimpleSettings();

/// <summary>Constructs a new <see cref="SimpleSettings"/> object with default settings.</summary>
public SimpleSettings()
{
}

private SimpleSettings(SimpleSettings existing) : base(existing)
{
GaxPreconditions.CheckNotNull(existing, nameof(existing));
RpcCallSettings = existing.RpcCallSettings;
OnCopy(existing);
}

/// <summary>
/// An example of a per-RPC call settings
/// </summary>
public CallSettings RpcCallSettings { get; set; } = CallSettings.FromExpiration(Expiration.FromTimeout(TimeSpan.FromMilliseconds(3600000)));

partial void OnCopy(SimpleSettings existing);

/// <summary>Creates a deep clone of this object, with all the same property values.</summary>
/// <returns>A deep clone of this <see cref="SimpleSettings"/> object.</returns>
public SimpleSettings Clone() => new SimpleSettings(this);
}
}
}
51 changes: 46 additions & 5 deletions Google.Api.Gax.Grpc/ClientBuilderBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ namespace Google.Api.Gax.Grpc
/// <typeparam name="TClient">The type of client created by this builder.</typeparam>
public abstract class ClientBuilderBase<TClient>
{
internal const string ApiKeyHeader = "x-goog-api-key";

/// <summary>
/// The default gRPC options.
/// </summary>
Expand Down Expand Up @@ -115,6 +117,17 @@ protected EmulatorDetection EmulatorDetection
set => _emulatorDetection = GaxPreconditions.CheckEnumValue(value, nameof(value));
}

/// <summary>
/// An API key to use as an alternative to a full credential.
/// </summary>
/// <remarks>
/// This is protected as not all APIs support API keys. APIs which support API keys
/// should declare a new public property (also called ApiKey) in the concrete client builder class,
/// and ensure they call <see cref="GetEffectiveSettings{T}(T)"/> to potentially specify the API key header
/// via CallSettings.
/// </remarks>
protected string ApiKey { get; set; }

/// <summary>
/// The GCP project ID that should be used for quota and billing purposes.
/// May be null.
Expand Down Expand Up @@ -177,6 +190,7 @@ protected void CopyCommonSettings<TOther>(ClientBuilderBase<TOther> source)
QuotaProject = source.QuotaProject;
UseJwtAccessWithScopes = source.UseJwtAccessWithScopes;
Logger = source.Logger;
ApiKey = source.ApiKey;

// Note that we may be copying from one type that supports emulators (e.g. FirestoreDbBuilder)
// to another type that doesn't (e.g. FirestoreClientBuilder). That ends up in a slightly odd situation,
Expand All @@ -198,6 +212,30 @@ protected void CopySettingsForEmulator(ClientBuilderBase<TClient> source)
Logger = source.Logger;
}

/// <summary>
/// Returns the effective settings for this builder, taking into account API keys and any other properties
/// which may require additional settings (typically via <see cref="ServiceSettingsBase.CallSettings"/>).
/// </summary>
/// <remarks>This method only needs to be called if the concrete builder type knows that the settings may
/// need to be modified (e.g. if the API supports API keys). It should typically be called as
/// <c>GetEffectiveSettings(Settings?.Clone())</c>.</remarks>
/// <typeparam name="T">The concrete settings type, derived from <see cref="ServiceSettingsBase"/>, with a
/// parameterless constructor that can be used to construct a new default instance.</typeparam>
/// <param name="settings">A clone of the existing settings specified in the concrete builder type. May be null.</param>
/// <returns>The appropriate effective settings for this builder, or null if no settings have been
/// provided and no other properties require additional settings. Note that clone operations are provided
/// on a per-concrete-type basis, so this method must accept already-cloned settings.</returns>
protected T GetEffectiveSettings<T>(T settings) where T : ServiceSettingsBase, new()
{
if (ApiKey is null)
{
return settings;
}
settings ??= new T();
settings.CallSettings = settings.CallSettings.WithHeader(ApiKeyHeader, ApiKey);
return settings;
}

/// <summary>
/// Validates that the builder is in a consistent state for building. For example, it's invalid to call
/// <see cref="Build()"/> on an instance which has both JSON credentials and a credentials path specified.
Expand All @@ -211,14 +249,14 @@ protected virtual void Validate()
ChannelCredentials, CredentialsPath, JsonCredentials, Scopes, Endpoint, TokenAccessMethod, GoogleCredential, Credential);

ValidateAtMostOneNotNull("Only one source of credentials can be specified",
ChannelCredentials, CredentialsPath, JsonCredentials, TokenAccessMethod, GoogleCredential, Credential);
ChannelCredentials, CredentialsPath, JsonCredentials, TokenAccessMethod, GoogleCredential, Credential, ApiKey);

ValidateOptionExcludesOthers("Scopes are not relevant when a token access method, channel credentials or ICredential are supplied", Scopes,
TokenAccessMethod, ChannelCredentials, Credential);
ValidateOptionExcludesOthers("Scopes are not relevant when a token access method, channel credentials, ICredential or ApiKey are supplied", Scopes,
TokenAccessMethod, ChannelCredentials, Credential, ApiKey);
#pragma warning restore CS0618 // Type or member is obsolete

ValidateOptionExcludesOthers($"{nameof(QuotaProject)} cannot be specified if a {nameof(CallInvoker)}, {nameof(ChannelCredentials)} or {nameof(Credential)} is specified", QuotaProject,
CallInvoker, ChannelCredentials, Credential);
ValidateOptionExcludesOthers($"{nameof(QuotaProject)} cannot be specified if a {nameof(CallInvoker)}, {nameof(ChannelCredentials)}, {nameof(Credential)} or {nameof(ApiKey)} is specified", QuotaProject,
CallInvoker, ChannelCredentials, Credential, ApiKey);
}

/// <summary>
Expand Down Expand Up @@ -286,6 +324,7 @@ protected Dictionary<string, string> GetEmulatorEnvironment(
CheckNotSet(QuotaProject, nameof(QuotaProject));
CheckNotSet(Credential, nameof(Credential));
CheckNotSet(GoogleCredential, nameof(GoogleCredential));
CheckNotSet(ApiKey, nameof(ApiKey));

void CheckNotSet(object obj, string name)
{
Expand Down Expand Up @@ -424,6 +463,7 @@ protected async virtual Task<ChannelCredentials> GetChannelCredentialsAsync(Canc
/// </summary>
private ChannelCredentials MaybeGetSimpleChannelCredentials() =>
ChannelCredentials ?? Credential?.ToChannelCredentials() ??
(ApiKey is not null ? ChannelCredentials.SecureSsl : null) ??
#pragma warning disable CS0618 // Type or member is obsolete
(TokenAccessMethod is not null ? new DelegatedTokenAccess(TokenAccessMethod, QuotaProject).ToChannelCredentials() : null);
#pragma warning restore CS0618 // Type or member is obsolete
Expand Down Expand Up @@ -515,6 +555,7 @@ protected virtual GrpcChannelOptions GetChannelOptions()
QuotaProject == null &&
GoogleCredential == null &&
Credential == null &&
ApiKey == null &&
UseJwtAccessWithScopes == GetChannelPool().UseJwtAccessWithScopes;

/// <summary>
Expand Down

0 comments on commit efb0bdd

Please sign in to comment.