mp_weixin 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.
Files changed (52) hide show
  1. checksums.yaml +15 -0
  2. data/.gitignore +22 -0
  3. data/.rspec +7 -0
  4. data/.travis.yml +20 -0
  5. data/CHANGELOG.md +17 -0
  6. data/Gemfile +5 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +308 -0
  9. data/Rakefile +31 -0
  10. data/lib/config/mp_weixin_error.yml +82 -0
  11. data/lib/mp_weixin.rb +59 -0
  12. data/lib/mp_weixin/access_token.rb +172 -0
  13. data/lib/mp_weixin/client.rb +199 -0
  14. data/lib/mp_weixin/config.rb +36 -0
  15. data/lib/mp_weixin/error.rb +27 -0
  16. data/lib/mp_weixin/interface/base.rb +43 -0
  17. data/lib/mp_weixin/interface/group.rb +92 -0
  18. data/lib/mp_weixin/interface/menu.rb +73 -0
  19. data/lib/mp_weixin/interface/message.rb +38 -0
  20. data/lib/mp_weixin/interface/promotion.rb +48 -0
  21. data/lib/mp_weixin/interface/user.rb +39 -0
  22. data/lib/mp_weixin/models/event.rb +123 -0
  23. data/lib/mp_weixin/models/message.rb +227 -0
  24. data/lib/mp_weixin/models/reply_message.rb +180 -0
  25. data/lib/mp_weixin/response.rb +93 -0
  26. data/lib/mp_weixin/response_rule.rb +46 -0
  27. data/lib/mp_weixin/server.rb +39 -0
  28. data/lib/mp_weixin/server_helper.rb +94 -0
  29. data/lib/mp_weixin/version.rb +3 -0
  30. data/lib/support/active_model.rb +3 -0
  31. data/lib/support/active_model/model.rb +99 -0
  32. data/mp_weixin.gemspec +44 -0
  33. data/spec/client_spec.rb +87 -0
  34. data/spec/mp_weixin/access_token_spec.rb +140 -0
  35. data/spec/mp_weixin/client_spec.rb +111 -0
  36. data/spec/mp_weixin/config_spec.rb +24 -0
  37. data/spec/mp_weixin/interface/base_spec.rb +16 -0
  38. data/spec/mp_weixin/interface/group_spec.rb +133 -0
  39. data/spec/mp_weixin/interface/menu_spec.rb +72 -0
  40. data/spec/mp_weixin/interface/message_spec.rb +36 -0
  41. data/spec/mp_weixin/interface/promotion_spec.rb +48 -0
  42. data/spec/mp_weixin/interface/user_spec.rb +76 -0
  43. data/spec/mp_weixin/models/event_spec.rb +94 -0
  44. data/spec/mp_weixin/models/message_spec.rb +300 -0
  45. data/spec/mp_weixin/models/reply_message_spec.rb +365 -0
  46. data/spec/mp_weixin/server_helper_spec.rb +165 -0
  47. data/spec/mp_weixin/server_spec.rb +56 -0
  48. data/spec/spec_helper.rb +51 -0
  49. data/spec/support/mp_weixin.rb +7 -0
  50. data/spec/support/rspec_mixin.rb +8 -0
  51. data/spec/support/weixin.yml +12 -0
  52. metadata +363 -0
