ruby_robot 0.1.9

Sign up to get free protection for your applications and to get access to all the features.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +1 -0
  3. data/Gemfile +6 -0
  4. data/Gemfile.lock +133 -0
  5. data/Guardfile +77 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +50 -0
  8. data/Rakefile +24 -0
  9. data/bin/console +14 -0
  10. data/bin/setup +8 -0
  11. data/exe/ruby_robot +34 -0
  12. data/exe/ruby_robot_grpc_client +53 -0
  13. data/exe/ruby_robot_grpc_server +46 -0
  14. data/exe/ruby_robot_web +19 -0
  15. data/json_schema/request.schema.json +37 -0
  16. data/json_schema/response.schema.json +24 -0
  17. data/lib/public/favicon-16x16.png +0 -0
  18. data/lib/public/favicon-32x32.png +0 -0
  19. data/lib/public/index.html +95 -0
  20. data/lib/public/swagger-ui-bundle.js +93 -0
  21. data/lib/public/swagger-ui-standalone-preset.js +13 -0
  22. data/lib/public/swagger-ui.css +3 -0
  23. data/lib/public/swagger-ui.js +8 -0
  24. data/lib/public/swagger.json +179 -0
  25. data/lib/ruby_robot.rb +16 -0
  26. data/lib/ruby_robot/cacerts.crt +92 -0
  27. data/lib/ruby_robot/construction_error.rb +4 -0
  28. data/lib/ruby_robot/grpc/robot_service.rb +102 -0
  29. data/lib/ruby_robot/grpc/ruby_robot_services_pb.rb +34 -0
  30. data/lib/ruby_robot/grpc_helper.rb +15 -0
  31. data/lib/ruby_robot/grpc_ruby/ruby_robot_pb.rb +36 -0
  32. data/lib/ruby_robot/grpc_shell.rb +85 -0
  33. data/lib/ruby_robot/netflix_tabletop.rb +14 -0
  34. data/lib/ruby_robot/placement_error.rb +4 -0
  35. data/lib/ruby_robot/robot.rb +71 -0
  36. data/lib/ruby_robot/ruby_robot.proto +53 -0
  37. data/lib/ruby_robot/schema_loader.rb +10 -0
  38. data/lib/ruby_robot/shell.rb +204 -0
  39. data/lib/ruby_robot/tabletop.rb +120 -0
  40. data/lib/ruby_robot/version.rb +3 -0
  41. data/lib/ruby_robot/webapp.rb +263 -0
  42. data/ruby_robot.gemspec +48 -0
  43. metadata +258 -0
