magellan-rails 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +37 -0
  3. data/Gemfile +7 -0
  4. data/Gemfile.lock +76 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.md +29 -0
  7. data/Rakefile +7 -0
  8. data/bin/magellan-rails +7 -0
  9. data/gems/magellan-publisher/.gitignore +37 -0
  10. data/gems/magellan-publisher/Gemfile +11 -0
  11. data/gems/magellan-publisher/Gemfile.lock +51 -0
  12. data/gems/magellan-publisher/LICENSE.txt +22 -0
  13. data/gems/magellan-publisher/README.md +29 -0
  14. data/gems/magellan-publisher/Rakefile +14 -0
  15. data/gems/magellan-publisher/lib/magellan/publisher/version.rb +5 -0
  16. data/gems/magellan-publisher/lib/magellan/publisher.rb +37 -0
  17. data/gems/magellan-publisher/magellan-publisher.gemspec +27 -0
  18. data/gems/magellan-publisher/spec/magellan/publisher_spec.rb +39 -0
  19. data/gems/magellan-publisher/spec/spec_helper.rb +8 -0
  20. data/lib/generators/install_generator.rb +20 -0
  21. data/lib/generators/templates/Magellan.yml +115 -0
  22. data/lib/magellan/extentions/rails/engine.rb +28 -0
  23. data/lib/magellan/extentions/rails.rb +2 -0
  24. data/lib/magellan/extentions.rb +2 -0
  25. data/lib/magellan/rails/executor.rb +35 -0
  26. data/lib/magellan/rails/railtie.rb +12 -0
  27. data/lib/magellan/rails/request.rb +111 -0
  28. data/lib/magellan/rails/response.rb +35 -0
  29. data/lib/magellan/rails/version.rb +5 -0
  30. data/lib/magellan/rails.rb +10 -0
  31. data/lib/magellan/subscriber/base.rb +26 -0
  32. data/lib/magellan/subscriber/executor.rb +21 -0
  33. data/lib/magellan/subscriber/mapper.rb +105 -0
  34. data/lib/magellan/subscriber/request.rb +25 -0
  35. data/lib/magellan/subscriber/rspec.rb +7 -0
  36. data/lib/magellan/subscriber/testing/integration.rb +22 -0
  37. data/lib/magellan/subscriber.rb +10 -0
  38. data/lib/magellan/worker/config.rb +33 -0
  39. data/lib/magellan/worker/core.rb +70 -0
  40. data/lib/magellan/worker/executor.rb +38 -0
  41. data/lib/magellan/worker.rb +14 -0
  42. data/lib/magellan.rb +28 -0
  43. data/magellan-rails.gemspec +28 -0
  44. data/spec/magellan/rails/request_spec.rb +103 -0
  45. data/spec/magellan/rails/response_spec.rb +58 -0
  46. data/spec/magellan/subscriber/mapper_spec.rb +264 -0
  47. data/spec/magellan/worker/core_spec.rb +70 -0
  48. data/spec/spec_helper.rb +4 -0
  49. metadata +167 -0
