From 7c0c48863172debd3b1bdf516857523320e94d19 Mon Sep 17 00:00:00 2001 From: johanngan Date: Tue, 3 Nov 2026 16:48:34 -0600 Subject: [PATCH] feat(envconfig): add extension support Add support for envconfig extensions. These allow separate packages to extend envconfig with their own TOML keys and environment variables by including the ClientConfigExtension module. An extension operates on a config profile as input and outputs a plugin to be attached to the client created from envconfig output. A TOML file with an extension looks as follows: ``` [profile.default.ext.my_extension] some_field = "value" ``` An envconfig consumer specifies a list of extensions to use when loading connection options via ClientConfig.load_client_connect_options(extensions: [...]). Returned plugins are Client::Plugin instances set under the :plugins key of the resulting keyword arguments, so they are applied when the result is passed to Client.connect(*args, **kwargs). For now, extensions are not supported in other contexts. Co-Authored-By: Claude Opus 5.5 --- changelog/added/extension-extraordinaire.md | 1 + temporalio/ext/src/envconfig.rs | 39 ++- temporalio/lib/temporalio/env_config.rb | 95 +++++- temporalio/rbi/temporalio/env_config.rbi | 43 ++- temporalio/sig/temporalio/env_config.rbs | 22 +- temporalio/test/envconfig_test.rb | 313 ++++++++++++++++++++ temporalio/test/sig/envconfig_test.rbs | 31 +- 7 files changed, 528 insertions(+), 16 deletions(-) create mode 100644 changelog/added/extension-extraordinaire.md diff --git a/changelog/added/extension-extraordinaire.md b/changelog/added/extension-extraordinaire.md new file mode 100644 index 00000000..fd72a9b2 --- /dev/null +++ b/changelog/added/extension-extraordinaire.md @@ -0,0 +1 @@ +Added experimental envconfig extensions. Pass `extensions:` to `Temporalio::EnvConfig::ClientConfig.load_client_connect_options` to let separately packaged extensions read their own config from `profile..ext` and environment variables and apply it through client plugins. diff --git a/temporalio/ext/src/envconfig.rs b/temporalio/ext/src/envconfig.rs index 3d2b3712..9f3bd44f 100644 --- a/temporalio/ext/src/envconfig.rs +++ b/temporalio/ext/src/envconfig.rs @@ -1,12 +1,12 @@ use std::collections::HashMap; -use magnus::{Error, RHash, RString, Ruby, function, prelude::*, scan_args}; +use magnus::{Error, IntoValue, RHash, RString, Ruby, Value, function, prelude::*, scan_args}; use temporalio_common::envconfig::{ ClientConfig as CoreClientConfig, ClientConfigCodec, ClientConfigProfile as CoreClientConfigProfile, ClientConfigTLS as CoreClientConfigTLS, DataSource, LoadClientConfigOptions, LoadClientConfigProfileOptions, load_client_config as core_load_client_config, - load_client_config_profile as core_load_client_config_profile, + load_client_config_profile as core_load_client_config_profile, toml, }; use crate::{ROOT_MOD, error}; @@ -105,10 +105,45 @@ fn profile_to_hash(ruby: &Ruby, profile: &CoreClientConfigProfile) -> Result Result { + let hash = ruby.hash_new(); + for (k, v) in table { + hash.aset(ruby.str_new(k), toml_value_to_ruby(ruby, v)?)?; + } Ok(hash) } +// Datetimes become ISO 8601 strings (e.g. `1979-05-27T07:32:00Z`, or `1979-05-27` for a local +// date), since Ruby has no native types for TOML's local datetimes and local times. +fn toml_value_to_ruby(ruby: &Ruby, value: &toml::Value) -> Result { + Ok(match value { + toml::Value::String(s) => ruby.str_new(s).as_value(), + toml::Value::Integer(i) => i.into_value_with(ruby), + toml::Value::Float(f) => f.into_value_with(ruby), + toml::Value::Boolean(b) => b.into_value_with(ruby), + toml::Value::Datetime(dt) => ruby.str_new(&dt.to_string()).as_value(), + toml::Value::Array(a) => { + let ary = ruby.ary_new_capa(a.len()); + for v in a { + ary.push(toml_value_to_ruby(ruby, v)?)?; + } + ary.as_value() + } + toml::Value::Table(t) => toml_table_to_hash(ruby, t)?.as_value(), + }) +} + fn core_config_to_hash(ruby: &Ruby, core_config: &CoreClientConfig) -> Result { let profiles_hash = ruby.hash_new(); for (name, profile) in &core_config.profiles { diff --git a/temporalio/lib/temporalio/env_config.rb b/temporalio/lib/temporalio/env_config.rb index 8a2f6c03..c50a4440 100644 --- a/temporalio/lib/temporalio/env_config.rb +++ b/temporalio/lib/temporalio/env_config.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'pathname' +require 'temporalio/error' require 'temporalio/internal/bridge' module Temporalio @@ -136,7 +137,12 @@ def read_source(source) # @return [Boolean, ClientConfigTLS, nil] TLS configuration # @!attribute [r] grpc_meta # @return [Hash] gRPC metadata - ClientConfigProfile = Data.define(:address, :namespace, :api_key, :tls, :grpc_meta) + # @!attribute [r] ext + # @return [Hash>] Raw extension config from +profile..ext+, keyed by + # extension name. Each extension decodes its own table. TOML datetimes are ISO 8601 strings. Not modified by + # environment variables. This should be treated as read-only, since copies of a + # profile may share it. WARNING: Extensions are experimental. + ClientConfigProfile = Data.define(:address, :namespace, :api_key, :tls, :grpc_meta, :ext) # A client configuration profile loaded from environment and files. # @@ -153,7 +159,8 @@ def self.from_h(hash) namespace: hash[:namespace], api_key: hash[:api_key], tls: ClientConfigTLS.from_h(hash[:tls]), - grpc_meta: hash[:grpc_meta] || {} + grpc_meta: hash[:grpc_meta] || {}, + ext: hash[:ext] || {} ) end @@ -185,7 +192,7 @@ def self.load( end # Create a ClientConfigProfile instance with defaults - def initialize(address: nil, namespace: nil, api_key: nil, tls: nil, grpc_meta: {}) + def initialize(address: nil, namespace: nil, api_key: nil, tls: nil, grpc_meta: {}, ext: {}) super end @@ -197,7 +204,8 @@ def to_h namespace: namespace, api_key: api_key, tls: tls&.to_h&.then { |tls_hash| tls_hash.empty? ? nil : tls_hash }, - grpc_meta: grpc_meta && grpc_meta.empty? ? nil : grpc_meta + grpc_meta: grpc_meta && grpc_meta.empty? ? nil : grpc_meta, + ext: ext && ext.empty? ? nil : ext }.compact end @@ -222,6 +230,46 @@ def to_client_connect_options end end + # Extension mixin to include for extending client configuration with extra TOML properties and environment + # variables, and applying them to the client created from the configuration. + # + # Each extension owns a TOML table under +profile..ext.+, e.g.: + # + # [profile.default.ext.my_extension] + # some_field = "value" + # + # Extensions are recommended to use a +TEMPORAL_EXT__+ prefix for any environment variables they read. + # + # WARNING: Extensions are experimental. + module ClientConfigExtension + # @abstract + # @return [String] The extension's table key under +profile..ext+. Must be unique among loaded + # extensions. + def name + raise NotImplementedError + end + + # Read this extension's config from the profile and env, and return a plugin that applies it. + # + # @abstract + # @param request [ClientConfigExtensionRequest] Loaded profile and env. + # @return [Client::Plugin, nil] Plugin, or nil if there's nothing to apply. + def client_plugin(request) + raise NotImplementedError + end + end + + # Input to {ClientConfigExtension#client_plugin}. + # + # WARNING: Extensions are experimental. + # + # @!attribute [r] profile + # @return [ClientConfigProfile] Loaded profile, with environment variable overrides applied. + # @!attribute [r] env + # @return [Hash, nil] Environment variables to read extension env vars from, or nil if + # environment variable loading is disabled. + ClientConfigExtensionRequest = Data.define(:profile, :env) + # Client configuration loaded from TOML and environment variables. # # This contains a mapping of profile names to client profiles. @@ -273,7 +321,7 @@ def self.load( # Load a single client profile and convert to connect config # # This is a convenience function that combines loading a profile and - # converting it to a connect config hash. + # converting it to a connect config hash, plus applying extensions. # # @param profile [String, nil] The profile to load from the config # @param config_source [Pathname, String, nil] Configuration source @@ -281,6 +329,8 @@ def self.load( # @param disable_env [Boolean] If true, environment variable loading and overriding is disabled # @param config_file_strict [Boolean] If true, will error on unrecognized keys # @param override_env_vars [Hash, nil] Environment variables to use for loading and overrides + # @param extensions [Array] Extensions to call, in order, with the loaded profile. Plugins + # they return are included as +plugins+ in the keyword args. WARNING: Extensions are experimental. # @return [Array] Tuple of [positional_args, keyword_args] that can be splatted to Client.connect def self.load_client_connect_options( profile: nil, @@ -288,8 +338,10 @@ def self.load_client_connect_options( disable_file: false, disable_env: false, config_file_strict: false, - override_env_vars: nil + override_env_vars: nil, + extensions: [] ) + _validate_extensions!(extensions) prof = ClientConfigProfile.load( profile: profile, config_source: config_source, @@ -298,7 +350,36 @@ def self.load_client_connect_options( config_file_strict: config_file_strict, override_env_vars: override_env_vars ) - prof.to_client_connect_options + positional_args, keyword_args = prof.to_client_connect_options + return [positional_args, keyword_args] if extensions.empty? + + request = ClientConfigExtensionRequest.new( + profile: prof, + env: disable_env ? nil : override_env_vars || ENV.to_h + ) + plugins = extensions.filter_map do |extension| + extension.client_plugin(request) + rescue StandardError => e + raise Error, "client config extension \"#{extension.name}\": #{e.message}" + end + keyword_args[:plugins] = plugins unless plugins.empty? + [positional_args, keyword_args] + end + + # @!visibility private + def self._validate_extensions!(extensions) + names = {} + extensions.each_with_index do |extension, index| + unless extension.is_a?(ClientConfigExtension) + raise ArgumentError, "#{extension.class} does not implement EnvConfig::ClientConfigExtension" + end + + name = extension.name + raise ArgumentError, "client config extension at index #{index} has empty name" if name.empty? + raise ArgumentError, "duplicate client config extension name \"#{name}\"" if names.key?(name) + + names[name] = true + end end # Create a ClientConfig instance with defaults diff --git a/temporalio/rbi/temporalio/env_config.rbi b/temporalio/rbi/temporalio/env_config.rbi index ca9af2a8..245e8fab 100644 --- a/temporalio/rbi/temporalio/env_config.rbi +++ b/temporalio/rbi/temporalio/env_config.rbi @@ -65,13 +65,17 @@ class Temporalio::EnvConfig::ClientConfigProfile < ::Data sig { returns(T::Hash[String, String]) } def grpc_meta; end + sig { returns(T::Hash[String, T::Hash[String, Object]]) } + def ext; end + sig do params( address: T.nilable(String), namespace: T.nilable(String), api_key: T.nilable(String), tls: T.nilable(Temporalio::EnvConfig::ClientConfigTLS), - grpc_meta: T::Hash[String, String] + grpc_meta: T::Hash[String, String], + ext: T::Hash[String, T::Hash[String, Object]] ).void end def initialize( @@ -79,7 +83,8 @@ class Temporalio::EnvConfig::ClientConfigProfile < ::Data namespace: T.unsafe(nil), api_key: T.unsafe(nil), tls: T.unsafe(nil), - grpc_meta: T.unsafe(nil) + grpc_meta: T.unsafe(nil), + ext: T.unsafe(nil) ); end sig { params(hash: T::Hash[Symbol, Object]).returns(Temporalio::EnvConfig::ClientConfigProfile) } @@ -111,6 +116,34 @@ class Temporalio::EnvConfig::ClientConfigProfile < ::Data def to_client_connect_options; end end +module Temporalio::EnvConfig::ClientConfigExtension + sig { returns(String) } + def name; end + + sig do + params(request: Temporalio::EnvConfig::ClientConfigExtensionRequest).returns(T.nilable(Temporalio::Client::Plugin)) + end + def client_plugin(request); end +end + +class Temporalio::EnvConfig::ClientConfigExtensionRequest < ::Data + extend T::Sig + + sig { returns(Temporalio::EnvConfig::ClientConfigProfile) } + def profile; end + + sig { returns(T.nilable(T::Hash[String, String])) } + def env; end + + sig do + params( + profile: Temporalio::EnvConfig::ClientConfigProfile, + env: T.nilable(T::Hash[String, String]) + ).void + end + def initialize(profile:, env:); end +end + class Temporalio::EnvConfig::ClientConfig < ::Data extend T::Sig @@ -139,7 +172,8 @@ class Temporalio::EnvConfig::ClientConfig < ::Data disable_file: T::Boolean, disable_env: T::Boolean, config_file_strict: T::Boolean, - override_env_vars: T.nilable(T::Hash[String, String]) + override_env_vars: T.nilable(T::Hash[String, String]), + extensions: T::Array[Temporalio::EnvConfig::ClientConfigExtension] ).returns([T::Array[T.nilable(String)], T::Hash[Symbol, Object]]) end def self.load_client_connect_options( @@ -148,7 +182,8 @@ class Temporalio::EnvConfig::ClientConfig < ::Data disable_file: T.unsafe(nil), disable_env: T.unsafe(nil), config_file_strict: T.unsafe(nil), - override_env_vars: T.unsafe(nil) + override_env_vars: T.unsafe(nil), + extensions: T.unsafe(nil) ); end sig { override(allow_incompatible: true).returns(T::Hash[String, T::Hash[Symbol, Object]]) } diff --git a/temporalio/sig/temporalio/env_config.rbs b/temporalio/sig/temporalio/env_config.rbs index dbc3bfe1..2728f58d 100644 --- a/temporalio/sig/temporalio/env_config.rbs +++ b/temporalio/sig/temporalio/env_config.rbs @@ -35,6 +35,7 @@ module Temporalio attr_reader api_key: String? attr_reader tls: ClientConfigTLS? attr_reader grpc_meta: Hash[String, String] + attr_reader ext: Hash[String, Hash[String, untyped]] def self.from_h: (Hash[untyped, untyped] hash) -> ClientConfigProfile @@ -52,13 +53,27 @@ module Temporalio ?namespace: String?, ?api_key: String?, ?tls: ClientConfigTLS?, - ?grpc_meta: Hash[String, String] + ?grpc_meta: Hash[String, String], + ?ext: Hash[String, Hash[String, untyped]] ) -> void def to_h: -> Hash[Symbol, untyped] def to_client_connect_options: -> [Array[untyped], Hash[Symbol, untyped]] end + module ClientConfigExtension + def name: -> String + + def client_plugin: (ClientConfigExtensionRequest request) -> Client::Plugin? + end + + class ClientConfigExtensionRequest + attr_reader profile: ClientConfigProfile + attr_reader env: Hash[String, String]? + + def initialize: (profile: ClientConfigProfile, env: Hash[String, String]?) -> void + end + class ClientConfig attr_reader profiles: Hash[String, ClientConfigProfile] @@ -76,9 +91,12 @@ module Temporalio ?disable_file: bool, ?disable_env: bool, ?config_file_strict: bool, - ?override_env_vars: Hash[String, String]? + ?override_env_vars: Hash[String, String]?, + ?extensions: Array[ClientConfigExtension] ) -> [Array[untyped], Hash[Symbol, untyped]] + def self._validate_extensions!: (Array[ClientConfigExtension] extensions) -> void + def initialize: (?profiles: Hash[String, ClientConfigProfile]) -> void def to_h: -> Hash[String, Hash[Symbol, untyped]] end diff --git a/temporalio/test/envconfig_test.rb b/temporalio/test/envconfig_test.rb index 133817bd..7c890463 100644 --- a/temporalio/test/envconfig_test.rb +++ b/temporalio/test/envconfig_test.rb @@ -63,6 +63,20 @@ class EnvConfigTest < Test "mixed_Case-header" = "mixed-value" TOML + # TOML config with extension tables + TOML_CONFIG_EXT = <<~TOML + [profile.default] + address = "default-address" + [profile.default.ext.first] + value = "first-value" + nested = { list = [1, "two"], flag = true } + [profile.default.ext.second.deeper] + value = 5 + + [profile.other] + address = "other-address" + TOML + def setup super @original_temporal_env = ENV.to_h.select { |key, _| key.start_with?('TEMPORAL_') } @@ -955,6 +969,305 @@ def test_e2e_multi_profile_different_client_connections refute_nil prod_client.connection.options.tls # TLS enabled with API key end + # ============================================================================= + # EXTENSION TESTS + # ============================================================================= + + # Extension that returns whatever the given block returns + class TestExtension + include Temporalio::EnvConfig::ClientConfigExtension + + attr_reader :name + + def initialize(name, &block) + @name = name + @block = block + end + + def client_plugin(request) + @block&.call(request) + end + end + + class TestPlugin + include Temporalio::Client::Plugin + + attr_reader :name + + def initialize(name) + @name = name + end + end + + def test_load_profile_with_ext + [false, true].each do |strict| + profile = Temporalio::EnvConfig::ClientConfigProfile.load(config_source: TOML_CONFIG_EXT, + config_file_strict: strict) + assert_equal 'default-address', profile.address + assert_equal( + { + 'first' => { 'value' => 'first-value', 'nested' => { 'list' => [1, 'two'], 'flag' => true } }, + 'second' => { 'deeper' => { 'value' => 5 } } + }, + profile.ext + ) + + config = Temporalio::EnvConfig::ClientConfig.load(config_source: TOML_CONFIG_EXT, config_file_strict: strict) + assert_equal profile.ext, config.profiles['default']&.ext + assert_empty config.profiles['other']&.ext + end + end + + def test_load_profile_ext_value_conversion + profile = Temporalio::EnvConfig::ClientConfigProfile.load(config_source: <<~TOML) + [profile.default.ext.my_ext] + float = 1.5 + datetime = 1979-05-27T07:32:00Z + local_datetime = 1979-05-27T07:32:00 + local_date = 1979-05-27 + local_time = 07:32:00 + nan = nan + inf = -inf + TOML + ext = profile.ext['my_ext'] + assert ext['nan'].nan? + assert_equal( + { + 'float' => 1.5, + 'datetime' => '1979-05-27T07:32:00Z', + 'local_datetime' => '1979-05-27T07:32:00', + 'local_date' => '1979-05-27', + 'local_time' => '07:32:00', + 'inf' => -Float::INFINITY + }, + ext.except('nan') + ) + end + + def test_strict_mode_rejects_unknown_non_ext_keys_alongside_ext + err = assert_raises(Temporalio::Internal::Bridge::Error) do + Temporalio::EnvConfig::ClientConfigProfile.load(config_source: <<~TOML, config_file_strict: true) + [profile.default] + unrecognized_field = "should-fail" + [profile.default.ext.first] + value = "first-value" + TOML + end + assert_includes err.message, 'unrecognized_field' + end + + def test_ext_ignores_env_vars + profile = Temporalio::EnvConfig::ClientConfigProfile.load( + config_source: "[profile.default.ext.first]\nvalue = \"file-value\"", + override_env_vars: { 'TEMPORAL_EXT_FIRST_VALUE' => 'env-value' } + ) + assert_equal({ 'first' => { 'value' => 'file-value' } }, profile.ext) + end + + def test_client_config_profile_ext_to_from_h + ext = { 'first' => { 'value' => 'first-value' } } + profile = Temporalio::EnvConfig::ClientConfigProfile.new(address: 'some-address', ext:) + assert_equal({ address: 'some-address', ext: }, profile.to_h) + assert_equal ext, Temporalio::EnvConfig::ClientConfigProfile.from_h(profile.to_h).ext + end + + def test_load_client_connect_options_calls_extensions_in_order + env = { 'TEMPORAL_ADDRESS' => 'env-address', 'TEMPORAL_EXT_FIRST_VALUE' => 'env-value' } + calls = [] + first = TestExtension.new('first') do |req| + calls << 'first' + # Env vars have already been applied to the profile + assert_equal 'env-address', req.profile.address + assert_equal({ 'value' => 'file-value' }, req.profile.ext['first']) + assert_same env, req.env + TestPlugin.new("first-#{req.env&.[]('TEMPORAL_EXT_FIRST_VALUE')}") + end + noop = TestExtension.new('noop') do |req| + calls << 'noop' + assert_nil req.profile.ext['noop'] + nil + end + second = TestExtension.new('second') do + calls << 'second' + TestPlugin.new('second') + end + + args, kwargs = Temporalio::EnvConfig::ClientConfig.load_client_connect_options( + config_source: <<~TOML, + [profile.default] + address = "file-address" + [profile.default.ext.first] + value = "file-value" + TOML + config_file_strict: true, + override_env_vars: env, + extensions: [second, first, noop] + ) + assert_equal %w[second first noop], calls + assert_equal(%w[second first-env-value], kwargs[:plugins].map(&:name)) + assert_equal 'env-address', args[0] + end + + def test_load_client_connect_options_omits_plugins_without_any + [[], [TestExtension.new('noop')]].each do |extensions| + _, kwargs = Temporalio::EnvConfig::ClientConfig.load_client_connect_options( + config_source: TOML_CONFIG_EXT, override_env_vars: {}, extensions: + ) + refute_includes kwargs, :plugins + end + end + + def test_load_client_connect_options_passes_env_to_extensions + got_envs = [] + ext = TestExtension.new('ext') do |req| + got_envs << req.env + nil + end + env = { 'TEMPORAL_EXT_EXT_VALUE' => 'value' } + + with_system_env_vars({ 'TEMPORAL_EXT_EXT_VALUE' => 'system-value' }) do + # Defaults to system env + Temporalio::EnvConfig::ClientConfig.load_client_connect_options(config_source: '', extensions: [ext]) + # Uses override env when given + Temporalio::EnvConfig::ClientConfig.load_client_connect_options( + config_source: '', override_env_vars: env, extensions: [ext] + ) + # nil when env is disabled + Temporalio::EnvConfig::ClientConfig.load_client_connect_options( + config_source: '', disable_env: true, override_env_vars: env, extensions: [ext] + ) + end + + assert_equal 3, got_envs.size + assert_equal 'system-value', got_envs[0]&.[]('TEMPORAL_EXT_EXT_VALUE') + assert_same env, got_envs[1] + assert_nil got_envs[2] + end + + def test_load_client_connect_options_rejects_invalid_extensions_before_calling_any + new_ext = ->(name) { TestExtension.new(name) { flunk 'extension should not be called' } } + [ + [[new_ext.call('a'), new_ext.call('b'), new_ext.call('a')], 'duplicate client config extension name "a"'], + [[new_ext.call('a'), new_ext.call('')], 'client config extension at index 1 has empty name'] + ].each do |extensions, expected_message| + err = assert_raises(ArgumentError) do + Temporalio::EnvConfig::ClientConfig.load_client_connect_options( + config_source: '', override_env_vars: {}, extensions: # steep:ignore + ) + end + assert_equal expected_message, err.message + end + + block = proc do + err = assert_raises(ArgumentError) do + Temporalio::EnvConfig::ClientConfig.load_client_connect_options( + config_source: '', override_env_vars: {}, extensions: [nil] # steep:ignore + ) + end + assert_equal 'NilClass does not implement EnvConfig::ClientConfigExtension', err.message + end + # Passing the wrong type is the point of this check, so the runtime signature check must not record it. + if defined?(SigApplicator) + SigApplicator.suppress_errors { block.call } + else + block.call + end + end + + def test_load_client_connect_options_wraps_extension_errors + ext_err = RuntimeError.new('bad config') + err = assert_raises(Temporalio::Error) do + Temporalio::EnvConfig::ClientConfig.load_client_connect_options( + config_source: '', override_env_vars: {}, extensions: [TestExtension.new('bad') { raise ext_err }] + ) + end + assert_equal 'client config extension "bad": bad config', err.message + assert_same ext_err, err.cause + end + + # Example extension that reads an API key from a file, configured like: + # + # [profile.default.ext.api_key_file] + # path = "/path/to/api-key" + # + # or with the TEMPORAL_EXT_API_KEY_FILE_PATH env var, which takes precedence. + class ApiKeyFileExtension + include Temporalio::EnvConfig::ClientConfigExtension + + def name + 'api_key_file' + end + + def client_plugin(request) + key_path = request.profile.ext.dig(name, 'path') + raise TypeError, "path must be a string, got #{key_path.class}" unless key_path.nil? || key_path.is_a?(String) + + key_path = request.env&.fetch('TEMPORAL_EXT_API_KEY_FILE_PATH', nil) || key_path + return nil if key_path.nil? + + Plugin.new(File.read(key_path).strip) + end + + class Plugin + include Temporalio::Client::Plugin + + def initialize(api_key) + @api_key = api_key + end + + def name + 'example.ApiKeyFileExtension' + end + + def configure_client(options) + options + end + + def connect_client(options, next_call) + next_call.call(options.with(api_key: @api_key, tls: options.tls || true)) + end + end + end + + def test_example_extension + Dir.mktmpdir do |tmpdir| # steep:ignore + key_path = File.join(tmpdir, 'api-key') + File.write(key_path, "my-api-key\n") + + args, kwargs = Temporalio::EnvConfig::ClientConfig.load_client_connect_options( + config_source: <<~TOML, + [profile.default] + address = "localhost:7233" + namespace = "my-namespace" + [profile.default.ext.api_key_file] + path = #{key_path.inspect} + TOML + config_file_strict: true, + override_env_vars: {}, + extensions: [ApiKeyFileExtension.new] + ) + client = Temporalio::Client.connect(*args, **kwargs, lazy_connect: true) # steep:ignore + assert_equal 'my-api-key', client.connection.options.api_key + assert client.connection.options.tls + + # Nothing configured + _, unconfigured_kwargs = Temporalio::EnvConfig::ClientConfig.load_client_connect_options( + config_source: '', override_env_vars: {}, extensions: [ApiKeyFileExtension.new] + ) + refute_includes unconfigured_kwargs, :plugins + + # Invalid config + err = assert_raises(Temporalio::Error) do + Temporalio::EnvConfig::ClientConfig.load_client_connect_options( + config_source: "[profile.default.ext.api_key_file]\npath = 5", + override_env_vars: {}, + extensions: [ApiKeyFileExtension.new] + ) + end + assert_equal 'client config extension "api_key_file": path must be a string, got Integer', err.message + end + end + private def with_temp_config_file(content) diff --git a/temporalio/test/sig/envconfig_test.rbs b/temporalio/test/sig/envconfig_test.rbs index 7a8a445b..1458e0df 100644 --- a/temporalio/test/sig/envconfig_test.rbs +++ b/temporalio/test/sig/envconfig_test.rbs @@ -4,9 +4,38 @@ class EnvConfigTest < Test TOML_CONFIG_MALFORMED: String TOML_CONFIG_TLS_DETAILED: String TOML_CONFIG_GRPC_META: String + TOML_CONFIG_EXT: String + + class TestExtension + include Temporalio::EnvConfig::ClientConfigExtension + + attr_reader name: String + + def initialize: ( + String name + ) ?{ (Temporalio::EnvConfig::ClientConfigExtensionRequest request) -> Temporalio::Client::Plugin? } -> void + end + + class TestPlugin + include Temporalio::Client::Plugin + + attr_reader name: String + + def initialize: (String name) -> void + end + + class ApiKeyFileExtension + include Temporalio::EnvConfig::ClientConfigExtension + + class Plugin + include Temporalio::Client::Plugin + + def initialize: (String api_key) -> void + end + end private def with_temp_config_file: [T] (String content) { (String config_file) -> T } -> T def with_system_env_vars: [T] (Hash[String, String] env_vars) { () -> T } -> T -end \ No newline at end of file +end