yes-command-api 1.0.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 +242 -0
- data/Rakefile +14 -0
- data/app/controllers/yes/command/api/application_controller.rb +10 -0
- data/app/controllers/yes/command/api/v1/commands_controller.rb +213 -0
- data/config/initializers/message_bus_filters.rb +42 -0
- data/config/routes.rb +54 -0
- data/lib/yes/command/api/commands/batch_authorizer.rb +89 -0
- data/lib/yes/command/api/commands/batch_validator.rb +75 -0
- data/lib/yes/command/api/commands/deserializer.rb +93 -0
- data/lib/yes/command/api/commands/notifiers/action_cable.rb +82 -0
- data/lib/yes/command/api/commands/notifiers/message_bus.rb +100 -0
- data/lib/yes/command/api/commands/params_validator.rb +62 -0
- data/lib/yes/command/api/engine.rb +16 -0
- data/lib/yes/command/api/version.rb +9 -0
- data/lib/yes/command/api.rb +18 -0
- metadata +102 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c850bff22a130767ba5aea77998f8a01a1ad9ac2b308537318d7e3276c8c5e9d
|
|
4
|
+
data.tar.gz: 73b487f88eb8ed2398344218c72b7042c59bdc5f8c3e7e029809db197d3813c0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6fb467acd706625779f0c438b4400c7b086d500a2cb188ec140b5cdf472da54a3ee8a83499ed9a5e64236f8ac468e95675f13388ac5655e9db207a6ed13e75ab
|
|
7
|
+
data.tar.gz: aba5cd97549563b812be96f85bd7b3701570ef775257a3a1ee45d72d3128c57386cbac412b492739aa61a9430ae9de731b8852e853101b9530a01fc99d782f33
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2023 Nico Ritsche
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# Yes Command API
|
|
2
|
+
|
|
3
|
+
The Yes command API is a mountable rails engine providing an endpoint for calling API commands.
|
|
4
|
+
|
|
5
|
+
Commands represent the write side of CQRS in our eventsourced system.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
Add this line to your application's Gemfile:
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
gem "yes-command-api"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
And then execute:
|
|
15
|
+
```bash
|
|
16
|
+
bundle install
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
See the [root README](../README.md) for the full DSL documentation, aggregate definition, and usage examples.
|
|
22
|
+
|
|
23
|
+
### Configuration
|
|
24
|
+
|
|
25
|
+
The preferred way of issuing commands using the commands API is asynchronously.
|
|
26
|
+
|
|
27
|
+
For that, you need to configure Yes::Core to process commands asynchronously.
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
Yes::Core.configure do |config|
|
|
31
|
+
config.process_commands_inline = false
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
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.
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
### Mounting the Endpoint
|
|
38
|
+
|
|
39
|
+
Mount the command endpoint to your rails application in `config/routes.rb`:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
Rails.application.routes.draw do
|
|
43
|
+
mount Yes::Command::Api::Engine => '/v1/commands'
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The mounted endpoint exposes all commands defined in your bounded context(s).
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
### Writing Authorizers
|
|
51
|
+
|
|
52
|
+
To make a command accessible for a caller, you need to define an authorizer for it.
|
|
53
|
+
If there is no authorizer defined a command is considered unauthorized for all callers by default.
|
|
54
|
+
|
|
55
|
+
Example:
|
|
56
|
+
|
|
57
|
+
Given a command
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
module MyContext
|
|
61
|
+
module MyAggregate
|
|
62
|
+
class Aggregate < Yes::Core::Aggregate
|
|
63
|
+
attribute :what, :string, command: true
|
|
64
|
+
attribute :user_id, :uuid
|
|
65
|
+
|
|
66
|
+
authorize do
|
|
67
|
+
command.user_id == auth_data['user_id']
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The authorizer needs to raise `CommandNotAuthorized` if the given `auth_data` (jwt payload + referer host) does not authorize the given `command`.
|
|
75
|
+
In case the authorizer raises nothing, the command is considered authorized.
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
### Making a Command(s) Request
|
|
79
|
+
|
|
80
|
+
The commands endpoint accepts commands supplied as a json array, using a POST request.
|
|
81
|
+
|
|
82
|
+
The endpoint is located where you mounted it, e.g. `https://your-app.example.com/v1/commands`.
|
|
83
|
+
|
|
84
|
+
Here is an example of a valid payload:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"commands": [{
|
|
89
|
+
"subject": "MyAggregate",
|
|
90
|
+
"context": "MyContext",
|
|
91
|
+
"command": "DoSomething",
|
|
92
|
+
"data": {
|
|
93
|
+
"user_id": "07393424-fa57-40fe-a3d2-c3bdd8b8e952",
|
|
94
|
+
"what": "Nonsense"
|
|
95
|
+
}
|
|
96
|
+
}],
|
|
97
|
+
"channel": "/notifications-for-user-07393424-fa57-40fe-a3d2-c3bdd8b8e952"
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
You also need to supply a valid JWT token as a bearer token for authorization and authentication.
|
|
101
|
+
|
|
102
|
+
Note that commands is an array, so you can supply any number of commands in a single request.
|
|
103
|
+
|
|
104
|
+
See the next section for how to receive updates about your commands using the standard message bus notifier.
|
|
105
|
+
|
|
106
|
+
### MessageBus Notifier
|
|
107
|
+
|
|
108
|
+
#### Authorization
|
|
109
|
+
|
|
110
|
+
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:
|
|
111
|
+
|
|
112
|
+
```javascript
|
|
113
|
+
let headers = { 'Authorization': 'Token eyJhbGciOiJFRDI1NTE5In0.eyJzY29wZXMiOlsiYWRtaW4iLCJjdXJyZW50X3VzZXIiLCJ1c2VyX3Byb2ZpbGUiXSwiZGF0YSI6eyJ1c2VyX3V1aWQiOiIyMjUwODIwZS00MzVhLTQ0ODQtYWUzMS1iYTFiODk1NDI2MWUifSwiZXhwIjoxNjkxNzQwNTA3fQ.D_TuOKh5LyGtusU5cZrJih-WYbB7MWChDOTS6WcWCRZUdldzZzKmXLtdgE93bkgb0TV9FNKXSvHt8DLhBZIoCA' };
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
#### Filters
|
|
117
|
+
|
|
118
|
+
You can filter messages by providing filter params in the request url. Here they are:
|
|
119
|
+
|
|
120
|
+
- `batch_id`. It is your command batch id. Example: `/message-bus/some-client-id/poll?batch_id=7121e60e-4d3d-4fb7-b454-f603c75f1359`
|
|
121
|
+
- `type`. It is a command type. Possible values are `batch_started`, `batch_finished`, `command_success` and `command_error` so far. Example: `/message-bus/some-client-id/poll?type=command_error`
|
|
122
|
+
- `command`. It is a command name. Example `/message-bus/some-client-id/poll?command=ApprenticeshipPresentation::Apprenticeship::Commands::AssignCompany::Command`
|
|
123
|
+
- `since`. Unix timestamp. Providing it will filter messages which are not older than the `since` param value. Example: `/message-bus/some-client-id/poll?since=1689778808`
|
|
124
|
+
|
|
125
|
+
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:
|
|
126
|
+
|
|
127
|
+
```javascript
|
|
128
|
+
let payload = { 'some-channel-name': 123 }
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
#### Examples
|
|
132
|
+
|
|
133
|
+
Here is how long-polling HTTP request from browser using various filters and JWT authorization may look like:
|
|
134
|
+
|
|
135
|
+
```javascript
|
|
136
|
+
async function postData(url = "", data = {}) {
|
|
137
|
+
// Default options are marked with *
|
|
138
|
+
const response = fetch(url, {
|
|
139
|
+
method: "POST", // *GET, POST, PUT, DELETE, etc.
|
|
140
|
+
mode: "same-origin", // no-cors, *cors, same-origin
|
|
141
|
+
cache: "no-cache", // *default, no-cache, reload, force-cache, only-if-cached
|
|
142
|
+
credentials: "same-origin", // include, *same-origin, omit
|
|
143
|
+
headers: {
|
|
144
|
+
'Content-Type': 'application/json',
|
|
145
|
+
'X-SILENCE-LOGGER': 'true',
|
|
146
|
+
'Transfer-Encoding': 'chunked',
|
|
147
|
+
'Authorization': 'Token eyJhbGciOiJFRDI1NTE5In0.eyJzY29wZXMiOlsiYWRtaW4iXSwiZGF0YSI6eyJ1c2VyX3V1aWQiOiJlMmMwYzBkNC1iMWMzLTQwNzktOTlhMi0zYTlhOTg2MWVhYzgifSwiZXhwIjoxNjg5NzgzMjE4fQ.HKmthrv7HDsMof88hvCErVSlTCGg-Ikeb9-eb0DLPVXQQmpJ_4gTD52bgMFBGmGaA_TdRakAG3UGgCp9d9VYAw'
|
|
148
|
+
},
|
|
149
|
+
redirect: "error", // manual, *follow, error
|
|
150
|
+
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
|
|
151
|
+
body: JSON.stringify(data), // body data type must match "Content-Type" header
|
|
152
|
+
});
|
|
153
|
+
return response;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function processChunkedResponse(response) {
|
|
157
|
+
var text = '';
|
|
158
|
+
var reader = response.body.getReader()
|
|
159
|
+
var decoder = new TextDecoder();
|
|
160
|
+
|
|
161
|
+
return readChunk();
|
|
162
|
+
|
|
163
|
+
function readChunk() {
|
|
164
|
+
return reader.read().then(appendChunks);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function appendChunks(result) {
|
|
168
|
+
var chunk = decoder.decode(result.value || new Uint8Array, {stream: !result.done});
|
|
169
|
+
console.log('got chunk of', chunk.length, 'bytes')
|
|
170
|
+
console.log('chunk so far is', chunk);
|
|
171
|
+
text += chunk;
|
|
172
|
+
|
|
173
|
+
if (result.done) {
|
|
174
|
+
return text;
|
|
175
|
+
} else {
|
|
176
|
+
return readChunk();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
//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'
|
|
181
|
+
let url = new URL('http://localhost:3000/message-bus/some-client-id/poll');
|
|
182
|
+
url.search = new URLSearchParams(
|
|
183
|
+
{
|
|
184
|
+
since: 1689778808,
|
|
185
|
+
type: 'batch_started',
|
|
186
|
+
batch_id: '7121e60e-4d3d-4fb7-b454-f603c75f1359',
|
|
187
|
+
command: 'ApprenticeshipPresentation::Apprenticeship::Commands::AssignCompany::Command'
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
postData(url, { '/notifications/testing-12345678': 0 }).then(processChunkedResponse);
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Development
|
|
195
|
+
|
|
196
|
+
### Prerequisites
|
|
197
|
+
|
|
198
|
+
- Docker and Docker Compose
|
|
199
|
+
- Ruby >= 3.2.0
|
|
200
|
+
- Bundler
|
|
201
|
+
|
|
202
|
+
### Setup
|
|
203
|
+
|
|
204
|
+
Start PostgreSQL and Redis from the **repository root**:
|
|
205
|
+
|
|
206
|
+
```shell
|
|
207
|
+
docker compose up -d
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Install dependencies:
|
|
211
|
+
|
|
212
|
+
```shell
|
|
213
|
+
bundle install
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Set up the EventStore database:
|
|
217
|
+
|
|
218
|
+
```shell
|
|
219
|
+
PG_EVENTSTORE_URI="postgresql://postgres:postgres@localhost:5532/eventstore_test" bundle exec rake pg_eventstore:create pg_eventstore:migrate
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Set up the test database:
|
|
223
|
+
|
|
224
|
+
```shell
|
|
225
|
+
RAILS_ENV=test bundle exec rake db:create db:migrate
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The `.env` file at `spec/.env` is loaded automatically and contains JWT test keys.
|
|
229
|
+
|
|
230
|
+
### Running Specs
|
|
231
|
+
|
|
232
|
+
```shell
|
|
233
|
+
bundle exec rspec
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Contributing
|
|
237
|
+
|
|
238
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/yousty/yes.
|
|
239
|
+
|
|
240
|
+
## License
|
|
241
|
+
|
|
242
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
|
|
5
|
+
APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
|
|
6
|
+
load 'rails/tasks/engine.rake'
|
|
7
|
+
|
|
8
|
+
load 'rails/tasks/statistics.rake'
|
|
9
|
+
|
|
10
|
+
require 'pg_eventstore'
|
|
11
|
+
|
|
12
|
+
load 'pg_eventstore/tasks/setup.rake'
|
|
13
|
+
|
|
14
|
+
require 'bundler/gem_tasks'
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Command
|
|
5
|
+
module Api
|
|
6
|
+
module V1
|
|
7
|
+
# Controller for executing command batches via the command bus.
|
|
8
|
+
#
|
|
9
|
+
# Auth is delegated to the configured auth adapter in Yes::Core.configuration.
|
|
10
|
+
# If no auth adapter is configured, authentication will raise an error.
|
|
11
|
+
class CommandsController < ActionController::API
|
|
12
|
+
MAX_INLINE_COMMANDS_PER_REQ = 10
|
|
13
|
+
|
|
14
|
+
include Yes::Core::OpenTelemetry::Trackable
|
|
15
|
+
|
|
16
|
+
before_action :authenticate_with_token
|
|
17
|
+
before_action :set_channel
|
|
18
|
+
|
|
19
|
+
rescue_from(StandardError, with: :handle_unexpected_error)
|
|
20
|
+
rescue_from(Yes::Core::AuthenticationError, with: :auth_error_response)
|
|
21
|
+
|
|
22
|
+
rescue_from(
|
|
23
|
+
Yes::Command::Api::Commands::ParamsValidator::CommandParamsInvalid,
|
|
24
|
+
with: :command_params_invalid_response
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
rescue_from(
|
|
28
|
+
Yes::Command::Api::Commands::Deserializer::DeserializationFailed,
|
|
29
|
+
with: :deserialization_failed_response
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
rescue_from(
|
|
33
|
+
Yes::Command::Api::Commands::BatchAuthorizer::CommandsNotAuthorized,
|
|
34
|
+
with: :commands_unauthorized_response
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
rescue_from(
|
|
38
|
+
Yes::Command::Api::Commands::BatchValidator::CommandsInvalid,
|
|
39
|
+
with: :commands_invalid_response
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Executes a batch of commands.
|
|
43
|
+
def execute
|
|
44
|
+
Yes::Command::Api::Commands::ParamsValidator.call(params[:commands])
|
|
45
|
+
deserialize_commands = Yes::Command::Api::Commands::Deserializer.call(params[:commands])
|
|
46
|
+
expanded_commands = expand_commands(deserialize_commands)
|
|
47
|
+
return too_many_inline_commands if perform_inline? && expanded_commands.size > MAX_INLINE_COMMANDS_PER_REQ
|
|
48
|
+
|
|
49
|
+
Yes::Command::Api::Commands::BatchAuthorizer.call(expanded_commands, auth_data)
|
|
50
|
+
Yes::Command::Api::Commands::BatchValidator.call(expanded_commands)
|
|
51
|
+
cmd_bus_response = command_bus.call(
|
|
52
|
+
add_metadata(deserialize_commands),
|
|
53
|
+
notifier_options: { channel: @channel }
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
render json: success_response_data(cmd_bus_response), status: :ok
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Authenticates the request using the configured auth adapter.
|
|
62
|
+
# Stores the returned auth data for use in subsequent actions.
|
|
63
|
+
#
|
|
64
|
+
# @raise [RuntimeError] if no auth adapter is configured
|
|
65
|
+
# @return [void]
|
|
66
|
+
def authenticate_with_token
|
|
67
|
+
adapter = Yes::Core.configuration.auth_adapter
|
|
68
|
+
raise Yes::Core::AuthenticationError, 'No auth adapter configured. Set Yes::Core.configuration.auth_adapter.' unless adapter
|
|
69
|
+
|
|
70
|
+
@auth_data = adapter.authenticate(request)
|
|
71
|
+
rescue *auth_error_classes => e
|
|
72
|
+
auth_error_response(e)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Hash] the authentication data
|
|
76
|
+
attr_reader :auth_data
|
|
77
|
+
|
|
78
|
+
# Returns the error classes defined by the auth adapter.
|
|
79
|
+
#
|
|
80
|
+
# @return [Array<Class>] auth error classes
|
|
81
|
+
def auth_error_classes
|
|
82
|
+
Yes::Core.configuration.auth_adapter&.error_classes || []
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def perform_inline?
|
|
86
|
+
return false if params[:async] == 'true'
|
|
87
|
+
return true if params[:async] == 'false'
|
|
88
|
+
|
|
89
|
+
Yes::Core.configuration.process_commands_inline
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def command_bus
|
|
93
|
+
return Yes::Core::Commands::Bus.new unless perform_inline?
|
|
94
|
+
|
|
95
|
+
Yes::Core::Commands::Bus.new(perform_inline: perform_inline?)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def too_many_inline_commands
|
|
99
|
+
error = "Too many commands. You can process up to #{MAX_INLINE_COMMANDS_PER_REQ} commands inline."
|
|
100
|
+
render json: { error: }, status: :unprocessable_content
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def expand_commands(deserialize_commands)
|
|
104
|
+
deserialize_commands.map do |command|
|
|
105
|
+
command.is_a?(Yes::Core::Commands::Group) ? command.commands : command
|
|
106
|
+
end.flatten
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def add_metadata(commands)
|
|
110
|
+
commands.map do |command|
|
|
111
|
+
command.class.new(
|
|
112
|
+
command.to_h.merge(
|
|
113
|
+
metadata: (command.metadata || {}).merge(identity_id: auth_data[:identity_id], otl_contexts:)
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def success_response_data(cmd_bus_response)
|
|
120
|
+
return { batch_id: cmd_bus_response.job_id } if cmd_bus_response.respond_to?(:job_id)
|
|
121
|
+
|
|
122
|
+
cmd_bus_response
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def params
|
|
126
|
+
request.parameters.deep_symbolize_keys
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def command_params_invalid_response(error)
|
|
130
|
+
error_info = {
|
|
131
|
+
title: 'Bad request',
|
|
132
|
+
detail: { message: error.message }
|
|
133
|
+
}
|
|
134
|
+
error_info[:detail][:invalid] = error.extra if error.extra
|
|
135
|
+
|
|
136
|
+
render json: error_info.to_json, status: :bad_request
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def deserialization_failed_response(error)
|
|
140
|
+
error_info = {
|
|
141
|
+
title: 'Bad request',
|
|
142
|
+
detail: error.extra
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
render json: error_info.to_json, status: :bad_request
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Handles auth token errors from the configured auth adapter.
|
|
149
|
+
#
|
|
150
|
+
# @param error [StandardError] the auth error
|
|
151
|
+
# @return [void]
|
|
152
|
+
def auth_error_response(error)
|
|
153
|
+
render(
|
|
154
|
+
json: { title: 'Auth Token Invalid', detail: error.message }.to_json,
|
|
155
|
+
status: :unauthorized
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def commands_unauthorized_response(error)
|
|
160
|
+
render(
|
|
161
|
+
json: { title: 'Unauthorized', detail: error.extra }.to_json, status: :unauthorized
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def commands_invalid_response(error)
|
|
166
|
+
error_info = {
|
|
167
|
+
title: 'Unprocessable Entity',
|
|
168
|
+
errors: error.extra
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
render json: error_info.to_json, status: 422
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def set_channel
|
|
175
|
+
@channel = params[:channel].presence || auth_data[:identity_id]
|
|
176
|
+
|
|
177
|
+
self.class.current_span&.set_attribute('channel', @channel)
|
|
178
|
+
return if @channel.present?
|
|
179
|
+
|
|
180
|
+
render json: { title: '"channel" param is required' }, status: :bad_request
|
|
181
|
+
end
|
|
182
|
+
otl_trackable :set_channel,
|
|
183
|
+
Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(
|
|
184
|
+
span_name: 'Set Channel',
|
|
185
|
+
span_kind: :client
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def handle_unexpected_error(error)
|
|
189
|
+
self.class.current_span&.status = OpenTelemetry::Trace::Status.error(error.message)
|
|
190
|
+
self.class.current_span&.record_exception(error)
|
|
191
|
+
|
|
192
|
+
raise error
|
|
193
|
+
end
|
|
194
|
+
otl_trackable :handle_unexpected_error,
|
|
195
|
+
Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Handle Unexpected Error')
|
|
196
|
+
|
|
197
|
+
def command_request_started_at_ms
|
|
198
|
+
return nil unless request.env['HTTP_X_REQUEST_START']
|
|
199
|
+
|
|
200
|
+
(request.env['HTTP_X_REQUEST_START'].to_f * 1000).to_i
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def otl_contexts
|
|
204
|
+
{
|
|
205
|
+
root: self.class.propagate_context(service_name: true),
|
|
206
|
+
timestamps: { command_request_started_at_ms: }.compact
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless defined?(MessageBus)
|
|
4
|
+
|
|
5
|
+
# Filtering by params[:batch_id]. Applied to all channels
|
|
6
|
+
MessageBus.register_client_message_filter('') do |params, message|
|
|
7
|
+
next true unless params.key?('batch_id')
|
|
8
|
+
|
|
9
|
+
message.data['batch_id'].to_s == params['batch_id']
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Filtering by params[:type]. Applied to all channels
|
|
13
|
+
MessageBus.register_client_message_filter('') do |params, message|
|
|
14
|
+
next true unless params.key?('type')
|
|
15
|
+
|
|
16
|
+
message.data['type'] == params['type']
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Filtering by params[:command]. Applied to all channels
|
|
20
|
+
MessageBus.register_client_message_filter('') do |params, message|
|
|
21
|
+
next true unless params.key?('command')
|
|
22
|
+
|
|
23
|
+
message.data['command'] == params['command']
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Filtering by params[:since]. Applied to all channels
|
|
27
|
+
MessageBus.register_client_message_filter('') do |params, message|
|
|
28
|
+
next true unless params.key?('since')
|
|
29
|
+
next true unless message.data.key?('published_at')
|
|
30
|
+
|
|
31
|
+
message.data['published_at'] >= params['since'].to_i
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
MessageBus.user_id_lookup do |env|
|
|
35
|
+
request = ActionDispatch::Request.new(env)
|
|
36
|
+
token, = ActionController::HttpAuthentication::Token.token_and_options(request)
|
|
37
|
+
|
|
38
|
+
if token && Yes::Core.configuration.auth_adapter
|
|
39
|
+
verified_token = Yes::Core.configuration.auth_adapter.verify_token(token)
|
|
40
|
+
verified_token.token.first['identity_id']
|
|
41
|
+
end
|
|
42
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Command
|
|
5
|
+
module Api
|
|
6
|
+
class OtlTrackableRequest
|
|
7
|
+
def call(env)
|
|
8
|
+
tracer = Yes::Core.configuration.otl_tracer
|
|
9
|
+
controller = Yes::Command::Api::V1::CommandsController
|
|
10
|
+
|
|
11
|
+
return controller.action(:execute).call(env) unless tracer
|
|
12
|
+
|
|
13
|
+
tracer.in_span("Request #{controller}", kind: :client) do |request_span|
|
|
14
|
+
request_span.add_attributes(otl_auth_data(env))
|
|
15
|
+
|
|
16
|
+
Yes::Command::Api::V1::CommandsController.action(:execute).call(env).tap do |status, _headers, rack_response|
|
|
17
|
+
tracer.in_span("Response #{controller}", kind: :client) do |response_span|
|
|
18
|
+
response_span.status = OpenTelemetry::Trace::Status.error if status >= 300
|
|
19
|
+
response_span.add_attributes(
|
|
20
|
+
{
|
|
21
|
+
'response.status': status,
|
|
22
|
+
'response.body': rack_response.body
|
|
23
|
+
}.stringify_keys
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def otl_auth_data(env)
|
|
33
|
+
request = Rack::Request.new(env)
|
|
34
|
+
|
|
35
|
+
request.body.rewind
|
|
36
|
+
params = request.body.read
|
|
37
|
+
request.body.rewind
|
|
38
|
+
|
|
39
|
+
auth_token = env['HTTP_AUTHORIZATION'] || ''
|
|
40
|
+
auth_data = auth_token.present? ? JWT.decode(auth_token.gsub('Bearer ', ''), nil, false) : {}
|
|
41
|
+
{
|
|
42
|
+
auth_token:,
|
|
43
|
+
auth_data: auth_data.to_json,
|
|
44
|
+
params:
|
|
45
|
+
}.stringify_keys
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Yes::Command::Api::Engine.routes.draw do
|
|
53
|
+
post '/', to: Yes::Command::Api::OtlTrackableRequest.new
|
|
54
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Command
|
|
5
|
+
module Api
|
|
6
|
+
module Commands
|
|
7
|
+
# Authorizes a collection of commands using their respective authorizer classes.
|
|
8
|
+
# Raises if any command is not authorized.
|
|
9
|
+
class BatchAuthorizer
|
|
10
|
+
CommandsNotAuthorized = Class.new(Yes::Core::Error)
|
|
11
|
+
CommandAuthorizerNotFound = Class.new(Yes::Core::Error)
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
include Yes::Core::OpenTelemetry::Trackable
|
|
15
|
+
|
|
16
|
+
# Authorizes the given commands with the provided auth data.
|
|
17
|
+
#
|
|
18
|
+
# @param commands [Array<Yes::Core::Command>] commands to authorize
|
|
19
|
+
# @param auth_data [Hash] authorization data
|
|
20
|
+
# @raise [CommandsNotAuthorized] if any command is not authorized
|
|
21
|
+
# @return [void]
|
|
22
|
+
def call(commands, auth_data)
|
|
23
|
+
unauthorized = []
|
|
24
|
+
|
|
25
|
+
commands.each do |command|
|
|
26
|
+
authorizer = authorizer_for(command)
|
|
27
|
+
authorizer.call(command, auth_data)
|
|
28
|
+
rescue CommandAuthorizerNotFound
|
|
29
|
+
unauthorized << unauthorized_data(command, 'Not allowed').tap do
|
|
30
|
+
trace_error('Command authorizer not found', { command: command.to_json })
|
|
31
|
+
end
|
|
32
|
+
rescue Yes::Core::Authorization::CommandAuthorizer::CommandNotAuthorized => e
|
|
33
|
+
unauthorized << unauthorized_data(command, e.message).tap do
|
|
34
|
+
trace_error('Command not authorized', { command: })
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
return unless unauthorized.any?
|
|
39
|
+
|
|
40
|
+
trace_error('Unauthorized', { unauthorized: unauthorized.to_json })
|
|
41
|
+
raise CommandsNotAuthorized.new(extra: unauthorized)
|
|
42
|
+
end
|
|
43
|
+
otl_trackable :call, Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Authorize Commands')
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Returns the command authorizer for the given command.
|
|
48
|
+
#
|
|
49
|
+
# @param command [Yes::Core::Command] command to authorize
|
|
50
|
+
# @return [Class] authorizer class for the command
|
|
51
|
+
# @raise [CommandAuthorizerNotFound] if no authorizer is found
|
|
52
|
+
def authorizer_for(command)
|
|
53
|
+
class_name = Yes::Core::Commands::Helper.new(command).authorizer_classname
|
|
54
|
+
|
|
55
|
+
Kernel.const_get(class_name)
|
|
56
|
+
rescue NameError
|
|
57
|
+
raise CommandAuthorizerNotFound, "#{class_name} not found"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Builds unauthorized data hash for error reporting.
|
|
61
|
+
#
|
|
62
|
+
# @param command [Yes::Core::Command] the unauthorized command
|
|
63
|
+
# @param message [String] the error message
|
|
64
|
+
# @return [Hash] unauthorized data
|
|
65
|
+
def unauthorized_data(command, message)
|
|
66
|
+
{
|
|
67
|
+
message:,
|
|
68
|
+
command: command.class.to_s,
|
|
69
|
+
command_id: command.command_id,
|
|
70
|
+
data: command.payload,
|
|
71
|
+
metadata: command.metadata || {}
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Traces an error on the current OpenTelemetry span.
|
|
76
|
+
#
|
|
77
|
+
# @param message [String] error message
|
|
78
|
+
# @param attributes [Hash] span attributes
|
|
79
|
+
# @return [void]
|
|
80
|
+
def trace_error(message, attributes = {})
|
|
81
|
+
singleton_class.current_span&.status = ::OpenTelemetry::Trace::Status.error(message)
|
|
82
|
+
singleton_class.current_span&.add_attributes(attributes.stringify_keys)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Command
|
|
5
|
+
module Api
|
|
6
|
+
module Commands
|
|
7
|
+
# Validates a collection of commands using their respective validator classes.
|
|
8
|
+
# Raises if any command is invalid.
|
|
9
|
+
class BatchValidator
|
|
10
|
+
CommandsInvalid = Class.new(Yes::Core::Error)
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
include Yes::Core::OpenTelemetry::Trackable
|
|
14
|
+
|
|
15
|
+
# Validates the given commands, raises CommandsInvalid if any are invalid.
|
|
16
|
+
#
|
|
17
|
+
# @param commands [Array<Yes::Core::Command>] commands to validate
|
|
18
|
+
# @raise [CommandsInvalid] if any command is invalid
|
|
19
|
+
# @return [void]
|
|
20
|
+
def call(commands)
|
|
21
|
+
invalid = []
|
|
22
|
+
commands.each do |command|
|
|
23
|
+
validator = validator_for(command)
|
|
24
|
+
next unless validator
|
|
25
|
+
|
|
26
|
+
validator.call(command)
|
|
27
|
+
rescue Yes::Core::Commands::Validator::CommandInvalid => e
|
|
28
|
+
invalid << {
|
|
29
|
+
message: e.message,
|
|
30
|
+
command: command.class.to_s,
|
|
31
|
+
command_id: command.command_id,
|
|
32
|
+
data: command.payload,
|
|
33
|
+
metadata: command.metadata || {},
|
|
34
|
+
details: e.extra
|
|
35
|
+
}.tap do
|
|
36
|
+
trace_error('Command validation failed', { command: command.to_json })
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
return unless invalid.any?
|
|
41
|
+
|
|
42
|
+
trace_error('Commands invalid', { invalid: invalid.to_json })
|
|
43
|
+
raise CommandsInvalid.new(extra: invalid)
|
|
44
|
+
end
|
|
45
|
+
otl_trackable :call, Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Validate Commands')
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Returns the validator for the given command, or nil if none exists.
|
|
50
|
+
#
|
|
51
|
+
# @param command [Yes::Core::Command] command to validate
|
|
52
|
+
# @return [Class, nil] validator class for the command, or nil
|
|
53
|
+
def validator_for(command)
|
|
54
|
+
class_name = Yes::Core::Commands::Helper.new(command).validator_classname
|
|
55
|
+
|
|
56
|
+
Kernel.const_get(class_name)
|
|
57
|
+
rescue NameError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Traces an error on the current OpenTelemetry span.
|
|
62
|
+
#
|
|
63
|
+
# @param message [String] error message
|
|
64
|
+
# @param attributes [Hash] span attributes
|
|
65
|
+
# @return [void]
|
|
66
|
+
def trace_error(message, attributes = {})
|
|
67
|
+
singleton_class.current_span&.status = ::OpenTelemetry::Trace::Status.error(message)
|
|
68
|
+
singleton_class.current_span&.add_attributes(attributes.stringify_keys)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Command
|
|
5
|
+
module Api
|
|
6
|
+
module Commands
|
|
7
|
+
# Deserializes command data hashes into command instances.
|
|
8
|
+
# Supports V1, V2, and command group class resolution.
|
|
9
|
+
class Deserializer
|
|
10
|
+
DeserializationFailed = Class.new(Yes::Core::Error)
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
include Yes::Core::OpenTelemetry::Trackable
|
|
14
|
+
|
|
15
|
+
# Deserializes command data into command instances.
|
|
16
|
+
#
|
|
17
|
+
# @param command_data [Array<Hash>] commands to deserialize
|
|
18
|
+
# @return [Array<Yes::Core::Command>] deserialized commands
|
|
19
|
+
# @raise [DeserializationFailed] if any command cannot be deserialized
|
|
20
|
+
def call(command_data)
|
|
21
|
+
failed = { invalid: [], not_found: [] }
|
|
22
|
+
commands = []
|
|
23
|
+
|
|
24
|
+
command_data.each do |command|
|
|
25
|
+
commands << Kernel.const_get(command_class_name(command)).new(
|
|
26
|
+
{ metadata: command[:metadata] }.merge(command[:data])
|
|
27
|
+
).tap do |cmd|
|
|
28
|
+
singleton_class.current_span&.add_event('Deserialized',
|
|
29
|
+
attributes: { 'command' => cmd.to_json })
|
|
30
|
+
end
|
|
31
|
+
rescue NameError
|
|
32
|
+
failed[:not_found] << command
|
|
33
|
+
rescue Yes::Core::Command::Invalid
|
|
34
|
+
failed[:invalid] << command
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if failed.values.flatten.any?
|
|
38
|
+
singleton_class.current_span&.status = ::OpenTelemetry::Trace::Status.error('Deserialization failed')
|
|
39
|
+
singleton_class.current_span&.add_attributes({ 'failed' => failed.to_json })
|
|
40
|
+
|
|
41
|
+
raise DeserializationFailed.new(extra: failed)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
commands
|
|
45
|
+
end
|
|
46
|
+
otl_trackable :call, Yes::Core::OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Deserialize Commands')
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Resolves the command class name, trying command group, V2, then V1 conventions.
|
|
51
|
+
#
|
|
52
|
+
# @param command [Hash] command data
|
|
53
|
+
# @return [String] command class name
|
|
54
|
+
def command_class_name(command)
|
|
55
|
+
[command_group_class(command), command_v2_class(command), command_class(command)].each do |name|
|
|
56
|
+
Kernel.const_get(name)
|
|
57
|
+
return name
|
|
58
|
+
rescue NameError
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
# None found — return V2 name so const_get in caller raises NameError
|
|
62
|
+
command_v2_class(command)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns the V1 command class name.
|
|
66
|
+
#
|
|
67
|
+
# @param command [Hash] command data
|
|
68
|
+
# @return [String] command class name
|
|
69
|
+
def command_class(command)
|
|
70
|
+
"#{command[:context]}::Commands::#{command[:subject]}::#{command[:command]}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Returns the V2 command class name.
|
|
74
|
+
#
|
|
75
|
+
# @param command [Hash] command data
|
|
76
|
+
# @return [String] command class name
|
|
77
|
+
def command_v2_class(command)
|
|
78
|
+
"#{command[:context]}::#{command[:subject]}::Commands::#{command[:command]}::Command"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Returns the command group class name.
|
|
82
|
+
#
|
|
83
|
+
# @param command [Hash] command data
|
|
84
|
+
# @return [String] command group class name
|
|
85
|
+
def command_group_class(command)
|
|
86
|
+
"CommandGroups::#{command[:command]}::Command"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Command
|
|
5
|
+
module Api
|
|
6
|
+
module Commands
|
|
7
|
+
module Notifiers
|
|
8
|
+
# Notifies command processing events via ActionCable broadcast.
|
|
9
|
+
# Used with an external WebSocket gateway (e.g. socket_gate).
|
|
10
|
+
class ActionCable < Yes::Core::Commands::Notifier
|
|
11
|
+
# @param batch_id [String] the id of the batch that has started processing
|
|
12
|
+
# @param transaction [TransactionDetails] the transaction details
|
|
13
|
+
# @param commands [Array<Command>] the commands being processed
|
|
14
|
+
def notify_batch_started(batch_id, transaction = nil, commands = nil)
|
|
15
|
+
::ActionCable.server.broadcast(
|
|
16
|
+
channel,
|
|
17
|
+
{
|
|
18
|
+
batch_id:,
|
|
19
|
+
published_at:,
|
|
20
|
+
type: 'batch_started',
|
|
21
|
+
transaction: transaction.to_h
|
|
22
|
+
}.merge(commands_data(commands))
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param batch_id [String] the id of the batch that has finished processing
|
|
27
|
+
# @param transaction [TransactionDetails] the transaction details
|
|
28
|
+
# @param responses [Array<Response>] the command responses
|
|
29
|
+
def notify_batch_finished(batch_id, transaction = nil, responses = nil)
|
|
30
|
+
::ActionCable.server.broadcast(
|
|
31
|
+
channel,
|
|
32
|
+
{
|
|
33
|
+
batch_id:,
|
|
34
|
+
published_at:,
|
|
35
|
+
type: 'batch_finished',
|
|
36
|
+
transaction: transaction.to_h
|
|
37
|
+
}.merge(failed_commands_data(responses))
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param cmd_response [Yes::Core::Commands::Response] the command response
|
|
42
|
+
def notify_command_response(cmd_response)
|
|
43
|
+
::ActionCable.server.broadcast(
|
|
44
|
+
channel,
|
|
45
|
+
cmd_response.to_notification.merge(published_at:)
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# @return [Integer]
|
|
52
|
+
def published_at
|
|
53
|
+
Time.now.to_i
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def commands_data(commands)
|
|
57
|
+
return {} if commands.nil?
|
|
58
|
+
|
|
59
|
+
{ commands: commands.map { { command: _1.class.to_s, command_id: _1.command_id } } }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def failed_commands_data(responses)
|
|
63
|
+
return {} if responses.nil?
|
|
64
|
+
|
|
65
|
+
failed = responses.filter_map do |resp|
|
|
66
|
+
next unless resp.error
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
command: resp.cmd.class.to_s,
|
|
70
|
+
command_id: resp.cmd.command_id,
|
|
71
|
+
error: resp.error.to_s
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
failed.empty? ? {} : { failed_commands: failed }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Command
|
|
5
|
+
module Api
|
|
6
|
+
module Commands
|
|
7
|
+
module Notifiers
|
|
8
|
+
class MessageBus < Yes::Core::Commands::Notifier
|
|
9
|
+
attr_reader :channel
|
|
10
|
+
private :channel
|
|
11
|
+
|
|
12
|
+
# @param options [Hash] the options to create a notifier with
|
|
13
|
+
# @option options [String] :channel the channel name to publish notifications to
|
|
14
|
+
def initialize(options)
|
|
15
|
+
super()
|
|
16
|
+
|
|
17
|
+
@channel = options[:channel]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param batch_id [String] the id of the batch that has started processing
|
|
21
|
+
# @param transaction [TransactionDetails] the transaction details of the current transaction
|
|
22
|
+
# @param commands [Array<Command>] the commands that are being processed
|
|
23
|
+
#
|
|
24
|
+
def notify_batch_started(batch_id, transaction = nil, commands = nil)
|
|
25
|
+
user_ids = [transaction&.caller_id].compact
|
|
26
|
+
|
|
27
|
+
data =
|
|
28
|
+
{
|
|
29
|
+
batch_id:, published_at:, type: 'batch_started'
|
|
30
|
+
}.merge(
|
|
31
|
+
transaction: transaction.to_h
|
|
32
|
+
).merge(commands_data(commands))
|
|
33
|
+
|
|
34
|
+
::MessageBus.publish(channel, data, user_ids: user_ids.empty? ? nil : user_ids)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param batch_id [String] the id of the batch that has finished processing
|
|
38
|
+
# @param transaction [TransactionDetails] the transaction details of the current transaction
|
|
39
|
+
# @param responses [Array<Response>] the responses of the commands that were processed
|
|
40
|
+
#
|
|
41
|
+
def notify_batch_finished(batch_id, transaction = nil, responses = nil)
|
|
42
|
+
user_ids = [transaction&.caller_id].compact
|
|
43
|
+
|
|
44
|
+
data = {
|
|
45
|
+
type: 'batch_finished',
|
|
46
|
+
batch_id:,
|
|
47
|
+
published_at:
|
|
48
|
+
}.merge(
|
|
49
|
+
transaction: transaction.to_h
|
|
50
|
+
).merge(failed_commands_data(responses))
|
|
51
|
+
|
|
52
|
+
::MessageBus.publish(channel, data, user_ids: user_ids.empty? ? nil : user_ids)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param cmd_response [Yes::Core::Commands::Response]
|
|
56
|
+
# the command response to notify
|
|
57
|
+
def notify_command_response(cmd_response)
|
|
58
|
+
user_ids = [cmd_response.transaction&.caller_id].compact
|
|
59
|
+
|
|
60
|
+
::MessageBus.publish(
|
|
61
|
+
channel,
|
|
62
|
+
cmd_response.to_notification.merge(published_at:),
|
|
63
|
+
user_ids: user_ids.empty? ? nil : user_ids
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# @return [Integer]
|
|
70
|
+
def published_at
|
|
71
|
+
Time.now.to_i
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def commands_data(commands)
|
|
75
|
+
return {} if commands.nil?
|
|
76
|
+
|
|
77
|
+
{ commands: commands.map { { command: _1.class.to_s, command_id: _1.command_id } } }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def failed_commands_data(responses)
|
|
81
|
+
return {} if responses.nil?
|
|
82
|
+
|
|
83
|
+
failed = responses.filter_map do |resp|
|
|
84
|
+
next unless resp.error
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
command: resp.cmd.class.to_s,
|
|
88
|
+
command_id: resp.cmd.command_id,
|
|
89
|
+
error: resp.error.to_s
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
failed.empty? ? {} : { failed_commands: failed }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Command
|
|
5
|
+
module Api
|
|
6
|
+
module Commands
|
|
7
|
+
# Validates the structure of command parameter hashes before deserialization.
|
|
8
|
+
# Ensures each command hash contains the required keys.
|
|
9
|
+
class ParamsValidator
|
|
10
|
+
CommandParamsInvalid = Class.new(Yes::Core::Error)
|
|
11
|
+
|
|
12
|
+
REQUIRED_KEYS = %i[command data context subject].freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# Validates command params.
|
|
16
|
+
#
|
|
17
|
+
# @param params [Array<Hash>] Array of command params
|
|
18
|
+
# @raise [CommandParamsInvalid] if params are invalid
|
|
19
|
+
# @return [void]
|
|
20
|
+
def call(params)
|
|
21
|
+
invalid = []
|
|
22
|
+
raise CommandParamsInvalid, 'Commands must be an array' unless params.is_a? Array
|
|
23
|
+
|
|
24
|
+
params.each do |command|
|
|
25
|
+
missing_keys = missing_keys?(command)
|
|
26
|
+
next unless missing_keys.any?
|
|
27
|
+
|
|
28
|
+
invalid << {
|
|
29
|
+
command:,
|
|
30
|
+
error: missing_keys_message(missing_keys)
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
raise CommandParamsInvalid.new(required_keys_message, extra: invalid) if invalid.any?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# @return [String] general error message for missing keys
|
|
40
|
+
def required_keys_message
|
|
41
|
+
"A command must have the following keys: #{REQUIRED_KEYS.join(', ')}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns the missing keys of the given command params, if any.
|
|
45
|
+
#
|
|
46
|
+
# @param command [Hash] command params
|
|
47
|
+
# @return [Array<Symbol>] missing keys
|
|
48
|
+
def missing_keys?(command)
|
|
49
|
+
REQUIRED_KEYS.reject { |s| command.key? s }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param missing_keys [Array<Symbol>] missing keys
|
|
53
|
+
# @return [String] error message for missing keys
|
|
54
|
+
def missing_keys_message(missing_keys)
|
|
55
|
+
"Missing keys: #{missing_keys.sort.join(', ')}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Command
|
|
5
|
+
module Api
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace Yes::Command::Api
|
|
8
|
+
config.generators.api_only = true
|
|
9
|
+
|
|
10
|
+
config.generators do |g|
|
|
11
|
+
g.test_framework :rspec
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yes/command/api/version'
|
|
4
|
+
require 'yes/command/api/engine'
|
|
5
|
+
require 'yes/core'
|
|
6
|
+
require 'yes/command/api/commands/params_validator'
|
|
7
|
+
require 'yes/command/api/commands/deserializer'
|
|
8
|
+
require 'yes/command/api/commands/batch_authorizer'
|
|
9
|
+
require 'yes/command/api/commands/batch_validator'
|
|
10
|
+
require 'yes/command/api/commands/notifiers/action_cable'
|
|
11
|
+
require 'yes/command/api/commands/notifiers/message_bus'
|
|
12
|
+
|
|
13
|
+
module Yes
|
|
14
|
+
module Command
|
|
15
|
+
module Api
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: yes-command-api
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Nico Ritsche
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: message_bus
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '4.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '4.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rails
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: yes-core
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
description: Command API for the Yes event sourcing framework
|
|
55
|
+
email:
|
|
56
|
+
- nico.ritsche@yousty.ch
|
|
57
|
+
executables: []
|
|
58
|
+
extensions: []
|
|
59
|
+
extra_rdoc_files: []
|
|
60
|
+
files:
|
|
61
|
+
- MIT-LICENSE
|
|
62
|
+
- README.md
|
|
63
|
+
- Rakefile
|
|
64
|
+
- app/controllers/yes/command/api/application_controller.rb
|
|
65
|
+
- app/controllers/yes/command/api/v1/commands_controller.rb
|
|
66
|
+
- config/initializers/message_bus_filters.rb
|
|
67
|
+
- config/routes.rb
|
|
68
|
+
- lib/yes/command/api.rb
|
|
69
|
+
- lib/yes/command/api/commands/batch_authorizer.rb
|
|
70
|
+
- lib/yes/command/api/commands/batch_validator.rb
|
|
71
|
+
- lib/yes/command/api/commands/deserializer.rb
|
|
72
|
+
- lib/yes/command/api/commands/notifiers/action_cable.rb
|
|
73
|
+
- lib/yes/command/api/commands/notifiers/message_bus.rb
|
|
74
|
+
- lib/yes/command/api/commands/params_validator.rb
|
|
75
|
+
- lib/yes/command/api/engine.rb
|
|
76
|
+
- lib/yes/command/api/version.rb
|
|
77
|
+
homepage: https://github.com/yousty/yes
|
|
78
|
+
licenses:
|
|
79
|
+
- MIT
|
|
80
|
+
metadata:
|
|
81
|
+
homepage_uri: https://github.com/yousty/yes
|
|
82
|
+
source_code_uri: https://github.com/yousty/yes/tree/main/yes-command-api
|
|
83
|
+
changelog_uri: https://github.com/yousty/yes/blob/main/yes-command-api/CHANGELOG.md
|
|
84
|
+
rubygems_mfa_required: 'true'
|
|
85
|
+
rdoc_options: []
|
|
86
|
+
require_paths:
|
|
87
|
+
- lib
|
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
89
|
+
requirements:
|
|
90
|
+
- - ">="
|
|
91
|
+
- !ruby/object:Gem::Version
|
|
92
|
+
version: 3.2.0
|
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
94
|
+
requirements:
|
|
95
|
+
- - ">="
|
|
96
|
+
- !ruby/object:Gem::Version
|
|
97
|
+
version: '0'
|
|
98
|
+
requirements: []
|
|
99
|
+
rubygems_version: 3.6.9
|
|
100
|
+
specification_version: 4
|
|
101
|
+
summary: Command API for the Yes event sourcing framework
|
|
102
|
+
test_files: []
|