Tools

A library of utility services and concerns.

Messages

The Messages tool is used for defining and generating human-readable messages.

For a full list of available methods, see the Reference documentation.

Contents

Generating Messages

To generate a human-readable message, use the #message method:

tools = SleepingKingStudios::Tools::Toolbelt.instance

tools.messages.message('sleeping_king_studios.tools.assertions.name')
#=> "is not a String or a Symbol"

Each defined message has a scope and a key, separated by a period (.).

Generally speaking, a message’s scope should match the structure of the relevant code. The scope should always start with the name of the gem, application, or project.

Alternatively, you can pass the scope separately when calling #message:

tools = SleepingKingStudios::Tools::Toolbelt.instance
scope = 'sleeping_king_studios.tools.assertions'
key   = :name

tools.messages.message(key, scope:)
#=> "is not a String or a Symbol"

This can be useful when generating multiple human-readable messages with the same base scope.

Back to Top

Missing Messages

If a requested message is not defined, #message returns an error string with the scoped key:

tools = SleepingKingStudios::Tools::Toolbelt.instance

tools.messages.message('sleeping_king_studios.tools.assertions.undefined_message')
#=> "Message missing: sleeping_king_studios.tools.assertions.undefined_message"

To define your own message definitions, see Registering Messages, below.

Back to Top

Parameterized Messages

Messages can also have named parameters.

tools = SleepingKingStudios::Tools::Toolbelt.instance

tools.messages.message(
  'sleeping_king_studios.tools.assertions.instance_of',
  parameters: { expected: 'String' }
)
#=> "is not an instance of String"

If a message requires parameters but a parameter is missing, #message returns an error string:

tools = SleepingKingStudios::Tools::Toolbelt.instance

tools.messages.message('sleeping_king_studios.tools.assertions.instance_of')
#=> "Message missing parameters: sleeping_king_studios.tools.assertions.instance_of key<expected> not found"

You can force an exception to be raised for missing parameters by passing the :reraise_exceptions flag:

tools = SleepingKingStudios::Tools::Toolbelt.instance

tools.messages.message('sleeping_king_studios.tools.assertions.instance_of', reraise_exceptions: true)
#=> raises a KeyError with message "key<expected> not found"

Back to Top

Default Values

You can also provide a default value or Proc in case the requested message is not defined.

If you pass an object as the default: keyword, that default value will be returned if the requested message is not defined.

tools = SleepingKingStudios::Tools::Toolbelt.instance

tools.messages.message(
  'example.messages.undefined_message',
  default: 'message not found'
)
#=> "message not found"

If you pass a Proc as the default: keyword, the proc will be called with the scoped message key and any additional parameters passed to #message.

tools = SleepingKingStudios::Tools::Toolbelt.instance

tools.messages.message(
  'example.messages.undefined_message',
  default: lambda do |key, locale: 'en', **|
    "message not found with key #{key} and locale #{locale}"
  end,
  locale:  'es'
)
#=> "message not found with key example.messages.undefined_message and locale es"

Back to Top

Registering Messages

Registering messages for use in the #messages tool takes two steps.

Back to Top

Message Strategies

Each message strategy must define a #call method that takes a key argument and optional parameters: and scope: keywords and returns the matching generated message. The strategy must ignore unexpected keyword parameters.

SleepingKingStudios::Tools provides two pre-defined strategy implementations:

You can also define a custom strategy class. This allows you to retrieve messages from an external tool, or specify additional filters for matching the message definition (such as a locale: keyword).

Back to Top

File Strategies

A FileStrategy loads message definitions from a file.

# In config/messages.yml
---
space:
  messages:
    errors:
      failure: 'not going to space'
    rockets:
      launch_status: 'rocket %<name>s is ready to launch'
  module_name: 'Console Space Program'
file_name = 'config/messages.yml'
strategy  =
  SleepingKingStudios::Tools::Messages::Strategies::FileStrategy
  .new(file_name)

strategy.call('space.module_name')
#=> "Console Space Program"
strategy.call(:launch_status, parameters: { name: 'Imp VI' }, scope: 'space.messages.rockets')
#=> "rocket Imp VI is ready to launch"

The FileStrategy is initialized with the path to a definitions file.

Nested scopes will be automatically flattened when the file is read. For example, the above file defines the scoped key 'space.messages.errors.failure' to be 'not going to space'.

Each message defined in a FileStrategy must be a String. Parameterized messages are supported using Kernel#format. For example, the 'space.messages.rockets.launch_status definition takes a name parameter.

You can also register a file strategy using the following shorthand:

registry.register(scope: 'space', file: file_name)

By default, a FileStrategy will flatten the parsed templates for efficient lookup - a nested Hash of { foo: { bar: { baz: 'template' } } } is stored as a flat Hash with contents { 'foo.bar.baz' => 'template' }. This makes lookup of scoped keys more efficient but prevents retrieving non-leaf nodes (such as "foo" or "foo.bar" in the above Hash). If you need to retrieve Hash values, initialize the strategy with flatten_templates: false.

file_name = 'config/messages.yml'
strategy  =
  SleepingKingStudios::Tools::Messages::Strategies::FileStrategy
  .new(file_name, flatten_templates: false)

strategy.get('space.messages.errors')
#=> { 'failure' => 'not going to space' }

Back to Top

Hash Strategies

A HashStrategy takes message definitions as a pre-defined Hash.

