Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/added/extension-extraordinaire.md
Original file line number Diff line number Diff line change
@@ -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.<name>.ext` and environment variables and apply it through client plugins.
39 changes: 37 additions & 2 deletions temporalio/ext/src/envconfig.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -105,10 +105,45 @@ fn profile_to_hash(ruby: &Ruby, profile: &CoreClientConfigProfile) -> Result<RHa
}
hash.aset(ruby.sym_new("grpc_meta"), grpc_meta_hash)?;
}
if !profile.ext.is_empty() {
let ext_hash = ruby.hash_new();
for (name, table) in &profile.ext {
ext_hash.aset(ruby.str_new(name), toml_table_to_hash(ruby, table)?)?;
}
hash.aset(ruby.sym_new("ext"), ext_hash)?;
}

Ok(hash)
}

fn toml_table_to_hash(ruby: &Ruby, table: &toml::Table) -> Result<RHash, Error> {
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<Value, Error> {
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<RHash, Error> {
let profiles_hash = ruby.hash_new();
for (name, profile) in &core_config.profiles {
Expand Down
95 changes: 88 additions & 7 deletions temporalio/lib/temporalio/env_config.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require 'pathname'
require 'temporalio/error'
require 'temporalio/internal/bridge'

module Temporalio
Expand Down Expand Up @@ -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<String, Hash<String, Object>>] Raw extension config from +profile.<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.
#
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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.<profile>.ext.<name>+, e.g.:
#
# [profile.default.ext.my_extension]
# some_field = "value"
#
# Extensions are recommended to use a +TEMPORAL_EXT_<NAME>_+ prefix for any environment variables they read.
#
# WARNING: Extensions are experimental.
module ClientConfigExtension
# @abstract
# @return [String] The extension's table key under +profile.<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<String, String>, 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.
Expand Down Expand Up @@ -273,23 +321,27 @@ 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
# @param disable_file [Boolean] If true, file loading is disabled
# @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<ClientConfigExtension>] 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,
config_source: nil,
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,
Expand All @@ -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
Expand Down
43 changes: 39 additions & 4 deletions temporalio/rbi/temporalio/env_config.rbi
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,26 @@ 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(
address: T.unsafe(nil),
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) }
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand All @@ -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]]) }
Expand Down
22 changes: 20 additions & 2 deletions temporalio/sig/temporalio/env_config.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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]

Expand All @@ -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
Expand Down
Loading
Loading