@@ -0,0 +1,53 @@
1
+ //
2
+ // gRPC service definition for the RubyRobot.
3
+ //
4
+ syntax = "proto3";
5
+
6
+ import "google/protobuf/empty.proto";
7
+
8
+ option java_multiple_files = true;
9
+ option java_package = "net.avilla.netflix.studio.robot";
10
+ option java_outer_classname = "RobotProto";
11
+ option objc_class_prefix = "RBT";
12
+
13
+ package RubyRobot;
14
+
15
+ //
16
+ // Service definitions
17
+ //
18
+ service RubyRobot {
19
+ rpc Left(google.protobuf.Empty) returns (RubyRobotResponse) {}
20
+ rpc Move(google.protobuf.Empty) returns (RubyRobotResponse) {}
21
+ rpc Place(RubyRobotRequest) returns (RubyRobotResponse) {}
22
+ rpc Remove(google.protobuf.Empty) returns (google.protobuf.Empty) {}
23
+ rpc Report(google.protobuf.Empty) returns (RubyRobotResponse) {}
24
+ rpc Right(google.protobuf.Empty) returns (RubyRobotResponse) {}
25
+ }
26
+
27
+ //
28
+ // Message type definitions
29
+ //
30
+ message RubyRobotRequest {
31
+ int32 x = 1;
32
+ int32 y = 2;
33
+ enum Direction {
34
+ // Clockwise from NORTH
35
+ NORTH=0;
36
+ EAST =1;
37
+ SOUTH=2;
38
+ WEST =3;
39
+ }
40
+ Direction direction = 3;
41
+ }
42
+
43
+ message RubyRobotError {
44
+ int32 error = 1;
45
+ string message = 2;
46
+ }
47
+
48
+ message RubyRobotResponse {
49
+ oneof response {
50
+ RubyRobotRequest current_state = 1;
51
+ RubyRobotError error = 2;
52
+ }
53
+ }
@@ -0,0 +1,10 @@
1
+ module RubyRobot
2
+ module SchemaLoader
3
+
4
+ def load_schema(name)
5
+ schema_path = File.join(File.dirname(__FILE__), '..', '..', 'json_schema', "#{name}.schema.json")
6
+ schema = JSON.load(File.new(schema_path))
7
+ end
8
+
9
+ end
10
+ end
@@ -0,0 +1,204 @@
1
+ #
2
+ # Behold: I stand on the shoulders of giants...rossmeissl@github.com
3
+ # is the _(wo?)man_
4
+ #
5
+ require 'bombshell'
6
+ require 'logger'
7
+
8
+ module RubyRobot
9
+ class Shell < ::Bombshell::Environment
10
+ include ::Bombshell::Shell
11
+
12
+ prompt_with 'ILoveNetflixStudio'
13
+
14
+ attr_reader :logger
15
+
16
+ def initialize
17
+ @logger = Logger.new(STDOUT)
18
+ @logger.formatter = proc { |severity, datetime, progname, msg|
19
+ "#{msg}\n"
20
+ }
21
+ end
22
+
23
+ #
24
+ # Place a robot
25
+ #
26
+ def PLACE(x, y, direction)
27
+ # Save state in case place is called w/ invalid coords
28
+ orig_robot = @robot
29
+ orig_tabletop = @tabletop
30
+ # TODO: What happens when place is called > 1x per session?
31
+ # Answer under time crunch: just replace the Robot and Tabletop
32
+ @robot = Robot.new(direction)
33
+ @tabletop = NetflixTabletop.new
34
+ begin
35
+ @tabletop.place(@robot, x: x, y: y)
36
+ true
37
+ rescue
38
+ @robot = orig_robot
39
+ @tabletop = orig_tabletop
40
+ @logger.info $!
41
+ false
42
+ end
43
+ end
44
+
45
+ def MOVE
46
+ return if @robot.nil?
47
+ @robot.move
48
+ end
49
+
50
+ def LEFT
51
+ return if @robot.nil?
52
+ @robot.left
53
+ end
54
+
55
+ def RIGHT
56
+ return if @robot.nil?
57
+ @robot.right
58
+ end
59
+
60
+ def REPORT(to_stderr=true)
61
+ return nil if @robot.nil?
62
+ @logger.info(@robot.report) if to_stderr
63
+ @robot.report
64
+ end
65
+
66
+ # Exit Bombshell
67
+ alias :QUIT :quit
68
+ end
69
+ end
70
+
71
+ #
72
+ # Monkeypatch Irb to allow no-arg uppercase methods
73
+ #
74
+ module IRB
75
+ class Irb
76
+ # Evaluates input for this session.
77
+ def eval_input
78
+ @scanner.set_prompt do
79
+ |ltype, indent, continue, line_no|
80
+ if ltype
81
+ f = @context.prompt_s
82
+ elsif continue
83
+ f = @context.prompt_c
84
+ elsif indent > 0
85
+ f = @context.prompt_n
86
+ else
87
+ f = @context.prompt_i
88
+ end
89
+ f = "" unless f
90
+ if @context.prompting?
91
+ @context.io.prompt = p = prompt(f, ltype, indent, line_no)
92
+ else
93
+ @context.io.prompt = p = ""
94
+ end
95
+ if @context.auto_indent_mode
96
+ unless ltype
97
+ ind = prompt(@context.prompt_i, ltype, indent, line_no)[/.*\z/].size +
98
+ indent * 2 - p.size
99
+ ind += 2 if continue
100
+ @context.io.prompt = p + " " * ind if ind > 0
101
+ end
102
+ end
103
+ end
104
+
105
+ @scanner.set_input(@context.io) do
106
+ signal_status(:IN_INPUT) do
107
+ if l = @context.io.gets
108
+ #
109
+ # Begin monkeypatch: turn uppercase constants into
110
+ # method calls
111
+ #
112
+ if [:MOVE,:LEFT,:RIGHT,:REPORT,:REMOVE,:QUIT].include?(l.strip.to_sym)
113
+ l = "#{l.strip}()\n"
114
+ end
115
+ #
116
+ # End monkeypatch
117
+ #
118
+ print l if @context.verbose?
119
+ else
120
+ if @context.ignore_eof? and @context.io.readable_after_eof?
121
+ l = "\n"
122
+ if @context.verbose?
123
+ printf "Use \"exit\" to leave %s\n", @context.ap_name
124
+ end
125
+ else
126
+ print "\n"
127
+ end
128
+ end
129
+ l
130
+ end
131
+ end
132
+
133
+ @scanner.each_top_level_statement do |line, line_no|
134
+ signal_status(:IN_EVAL) do
135
+ begin
136
+ line.untaint
137
+ @context.evaluate(line, line_no)
138
+ output_value if @context.echo?
139
+ exc = nil
140
+ rescue Interrupt => exc
141
+ rescue SystemExit, SignalException
142
+ raise
143
+ rescue Exception => exc
144
+ end
145
+ if exc
146
+ print exc.class, ": ", exc, "\n"
147
+ if exc.backtrace && exc.backtrace[0] =~ /irb(2)?(\/.*|-.*|\.rb)?:/ && exc.class.to_s !~ /^IRB/ &&
148
+ !(SyntaxError === exc)
149
+ irb_bug = true
150
+ else
151
+ irb_bug = false
152
+ end
153
+
154
+ messages = []
155
+ lasts = []
156
+ levels = 0
157
+ if exc.backtrace
158
+ for m in exc.backtrace
159
+ m = @context.workspace.filter_backtrace(m) unless irb_bug
160
+ if m
161
+ if messages.size < @context.back_trace_limit
162
+ messages.push "\tfrom "+m
163
+ else
164
+ lasts.push "\tfrom "+m
165
+ if lasts.size > @context.back_trace_limit
166
+ lasts.shift
167
+ levels += 1
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+ print messages.join("\n"), "\n"
174
+ unless lasts.empty?
175
+ printf "... %d levels...\n", levels if levels > 0
176
+ print lasts.join("\n"), "\n"
177
+ end
178
+ print "Maybe IRB bug!\n" if irb_bug
179
+ end
180
+ if $SAFE > 2
181
+ abort "Error: irb does not work for $SAFE level higher than 2"
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
188
+
189
+ #
190
+ # Map these constants to symbols so the shell
191
+ # will pass them through
192
+ #
193
+ module Bombshell
194
+ module Shell
195
+ NORTH=:north
196
+ SOUTH=:south
197
+ EAST=:east
198
+ WEST=:west
199
+ end
200
+ end
201
+ # Tell shell not to show lower-case 'quit' method; it is aliased
202
+ # to #QUIT along w/ all the other upper-case methods.
203
+ ::Bombshell::Shell::Commands::HIDE << :quit
204
+ ::Bombshell::Shell::Commands::HIDE << :logger
@@ -0,0 +1,120 @@
1
+ #
2
+ # This class is a tabletop which is essentially a 2D
3
+ # array even though Ruby doesn't support 2D arrays
4
+ # like in other languages.
5
+ #
6
+ # As per the API instructions, (0,0) is considered
7
+ # to be the SOUTH WEST most corner.
8
+ #
9
+ module RubyRobot
10
+ class Tabletop
11
+ attr_reader :width, :height
12
+
13
+ def initialize(width, height)
14
+ # Store position of each piece
15
+ @width = width
16
+ @height = height
17
+ # Actually, this probably isn't necessary
18
+ # @playing_field = Array.new(@width) { Array.new(@height) }
19
+ # Hash with keys as the robot object and values are x/y coords
20
+ @robots = {}
21
+ end
22
+
23
+ def width_range
24
+ # Exclude width since positions are 0-based
25
+ (0...@width)
26
+ end
27
+
28
+ def height_range
29
+ # Exclude height since positions are 0-based
30
+ (0...@height)
31
+ end
32
+
33
+ def calculate_position(orig_position, direction_sym)
34
+ case direction_sym
35
+ # These are clockwise from north
36
+ when :north then orig_position[:y] += 1
37
+ when :east then orig_position[:x] += 1
38
+ when :south then orig_position[:y] -= 1
39
+ when :west then orig_position[:x] -= 1
40
+ end
41
+ orig_position
42
+ end
43
+
44
+ #
45
+ # Return hash of x/y coords for a placed
46
+ # Robot, else raise PlacementError. Position state doesn't
47
+ # mean much to a Robot, by itself: only in
48
+ # relation to a Tabletop.
49
+ #
50
+ def position(robot)
51
+ raise PlacementError.new "Robot is not on this table" unless placed?(robot)
52
+ @robots[robot]
53
+ end
54
+
55
+ #
56
+ # Can the Robot move in the specified direction
57
+ # w/o falling off? Even though direction is a
58
+ # property of a specific Robot, pass it in here.
59
+ #
60
+ # Later this could be extended to support multiple
61
+ # Robots on a Tabletop. In that case, this and
62
+ # #move would need to be synchronized...
63
+ #
64
+ def move?(robot, direction_sym)
65
+ raise PlacementError.new "Robot is not on this table" unless placed?(robot)
66
+ possible_position = calculate_position(@robots[robot].clone, direction_sym)
67
+
68
+ # Is current_position on the board?
69
+ # Check in range (0..width).include?(x) and (0..height).include?(y)
70
+ return false if
71
+ !width_range.include?(possible_position[:x]) or
72
+ !height_range.include?(possible_position[:y])
73
+ true
74
+ end
75
+
76
+ #
77
+ # Move the robot in the specified direction.
78
+ #
79
+ def move(robot, direction_sym)
80
+ raise PlacementError.new "Robot is not on this table" unless placed?(robot)
81
+ new_position = calculate_position(@robots[robot].clone, direction_sym)
82
+ # Move the robot by placing it at its new location
83
+ place(robot, **new_position)
84
+ end
85
+
86
+ #
87
+ # Is this robot on this Tabletop?
88
+ #
89
+ def placed?(robot)
90
+ @robots.keys.include?(robot)
91
+ end
92
+
93
+ #
94
+ # NYI: Implement for multiple Robots per Tabletop
95
+ #
96
+ def place?(robot, direction_sym)
97
+ true
98
+ end
99
+
100
+ #
101
+ # Place a robot on this board
102
+ #
103
+ def place(robot, x:0, y:0)
104
+ raise PlacementError.new "Coordinates (#{x},#{y}) are not on this board" if
105
+ !width_range.include?(x) || !height_range.include?(y)
106
+ # @playing_field[x][y] = robot
107
+ @robots[robot] = {x: x, y: y}
108
+ robot.place(self)
109
+ end
110
+
111
+ #
112
+ # Human-readable dump of Tabletop, with
113
+ # 2D array index {x:0,y:0} in the lower-left
114
+ # corner of the output.
115
+ #
116
+ def inspect
117
+ @playing_field
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,3 @@
1
+ module RubyRobot
2
+ VERSION = "0.1.9"
3
+ end
@@ -0,0 +1,263 @@
1
+ require 'sinatra/base'
2
+ require 'sinatra/swagger-exposer/swagger-exposer'
3
+ require 'ruby_robot'
4
+ require 'json-schema'
5
+ require 'json'
6
+
7
+ module RubyRobot
8
+
9
+ #
10
+ # SwaggerExposer turns out to have a few drawbacks; it doesn't support
11
+ # specifying enumerated string types.
12
+ #
13
+ USE_SWAGGER_EXPOSER=false
14
+
15
+ #
16
+ # Simple Sinatra webapp that supports:
17
+ #
18
+ # Run HTTP GET on /swagger.json to fetch a static JSON OpenAPI description
19
+ # for this webapp (suitable for use with swagger.io's GUI).
20
+ #
21
+ class Webapp < Sinatra::Base
22
+ include ::RubyRobot::SchemaLoader
23
+
24
+ set :public_folder, File.expand_path(File.join(File.dirname(__FILE__), '..', 'public'))
25
+ enable :static
26
+
27
+ # Tell sinatra to listen on all interfaces if it detects
28
+ # it is running on a docker container...otherwise it
29
+ # will just bind to the loopback interface which can't be
30
+ # exposed from the container.
31
+ set :bind, '0.0.0.0' if File.exist?('/.dockerenv')
32
+
33
+ REPORT_EXAMPLE_OBJ = {x:1,y:1,direction: :NORTH}
34
+ REPORT_EXAMPLE = REPORT_EXAMPLE_OBJ.to_json
35
+
36
+ ERR_PLACEMENT_MSG = 'Robot is not currently placed'
37
+
38
+ def request_schema
39
+ @@request_schema ||= schema = load_schema('request')
40
+ end
41
+
42
+ def response_schema
43
+ @@response_schema ||= schema = load_schema('response')
44
+ end
45
+
46
+ def max_error_message_length
47
+ # Grab from the schema
48
+ (response_schema['definitions']['Error']['properties']['message']['maxLength'] || 1024)
49
+ end
50
+
51
+ if USE_SWAGGER_EXPOSER
52
+ register Sinatra::SwaggerExposer
53
+ #
54
+ # Swagger general info
55
+ #
56
+ general_info(
57
+ {
58
+ version: ::RubyRobot::VERSION,
59
+ title: 'RubyRobot',
60
+ description: 'Web interface to RubyRobot API',
61
+ }
62
+ )
63
+
64
+ #
65
+ # Swagger types
66
+ #
67
+ type 'Error', {
68
+ :required => [:code, :message],
69
+ :properties => {
70
+ :code => {
71
+ :type => Integer,
72
+ :example => 400,
73
+ :description => 'The error code',
74
+ },
75
+ :message => {
76
+ :type => String,
77
+ :example => ERR_PLACEMENT_MSG,
78
+ :description => 'The error message',
79
+ }
80
+ }
81
+ }
82
+
83
+ type 'Report', {
84
+ required: [:x, :y, :direction],
85
+ properties: {
86
+ x: {
87
+ type: Integer,
88
+ example: 0
89
+ },
90
+ y: {
91
+ type: Integer,
92
+ example: 0
93
+ },
94
+ direction: {
95
+ type: String,
96
+ example: "NORTH"
97
+ }
98
+ }
99
+ }
100
+ end # if USE_SWAGGER_EXPOSER
101
+
102
+ #
103
+ # "Normal" sinatra/ruby code.
104
+ #
105
+ def robot
106
+ @@robot ||= proc {
107
+ rr = ::RubyRobot::Shell.new
108
+ # In the webapp, don't log the REPORT messages to stdout
109
+ rr.logger.formatter = proc { |severity, datetime, progname, msg| "" }
110
+ rr
111
+ }.call
112
+ end
113
+
114
+ def not_placed_error
115
+ [400, {code: 400, message: ERR_PLACEMENT_MSG}.to_json]
116
+ end
117
+
118
+ def formatted_report
119
+ r = robot.REPORT(false)
120
+ if !r.nil?
121
+ r[:direction] = r[:direction].upcase unless r[:direction].nil?
122
+ end
123
+ r
124
+ end
125
+
126
+ def position_report
127
+ # Pass along the report, but the direction needs to be upcased to
128
+ # comply w/ the JSON schema for the web API
129
+ [200, formatted_report.to_json]
130
+ end
131
+
132
+ if USE_SWAGGER_EXPOSER
133
+ endpoint_description 'Place the robot'
134
+ endpoint_parameter :body, "Robot placement specification object", :body, true, 'Report', {
135
+ example: REPORT_EXAMPLE_OBJ
136
+ }
137
+ endpoint_response 200, 'Report', 'Successful placement'
138
+ endpoint_response 400, 'Error', ERR_PLACEMENT_MSG
139
+ endpoint_tags 'Robot'
140
+ end # if USE_SWAGGER_EXPOSER
141
+ post '/place' do
142
+ content_type :json
143
+ request_params = nil
144
+ result = nil
145
+ begin
146
+ # Parse JSON args
147
+ request.body.rewind
148
+ request_params = JSON.parse(request.body.read)
149
+ # Validate input against JSON schema
150
+ json_schema_errors = JSON::Validator.fully_validate(request_schema, request_params)
151
+ body_json = {code: 400, message: "Bad request: #{json_schema_errors.join('; ')}"[0...max_error_message_length] }.to_json
152
+ body body_json
153
+ return [400, body_json] unless json_schema_errors.empty?
154
+ # Call robot#PLACE: inputs have already been validated by the JSON schema
155
+ robot.PLACE(request_params['x'], request_params['y'], (request_params['direction']))
156
+ body_json = formatted_report.to_json
157
+ body body_json
158
+ [200, body_json]
159
+ rescue
160
+ # TODO: log failing request details to enterprise logging...
161
+ # STDERR.puts $!
162
+ body_json = {code: 400, message: "Bad request (#{$!})"[0...max_error_message_length]}.to_json
163
+ body body_json
164
+ [400, body_json]
165
+ end
166
+ end
167
+
168
+ if USE_SWAGGER_EXPOSER
169
+ endpoint_description 'Move the robot'
170
+ endpoint_response 200, 'Report', REPORT_EXAMPLE
171
+ endpoint_response 400, 'Error', ERR_PLACEMENT_MSG
172
+ endpoint_tags 'Robot'
173
+ end # if USE_SWAGGER_EXPOSER
174
+ post '/move' do
175
+ content_type :json
176
+ if robot.REPORT.nil?
177
+ not_placed_error
178
+ else
179
+ robot.MOVE
180
+ position_report
181
+ end
182
+ end
183
+
184
+ if USE_SWAGGER_EXPOSER
185
+ endpoint_description 'Turn the robot left'
186
+ endpoint_response 200, 'Report', REPORT_EXAMPLE
187
+ endpoint_response 400, 'Error', ERR_PLACEMENT_MSG
188
+ endpoint_tags 'Robot'
189
+ end # if USE_SWAGGER_EXPOSER
190
+ post '/left' do
191
+ content_type :json
192
+ if robot.REPORT.nil?
193
+ not_placed_error
194
+ else
195
+ robot.LEFT
196
+ position_report
197
+ end
198
+ end
199
+
200
+ if USE_SWAGGER_EXPOSER
201
+ endpoint_description 'Turn the robot right'
202
+ endpoint_response 200, 'Report', REPORT_EXAMPLE
203
+ endpoint_response 400, 'Error', ERR_PLACEMENT_MSG
204
+ endpoint_tags 'Robot'
205
+ end # if USE_SWAGGER_EXPOSER
206
+ post '/right' do
207
+ content_type :json
208
+ if robot.REPORT.nil?
209
+ not_placed_error
210
+ else
211
+ robot.RIGHT
212
+ position_report
213
+ end
214
+ end
215
+
216
+ if USE_SWAGGER_EXPOSER
217
+ endpoint_description "Report the robot's position and orientation"
218
+ endpoint_response 200, 'Report', REPORT_EXAMPLE
219
+ endpoint_response 400, 'Error', ERR_PLACEMENT_MSG
220
+ endpoint_tags 'Robot'
221
+ end # if USE_SWAGGER_EXPOSER
222
+ get '/report' do
223
+ content_type :json
224
+ if robot.REPORT.nil?
225
+ not_placed_error
226
+ else
227
+ position_report
228
+ end
229
+ end
230
+
231
+ if !USE_SWAGGER_EXPOSER
232
+ get '/' do
233
+ redirect '/index.html'
234
+ end
235
+ end
236
+
237
+ post '/remove' do
238
+ @@robot = nil
239
+ [200]
240
+ end
241
+
242
+ #
243
+ # For now, just validate the response for POST /place to show
244
+ # responses can be validated within a Sinatra URL handler.
245
+ #
246
+ after '/place' do
247
+ # Validate response
248
+ begin
249
+ obj = JSON.parse(body.first)
250
+ unless JSON::Validator.validate(response_schema, obj)
251
+ # TODO: Enteprise logging
252
+ # Print out the failing constraints
253
+ STDERR.puts JSON::Validator.fully_validate(response_schema, obj)
254
+ STDERR.puts "Return value doesn't match response.schema.json: #{body.first}"
255
+ end
256
+ rescue
257
+ # TODO: Enterprise logging here...
258
+ STDERR.puts "Error (#{$!}) parsing JSON: '#{body}'"
259
+ end
260
+ # STDERR.puts "Failed validation" if JSON::Validator.validate(@response_schema, obj)
261
+ end
262
+ end
263
+ end