diff --git a/LICENSE b/LICENSE index 3b7826b..880c5e4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2020 Blacksmoke16 +Copyright (c) 2020 George Dietrich Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 668458f..09c83ef 100644 --- a/README.md +++ b/README.md @@ -31,4 +31,4 @@ Everything is documented in the [API Docs](https://athena-framework.github.io/de ## Contributors -- [Blacksmoke16](https://github.com/blacksmoke16) - creator and maintainer +- [George Dietrich](https://github.com/blacksmoke16) - creator and maintainer diff --git a/shard.yml b/shard.yml index fff5a47..a5ef762 100644 --- a/shard.yml +++ b/shard.yml @@ -14,7 +14,7 @@ description: | Flexible instance based dependency injection service container library. authors: - - Blacksmoke16 + - George Dietrich development_dependencies: ameba: diff --git a/spec/compiler_spec.cr b/spec/compiler_spec.cr index 43a9e02..d562f36 100644 --- a/spec/compiler_spec.cr +++ b/spec/compiler_spec.cr @@ -1,6 +1,6 @@ require "./spec_helper" -describe Athena::DependencyInjection do +pending Athena::DependencyInjection do describe Athena::DependencyInjection::ServiceContainer do describe "compiler errors" do describe "when trying to access a private service directly" do diff --git a/spec/injectable_spec.cr b/spec/injectable_spec.cr index a1aa347..6f64018 100644 --- a/spec/injectable_spec.cr +++ b/spec/injectable_spec.cr @@ -1,6 +1,6 @@ require "./spec_helper" -describe ADI::Injectable do +pending ADI::Injectable do describe "with only services" do it "should inject an instance of the Store class" do klass = SomeClass.new diff --git a/spec/service_container_spec.cr b/spec/service_container_spec.cr index 4df169c..46e66e2 100644 --- a/spec/service_container_spec.cr +++ b/spec/service_container_spec.cr @@ -1,6 +1,6 @@ require "./spec_helper" -describe ADI::ServiceContainer do +pending ADI::ServiceContainer do describe "#get" do describe "by type" do it "should return an array of services with that type" do diff --git a/src/athena-dependency_injection.cr b/src/athena-dependency_injection.cr index ad186fe..c4412ce 100644 --- a/src/athena-dependency_injection.cr +++ b/src/athena-dependency_injection.cr @@ -23,6 +23,16 @@ alias ADI = Athena::DependencyInjection # Using interfaces allows changing the functionality of a type by just changing what service gets injected into it. # See this [blog post](https://dev.to/blacksmoke16/dependency-injection-in-crystal-2d66#plug-and-play) for an example of this. module Athena::DependencyInjection + module CompilerPass + macro included + macro pre_process(service_hash, alias_hash) + end + + macro post_process(service_hash, alias_hash) + end + end + end + # Stores metadata associated with a specific service. # # The type of the service affects how it behaves within the container. When a `struct` service is retrieved or injected into a type, it will be a copy of the one in the SC (passed by value). @@ -152,6 +162,7 @@ module Athena::DependencyInjection # end # ``` annotation Register; end + annotation Inject; end # Used to designate a type as a service. # @@ -162,78 +173,107 @@ module Athena::DependencyInjection def self.container : ADI::ServiceContainer Fiber.current.container end +end - # Adds a new constructor that resolves the required services based on type and name. - # - # Can be included into a `class`/`struct` in order to automatically inject the required services from the container based on the type's initializer. - # - # Service lookup is based on the type restriction and name of the initializer arguments. If there is only a single service - # of the required type, then that service is used. If there are multiple services of the required type then the name of the argument's name is used. - # An exception is raised if a service was not able to be resolved. - # - # ## Examples - # - # ### Default Usage - # - # ``` - # @[ADI::Register] - # class Store - # include ADI::Service - # - # property uuid : String = "UUID" - # end - # - # class MyNonService - # include ADI::Injectable - # - # getter store : Store - # - # def initialize(@store : Store); end - # end - # - # MyNonService.new.store.uuid # => "UUID" - # ``` - # - # ### Non Service Dependencies - # - # Named arguments take precedence. This allows dependencies to be supplied explicitly without going through the resolving process; such as for testing. - # ``` - # @[ADI::Register] - # class Store - # include ADI::Service - # - # property uuid : String = "UUID" - # end - # - # class MyNonService - # include ADI::Injectable - # - # getter store : Store - # getter id : String - # - # def initialize(@store : Store, @id : String); end - # end - # - # service = MyNonService.new(id: "FOO") - # service.store.uuid # => "UUID" - # service.id # => "FOO" - # ``` - module Injectable - macro included - macro finished - {% verbatim do %} - {% if initializer = @type.methods.find &.name.stringify.==("initialize") %} - # Auto generated via `ADI::Injectable` module. - def self.new(**args) - new( - {% for arg in initializer.args %} - {{arg.name.id}}: args[{{arg.name.symbolize}}]? || ADI.container.resolve({{arg.restriction.id}}, {{arg.name.stringify}}), - {% end %} - ) - end - {% end %} - {% end %} - end - end +abstract class FakeServices +end + +@[ADI::Register] +class FakeService < FakeServices + include ADI::Service +end + +@[ADI::Register(name: "custom_fake", alias: FakeServices)] +class CustomFooFakeService < FakeServices + include ADI::Service +end + +@[ADI::Register(_name: "JIM")] +class Bar + include ADI::Service + + def initialize(@asdf : FakeServices, @name : String); end +end + +@[ADI::Register] +class FooBar + include ADI::Service + + def initialize(@obj : Foo); end +end + +@[ADI::Register(1, "fred", false)] +class Foo + include ADI::Service + + def initialize(@id : Int32, @name : String, @active : Bool); end +end + +@[ADI::Register] +class Blah + include ADI::Service +end + +@[ADI::Register(decorates: "blah")] +class BlahDecorator + include ADI::Service + + def initialize(@blah : Blah); end +end + +@[ADI::Register("@?blah")] +class Baz + include ADI::Service + + def initialize(@blah : Blah?); end +end + +@[ADI::Register] +class Athena::RoutingStuff::Public + include ADI::Service + + def initialize + # pp "new public" end end + +@[ADI::Register(lazy: true)] +class Lazy + include ADI::Service + + def initialize + # pp "new lazy" + end +end + +@[ADI::Register("GOOGLE", "Google", name: "google", tags: ["feed_partner", "partner"])] +@[ADI::Register("FACEBOOK", "Facebook", name: "facebook", tags: ["partner"])] +struct FeedPartner + include ADI::Service + + getter id : String + getter name : String + + def initialize(@id : String, @name : String); end +end + +@[ADI::Register("!partner")] +class PartnerManager + include ADI::Service + + getter partners + + def initialize(@partners : Array(FeedPartner)) + end +end + +@[ADI::Register("@fake_services")] +class Test + include ADI::Service + + def initialize(@t : FakeServices); end +end + +CONTAINER = ADI::ServiceContainer.new + +pp CONTAINER diff --git a/src/service_container.cr b/src/service_container.cr index a59d7a1..9548e30 100644 --- a/src/service_container.cr +++ b/src/service_container.cr @@ -3,238 +3,194 @@ # A getter is defined for each service, if it is public. # Otherwise, services are only available via constructor DI. struct Athena::DependencyInjection::ServiceContainer - # Mapping of tag name to services with that tag. - getter tags : Hash(String, Array(String)) = Hash(String, Array(String)).new + private macro stringify(string) + string.is_a?(StringLiteral) ? string : string.stringify + end - macro finished - {% begin %} - # Define a temp hash to handle overriding services - {% services = {} of Nil => Nil %} - - {% for service in ADI::Service.includers %} - {% raise "#{service.name} includes `ADI::Service` but is not registered. Did you forget the annotation?" if (annotations = service.annotations(ADI::Register)) && annotations.empty? && !service.abstract? %} - {% for ann in annotations %} - {% key = ann[:name] ? ann[:name] : service.name.split("::").last.underscore %} - {% services[key.id] = {"public" => ann[:public], "type" => service.id} %} - {% end %} - {% end %} + private macro is_optional_service(arg) + arg.is_a?(StringLiteral) && arg.starts_with?("@?") + end - # Define a `getter` in the container for each registered service. - {% for name, service in services %} - # Only make the getter public if the service is public - {% if service["public"] != true %} private {% end %}getter {{name}} : {{service["type"]}} - {% end %} - {% end %} - end + private macro is_service(arg) + arg.is_a?(StringLiteral) && arg.starts_with?('@') + end - # Initializes the container. Auto registering annotated services. - def initialize - # Work around for https://github.com/crystal-lang/crystal/issues/7975. - {{@type}} + private macro is_tagged_service(arg) + arg.is_a?(StringLiteral) && arg.starts_with?('!') + end - {% begin %} - # List of service names already registered. - {% registered_services = [] of String %} - - # Mapping of tag name to services with that tag. - {% tagged_services = {} of String => Array(String) %} - - # Generate an array of types registered with the SC, used to allow for redefining services. - {% service_types = @type.instance_vars.map(&.type.stringify) %} - - # Obtain an array of registered services, exclude any that have not already been registered. - {% services = ADI::Service.includers.select { |type| type.annotation(ADI::Register) && service_types.includes? type.stringify } %} - - # Array of services that have no dependencies. - {% no_dependency_services = services.select { |service| init = service.methods.find(&.name.==("initialize")); !init || init.args.size == 0 } %} - - # Array of services that do not have any tagged services. - # This includes any service who's arguments are strings and don't start with `!` or are not strings. - {% services_without_tagged_dependencies = services.select do |service| - initializer = service.methods.find(&.name.==("initialize")) - initializer && initializer.args.size > 0 && service.annotations(ADI::Register).all? do |service_ann| - service_ann.args.all? do |arg| - (arg.is_a?(StringLiteral) && !arg.starts_with?('!')) || !arg.is_a?(StringLiteral) - end - end - end %} - - # Array of services with tag argument. Assuming by now all other services would be registered. - # This includes any services who has at least one argument that is a string and starts with `!`. - {% services_with_tagged_dependencies = services.select do |service| - initializer = service.methods.find(&.name.==("initialize")) - initializer && service.annotations(ADI::Register).any? do |service_ann| - service_ann.args.any? do |arg| - (arg.is_a?(StringLiteral) && arg.starts_with?('!')) - end - end - end %} - - # Register the services without dependencies first - {% for service in no_dependency_services %} - {% for service_ann in service.annotations(ADI::Register) %} - {% key = service_ann[:name] ? service_ann[:name] : service.name.split("::").last.underscore %} - {% tags = service_ann[:tags] ? service_ann[:tags] : [] of String %} - {% registered_services << key %} - - {% for tag in tags %} - {% tagged_services[tag] ? tagged_services[tag] << key : (tagged_services[tag] = [] of String; tagged_services[tag] << key) %} - {% end %} - - @{{key.id}} = {{service.id}}.new - {% end %} - {% end %} + private macro get_service_id(service, service_ann) + @type.stringify(service_ann && service_ann[:name] ? service_ann[:name] : service.name.gsub(/::/, "_").underscore) + end - {% for service in services_without_tagged_dependencies %} - {% for service_ann in service.annotations(ADI::Register) %} - {% key = service_ann[:name] ? service_ann[:name] : service.name.split("::").last.underscore %} - {% tags = service_ann[:tags] ? service_ann[:tags] : [] of String %} - {% initializer = service.methods.find(&.name.==("initialize")) %} - - {% for tag in tags %} - {% tagged_services[tag] ? tagged_services[tag] << key : (tagged_services[tag] = [] of String; tagged_services[tag] << key) %} - {% end %} - - {% if service_ann.args.all? do |arg| - if arg.is_a?(StringLiteral) && arg.starts_with?("@?") - true - elsif arg.is_a?(StringLiteral) && arg.starts_with?('@') - registered_services.includes? arg[1..-1] - else - true - end - end %} - - {% registered_services << key %} - - @{{key.id}} = {{service.id}}.new({{service_ann.args.map_with_index do |arg, idx| - if arg.is_a?(ArrayLiteral) - "#{arg.map do |array_arg| - if array_arg.is_a?(StringLiteral) && array_arg.starts_with?("@?") - # if there is no service use `nil`. - services.any? &.<=(initializer.args[idx].restriction.resolve) ? "#{array_arg[2..-1].id}".id : nil - elsif array_arg.is_a?(StringLiteral) && array_arg.starts_with?('@') - "#{array_arg[1..-1].id}".id - else - array_arg - end - end} of Union(#{initializer.args[idx].restriction.resolve.type_vars.splat})".id - elsif arg.is_a?(StringLiteral) && arg.starts_with?("@?") - # if there is no service use `nil`. - services.any? &.<=(initializer.args[idx].restriction.resolve) ? "#{arg[2..-1].id}".id : nil - elsif arg.is_a?(StringLiteral) && arg.starts_with?('@') - "#{arg[1..-1].id}".id - else - arg - end - end.splat}}) - {% else %} - {% for arg, idx in service_ann.args %} - {% if arg.is_a?(StringLiteral) && arg.starts_with?("@?") %} - # ignore - {% elsif arg.is_a?(StringLiteral) && arg.starts_with?('@') %} - {% raise "Could not resolve dependency '#{arg[1..-1].id}' for service '#{service}'. Did you forget to include `ADI::Service` or declare it optional?" unless services.any? &.<=(initializer.args[idx].restriction.resolve) %} - {% end %} - {% end %} - - {% services_without_tagged_dependencies << service %} - {% end %} - {% end %} - {% end %} + private macro get_service_hash_value(service_id, service, service_ann, alias_hash) + if service_ann && service_ann[:alias] != nil + alias_hash[service_ann[:alias].resolve] = service_id + end - # Lastly register services with tags, assuming their dependencies would have been resolved by now. - {% for service in services_with_tagged_dependencies %} - {% for service_ann in service.annotations(Register) %} - {% key = service_ann[:name] ? service_ann[:name] : service.name.split("::").last.underscore %} - {% tags = service_ann[:tags] ? service_ann[:tags] : [] of String %} - {% initializer = service.methods.find(&.name.==("initialize")) %} - {% registered_services << key %} - - @{{key.id}} = {{service.id}}.new({{service_ann.args.map_with_index do |arg, idx| - if arg.is_a?(ArrayLiteral) - arg.map do |array_arg| - if array_arg.is_a?(StringLiteral) && array_arg.starts_with?("@?") - # if there is no service use `nil`. - services.any? &.<=(initializer.args[idx].restriction.resolve) ? "#{array_arg[2..-1].id}".id : nil - elsif array_arg.is_a?(StringLiteral) && array_arg.starts_with?('@') - "#{array_arg[1..-1].id}".id - else - array_arg - end - end - elsif arg.is_a?(StringLiteral) && arg.starts_with?("@?") - # if there is no service use `nil`. - services.any? &.<=(initializer.args[idx].restriction.resolve) ? "#{arg[2..-1].id}".id : nil - elsif arg.is_a?(StringLiteral) && arg.starts_with?('@') - "#{arg[1..-1].id}".id - elsif arg.is_a?(StringLiteral) && arg.starts_with?('!') - %(#{tagged_services[arg[1..-1]].map { |ts| "#{ts.id}".id }} of Union(#{initializer.args[idx].restriction.resolve.type_vars.splat})).id - else - arg - end - end.splat}}) - {% end %} - {% end %} - @tags = {{tagged_services}} of String => Array(String) - {% end %} + { + lazy: (service_ann && service_ann[:lazy]) || false, + public: (service_ann && service_ann[:public]) || false, + public_alias: (service_ann && service_ann[:public_alias]) || false, + tags: (service_ann && service_ann[:tags]) || [] of Nil, + type: service.resolve, + service_annotation: service_ann + } end - # Returns an array of services of the provided *type*. - def get(type : Service.class) - get_services_by_type type + private macro get_initializer_args(service) + initializer = service.methods.find(&.annotation(ADI::Inject)) || service.methods.find(&.name.==("initialize")) + (i = initializer) ? i.args : [] of Nil end - # Returns `true` if a service with the provided *name* has been registered. - def has?(name : String) : Bool - service_names.includes? name + private macro resolve_dependencies(service_hash, alias_hash, service, service_ann) + # If positional arguments are provided, + # use them to instantiate the object + if service_ann && !service_ann.args.empty? + service_ann.args.map_with_index do |arg, idx| + @type.parse_arg service_hash, service, arg, idx + end + else + # Otherwise, try and auto resolve the arguments + @type.get_initializer_args(service).map_with_index do |arg, idx| + # Check if an explicit value was passed for this arg + if service_ann && service_ann.named_args.keys.includes? "_#{arg.name}".id + @type.parse_arg service_hash, service, service_ann.named_args["_#{arg.name}"], idx + else + resolved_services = [] of Nil + + # Otherwise resolve possible services based on type + service_hash.each do |service_id, metadata| + if metadata[:type] <= arg.restriction.resolve + resolved_services << service_id + end + end + + # If no services could be resolved + if resolved_services.size == 0 + # Return a default value if any + unless arg.default_value.is_a? Nop + arg.default_value + else + # otherwise raise an exception + arg.raise "Could not auto resolve argument #{arg}" + end + elsif resolved_services.size == 1 + # If only one was matched, return it + resolved_services[0].id + else + # Otherwise fallback on the argument's name as well + if resolved_service = resolved_services.find(&.==(arg.name)) + resolved_service.id + # If no service with that name could be resolved, + # check the alias map for the restriction + elsif aliased_service = alias_hash[arg.restriction.resolve] + # If one is found returned the aliased service + aliased_service.id + else + # Otherwise raise an exception + arg.raise "Could not auto resolve argument #{arg}" + end + end + end + end + end end - # Returns the service of the given *type* and *name*. - # - # Raises an exception if a service could not be resolved. - def resolve(type : _, name : String) : ADI::Service - services = get_services_by_type type - - # Return the service if there is only one. - return services.first if services.size == 1 - - # # Otherwise, also use the name to resolve the service. - if (service = internal_get name) && services.includes? service - return service + private macro resolve_tags(service_hash, tag) + tagged_services = [] of Nil + service_hash.each do |service_id, metadata| + tagged_services << service_id.id if metadata[:tags].includes? tag end - - # Throw an exception if it could not be resolved. - raise "Could not resolve a service with type '#{type}' and name of '#{name}'." + tagged_services end - # Returns services with the specified *tag*. - def tagged(tag : String) - (service_names = @tags[tag]?) ? service_names.map { |service_name| internal_get(service_name).not_nil! } : Array(ADI::Service).new + private macro parse_arg(service_hash, service, arg, idx) + if arg.is_a?(ArrayLiteral) + %(#{arg.map_with_index { |arr_arg, arr_idx| @type.parse_arg service_hash, service, arr_arg, arr_idx }} of Union(#{@type.get_initializer_args(service)[idx].restriction.resolve.type_vars.splat})).id + elsif @type.is_optional_service arg + service_id = arg[2..-1] + + (s = service_hash[service_id]) ? service_id.id : nil + elsif @type.is_service arg + arg[1..-1].id + elsif @type.is_tagged_service arg + %(#{@type.resolve_tags service_hash, arg[1..-1]} of Union(#{@type.get_initializer_args(service)[idx].restriction.resolve.type_vars.splat})).id + else + arg + end end - # Returns an `Array(ADI::Service)` for services of *type*. - private def get_services_by_type(type : T) forall T + macro finished {% begin %} - # Select the services that `T` is included in if it's a `:Module` type. - # Next, select services that are, or a child of, `T`. - {% services = @type.instance_vars.select { |iv| T.instance.resolve.includers.any?(&.<=(iv.type.resolve)) || iv.type.resolve.class <= T.resolve } %} - {% if services.empty? %}[] of ADI::Service{% else %}{{services}}{% end %} - {% end %} - end + # Define a hash to store services while the container is being built + # Key is the ID of the service and the value is another hash containing its arguments, type, etc. + {% service_hash = {} of Nil => Nil %} + + # Define a hash to map alias types to a service ID. + {% alias_hash = {} of Nil => Nil %} + + # Register each service in the hash along with some related metadata. + {% for service in ADI::Service.includers %} + {% raise "#{service.name} includes `ADI::Service` but is not registered. Did you forget the annotation?" if (annotations = service.annotations(ADI::Register)) && annotations.empty? && !service.abstract? %} + {% for ann in annotations %} + {% service_id = @type.get_service_id service, ann %} + {% service_hash[service_id] = @type.get_service_hash_value service_id, service, ann, alias_hash %} + {% end %} + {% end %} - # Returns a Tuple of registered service names. - private def service_names - {{{@type.instance_vars.reject(&.name.==("tags")).map(&.name.stringify).splat}}} - end + # Run pre process compiler pass + {% for pass in ADI::CompilerPass.includers %} + {% pass.pre_process service_hash, alias_hash %} + {% end %} - # Attempts to resolve the provided *name* into a service. - private def internal_get(name : String) : ADI::Service? - {% begin %} - case name - {% for ivar in @type.instance_vars.reject(&.name.==("tags")) %} - when {{ivar.name.stringify}} then {{ivar.id}} + # Resolve the arguments for each service + {% for service_id, metadata in service_hash %} + {% service_hash[service_id][:arguments] = @type.resolve_dependencies service_hash, alias_hash, metadata[:type], metadata[:service_annotation] %} {% end %} + + # Run post process compiler pass + {% for pass in ADI::CompilerPass.includers %} + {% pass.post_process service_hash, alias_hash %} + {% end %} + + # Define getters for each service, if the service is public, make the getter public and also define a type based getter + {% for service_id, metadata in service_hash %} + {% if metadata[:public] != true %}private{% end %} getter {{service_id.id}} : {{metadata[:type]}} { {{metadata[:type]}}.new({{metadata[:arguments].splat}}) } + + {% if metadata[:public] %} + def get(service : {{metadata[:type]}}.class) : {{metadata[:type]}} + {{service_id.id}} + end + {% end %} + {% end %} + + # Define getters for aliased service, if the alias is public, make the getter public and also define a type based getter + {% for service_type, service_id in alias_hash %} + {% service = service_hash[service_id] %} + + {% if service[:public_alias] != true %}private{% end %} def {{@type.get_service_id(service_type, nil).id}} : {{service[:type]}}; {{service_id.id}}; end + + {% if service[:public_alias] == true %} + def get(service : {{service_type}}.class) : {{service[:type]}} + {{service_id.id}} + end + {% end %} + {% end %} + + # Initializes the container. Auto registering annotated services. + def initialize + # Work around for https://github.com/crystal-lang/crystal/issues/7975. + {{@type}} + + # Initialize non lazy services + {% for service_id, metadata in service_hash %} + {% unless metadata[:lazy] == true %} + @{{service_id.id}} = {{service_id.id}} + {% end %} + {% end %} end + {{debug}} {% end %} end end