Skip to content

Commit 2e7c266

Browse files
committed
merged
2 parents 14cb9fb + 150b3f8 commit 2e7c266

File tree

58 files changed

+1943
-606
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+1943
-606
lines changed

docs/dsl-isomorphic/hyper-operation.md

Lines changed: 195 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,209 @@ Operations are Hyperstack's implementation of Service Object which is...
66

77
> "A class that performs an action" [A simple explanation of Service Objects for Ruby on Rails](https://medium.freecodecamp.org/service-objects-explained-simply-for-ruby-on-rails-5-a8cc42a5441f)
88
9-
Why do we need Service Objects? Because in any real world system you have logic that does belong in models or stores because it effects multiple models or stores, and it does not belong in components because the logic of the task is independent of the specific user interface design.
9+
Why do we need Service Objects? Because in any real world system you have logic that does not belong in models (or stores) because it effects multiple models or stores, and it does not belong in components because the logic of the task is independent of the specific user interface design. In MVC frameworks this kind of logic is often shoved in the controller, but it doesn't belong there either.
1010

11-
There are also those boundary areas between gathering and processing external data and getting into or out of our stores and models. You don't want that kind of logic in your model or store, so where does it go? Into an operation.
11+
There are also those boundary areas between gathering and processing external data and getting data into or out of our stores and models. You don't want that kind of logic in your model or store, so where does it go? It belongs in a service object or *Operation* in Hyperstack terminology.
1212

13-
Simply put an operation is like a large standalone method that has no internal state of its own. You run an operation, it does it thing, and it returns an answer.
13+
> The term Operation, the key concepts of the Operation, and a lot of the implementation was taken from [Trailblazer](http://trailblazer.to/guides/trailblazer/2.0/01-operation-basics.html)
14+
15+
Simply put an Operation is like a large standalone method that has no internal state of its own. You run an operation, it does it thing, and it returns an answer.
1416

1517
Any state that an operation needs to retrieve or save is stored somewhere else: in a model, a store, or even in a remote API. Once the operation completes, it has no memory of its own.
1618

19+
Being a stand-alone, glue and business logic method is an Operation's full time mission. The Hyperstack `Operation` base class is therefor structured to make writing this kind of code easy.
20+
21+
+ An Operation may take parameters (params) just like any other method;
22+
+ An Operation may validate the parameters;
23+
+ An Operation then executes a number of *steps*;
24+
+ The steps can be part of a success track or a failure track;
25+
+ The value of the final step is returned to the caller;
26+
+ And the results can be *broadcast* to any interested parties.
27+
28+
Hyperstack's Operations often involve asynchronous methods such as HTTP requests and so Operations always return *Promises*. Likewise each of the steps of an Operation can itself be an asynchronous action, and the Operation class will take care of chaining the promises together for you.
29+
30+
Another key feature of Operations is that because they are stateless they make a perfect RPC (Remote Procedure Call) mechanism. So an Operation can be called on the client, but will run on the server, and then return or broadcast the results to the clients. Thus Operations form the underlying data *transport* mechanism between the server and clients.
31+
32+
That is a lot to digest, and truly Operations are the swiss-army knife of Hyperstack. So let's dive into some examples.
33+
34+
In this simple example we are going to use a third-party API to determine our browser's IP address. First without Operations:
35+
36+
```ruby
37+
class App < HyperComponent
38+
before_mount do
39+
HTTP.get('https://api.ipify.org?format=json').then do |response|
40+
mutate @ip_address = response.json[:ip]
41+
end
42+
end
43+
44+
render do
45+
H1 { "Hello world from Hyperstack your ip address: #{@ip_address}" }
46+
end
47+
end
48+
```
49+
50+
Nice and simple. Our App mounts, does a HTTP get from our API, and when it returns it updates the state. The problem is
51+
our view logic is cluttered up with low level specifics of how to get the address. Lets fix that by moving that logic to a separate service object:
52+
53+
```ruby
54+
class GetIPAddress
55+
def self.run
56+
HTTP.get('https://api.ipify.org?format=json').then do |response|
57+
response.json[:ip]
58+
end
59+
end
60+
end
61+
```
62+
63+
Notice that the object is stateless and because it has no state it is simply a class method. We then use our service object like this:
64+
65+
```ruby
66+
class App < HyperComponent
67+
before_mount do
68+
GetIPAddress.run.then { |ip_address| mutate @ip_address = ip_address}
69+
end
70+
71+
render do
72+
H1 { "Hello world from Hyperstack. Your ip address is #{@ip_address}" }
73+
end
74+
end
75+
```
76+
77+
If we were to change how we get the IP address, the Component now doesn't have to change.
78+
79+
Now we will redefine our service object using the Hyperstack::Operation class.
80+
```ruby
81+
class GetIPAddress < Hyperstack::Operation
82+
step { HTTP.get('https://api.ipify.org?format=json') }
83+
step { |response| response.json[:ip] }
84+
end
85+
```
86+
You invoke Operations using the run method, so our Component does not have to change at all.
87+
88+
The advantage is that the Operation syntax takes care of a lot of clutter, allows our
89+
promise to be chained neatly, and makes our intention clear to the reader.
90+
91+
We will see how these advantages multiply as our example becomes more complex.
92+
93+
Before moving on lets understand the basics of Operations.
94+
95+
+ Every Operation has as its external API a single `run` method.
96+
+ The work of the Operation is defined by a series of *steps*.
97+
+ When the run method is called, the code associated with each step is executed.
98+
+ If a step returns a promise the next step will wait till the promise is resolved.
99+
+ The result of the final step is wrapped in a promise and is the result of the operation.
100+
101+
The final point means that regardless of the Operation's internal implementation, the Operation always returns a promise,
102+
so its API is consistent. As operations always return promises you can simply apply the `then` and `fail` promise methods
103+
directly to the Operation rather than saying `Op.run.then`.
104+
105+
Let's say that rather than a simple ip address what we want is a full set of geo-location data. We can use another
106+
third party API to do the job. This API requires we supply our IP address, so we will reuse our IPAddress Op.
107+
108+
```ruby
109+
class GetGeoData < Hyperstack::Operation
110+
step GetIPAddress
111+
step { |ip_address| HTTP.get("https://ipapi.co/#{ip_address}/json/") }
112+
step { |response| response.json }
113+
end
114+
```
115+
116+
Here we can see one of the different ways to define a step: We simply delegate the first step to our already defined `GetIPAddress` operation.
117+
118+
Again lets compare to a traditional ServiceObject:
119+
120+
```ruby
121+
class GetGeoData
122+
def self.run
123+
IPAddress.run.then do |ip_address|
124+
HTTP.get("https://ipapi.co/#{ip_address}/json/")
125+
end.then do |response|
126+
response.json
127+
end
128+
end
129+
end
130+
```
131+
132+
Again its the same logic, but the body of our service object is over twice the number of lines and logic
133+
is obscured by the promise handlers.
134+
135+
It would be nice if we could include a flag icon to go with the country in the response data. Lets do that:
136+
137+
```ruby
138+
class GetGeoData < Hyperstack::Operation
139+
step IPAddress
140+
step { |ip_address| HTTP.get("https://ipapi.co/#{ip_address}/json/") }
141+
step { |response| response.json }
142+
step { |json| json.merge flag_url: "https://www.countryflags.io/#{json['country']}/shiny/64.png" }
143+
end
144+
```
145+
146+
Of course its just *Ruby*, so we can further clean up our code by defining some helper methods:
147+
148+
```ruby
149+
class GetGeoData < Hyperstack::Operation
150+
step IPAddress
151+
step { |ip_address| HTTP.get(geo_data_url_for(ip_address)) }
152+
step { |response| response.json }
153+
step { |json| json.merge flag_url: flag_url_for(json['country']) }
154+
155+
def geo_data_url_for(ip_address)
156+
"https://ipapi.co/#{ip_address}/json/"
157+
end
158+
159+
def flag_url_for(country_code)
160+
"https://www.countryflags.io/#{country_code}/shiny/64.png"
161+
end
162+
end
163+
```
164+
165+
Our `GetGeoData` uses two remote third party operations, which may occasionally fail so we add a retry
166+
mechanism. This will introduce four new features of Operation: The *failure track*, *parameters*, and the
167+
`abort!` and `succeed!` methods.
168+
169+
Tracks
170+
171+
Operations have two *tracks* of execution. The normal success track which is defined by the `step` method, and a
172+
*failure track* which is defined by a series of `failed` methods.
173+
174+
Execution begins with the first step, and continues with each step until an exception is raised, or a promise fails. When that happens execution jumps *to the next* `failed` step, and the continues executing `failed` steps. The result of the
175+
Operation will be value of the last failed step, and the Operation's promise will be be rejected (i.e. will be in the fail state.)
176+
177+
Parameters
178+
179+
Operations can take a series of named parameters defined by the `param` method. Parameters can have type information, defaults, and can be validated. This helps Operations act like a firewall between various parts of the system, making debugging and error handling easier. For now we are just going to use a simple case of a parameter that takes a default value.
180+
181+
The `abort!` and `succeed!` methods
182+
183+
These provide an early exit like `return`, `break` and next statements. Calling `abort!` and `succeed!` immediately exits the Operation by the appropriate track.
184+
185+
Putting it together:
186+
187+
```ruby
188+
class GetGeoData < Hyperstack::Operation
189+
param attempts: 0
190+
191+
step IPAddress
192+
step { |ip_address| HTTP.get(geo_data_url_for(ip_address)) }
193+
step { |response| response.json }
194+
step { |json| json.merge flag_url: flag_url_for(json['country']) }
195+
196+
failed { abort! if params.attempts > 3 }
197+
failed { sleep 1.second }
198+
failed { GeoData.run(attempts: params.attempts+1).then(&:succeed!) }
199+
200+
def geo_data_url_for(ip_address)
201+
"https://ipapi.co/#{ip_address}/json/"
202+
end
203+
204+
def flag_url_for(country_code)
205+
"https://www.countryflags.io/#{country_code}/shiny/64.png"
206+
end
207+
end
208+
```
17209

18-
Because this an operation's full time mission the Hyperstack Operation base class is structured to make writing this kind of code easy.
19210

20-
An Operation may take parameters (params) just like any other method.
21-
An Operation may validate the parameters
22-
An Operation then executes a number of steps
23-
The value of the final step is returned
24211

25-
Hyperstack Operations often involve asynchronous methods such as HTTP requests, and so Operations always return promises
26212

27213

28214
; they orchestrate the interactions between Components, external services, Models, and Stores. Operations provide a tidy place to keep your business logic.

0 commit comments

Comments
 (0)