ice-jade 0.1.0 → 0.5.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.
@@ -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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cradle/server'
4
+ require_relative 'cradle/yaml_server'
@@ -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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'poster/client'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module IceJade
4
- VERSION = '0.1.0'
4
+ VERSION = '0.5.0'
5
5
  end
data/lib/ice_jade.rb CHANGED
@@ -9,3 +9,5 @@ require_relative 'ice_jade/response'
9
9
  require_relative 'ice_jade/client_base'
10
10
  require_relative 'ice_jade/quantum/client'
11
11
  require_relative 'ice_jade/quantum/message'
12
+ require_relative 'ice_jade/poster'
13
+ require_relative 'ice_jade/cradle'
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.1.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Frampt
@@ -9,18 +9,29 @@ 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 for IM robot messaging.
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
+ - lib/http_poster.rb
21
27
  - lib/ice_jade.rb
22
28
  - lib/ice_jade/client_base.rb
29
+ - lib/ice_jade/cradle.rb
30
+ - lib/ice_jade/cradle/server.rb
31
+ - lib/ice_jade/cradle/yaml_server.rb
23
32
  - lib/ice_jade/error.rb
33
+ - lib/ice_jade/poster.rb
34
+ - lib/ice_jade/poster/client.rb
24
35
  - lib/ice_jade/quantum/client.rb
25
36
  - lib/ice_jade/quantum/message.rb
26
37
  - lib/ice_jade/response.rb
@@ -45,5 +56,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
45
56
  requirements: []
46
57
  rubygems_version: 4.0.10
47
58
  specification_version: 4
48
- summary: IM webhook & outgoing-callback SDK
59
+ summary: IM webhook SDK & general-purpose HTTP POST client
49
60
  test_files: []