reason_me_this 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +199 -0
- data/exe/rmt +4 -0
- data/lib/reason_me_this/answer.rb +17 -0
- data/lib/reason_me_this/cli/output.rb +169 -0
- data/lib/reason_me_this/cli.rb +243 -0
- data/lib/reason_me_this/configuration.rb +3 -0
- data/lib/reason_me_this/connection.rb +41 -0
- data/lib/reason_me_this/errors.rb +30 -0
- data/lib/reason_me_this/explanation.rb +3 -0
- data/lib/reason_me_this/model.rb +21 -0
- data/lib/reason_me_this/values.rb +48 -0
- data/lib/reason_me_this/version.rb +3 -0
- data/lib/reason_me_this.rb +70 -0
- metadata +86 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4df9a831994e4b534a9aaeb9fcf9f014414723658d37bb682069bc2fddae6511
|
|
4
|
+
data.tar.gz: 9ce6fa1cafeff01a49479e5019fe1985d844985dddf526010ef42ab019d70c28
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7ddb5ae04b29565f4a4931593c804f9b61de5dd5ef49bbc2b57ac1c0ab731fa88ff52abbb996f3213d194a01a3b336d90298df16e20fb68bb02c255580af5e16
|
|
7
|
+
data.tar.gz: 221f7838a5d8c03fe7385ab3cc67b396e79ce35ed8050dba0440aebd462c91a9023d22db9dd3d33d1ca3f69fb605e16cf83f800e3cf9b62f896ee77421ab2464
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rhys Murray
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# reason_me_this
|
|
2
|
+
|
|
3
|
+
The Ruby client for [Reason Me This](https://reason-me-this.com). It runs, explains, pushes and publishes definitions over HTTP
|
|
4
|
+
and keeps every number exact on the way out and on the way back.
|
|
5
|
+
|
|
6
|
+
It only speaks HTTP: it needs nothing beyond Ruby's standard library and does not load the engine.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
gem "reason_me_this"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## The rmt CLI
|
|
15
|
+
|
|
16
|
+
The gem installs `rmt`, which builds, proves, publishes and runs blueprints on the service. It answers in
|
|
17
|
+
JSON when its output is not a terminal, so an agent can loop on it, and in text at a terminal.
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
export RMT_API_KEY=... # from https://reason-me-this.com/app/api_keys
|
|
21
|
+
rmt guide # how to build a blueprint
|
|
22
|
+
rmt check bill.yml # exit 2 while it has problems
|
|
23
|
+
rmt test bill.yml cases.yml # exit 3 while a case fails
|
|
24
|
+
rmt push bill bill.yml cases.yml && rmt publish bill
|
|
25
|
+
echo '{"plan": "flat_rate"}' | rmt run bill --trace
|
|
26
|
+
rmt run electricity_bill --facts facts.json # a shipped blueprint, by name
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`check`, `test` and `run` on a file send its text to the service and save nothing. Exit codes: 0 success,
|
|
30
|
+
1 a run failed, 2 problems, 3 a case failed, 64 usage, 69 the service could not be reached, 77 the key was
|
|
31
|
+
refused. `rmt help` lists every command. `RMT_API_URL` points it at another service.
|
|
32
|
+
|
|
33
|
+
## Configure
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
require "reason_me_this"
|
|
37
|
+
|
|
38
|
+
ReasonMeThis.configure do |config|
|
|
39
|
+
config.url = ENV["RMT_API_URL"] # https://reason-me-this.com/api
|
|
40
|
+
config.api_key = ENV["RMT_API_KEY"]
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
In Rails this belongs in `config/initializers/reason_me_this.rb`.
|
|
45
|
+
|
|
46
|
+
## Run
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
answer = ReasonMeThis.run("sales_commission@3", facts, calculation: :year)
|
|
50
|
+
|
|
51
|
+
answer.total_paid # => BigDecimal("55830")
|
|
52
|
+
answer[:by_quarter] # => [{start: Date(2026-01-01), payout: BigDecimal("5000"), ...}, ...]
|
|
53
|
+
answer.to_h
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| Option | Means |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| `calculation:` | which calculation in the definition to run |
|
|
59
|
+
| `version:` | a published version number, `"latest"` (the default) or `"draft"` |
|
|
60
|
+
| `trace:` | `true` answers as `explain` does |
|
|
61
|
+
|
|
62
|
+
`"sales_commission@3"` pins the name to published version 3. A `version:` given as well wins over the pin.
|
|
63
|
+
|
|
64
|
+
### What comes back
|
|
65
|
+
|
|
66
|
+
The service sends numbers as strings so they stay exact. An answer reads them back:
|
|
67
|
+
|
|
68
|
+
| The service sends | You get |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| `"55830"`, `"0.8"`, `"-12.5"` | `BigDecimal` |
|
|
71
|
+
| `"2026-07-01"` | `Date` |
|
|
72
|
+
| any other string, number, `true`, `false`, `null` | as sent |
|
|
73
|
+
| an object | a hash with symbol keys, its values read the same way |
|
|
74
|
+
|
|
75
|
+
A text output that happens to look like a number (an id of `"1042"`) is read as a number too.
|
|
76
|
+
|
|
77
|
+
### What goes out
|
|
78
|
+
|
|
79
|
+
Facts go out exactly as you hold them: a `BigDecimal` is written as a JSON number in full, never rounded
|
|
80
|
+
through a `Float` or quoted as a string, and a `Date` or `Time` as an ISO 8601 string. Use `BigDecimal` for
|
|
81
|
+
money and rates; a `Float` goes out as Ruby writes it.
|
|
82
|
+
|
|
83
|
+
## Explain
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
explained = ReasonMeThis.explain("sales_commission", facts, calculation: :year)
|
|
87
|
+
|
|
88
|
+
explained.outputs # an answer, as run gives
|
|
89
|
+
explained.steps # each step with its value, in the order they ran
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Manage definitions
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
ReasonMeThis.push("bill", yaml: File.read("bill.yml"), cases: File.read("cases.yml"))
|
|
96
|
+
# => {problems: [], passed: 4, failed: 0, cases: [...]}
|
|
97
|
+
|
|
98
|
+
ReasonMeThis.publish("bill") # => 4, the new version
|
|
99
|
+
ReasonMeThis.definitions # => [{name: "bill", published_version: 4, updated_at: "..."}]
|
|
100
|
+
ReasonMeThis.definition("bill") # => {name: "bill", yaml: "...", cases: "...", published_version: 4}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Errors
|
|
104
|
+
|
|
105
|
+
Every error is a `ReasonMeThis::Error`, with a message in plain words.
|
|
106
|
+
|
|
107
|
+
| Error | When |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| `Unauthorized` | the service refused the API key (401) |
|
|
110
|
+
| `NotFound` | no such definition or version (404) |
|
|
111
|
+
| `InvalidFacts` | the facts have problems (422); `#problems` lists them |
|
|
112
|
+
| `StepFailed` | a step failed while running (422); the message says which |
|
|
113
|
+
| `Timeout` | the run took too long on the service (503) |
|
|
114
|
+
| `Unreachable` | the service could not be reached: refused, DNS, or timed out |
|
|
115
|
+
| `Rejected` | `push` or `publish` was refused: problems in the definition or failing cases; `#report` has them |
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
begin
|
|
119
|
+
ReasonMeThis.run("bill", facts)
|
|
120
|
+
rescue ReasonMeThis::InvalidFacts => e
|
|
121
|
+
e.problems # => ["hours is missing"]
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## In a model
|
|
126
|
+
|
|
127
|
+
`ReasonMeThis::Model` works in any class, ActiveRecord or not.
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
class Rep < ApplicationRecord
|
|
131
|
+
include ReasonMeThis::Model
|
|
132
|
+
|
|
133
|
+
reasons :statement, with: "sales_commission@3", calculation: :year do |year|
|
|
134
|
+
{deals: deals.closed_in(year).map(&:to_fact), quarters: quarter_targets.in(year).map(&:to_fact)}
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
rep.statement(2026) # => an answer, remembered on this rep for these arguments
|
|
139
|
+
rep.statement(2026).total_paid
|
|
140
|
+
rep.statement_explained(2026) # the same run, explained
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The block runs on the instance, takes the method's arguments, and returns the facts.
|
|
144
|
+
|
|
145
|
+
## More than one service
|
|
146
|
+
|
|
147
|
+
Build a client for each service and use it as you would the module:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
client = ReasonMeThis::Client.new(url: tenant.rmt_url, api_key: tenant.rmt_key)
|
|
151
|
+
client.run("sales_commission", facts, calculation: :year)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
A model runs on `reason_me_this_client`, which is the configured client unless you say otherwise:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
class Rep < ApplicationRecord
|
|
158
|
+
include ReasonMeThis::Model
|
|
159
|
+
|
|
160
|
+
def reason_me_this_client = tenant.reason_me_this_client
|
|
161
|
+
end
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## In tests
|
|
165
|
+
|
|
166
|
+
A client talks through a connection: anything with `request(method, path, body)` that answers
|
|
167
|
+
`[status, parsed_json]`. Give it a fake to run offline:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
class FakeConnection
|
|
171
|
+
def initialize(body) = @body = body
|
|
172
|
+
def request(_method, _path, _body = nil) = [200, @body]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
ReasonMeThis.configure { |config| config.connection = FakeConnection.new("total_paid" => "55830") }
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Against a local service
|
|
179
|
+
|
|
180
|
+
Run the service on port 3000, make an API key there, then:
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
export RMT_API_URL=http://localhost:3000/api
|
|
184
|
+
export RMT_API_KEY=your-key
|
|
185
|
+
bundle exec ruby -Ilib -r reason_me_this -e 'ReasonMeThis.configure { |c| c.url = ENV["RMT_API_URL"]; c.api_key = ENV["RMT_API_KEY"] }; p ReasonMeThis.definitions'
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Example
|
|
189
|
+
|
|
190
|
+
`examples/commissions` is a small Rails app that shows a sales rep's commission statement by quarter, worked
|
|
191
|
+
out by the service from the rep's deals. See its README.
|
|
192
|
+
|
|
193
|
+
## Development
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
bundle install
|
|
197
|
+
bundle exec rspec
|
|
198
|
+
bundle exec rubocop
|
|
199
|
+
```
|
data/exe/rmt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module ReasonMeThis
|
|
2
|
+
class Answer
|
|
3
|
+
def initialize(outputs)
|
|
4
|
+
@outputs = Values.read(outputs)
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def [](name) = @outputs[name.to_sym]
|
|
8
|
+
|
|
9
|
+
def to_h = @outputs
|
|
10
|
+
|
|
11
|
+
def method_missing(name, *args)
|
|
12
|
+
args.empty? && @outputs.key?(name) ? @outputs[name] : super
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def respond_to_missing?(name, include_private = false) = @outputs.key?(name) || super
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
module ReasonMeThis
|
|
2
|
+
class CLI
|
|
3
|
+
module Output
|
|
4
|
+
def self.for(out, format)
|
|
5
|
+
terminal = out.respond_to?(:tty?) && out.tty?
|
|
6
|
+
return Json.new(out) if (format || (terminal ? :text : :json)) == :json
|
|
7
|
+
|
|
8
|
+
Text.new(out, colour: terminal && !ENV.key?('NO_COLOR'))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class Json
|
|
12
|
+
def initialize(out)
|
|
13
|
+
@out = out
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def show(_kind, payload, **)
|
|
17
|
+
@out.puts(JSON.pretty_generate(payload))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Text
|
|
22
|
+
def initialize(out, colour:)
|
|
23
|
+
@out = out
|
|
24
|
+
@colour = colour
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def show(kind, payload, **context)
|
|
28
|
+
@out.puts(send(kind, symbolized(payload), **context))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def symbolized(value)
|
|
34
|
+
case value
|
|
35
|
+
when Hash then value.to_h { |key, item| [key.to_sym, symbolized(item)] }
|
|
36
|
+
when Array then value.map { |item| symbolized(item) }
|
|
37
|
+
else value
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def outputs(values) = fields(values, 0).join("\n")
|
|
42
|
+
|
|
43
|
+
def trace(explained)
|
|
44
|
+
[outputs(explained[:outputs]), '', bold('How it was worked out'), *steps(explained[:steps], 1)].join("\n")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def problems(answer)
|
|
48
|
+
listed = answer[:problems]
|
|
49
|
+
return pass('No problems') if listed.empty?
|
|
50
|
+
|
|
51
|
+
[fail("#{listed.size} #{listed.size == 1 ? 'problem' : 'problems'}"), *listed.map { |problem| " • #{problem}" }].join("\n")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def error(answer) = fail(answer[:error])
|
|
55
|
+
|
|
56
|
+
def refusal(answer) = answer.key?(:problems) ? problems(answer) : error(answer)
|
|
57
|
+
|
|
58
|
+
def report(answer)
|
|
59
|
+
answer.key?(:error) || answer[:problems]&.any? ? refusal(answer) : cases(answer)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def cases(answer)
|
|
63
|
+
passed, failed = answer.values_at(:passed, :failed).map(&:to_i)
|
|
64
|
+
failing = answer[:cases].reject { |result| result[:passed] }
|
|
65
|
+
return pass("#{passed} #{passed == 1 ? 'case' : 'cases'} passed") if failing.empty?
|
|
66
|
+
|
|
67
|
+
[fail("#{failed} of #{passed + failed} cases failed"), *failing.flat_map { |result| failed_case(result) }].join("\n")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def failed_case(result)
|
|
71
|
+
detail = if result[:error]
|
|
72
|
+
[" #{result[:error]}"]
|
|
73
|
+
else
|
|
74
|
+
result[:differences].map { |d| " #{d[:output]}: expected #{d[:expected]}, got #{d[:actual]}" }
|
|
75
|
+
end
|
|
76
|
+
['', " #{fail(result[:name])}", *detail]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def published(answer, name:) = pass("Published #{name} v#{answer[:version]}")
|
|
80
|
+
|
|
81
|
+
def definitions(answer)
|
|
82
|
+
rows = answer[:definitions].map do |definition|
|
|
83
|
+
[definition[:name], definition[:published_version] ? "v#{definition[:published_version]}" : 'draft only', definition[:updated_at].to_s[0, 10]]
|
|
84
|
+
end
|
|
85
|
+
table([%w[NAME PUBLISHED UPDATED], *rows]).join("\n")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def created(answer) = answer[:created].map { |path| "Wrote #{path}" }.join("\n")
|
|
89
|
+
|
|
90
|
+
def describe(answer)
|
|
91
|
+
types = answer[:steps].map { |name, type| " #{name.to_s.ljust(10)} #{type[:purpose]}" }
|
|
92
|
+
functions = answer[:formulas][:usage].map { |_name, usage| " #{usage}" }
|
|
93
|
+
[bold('Step types'), *types, '', bold('Formula functions'), *functions, '',
|
|
94
|
+
'Run rmt describe --json for an example of each, with what it gives, or rmt guide for the whole guide.'].join("\n")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def fields(values, depth)
|
|
98
|
+
width = values.keys.map { |name| name.to_s.size }.max
|
|
99
|
+
values.flat_map do |name, value|
|
|
100
|
+
label = "#{' ' * depth}#{name}"
|
|
101
|
+
case value
|
|
102
|
+
when Hash then [label, *fields(value, depth + 1)]
|
|
103
|
+
when Array then list(label, value, width, depth)
|
|
104
|
+
else "#{label.ljust(width + (2 * depth))} #{value}"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def list(label, value, width, depth)
|
|
110
|
+
return ["#{label.ljust(width + (2 * depth))} #{value.join(', ')}"] unless rows?(value)
|
|
111
|
+
|
|
112
|
+
[label, *table(columns(value)).map { |line| "#{' ' * (depth + 1)}#{line}" }]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def rows?(list) = list.any? && list.all?(Hash)
|
|
116
|
+
|
|
117
|
+
def columns(rows)
|
|
118
|
+
names = rows.flat_map(&:keys).uniq
|
|
119
|
+
[names.map(&:to_s), *rows.map { |row| names.map { |name| cell(row[name]) } }]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def cell(value) = value.is_a?(Array) || value.is_a?(Hash) ? value.size.to_s : value.to_s
|
|
123
|
+
|
|
124
|
+
def table(lines)
|
|
125
|
+
widths = lines.transpose.map { |column| column.map { |cell| cell.to_s.size }.max }
|
|
126
|
+
lines.map { |line| line.each_with_index.map { |cell, index| cell.to_s.ljust(widths[index]) }.join(' ').rstrip }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def steps(list, depth)
|
|
130
|
+
lines = list.map { |step| ["#{' ' * depth}#{step[:step]}", summary(step[:value], step[:of]), detail(step)] }
|
|
131
|
+
names = lines.map { |name, *| name.size }.max
|
|
132
|
+
values = lines.select(&:last).map { |_, value, _| value.size }.max
|
|
133
|
+
list.zip(lines).flat_map { |step, line| [step_line(*line, names, values), *step_rows(step, depth)] }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def step_line(name, value, detail, names, values)
|
|
137
|
+
detail ? "#{name.ljust(names)} #{value.ljust(values)} #{detail}" : "#{name.ljust(names)} #{value}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def step_rows(step, depth)
|
|
141
|
+
Array(step[:rows]).each_with_index.flat_map { |row, index| ["#{' ' * (depth + 1)}row #{index + 1}", *steps(row, depth + 2)] }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def summary(value, over)
|
|
145
|
+
return "#{value.size} #{value.size == 1 ? 'row' : 'rows'}#{" over #{over}" if over}" if value.is_a?(Array)
|
|
146
|
+
|
|
147
|
+
value.is_a?(Hash) ? value.map { |name, item| "#{name} #{item}" }.join(', ') : value.to_s
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def detail(step)
|
|
151
|
+
return dim(step[:formula]) if step[:formula]
|
|
152
|
+
return dim("row #{step[:row].map { |name, value| "#{name} #{value}" }.join(', ')}") if step[:row]
|
|
153
|
+
|
|
154
|
+
dim("from #{step[:of]}") if step[:of] && !step[:value].is_a?(Array)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def pass(message) = paint("✓ #{message}", 32)
|
|
158
|
+
|
|
159
|
+
def fail(message) = paint("✗ #{message}", 31)
|
|
160
|
+
|
|
161
|
+
def bold(text) = paint(text, 1)
|
|
162
|
+
|
|
163
|
+
def dim(text) = paint(text, 2)
|
|
164
|
+
|
|
165
|
+
def paint(text, code) = @colour ? "\e[#{code}m#{text}\e[0m" : text
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'fileutils'
|
|
3
|
+
require 'optparse'
|
|
4
|
+
require 'reason_me_this'
|
|
5
|
+
require 'reason_me_this/cli/output'
|
|
6
|
+
|
|
7
|
+
module ReasonMeThis
|
|
8
|
+
class CLI
|
|
9
|
+
HOSTED = 'https://reason-me-this.com/api'.freeze
|
|
10
|
+
KEYS = 'https://reason-me-this.com/app/api_keys'.freeze
|
|
11
|
+
|
|
12
|
+
USAGE = <<~TEXT.freeze
|
|
13
|
+
Usage: rmt COMMAND [arguments] [options]
|
|
14
|
+
|
|
15
|
+
Build and prove a blueprint:
|
|
16
|
+
guide print the guide to building a blueprint
|
|
17
|
+
describe list step types with examples and the functions formulas can call
|
|
18
|
+
check BLUEPRINT list problems with a blueprint file
|
|
19
|
+
test BLUEPRINT CASES run each case's facts and compare the expected outputs
|
|
20
|
+
run BLUEPRINT [--facts FILE] [--calculation NAME] [--trace]
|
|
21
|
+
run a blueprint file; facts come from FILE or standard input
|
|
22
|
+
|
|
23
|
+
Publish and run definitions:
|
|
24
|
+
push NAME BLUEPRINT [CASES] save a definition's draft, checked and tested
|
|
25
|
+
publish NAME publish the draft as the next version
|
|
26
|
+
run NAME [--version N|draft] [...] run a definition, or a shipped blueprint, by name
|
|
27
|
+
list list the definitions and their published versions
|
|
28
|
+
pull NAME [--dir DIR] write DIR/NAME/blueprint.yml and cases.yml
|
|
29
|
+
|
|
30
|
+
A BLUEPRINT ending in .yml or .yaml or containing a / is a file; anything else is a name.
|
|
31
|
+
Every command but guide and describe needs RMT_API_KEY (create one at #{KEYS}).
|
|
32
|
+
RMT_API_URL defaults to #{HOSTED}.
|
|
33
|
+
|
|
34
|
+
Options for any command:
|
|
35
|
+
--json answer in JSON (the default when output is not a terminal)
|
|
36
|
+
--text answer in text (the default at a terminal)
|
|
37
|
+
|
|
38
|
+
Exit codes: 0 success, 1 a run failed, 2 the blueprint has problems, 3 a case failed, 64 usage,
|
|
39
|
+
69 the service could not be reached, 77 the service refused the key
|
|
40
|
+
TEXT
|
|
41
|
+
|
|
42
|
+
WORKFLOW = <<~MARKDOWN.freeze
|
|
43
|
+
# Building a blueprint
|
|
44
|
+
|
|
45
|
+
A blueprint is a YAML file describing calculations as data. You can turn a source document (a rulebook
|
|
46
|
+
chapter, a price list, a policy) into a blueprint, prove it against worked examples, and run it.
|
|
47
|
+
|
|
48
|
+
## Workflow
|
|
49
|
+
|
|
50
|
+
1. Run `rmt describe` to see every step type with an example, and the functions formulas can call.
|
|
51
|
+
2. Read the source and list the outputs it asks for and the facts they depend on.
|
|
52
|
+
3. Write worked examples first, in a cases file, from figures in the source where it gives them. Work out
|
|
53
|
+
others by hand and say so in the case name.
|
|
54
|
+
4. Write the blueprint.
|
|
55
|
+
5. Run `rmt check BLUEPRINT` until it prints no problems.
|
|
56
|
+
6. Run `rmt test BLUEPRINT CASES` until every case passes. Change the blueprint, not the cases,
|
|
57
|
+
unless a case was worked out wrongly.
|
|
58
|
+
7. Run `rmt run BLUEPRINT --facts FACTS.json` for new facts. Add `--trace` to see how every step
|
|
59
|
+
was reached when a value surprises you.
|
|
60
|
+
8. `rmt push NAME BLUEPRINT CASES` saves it as the draft of the definition NAME, and `rmt publish NAME`
|
|
61
|
+
makes the draft the next version, which applications call. A draft with problems or a failing case
|
|
62
|
+
is refused.
|
|
63
|
+
|
|
64
|
+
Every command prints JSON to standard output when it is not a terminal, which is how an agent runs it,
|
|
65
|
+
and text at a terminal; `--json` or `--text` chooses either way. Exit codes: 0 success, 1 a run failed,
|
|
66
|
+
2 the blueprint has problems, 3 a case failed, 64 usage, 69 the service could not be reached, 77 the
|
|
67
|
+
service refused the key. A blueprint imports others by name (`imports: [rates]`, `rates@2`,
|
|
68
|
+
`rates@draft`), never by path.
|
|
69
|
+
|
|
70
|
+
MARKDOWN
|
|
71
|
+
|
|
72
|
+
def self.call(argv, env: ENV, connection: nil, **io)
|
|
73
|
+
key = env['RMT_API_KEY'].to_s
|
|
74
|
+
connection ||= Connection.new(env['RMT_API_URL'].to_s.empty? ? HOSTED : env['RMT_API_URL'], key)
|
|
75
|
+
new(out: io.fetch(:out, $stdout), err: io.fetch(:err, $stderr), input: io.fetch(:input, $stdin), connection:, keyed: !key.empty?).call(argv.dup)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def initialize(out:, err:, input:, connection:, keyed:)
|
|
79
|
+
@out = out
|
|
80
|
+
@err = err
|
|
81
|
+
@input = input
|
|
82
|
+
@connection = connection
|
|
83
|
+
@keyed = keyed
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def call(argv)
|
|
87
|
+
@output = Output.for(@out, format(argv))
|
|
88
|
+
command = argv.shift
|
|
89
|
+
return help(argv.first) if command == 'help'
|
|
90
|
+
return help(command) if argv.delete('--help')
|
|
91
|
+
|
|
92
|
+
dispatch(command, argv)
|
|
93
|
+
rescue OptionParser::ParseError, Errno::ENOENT => e
|
|
94
|
+
@err.puts(e.message)
|
|
95
|
+
usage
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def dispatch(command, argv)
|
|
101
|
+
case command
|
|
102
|
+
when 'guide', 'describe', 'list' then send(command)
|
|
103
|
+
when 'check', 'test', 'run', 'push', 'publish', 'pull' then argv.first ? send(command, argv) : usage
|
|
104
|
+
else usage
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def format(argv)
|
|
109
|
+
chosen = %w[--json --text].select { |flag| argv.delete(flag) }
|
|
110
|
+
chosen.last&.delete_prefix('--')&.to_sym
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def guide
|
|
114
|
+
on_service(:get, '/describe', keyless: true) do |_status, answer|
|
|
115
|
+
@out.print(WORKFLOW, answer['guide'])
|
|
116
|
+
0
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def describe
|
|
121
|
+
on_service(:get, '/describe', keyless: true) { |_status, answer| answered(:describe, answer.except('guide'), 0) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def check(argv)
|
|
125
|
+
on_service(:post, '/check', {yaml: File.read(argv.first)}) do |_status, answer|
|
|
126
|
+
answered(:problems, answer.slice('problems'), answer['problems'].any? ? 2 : 0)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def test(argv)
|
|
131
|
+
return usage unless argv[1]
|
|
132
|
+
|
|
133
|
+
on_service(:post, '/check', {yaml: File.read(argv[0]), cases: File.read(argv[1])}) { |_status, answer| answered(:report, answer, proven(answer)) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def run(argv)
|
|
137
|
+
options = {}
|
|
138
|
+
OptionParser.new do |parser|
|
|
139
|
+
parser.on('--facts FILE') { |file| options[:facts] = file }
|
|
140
|
+
parser.on('--calculation NAME') { |name| options[:calculation] = name }
|
|
141
|
+
parser.on('--version VERSION') { |version| options[:version] = version }
|
|
142
|
+
parser.on('--trace') { options[:trace] = true }
|
|
143
|
+
end.parse!(argv)
|
|
144
|
+
facts = read_facts(options) or return 1
|
|
145
|
+
body = {facts:, **options.slice(:calculation, :version, :trace)}
|
|
146
|
+
return ran(:post, "/definitions/#{argv.first}/run", body, options) unless path?(argv.first)
|
|
147
|
+
|
|
148
|
+
ran(:post, '/try', {yaml: File.read(argv.first), **body.except(:version)}, options)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def ran(method, path, body, options)
|
|
152
|
+
on_service(method, path, body) do |status, answer|
|
|
153
|
+
if status == 200
|
|
154
|
+
answered(options[:trace] ? :trace : :outputs, answer, 0)
|
|
155
|
+
else
|
|
156
|
+
answered(:refusal, answer, 1)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def read_facts(options)
|
|
162
|
+
JSON.parse(options[:facts] ? File.read(options[:facts]) : @input.read, decimal_class: BigDecimal)
|
|
163
|
+
rescue JSON::ParserError => e
|
|
164
|
+
answered(:problems, {'problems' => ["The facts are not valid JSON: #{e.message}"]}, 1)
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def push(argv)
|
|
169
|
+
name, blueprint, cases = argv
|
|
170
|
+
return usage unless blueprint
|
|
171
|
+
|
|
172
|
+
body = {yaml: File.read(blueprint), cases: cases ? File.read(cases) : ''}
|
|
173
|
+
on_service(:put, "/definitions/#{name}", body) { |_status, answer| answered(:report, answer, proven(answer)) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def publish(argv)
|
|
177
|
+
on_service(:post, "/definitions/#{argv.first}/publish") do |status, answer|
|
|
178
|
+
status == 201 ? answered(:published, answer, 0, name: argv.first) : answered(:report, answer, proven(answer))
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def list
|
|
183
|
+
on_service(:get, '/definitions') { |status, answer| status == 200 ? answered(:definitions, answer, 0) : answered(:refusal, answer, 1) }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def pull(argv)
|
|
187
|
+
options = {dir: '.'}
|
|
188
|
+
OptionParser.new { |parser| parser.on('--dir DIR') { |dir| options[:dir] = dir } }.parse!(argv)
|
|
189
|
+
name = argv.first
|
|
190
|
+
on_service(:get, "/definitions/#{name}") do |status, answer|
|
|
191
|
+
next answered(:refusal, answer, 1) unless status == 200
|
|
192
|
+
|
|
193
|
+
write_new({'blueprint.yml' => answer['yaml'], 'cases.yml' => answer['cases']}.transform_keys { |file| File.join(options[:dir], name, file) })
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def write_new(files)
|
|
198
|
+
existing = files.keys.select { |path| File.exist?(path) }
|
|
199
|
+
return answered(:problems, {'problems' => existing.map { |path| "#{path} already exists" }}, 1) if existing.any?
|
|
200
|
+
|
|
201
|
+
files.each do |path, content|
|
|
202
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
203
|
+
File.write(path, content)
|
|
204
|
+
end
|
|
205
|
+
answered(:created, {'created' => files.keys}, 0)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def on_service(method, path, body = nil, keyless: false)
|
|
209
|
+
return answered(:problems, {'problems' => ["#{path} needs an API key: create one at #{KEYS} and set RMT_API_KEY"]}, 64) unless keyless || @keyed
|
|
210
|
+
|
|
211
|
+
status, answer = @connection.request(method, path, body)
|
|
212
|
+
return answered(:refusal, answer, 77) if [401, 403].include?(status)
|
|
213
|
+
|
|
214
|
+
yield(status, answer)
|
|
215
|
+
rescue Unreachable => e
|
|
216
|
+
answered(:refusal, {'error' => e.message}, 69)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def proven(answer)
|
|
220
|
+
return 1 unless answer.key?('problems')
|
|
221
|
+
return 2 if answer['problems'].any?
|
|
222
|
+
|
|
223
|
+
answer['failed'].to_i.positive? ? 3 : 0
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def path?(argument) = argument.end_with?('.yml', '.yaml') || argument.include?('/')
|
|
227
|
+
|
|
228
|
+
def answered(kind, answer, code, context = {})
|
|
229
|
+
@output.show(kind, answer, **context)
|
|
230
|
+
code
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def help(_command)
|
|
234
|
+
@out.puts(USAGE)
|
|
235
|
+
0
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def usage
|
|
239
|
+
@err.puts(USAGE)
|
|
240
|
+
64
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'net/http'
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
module ReasonMeThis
|
|
6
|
+
class Connection
|
|
7
|
+
def initialize(url, api_key)
|
|
8
|
+
@url = url.to_s.chomp('/')
|
|
9
|
+
@api_key = api_key
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def request(method, path, body = nil)
|
|
13
|
+
raise Error.new('no URL is configured for Reason Me This') if @url.empty?
|
|
14
|
+
|
|
15
|
+
uri = URI("#{@url}#{path}")
|
|
16
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https', open_timeout: 10, read_timeout: 60) do |http|
|
|
17
|
+
http.request(build(method, uri, body))
|
|
18
|
+
end
|
|
19
|
+
[response.code.to_i, parsed(response)]
|
|
20
|
+
rescue SystemCallError, SocketError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
|
|
21
|
+
raise Unreachable.new("could not reach the service at #{@url}: #{e.message}")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def build(method, uri, body)
|
|
27
|
+
Net::HTTP.const_get(method.to_s.capitalize).new(uri).tap do |request|
|
|
28
|
+
request['Authorization'] = "Bearer #{@api_key}"
|
|
29
|
+
request['Content-Type'] = 'application/json'
|
|
30
|
+
request['Accept'] = 'application/json'
|
|
31
|
+
request.body = JSON.generate(Values.write(body)) if body
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def parsed(response)
|
|
36
|
+
JSON.parse(response.body.to_s.empty? ? '{}' : response.body)
|
|
37
|
+
rescue JSON::ParserError
|
|
38
|
+
{'error' => "the service answered HTTP #{response.code} with something other than JSON"}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module ReasonMeThis
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
class Unauthorized < Error; end
|
|
4
|
+
class NotFound < Error; end
|
|
5
|
+
class StepFailed < Error; end
|
|
6
|
+
class Timeout < Error; end
|
|
7
|
+
class Unreachable < Error; end
|
|
8
|
+
|
|
9
|
+
class Refused < Error
|
|
10
|
+
attr_reader :report
|
|
11
|
+
|
|
12
|
+
def initialize(report)
|
|
13
|
+
@report = report
|
|
14
|
+
super(message)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def problems = report.fetch(:problems, [])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class InvalidFacts < Refused
|
|
21
|
+
def message = "the facts have problems: #{problems.join('; ')}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class Rejected < Refused
|
|
25
|
+
def message
|
|
26
|
+
failed = "#{report[:failed]} cases failed" if report[:failed].to_i.positive?
|
|
27
|
+
"the definition was not accepted: #{[*problems, failed].compact.join('; ')}"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module ReasonMeThis
|
|
2
|
+
module Model
|
|
3
|
+
def self.included(base) = base.extend(ClassMethods)
|
|
4
|
+
|
|
5
|
+
module ClassMethods
|
|
6
|
+
def reasons(name, with:, calculation: nil, &facts)
|
|
7
|
+
{name => :run, "#{name}_explained": :explain}.each do |method, call|
|
|
8
|
+
define_method(method) do |*args|
|
|
9
|
+
reasonings[[method, args]] ||= reason_me_this_client.public_send(call, with, instance_exec(*args, &facts), calculation:)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def reason_me_this_client = ReasonMeThis.default
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def reasonings = @reasonings ||= {}
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require 'bigdecimal'
|
|
2
|
+
require 'date'
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module ReasonMeThis
|
|
7
|
+
module Values
|
|
8
|
+
NUMBER = /\A-?\d+(\.\d+)?\z/
|
|
9
|
+
DATE = /\A\d{4}-\d{2}-\d{2}\z/
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def read(value)
|
|
14
|
+
deep(value) do |leaf|
|
|
15
|
+
case leaf
|
|
16
|
+
when NUMBER then BigDecimal(leaf)
|
|
17
|
+
when DATE then date(leaf)
|
|
18
|
+
else leaf
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A decimal goes out as a JSON number written in full, so the service reads the value that was typed.
|
|
24
|
+
def write(value)
|
|
25
|
+
deep(value) do |leaf|
|
|
26
|
+
case leaf
|
|
27
|
+
when BigDecimal then JSON::Fragment.new(leaf.to_s('F'))
|
|
28
|
+
when Date, Time then leaf.iso8601
|
|
29
|
+
else leaf
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def date(text)
|
|
35
|
+
Date.iso8601(text)
|
|
36
|
+
rescue Date::Error
|
|
37
|
+
text
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def deep(value, &)
|
|
41
|
+
case value
|
|
42
|
+
when Hash then value.to_h { |key, item| [key.is_a?(String) ? key.to_sym : key, deep(item, &)] }
|
|
43
|
+
when Array then value.map { |item| deep(item, &) }
|
|
44
|
+
else yield value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require 'reason_me_this/version'
|
|
2
|
+
require 'reason_me_this/values'
|
|
3
|
+
require 'reason_me_this/answer'
|
|
4
|
+
require 'reason_me_this/errors'
|
|
5
|
+
require 'reason_me_this/explanation'
|
|
6
|
+
require 'reason_me_this/connection'
|
|
7
|
+
require 'reason_me_this/configuration'
|
|
8
|
+
require 'reason_me_this/model'
|
|
9
|
+
require 'forwardable'
|
|
10
|
+
|
|
11
|
+
module ReasonMeThis
|
|
12
|
+
class << self
|
|
13
|
+
extend Forwardable
|
|
14
|
+
|
|
15
|
+
def_delegators :default, :run, :explain, :push, :publish, :definitions, :definition
|
|
16
|
+
|
|
17
|
+
def configure
|
|
18
|
+
yield configuration
|
|
19
|
+
@default = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def configuration = @configuration ||= Configuration.new
|
|
23
|
+
|
|
24
|
+
def default = @default ||= Client.new(**configuration.to_h.compact)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class Client
|
|
28
|
+
def initialize(url: nil, api_key: nil, connection: Connection.new(url, api_key))
|
|
29
|
+
@connection = connection
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def run(name, facts, calculation: nil, version: nil, trace: false)
|
|
33
|
+
name, pinned = name.to_s.split('@', 2)
|
|
34
|
+
version ||= pinned&.match?(/\A\d+\z/) ? Integer(pinned) : pinned
|
|
35
|
+
answer = request(:post, at(name, :run), {facts:, calculation:, version:, trace: (true if trace)}.compact)
|
|
36
|
+
trace ? Explanation.new(outputs: Answer.new(answer[:outputs]), steps: answer[:steps]) : Answer.new(answer)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def explain(name, facts, calculation: nil, version: nil) = run(name, facts, calculation:, version:, trace: true)
|
|
40
|
+
|
|
41
|
+
def push(name, yaml:, cases: '') = request(:put, at(name), {yaml:, cases:}, invalid: Rejected)
|
|
42
|
+
|
|
43
|
+
def publish(name) = request(:post, at(name, :publish), invalid: Rejected)[:version]
|
|
44
|
+
|
|
45
|
+
def definitions = request(:get, '/definitions')[:definitions]
|
|
46
|
+
|
|
47
|
+
def definition(name) = request(:get, at(name))
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def at(name, *action) = ['/definitions', name, *action].join('/')
|
|
52
|
+
|
|
53
|
+
def request(method, path, body = nil, invalid: InvalidFacts)
|
|
54
|
+
status, answer = @connection.request(method, path, body)
|
|
55
|
+
answer = Values.read(answer)
|
|
56
|
+
status.between?(200, 299) ? answer : raise(refusal(status, answer, invalid))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def refusal(status, answer, invalid)
|
|
60
|
+
case [status, answer]
|
|
61
|
+
in [401, _] then Unauthorized.new('the service refused the API key')
|
|
62
|
+
in [404, {error:}] then NotFound.new(error)
|
|
63
|
+
in [422, {problems:}] then invalid.new(answer)
|
|
64
|
+
in [422, {error:}] then StepFailed.new(error)
|
|
65
|
+
in [503, {error:}] then Timeout.new(error)
|
|
66
|
+
else Error.new(["the service answered HTTP #{status}", answer[:error]].compact.join(': '))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: reason_me_this
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Rhys Murray
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: bigdecimal
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: json
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.10'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.10'
|
|
40
|
+
description: 'The client for reason-me-this.com: runs, explains, pushes and publishes
|
|
41
|
+
blueprints over HTTP, keeping numbers exact.'
|
|
42
|
+
executables:
|
|
43
|
+
- rmt
|
|
44
|
+
extensions: []
|
|
45
|
+
extra_rdoc_files: []
|
|
46
|
+
files:
|
|
47
|
+
- LICENSE.txt
|
|
48
|
+
- README.md
|
|
49
|
+
- exe/rmt
|
|
50
|
+
- lib/reason_me_this.rb
|
|
51
|
+
- lib/reason_me_this/answer.rb
|
|
52
|
+
- lib/reason_me_this/cli.rb
|
|
53
|
+
- lib/reason_me_this/cli/output.rb
|
|
54
|
+
- lib/reason_me_this/configuration.rb
|
|
55
|
+
- lib/reason_me_this/connection.rb
|
|
56
|
+
- lib/reason_me_this/errors.rb
|
|
57
|
+
- lib/reason_me_this/explanation.rb
|
|
58
|
+
- lib/reason_me_this/model.rb
|
|
59
|
+
- lib/reason_me_this/values.rb
|
|
60
|
+
- lib/reason_me_this/version.rb
|
|
61
|
+
homepage: https://reason-me-this.com
|
|
62
|
+
licenses:
|
|
63
|
+
- MIT
|
|
64
|
+
metadata:
|
|
65
|
+
documentation_uri: https://reason-me-this.com/docs/
|
|
66
|
+
source_code_uri: https://github.com/rhys117/reason-me-this
|
|
67
|
+
rubygems_mfa_required: 'true'
|
|
68
|
+
rdoc_options: []
|
|
69
|
+
require_paths:
|
|
70
|
+
- lib
|
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '3.4'
|
|
76
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0'
|
|
81
|
+
requirements: []
|
|
82
|
+
rubygems_version: 4.0.20
|
|
83
|
+
specification_version: 4
|
|
84
|
+
summary: Run, explain and publish Reason Me This blueprints from Ruby, and the rmt
|
|
85
|
+
CLI
|
|
86
|
+
test_files: []
|