From 55180efdd9ba5f5a396bab1db8e2c15c08c34497 Mon Sep 17 00:00:00 2001 From: George Dietrich Date: Sat, 20 Mar 2021 16:13:53 -0400 Subject: [PATCH] Allow directly injecting configuration types (#27) * Allow directly injecting configuration types --- shard.yml | 4 +- spec/service_container_spec.cr | 7 +++ spec/service_mocks.cr | 42 ++++++++++++++ src/athena-dependency_injection.cr | 89 +++++++++++++++++++++++++++++- src/service_container.cr | 13 ++++- 5 files changed, 149 insertions(+), 6 deletions(-) diff --git a/shard.yml b/shard.yml index 1022863..9c70390 100644 --- a/shard.yml +++ b/shard.yml @@ -1,6 +1,6 @@ name: athena-dependency_injection -version: 0.2.6 +version: 0.3.0 crystal: '>= 0.35.0' @@ -19,7 +19,7 @@ authors: dependencies: athena-config: github: athena-framework/config - version: ~> 0.2.0 + version: ~> 0.3.0 development_dependencies: ameba: diff --git a/spec/service_container_spec.cr b/spec/service_container_spec.cr index ed6e572..4a7d413 100644 --- a/spec/service_container_spec.cr +++ b/spec/service_container_spec.cr @@ -221,5 +221,12 @@ describe Athena::DependencyInjection::ServiceContainer do service.password.should eq "PASS" service.credentials.should eq ["USER", "HOST"] end + + it "with configuration" do + service = ADI::ServiceContainer.new.configuration_client + service.some_config.value.should eq 123 + service.nilable_config.should be_nil + service.nested_config.value.should eq 456 + end end end diff --git a/spec/service_mocks.cr b/spec/service_mocks.cr index dc8ba17..fa3752d 100644 --- a/spec/service_mocks.cr +++ b/spec/service_mocks.cr @@ -451,3 +451,45 @@ record ParameterClient, username : String, password : String, credentials : Array(String) + +################# +# CONFIGURATION # +################# +@[ACFA::Resolvable("some_config")] +record SomeConfig, value : Int32 = 123 do + def self.configure + new + end +end + +@[ACFA::Resolvable("nested.cfg")] +record Nestedconfig, value : Int32 = 456 + +@[ACFA::Resolvable("nilable_config")] +record NilableConfig, value : Int32 = -1 do + def self.configure + nil + end +end + +class Nested + getter cfg : Nestedconfig = Nestedconfig.new +end + +class ACF::Base + getter some_config : SomeConfig = SomeConfig.configure + getter nilable_config : NilableConfig? = NilableConfig.configure + getter nested : Nested = Nested.new +end + +@[ADI::Register(public: true)] +class ConfigurationClient + getter some_config, nilable_config, nested_config + + def initialize( + @some_config : SomeConfig, + @nilable_config : NilableConfig?, + @nested_config : Nestedconfig + ) + end +end diff --git a/src/athena-dependency_injection.cr b/src/athena-dependency_injection.cr index e77c4cb..2a39aaf 100644 --- a/src/athena-dependency_injection.cr +++ b/src/athena-dependency_injection.cr @@ -418,7 +418,7 @@ module Athena::DependencyInjection # # ### Parameters # - # The `Athena::Config` component provides a way to manage `ACF::Parameters`. + # The `Athena::Config` component provides a way to manage `ACF::Parameters` objects used to define reusable [parameters](/components/config#parameters). # It is possible to inject these parameters directly into services in a type safe way. # # Parameter injection utilizes a specially formatted string, similar to tagged services. @@ -451,6 +451,93 @@ module Athena::DependencyInjection # service.db_username # => "USERNAME" # ``` # + # ### Configuration + # + # The `Athena::Config` component provides a way to manage `ACF::Base` objects used for [configuration](/components/config#configuration). + # The `Athena::DependencyInjection` component leverages the `ACFA::Resolvable` annotation to allow injecting entire configuration objects into services + # in addition to individual [parameters][Athena::DependencyInjection::Register--parameters]. + # + # The primary use case for is for types that have functionality that should be configurable by the end user. + # The configuration object could be injected as a constructor argument to set the value of instance variables, or be one itself. + # + # ``` + # # Define an example configuration type for a fictional Athena component. + # # The annotation argument describes the "path" to this particular configuration + # # type from `ACF.config`. I.e. `ACF.config.some_component`. + # @[ACFA::Resolvable("some_component")] + # struct SomeComponentConfig + # # By default return a new instance with a default value. + # def self.configure : self + # new + # end + # + # getter multiplier : Int32 + # + # def initialize(@multiplier : Int32 = 1); end + # end + # + # # This type would be a part of the `ACF::Base` type. + # class ACF::Base + # getter some_component : SomeComponentConfig = SomeComponentConfig.configure + # end + # + # # Define an example configurable service to use our configuration object. + # @[ADI::Register(public: true)] + # class MultiplierService + # @multiplier : Int32 + # + # def initialize(config : SomeComponentConfig) + # @multiplier = config.multiplier + # end + # + # def multiply(value : Number) + # value * @multiplier + # end + # end + # + # ADI.container.multiplier_service.multiply 10 # => 10 + # ``` + # + # By default our `MultiplierService` will use a multiplier of `1`, the default value in the `SomeComponentConfig`. + # However, if we wanted to change that value we could do something like this, without changing any of the earlier code. + # + # ``` + # # Override the configuration type's configure method + # # to supply our own custom multiplier value. + # def SomeComponentConfig.configure + # new 10 + # end + # + # ADI.container.multiplier_service.multiply 10 # => 100 + # ``` + # + # If the configurable service is also used outside of the service container, + # the [factory][Athena::DependencyInjection::Register--factories] pattern could also be used. + # + # ``` + # @[ADI::Register(public: true)] + # class MultiplierService + # # Tell the service container to use this constructor for DI. + # @[ADI::Inject] + # def self.new(config : SomeComponentConfig) + # # Using the configuration object to supply the argument to the standard initialize method. + # new config.multiplier + # end + # + # def initialize(@multiplier : Int32); end + # + # def multiply(value : Number) + # value * @multiplier + # end + # end + # + # # Multiplier from the service container. + # ADI.container.multiplier_service.multiply 10 # => 10 + # + # # A directly instantiated type. + # MultiplierService.new(10).multiply 10 # => 100 + # ``` + # # ### Optional Services # # Services defined with a nillable type restriction are considered to be optional. If no service could be resolved from the type, then `nil` is injected instead. diff --git a/src/service_container.cr b/src/service_container.cr index 7652f3d..f20ab6c 100644 --- a/src/service_container.cr +++ b/src/service_container.cr @@ -241,10 +241,17 @@ class Athena::DependencyInjection::ServiceContainer # If no services could be resolved if resolved_services.size == 0 # Return a default value if any - if !initializer_arg.default_value.is_a? Nop + + # First check to see if it's a resolvable configuration type. + if (configuration_type = initializer_arg.restriction.types.find(&.resolve.annotation ACFA::Resolvable)) && (configuration_ann = configuration_type.resolve.annotation ACFA::Resolvable) + path = configuration_ann[0] || configuration_ann["path"] || configuration_type.raise "Configuration type '#{configuration_type}' has an ACFA::Resolvable annotation but is missing the type's configuration path. It was not provided as the first positional argument nor via the 'path' field." + + "ACF.config.#{path.id}".id + elsif !initializer_arg.default_value.is_a? Nop + # Otherwise fallback on a default value, if any initializer_arg.default_value - # including `nil` if thats a possibility elsif initializer_arg.restriction.resolve.nilable? + # including `nil` if thats a possibility nil else # otherwise raise an exception @@ -254,7 +261,7 @@ class Athena::DependencyInjection::ServiceContainer # If only one was matched, return it, # using an ADI::Proxy object if thats what the initializer expects. if initializer_arg.restriction.resolve < ADI::Proxy - "ADI::Proxy.new(#{resolved_services[0]}, ->#{resolved_services[0].id.id})".id + "ADI::Proxy.new(#{resolved_services[0]}, ->#{resolved_services[0].id})".id else resolved_services[0].id end