@@ -0,0 +1,82 @@
1
+ mp_weixin_errors:
2
+ '-1': 系统繁忙
3
+ '0': 请求成功
4
+ '40001': 获取access_token时AppSecret错误,或者access_token无效
5
+ '40002': 不合法的凭证类型
6
+ '40003': 不合法的OpenID
7
+ '40004': 不合法的媒体文件类型
8
+ '40005': 不合法的文件类型
9
+ '40006': 不合法的文件大小
10
+ '40007': 不合法的媒体文件id
11
+ '40008': 不合法的消息类型
12
+ '40009': 不合法的图片文件大小
13
+ '40010': 不合法的语音文件大小
14
+ '40011': 不合法的视频文件大小
15
+ '40012': 不合法的缩略图文件大小
16
+ '40013': 不合法的APPID
17
+ '40014': 不合法的access_token
18
+ '40015': 不合法的菜单类型
19
+ '40016': 不合法的按钮个数
20
+ '40017': 不合法的按钮个数
21
+ '40018': 不合法的按钮名字长度
22
+ '40019': 不合法的按钮KEY长度
23
+ '40020': 不合法的按钮URL长度
24
+ '40021': 不合法的菜单版本号
25
+ '40022': 不合法的子菜单级数
26
+ '40023': 不合法的子菜单按钮个数
27
+ '40024': 不合法的子菜单按钮类型
28
+ '40025': 不合法的子菜单按钮名字长度
29
+ '40026': 不合法的子菜单按钮KEY长度
30
+ '40027': 不合法的子菜单按钮URL长度
31
+ '40028': 不合法的自定义菜单使用用户
32
+ '40029': 不合法的oauth_code
33
+ '40030': 不合法的refresh_token
34
+ '40031': 不合法的openid列表
35
+ '40032': 不合法的openid列表长度
36
+ '40033': 不合法的请求字符,不能包含\uxxxx格式的字符
37
+ '40035': 不合法的参数
38
+ '40038': 不合法的请求格式
39
+ '40039': 不合法的URL长度
40
+ '40050': 不合法的分组id
41
+ '40051': 分组名字不合法
42
+ '41001': 缺少access_token参数
43
+ '41002': 缺少appid参数
44
+ '41003': 缺少refresh_token参数
45
+ '41004': 缺少secret参数
46
+ '41005': 缺少多媒体文件数据
47
+ '41006': 缺少media_id参数
48
+ '41007': 缺少子菜单数据
49
+ '41009': 缺少openid
50
+ '42001': access_token超时
51
+ '42002': refresh_token超时
52
+ '42003': oauth_code超时
53
+ '43001': 需要GET请求
54
+ '43002': 需要POST请求
55
+ '43003': 需要HTTPS请求
56
+ '43004': 需要接收者关注
57
+ '43005': 需要好友关系
58
+ '44001': 多媒体文件为空
59
+ '44002': POST的数据包为空
60
+ '44003': 图文消息内容为空
61
+ '44004': 文本消息内容为空
62
+ '45001': 多媒体文件大小超过限制
63
+ '45002': 消息内容超过限制
64
+ '45003': 标题字段超过限制
65
+ '45004': 描述字段超过限制
66
+ '45005': 链接字段超过限制
67
+ '45006': 图片链接字段超过限制
68
+ '45007': 语音播放时间超过限制
69
+ '45008': 图文消息超过限制
70
+ '45009': 接口调用超过限制
71
+ '45010': 创建菜单个数超过限制
72
+ '45015': 回复时间超过限制
73
+ '45016': 系统分组,不允许修改
74
+ '45017': 分组名字过长
75
+ '45018': 分组数量超过上限
76
+ '46001': 不存在媒体数据
77
+ '46002': 不存在的菜单版本
78
+ '46003': 不存在的菜单数据
79
+ '46004': 不存在的用户
80
+ '47001': 解析JSON/XML内容错误
81
+ '48001': api功能未授权
82
+ '50001': 用户未授权该api
data/lib/mp_weixin.rb ADDED
@@ -0,0 +1,59 @@
1
+ # encoding: utf-8
2
+
3
+ require 'rubygems'
4
+ require 'bundler/setup'
5
+
6
+ if defined?(Bundler)
7
+ Bundler.require
8
+ end
9
+
10
+ require 'roxml'
11
+ require 'multi_xml'
12
+ require 'ostruct'
13
+
14
+ require 'faraday'
15
+ require 'active_model'
16
+ require 'active_support/all'
17
+
18
+ require 'support/active_model'
19
+
20
+ require "mp_weixin/version"
21
+ require "mp_weixin/config"
22
+ require "mp_weixin/error"
23
+
24
+ # require models
25
+
26
+ # require client
27
+
28
+ ## require client dependence
29
+ require "mp_weixin/response"
30
+ require "mp_weixin/access_token"
31
+
32
+ ## require interface
33
+ require "mp_weixin/interface/base"
34
+ require "mp_weixin/interface/message"
35
+ require "mp_weixin/interface/menu"
36
+ require "mp_weixin/interface/promotion"
37
+ require "mp_weixin/interface/group"
38
+ require "mp_weixin/interface/user"
39
+
40
+ require "mp_weixin/client"
41
+
42
+ # require server
43
+ require 'sinatra'
44
+ require 'digest/md5'
45
+ require 'rexml/document'
46
+
47
+ # include base class Message
48
+ # and some children class TextMessage, ImageMessage, LocationMessage,
49
+ # LinkMessage, VoiceMessage, VideoMessage
50
+ require 'mp_weixin/models/message'
51
+ require 'mp_weixin/models/event'
52
+ require 'mp_weixin/models/reply_message'
53
+ # require 'mp_weixin/models/location_message'
54
+
55
+ ## require helpers
56
+ require 'mp_weixin/server_helper'
57
+ require 'mp_weixin/response_rule'
58
+
59
+ require 'mp_weixin/server'
@@ -0,0 +1,172 @@
1
+ module MpWeixin
2
+ class AccessToken
3
+ attr_reader :client, :token, :expires_in, :expires_at, :params
4
+ attr_accessor :options, :refresh_token
5
+
6
+ class << self
7
+ # Initializes an AccessToken from a Hash
8
+ #
9
+ # @param [Client] the MpWeixin::Client instance
10
+ # @param [Hash] a hash of AccessToken property values
11
+ # @return [AccessToken] the initalized AccessToken
12
+ def from_hash(client, hash)
13
+ self.new(client, hash.delete('access_token') || hash.delete(:access_token), hash)
14
+ end
15
+
16
+ # Initializes an AccessToken from a key/value application/x-www-form-urlencoded string
17
+ #
18
+ # @param [Client] client the MpWeixin::Client instance
19
+ # @param [String] kvform the application/x-www-form-urlencoded string
20
+ # @return [AccessToken] the initalized AccessToken
21
+ def from_kvform(client, kvform)
22
+ from_hash(client, Rack::Utils.parse_query(kvform))
23
+ end
24
+ end
25
+
26
+ # Initalize an AccessToken
27
+ #
28
+ # @param [Client] client the MpWeixin::Client instance
29
+ # @param [String] token the Access Token value
30
+ # @param [Hash] opts the options to create the Access Token with
31
+ # @option opts [String] :refresh_token (nil) the refresh_token value
32
+ # @option opts [FixNum, String] :expires_in (nil) the number of seconds in which the AccessToken will expire
33
+ # @option opts [FixNum, String] :expires_at (nil) the epoch time in seconds in which AccessToken will expire
34
+ # @option opts [Symbol] :mode (:header) the transmission mode of the Access Token parameter value
35
+ # one of :header, :body or :query
36
+ # @option opts [String] :header_format ('Bearer %s') the string format to use for the Authorization header
37
+ # @option opts [String] :param_name ('access_token') the parameter name to use for transmission of the
38
+ # Access Token value in :body or :query transmission mode
39
+ def initialize(client, token, opts={})
40
+ @client = client
41
+ @token = token.to_s
42
+ [:refresh_token, :expires_in, :expires_at].each do |arg|
43
+ instance_variable_set("@#{arg}", opts.delete(arg) || opts.delete(arg.to_s))
44
+ end
45
+ @expires_in ||= opts.delete('expires')
46
+ @expires_in &&= @expires_in.to_i
47
+ @expires_at &&= @expires_at.to_i
48
+ @expires_at ||= Time.now.to_i + @expires_in if @expires_in
49
+ @options = {:mode => opts.delete(:mode) || :header,
50
+ :header_format => opts.delete(:header_format) || 'Bearer %s',
51
+ :param_name => opts.delete(:param_name) || 'access_token'}
52
+ @params = opts
53
+ end
54
+
55
+ # Indexer to additional params present in token response
56
+ #
57
+ # @param [String] key entry key to Hash
58
+ def [](key)
59
+ @params[key]
60
+ end
61
+
62
+ # Whether or not the token expires
63
+ #
64
+ # @return [Boolean]
65
+ def expires?
66
+ !!@expires_at
67
+ end
68
+
69
+ # Whether or not the token is expired
70
+ #
71
+ # @return [Boolean]
72
+ def expired?
73
+ expires? && (expires_at < Time.now.to_i)
74
+ end
75
+
76
+ # Refreshes the current Access Token
77
+ #
78
+ # @return [AccessToken] a new AccessToken
79
+ # @note options should be carried over to the new AccessToken
80
+ def refresh!(params={})
81
+ raise "A refresh_token is not available" unless refresh_token
82
+ params.merge!(:client_id => @client.id,
83
+ :client_secret => @client.secret,
84
+ :grant_type => 'client_credential'
85
+ )
86
+ new_token = @client.get_token(params)
87
+ new_token.options = options
88
+ new_token.refresh_token = refresh_token unless new_token.refresh_token
89
+ new_token
90
+ end
91
+
92
+ # Convert AccessToken to a hash which can be used to rebuild itself with AccessToken.from_hash
93
+ #
94
+ # @return [Hash] a hash of AccessToken property values
95
+ def to_hash
96
+ params.merge({:access_token => token, :refresh_token => refresh_token, :expires_at => expires_at})
97
+ end
98
+
99
+ # Make a request with the Access Token
100
+ #
101
+ # @param [Symbol] verb the HTTP request method
102
+ # @param [String] path the HTTP URL path of the request
103
+ # @param [Hash] opts the options to make the request with
104
+ # @see Client#request
105
+ def request(verb, path, opts={}, &block)
106
+ set_token(opts)
107
+ @client.request(verb, path, opts, &block)
108
+ end
109
+
110
+ # Make a GET request with the Access Token
111
+ #
112
+ # @see AccessToken#request
113
+ def get(path, opts={}, &block)
114
+ request(:get, path, opts, &block)
115
+ end
116
+
117
+ # Make a POST request with the Access Token
118
+ #
119
+ # @see AccessToken#request
120
+ def post(path, opts={}, &block)
121
+ request(:post, path, opts, &block)
122
+ end
123
+
124
+ # Make a PUT request with the Access Token
125
+ #
126
+ # @see AccessToken#request
127
+ def put(path, opts={}, &block)
128
+ request(:put, path, opts, &block)
129
+ end
130
+
131
+ # Make a PATCH request with the Access Token
132
+ #
133
+ # @see AccessToken#request
134
+ def patch(path, opts={}, &block)
135
+ request(:patch, path, opts, &block)
136
+ end
137
+
138
+ # Make a DELETE request with the Access Token
139
+ #
140
+ # @see AccessToken#request
141
+ def delete(path, opts={}, &block)
142
+ request(:delete, path, opts, &block)
143
+ end
144
+
145
+ # Get the headers hash (includes Authorization token)
146
+ def headers
147
+ { 'Authorization' => options[:header_format] % token }
148
+ end
149
+
150
+ private
151
+ def set_token(opts)
152
+ case options[:mode]
153
+ when :header
154
+ opts[:headers] ||= {}
155
+ opts[:headers].merge!(headers)
156
+ when :query
157
+ opts[:params] ||= {}
158
+ opts[:params][options[:param_name]] = token
159
+ when :body
160
+ opts[:body] ||= {}
161
+ if opts[:body].is_a?(Hash)
162
+ opts[:body][options[:param_name]] = token
163
+ else
164
+ opts[:body] << "&#{options[:param_name]}=#{token}"
165
+ end
166
+ # @todo support for multi-part (file uploads)
167
+ else
168
+ raise "invalid :mode option of #{options[:mode]}"
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,199 @@
1
+ # encoding: utf-8
2
+ module MpWeixin
3
+ # The MpWeixin::Client class
4
+ # reference to The OAuth2::Client class
5
+ class Client
6
+ attr_reader :id, :secret, :site
7
+ attr_accessor :options, :token
8
+ attr_writer :connection
9
+
10
+ # Instantiate a new client using the
11
+ # Client ID and Client Secret registered to your
12
+ # weixin mp account.
13
+ #
14
+ # @param [String] app_id the app_id value
15
+ # @param [String] app_secret the app_secret value
16
+ # @param [Hash] opts the options to create the client with
17
+ # @option opts [String] :site the site host provider to connection
18
+ # @option opts [String] :authorize_url ('/oauth/authorize') absolute or relative URL path to the Authorization endpoint
19
+ # @option opts [String] :token_url ('/oauth/token') absolute or relative URL path to the Token endpoint
20
+ # @option opts [Hash] :connection_opts ({}) Hash of connection options to pass to initialize Faraday with
21
+ # @option opts [Boolean] :raise_errors (true) whether or not to raise an MpWeixin::Error
22
+ # on responses with 400+ status codes
23
+ # @yield [builder] The Faraday connection builder
24
+ def initialize(app_id = nil, app_secret = nil, opts={}, &block)
25
+ _opts = opts.dup
26
+ @id = app_id || Config.app_id
27
+ @secret = app_secret || Config.app_secret
28
+ @site = _opts.delete(:site) || "https://api.weixin.qq.com/"
29
+ ssl = _opts.delete(:ssl)
30
+ @options = {:authorize_url => '/oauth/authorize',
31
+ :token_url => '/cgi-bin/token',
32
+ :token_method => :post,
33
+ :connection_opts => {},
34
+ :connection_build => block,
35
+ :raise_errors => true}.merge(_opts)
36
+ @options[:connection_opts][:ssl] = ssl if ssl
37
+ end
38
+
39
+ # Whether or not the client is authorized
40
+ #
41
+ # @return [Boolean]
42
+ def is_authorized?
43
+ !!token && !token.expired?
44
+ end
45
+
46
+ # Set the site host
47
+ #
48
+ # @param [String] the MpWeixin provider site host
49
+ def site=(value)
50
+ @connection = nil
51
+ @site = value
52
+ end
53
+
54
+ # The Faraday connection object
55
+ def connection
56
+ @connection ||= begin
57
+ conn = Faraday.new(site, options[:connection_opts])
58
+ conn.build do |b|
59
+ options[:connection_build].call(b)
60
+ end if options[:connection_build]
61
+ conn
62
+ end
63
+ end
64
+
65
+ # Makes a request relative to the specified site root.
66
+ #
67
+ # @param [Symbol] verb one of :get, :post, :put, :delete
68
+ # @param [String] url URL path of request
69
+ # @param [Hash] opts the options to make the request with
70
+ # @option opts [Hash] :params additional query parameters for the URL of the request
71
+ # @option opts [Hash, String] :body the body of the request
72
+ # @option opts [Hash] :headers http request headers
73
+ # @option opts [Boolean] :raise_errors whether or not to raise an MpWeixin::Error on 400+ status
74
+ def request(verb, url, opts={})
75
+ url = self.connection.build_url(url, opts[:params]).to_s
76
+
77
+ response = connection.run_request(verb, url, opts[:body], opts[:headers]) do |req|
78
+ yield(req) if block_given?
79
+ end
80
+ response = Response.new(response, :parse => opts[:parse])
81
+
82
+ case response.status
83
+ when 301, 302, 303, 307
84
+ opts[:redirect_count] ||= 0
85
+ opts[:redirect_count] += 1
86
+ return response if opts[:redirect_count] > options[:max_redirects]
87
+ if response.status == 303
88
+ verb = :get
89
+ opts.delete(:body)
90
+ end
91
+ request(verb, response.headers['location'], opts)
92
+ when 200..299, 300..399
93
+ # on non-redirecting 3xx statuses, just return the response
94
+ response
95
+ when 400..599
96
+ e = Error.new(response)
97
+ raise e if opts.fetch(:raise_errors, options[:raise_errors])
98
+ response.error = e
99
+ response
100
+ else
101
+ raise Error.new(response), "Unhandled status code value of #{response.status}"
102
+ end
103
+ end
104
+
105
+ # The authorize endpoint URL of the MpWeixin provider
106
+ #
107
+ # @param [Hash] params additional query parameters
108
+ def authorize_url(params=nil)
109
+ connection.build_url(options[:authorize_url], params).to_s
110
+ end
111
+
112
+ # The token endpoint URL of the MpWeixin provider
113
+ #
114
+ # @param [Hash] params additional query parameters
115
+ def token_url(params=nil)
116
+ connection.build_url(options[:token_url], params).to_s
117
+ end
118
+
119
+ # Initializes an AccessToken by making a request to the token endpoint
120
+ #
121
+ # @param [Hash] params a Hash of params for the token endpoint
122
+ # @param [Hash] access token options, to pass to the AccessToken object
123
+ # @param [Class] class of access token for easier subclassing MpWeixin::AccessToken
124
+ # @return [AccessToken] the initalized AccessToken
125
+ def get_token(params = {}, access_token_opts = {}, access_token_class = AccessToken)
126
+ params = ActiveSupport::HashWithIndifferentAccess.new(params)
127
+ params.reverse_merge!(grant_type: "client_credential", appid: id, secret: secret)
128
+
129
+ opts = {:raise_errors => options[:raise_errors], :parse => params.delete(:parse)}
130
+ if options[:token_method] == :post
131
+ headers = params.delete(:headers)
132
+ opts[:body] = params
133
+ opts[:headers] = {'Content-Type' => 'application/x-www-form-urlencoded'}
134
+ opts[:headers].merge!(headers) if headers
135
+ else
136
+ opts[:params] = params
137
+ end
138
+ response = request(options[:token_method], token_url, opts)
139
+ raise Error.new(response) if options[:raise_errors] && !(response.parsed.is_a?(Hash) && response.parsed['access_token'])
140
+ @token = access_token_class.from_hash(self, response.parsed.merge(access_token_opts))
141
+ end
142
+
143
+ # Initializes an AccessToken from a hash
144
+ #
145
+ # @param [Hash] hash a Hash contains access_token and expires
146
+ # @return [AccessToken] the initalized AccessToken
147
+ def get_token_from_hash(hash)
148
+ access_token = hash.delete('access_token') || hash.delete(:access_token) || hash.delete('oauth_token') || hash.delete(:oauth_token)
149
+ opts = {:expires_at => hash["expires"] || hash[:expires],
150
+ :header_format => "OAuth2 %s",
151
+ :param_name => "access_token"}
152
+
153
+ @token = AccessToken.new(self, access_token, opts)
154
+ end
155
+
156
+ # Initializes a new Client from a hash
157
+ #
158
+ # @param [Hash] a Hash contains access_token and expires
159
+ # @param [Hash] opts the options to create the client with
160
+ # @option opts [Hash] :connection_opts ({}) Hash of connection options to pass to initialize Faraday with
161
+ # @option opts [FixNum] :max_redirects (5) maximum number of redirects to follow
162
+ # @yield [builder] The Faraday connection builder
163
+ def self.from_hash(hash, opts={}, &block)
164
+ client = self.new(opts, &block)
165
+ client.get_token_from_hash(hash)
166
+
167
+ client
168
+ end
169
+
170
+ #
171
+ # APIs
172
+ #
173
+
174
+ # assocation an Interface::Message instance to client
175
+ def message
176
+ @message ||= Interface::Message.new(self)
177
+ end
178
+
179
+ # assocation an Interface::Menu instance to client
180
+ def menu
181
+ @menu ||= Interface::Menu.new(self)
182
+ end
183
+
184
+ # assocation an Interface::Promotion instance to client
185
+ def promotion
186
+ @promotion ||= Interface::Promotion.new(self)
187
+ end
188
+
189
+ # assocation an Interface::Group instance to client
190
+ def group
191
+ @group ||= Interface::Group.new(self)
192
+ end
193
+
194
+ # assocation an Interface::User instance to client
195
+ def user
196
+ @user ||= Interface::User.new(self)
197
+ end
198
+ end
199
+ end