launch_status = lambda do |parameters: {}, ready: false, **|
  str = +'rocket'
  str << ' ' << parameters[:name] if parameters.key?(:name)
  str << (ready ? ' is' : ' is not')
  str << ' ready to launch'
  str.freeze
end
definitions = {
  'space' => {
    'messages'    => {
      'errors'  => {
        'failure' => 'not going to space'
      },
      'rockets' => {
        'launch_status' => launch_status
      }
    }
    'module_name' => 'Console Space Program'
  }
}
strategy =
  SleepingKingStudios::Tools::Messages::Strategies::HashStrategy
  .new(definitions)

strategy.call('space.module_name')
#=> "Console Space Program"

strategy.call(:launch_status, ready: true, scope: 'space.messages.rockets')
#=> "rocket is ready to launch"

The HashStrategy is initialized with a Hash of message definitions. Hash strategies support two types of message definition:

Nested scopes will be automatically flattened when the file is read. For example, the above definitions define the scoped key 'space.messages.errors.failure' to be 'not going to space'.

You can also register a hash strategy using the following shorthand:

registry.register(scope: 'space', hash: definitions)

By default, a HashStrategy will flatten the parsed templates for efficient lookup - a nested Hash of { foo: { bar: { baz: 'template' } } } is stored as a flat Hash with contents { 'foo.bar.baz' => 'template' }. This makes lookup of scoped keys more efficient but prevents retrieving non-leaf nodes (such as "foo" or "foo.bar" in the above Hash). If you need to retrieve Hash values, initialize the strategy with flatten_templates: false.

strategy  =
  SleepingKingStudios::Tools::Messages::Strategies::FileStrategy
  .new(definitions, flatten_templates: false)

strategy.get('space.messages.errors')
#=> { 'failure' => 'not going to space' }

Back to Top

Message Registries

Once your messages have been defined using a Messages strategy, the strategy must be added to a registry. By default, the Messages tool uses a shared global registry.

registry = SleepingKingStudios::Tools::Messages::Registry.global
strategy = SleepingKingStudios::Tools::Messages::Strategies::HashStrategy.new

registry.register(scope: 'space', strategy:)

registry.get('space')
#=> strategy

When #message is called with a scope: or a scoped key starting with 'space.', the registry will resolve the message definition to the registed strategy with matching scope. That strategy is then called with the key, scope, and parameters to generate the message.

Registed strategies can be nested, in which case the registry will use the most specific strategy matching the message scope. For example, one strategy might be registered with scope 'space', and another strategy with scope 'space.planets'. A message with scope 'space.stars' would be resolved using the 'space' strategy, but a message with scope 'space.planets.names' would be resolved using the 'space.planets' strategy.

Back to Top

Global Registry

The global registry provides a single entry point for defining and accessing message definitions across an application and its dependencies. Using the global registry has several advantages:

The default Toolbelt.instance is already configured to use the global registry.

Back to Top

Custom Registries

A custom registry can be defined by instantiating a new copy of Messages::Registry or a subclass thereof. The registry can then be used by passing it a custom toolbelt.

definitions = {
  'spec.test_cases.going_to_space' = 'Is the rocket going to space?'
}

registry = SleepingKingStudios::Tools::Messages::Registry.new
registry.register(scope: 'spec', hash: definitions)

toolbelt = SleepingKingStudios::Tools::Toolbelt.new(messages_registry: registry)
toolbelt.messages.message(:going_to_space, scope: 'spec.test_cases')
#=> "Is the rocket going to space?"

Back to Top

Initializing Messages

The recommended way to register messages for your application or project is using a project initializer.

module Space
  @initializer = SleepingKingStudios::Tools::Toolbox::Initializer.new do
    # Load human-readable message definitions for the project namespace.
    SleepingKingStudios::Tools.initializer.call
    SleepingKingStudios::Tools::Messages::Registry
      .global
      .register(file: 'config/messages.yml' scope: 'space')
  end

  def self.initializer = @initializer
end

Once the initializer is defined, update the entry point for your project to call the initializer.

Back to Top

Registered Messages

In addition to retrieving messages from tools.messages, you can also generate messages directly from a registry. This allows defining multiple registries for different scopes or purposes, or using dependency injection to define different registries for development, test, and production environments.

# Access the registry instance.
registry = SleepingKingStudios::Tools::Messages::Registry.global

strategy = registry.get('example.message_key')
message  = strategy&.call('example.message_key', default: nil)

First, we retrieve the strategy that defines messages for the requested key. If there is no matching strategy, registry.get will return nil.

Second, if we have a matching strategy, we then ask the strategy to generate the message using strategy.call. We also pass default: nil to ensure that message will be nil when either the strategy or the message template is not defined. (When called via tools.messages, this is automatically handled to generate an error message).

Back to Top

Message Templates

For some use cases, you may need to access the raw message templates rather than the formatted values. For example, you may have custom message formatting logic, or you may store messages in a custom format, such as a Hash with optional properties. The Strategy#get and Strategy#fetch methods allow you to do just that.

# Returns nil if the strategy does not define the message.
strategy.get('example.message_key')

# Returns 'Missing template "example.message_key"' if the strategy does not
# define the message.
strategy.fetch('example.message_key', 'Missing template "example.message_key"')

# Returns 'Missing template "example.message_key"' if the strategy does not
# define the message.
strategy.fetch('example.message_key') do |scoped_key, **options|
  message = 'Missing template "example.message_key"'
  message += " with options #{options.inspect}" unless options.empty?
  message
end

Back to Top


Back to Documentation | Tools