@@ -0,0 +1,21 @@
1
+ # -*- coding: utf-8 -*-
2
+ require 'magellan/rails'
3
+
4
+ ActiveSupport::Dependencies.autoload_paths << Rails.root.join("app/subscribers").to_s
5
+
6
+ class Magellan::Subscriber::Executor
7
+ def initialize
8
+ @map = Magellan::Subscriber::Mapper.new
9
+ end
10
+
11
+ def execute(request_message)
12
+ request = Magellan::Subscriber::Request.new()
13
+ request.parse_message(request_message)
14
+
15
+ # ロード済み定数をクリア
16
+ # TODO: 更新時刻をチェックする
17
+ ActiveSupport::Dependencies.clear
18
+
19
+ @map.call(request)
20
+ end
21
+ end
@@ -0,0 +1,105 @@
1
+ # -*- coding: utf-8 -*-
2
+ require 'magellan/rails'
3
+
4
+ class Magellan::Subscriber::RoutingError < StandardError
5
+ end
6
+
7
+ class Magellan::Subscriber::Mapper
8
+ def initialize(map_file_path=Rails.root.join("config/subscribes.yml"))
9
+ # テストのため nil で初期化できるようにしています
10
+ if map_file_path
11
+ @config_file = map_file_path.to_s
12
+ @config_file_size = nil
13
+ @config_file_mtime = nil
14
+ load_mapper_file
15
+ end
16
+ end
17
+
18
+ def load_mapper_file
19
+ if @config_file
20
+ st = File.stat(@config_file)
21
+ if st.size != @config_file_size or st.mtime > @config_file_mtime
22
+ @config = YAML.load(File.read(@config_file))
23
+ self.map = @config
24
+ end
25
+ end
26
+ end
27
+
28
+ def topic_regexp(topic)
29
+ # "#" は特別に任意の1文字以上のトピックにマッチするとする(MQTT の仕様上空文字列はトピックとして不正)
30
+ return /.+/ if topic == "#"
31
+
32
+ words = topic.split("/", -1) # 末尾の a/ を ["a", ""] と分離するため第2引数の-1が必要
33
+ # 前処理ととして連続している "#" は冗長なので1つに正規化する
34
+ words.each_cons(2).each_with_index{|(w1, w2), idx| words[idx] = nil if w1 == "#" and w2 == "#" }
35
+ words.compact!
36
+ first, *remain = words
37
+ optional_sep = false
38
+ # 先頭の要素を正規表現文字列にする
39
+ regexp_str = "\\A"
40
+ case first
41
+ when "#"
42
+ # 先頭が "#" の場合 "#/a" は "a" にもマッチするので後続の要素の "/" の正規表現はここで追加する
43
+ optional_sep = true
44
+ # #/〜 の場合 # はなくてもマッチするので、空文字列もしくは任意のword+"/"とする
45
+ regexp_str += "(?:.*/|)"
46
+ when "+"
47
+ regexp_str += "[^/]*"
48
+ else
49
+ regexp_str += Regexp.escape(first)
50
+ end
51
+ remain.each do |w|
52
+ case w
53
+ when "#"
54
+ # "〜/#" の場合 "/" はなくてもマッチする(# はレベル0でもマッチする)ので、空文字列もしくは"/"+任意のwordとする
55
+ regexp_str += "(?:|/.*)"
56
+ when "+"
57
+ regexp_str += "/" unless optional_sep
58
+ regexp_str += "[^/]*"
59
+ else
60
+ regexp_str += "/" unless optional_sep
61
+ regexp_str += Regexp.escape(w)
62
+ end
63
+ optional_sep = false
64
+ end
65
+ regexp_str += "\\z"
66
+ Regexp.compile(regexp_str)
67
+ end
68
+ private :topic_regexp
69
+
70
+ def map=(config)
71
+ @map = config.each_with_object({}) do |(key, value), tbl|
72
+ regexp = topic_regexp(key)
73
+ controller, action = value.split("#", 2)
74
+ controller_name = (controller.classify + "Subscriber")
75
+ tbl[regexp] = [controller_name, action]
76
+ end
77
+ end
78
+
79
+ def map
80
+ if Rails.env.development?
81
+ load_mapper_file
82
+ end
83
+ @map
84
+ end
85
+
86
+ def map_action(topic)
87
+ self.map.select do |re, _|
88
+ re.match(topic)
89
+ end.map do |_, (controller_name, action)|
90
+ [controller_name, action]
91
+ end
92
+ end
93
+
94
+ def call(request)
95
+ actions = map_action(request.topic)
96
+ actions.each do |controller_name, action|
97
+ controller_class = ActiveSupport::Dependencies.constantize(controller_name)
98
+ controller = controller_class.new(request)
99
+ return controller.process_action(action)
100
+ end
101
+ if actions.empty?
102
+ raise Magellan::Subscriber::RoutingError, "topic #{request.topic} has no matched subscriber"
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,25 @@
1
+ # -*- coding: utf-8 -*-
2
+ require 'magellan/rails'
3
+ require 'set'
4
+
5
+ class Magellan::Subscriber::Request
6
+ attr_reader :headers, :options, :topic, :body
7
+
8
+ def option(key)
9
+ options[key]
10
+ end
11
+
12
+ def parse_message(request_message)
13
+ # @options = request_message['options']
14
+ # TRから送られるメッセージのoptionsの型が本来はHash型なのですが、配列型になってしまっているため、暫定的に空のHashをセットします
15
+ @options = {}
16
+ @headers = request_message['headers']
17
+ @topic = request_message["headers"]["Path-Info"]
18
+ case request_message["body_encoding"]
19
+ when /\Aplain\z/i, nil
20
+ @body = request_message["body"]
21
+ when /\Abase64\z/i
22
+ @body = Base64.decode64(request_message['body'])
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,7 @@
1
+ require "magellan/subscriber/testing/integration"
2
+
3
+ require "rspec/rails/example/request_example_group"
4
+
5
+ module RSpec::Rails::RequestExampleGroup
6
+ include Magellan::Subscriber::Testing::Integration
7
+ end
@@ -0,0 +1,22 @@
1
+ # coding: utf-8
2
+
3
+ require "base64"
4
+ require "magellan/subscriber"
5
+
6
+ module Magellan::Subscriber::Testing
7
+ module Integration
8
+ def publish(topic, body="")
9
+ executor = Magellan::Subscriber::Executor.new
10
+ request_message = {
11
+ "headers" => {
12
+ "Method" => "PUBLISH",
13
+ "Path-Info" => topic,
14
+ },
15
+ "body" => Base64.strict_encode64(body),
16
+ "body_encoding" => "base64",
17
+ }
18
+ executor.execute(request_message)
19
+ end
20
+ end
21
+ end
22
+
@@ -0,0 +1,10 @@
1
+ # -*- coding: utf-8 -*-
2
+ require "magellan"
3
+ require "magellan/rails"
4
+
5
+ module Magellan::Subscriber
6
+ autoload :Executor, "magellan/subscriber/executor"
7
+ autoload :Request, "magellan/subscriber/request"
8
+ autoload :Mapper, "magellan/subscriber/mapper"
9
+ autoload :Base, "magellan/subscriber/base"
10
+ end
@@ -0,0 +1,33 @@
1
+ # -*- coding: utf-8 -*-
2
+ require 'magellan/rails'
3
+ require 'yaml'
4
+
5
+ module Magellan::Worker::Config
6
+ class << self
7
+
8
+ def parse_boolean(val, default_value=false)
9
+ case val
10
+ when /\A1\z/, /\Atrue\z/i, /\Ayes\z/i, /\Aon\z/
11
+ true
12
+ when /\A0\z/, /\Afalse\z/i, /\Ano\z/i, /\Aoff\z/
13
+ false
14
+ else
15
+ default_value
16
+ end
17
+ end
18
+
19
+ def load_config()
20
+ config = {}
21
+ config[:host] = ENV['RABBITMQ_PORT_5672_TCP_ADDR']
22
+ config[:port] = ENV['RABBITMQ_PORT_5672_TCP_PORT']
23
+ config[:vhost] = ENV['VHOST']
24
+ config[:rabbitmq_user] = ENV['RABBITMQ_USER']
25
+ config[:rabbitmq_password] = ENV['RABBITMQ_PASS']
26
+ config[:request_queue] = ENV['REQUEST_QUEUE']
27
+ config[:response_exchange] = ENV['RESPONSE_EXCHANGE']
28
+ config[:http_worker] = parse_boolean(ENV['MAGELLAN_HTTP_WORKER'], true)
29
+ config[:subscriber_worker] = parse_boolean(ENV['MAGELLAN_SUBSCRIBER_WORKER'], true)
30
+ config
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,70 @@
1
+ # -*- coding: utf-8 -*-
2
+ require 'magellan/rails'
3
+ require "magellan/worker"
4
+ require "magellan/extentions"
5
+
6
+ require 'json'
7
+ require "bunny"
8
+
9
+ class Magellan::Worker::Core
10
+
11
+ attr_reader :config
12
+
13
+ def initialize()
14
+ # ワーカーの設定ファイルを読み込む
15
+ @config = Magellan::Worker::Config.load_config
16
+ end
17
+
18
+ def initialize!
19
+ # 設定ファイルからRabbitMQへの接続設定を作成
20
+ connection_settings = {
21
+ host: @config[:host],
22
+ port: @config[:port],
23
+ vhost: @config[:vhost],
24
+ user: @config[:rabbitmq_user],
25
+ pass: @config[:rabbitmq_password],
26
+ timeout: 0.3
27
+ }
28
+
29
+ @conn = Bunny.new(connection_settings)
30
+ @conn.start
31
+ @channel = @conn.create_channel
32
+
33
+ queue_name = @config[:request_queue]
34
+ exchange_name = @config[:response_exchange]
35
+
36
+ # no_declareを設定しない場合、キューやエクスチェンジに対してdeclareを実行してしまい、
37
+ # Acess Contorolにひっかかりエラーとなります
38
+ # queueとexchange及びbindはmagellan-conductorによって作成済みの想定です
39
+ @queue = @channel.queue(queue_name, no_declare: true)
40
+ @exchange = @channel.exchange(exchange_name, no_declare: true)
41
+
42
+ @executor = Magellan::Worker::Executor.new(@exchange)
43
+
44
+ self
45
+ end
46
+
47
+ def run
48
+ Magellan.logger.info("====== Magellan Worker start ======")
49
+ @queue.subscribe(block: true, ack: true) do |delivery_info, properties, payload|
50
+ reply_to = properties.reply_to # ワーカー実行結果返却時のルーティングキー
51
+ correlation_id = properties.correlation_id # ワーカー実行結果返却時にどのリクエストに対応するメッセージか判別するための識別子
52
+ delivery_tag = delivery_info.delivery_tag # ackを返却時に使用
53
+
54
+ # ワーカーロジック実行中に落ちたとしてもRabbitMQから再送させないために
55
+ # メッセージを取得した直後にackを返します
56
+ # 第1引数: ackを返す対象のメッセージを指定
57
+ # 第2引数: ackを返す対象以前のメッセージもackを返すか
58
+ # true: 指定したメッセージ以前に受信したメッセージ全てが対象
59
+ # false: 指定したメッセージのみが対象
60
+ @channel.basic_ack(delivery_tag, false)
61
+
62
+ begin
63
+ request_message = JSON.parse(payload)
64
+ @executor.execute(reply_to, correlation_id, delivery_tag, request_message)
65
+ rescue
66
+ Magellan.logger.error("Magellan Worker request execution error: #{$!}\n" + $@.join("\n"))
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,38 @@
1
+ # -*- coding: utf-8 -*-
2
+ require 'magellan/rails'
3
+ require "magellan/subscriber"
4
+ require "bunny"
5
+
6
+ class Magellan::Worker::Executor
7
+ def initialize(exchange)
8
+ @exchange = exchange
9
+ @rails_executor = nil
10
+ @subscriber_executor = nil
11
+
12
+ # Rails 環境の初期化は Subscriber だけの時も実施する
13
+ # Rails.root やら Rails.application.config がないと困ることが多いと思うので
14
+ require File.expand_path('config/environment')
15
+
16
+ # production 環境で subscriber として動作する時は eager loading しておく
17
+ if Rails.env.production? and Magellan::Worker.worker.config[:subscriber_worker]
18
+ subscriber_executor
19
+ end
20
+ end
21
+
22
+ def rails_executor
23
+ @rails_executor ||= Magellan::Rails::Executor.new(@exchange)
24
+ end
25
+
26
+ def subscriber_executor
27
+ @subscriber_executor ||= Magellan::Subscriber::Executor.new
28
+ end
29
+
30
+ def execute(reply_to, correlation_id, delivery_tag, request_message)
31
+ case request_message["headers"]["Method"]
32
+ when /\Apublish\z/i
33
+ subscriber_executor.execute(request_message)
34
+ else
35
+ rails_executor.execute(reply_to, correlation_id, delivery_tag, request_message)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,14 @@
1
+ # -*- coding: utf-8 -*-
2
+ require "magellan"
3
+
4
+ module Magellan::Worker
5
+ autoload :Core, 'magellan/worker/core'
6
+ autoload :Executor, 'magellan/worker/executor'
7
+ autoload :Config, 'magellan/worker/config'
8
+
9
+ class << self
10
+ def worker
11
+ @worker ||= Magellan::Worker::Core.new
12
+ end
13
+ end
14
+ end
data/lib/magellan.rb ADDED
@@ -0,0 +1,28 @@
1
+ # -*- coding: utf-8 -*-
2
+ require 'logger'
3
+ require 'fileutils'
4
+
5
+ module Magellan
6
+ autoload :Rails, 'magellan/rails'
7
+
8
+ class << self
9
+ attr_accessor :logger
10
+
11
+ # loggerの設定
12
+ # ログレベルは環境変数: MAGELLAN_RAILS_LOG_LEVEL に設定して必要に応じて変更してください
13
+ environment = ENV['RAILS_ENV'] || 'development'
14
+ log_dir = File.expand_path("log")
15
+ FileUtils.makedirs(log_dir) unless File.exists?(log_dir)
16
+ logger = ::Logger.new(File.expand_path("log/#{environment}.log"))
17
+
18
+ log_level_env = ENV['MAGELLAN_RAILS_LOG_LEVEL']
19
+ log_levels = ['debug', 'info', 'warn', 'error', 'fatal', 'unknown']
20
+ if log_level_env && log_levels.include?(log_level_env)
21
+ log_level = Logger.const_get(log_level_env.upcase.to_sym)
22
+ else
23
+ log_level = Logger::INFO
24
+ end
25
+ logger.level = log_level
26
+ Magellan.logger = logger
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'magellan/rails/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "magellan-rails"
8
+ spec.version = Magellan::Rails::VERSION
9
+ spec.authors = ["Yuuki Noguchi"]
10
+ spec.email = ["otonakaoru@gmail.com"]
11
+ spec.summary = %q{}
12
+ spec.description = %q{}
13
+ spec.homepage = ""
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.add_runtime_dependency "railties", '~> 4.1.0'
22
+ spec.add_runtime_dependency "bunny"
23
+ spec.add_runtime_dependency "json"
24
+
25
+ spec.add_development_dependency "bundler", "~> 1.6"
26
+ spec.add_development_dependency "rspec"
27
+ # spec.add_development_dependency "bunny_mock"
28
+ end
@@ -0,0 +1,103 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require "spec_helper"
4
+ require 'json'
5
+
6
+ describe Magellan::Rails::Request do
7
+ describe '#parse_message' do
8
+ before(:all) do
9
+ @request_message_base = {
10
+ 'headers' => {
11
+ 'Title' => 'groovenauts-app',
12
+ 'Interface-Version' => '1.0.0',
13
+ 'Title-Runtime-Version'=> '1.0.0',
14
+ 'Reference-Id' => '192.168.1.110',
15
+ 'Url' => 'http://magellan-clouds.com/worker/test',
16
+ 'Oauth-Requester-Id' => 'tarou',
17
+ 'Content-Type' => 'application/json',
18
+ 'Method' => 'GET',
19
+ "Server-Name" => "localhost",
20
+ "Server-Port" => 3000,
21
+ "Path-Info" => "/hello/index",
22
+ "Query-String" => "qs1=abc&qs2=def"
23
+ },
24
+ 'body' => '',
25
+ 'body_encoding' => 'plain',
26
+ 'options' => {
27
+ # 当分実装される予定がなく、空のハッシュが渡されます
28
+ # 'reply_ttl' => 1000
29
+ }
30
+ }
31
+ end
32
+
33
+ context "valid format" do
34
+ context "body_encoding=nil" do
35
+ let(:request){ Magellan::Rails::Request.new }
36
+
37
+ before do
38
+ @request_message = @request_message_base.dup
39
+ @request_message.delete("body_encoding")
40
+ end
41
+
42
+ subject{ request }
43
+
44
+ it do
45
+ request.parse_message(@request_message)
46
+ expect(request.headers).to eq(@request_message['headers'])
47
+ expect(request.body ).to eq(@request_message['body'])
48
+ expect(request.options).to eq(@request_message['options'])
49
+ end
50
+ end
51
+
52
+ context "body_encoding=plain" do
53
+ let(:request){ Magellan::Rails::Request.new }
54
+
55
+ before do
56
+ @request_message = @request_message_base.dup
57
+ end
58
+
59
+ subject{ request }
60
+
61
+ it do
62
+ request.parse_message(@request_message)
63
+ expect(request.headers).to eq(@request_message['headers'])
64
+ expect(request.body ).to eq(@request_message['body'])
65
+ expect(request.options).to eq(@request_message['options'])
66
+ end
67
+ end
68
+
69
+ context "body_encoding=base64" do
70
+ let(:request){ Magellan::Rails::Request.new }
71
+
72
+ before do
73
+ @request_body = "\xff"
74
+ @request_message = @request_message_base.dup
75
+ @request_message['body'] = Base64.strict_encode64(@request_body)
76
+ @request_message['body_encoding'] = 'base64'
77
+ end
78
+
79
+ subject{ request }
80
+
81
+ it do
82
+ request.parse_message(@request_message)
83
+ expect(request.headers).to eq(@request_message['headers'])
84
+ expect(request.body.inspect).to eq(@request_body.inspect)
85
+ expect(request.options).to eq(@request_message['options'])
86
+ end
87
+ end
88
+ end
89
+
90
+ context "invalid format" do
91
+ it 'raise invalid format exception'
92
+ end
93
+ end
94
+
95
+ describe '#option' do
96
+ end
97
+
98
+ describe '#reply_ttl' do
99
+ end
100
+
101
+ describe '#to_rack_env' do
102
+ end
103
+ end