ros-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +3 -0
- data/Rakefile +22 -0
- data/app/controllers/ros/application_controller.rb +43 -0
- data/app/controllers/tenants_controller.rb +4 -0
- data/app/docs/application_doc.rb +5 -0
- data/app/docs/tenants_doc.rb +9 -0
- data/app/models/concerns/api_belongs_to.rb +54 -0
- data/app/models/concerns/ros/tenant_concern.rb +104 -0
- data/app/models/ros/application_record.rb +33 -0
- data/app/policies/ros/application_policy.rb +139 -0
- data/app/policies/tenant_policy.rb +3 -0
- data/app/resources/ros/application_resource.rb +11 -0
- data/app/resources/tenant_resource.rb +6 -0
- data/config/environment.rb +0 -0
- data/config/initializers/apartment.rb +11 -0
- data/config/initializers/config.rb +55 -0
- data/config/initializers/jsonapi_resources.rb +14 -0
- data/config/routes.rb +7 -0
- data/config/settings.yml +33 -0
- data/lib/generators/endpoint/USAGE +14 -0
- data/lib/generators/endpoint/endpoint_generator.rb +34 -0
- data/lib/migrations.rb +19 -0
- data/lib/ros/api_token_strategy.rb +45 -0
- data/lib/ros/core.rb +70 -0
- data/lib/ros/core/console.rb +154 -0
- data/lib/ros/core/engine.rb +88 -0
- data/lib/ros/core/version.rb +5 -0
- data/lib/ros/tenant_middleware.rb +108 -0
- data/lib/tasks/db.rake +42 -0
- data/lib/tasks/ros/core_tasks.rake +4 -0
- metadata +313 -0
File without changes
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
Apartment.configure do |config|
|
4
|
+
# Provide list of schemas to be migrated when rails db:migrate is invoked
|
5
|
+
# SEE: https://github.com/influitive/apartment#managing-migrations
|
6
|
+
config.tenant_names = proc { Tenant.pluck(:schema_name) }
|
7
|
+
|
8
|
+
# List of models that are NOT multi-tenanted
|
9
|
+
# See: https://github.com/influitive/apartment#excluding-models
|
10
|
+
config.excluded_models = Tenant.excluded_models
|
11
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
Config.setup do |config|
|
2
|
+
# Name of the constant exposing loaded settings
|
3
|
+
config.const_name = 'Settings'
|
4
|
+
|
5
|
+
# Ability to remove elements of the array set in earlier loaded settings file. For example value: '--'.
|
6
|
+
#
|
7
|
+
# config.knockout_prefix = nil
|
8
|
+
|
9
|
+
# Overwrite an existing value when merging a `nil` value.
|
10
|
+
# When set to `false`, the existing value is retained after merge.
|
11
|
+
#
|
12
|
+
# config.merge_nil_values = true
|
13
|
+
|
14
|
+
# Overwrite arrays found in previously loaded settings file. When set to `false`, arrays will be merged.
|
15
|
+
#
|
16
|
+
# config.overwrite_arrays = true
|
17
|
+
config.overwrite_arrays = false
|
18
|
+
|
19
|
+
# Load environment variables from the `ENV` object and override any settings defined in files.
|
20
|
+
#
|
21
|
+
# config.use_env = false
|
22
|
+
config.use_env = true
|
23
|
+
|
24
|
+
# Define ENV variable prefix deciding which variables to load into config.
|
25
|
+
#
|
26
|
+
# config.env_prefix = 'Settings'
|
27
|
+
config.env_prefix = 'PLATFORM'
|
28
|
+
|
29
|
+
# What string to use as level separator for settings loaded from ENV variables. Default value of '.' works well
|
30
|
+
# with Heroku, but you might want to change it for example for '__' to easy override settings from command line, where
|
31
|
+
# using dots in variable names might not be allowed (eg. Bash).
|
32
|
+
#
|
33
|
+
# config.env_separator = '.'
|
34
|
+
config.env_separator = '__'
|
35
|
+
|
36
|
+
# Ability to process variables names:
|
37
|
+
# * nil - no change
|
38
|
+
# * :downcase - convert to lower case
|
39
|
+
#
|
40
|
+
# config.env_converter = :downcase
|
41
|
+
|
42
|
+
# Parse numeric values as integers instead of strings.
|
43
|
+
#
|
44
|
+
# config.env_parse_values = true
|
45
|
+
|
46
|
+
# Validate presence and type of specific config values. Check https://github.com/dry-rb/dry-validation for details.
|
47
|
+
#
|
48
|
+
# config.schema do
|
49
|
+
# required(:name).filled
|
50
|
+
# required(:age).maybe(:int?)
|
51
|
+
# required(:email).filled(format?: EMAIL_REGEX)
|
52
|
+
# end
|
53
|
+
|
54
|
+
end
|
55
|
+
Settings.load_env!
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
JSONAPI.configure do |config|
|
4
|
+
# http://jsonapi-resources.com/v0.9/guide/resource_caching.html
|
5
|
+
config.resource_cache = Rails.cache
|
6
|
+
|
7
|
+
#:underscored_key, :camelized_key, :dasherized_key, or custom
|
8
|
+
config.json_key_format = :underscored_key
|
9
|
+
|
10
|
+
#:underscored_route, :camelized_route, :dasherized_route, or custom
|
11
|
+
config.route_format = :underscored_route
|
12
|
+
end
|
13
|
+
|
14
|
+
Mime::Type.register 'application/json-patch+json', :json_patch
|
data/config/routes.rb
ADDED
data/config/settings.yml
ADDED
@@ -0,0 +1,33 @@
|
|
1
|
+
# This service's specific configuration; calculate URNs, etc
|
2
|
+
service:
|
3
|
+
# name is set by the service
|
4
|
+
# name:
|
5
|
+
# policy_name is set by the service
|
6
|
+
# policy_name:
|
7
|
+
partition_name: ros
|
8
|
+
region: ''
|
9
|
+
auth_type: Internal
|
10
|
+
# The options for configuring internal connections to services
|
11
|
+
services:
|
12
|
+
connection:
|
13
|
+
type: port
|
14
|
+
# 'host' is the default. Each service runs on port 3000 and is uniquely
|
15
|
+
# addressed by it's host name which is taken from the module name of the Client
|
16
|
+
# Can override values here with an ENV. For example, to use https instead of http:
|
17
|
+
# export PLATFORM_SERVICES_CONNECTION_HOST_SCHEME=https
|
18
|
+
host:
|
19
|
+
scheme: http
|
20
|
+
port: 3000
|
21
|
+
# port based means that each configured service has its own port on localhost
|
22
|
+
# port number starts at value of 'port' and increments by 1 for each additional service
|
23
|
+
# The port to service mapping is in alphabetical order by the service name
|
24
|
+
# e.g. 'comm' is a lower port number than 'iam'
|
25
|
+
# To use this connection type:
|
26
|
+
# export PLATFORM_SERVICES_CONNECTION_TYPE=port
|
27
|
+
port:
|
28
|
+
scheme: http
|
29
|
+
host: localhost
|
30
|
+
port: 3000
|
31
|
+
jwt:
|
32
|
+
iss: ros
|
33
|
+
alg: HS256
|
@@ -0,0 +1,14 @@
|
|
1
|
+
Description:
|
2
|
+
Generates jsonapi controller and resource, pundit policy, model, spec and factory
|
3
|
+
|
4
|
+
Example:
|
5
|
+
rails generate endpoint Thing
|
6
|
+
|
7
|
+
This will create:
|
8
|
+
app/controllers/things_controller.rb
|
9
|
+
app/resources/thing_resource.rb
|
10
|
+
app/policies/thing_policy.rb
|
11
|
+
app/models/thing.rb
|
12
|
+
spec/models/thing_spec.rb
|
13
|
+
spec/factories/things.rb
|
14
|
+
db/migrate/XXX_create_things.rb
|
@@ -0,0 +1,34 @@
|
|
1
|
+
class EndpointGenerator < Rails::Generators::NamedBase
|
2
|
+
source_root File.expand_path('templates', __dir__)
|
3
|
+
|
4
|
+
def create_files
|
5
|
+
parent_module = Dir.pwd.split('/').last.remove('ros-').classify
|
6
|
+
invoke(:model)
|
7
|
+
gsub_file("app/models/#{name}.rb", 'ApplicationRecord', "#{parent_module}::ApplicationRecord")
|
8
|
+
insert_into_file 'config/routes.rb', after: "Rails.application.routes.draw do\n" do
|
9
|
+
" jsonapi_resources :#{plural_name}\n"
|
10
|
+
end
|
11
|
+
create_file "app/controllers/#{plural_name}_controller.rb", <<-FILE
|
12
|
+
# frozen_string_literal: true
|
13
|
+
|
14
|
+
class #{name.classify.pluralize}Controller < #{parent_module}::ApplicationController
|
15
|
+
end
|
16
|
+
FILE
|
17
|
+
|
18
|
+
create_file "app/resources/#{name}_resource.rb", <<-FILE
|
19
|
+
# frozen_string_literal: true
|
20
|
+
|
21
|
+
class #{name.classify}Resource < #{parent_module}::ApplicationResource
|
22
|
+
attributes #{args.map { |e| ':' + e.split(':').first }.join(', ')}
|
23
|
+
end
|
24
|
+
FILE
|
25
|
+
|
26
|
+
create_file "app/policies/#{name}_policy.rb", <<-FILE
|
27
|
+
# frozen_string_literal: true
|
28
|
+
|
29
|
+
class #{name.classify}Policy < Cognito::ApplicationPolicy
|
30
|
+
end
|
31
|
+
FILE
|
32
|
+
|
33
|
+
end
|
34
|
+
end
|
data/lib/migrations.rb
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_record'
|
4
|
+
|
5
|
+
module Ros
|
6
|
+
module Migrations
|
7
|
+
module Create
|
8
|
+
module Tenant
|
9
|
+
def change
|
10
|
+
create_table :tenants do |t|
|
11
|
+
t.string :schema_name, null: false, index: { unique: true }
|
12
|
+
|
13
|
+
t.timestamps null: false
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ros
|
4
|
+
class ApiTokenStrategy < Warden::Strategies::Base
|
5
|
+
attr_accessor :auth_string, :auth_type, :token, :access_key_id, :secret_access_key, :urn
|
6
|
+
|
7
|
+
def auth_string; @auth_string ||= env['HTTP_AUTHORIZATION'] end
|
8
|
+
|
9
|
+
def auth_type; @auth_type ||= auth_string.split.first.downcase end
|
10
|
+
|
11
|
+
def token; @token ||= auth_string.split.last end
|
12
|
+
|
13
|
+
def access_key_id; @access_key_id ||= token.split(':').first end
|
14
|
+
|
15
|
+
def secret_access_key; @secret_access_key ||= token.split(':').last end
|
16
|
+
|
17
|
+
def valid?; token.present? end
|
18
|
+
|
19
|
+
def authenticate!
|
20
|
+
user = send("authenticate_#{auth_type}") if auth_type.in? %w(basic bearer)
|
21
|
+
return success!(user) if user
|
22
|
+
# This is returned to IAM service
|
23
|
+
fail!({ errors: [{ status: 401, code: 'unauthorized', title: 'Unauthorized' }] }.to_json)
|
24
|
+
end
|
25
|
+
|
26
|
+
def authenticate_basic
|
27
|
+
# TODO: Credential.authorization must be an instance variable
|
28
|
+
Ros::Sdk::Credential.authorization = auth_string
|
29
|
+
return unless credential = Ros::IAM::Credential.where(access_key_id: access_key_id).first
|
30
|
+
"Ros::IAM::#{credential.owner_type}".constantize.find(credential.owner_id).first
|
31
|
+
# NOTE: Swallow the auth error and return nil which causes user to be nil, which cuases FailureApp to be invoked
|
32
|
+
rescue JsonApiClient::Errors::NotAuthorized => e
|
33
|
+
end
|
34
|
+
|
35
|
+
def authenticate_bearer
|
36
|
+
return unless urn = Urn.from_jwt(token)
|
37
|
+
return unless urn.model_name.in? %w(Root User)
|
38
|
+
# TODO: Credential.authorization must be an instance variable
|
39
|
+
Ros::Sdk::Credential.authorization = auth_string
|
40
|
+
"Ros::IAM::#{urn.model_name}".constantize.find_by_urn(urn.resource_id)
|
41
|
+
# NOTE: Swallow the auth error and return nil which causes user to be nil, which cuases FailureApp to be invoked
|
42
|
+
rescue JsonApiClient::Errors::NotAuthorized => e
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
data/lib/ros/core.rb
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Require gems from gemspec so they are available to the gems that depend on ros-core
|
4
|
+
require 'seedbank'
|
5
|
+
require 'apartment'
|
6
|
+
require 'jsonapi-resources'
|
7
|
+
require 'jsonapi/authorization'
|
8
|
+
require 'attr_encrypted'
|
9
|
+
require 'pry'
|
10
|
+
require 'jwt'
|
11
|
+
require 'warden'
|
12
|
+
require 'config'
|
13
|
+
require 'ros_sdk'
|
14
|
+
|
15
|
+
require_relative 'tenant_middleware'
|
16
|
+
require_relative 'api_token_strategy'
|
17
|
+
|
18
|
+
require 'ros/core/engine'
|
19
|
+
|
20
|
+
module Ros
|
21
|
+
class Jwt
|
22
|
+
def self.encode(payload)
|
23
|
+
# TODO: This is called twice by IAM on basic auth
|
24
|
+
JWT.encode(payload, Settings.credentials.jwt_encryption_key, Settings.jwt.alg)
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.decode(payload)
|
28
|
+
JWT.decode(payload, Settings.credentials.jwt_encryption_key, Settings.jwt.alg)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
Urn = Struct.new(:txt, :partition_name, :service_name, :region, :account_id, :resource) do
|
33
|
+
def to_s; to_a.join(':') end
|
34
|
+
|
35
|
+
def self.from_jwt(token)
|
36
|
+
jwt = Jwt.decode(token)
|
37
|
+
return unless urn_string = jwt[0]['urn']
|
38
|
+
urn_array = urn_string.split(':')
|
39
|
+
new(*urn_array)
|
40
|
+
rescue JWT::DecodeError
|
41
|
+
end
|
42
|
+
|
43
|
+
def resource_type; resource.split('/').first end
|
44
|
+
def resource_id; resource.split('/').last end
|
45
|
+
|
46
|
+
def model_name; resource_type.classify end
|
47
|
+
def model; model_name.constantize end
|
48
|
+
def instance; model.find_by_urn(resource_id) end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Failure response to return JSONAPI error message when authentication failse
|
52
|
+
class FailureApp
|
53
|
+
def self.call(env)
|
54
|
+
[
|
55
|
+
'401',
|
56
|
+
{ 'Content-Type' => 'application/vnd.api+json' },
|
57
|
+
[
|
58
|
+
{ errors: [{ status: '401', title: 'Unauthorized' }] }.to_json
|
59
|
+
]
|
60
|
+
]
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
module Core
|
65
|
+
class << self
|
66
|
+
def load_factories
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,154 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Add commands to the Pry command set for all services
|
4
|
+
# Change the pry cli prompt to displace the current tenant
|
5
|
+
# "[#{PryRails::Prompt.project_name}][#{PryRails::Prompt.formatted_env}][#{Apartment::Tenant.current}] " \
|
6
|
+
|
7
|
+
def ab
|
8
|
+
Apartment::Tenant.current
|
9
|
+
rescue ActiveRecord::ConnectionNotEstablished
|
10
|
+
'n/a'
|
11
|
+
end
|
12
|
+
|
13
|
+
if Pry::Prompt.respond_to?(:add)
|
14
|
+
desc = "Includes the current Rails environment and project folder name.\n" \
|
15
|
+
"[1] [project_name][Rails.env][Apartment::Tenant.current] pry(main)>"
|
16
|
+
Pry::Prompt.add 'ros', desc, %w(> *) do |target_self, nest_level, pry, sep|
|
17
|
+
"[#{pry.input_ring.size}] " \
|
18
|
+
"[#{PryRails::Prompt.project_name}][#{PryRails::Prompt.formatted_env}][#{ab}] " \
|
19
|
+
"#{pry.config.prompt_name}(#{Pry.view_clip(target_self)})" \
|
20
|
+
"#{":#{nest_level}" unless nest_level.zero?}#{sep} "
|
21
|
+
end
|
22
|
+
|
23
|
+
Pry.config.prompt = Pry::Prompt[:ros][:value]
|
24
|
+
end
|
25
|
+
|
26
|
+
module Ros
|
27
|
+
module Console
|
28
|
+
module Methods
|
29
|
+
def fbc(type)
|
30
|
+
try_count ||= 0
|
31
|
+
FactoryBot.create(type)
|
32
|
+
rescue KeyError
|
33
|
+
try_count += 1
|
34
|
+
Dir[Pathname.new(Dir.pwd).join('spec', 'factories', '**', '*.rb')].each { |f| require f }
|
35
|
+
retry if try_count < 2
|
36
|
+
end
|
37
|
+
class << self
|
38
|
+
def models
|
39
|
+
Dir[Pathname.new(Dir.pwd).join('app', 'models', '**', '*.rb')]
|
40
|
+
end unless Ros::Console::Methods.methods.include? :models
|
41
|
+
def init
|
42
|
+
models.each do |model|
|
43
|
+
name = File.basename(model).gsub('.rb', '')
|
44
|
+
id = name[0]
|
45
|
+
define_method("#{id}a") { name.classify.constantize.all }
|
46
|
+
define_method("#{id}c") { fbc(name) }
|
47
|
+
define_method("#{id}f") { Rails.configuration.x.memoized_shortcuts["#{id}f"] ||= name.classify.constantize.first }
|
48
|
+
define_method("#{id}l") { Rails.configuration.x.memoized_shortcuts["#{id}l"] ||= name.classify.constantize.last }
|
49
|
+
define_method("#{id}p") { |column| name.classify.constantize.pluck(column) }
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
Ros::Console::Methods.init
|
54
|
+
def ct; Rails.configuration.x.memoized_shortcuts[:ct] ||= Tenant.find_by(schema_name: Apartment::Tenant.current) end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
Ros::PryCommandSet = Pry::CommandSet.new
|
60
|
+
|
61
|
+
module Ros::Console::Commands
|
62
|
+
class TenantSelect < Pry::ClassCommand
|
63
|
+
match 'select-tenant'
|
64
|
+
group 'ros'
|
65
|
+
description 'Select Tenant'
|
66
|
+
banner <<-BANNER
|
67
|
+
Usage: select-tenant [id]
|
68
|
+
|
69
|
+
'id' is the numerical id returned from `select-tenant` when no id is passed
|
70
|
+
If the id is passed that tenant's schema will become the active schema
|
71
|
+
If the id that is passed doesn't exist then the default schema 'public' will become the active schema
|
72
|
+
BANNER
|
73
|
+
|
74
|
+
def process(id = nil)
|
75
|
+
if id.nil?
|
76
|
+
# NOTE: This is dumb, but passing an array of field names to #pluck results in a noisy DEPRECATION WARNING
|
77
|
+
if Tenant.column_names.include? 'name'
|
78
|
+
output.puts Tenant.order(:id).pluck(:id, :schema_name, :name).each_with_object([]) { |a, ary| ary << a.join(' ') }
|
79
|
+
else
|
80
|
+
output.puts Tenant.order(:id).pluck(:id, :schema_name).each_with_object([]) { |a, ary| ary << a.join(' ') }
|
81
|
+
end
|
82
|
+
return
|
83
|
+
end
|
84
|
+
Apartment::Tenant.switch! Tenant.schema_name_for(id: id)
|
85
|
+
Rails.configuration.x.memoized_shortcuts = {}
|
86
|
+
end
|
87
|
+
|
88
|
+
Ros::PryCommandSet.add_command(self)
|
89
|
+
end
|
90
|
+
|
91
|
+
class Reload < Pry::ClassCommand
|
92
|
+
match 'reload'
|
93
|
+
group 'ros'
|
94
|
+
description 'reload rails and reset memoized'
|
95
|
+
|
96
|
+
def process
|
97
|
+
Rails.configuration.x.memoized_shortcuts = {}
|
98
|
+
TOPLEVEL_BINDING.eval('self').reload!
|
99
|
+
end
|
100
|
+
|
101
|
+
Ros::PryCommandSet.add_command(self)
|
102
|
+
end
|
103
|
+
|
104
|
+
class RabbitMQ < Pry::ClassCommand
|
105
|
+
match 'mq-send'
|
106
|
+
group 'ros'
|
107
|
+
description 'send a message on the mq bus'
|
108
|
+
|
109
|
+
# TODO: refactor
|
110
|
+
def process
|
111
|
+
return unless ENV['AMQP_URL']
|
112
|
+
record = { bucket: 'test', key: 'path/to/object' }
|
113
|
+
conn = Bunny.new(ENV['AMQP_URL'])
|
114
|
+
conn.start
|
115
|
+
ch = conn.create_channel
|
116
|
+
puts "#{record[:bucket]}/#{record[:key]}"
|
117
|
+
puts ENV['AMQP_QUEUE_NAME']
|
118
|
+
puts record.merge!({ tenant: 'hsbc', environment: 'development' })
|
119
|
+
|
120
|
+
res = ch.default_exchange.publish("#{record[:bucket]}/#{record[:key]}",
|
121
|
+
routing_key: ENV['AMQP_QUEUE_NAME'],
|
122
|
+
headers: record.merge({ version: ENV['AMQP_VERSION'].to_s }))
|
123
|
+
|
124
|
+
puts 'Here is output from bunny'
|
125
|
+
puts res
|
126
|
+
conn.close
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
class ToggleLogger < Pry::ClassCommand
|
131
|
+
match 'toggle-logger'
|
132
|
+
group 'ros'
|
133
|
+
description 'Toggle the Rails Logger on/off'
|
134
|
+
|
135
|
+
def process(state = nil)
|
136
|
+
unless state.nil?
|
137
|
+
return if (state == 'off' and ActiveRecord::Base.logger.nil?) or (state == 'on' and not ActiveRecord::Base.logger.nil?)
|
138
|
+
end
|
139
|
+
if ActiveRecord::Base.logger.nil?
|
140
|
+
ActiveRecord::Base.logger = Rails.configuration.x.old_logger
|
141
|
+
else
|
142
|
+
Rails.configuration.x.old_logger = ActiveRecord::Base.logger
|
143
|
+
ActiveRecord::Base.logger = nil
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
Ros::PryCommandSet.add_command(self)
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
Pry.config.commands.import Ros::PryCommandSet
|
152
|
+
Pry.config.commands.alias_command 'r', 'reload'
|
153
|
+
Pry.config.commands.alias_command 'st', 'select-tenant'
|
154
|
+
Pry.config.commands.alias_command 'to', 'toggle-logger'
|