hyper-operation 0.99.6 → 1.0.alpha1

Sign up to get free protection for your applications and to get access to all the features.
data/DOCS.md DELETED
@@ -1,869 +0,0 @@
1
-
2
- # Hyperloop Operations
3
-
4
- Operations are the engine rooms of Hyperloop; they orchestrate the interactions between Components, external services, Models, and Stores. Operations provide a tidy place to keep your business logic.
5
-
6
- Operations receive parameters and execute a series of steps. They have a simple structure which is not dissimilar to a Component:
7
-
8
- ```ruby
9
- class SimpleOperation < Hyperloop::Operation
10
- param :anything
11
- step { do_something }
12
- end
13
-
14
- #to invoke from anywhere
15
- SimpleOperation.run(anything: :something)
16
- .then { success }
17
- .fail { fail }
18
- ```
19
-
20
- Hyperloop's Isomorphic Operations span the client and server divide automagically. Operations can run on the client, the server, and traverse between the two.
21
-
22
- This goal of this documentation is to outline Operations classes and provides enough information and examples to show how to implement Operations in an application.
23
-
24
- ### Operations have three core functions
25
-
26
- Operations are packaged as one neat package but perform three different functions:
27
-
28
- 1. Operations encapsulate business logic into a series of steps
29
- 2. Operations can dispatch messages (either on the client or between the client and server)
30
- 3. ServerOps can be used to replace boiler-plate APIs through a bi-directional RPC mechanism
31
-
32
- **Important to understand:** There is no requirement to use all three functions. Use only the functionality your application requires.
33
-
34
- ## Operations encapsulate business logic
35
-
36
- In a traditional MVC architecture, the business logic ends up either in Controllers, Models, Views or some other secondary construct such as service objects, helpers, or concerns. In Hyperloop, Operations are first class objects who's job is to mutate state in the Stores, Models, and Components. Operations are discreet logic, which is of course, testable and maintainable.
37
-
38
- An Operation does the following things:
39
-
40
- 1. receives incoming parameters, and does basic validations
41
- 2. performs any further validations
42
- 3. executes the operation
43
- 4. dispatches to any listeners
44
- 5. returns the value of the execution (step 3)
45
-
46
- These are defined by series of class methods described below.
47
-
48
- ### Operation Structure
49
-
50
- `Hyperloop::Operation` is the base class for an *Operation*
51
-
52
- As an example, here is an Operation which ensures that the Model being saved always has the current `created_by` and `updated_by` `Member`.
53
-
54
- ```ruby
55
- class SaveWithUpdatingMemberOp < Hyperloop::Operation
56
- param :model
57
- step { params.model.created_by = Member.current if params.model.new? }
58
- step { params.model.updated_by = Member.current }
59
- step { model.save.then { } }
60
- end
61
- ```
62
- This Operation is run from anywhere in the client or server code:
63
-
64
- ```ruby
65
- SaveWithUpdatingMemberOp.run(model: MyModel)
66
- ```
67
-
68
- Operations always return Promises, and those Promises can be chained together. See the section on Promises later in this documentation for details on how Promises work.
69
-
70
- Operations can invoke other Operations so you can chain a sequence of `steps` and Promises which proceed unless the previous `step` fails:
71
-
72
- ```ruby
73
- class InvoiceOpertion < Hyperloop::Operation
74
- param :order, type: Order
75
- param :customer, type: Customer
76
-
77
- step { CheckInventoryOp.run(order: params.order) }
78
- step { BillCustomerOp.run(order: params.order, customer: params.customer) }
79
- step { DispatchOrderOp.run(order: params.order, customer: params.customer) }
80
- end
81
- ```
82
-
83
- This approach allows you to build readable and testable workflows in your application.
84
-
85
- ### Running Operations
86
-
87
- To run an Operation:
88
-
89
- + use the `run` method:
90
-
91
- ```ruby
92
- MyOperation.run
93
- ```
94
-
95
- + passing params:
96
-
97
- ```ruby
98
- MyOperation.run(params)
99
- ```
100
-
101
- + the `then` and `fail` methods, which will dispatch the operation and attach a promise handler:
102
-
103
- ```ruby
104
- MyOperation.run(params)
105
- .then { do_the_next_thing }
106
- .fail { puts 'failed' }
107
- ```
108
-
109
- ### Parameters
110
-
111
- Operations can take parameters when they are run. Parameters are described and accessed with the same syntax as Hyperloop Components.
112
-
113
- The parameter filter types and options are taken from the [Mutations](https://github.com/cypriss/mutations) gem with the following changes:
114
-
115
- + In Hyperloop::Operations all params are declared with the param macro
116
- + The type *can* be specified using the `type:` option
117
- + Array and hash types can be shortened to `[]` and `{}`
118
- + Optional params either have the default value associated with the param name or by having the `default` option present
119
- + All other [Mutation filter options](https://github.com/cypriss/mutations/wiki/Filtering-Input) (such as `:min`) will work the same
120
-
121
- ```ruby
122
- # required param (does not have a default value)
123
- param :sku, type: String
124
- # equivalent Mutation syntax
125
- # required { string :sku }
126
-
127
- # optional params (does have a default value)
128
- param qty: 1, min: 1
129
- # alternative syntax
130
- param :qty, default: 1, min: 1
131
- # equivalent Mutation syntax
132
- # optional { integer :qty, default: 1, min: 1 }
133
- ```
134
-
135
- All incoming params are validated against the param declarations, and any errors are posted to the `@errors` instance variable. Extra params are ignored, but missing params unless they have a default value will cause a validation error.
136
-
137
- ### Defining Execution Steps
138
-
139
- Operations may define a sequence of steps to be executed when the operation is run, using the `step`, `failed` and `async` callback macros.
140
-
141
- ```ruby
142
- class Reset < Hyperloop::Operation
143
- step { HTTP.post('/logout') }
144
- end
145
- ```
146
-
147
- + `step`: runs a callback - each step is run in order.
148
- + `failed`: runs a callback if a previous `step` or validation has failed.
149
- + `async`: will be explained below.
150
-
151
- ```ruby
152
- step { } # do something
153
- step { } # do something else once above step is done
154
- failed { } # do this if anything above has failed
155
- step { } # do a third thing, unless we are on the failed track
156
- failed { } # do this if anything above has failed
157
- ```
158
-
159
- Together `step` and `failed` form two *railway tracks*. Initially, execution proceeds down the success track until something goes wrong; then execution switches to the failure track starting at the next `failed` statement. Once on the failed track execution continues performing each `failed` callback and skipping any `step` callbacks.
160
-
161
- Failure occurs when either an exception is raised, or a Promise fails (more on this in the next section.) The Ruby `fail` keyword can be used as a simple way to switch to the failed track.
162
-
163
- Both `step` and `failed` can receive any results delivered by the previous step. If the last step raised an exception (outside a Promise), the failure track would receive the exception object.
164
-
165
- The callback may be provided to `step` and `failed` either as a block, a symbol (which will name a method), a proc, a lambda, or an Operation.
166
-
167
- ```ruby
168
- step { puts 'hello' }
169
- step :say_hello
170
- step -> () { puts 'hello' }
171
- step Proc.new { puts 'hello' }
172
- step SayHello # your params will be passed along to SayHello
173
- ```
174
-
175
- FYI: You can also use the Ruby `next` keyword as expected to leave the current step and move to the next one.
176
-
177
- ### Promises and Operations
178
-
179
- Within the browser, the code does not wait for asynchronous methods (such as HTTP requests or timers) to complete. Operations use Opal's [Promise library](http://opalrb.org/docs/api/v0.10.3/stdlib/Promise.html) to deal with these situations cleanly. A Promise is an object that has three states: It is either still pending, or has been rejected (i.e. failed), or has been successfully resolved. A Promise can have callbacks attached to either the failed or resolved state, and these callbacks will be executed once the Promise is resolved or rejected.
180
-
181
- If a `step` or `failed` callback returns a pending Promise then the execution of the operation is suspended, and the Operation will return the Promise to the caller. If there is more track ahead, then execution will resume at the next step when the Promise is resolved. Likewise, if the pending Promise is rejected execution will resume on the next `failed` callback. Because of the way Promises work, the operation steps will all be completed before the resolved state is passed along to the caller so that everything will execute in its original order.
182
-
183
- Likewise, the Operation's dispatch occurs when the Promise resolves as well.
184
-
185
- The `async` method can be used to override the waiting behavior. If a `step` returns a Promise, and there is an `async` callback further down the track, execution will immediately pick up at the `async`. Any steps in between will still be run when the Promise resolves, but their results will not be passed outside of the operation.
186
-
187
- These features make it easy to organize, understand and compose asynchronous code:
188
-
189
- ```ruby
190
- class AddItemToCart < Hyperloop::Operation
191
- step { HTTP.get('/inventory/#{params.sku}/qty') }
192
- # previous step returned a Promise so next step
193
- # will execute when that Promise resolves
194
- step { |response| fail if params.qty > response.to_i }
195
- # once we are sure we have inventory we will dispatch
196
- # to any listening stores.
197
- end
198
- ```
199
-
200
- Operations will *always* return a *Promise*. If an Operation has no steps that return a Promise the value of the last step will be wrapped in a resolved Promise. Operations can be easily changed regardless of their internal implementation:
201
-
202
- ```ruby
203
- class QuickCheckout < Hyperloop::Operation
204
- param :sku, type: String
205
- param qty: 1, type: Integer, minimum: 1
206
-
207
- step { AddItemToCart.run(params) }
208
- step ValidateUserDefaultCC
209
- step Checkout
210
- end
211
- ```
212
-
213
- You can also use `Promise#when` if you don't care about the order of Operations
214
-
215
- ```ruby
216
- class DoABunchOStuff < Hyperloop::Operation
217
- step { Promise.when(SomeOperation.run, SomeOtherOperation.run) }
218
- # dispatch when both operations complete
219
- end
220
- ```
221
-
222
- ### Early Exits
223
-
224
- Any `step` or `failed` callback, can have an immediate exit from the Operation using the `abort!` and `succeed!` methods. The `abort!` method returns a failed Promise with any supplied parameters. The `succeed!` method does an immediate dispatch and returns a resolved Promise with any supplied parameters. If `succeed!` is used in a `failed` callback, it will override the failed status of the Operation. This is especially useful if you want to dispatch in spite of failures:
225
-
226
- ```ruby
227
- class Pointless < Hyperloop::Operation
228
- step { fail } # go to failure track
229
- failed { succeed! } # dispatch and exit
230
- end
231
- ```
232
-
233
- ### Validation
234
-
235
- An Operation can also have some `validate` callbacks which will run before the first step. This is a handy place to put any additional validations. In the validate method you can add validation type messages using the `add_error` method, and these will be passed along like any other param validation failures.
236
-
237
- ```ruby
238
- class UpdateProfile < Hyperloop::Operation
239
- param :first_name, type: String
240
- param :last_name, type: String
241
- param :password, type: String, nils: true
242
- param :password_confirmation, type: String, nils: true
243
-
244
- validate do
245
- add_error(
246
- :password_confirmation,
247
- :doesnt_match,
248
- "Your new password and confirmation do not match"
249
- ) unless params.password == params.confirmation
250
- end
251
-
252
- # or more simply:
253
-
254
- add_error :password_confirmation, :doesnt_match, "Your new password and confirmation do not match" do
255
- params.password != params.confirmation
256
- end
257
-
258
- ...
259
- end
260
- ```
261
-
262
- If the validate method returns a Promise, then execution will wait until the Promise resolves. If the Promise fails, then the current validation fails.
263
-
264
- `abort!` can be called from within `validate` or `add_error` to exit the Operation immediately. Otherwise, all validations will be run and collected together, and the Operation will move onto the `failed` track. If `abort!` is called within an `add_error` callback the error will be added before aborting.
265
-
266
- You can also raise an exception directly in validate if appropriate. If a `Hyperloop::AccessViolation` exception is raised the Operation will immediately abort, otherwise just the current validation fails.
267
-
268
- To avoid further validations if there are any failures in the basic parameter validations, this can be added
269
-
270
- ```ruby
271
- validate { abort! if has_errors? }
272
- ```
273
-
274
- before the first `validate` or `add_error` call.
275
-
276
- ### Handling Failed Operations
277
-
278
- Because Operations always return a promise, the Promise's `fail` method can be used on the Operation's result to detect failures.
279
-
280
- ```ruby
281
- QuickCheckout.run(sku: selected_item, qty: selected_qty)
282
- .then do
283
- # show confirmation
284
- end
285
- .fail do |exception|
286
- # whatever exception was raised is passed to the fail block
287
- end
288
- ```
289
-
290
- Failures to validate params result in `Hyperloop::ValidationException` which contains a [Mutations error object](https://github.com/cypriss/mutations#what-about-validation-errors).
291
-
292
- ```ruby
293
- MyOperation.run.fail do |e|
294
- if e.is_a? Hyperloop::ValidationException
295
- e.errors.symbolic # hash: each key is a parameter that failed validation,
296
- # value is a symbol representing the reason
297
- e.errors.message # same as symbolic but message is in English
298
- e.errors.message_list # array of messages where failed parameter is
299
- # combined with the message
300
- end
301
- end
302
- ```
303
-
304
- ### Instance Versus Class Execution Context
305
-
306
- Typically the Operation's steps are declared and run in the context of an instance of the Operation. An instance of the Operation is created, runs and is thrown away.
307
-
308
- Sometimes it's useful to run a step (or other macro such as `validate`) in the context of the class. This is useful especially for caching values between calls to the Operation. This can be done by defining the steps in the class context, or by providing the option `scope: :class` to the step.
309
-
310
- Note that the primary use should be in interfacing to an outside APIs. Application state should not be hidden inside an Operation, and it should be moved to a Store.
311
-
312
-
313
- ```ruby
314
- class GetRandomGithubUser < Hyperloop::Operation
315
- def self.reload_users
316
- @promise = HTTP.get("https://api.github.com/users?since=#{rand(500)}").then do |response|
317
- @users = response.json.collect do |user|
318
- { name: user[:login], website: user[:html_url], avatar: user[:avatar_url] }
319
- end
320
- end
321
- end
322
- self.class.step do # as one big step
323
- return @users.delete_at(rand(@users.length)) unless @users.blank?
324
- reload_users unless @promise && @promise.pending?
325
- @promise.then { run }
326
- end
327
- end
328
- # or
329
- class GetRandomGithubUser < Hyperloop::Operation
330
- class << self # as 4 steps - whatever you like
331
- step { succeed! @users.delete_at(rand(@users.length)) unless @users.blank? }
332
- step { succeed! @promise.then { run } if @promise && @promise.pending? }
333
- step { self.class.reload_users }
334
- async { @promise.then { run } }
335
- end
336
- end
337
- ```
338
-
339
- An instance of the operation is always created to hold the current parameter values, dispatcher, etc. The first parameter to a class level `step` block or method (if it takes parameters) will always be the instance.
340
-
341
- ```ruby
342
- class Interesting < Hyperloop::Operation
343
- param :increment
344
- param :multiply
345
- outbound :result
346
- outbound :total
347
- step scope: :class { @total ||= 0 }
348
- step scope: :class { |op| op.params.result = op.params.increment * op.params.multiply }
349
- step scope: :class { |op| op.params.total = (@total += op.params.result) }
350
- dispatch
351
- end
352
- ```
353
-
354
- ### The Boot Operation
355
-
356
- Hyperloop includes one predefined Operation, `Hyperloop::Application::Boot`, that runs at system initialization. Stores can receive `Hyperloop::Application::Boot` to initialize their state. To reset the state of the application, you can just execute `Hyperloop::Application::Boot`
357
-
358
-
359
- ## Operations can dispatch messages
360
-
361
- Hyperloop Operations borrow from the Flux pattern where Operations are dispatchers and Stores are receivers. The choice to use Operations in this depends entirely on the needs and design of your application.
362
-
363
- To illustrate this point, here is the simplest Operation:
364
-
365
- ```ruby
366
- class Reset < Hyperloop::Operation
367
- end
368
- ```
369
-
370
- To 'Reset' the system you would say
371
-
372
- ```ruby
373
- Reset.run
374
- ```
375
-
376
- Elsewhere your HyperStores can receive the Reset *Dispatch* using the `receives` macro:
377
-
378
- ```ruby
379
- class Cart < Hyperloop::Store
380
- receives Reset do
381
- mutate.items Hash.new { |h, k| h[k] = 0 }
382
- end
383
- end
384
- ```
385
-
386
- Note that multiple stores can receive the same *Dispatch*.
387
-
388
- >**Note: Flux pattern vs. Hyperloop Operations** Operations serve the role of both Action Creators and Dispatchers described in the Flux architecture. We chose the name `Operation` rather than `Action` or `Mutation` because we feel it best captures all the capabilities of a `Hyperloop::Operation`. Nevertheless, Operations are fully compatible with the Flux Pattern.
389
-
390
- ### Dispatching With New Parameters
391
-
392
- The `dispatch` method sends the `params` object on to any registered receivers. Sometimes it's useful to add additional outbound params before dispatching. Additional params can be declared using the `outbound` macro:
393
-
394
- ```ruby
395
- class AddItemToCart < Hyperloop::Operation
396
- param :sku, type: String
397
- param qty: 1, type: Integer, minimum: 1
398
- outbound :available
399
-
400
- step { HTTP.get('/inventory/#{params.sku}/qty') }
401
- step { |response| params.available = response.to_i }
402
- step { fail if params.qty > params.available }
403
- dispatch
404
- end
405
- ```
406
-
407
- ### Dispatching messages or invoking steps (or both)?
408
-
409
- Facebook is very keen on their Flux architecture where messages are dispatched between receivers. In an extensive and complicated front end application it is easy to see why they are drawn to this architecture as it creates an independence and isolation between Components.
410
-
411
- As stated earlier in this documentation, the `step` idea came from Trailblazer, which is an alternative Rails architecture that posits that business functionality should not be kept in the Models, Controllers or Views.
412
-
413
- In designing Hyperloop's Isomorphic Operations (which would run on the client and the server), we decided to borrow from the best of both architectures and let Operations work in either way. The decision as to adopt the dispatching or stepping based model is left down to the programmer as determined by their preference or the needs of their application.
414
-
415
- ## ServerOps can be used to replace boiler-plate APIs
416
-
417
- Some Operations simply do not make sense to run on the client as the resources they depend on may not be available on the client. For example, consider an Operation that needs to send an email - there is no mailer on the client so the Operation has to execute from the server.
418
-
419
- That said, with our highest goal being developer productivity, it should be as invisible as possible to the developer where the Operation will execute. A developer writing front-end code should be able to invoke a server-side resource (like a mailer) just as easily as they might invoke a client-side resource.
420
-
421
- Hyperloop `ServerOps` replace the need for a boiler-plate HTTP API. All serialization and de-serialization of params are handled by Hyperloop. Hyperloop automagically creates the API endpoint needed to invoke a function from the client which executes on the server and returns the results (via a Promise) to the calling client-side code.
422
-
423
- ### Server Operations
424
-
425
- Operations will run on the client or the server. However, some Operations like `ValidateUserDefaultCC` probably need to check information server side and make secure API calls to our credit card processor. Rather than build an API and controller to "validate the user credentials" you just specify that the operation must run on the server by using the `Hyperloop::ServerOp` class.
426
-
427
- ```ruby
428
- class ValidateUserCredentials < Hyperloop::ServerOp
429
- param :acting_user
430
- add_error :acting_user, :no_valid_default_cc, "No valid default credit card" do
431
- !params.acting_user.has_default_cc?
432
- end
433
- end
434
- ```
435
-
436
- A Server Operation will always run on the server even if invoked on the client. When invoked from the client, the ServerOp will receive the `acting_user` param with the current value that your ApplicationController's `acting_user` method returns. Typically the `acting_user` method will return either some User model or nil (if there is no logged in user.) It's up to you to define how `acting_user` is computed, but this is easily done with any of the popular authentication gems. Note that unless you explicitly add `nils: true` to the param declaration, nil will not be accepted.
437
-
438
- > **Note regarding Rails Controllers:** Hyperloop is quite flexible and rides along side Rails, without interfering. So you could still have your old controllers, and invoke them the "non-hyperloop" way by doing say an HTTP.post from the client, etc. Hyperloop adds a new mechanism for communicating between client and server called the Server Operation (which is a subclass of Operation.) A ServerOp has no implication on your existing controllers or code, and if used replaces controllers and client side API calls. HyperModel is built on top of Rails ActiveRecord models, and Server Operations, to keep models in sync across the application. ActiveRecord models that are made public (by moving them to the hyperloop/models folder) will automatically be synchronized across the clients and the server (subject to permissions given in the Policy classes.)
439
- Like Server Operations, HyperModel completely removes the need to build controllers, and client side API code. However all of your current active record models, controllers will continue to work unaffected.
440
-
441
-
442
- As shown above, you can also define a validation to ensure further that the acting user (with perhaps other parameters) is allowed to perform the operation. In the above case that is the only purpose of the Operation. Another typical use would be to make sure the current acting user has the correct role to perform the operation:
443
-
444
- ```ruby
445
- ...
446
- validate { raise Hyperloop::AccessViolation unless params.acting_user.admin? }
447
- ...
448
- ```
449
-
450
- You can bake this kind logic into a superclass:
451
-
452
- ```ruby
453
- class AdminOnlyOp < Hyperloop::ServerOp
454
- param :acting_user
455
- validate { raise Hyperloop::AccessViolation unless params.acting_user.admin? }
456
- end
457
-
458
- class DeleteUser < AdminOnlyOp
459
- param :user
460
- add_error :user, :cant_delete_user, "Can't delete yourself, or the last admin user" do
461
- params.user == params.acting_user || (params.user.admin? && AdminUsers.count == 1)
462
- end
463
- end
464
- ```
465
-
466
- Because Operations always return a Promise, there is nothing to change on the client to call a Server Operation. A Server Operation will return a Promise that will be resolved (or rejected) when the Operation completes (or fails) on the server.
467
-
468
- ### Isomorphic Operations
469
-
470
- Unless the Operation is a Server Operation, it will run where it was invoked. This can be handy if you have an Operation that needs to run on both the server and the client. For example, an Operation that calculates the customers discount will want to run on the client so the user gets immediate feedback, and then will be run again on the server when the order is submitted as a double check.
471
-
472
-
473
- ### Parameters and ServerOps
474
-
475
- You cannot pass an object from the client to the server as a parameter as the server has no way of knowing the state of the object. Hyperloop takes a traditional implementation approach where an id (or some unique identifier) is passed as the parameter and the receiving code finds and created an instance of that object. For example:
476
-
477
- ```ruby
478
- class IndexBookOp < Hyperloop::ServerOp
479
- param :book_id
480
- step { index_book Book.find_by_id params.book_id }
481
- end
482
- ```
483
-
484
- ### Restricting server code to the server
485
-
486
- There are valid cases where you will not want your ServerOp's code to be on the client yet still be able to invoke a ServerOp from client or server code. Good reasons for this would include:
487
-
488
- + Security concerns where you would not want some part of your code on the client
489
- + Size of code, where there will be unnecessary code downloaded to the client
490
- + Server code using backticks (`) or the %x{ ... } sequence, both of which are interpreted on the client as escape to generate JS code.
491
-
492
- To accomplish this, you wrap the server side implementation of the ServerOp in a `RUBY_ENGINE == 'opal'` test which acts as a compiler directive so that this code is not compiled by Opal.
493
-
494
- There are several strategies you can use to apply the RUBY_ENGINE == 'opal' guard to your code.
495
-
496
- ```ruby
497
- # strategy 1: guard blocks of code and declarations that you don't want to compile to the client
498
- class MyServerOp < Hyperloop::ServerOp
499
- # stuff that is okay to compile on the client
500
- # ... etc
501
- unless RUBY_ENGINE == 'opal'
502
- # other code that should not be compiled to the client...
503
- end
504
- end
505
- ```
506
-
507
- ```ruby
508
- # strategy 2: guard individual methods
509
- class MyServerOp < Hyperloop::ServerOp
510
- # stuff that is okay to compile on the client
511
- # ... etc
512
- def my_secret_method
513
- # do something we don't want to be shown on the client
514
- end unless RUBY_ENGINE == 'opal'
515
- end
516
- ```
517
-
518
- ```ruby
519
- # strategy 3: describe class in two pieces
520
- class MyServerOp < Hyperloop::ServerOp; end # publically declare the operation
521
- # provide the private implementation only on the server
522
- class MyServerOp < Hyperloop::ServerOp
523
- #
524
- end unless RUBY_ENGINE == 'opal'
525
- ```
526
-
527
- Here is a fuller example:
528
-
529
- ```ruby
530
- # app/hyperloop/operations/list_files.rb
531
- class ListFiles < Hyperloop::ServerOp
532
- param :acting_user, nils: true
533
- param pattern: '*'
534
- step { run_ls }
535
-
536
- # because backticks are interpreted by the Opal compiler as escape to JS, we
537
- # have to make sure this does not compile on the client
538
- def run_ls
539
- `ls -l #{params.pattern}`
540
- end unless RUBY_ENGINE == 'opal'
541
- end
542
-
543
- # app/hyperloop/components/app.rb
544
- class App < Hyperloop::Component
545
- state files: []
546
-
547
- after_mount do
548
- @pattern = ''
549
- every(1) { ListFiles.run(pattern: @pattern).then { |files| mutate.files files.split("\n") } }
550
- end
551
-
552
- render(DIV) do
553
- INPUT(defaultValue: '')
554
- .on(:change) { |evt| @pattern = evt.target.value }
555
- DIV(style: {fontFamily: 'Courier'}) do
556
- state.files.each do |file|
557
- DIV { file }
558
- end
559
- end
560
- end
561
- end
562
- ```
563
-
564
- ### Dispatching From Server Operations
565
-
566
- You can also broadcast the dispatch from Server Operations to all authorized clients. The `dispatch_to` will determine a list of *channels* to broadcast the dispatch to:
567
-
568
- ```ruby
569
- class Announcement < Hyperloop::ServerOp
570
- # no acting_user because we don't want clients to invoke the Operation
571
- param :message
572
- param :duration, type: Float, nils: true
573
- # dispatch to the built-in Hyperloop::Application Channel
574
- dispatch_to Hyperloop::Application
575
- end
576
-
577
- class CurrentAnnouncements < Hyperloop::Store
578
- state_reader all: [], scope: :class
579
- receives Announcement do
580
- mutate.all << params.message
581
- after(params.duration) { delete params.message } if params.duration
582
- end
583
- def self.delete(message)
584
- mutate.all.delete message
585
- end
586
- end
587
- ```
588
-
589
- #### Channels
590
-
591
- As seen above broadcasting is done over a *Channel*. Any Ruby class (including Operations) can be used as *class channel*. Any Ruby class that responds to the `id` method can be used as an *instance channel.*
592
-
593
- For example, the `User` active record model could be a used as a channel to broadcast to *all* users. Each user instance could also be a separate instance channel that would be used to broadcast to a specific user.
594
-
595
- The purpose of having channels is to restrict what gets broadcast to who, therefore typically channels represent *connections* to
596
-
597
- + the application (represented by the `Hyperloop::Application` class)
598
- + or some function within the application (like an Operation)
599
- + or some class which is *authenticated* like a User or Administrator,
600
- + instances of those classes,
601
- + or instances of classes in some relationship - like a `team` that a `user` belongs to.
602
-
603
- A channel can be created by including the `Hyperloop::Policy::Mixin`,
604
- which gives three class methods: `regulate_class_connection` `always_allow_connection` and `regulate_instance_connections`.
605
-
606
- For example...
607
-
608
- ```ruby
609
- class User < ActiveRecord::Base
610
- include Hyperloop::Policy::Mixin
611
- regulate_class_connection { self }
612
- regulate_instance_connection { self }
613
- end
614
- ```
615
-
616
- will attach the current acting user to the `User` channel (which is shared with all users) and to that user's private channel.
617
-
618
- Both blocks execute with `self` set to the current acting user, but the return value has a different meaning. If `regulate_class_connection` returns any truthy value, then the class level connection will be made on behalf of the acting user. On the other hand, if `regulate_instance_connection` returns an array (possibly nested) or Active Record relationship then an instance connection is made with each object in the list. So, for example, you could add:
619
-
620
- ```ruby
621
- class User < ActiveRecord::Base
622
- has_many chat_rooms
623
- regulate_instance_connection { chat_rooms }
624
- # we will connect to all the chat room channels we are members of
625
- end
626
- ```
627
-
628
- To broadcast to all users, the Operation would have
629
-
630
- ```ruby
631
- dispatch_to { User } # dispatch to the User class channel
632
- ```
633
-
634
- or to send an announcement to a specific user
635
-
636
- ```ruby
637
- class PrivateAnnouncement < Hyperloop::ServerOp
638
- param :receiver
639
- param :message
640
- # dispatch_to can take a block if we need to
641
- # dynamically compute the channels
642
- dispatch_to { params.receiver }
643
- end
644
- ...
645
- # somewhere else in the server
646
- PrivateAnnouncement.run(receiver: User.find_by_login(login), message: 'log off now!')
647
- ```
648
-
649
- The above will work if `PrivateAnnouncement` is invoked from the server, but usually, some other client would be sending the message so the operation could look like this:
650
-
651
- ```ruby
652
- class PrivateAnnouncement < Hyperloop::ServerOp
653
- param :acting_user
654
- param :receiver
655
- param :message
656
- validate { raise Hyperloop::AccessViolation unless params.acting_user.admin? }
657
- validate { params.receiver = User.find_by_login(receiver) }
658
- dispatch_to { params.receiver }
659
- end
660
- ```
661
-
662
- On the client::
663
-
664
- ```ruby
665
- PrivateAnnouncement.run(receiver: login_name, message: 'log off now!').fail do
666
- alert('message could not be sent')
667
- end
668
- ```
669
-
670
- and elsewhere in the client code, there would be a component like this:
671
-
672
- ```ruby
673
- class Alerts < Hyperloop::Component
674
- include Hyperloop::Store::Mixin
675
- # for simplicity we are going to merge our store with the component
676
- state alert_messages: [] scope: :class
677
- receives PrivateAnnouncement { |params| mutate.alert_messages << params.message }
678
- render(DIV, class: :alerts) do
679
- UL do
680
- state.alert_messages.each do |message|
681
- LI do
682
- SPAN { message }
683
- BUTTON { 'dismiss' }.on(:click) { mutate.alert_messages.delete(message) }
684
- end
685
- end
686
- end
687
- end
688
- end
689
- ```
690
-
691
- This will (in only 28 lines of code)
692
- + associate a channel with each logged in user
693
- + invoke the PrivateAnnouncement Operation on the server (remotely from the client)
694
- + validate that there is a logged in user at that client
695
- + validate that we have a non-nil, non-blank receiver and message
696
- + validate that the acting_user is an admin
697
- + look up the receiver in the database under their login name
698
- + dispatch the parameters back to any clients where the receiver is logged in
699
- + those clients will update their alert_messages state and
700
- + display the message
701
-
702
-
703
- The `dispatch_to` callback takes a list of classes, representing *Channels.* The Operation will be dispatched to all clients connected to those Channels. Alternatively `dispatch_to` can take a block, a symbol (indicating a method to call) or a proc. The block, proc or method should return a single Channel, or an array of Channels, which the Operation will be dispatched to. The dispatch_to callback has access to the params object. For example, we can add an optional `to` param to our Operation, and use this to select which Channel we will broadcast to.
704
-
705
- ```ruby
706
- class Announcement < Hyperloop::Operation
707
- param :message
708
- param :duration
709
- param to: nil, type: User
710
- # dispatch to the Users channel only if specified otherwise announcement is application wide
711
- dispatch_to { params.to || Hyperloop::Application }
712
- end
713
- ```
714
-
715
- ### Defining Connections in ServerOps
716
-
717
- The policy methods `always_allow_connection` and `regulate_class_connection` may be used directly in a ServerOp class. This will define a channel dedicated to that class, and will also dispatch to that channel when the Operation completes.
718
-
719
- ```ruby
720
- class Announcement < HyperLoop::ServerOp
721
- # all clients will have an Announcement Channel which will
722
- # receive all dispatches from the Announcement Operation
723
- always_allow_connection
724
- end
725
- ```
726
-
727
- ```ruby
728
- class AdminOps < HyperLoop::ServerOp
729
- # subclasses can be invoked from the client if an admin is logged in
730
- # and all other clients that have a logged in admin will receive the dispatch
731
- regulate_class_connection { acting_user.admin? }
732
- param :acting_user
733
- validate { param.acting_user.admin? }
734
- end
735
- ```
736
-
737
- ### Regulating Dispatches in Policy Classes
738
-
739
- Regulations and dispatch lists can be grouped and specified in Policy files, which are by convention kept in the Rails `app/policies` directory.
740
-
741
- ```ruby
742
- # app/policies/announcement_policy.rb
743
- class AnnouncementPolicy
744
- always_allow_connection
745
- dispatch_to { params.acting_user }
746
- end
747
-
748
- # app/policies/user_policy.rb
749
- class UserPolicy
750
- regulate_instance_connection { self }
751
- end
752
- ```
753
-
754
- ### Serialization
755
-
756
- If you need to control serialization and deserialization across the wire you can define the following *class* methods:
757
-
758
- ```ruby
759
- def self.serialize_params(hash)
760
- # receives param_name -> value pairs
761
- # return an object ready for to_json
762
- # default is just return the input hash
763
- end
764
-
765
- def self.deserialize_params(object)
766
- # recieves whatever was returned from serialize_to_server
767
- # (param_name => value pairs by default)
768
- # must return a hash of param_name => value pairs
769
- # by default this returns object
770
- end
771
-
772
- def self.serialize_response(object)
773
- # receives the object ready for to_json
774
- # by default this returns object
775
- end
776
-
777
- def self.deserialize_response(object)
778
- # receives whatever was returned from serialize_response
779
- # by default this returns object
780
- end
781
-
782
- def self.serialize_dispatch(hash)
783
- # input is always key - value pairs
784
- # return an object ready for to_json
785
- # default just returns the input hash
786
- end
787
-
788
- def self.deserialize_dispatch(object)
789
- # recieves whatever was returned from serialize_to_server
790
- # (param_name => value pairs by default)
791
- # must return a hash of param_name => value pairs
792
- # by default this returns object
793
- end
794
- ```
795
-
796
- ### Accessing the Controller
797
-
798
- ServerOps have the ability to receive the "controller" as a param. This is handy for low-level stuff (like login) where you need access to the controller. There is a subclass of ServerOp called ControllerOp that simply declares this param and will delegate any controller methods to the controller param. So within a `ControllerOp` if you say `session` you will get the session object from the controller.
799
-
800
- Here is a sample of the SignIn operation using the Devise Gem:
801
-
802
- ```ruby
803
- class SignIn < Hyperloop::ControllerOp
804
- param :email
805
- inbound :password
806
- add_error(:email, :does_not_exist, 'that login does not exist') { !(@user = User.find_by_email(params.email)) }
807
- add_error(:password, :is_incorrect, 'password is incorrect') { !@user.valid_password?(params.password) }
808
- # no longer have to do this step { params.password = nil }
809
- step { sign_in(:user, @user) }
810
- end
811
- ```
812
-
813
- In the code above there is another parameter type in ServerOps, called inbound, which will not get dispatched.
814
-
815
- ### Broadcasting to the current_session
816
-
817
- Let's say you would like to be able to broadcast to the current session. For example, after the user signs in we want to broadcast to all the browser windows the user happens to have open so that they can update.
818
-
819
- For this, we have a `current_session` method in the `ControllerOp` that you can dispatch to.
820
-
821
- ```ruby
822
- class SignIn < Hyperloop::ControllerOp
823
- param :email
824
- inbound :password
825
- add_error(:email, :does_not_exist, 'that login does not exist') { !(@user = User.find_by_email(params.email)) }
826
- add_error(:password, :is_incorrect, 'password is incorrect') { !@user.valid_password?(params.password) }
827
- step { sign_in(:user, @user) }
828
- dispatch_to { current_session }
829
- end
830
- ```
831
-
832
- The Session channel is special so to attach to the application to it you would say in the top level component:
833
-
834
- ```ruby
835
- class App < Hyperloop::Component
836
- after_mount :connect_session
837
- end
838
- ```
839
-
840
- ## Additional information
841
-
842
- ### Operation Capabilities
843
-
844
- Operations have the following capabilities:
845
-
846
- + Can easily be chained because they always return Promises
847
- + declare both their parameters and what they will dispatch
848
- + Parameters can be validated and type checked
849
- + Can run remotely on the server
850
- + Can be dispatched from the server to all authorized clients.
851
- + Can hold their own state data when appropriate
852
- + Operations also serves as the bridge between client and server
853
- + An operation can run on the client or the server and can be invoked remotely.
854
-
855
- **Use Operations as you choose**. This architecture is descriptive but not prescriptive. Depending on the needs of your application and your overall thoughts on architecture, you may need a little or a lot of the functionality provided by Operations. If you chose, you could keep all your business logic in your Models, Stores or Components - we suggest that it is better application design not to do this, but the choice is yours.
856
-
857
- ### Background
858
-
859
- The design of Hyperloop's Operations have been inspired by three concepts: [Trailblazer Operations](http://trailblazer.to/gems/operation/2.0/) (for encapsulating business logic in `steps`), the [Flux pattern](https://facebook.github.io/flux/) (for dispatchers and receivers), and the [Mutation Gem](https://github.com/cypriss/mutations) (for validating params).
860
-
861
- ### Hyperloop Operations compared to Flux
862
-
863
- | Flux | HyperLoop |
864
- |-----| --------- |
865
- | Action | Hyperloop::Operation subclass |
866
- | ActionCreator | `Hyperloop::Operation.step/failed/async` methods |
867
- | Action Data | Hyperloop::Operation parameters |
868
- | Dispatcher | `Hyperloop::Operation#dispatch` method |
869
- | Registering a Store | `Store.receives` |