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 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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Command
5
+ module Api
6
+ class ApplicationController < ActionController::API
7
+ end
8
+ end
9
+ end
10
+ end
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Command
5
+ module Api
6
+ VERSION = '1.0.0'
7
+ end
8
+ end
9
+ 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: []