Yes Command API
The Yes command API is a mountable rails engine providing an endpoint for calling API commands.
Commands represent the write side of CQRS in our eventsourced system.
Installation
Add this line to your application's Gemfile:
gem "yes-command-api"
And then execute:
bundle install
Usage
See the root README for the full DSL documentation, aggregate definition, and usage examples.
Configuration
The preferred way of issuing commands using the commands API is asynchronously.
For that, you need to configure Yes::Core to process commands asynchronously.
Yes::Core.configure do |config|
config.process_commands_inline = false
end
If process_commands_inline is true (the default), commands are processed synchronously in the request. When set to false, commands are enqueued via ActiveJob for asynchronous processing.
Mounting the Endpoint
Mount the command endpoint to your rails application in config/routes.rb:
Rails.application.routes.draw do
mount Yes::Command::Api::Engine => '/v1/commands'
end
The mounted endpoint exposes all commands defined in your bounded context(s).
Writing Authorizers
To make a command accessible for a caller, you need to define an authorizer for it. If there is no authorizer defined a command is considered unauthorized for all callers by default.
Example:
Given a command
module MyContext
module MyAggregate
class Aggregate < Yes::Core::Aggregate
attribute :what, :string, command: true
attribute :user_id, :uuid
do
command.user_id == auth_data['user_id']
end
end
end
end
The authorizer needs to raise CommandNotAuthorized if the given auth_data (jwt payload + referer host) does not authorize the given command.
In case the authorizer raises nothing, the command is considered authorized.
Making a Command(s) Request
The commands endpoint accepts commands supplied as a json array, using a POST request.
The endpoint is located where you mounted it, e.g. https://your-app.example.com/v1/commands.
Here is an example of a valid payload:
{
"commands": [{
"subject": "MyAggregate",
"context": "MyContext",
"command": "DoSomething",
"data": {
"user_id": "07393424-fa57-40fe-a3d2-c3bdd8b8e952",
"what": "Nonsense"
}
}],
"channel": "/notifications-for-user-07393424-fa57-40fe-a3d2-c3bdd8b8e952"
}
You also need to supply a valid JWT token as a bearer token for authorization and authentication.
Note that commands is an array, so you can supply any number of commands in a single request.
See the next section for how to receive updates about your commands using the standard message bus notifier.
MessageBus Notifier
Authorization
In order to receive user-targeted messages - you should authorize your request first. It can be done by providing JWT token along with Authorization header. Example:
let headers = { 'Authorization': 'Token eyJhbGciOiJFRDI1NTE5In0.eyJzY29wZXMiOlsiYWRtaW4iLCJjdXJyZW50X3VzZXIiLCJ1c2VyX3Byb2ZpbGUiXSwiZGF0YSI6eyJ1c2VyX3V1aWQiOiIyMjUwODIwZS00MzVhLTQ0ODQtYWUzMS1iYTFiODk1NDI2MWUifSwiZXhwIjoxNjkxNzQwNTA3fQ.D_TuOKh5LyGtusU5cZrJih-WYbB7MWChDOTS6WcWCRZUdldzZzKmXLtdgE93bkgb0TV9FNKXSvHt8DLhBZIoCA' };
Filters
You can filter messages by providing filter params in the request url. Here they are:
batch_id. It is your command batch id. Example:/message-bus/some-client-id/poll?batch_id=7121e60e-4d3d-4fb7-b454-f603c75f1359type. It is a command type. Possible values arebatch_started,batch_finished,command_successandcommand_errorso far. Example:/message-bus/some-client-id/poll?type=command_errorcommand. It is a command name. Example/message-bus/some-client-id/poll?command=ApprenticeshipPresentation::Apprenticeship::Commands::AssignCompany::Commandsince. Unix timestamp. Providing it will filter messages which are not older than thesinceparam value. Example:/message-bus/some-client-id/poll?since=1689778808
You can provide a starting message id to start receiving messages from certain position. As stated in docs - you should pass it in the payload along with a channel name to subscribe to:
let payload = { 'some-channel-name': 123 }
Examples
Here is how long-polling HTTP request from browser using various filters and JWT authorization may look like:
async function postData(url = "", data = {}) {
// Default options are marked with *
const response = fetch(url, {
method: "POST", // *GET, POST, PUT, DELETE, etc.
mode: "same-origin", // no-cors, *cors, same-origin
cache: "no-cache", // *default, no-cache, reload, force-cache, only-if-cached
credentials: "same-origin", // include, *same-origin, omit
headers: {
'Content-Type': 'application/json',
'X-SILENCE-LOGGER': 'true',
'Transfer-Encoding': 'chunked',
'Authorization': 'Token eyJhbGciOiJFRDI1NTE5In0.eyJzY29wZXMiOlsiYWRtaW4iXSwiZGF0YSI6eyJ1c2VyX3V1aWQiOiJlMmMwYzBkNC1iMWMzLTQwNzktOTlhMi0zYTlhOTg2MWVhYzgifSwiZXhwIjoxNjg5NzgzMjE4fQ.HKmthrv7HDsMof88hvCErVSlTCGg-Ikeb9-eb0DLPVXQQmpJ_4gTD52bgMFBGmGaA_TdRakAG3UGgCp9d9VYAw'
},
redirect: "error", // manual, *follow, error
referrerPolicy: "no-referrer", // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url
body: JSON.stringify(data), // body data type must match "Content-Type" header
});
return response;
}
function processChunkedResponse(response) {
var text = '';
var reader = response.body.getReader()
var decoder = new TextDecoder();
return readChunk();
function readChunk() {
return reader.read().then(appendChunks);
}
function appendChunks(result) {
var chunk = decoder.decode(result.value || new Uint8Array, {stream: !result.done});
console.log('got chunk of', chunk.length, 'bytes')
console.log('chunk so far is', chunk);
text += chunk;
if (result.done) {
return text;
} else {
return readChunk();
}
}
}
//let url = 'http://localhost:3000/message-bus/some-client-id/poll?since=1689778808&type=batch_started&batch_id=7121e60e-4d3d-4fb7-b454-f603c75f1359&command=ApprenticeshipPresentation::Apprenticeship::Commands::AssignCompany::Command'
let url = new URL('http://localhost:3000/message-bus/some-client-id/poll');
url.search = new URLSearchParams(
{
since: 1689778808,
type: 'batch_started',
batch_id: '7121e60e-4d3d-4fb7-b454-f603c75f1359',
command: 'ApprenticeshipPresentation::Apprenticeship::Commands::AssignCompany::Command'
}
);
postData(url, { '/notifications/testing-12345678': 0 }).then(processChunkedResponse);
Development
Prerequisites
- Docker and Docker Compose
- Ruby >= 3.2.0
- Bundler
Setup
Start PostgreSQL and Redis from the repository root:
docker compose up -d
Install dependencies:
bundle install
Set up the EventStore database:
PG_EVENTSTORE_URI="postgresql://postgres:postgres@localhost:5532/eventstore_test" bundle exec rake pg_eventstore:create pg_eventstore:migrate
Set up the test database:
RAILS_ENV=test bundle exec rake db:create db:migrate
The .env file at spec/.env is loaded automatically and contains JWT test keys.
Running Specs
bundle exec rspec
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/yousty/yes.
License
The gem is available as open source under the terms of the MIT License.