Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More tightly integrate DI component with the other components #259

Merged
merged 5 commits into from
Feb 4, 2023
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
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