Routing in Salmon works via two mechanisms: the @route
decorator and a finite state machine.
The @route
decorator uses the recipient to determine which handlers match the message - in a similar way to how web frameworks configure URL handlers.
The finite state machine uses the sender as a key to keep track of which of the matched handlers the message should be given to. For example, a mailing list application might have two handlers with same @route
, one handler for those who are subscribed and another for those who are not.
Here's a brief overview of how Salmon decides which of your application's handlers should process the message:
- Match all handlers whose
@route
decorator matches theto
header Iterate over these and call the following
- any handlers that have been marked as
@stateless
- the first (and only) stateful handler. If it returns a handler reference, the state for that sender will be updated.
- any handlers that have been marked as
- If no valid handlers were found, the message is sent to the undeliverable queue
The ~salmon.routing.route
decorator takes a regex pattern as its first argument and then capture groups as keyword arguments:
from salmon.routing import route
@route("(list_name)-(action)@(host)",
list_name="[a-z]+", action="[a-z]+", host="example\.com")
def START(message, list_name=None, action=None, host=None)
....
For example, a message to salmon-subscribe@example.com
would match this handler, but a message to salmon@example.com
would not - even if START
was our only handler.
It's quite usual to multiple handlers decorated with the same route
- we'll cover why in the next section. To save typing, you can have your handler routed exactly like another:
from salmon.routing import route_like
@route_like(START):
def CONFIRM(message, list_name=None, action=None, host=None):
....
Again, a message to salmon-subscribe@example.com
would match this handler, but a message to salmon@example.com
would not. How to control which handler out of the two is ultimately used to process a message is discussed in the next section.
The finite state machine is how Salmon knows where to process a message, even when multiple handlers have routes that match the recipient. Before we explain how that is done, let's look at how to control the finite state machine.
First of all, let's flesh out the examples from the previous section. These examples will call some functions defined in myapp
which we won't define as how they work is not important.:
from salmon.routing import route, route_like
from myapp.utils import (
confirmed_checker, # returns True or False
confirm_sender, # adds sender to subsciber list
send_confirmation, # sends a confirmation email
post_message, # posts a message to the given mailing list
)
@route("(list_name)-(action)@(host)",
list_name="[a-z]+", action="[a-z]+", host="example\.com")
def START(message, list_name=None, action=None, host=None)
if action == "subscribe" and not confirmed_checker(message.from):
send_confirmation(message.from)
return CONFIRM
elif action == "post":
post_message(message, list_name)
return
else:
# unknown action
return
@route_like(START):
def CONFIRM(message, list_name=None, action=None, host=None):
confirm_sender(message.form)
return START
When a message from a previously unknown sender is received, it will be matched against a START
handler with the correct route
. In our example, if action
is "subscribe"
then the handler returns CONFIRM
- which is another handler. The next time a message from this sender is received, the CONFIRM
handler will process the message and the state will return to START
(as CONFIRM
always returns START
).
Note
The CONFIRM
handler wouldn't reset the state to START
in a real application, but examples have been kept short to make them easier to understand.
State storage in Salmon is controlled by encoding the current module and sender to a string, then using that string as a key for a dict
-like object that stores the state as the value for that key. For example, the state storage for our application might look like this:
>>> from salmon.routing import Router
>>> print(Router.STATE_STORE.states)
{
"['myapp', 'user1@example.com']": <function CONFIRM at 0x7f64194fa320>,
"['myapp', 'user2@example.com']": <function START at 0x7f64194fa398>
}
If you don't require states for one or more of your handlers, the decorator ~salmon.routing.stateless
will make sure the state machine is completely bypassed on the way in (but you can still return handles to affect the sender's state):
from salmon.routing import stateless, route
@route("admin@example.com")
@stateless
def ADMINS(message):
# forward the email to admins
....
The default state storage ~salmon.routing.MemoryStorage
is only intended for testing as it only stores state in memory - states will be lost. For small installations, ~salmon.routing.ShelveStorage
will save state to disk and be performant enough. Add the following lines to your boot.py
to use it:
from myapp.models import ShelveStorage
Router.STATE_STORAGE = ShelveStorage()
Larger installations will be required to write their own state storage. Any popular database that can provide some sort of atomic get and set should be capable. For example, Django's ORM could be used:
# in your models.py
from django.db import models
from salmon.routing import StateStorage, ROUTE_FIRST_STATE
# this model is incomplete, but should give you a good start
class SalmonState(models.Model):
key = models.CharField()
sender = models.CharField()
state = models.CharField()
class DjangoStateStorage(StateStorage):
def get(self, key, sender):
try:
state = SalmonState.objects.get(key=key, sender=sender)
return state.state
except SalmonState.DoesNotExist:
return ROUTE_FIRST_STATE
def set(self, key, sender, state):
SalmonState.objects.update_or_create(
key=key, sender=sender, kwargs={"state": state}
)
def clear(self):
SalmonState.objects.all().delete()
# at the end of boot.py
from myapp.models import DjangoStateStorage
Router.STATE_STORAGE = DjangoStateStorage()
Note
This example is incomplete, it's only there to give an idea of how to implement a state storage class.