Skip to content

Commit

Permalink
More tightly integrate DI component with the other components (#259)
Browse files Browse the repository at this point in the history
* Add event dispatcher ext to DI component
* Add console ext to DI component
  • Loading branch information
Blacksmoke16 authored Feb 4, 2023
1 parent 0ad460e commit 52e3796
Show file tree
Hide file tree
Showing 13 changed files with 325 additions and 287 deletions.
3 changes: 3 additions & 0 deletions src/components/console/src/athena-console.cr
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ alias ACONA = ACON::Annotations
#
# From here you can then setup your entry point file talked about earlier, being sure to require the component via `require "athena-console"`.
# Finally, create/require your `ACON::Command`s, and customize the `ACON::Application` as needed.
#
# TIP: If using this component with the `Athena::DependencyInjection` component, `ACON::Command` that have the `ADI::Register` annotation will automatically
# be registered as commands when using the `ADI::Console::Application` type.
module Athena::Console
VERSION = "0.3.0"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,6 @@ module Athena::DependencyInjection
Fiber.current.container
end
end

# Require extension code last so all built-in DI types are available
require "./ext/*"
210 changes: 210 additions & 0 deletions src/components/dependency_injection/src/ext/console.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# TODO: Clean this up once https://github.com/crystal-lang/crystal/issues/12965 is resolved
{% skip_file unless @top_level.has_constant?("Athena") && Athena.has_constant?("Console") && Athena::Console.has_constant?("Command") %}

ADI.auto_configure ACON::Command, {tags: [ADI::Console::Command::TAG]}

# Contains types related to the `Athena::Console` integration.
module Athena::DependencyInjection::Console
# :nodoc:
module Command
TAG = "athena.console.command"
end

@[ADI::Register(public: true, name: "athena_console_application")]
# Entrypoint for the `Athena::Console` integration.
# This service should be fetched via `ADI.container` within your console CLI file.
#
# ```
# # Require your code
# require "./main"
#
# # Run the application
# ADI.container.athena_console_application.run
# ```
#
# Checkout the [external documentation](/components/console/) for more information.
class Application < ACON::Application
protected def initialize(
command_loader : ACON::Loader::Interface? = nil,
event_dipatcher : AED::EventDispatcherInterface? = nil,
eager_commands : Enumerable(ACON::Command)? = nil
)
super "Athena", SemanticVersion.parse ATH::VERSION

self.command_loader = command_loader
# TODO: set event dispatcher when that's implemented in the console component.

eager_commands.try &.each do |cmd|
self.add cmd
end
end
end

# :nodoc:
class ContainerCommandLoader
include Athena::Console::Loader::Interface

@command_map : Hash(String, ACON::Command.class)

def initialize(
@command_map : Hash(String, ACON::Command.class),
@loader : ADI::Console::ContainerCommandLoaderLocator
); end

# :inherit:
def get(name : String) : ACON::Command
if !self.has? name
raise ACON::Exceptions::CommandNotFound.new "Command '#{name}' does not exist."
end

@loader.get @command_map[name]
end

# :inherit:
def has?(name : String) : Bool
@command_map.has_key? name
end

# :inherit:
def names : Array(String)
@command_map.keys
end
end

# :nodoc:
module CompilerPasses; end

module CompilerPasses::RegisterCommands
# Post arguments avoid dependency resolution
include Athena::DependencyInjection::PostArgumentsCompilerPass

macro included
macro finished
{% verbatim do %}
{%
command_map = {} of Nil => Nil
command_refs = {} of Nil => Nil

# Services that are not configured via the annotation so must be registered eagerly.
eager_service_ids = [] of Nil

(TAG_HASH[ADI::Console::Command::TAG] || [] of Nil).each do |service_id|
metadata = SERVICE_HASH[service_id]

# TODO: Any benefit in allowing commands to be configured via tags instead of the annotation?

metadata[:visibility] = metadata[:visibility] != Visibility::PRIVATE ? metadata[:visibility] : Visibility::INTERNAL

ann = metadata[:service].annotation ACONA::AsCommand

if ann == nil
if metadata[:visibility] == Visibility::PRIVATE
SERVICE_HASH[public_service_id = "_#{service_id.id}_public"] = metadata + {visibility: Visibility::INTERNAL}
service_id = public_service_id
end
eager_service_ids << service_id.id
else
name = ann[0] || ann[:name]

unless name
ann.raise "Console command '#{metadata[:service]}' has an 'ACONA::AsCommand' annotation but is missing the commands's name. It was not provided as the first positional argument nor via the 'name' field."
end

aliases = name.split '|'
aliases = aliases + (ann[:aliases] || [] of Nil)

if ann[:hidden] && "" != aliases[0]
aliases.unshift ""
end

command_name = aliases[0]
aliases = aliases[1..]

if is_hidden = "" == command_name
command_name = aliases[0]
aliases = aliases[1..]
end

command_map[command_name] = metadata[:service]
command_refs[metadata[:service]] = service_id

aliases.each do |a|
command_map[a] = metadata[:service]
end

SERVICE_HASH[lazy_service_id = "_#{service_id.id}_lazy"] = {
visibility: Visibility::INTERNAL,
service: "ACON::Commands::Lazy",
ivar_type: "ACON::Commands::Lazy",
tags: [] of Nil,
generics: [] of Nil,
arguments: [
{value: command_name},
{value: "#{aliases} of String".id},
{value: ann[:description] || ""},
{value: is_hidden},
{value: "->{ #{service_id.id}.as(ACON::Command) }".id},
],
}

command_refs[metadata[:service]] = lazy_service_id
end
end

SERVICE_HASH[loader_id = "athena_console_command_loader_container"] = {
visibility: Visibility::INTERNAL,
service: "Athena::DependencyInjection::Console::ContainerCommandLoaderLocator",
ivar_type: "Athena::DependencyInjection::Console::ContainerCommandLoaderLocator",
tags: [] of Nil,
generics: [] of Nil,
arguments: [
{value: "self".id},
],
}

SERVICE_HASH[command_loader_service_id = "athena_console_command_loader"] = {
visibility: Visibility::PUBLIC,
alias: ACON::Loader::Interface,
service: Athena::DependencyInjection::Console::ContainerCommandLoader,
ivar_type: Athena::DependencyInjection::Console::ContainerCommandLoader,
tags: [] of Nil,
generics: [] of Nil,
arguments: [
{value: "#{command_map} of String => ACON::Command.class".id},
{value: loader_id.id},
],
}

SERVICE_HASH["athena_console_application"][:arguments][0][:value] = command_loader_service_id.id
SERVICE_HASH["athena_console_application"][:arguments][2][:value] = "#{eager_service_ids} of ACON::Command".id
%}

# :nodoc:
#
# TODO: Define some more generic way to create these
struct ::Athena::DependencyInjection::Console::ContainerCommandLoaderLocator
def initialize(@container : ::ADI::ServiceContainer); end

{% for service_type, service_id in command_refs %}
def get(service : {{service_type}}.class) : ACON::Command
@container.{{service_id.id}}
end
{% end %}

def get(service) : ACON::Command
{% begin %}
case service
{% for service_type, service_id in command_refs %}
when {{service_type}} then @container.{{service_id.id}}
{% end %}
else
raise "BUG: Couldn't find correct service."
end
{% end %}
end
end
{% end %}
end
end
end
end
101 changes: 101 additions & 0 deletions src/components/dependency_injection/src/ext/event_dispatcher.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# TODO: Clean this up once https://github.com/crystal-lang/crystal/issues/12965 is resolved
{% skip_file unless @top_level.has_constant?("Athena") && Athena.has_constant?("EventDispatcher") && Athena::EventDispatcher.has_constant?("EventDispatcher") %}

@[ADI::Register(name: "event_dispatcher", alias: AED::EventDispatcherInterface)]
class AED::EventDispatcher
end

ADI.auto_configure AED::EventListenerInterface, {tags: [ADI::EventDispatcher::Listeners::TAG]}

# :nodoc:
module Athena::DependencyInjection::EventDispatcher
module Listeners
TAG = "athena.event_dispatcher.listener"
end

module CompilerPasses::RegisterEventListenersPass
include Athena::DependencyInjection::PreArgumentsCompilerPass

macro included
macro finished
{% verbatim do %}
{%
SERVICE_HASH["event_dispatcher"][:factory] = {"self".id, "get_event_dispatcher"}

TAG_HASH[ADI::EventDispatcher::Listeners::TAG].each do |service_id|
SERVICE_HASH[service_id][:visibility] = Visibility::INTERNAL
end
%}

private def get_event_dispatcher
dispatcher = AED::EventDispatcher.new

{% for service_id in TAG_HASH[ADI::EventDispatcher::Listeners::TAG] %}
{% metadata = SERVICE_HASH[service_id] %}

{% listeners = [] of Nil %}

# Changes made here should also be reflected within `AED::EventListenerInterface` overload within `AED::EventDispatcher`.
{%
class_listeners = metadata[:service].class.methods.select &.annotation(AEDA::AsEventListener)

# Raise compile time error if a listener is defined as a class method.
unless class_listeners.empty?
class_listeners.first.raise "Event listener methods can only be defined as instance methods. Did you mean '#{metadata[:service].name}##{class_listeners.first.name}'?"
end

metadata[:service].methods.select(&.annotation(AEDA::AsEventListener)).each do |m|
# Validate the parameters of each method.
if (m.args.size < 1) || (m.args.size > 2)
m.raise "Expected '#{metadata[:service].name}##{m.name}' to have 1..2 parameters, got '#{m.args.size}'."
end

event_arg = m.args[0]

# Validate the type restriction of the first parameter, if present
event_arg.raise "Expected parameter #1 of '#{metadata[:service].name}##{m.name}' to have a type restriction of an 'AED::Event' instance, but it is not restricted." if event_arg.restriction.is_a?(Nop)
event_arg.raise "Expected parameter #1 of '#{metadata[:service].name}##{m.name}' to have a type restriction of an 'AED::Event' instance, not '#{event_arg.restriction}'." if !(event_arg.restriction.resolve <= AED::Event)

if dispatcher_arg = m.args[1]
event_arg.raise "Expected parameter #2 of '#{metadata[:service].name}##{m.name}' to have a type restriction of 'AED::EventDispatcherInterface', but it is not restricted." if dispatcher_arg.restriction.is_a?(Nop)
event_arg.raise "Expected parameter #2 of '#{metadata[:service].name}##{m.name}' to have a type restriction of 'AED::EventDispatcherInterface', not '#{dispatcher_arg.restriction}'." if !(dispatcher_arg.restriction.resolve <= AED::EventDispatcherInterface)
end

priority = m.annotation(AEDA::AsEventListener)[:priority] || 0

unless priority.is_a? NumberLiteral
m.raise "Event listener method '#{metadata[:service].name}##{m.name}' expects a 'NumberLiteral' for its 'AEDA::AsEventListener#priority' field, but got a '#{priority.class_name.id}'."
end

listeners << {
event_arg.restriction.resolve.id,
m.args.size,
m.name.id,
"#{metadata[:service]}##{m.name.id}",
priority,
}
end
%}

{% for info in listeners %}
{% event, count, method, name, priority = info %}

{% if 1 == count %}
dispatcher.add_callable(
{{event}}.callable(priority: {{priority}}, name: {{name}}) { |event| self.{{service_id.id}}.{{method}} event.as({{event}}) },
)
{% else %}
dispatcher.add_callable(
{{event}}.callable(priority: {{priority}}, name: {{name}}) { |event, dispatcher| self.{{service_id.id}}.{{method}} event.as({{event}}), dispatcher },
)
{% end %}
{% end %}
{% end %}

dispatcher
end
{% end %}
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ alias AEDA = AED::Annotations
# WARNING: If using this component within the context of something that handles independent execution flows, such as a web framework, you will want there to be a dedicated dispatcher instance for each path.
# This ensures that one flow will not leak state to any other flow, while still allowing flow specific mutations to be used.
# Consider pairing this component with the [Athena::DependencyInjection][Athena::DependencyInjection--getting-started] component as a way to handle this.
#
# TIP: If using this component with the `Athena::DependencyInjection` component, `AED::EventListenerInterface` that have the `ADI::Register` annotation will automatically
# be registered with the default `AED::EventDispatcherInterface`.
module Athena::EventDispatcher
VERSION = "0.2.0"

Expand Down
2 changes: 1 addition & 1 deletion src/components/event_dispatcher/src/event_dispatcher.cr
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ class Athena::EventDispatcher::EventDispatcher
{% begin %}
{% listeners = [] of Nil %}

# Changes made here should also be reflected within `ATH::CompilerPasses::RegisterEventListenersPass`.
# Changes made here should also be reflected within `ADI::EventDispatcher::CompilerPasses::RegisterEventListenersPass`.
{%
class_listeners = T.class.methods.select &.annotation(AEDA::AsEventListener)

Expand Down
10 changes: 4 additions & 6 deletions src/components/framework/src/athena.cr
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ require "json"

require "athena-config"
require "athena-console"
require "athena-dependency_injection"
require "athena-event_dispatcher"
require "athena-negotiation"

# Require DI component last so it knows what extensions it should load
require "athena-dependency_injection"

require "./action"
require "./annotations"
require "./binary_file_response"
Expand Down Expand Up @@ -41,7 +43,6 @@ require "./view/*"

require "./ext/console"
require "./ext/conversion_types"
require "./ext/event_dispatcher"
require "./ext/negotiation"
require "./ext/routing"
require "./ext/validator"
Expand Down Expand Up @@ -106,10 +107,7 @@ module Athena::Framework
# The `AED::EventListenerInterface` that act upon `ATH::Events` to handle a request. Custom listeners can also be defined, see `AED::EventListenerInterface`.
#
# See each listener and the [external documentation](/components/event_dispatcher/) for more information.
module Listeners
# The tag name for Athena event listeners.
TAG = "athena.event_dispatcher.listener"
end
module Listeners; end

# Namespace for types related to request parameter processing.
#
Expand Down
2 changes: 0 additions & 2 deletions src/components/framework/src/ext/console.cr
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
require "./console/**"

ADI.auto_configure ACON::Command, {tags: ["athena.console.command"]}
Loading

0 comments on commit 52e3796

Please sign in to comment.