diff --git a/src/components/dependency_injection/spec/compiler_passes/calls_spec.cr b/src/components/dependency_injection/spec/compiler_passes/calls_spec.cr deleted file mode 100644 index a124d0a40..000000000 --- a/src/components/dependency_injection/spec/compiler_passes/calls_spec.cr +++ /dev/null @@ -1,44 +0,0 @@ -require "../spec_helper" - -private def assert_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil - ASPEC::Methods.assert_error message, <<-CR, line: line - require "../spec_helper.cr" - #{code} - ADI::ServiceContainer.new - CR -end - -@[ADI::Register(public: true, calls: [ - {"foo"}, - {"foo", {3}}, - {"foo", {6}}, -])] -class CallClient - getter values = [] of Int32 - - def foo(value : Int32 = 1) - @values << value - end -end - -describe ADI::ServiceContainer do - describe "compiler errors", tags: "compiled" do - it "errors if the method of a call is empty" do - assert_error "Method name cannot be empty.", <<-CR - @[ADI::Register(calls: [{""}])] - record Foo - CR - end - - it "errors if the method does not exist on the type" do - assert_error "Failed to auto register service for 'foo' (Foo). Call references non-existent method 'foo'.", <<-CR - @[ADI::Register(calls: [{"foo"}])] - record Foo - CR - end - end - - it "allows defining calls" do - ADI.container.call_client.values.should eq [1, 3, 6] - end -end diff --git a/src/components/dependency_injection/spec/compiler_passes/define_getters_spec.cr b/src/components/dependency_injection/spec/compiler_passes/define_getters_spec.cr new file mode 100644 index 000000000..0badad1cb --- /dev/null +++ b/src/components/dependency_injection/spec/compiler_passes/define_getters_spec.cr @@ -0,0 +1,88 @@ +require "../spec_helper" + +private def assert_success(code : String, *, line : Int32 = __LINE__) : Nil + ASPEC::Methods.assert_success <<-CR, codegen: true, line: line + require "../spec_helper.cr" + #{code} + ADI::ServiceContainer.new + CR +end + +private def assert_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil + ASPEC::Methods.assert_error message, <<-CR, line: line + require "../spec_helper.cr" + #{code} + CR +end + +private def assert_success(code : String, *, line : Int32 = __LINE__) : Nil + ASPEC::Methods.assert_success <<-CR, codegen: true, line: line + require "../spec_helper.cr" + #{code} + ADI::ServiceContainer.new + CR +end + +describe ADI::ServiceContainer::DefineGetters, tags: "compiled" do + describe "compiler errors" do + describe "aliases" do + it "does not expose named getter for non-public string aliases" do + assert_error "undefined method 'bar' for Athena::DependencyInjection::ServiceContainer", <<-CR + module SomeInterface; end + + @[ADI::Register] + @[ADI::AsAlias("bar")] + class Foo + include SomeInterface + end + + ADI.container.bar + CR + end + + it "does not expose typed getter for non-public typed aliases" do + assert_error "undefined method 'get' for Athena::DependencyInjection::ServiceContainer", <<-CR + module SomeInterface; end + + @[ADI::Register] + @[ADI::AsAlias] + class Foo + include SomeInterface + end + + ADI.container.get SomeInterface + CR + end + end + end + + describe "aliases" do + it "exposes named getter for public string alias" do + assert_success <<-CR + module SomeInterface; end + + @[ADI::Register] + @[ADI::AsAlias("bar", public: true)] + class Foo + include SomeInterface + end + + ADI.container.bar.should be_a Foo + CR + end + + it "exposes typed getter for public typed alias" do + assert_success <<-CR + module SomeInterface; end + + @[ADI::Register] + @[ADI::AsAlias(public: true)] + class Foo + include SomeInterface + end + + ADI.container.get(SomeInterface).should be_a Foo + CR + end + end +end diff --git a/src/components/dependency_injection/spec/compiler_passes/factory_spec.cr b/src/components/dependency_injection/spec/compiler_passes/factory_spec.cr deleted file mode 100644 index fb1ba3d32..000000000 --- a/src/components/dependency_injection/spec/compiler_passes/factory_spec.cr +++ /dev/null @@ -1,115 +0,0 @@ -require "../spec_helper" - -private def assert_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil - ASPEC::Methods.assert_error message, <<-CR, line: line - require "../spec_helper.cr" - #{code} - ADI::ServiceContainer.new - CR -end - -class TestFactory - def self.create_factory_tuple(value : Int32) : FactoryTuple - FactoryTuple.new value * 3 - end - - def self.create_factory_service(value_provider : ValueProvider) : FactoryService - FactoryService.new value_provider.valuee - end -end - -@[ADI::Register(_value: 10, public: true, factory: {TestFactory, "create_factory_tuple"})] -class FactoryTuple - getter value : Int32 - - def initialize(@value : Int32); end -end - -@[ADI::Register(_value: 10, public: true, factory: "double")] -class FactoryString - getter value : Int32 - - def self.double(value : Int32) : self - new value * 2 - end - - def initialize(@value : Int32); end -end - -@[ADI::Register(_value: 50, public: true)] -class PseudoFactory - getter value : Int32 - - @[ADI::Inject] - def self.new_instance(value : Int32) : self - new value * 2 - end - - def initialize(@value : Int32); end -end - -@[ADI::Register] -record ValueProvider, valuee : Int32 = 10 - -@[ADI::Register(public: true, factory: {TestFactory, "create_factory_service"})] -class FactoryService - getter value : Int32 - - def initialize(@value : Int32); end -end - -@[ADI::Register(_value: 99, public: true)] -class InstanceInjectService - getter value : Int32 - - def initialize(value : String) - @value = value.to_i - end - - @[ADI::Inject] - def initialize(@value : Int32); end -end - -describe ADI::ServiceContainer::RegisterServices do - describe "compiler errors", tags: "compiled" do - it "errors if a factory method is an instance method" do - assert_error "Failed to auto register service 'foo'. Factory method 'foo' within 'Foo' is an instance method.", <<-CR - @[ADI::Register(factory: "foo")] - record Foo do - def foo; end - end - CR - end - - it "errors if a factory method is missing" do - assert_error "Failed to auto register service 'foo'. Factory method 'foo' within 'Foo' does not exist.", <<-CR - @[ADI::Register(factory: "foo")] - record Foo - CR - end - end - - describe "with factory based services" do - it "supports passing a tuple" do - ADI::ServiceContainer.new.factory_tuple.value.should eq 30 - end - - it "supports passing the string method name" do - ADI::ServiceContainer.new.factory_string.value.should eq 20 - end - - it "supports auto resolving factory method service dependencies" do - ADI::ServiceContainer.new.factory_service.value.should eq 10 - end - - describe "with an ADI:Inject annotation" do - it "on a class method" do - ADI::ServiceContainer.new.pseudo_factory.value.should eq 100 - end - - it "allows specifying which initialize method to use" do - ADI::ServiceContainer.new.instance_inject_service.value.should eq 99 - end - end - end -end diff --git a/src/components/dependency_injection/spec/compiler_passes/process_aliases_spec.cr b/src/components/dependency_injection/spec/compiler_passes/process_aliases_spec.cr new file mode 100644 index 000000000..c3b22e194 --- /dev/null +++ b/src/components/dependency_injection/spec/compiler_passes/process_aliases_spec.cr @@ -0,0 +1,136 @@ +require "../spec_helper" + +private def assert_success(code : String, *, line : Int32 = __LINE__) : Nil + ASPEC::Methods.assert_success <<-CR, codegen: true, line: line + require "../spec_helper.cr" + #{code} + ADI::ServiceContainer.new + CR +end + +private def assert_error(message : String, code : String, *, line : Int32 = __LINE__) : Nil + ASPEC::Methods.assert_error message, <<-CR, line: line + require "../spec_helper.cr" + #{code} + ADI::ServiceContainer.new + CR +end + +describe ADI::ServiceContainer::ProcessAliases, tags: "compiled" do + it "errors if unable to determine the alias name" do + assert_error "Alias cannot be automatically determined for 'foo' (Foo). If the type includes multiple interfaces, provide the interface to alias as the first positional argument to `@[ADI::AsAlias]`.", <<-CR + module SomeInterface; end + module OtherInterface; end + + @[ADI::Register] + @[ADI::AsAlias] + class Foo + include SomeInterface + include OtherInterface + end + + macro finished + macro finished + it { \\{{ADI::ServiceContainer::ALIASES.keys}}.should eq [SomeInterface] } + it { \\{{ADI::ServiceContainer::ALIASES[SomeInterface]["id"].stringify}}.should eq %("foo") } + it { \\{{ADI::ServiceContainer::ALIASES[SomeInterface]["public"]}}.should be_false } + end + end + CR + end + + it "allows explicit string alias name" do + assert_success <<-CR + @[ADI::Register] + @[ADI::AsAlias("bar")] + class Foo; end + + macro finished + macro finished + it { \\{{ADI::ServiceContainer::ALIASES.keys}}.should eq ["bar"] } + it { \\{{ADI::ServiceContainer::ALIASES["bar"]["id"].stringify}}.should eq %("foo") } + it { \\{{ADI::ServiceContainer::ALIASES["bar"]["public"]}}.should be_false } + end + end + CR + end + + it "allows explicit const alias name" do + assert_success <<-CR + BAR = "bar" + + @[ADI::Register] + @[ADI::AsAlias(BAR)] + class Foo; end + + macro finished + macro finished + it { \\{{ADI::ServiceContainer::ALIASES.keys}}.should eq ["bar"] } + it { \\{{ADI::ServiceContainer::ALIASES["bar"]["id"].stringify}}.should eq %("foo") } + it { \\{{ADI::ServiceContainer::ALIASES["bar"]["public"]}}.should be_false } + end + end + CR + end + + it "allows explicit TypeNode alias name" do + assert_success <<-CR + module SomeInterface; end + + @[ADI::Register] + @[ADI::AsAlias(SomeInterface, public: true)] + class Foo + include SomeInterface + end + + macro finished + macro finished + it { \\{{ADI::ServiceContainer::ALIASES.keys}}.should eq [SomeInterface] } + it { \\{{ADI::ServiceContainer::ALIASES[SomeInterface]["id"].stringify}}.should eq %("foo") } + it { \\{{ADI::ServiceContainer::ALIASES[SomeInterface]["public"]}}.should be_true } + end + end + CR + end + + it "uses included interface type as alias name if there is only 1" do + assert_success <<-CR + module SomeInterface; end + + @[ADI::Register] + @[ADI::AsAlias] + class Foo + include SomeInterface + end + + macro finished + macro finished + it { \\{{ADI::ServiceContainer::ALIASES.keys}}.should eq [SomeInterface] } + it { \\{{ADI::ServiceContainer::ALIASES[SomeInterface]["id"].stringify}}.should eq %("foo") } + it { \\{{ADI::ServiceContainer::ALIASES[SomeInterface]["public"]}}.should be_false } + end + end + CR + end + + it "allows aliasing more than one interface" do + assert_success <<-CR + module SomeInterface; end + module OtherInterface; end + + @[ADI::Register] + @[ADI::AsAlias(SomeInterface)] + @[ADI::AsAlias(OtherInterface)] + class Foo + include SomeInterface + include OtherInterface + end + + macro finished + macro finished + it { \\{{ADI::ServiceContainer::ALIASES.keys}}.should eq [SomeInterface, OtherInterface] } + end + end + CR + end +end diff --git a/src/components/dependency_injection/spec/compiler_passes/process_auto_configurations_spec.cr b/src/components/dependency_injection/spec/compiler_passes/process_auto_configurations_spec.cr index d1b18f4ec..1b7d9b7b0 100644 --- a/src/components/dependency_injection/spec/compiler_passes/process_auto_configurations_spec.cr +++ b/src/components/dependency_injection/spec/compiler_passes/process_auto_configurations_spec.cr @@ -244,20 +244,6 @@ describe ADI::ServiceContainer::ProcessAutoconfigureAnnotations do CR end - it "errors if not all tags have a `name` field" do - assert_error "Failed to auto register service 'foo'. All tags must have a name.", <<-CR - @[ADI::Register(tags: [{priority: 100}])] - record Foo - CR - end - - it "errors if not all tags are of the proper type" do - assert_error "Tag '100' must be a 'StringLiteral' or 'NamedTupleLiteral', got 'NumberLiteral'.", <<-CR - @[ADI::Register(tags: [100])] - record Foo - CR - end - describe ADI::TaggedIterator do it "errors if used with unsupported collection type" do assert_error "Failed to register service 'fqn_tagged_iterator_named_client' (FQNTaggedIteratorNamedClient). Collection parameter '@[ADI::TaggedIterator] services : Set(String)' type must be one of `Indexable`, `Iterator`, or `Enumerable`. Got 'Set'.", <<-CR diff --git a/src/components/dependency_injection/spec/compiler_passes/register_services_spec.cr b/src/components/dependency_injection/spec/compiler_passes/register_services_spec.cr index b9c1afdda..902ed7d7a 100644 --- a/src/components/dependency_injection/spec/compiler_passes/register_services_spec.cr +++ b/src/components/dependency_injection/spec/compiler_passes/register_services_spec.cr @@ -8,65 +8,195 @@ private def assert_error(message : String, code : String, *, line : Int32 = __LI CR end -module AliasInterface; end - +# Happy Path @[ADI::Register] -record AliasOne do - include AliasInterface +class SingleService + getter value : Int32 = 1 end -@[ADI::Register(alias: AliasInterface)] -record AliasTwo do - include AliasInterface +@[ADI::Register(public: true)] +class SingleClient + getter service : SingleService + + def initialize(@service : SingleService); end end -@[ADI::Register(public: true)] -record AliasService, a_service : AliasInterface +# Factories +class TestFactory + def self.create_factory_tuple(value : Int32) : FactoryTuple + FactoryTuple.new value * 3 + end -module MultipleAliasOne; end + def self.create_factory_service(value_provider : ValueProvider) : FactoryService + FactoryService.new value_provider.valuee + end +end -module MultipleAliasTwo; end +@[ADI::Register(_value: 10, public: true, factory: {TestFactory, "create_factory_tuple"})] +class FactoryTuple + getter value : Int32 -module MultipleAliasThree; end + def initialize(@value : Int32); end +end -@[ADI::Register(alias: [MultipleAliasOne, MultipleAliasTwo])] -record TheService do - include MultipleAliasOne - include MultipleAliasTwo +@[ADI::Register(_value: 10, public: true, factory: "double")] +class FactoryString + getter value : Int32 + + def self.double(value : Int32) : self + new value * 2 + end + + def initialize(@value : Int32); end end -@[ADI::Register(alias: MultipleAliasThree)] -record OtherService do - include MultipleAliasThree +@[ADI::Register(_value: 50, public: true)] +class PseudoFactory + getter value : Int32 + + @[ADI::Inject] + def self.new_instance(value : Int32) : self + new value * 2 + end + + def initialize(@value : Int32); end end -@[ADI::Register(public: true)] -record MultipleAliasService, - one : MultipleAliasOne, - two : MultipleAliasTwo, - three : MultipleAliasThree, - four : MultipleAliasOne | MultipleAliasTwo +@[ADI::Register] +record ValueProvider, valuee : Int32 = 10 + +@[ADI::Register(public: true, factory: {TestFactory, "create_factory_service"})] +class FactoryService + getter value : Int32 + + def initialize(@value : Int32); end +end + +@[ADI::Register(_value: 99, public: true)] +class InstanceInjectService + getter value : Int32 + + def initialize(value : String) + @value = value.to_i + end + + @[ADI::Inject] + def initialize(@value : Int32); end +end + +# Calls +@[ADI::Register(public: true, calls: [ + {"foo"}, + {"foo", {3}}, + {"foo", {6}}, +])] +class CallClient + getter values = [] of Int32 + + def foo(value : Int32 = 1) + @values << value + end +end describe ADI::ServiceContainer::RegisterServices do describe "compiler errors", tags: "compiled" do it "errors if a service has multiple ADI::Register annotations but not all of them have a name" do - assert_error "Failed to auto register services for 'Foo'. Services based on this type must each explicitly provide a name.", <<-CR + assert_error "Failed to auto register services for 'Foo'. Each service must explicitly provide a name when auto registering more than one service based on the same type.", <<-CR @[ADI::Register(name: "one")] @[ADI::Register] record Foo CR end + + it "errors if the generic service does not have a name." do + assert_error "Failed to auto register service for 'Foo(T)'. Generic services must explicitly provide a name.", <<-CR + @[ADI::Register] + record Foo(T) + CR + end + + describe "factory" do + it "errors if method is an instance method" do + assert_error "Failed to auto register service 'foo'. Factory method 'foo' within 'Foo' is an instance method.", <<-CR + @[ADI::Register(factory: "foo")] + record Foo do + def foo; end + end + CR + end + + it "errors if the method is missing" do + assert_error "Failed to auto register service 'foo'. Factory method 'foo' within 'Foo' does not exist.", <<-CR + @[ADI::Register(factory: "foo")] + record Foo + CR + end + end + + describe "tags" do + it "errors if not all tags have a `name` field" do + assert_error "Failed to auto register service 'foo'. All tags must have a name.", <<-CR + @[ADI::Register(tags: [{priority: 100}])] + record Foo + CR + end + + it "errors if not all tags are of the proper type" do + assert_error "Tag '100' must be a 'StringLiteral' or 'NamedTupleLiteral', got 'NumberLiteral'.", <<-CR + @[ADI::Register(tags: [100])] + record Foo + CR + end + end + + describe "calls" do + it "errors if the method of a call is empty" do + assert_error "Method name cannot be empty.", <<-CR + @[ADI::Register(calls: [{""}])] + record Foo + CR + end + + it "errors if the method does not exist on the type" do + assert_error "Failed to auto register service for 'foo' (Foo). Call references non-existent method 'foo'.", <<-CR + @[ADI::Register(calls: [{"foo"}])] + record Foo + CR + end + end + end + + describe "with factory based services" do + it "supports passing a tuple" do + ADI::ServiceContainer.new.factory_tuple.value.should eq 30 + end + + it "supports passing the string method name" do + ADI::ServiceContainer.new.factory_string.value.should eq 20 + end + + it "supports auto resolving factory method service dependencies" do + ADI::ServiceContainer.new.factory_service.value.should eq 10 + end + + describe "with an ADI:Inject annotation" do + it "on a class method" do + ADI::ServiceContainer.new.pseudo_factory.value.should eq 100 + end + + it "allows specifying which initialize method to use" do + ADI::ServiceContainer.new.instance_inject_service.value.should eq 99 + end + end end - it "supports aliasing a specific service for an interface" do - ADI.container.alias_service.a_service.should be_a AliasTwo + it "correctly resolves the service" do + service = ADI.container.single_client.service + service.should be_a SingleService + service.value.should eq 1 end - it "supports aliasing a service to multiple other interfaces" do - service = ADI.container.multiple_alias_service - service.one.should be_a TheService - service.two.should be_a TheService - service.three.should be_a OtherService - service.four.should be_a TheService + it "registers calls" do + ADI.container.call_client.values.should eq [1, 3, 6] end end diff --git a/src/components/dependency_injection/spec/compiler_passes/resolve_values_spec.cr b/src/components/dependency_injection/spec/compiler_passes/resolve_values_spec.cr index 7dbe0eb92..f1cada72b 100644 --- a/src/components/dependency_injection/spec/compiler_passes/resolve_values_spec.cr +++ b/src/components/dependency_injection/spec/compiler_passes/resolve_values_spec.cr @@ -11,6 +11,7 @@ end module ResolveValuePriorityInterface; end @[ADI::Register] +@[ADI::AsAlias("my_string_alias")] record ServicePriorityOne do include ResolveValuePriorityInterface end @@ -20,7 +21,8 @@ record ServicePriorityTwo do include ResolveValuePriorityInterface end -@[ADI::Register(alias: ResolveValuePriorityInterface)] +@[ADI::Register] +@[ADI::AsAlias(ResolveValuePriorityInterface)] record ServicePriorityFour do include ResolveValuePriorityInterface end @@ -51,7 +53,7 @@ end ADI.bind ann_bind : Int32, 900 ADI.bind global_bind : Int32, 900 -@[ADI::Register(_alias_overridden_by_ann_bind: "@service_priority_one", public: true)] +@[ADI::Register(_alias_overridden_by_ann_bind: "@service_priority_one", _alias_service_via_string_alias: "@my_string_alias", public: true)] @[ADI::Autoconfigure(bind: {alias_overridden_by_auto_configure_bind: "@service_priority_two"})] class ServiceValuePriorityService getter explicit_auto_wire, interface_service_matches_name, default_alias, alias_overridden_by_ann_bind @@ -62,7 +64,10 @@ class ServiceValuePriorityService default_alias : ResolveValuePriorityInterface, alias_overridden_by_ann_bind : ResolveValuePriorityInterface, alias_overridden_by_global_bind : ResolveValuePriorityInterface, - alias_overridden_by_auto_configure_bind : ResolveValuePriorityInterface + alias_overridden_by_auto_configure_bind : ResolveValuePriorityInterface, + + # Validates container rewrites the alias service ID to the real underlying service ID + alias_service_via_string_alias : ResolveValuePriorityInterface ) explicit_auto_wire.should be_a ServicePriorityThree service_priority_two.should be_a ServicePriorityTwo @@ -70,6 +75,7 @@ class ServiceValuePriorityService alias_overridden_by_ann_bind.should be_a ServicePriorityOne alias_overridden_by_global_bind.should be_a ServicePriorityOne alias_overridden_by_auto_configure_bind.should be_a ServicePriorityTwo + alias_service_via_string_alias.should be_a ServicePriorityOne end end diff --git a/src/components/dependency_injection/spec/compiler_passes/single_service_spec.cr b/src/components/dependency_injection/spec/compiler_passes/single_service_spec.cr deleted file mode 100644 index e3a15f2b1..000000000 --- a/src/components/dependency_injection/spec/compiler_passes/single_service_spec.cr +++ /dev/null @@ -1,21 +0,0 @@ -require "../spec_helper" - -@[ADI::Register] -class SingleService - getter value : Int32 = 1 -end - -@[ADI::Register(public: true)] -class SingleClient - getter service : SingleService - - def initialize(@service : SingleService); end -end - -describe ADI::ServiceContainer do - it "correctly resolves the service" do - service = ADI.container.single_client.service - service.should be_a SingleService - service.value.should eq 1 - end -end diff --git a/src/components/dependency_injection/spec/compiler_passes/validate_generics_spec.cr b/src/components/dependency_injection/spec/compiler_passes/validate_generics_spec.cr index d036a02c7..9b291a723 100644 --- a/src/components/dependency_injection/spec/compiler_passes/validate_generics_spec.cr +++ b/src/components/dependency_injection/spec/compiler_passes/validate_generics_spec.cr @@ -18,13 +18,6 @@ end describe ADI::ServiceContainer::ValidateGenerics do describe "compiler errors", tags: "compiled" do - it "errors if the generic service does not have a name." do - assert_error "Failed to auto register service for 'Foo(T)'. Generic services must explicitly provide a name.", <<-CR - @[ADI::Register] - record Foo(T) - CR - end - it "errors if the generic service does not provide the generic arguments." do assert_error "Failed to register service 'foo_service'. Generic services must provide the types to use via the 'generics' field.", <<-CR @[ADI::Register(name: "foo_service")] diff --git a/src/components/dependency_injection/src/annotations.cr b/src/components/dependency_injection/src/annotations.cr index b82efda04..56ff44bcd 100644 --- a/src/components/dependency_injection/src/annotations.cr +++ b/src/components/dependency_injection/src/annotations.cr @@ -1,4 +1,61 @@ module Athena::DependencyInjection + # Allows defining an alternative name to identify a service. + # This helps solve two primary use cases: + # + # 1. Defining a default service to use when a parameter is typed as an interface + # 1. Decoupling a service from its ID to more easily allow customizing it. + # + # ### Default Service + # + # This annotation may be applied to a service that includes one or more interface(s). + # The annotation can then be provided the interface to alias as the first positional argument. + # If the service only includes one interface (module ending with `Interface`), the annotation argument can be omitted. + # Multiple annotations may be applied if it includes more than one. + # + # ``` + # module SomeInterface; end + # + # module OtherInterface; end + # + # module BlahInterface; end + # + # # `Foo` is implicitly aliased to `SomeInterface` since it only includes the one. + # @[ADI::Register] + # @[ADI::AsAlias] # SomeInterface is assumed + # class Foo + # include SomeInterface + # end + # + # # Alias `Bar` to both included interfaces. + # @[ADI::Register] + # @[ADI::AsAlias(BlahInterface)] + # @[ADI::AsAlias(OtherInterface)] + # class Bar + # include BlahInterface + # include OtherInterface + # end + # ``` + # + # In this example, anytime a parameter restriction for `SomeInterface` is encountered, `Foo` will be injected. + # Similarly, anytime a parameter restriction of `BlahInterface` or `OtherInterface` is encountered, `Bar` will be injected. + # This can be especially useful for when you want to define a default service to use when there are multiple implementations of an interface. + # + # ### String Keys + # + # The use case for string keys is you can do something like this: + # + # ``` + # @[ADI::Register(name: "default_service")] + # @[ADI::AsAlias("my_service")] + # class SomeService + # end + # ``` + # The idea being, have a service with an internal `default_service` id, but alias it to a more general `my_service` id. + # Dependencies could then be wired up to depend upon the `"@my_service"` implementation. + # This enabled the user/other logic to override the `my_service` alias to their own implementation (assuming it implements same API/interface(s)). + # This should allow everything to propagate and use the custom type without having to touch the original `default_service`. + annotation AsAlias; end + # Applies the provided configuration to any registered service of the type the annotation is applied to. # E.g. a module interface, or a parent type. # @@ -129,72 +186,12 @@ module Athena::DependencyInjection # # ### Aliasing Services # - # An important part of DI is building against interfaces as opposed to concrete types. This allows a type to depend upon abstractions rather than a specific implementation of the interface. + # An important part of DI is building against interfaces as opposed to concrete types. + # This allows a type to depend upon abstractions rather than a specific implementation of the interface. # Or in other words, prevents a singular implementation from being tightly coupled with another type. # - # We can use the `alias` argument when registering a service to tell the container that it should inject this service when a type restriction for the aliased service is found. - # - # ``` - # # Define an interface for our services to use. - # module TransformerInterface - # abstract def transform(value : String) : String - # end - # - # @[ADI::Register(alias: [TransformerInterface])] - # # Alias the `TransformerInterface` to this service. - # struct ShoutTransformer - # include TransformerInterface - # - # def transform(value : String) : String - # value.upcase - # end - # end - # - # @[ADI::Register] - # # Define another transformer type. - # struct ReverseTransformer - # include TransformerInterface - # - # def transform(value : String) : String - # value.reverse - # end - # end - # - # @[ADI::Register(public: true)] - # # The `ShoutTransformer` is injected because the `TransformerInterface` is aliased to the `ShoutTransformer`. - # struct SomeAPIClient - # def initialize(@transformer : TransformerInterface); end - # - # def send(message : String) - # message = @transformer.transform message - # - # # ... - # end - # end - # - # ADI.container.some_api_client.send "foo" # => FOO - # ``` - # - # Any service that uses `TransformerInterface` as a dependency type restriction will get the `ShoutTransformer`. - # However, it is also possible to use a specific implementation while still building against the interface. The name of the constructor argument is used in part to resolve the dependency. - # - # ``` - # @[ADI::Register(public: true)] - # # The `ReverseTransformer` is injected because the constructor argument's name matches the service name of `ReverseTransformer`. - # struct SomeAPIClient - # def initialize(reverse_transformer : TransformerInterface) - # @transformer = reverse_transformer - # end - # - # def send(message : String) - # message = @transformer.transform message - # - # # ... - # end - # end - # - # ADI.container.some_api_client.send "foo" # => oof - # ``` + # The `ADI::AsAlias` annotation can be used to define a default implementation for an interface. + # Checkout the annotation's docs for more information. # # ### Scalar Arguments # diff --git a/src/components/dependency_injection/src/compiler_passes/auto_wire.cr b/src/components/dependency_injection/src/compiler_passes/auto_wire.cr index 05a7066c5..134adcba2 100644 --- a/src/components/dependency_injection/src/compiler_passes/auto_wire.cr +++ b/src/components/dependency_injection/src/compiler_passes/auto_wire.cr @@ -26,20 +26,17 @@ module Athena::DependencyInjection::ServiceContainer::AutoWire # Try and determine the service to used in priority order: # - # 1. Use only resolved service - # 2. If the constructor arg explicitly matches service ID - # 3. Explicit match on the type of the constructor arg - # 4. The first valid alias based on type + # 1. The first service if there is only 1 option + # 2. If the constructor parameter name explicitly matches service ID + # 3. Constructor parameter type is aliased to another service - if resolved_services.size == 1 - resolved_service = resolved_services[0] - elsif rs = resolved_services.find(&.==(name.id)) - resolved_service = rs - elsif s = SERVICE_HASH[(param_resolved_restriction && param_resolved_restriction < ADI::Proxy ? param_resolved_restriction.type_vars.first.resolve : param_resolved_restriction)] - resolved_service = s["alias_service_id"] - else - resolved_service = resolved_services.first - end + resolved_service = if resolved_services.size == 1 + resolved_services[0] + elsif rs = resolved_services.find(&.==(name.id)) + rs + elsif a = ALIASES.keys.find { |k| k == param_resolved_restriction } + ALIASES[a]["id"] + end if resolved_service param["value"] = if param["resolved_restriction"] < ADI::Proxy diff --git a/src/components/dependency_injection/src/compiler_passes/define_getters.cr b/src/components/dependency_injection/src/compiler_passes/define_getters.cr index fceff4c7a..efa3a208c 100644 --- a/src/components/dependency_injection/src/compiler_passes/define_getters.cr +++ b/src/components/dependency_injection/src/compiler_passes/define_getters.cr @@ -19,23 +19,19 @@ module Athena::DependencyInjection::ServiceContainer::DefineGetters {% constructor_service, constructor_method = factory %} {% end %} - {% if !metadata[:public] %}protected {% end %}getter {{metadata[:alias] ? (metadata[:alias_service_id] || service_id).id : service_id.id}} : {{ivar_type}} do - {% if metadata[:alias] %} - {{metadata[:aliased_service_id].id}} - {% else %} - instance = {{constructor_service}}.{{constructor_method.id}}({{ - metadata["parameters"].map do |name, param| - "#{name.id}: #{param["value"]}".id - end.splat - }}) - - {% for call in metadata[:calls] %} - {% method, args = call %} - instance.{{method.id}}({{args.splat}}) - {% end %} - - instance + {% if !metadata[:public] %}protected {% end %}getter {{service_id.id}} : {{ivar_type}} do + instance = {{constructor_service}}.{{constructor_method.id}}({{ + metadata["parameters"].map do |name, param| + "#{name.id}: #{param["value"]}".id + end.splat + }}) + + {% for call in metadata[:calls] %} + {% method, args = call %} + instance.{{method.id}}({{args.splat}}) {% end %} + + instance end {% if metadata[:public] %} @@ -45,6 +41,22 @@ module Athena::DependencyInjection::ServiceContainer::DefineGetters {% end %} {% end %} {% end %} + + {% for alias_name, metadata in ALIASES %} + {% if metadata["public"] %} + # String alias maps to a service => service alias so we just need a method with the alias' name. + {% if alias_name.is_a?(StringLiteral) %} + def {{alias_name.id}} : {{SERVICE_HASH[metadata["id"]]["class"].id}} + {{metadata["id"].id}} + end + # TypeNode alias maps to an interface => service alias, so we need an override of `#get` pinned to the interface type. + {% else %} + def get(service : {{alias_name.id}}.class) : {{alias_name.id}} + {{metadata["id"].id}} + end + {% end %} + {% end %} + {% end %} {% end %} end end diff --git a/src/components/dependency_injection/src/compiler_passes/normalize_definitions.cr b/src/components/dependency_injection/src/compiler_passes/normalize_definitions.cr index b793a437a..1a02e3f55 100644 --- a/src/components/dependency_injection/src/compiler_passes/normalize_definitions.cr +++ b/src/components/dependency_injection/src/compiler_passes/normalize_definitions.cr @@ -41,10 +41,6 @@ module Athena::DependencyInjection::ServiceContainer::NormalizeDefinitions unless definition_keys.includes? "generics" definition["generics"] = [] of Nil end - - unless definition_keys.includes? "aliases" - definition["aliases"] = [] of Nil - end end %} {% end %} diff --git a/src/components/dependency_injection/src/compiler_passes/process_aliases.cr b/src/components/dependency_injection/src/compiler_passes/process_aliases.cr index 6af7a4ad7..b962dd35d 100644 --- a/src/components/dependency_injection/src/compiler_passes/process_aliases.cr +++ b/src/components/dependency_injection/src/compiler_passes/process_aliases.cr @@ -5,25 +5,27 @@ module Athena::DependencyInjection::ServiceContainer::ProcessAliases {% verbatim do %} {% SERVICE_HASH.each do |service_id, definition| - if al = definition["aliases"] - aliases = al.is_a?(ArrayLiteral) ? al : [al] + interface_modules = definition["class"].ancestors.select &.name.ends_with? "Interface" + default_alias = 1 == interface_modules.size ? interface_modules[0] : nil - aliases.each do |a| - id_key = a.resolve.name.gsub(/::/, "_").underscore - alias_service_id = id_key.is_a?(StringLiteral) ? id_key : id_key.stringify + definition["class"].annotations(ADI::AsAlias).each do |ann| + alias_id = if name = ann[0] + name.is_a?(Path) ? name.resolve : name + else + default_alias + end - SERVICE_HASH[a.resolve] = { - class: definition["class"].resolve, - tags: {} of Nil => Nil, - parameters: definition["parameters"], - bindings: {} of Nil => Nil, - generics: [] of Nil, - - alias_service_id: alias_service_id, - aliased_service_id: service_id, - alias: true, - } + unless alias_id + ann.raise <<-TXT + Alias cannot be automatically determined for '#{service_id.id}' (#{definition["class"]}). \ + If the type includes multiple interfaces, provide the interface to alias as the first positional argument to `@[ADI::AsAlias]`. + TXT end + + ALIASES[alias_id] = { + id: service_id, + public: ann["public"] == true, + } end end %} diff --git a/src/components/dependency_injection/src/compiler_passes/register_services.cr b/src/components/dependency_injection/src/compiler_passes/register_services.cr index badd5688e..5ad8d715c 100644 --- a/src/components/dependency_injection/src/compiler_passes/register_services.cr +++ b/src/components/dependency_injection/src/compiler_passes/register_services.cr @@ -10,7 +10,7 @@ module Athena::DependencyInjection::ServiceContainer::RegisterServices {% if (annotations = klass.annotations(ADI::Register)) && !annotations.empty? && !klass.abstract? %} # Raise a compile time exception if multiple services are based on this type, and not all of them specify a `name`. {% if annotations.size > 1 && !annotations.all? &.[:name] %} - {% klass.raise "Failed to auto register services for '#{klass}'. Services based on this type must each explicitly provide a name." %} + {% klass.raise "Failed to auto register services for '#{klass}'. Each service must explicitly provide a name when auto registering more than one service based on the same type." %} {% end %} {% for ann in annotations %} @@ -131,7 +131,6 @@ module Athena::DependencyInjection::ServiceContainer::RegisterServices bindings: {} of Nil => Nil, generics: ann.args, parameters: {} of Nil => Nil, - aliases: ann[:alias], } %} {% end %} diff --git a/src/components/dependency_injection/src/compiler_passes/resolve_values.cr b/src/components/dependency_injection/src/compiler_passes/resolve_values.cr index 7e98b0581..2d9afb793 100644 --- a/src/components/dependency_injection/src/compiler_passes/resolve_values.cr +++ b/src/components/dependency_injection/src/compiler_passes/resolve_values.cr @@ -8,9 +8,9 @@ module Athena::DependencyInjection::ServiceContainer::ResolveValues # The values should be provided in priority order: # # 1. Explicit value on annotation => _id - # 2. Bindings (typed and untyped) => ADI.bind > ADI.auto_configure + # 2. Bindings (typed and untyped) => `ADI.bind` > `ADI::Autoconfigure` # 3. Autowire => By direct type, or parameter name - # 4. Service Alias => Service registered with `alias` of a specific interface + # 4. Service Alias => Service registered with `AsAlias` of a specific interface # 5. Default value => some_value : Int32 = 123 # 6. Nilable Type => nil @@ -27,7 +27,16 @@ module Athena::DependencyInjection::ServiceContainer::ResolveValues # Service reference elsif unresolved_value.is_a?(StringLiteral) && unresolved_value.starts_with?('@') service_name = unresolved_value[1..-1] - unresolved_value.raise "Failed to register service '#{service_id.id}'. Argument '#{param["declaration"]}' references undefined service '#{service_name.id}'." unless SERVICE_HASH[service_name] + + # Resolve the alias ID to its underlying ID + if a = ALIASES[service_name] + service_name = a["id"] + end + + if SERVICE_HASH[service_name].nil? + unresolved_value.raise "Failed to register service '#{service_id.id}'. Argument '#{param["declaration"]}' references undefined service '#{service_name.id}'." + end + resolved_value = service_name.id # Tagged services diff --git a/src/components/dependency_injection/src/service_container.cr b/src/components/dependency_injection/src/service_container.cr index aa60dab2b..5807f75ab 100644 --- a/src/components/dependency_injection/src/service_container.cr +++ b/src/components/dependency_injection/src/service_container.cr @@ -14,6 +14,13 @@ class Athena::DependencyInjection::ServiceContainer # Key is the ID of the service and the value is another hash containing its arguments, type, etc. SERVICE_HASH = {} of Nil => Nil + # :nodoc: + # + # Maps services to their aliases + # + # Hash(String, NamedTuple(id: String, public: Bool)) + ALIASES = {} of Nil => Nil + # Define a hash to store the service ids for each tag. # # Tag Name, service_id, array attributes