ice-jade 0.1.0 → 0.6.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 +4 -4
- data/README.md +247 -69
- data/bin/cradle +72 -0
- data/bin/cradle-instance +74 -0
- data/config/cradle/poster_test.yml +41 -0
- data/examples/comparison_poster.rb +195 -0
- data/examples/cradle_usage.rb +92 -0
- data/examples/poster_usage.rb +89 -0
- data/examples/quantum_usage.rb +113 -0
- data/lib/http_poster.rb +225 -0
- data/lib/ice_jade/cradle/server.rb +402 -0
- data/lib/ice_jade/cradle/yaml_server.rb +109 -0
- data/lib/ice_jade/cradle.rb +4 -0
- data/lib/ice_jade/poster/client.rb +248 -0
- data/lib/ice_jade/poster.rb +3 -0
- data/lib/ice_jade/version.rb +1 -1
- data/lib/ice_jade.rb +2 -0
- metadata +20 -4
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require_relative 'server'
|
|
5
|
+
|
|
6
|
+
module IceJade
|
|
7
|
+
module Cradle
|
|
8
|
+
# YAML 配置驱动的 Cradle 实例
|
|
9
|
+
#
|
|
10
|
+
# 通过载入 YAML 配置文件,启动定制化的 HTTP 测试服务器。
|
|
11
|
+
# 与内置 Cradle 服务器独立,专注于通过配置快速搭建测试端点。
|
|
12
|
+
#
|
|
13
|
+
# @example 配置启动
|
|
14
|
+
# instance = IceJade::Cradle::YAMLServer.new('config/cradle/poster_test.yml')
|
|
15
|
+
# instance.start
|
|
16
|
+
#
|
|
17
|
+
class YAMLServer
|
|
18
|
+
attr_reader :config, :server
|
|
19
|
+
|
|
20
|
+
# 预定义路由处理器(按 handler 名称引用)
|
|
21
|
+
# 每个 lambda 接收 (request, route_cfg) 两个参数,返回 [status, headers, body]
|
|
22
|
+
HANDLERS = {
|
|
23
|
+
# 回显 JSON 请求体:method / path / content_type / headers / body
|
|
24
|
+
'echo_json' => lambda { |req, _cfg|
|
|
25
|
+
body = {
|
|
26
|
+
method: req.method,
|
|
27
|
+
path: req.path,
|
|
28
|
+
content_type: req.content_type,
|
|
29
|
+
headers: req.headers,
|
|
30
|
+
body: req.parsed_body
|
|
31
|
+
}
|
|
32
|
+
[200, { 'Content-Type' => 'application/json' }, JSON.generate(body)]
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
# 回显 form-urlencoded 数据
|
|
36
|
+
'echo_form' => lambda { |req, _cfg|
|
|
37
|
+
[200, { 'Content-Type' => 'application/json' },
|
|
38
|
+
JSON.generate(form: req.parsed_body)]
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
# 回显 multipart 上传:files(含文件名/大小/类型) + 普通字段
|
|
42
|
+
'echo_upload' => lambda { |req, _cfg|
|
|
43
|
+
files_info = req.files.transform_values do |v|
|
|
44
|
+
{ filename: v[:filename], size: v[:data].bytesize,
|
|
45
|
+
content_type: v[:content_type] }
|
|
46
|
+
end
|
|
47
|
+
[200, { 'Content-Type' => 'application/json' },
|
|
48
|
+
JSON.generate(files: files_info, fields: req.form_data)]
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
# 静态响应:直接返回 YAML 中配置的 status / headers / body
|
|
52
|
+
'static' => lambda { |_req, cfg|
|
|
53
|
+
status = cfg['status'] || 200
|
|
54
|
+
headers = cfg['headers'] || { 'Content-Type' => 'application/json' }
|
|
55
|
+
body = cfg['body'] || '{}'
|
|
56
|
+
[status, headers, body]
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
# 延迟响应:按 URL 参数 :seconds 或 YAML 中 delay 字段等待
|
|
60
|
+
'delay' => lambda { |req, cfg|
|
|
61
|
+
seconds = req.route_params['seconds'] || cfg['delay'] || 1
|
|
62
|
+
sleep(seconds.to_f)
|
|
63
|
+
[200, { 'Content-Type' => 'application/json' },
|
|
64
|
+
JSON.generate(delayed: true)]
|
|
65
|
+
}
|
|
66
|
+
}.freeze
|
|
67
|
+
|
|
68
|
+
# @param config_path [String] YAML 配置文件路径
|
|
69
|
+
def initialize(config_path)
|
|
70
|
+
@config = YAML.load_file(config_path)
|
|
71
|
+
@config['host'] ||= '127.0.0.1'
|
|
72
|
+
@config['port'] ||= 8765
|
|
73
|
+
@server = Cradle::Server.new(
|
|
74
|
+
port: @config['port'],
|
|
75
|
+
host: @config['host'],
|
|
76
|
+
silent: true
|
|
77
|
+
)
|
|
78
|
+
setup_routes
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# 启动服务器(阻塞)
|
|
82
|
+
def start
|
|
83
|
+
@server.start
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# 停止服务器
|
|
87
|
+
def stop
|
|
88
|
+
@server.stop
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def setup_routes
|
|
94
|
+
(@config['routes'] || []).each do |route_cfg|
|
|
95
|
+
method = (route_cfg['method'] || 'ALL').to_s.upcase
|
|
96
|
+
path = route_cfg['path']
|
|
97
|
+
handler_name = route_cfg['handler'] || 'static'
|
|
98
|
+
handler = HANDLERS[handler_name]
|
|
99
|
+
|
|
100
|
+
next unless handler
|
|
101
|
+
|
|
102
|
+
@server.route(method, path) do |req|
|
|
103
|
+
handler.call(req, route_cfg)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
require_relative '../response'
|
|
8
|
+
require_relative '../error'
|
|
9
|
+
|
|
10
|
+
module IceJade
|
|
11
|
+
module Poster
|
|
12
|
+
# 通用 HTTP POST 客户端
|
|
13
|
+
#
|
|
14
|
+
# 零外部依赖,支持 JSON / Form / Multipart 三种常用提交方式,
|
|
15
|
+
# 内置超时控制、指数退避重试、统一 Response 包装,与 Quantum 模块风格一致。
|
|
16
|
+
#
|
|
17
|
+
# @example 基础用法
|
|
18
|
+
# poster = IceJade::Poster::Client.new(
|
|
19
|
+
# base_url: 'https://api.example.com',
|
|
20
|
+
# headers: { 'Authorization' => 'Bearer token' },
|
|
21
|
+
# timeout: 30
|
|
22
|
+
# )
|
|
23
|
+
# resp = poster.post_json('/users', { name: 'Alice' })
|
|
24
|
+
# puts resp.data if resp.success?
|
|
25
|
+
#
|
|
26
|
+
class Client
|
|
27
|
+
attr_reader :base_url, :default_headers, :timeout, :open_timeout, :max_retries
|
|
28
|
+
|
|
29
|
+
# @param base_url [String, nil] 基础 URL,后续请求可传相对路径
|
|
30
|
+
# @param headers [Hash] 默认请求头(每次请求自动合并,同名会被覆盖)
|
|
31
|
+
# @param timeout [Integer] 读取超时(秒),默认 60
|
|
32
|
+
# @param open_timeout [Integer] 连接超时(秒),默认 10
|
|
33
|
+
# @param max_retries [Integer] 超时后最大重试次数,默认 2
|
|
34
|
+
def initialize(base_url: nil, headers: {}, timeout: 60, open_timeout: 10, max_retries: 2)
|
|
35
|
+
@base_url = base_url ? base_url.to_s.chomp('/') : nil
|
|
36
|
+
@default_headers = normalize_headers(headers)
|
|
37
|
+
@timeout = timeout
|
|
38
|
+
@open_timeout = open_timeout
|
|
39
|
+
@max_retries = max_retries
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
# 通用 POST
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
# 发送通用 POST 请求
|
|
47
|
+
#
|
|
48
|
+
# @param url [String] 完整 URL 或相对路径(需先设置 base_url)
|
|
49
|
+
# @param body [String, Hash, nil] 请求体;Hash 会根据 content_type 自动序列化
|
|
50
|
+
# @param headers [Hash] 额外请求头(与默认头合并,同名覆盖)
|
|
51
|
+
# @param content_type [String] Content-Type,默认 application/json
|
|
52
|
+
# @return [IceJade::Response]
|
|
53
|
+
def post(url, body = nil, headers: {}, content_type: 'application/json')
|
|
54
|
+
uri = build_uri(url)
|
|
55
|
+
merged_headers = merge_headers(default_headers, normalize_headers(headers))
|
|
56
|
+
req = build_request(uri, body, content_type, merged_headers)
|
|
57
|
+
execute(uri, req)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
# 快捷方法
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
# POST JSON(API 调用最常用)
|
|
65
|
+
# @param url [String]
|
|
66
|
+
# @param params [Hash] 请求参数,自动转为 JSON
|
|
67
|
+
# @param headers [Hash] 额外请求头
|
|
68
|
+
# @return [IceJade::Response]
|
|
69
|
+
def post_json(url, params = {}, headers = {})
|
|
70
|
+
post(url, params, headers: headers, content_type: 'application/json')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# POST Form(传统表单提交)
|
|
74
|
+
# @param url [String]
|
|
75
|
+
# @param params [Hash] 请求参数,自动转为 URL-encoded
|
|
76
|
+
# @param headers [Hash] 额外请求头
|
|
77
|
+
# @return [IceJade::Response]
|
|
78
|
+
def post_form(url, params = {}, headers = {})
|
|
79
|
+
post(url, params, headers: headers, content_type: 'application/x-www-form-urlencoded')
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# POST Multipart(文件上传)
|
|
83
|
+
#
|
|
84
|
+
# @param url [String]
|
|
85
|
+
# @param params [Hash] 支持文件字段(值为本地文件路径字符串)
|
|
86
|
+
# 示例: { file: '/path/to/file.png', purpose: 'image' }
|
|
87
|
+
# @param headers [Hash] 额外请求头
|
|
88
|
+
# @return [IceJade::Response]
|
|
89
|
+
def post_multipart(url, params = {}, headers = {})
|
|
90
|
+
uri = build_uri(url)
|
|
91
|
+
body, boundary = build_multipart(params)
|
|
92
|
+
merged_headers = merge_headers(default_headers, normalize_headers(headers))
|
|
93
|
+
merged_headers['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
|
|
94
|
+
|
|
95
|
+
req = Net::HTTP::Post.new(uri)
|
|
96
|
+
apply_headers(req, merged_headers)
|
|
97
|
+
req.body = body
|
|
98
|
+
|
|
99
|
+
execute(uri, req)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def build_uri(url)
|
|
105
|
+
if base_url && !url.to_s.start_with?('http')
|
|
106
|
+
URI.parse("#{base_url}#{url}")
|
|
107
|
+
else
|
|
108
|
+
URI.parse(url.to_s)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_request(uri, body, content_type, headers)
|
|
113
|
+
req = Net::HTTP::Post.new(uri)
|
|
114
|
+
apply_headers(req, headers)
|
|
115
|
+
|
|
116
|
+
case content_type.to_s
|
|
117
|
+
when /application\/json/
|
|
118
|
+
req['Content-Type'] = 'application/json'
|
|
119
|
+
req.body = body.is_a?(Hash) ? JSON.generate(body) : body.to_s
|
|
120
|
+
when /application\/x-www-form-urlencoded/
|
|
121
|
+
req['Content-Type'] = 'application/x-www-form-urlencoded'
|
|
122
|
+
req.body = body.is_a?(Hash) ? URI.encode_www_form(body) : body.to_s
|
|
123
|
+
else
|
|
124
|
+
req['Content-Type'] = content_type
|
|
125
|
+
req.body = body.is_a?(String) ? body : body.to_s
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
req
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def apply_headers(req, headers)
|
|
132
|
+
headers.each do |k, v|
|
|
133
|
+
req[k.to_s] = v.to_s
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def normalize_headers(h)
|
|
138
|
+
h.transform_keys(&:to_s).transform_values(&:to_s)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def merge_headers(base, extra)
|
|
142
|
+
base.merge(extra)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def execute(uri, request)
|
|
146
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
147
|
+
http.use_ssl = uri.scheme == 'https'
|
|
148
|
+
http.open_timeout = open_timeout
|
|
149
|
+
http.read_timeout = timeout
|
|
150
|
+
|
|
151
|
+
retries = 0
|
|
152
|
+
begin
|
|
153
|
+
response = http.request(request)
|
|
154
|
+
Response.new(adapt_response(response))
|
|
155
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
156
|
+
retries += 1
|
|
157
|
+
if retries <= max_retries
|
|
158
|
+
sleep(2**retries)
|
|
159
|
+
retry
|
|
160
|
+
end
|
|
161
|
+
Response.new({ 'ok' => false, 'code' => 0,
|
|
162
|
+
'message' => "Timeout after #{max_retries} retries: #{e.message}", 'data' => nil })
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
Response.new({ 'ok' => false, 'code' => 0, 'message' => e.message, 'data' => nil })
|
|
165
|
+
ensure
|
|
166
|
+
http.finish if http.started?
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# 内置 HTTP 状态码文本(兼容各 Ruby 版本)
|
|
171
|
+
HTTP_STATUS_TEXT = {
|
|
172
|
+
100 => 'Continue', 101 => 'Switching Protocols',
|
|
173
|
+
200 => 'OK', 201 => 'Created', 202 => 'Accepted',
|
|
174
|
+
204 => 'No Content',
|
|
175
|
+
301 => 'Moved Permanently', 302 => 'Found', 304 => 'Not Modified',
|
|
176
|
+
400 => 'Bad Request', 401 => 'Unauthorized', 403 => 'Forbidden',
|
|
177
|
+
404 => 'Not Found', 405 => 'Method Not Allowed',
|
|
178
|
+
429 => 'Too Many Requests',
|
|
179
|
+
500 => 'Internal Server Error', 502 => 'Bad Gateway',
|
|
180
|
+
503 => 'Service Unavailable'
|
|
181
|
+
}.freeze
|
|
182
|
+
|
|
183
|
+
def adapt_response(response)
|
|
184
|
+
body = response.body.to_s
|
|
185
|
+
code = response.code.to_i
|
|
186
|
+
|
|
187
|
+
data = begin
|
|
188
|
+
JSON.parse(body)
|
|
189
|
+
rescue JSON::ParserError
|
|
190
|
+
body
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
{
|
|
194
|
+
'ok' => code >= 200 && code < 300,
|
|
195
|
+
'code' => code,
|
|
196
|
+
'message' => HTTP_STATUS_TEXT[code] || 'Unknown',
|
|
197
|
+
'data' => data
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def build_multipart(params)
|
|
202
|
+
boundary = "----IceJadePosterBoundary#{SecureRandom.hex(16)}"
|
|
203
|
+
body = +"".b
|
|
204
|
+
|
|
205
|
+
params.each do |key, value|
|
|
206
|
+
if value.is_a?(String) && File.exist?(value)
|
|
207
|
+
filename = File.basename(value)
|
|
208
|
+
file_data = File.binread(value)
|
|
209
|
+
mime = mime_type(filename)
|
|
210
|
+
body << "--#{boundary}\r\n"
|
|
211
|
+
body << %(Content-Disposition: form-data; name="#{key}"; filename="#{filename}") << "\r\n"
|
|
212
|
+
body << "Content-Type: #{mime}" << "\r\n\r\n"
|
|
213
|
+
body << file_data.b
|
|
214
|
+
body << "\r\n"
|
|
215
|
+
else
|
|
216
|
+
body << "--#{boundary}\r\n"
|
|
217
|
+
body << %(Content-Disposition: form-data; name="#{key}") << "\r\n\r\n"
|
|
218
|
+
body << value.to_s
|
|
219
|
+
body << "\r\n"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
body << "--#{boundary}--\r\n"
|
|
224
|
+
[body, boundary]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def mime_type(filename)
|
|
228
|
+
ext = File.extname(filename).downcase
|
|
229
|
+
case ext
|
|
230
|
+
when '.png' then 'image/png'
|
|
231
|
+
when '.jpg', '.jpeg' then 'image/jpeg'
|
|
232
|
+
when '.gif' then 'image/gif'
|
|
233
|
+
when '.webp' then 'image/webp'
|
|
234
|
+
when '.bmp' then 'image/bmp'
|
|
235
|
+
when '.pdf' then 'application/pdf'
|
|
236
|
+
when '.txt' then 'text/plain'
|
|
237
|
+
when '.json' then 'application/json'
|
|
238
|
+
when '.zip' then 'application/zip'
|
|
239
|
+
when '.doc' then 'application/msword'
|
|
240
|
+
when '.docx' then 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
|
241
|
+
when '.xls' then 'application/vnd.ms-excel'
|
|
242
|
+
when '.xlsx' then 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
243
|
+
else 'application/octet-stream'
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
data/lib/ice_jade/version.rb
CHANGED
data/lib/ice_jade.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ice-jade
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Frampt
|
|
@@ -9,18 +9,34 @@ bindir: bin
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies: []
|
|
12
|
-
description: A lightweight Ruby SDK
|
|
12
|
+
description: A lightweight Ruby SDK featuring Quantum (IM robot messaging) and Poster
|
|
13
|
+
(zero-dependency HTTP POST client with JSON/Form/Multipart support).
|
|
13
14
|
email:
|
|
14
15
|
- 18995691365@189.cn
|
|
15
|
-
executables:
|
|
16
|
+
executables:
|
|
17
|
+
- cradle
|
|
18
|
+
- cradle-instance
|
|
16
19
|
extensions: []
|
|
17
20
|
extra_rdoc_files: []
|
|
18
21
|
files:
|
|
19
22
|
- LICENSE
|
|
20
23
|
- README.md
|
|
24
|
+
- bin/cradle
|
|
25
|
+
- bin/cradle-instance
|
|
26
|
+
- config/cradle/poster_test.yml
|
|
27
|
+
- examples/comparison_poster.rb
|
|
28
|
+
- examples/cradle_usage.rb
|
|
29
|
+
- examples/poster_usage.rb
|
|
30
|
+
- examples/quantum_usage.rb
|
|
31
|
+
- lib/http_poster.rb
|
|
21
32
|
- lib/ice_jade.rb
|
|
22
33
|
- lib/ice_jade/client_base.rb
|
|
34
|
+
- lib/ice_jade/cradle.rb
|
|
35
|
+
- lib/ice_jade/cradle/server.rb
|
|
36
|
+
- lib/ice_jade/cradle/yaml_server.rb
|
|
23
37
|
- lib/ice_jade/error.rb
|
|
38
|
+
- lib/ice_jade/poster.rb
|
|
39
|
+
- lib/ice_jade/poster/client.rb
|
|
24
40
|
- lib/ice_jade/quantum/client.rb
|
|
25
41
|
- lib/ice_jade/quantum/message.rb
|
|
26
42
|
- lib/ice_jade/response.rb
|
|
@@ -45,5 +61,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
45
61
|
requirements: []
|
|
46
62
|
rubygems_version: 4.0.10
|
|
47
63
|
specification_version: 4
|
|
48
|
-
summary: IM webhook &
|
|
64
|
+
summary: IM webhook SDK & general-purpose HTTP POST client
|
|
49
65
|
test_files: []
|