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,225 @@
1
+ # http_poster.rb
2
+ # Ruby 纯标准库 HTTP POST 工具模块
3
+ # 零外部依赖,支持 JSON / Form / Multipart
4
+ #
5
+ # =============================================================================
6
+ # 说明:本模块是独立工具脚本,不依赖 ice-jade gem。
7
+ #
8
+ # 如果你正在使用 ice-jade 项目,推荐优先使用 IceJade::Poster::Client
9
+ # (位于 lib/ice_jade/poster/client.rb),原因:
10
+ #
11
+ # - 面向对象:初始化时配置 base_url/headers/timeout,后续复用
12
+ # - 统一响应:返回 IceJade::Response(ok, code, message, data, raw)
13
+ # - 异常友好:超时/HTTP 错误均包装为 Response,不抛异常
14
+ # - 与 Quantum 分支共享同一套响应体系
15
+ #
16
+ # HttpPoster 适合:
17
+ # - 不想引入任何 gem 依赖的独立脚本
18
+ # - 快速测试、一次性请求
19
+ # - 学习/教学用途,代码更扁平直观
20
+ #
21
+ # 对比示例见:examples/comparison_poster.rb
22
+ # =============================================================================
23
+
24
+ require 'net/http'
25
+ require 'json'
26
+ require 'uri'
27
+ require 'securerandom'
28
+
29
+ module HttpPoster
30
+ class Error < StandardError; end
31
+ class TimeoutError < Error; end
32
+ class HttpError < Error; end
33
+
34
+ # ============ 核心 POST 方法 ============
35
+ #
36
+ # @param url [String] 完整 URL 或路径
37
+ # @param params [Hash] 提交参数
38
+ # @param headers [Hash] 自定义请求头(自动覆盖默认头)
39
+ # @param opts [Hash] 选项: :timeout, :open_timeout, :max_retries
40
+ #
41
+ # @return [Hash/String] 解析后的 JSON 或原始响应体
42
+ #
43
+ def self.post(url, params = {}, headers = {}, opts = {})
44
+ uri = URI.parse(url)
45
+ content_type = headers['Content-Type'] || headers[:content_type] || 'application/json'
46
+
47
+ req = Net::HTTP::Post.new(uri)
48
+ req['User-Agent'] = headers['User-Agent'] || 'ruby-http-poster/1.0'
49
+ req['Accept'] = headers['Accept'] || 'application/json'
50
+
51
+ # 认证头(支持 Bearer Token)
52
+ if headers['Authorization'] || headers[:authorization]
53
+ req['Authorization'] = headers['Authorization'] || headers[:authorization]
54
+ end
55
+
56
+ # 根据 Content-Type 序列化参数
57
+ case content_type.to_s
58
+ when /application\/json/
59
+ req['Content-Type'] = 'application/json'
60
+ req.body = params.to_json
61
+ when /application\/x-www-form-urlencoded/
62
+ req['Content-Type'] = 'application/x-www-form-urlencoded'
63
+ req.body = URI.encode_www_form(params)
64
+ when /multipart\/form-data/
65
+ body, boundary = build_multipart(params)
66
+ req['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
67
+ req.body = body
68
+ else
69
+ req['Content-Type'] = content_type
70
+ req.body = params.is_a?(String) ? params : params.to_json
71
+ end
72
+
73
+ # 追加其余自定义头
74
+ headers.each do |k, v|
75
+ next if %w[Content-Type Authorization User-Agent Accept].include?(k.to_s)
76
+ req[k.to_s] = v.to_s
77
+ end
78
+
79
+ execute(uri, req, opts)
80
+ end
81
+
82
+ # ============ 快捷方法 ============
83
+
84
+ # POST JSON(API 调用最常用)
85
+ def self.post_json(url, params = {}, headers = {}, opts = {})
86
+ headers = headers.merge('Content-Type' => 'application/json')
87
+ post(url, params, headers, opts)
88
+ end
89
+
90
+ # POST Form(传统表单提交)
91
+ def self.post_form(url, params = {}, headers = {}, opts = {})
92
+ headers = headers.merge('Content-Type' => 'application/x-www-form-urlencoded')
93
+ post(url, params, headers, opts)
94
+ end
95
+
96
+ # POST Multipart(文件上传)
97
+ # params 示例: { file: '/path/to/file.png', purpose: 'image' }
98
+ def self.post_multipart(url, params = {}, headers = {}, opts = {})
99
+ headers = headers.merge('Content-Type' => 'multipart/form-data')
100
+ post(url, params, headers, opts)
101
+ end
102
+
103
+ private
104
+
105
+ def self.execute(uri, request, opts = {})
106
+ timeout = opts[:timeout] || 60
107
+ open_timeout = opts[:open_timeout] || 10
108
+ max_retries = opts[:max_retries] || 2
109
+
110
+ http = Net::HTTP.new(uri.host, uri.port)
111
+ http.use_ssl = uri.scheme == 'https'
112
+ http.open_timeout = open_timeout
113
+ http.read_timeout = timeout
114
+
115
+ retries = 0
116
+ begin
117
+ response = http.request(request)
118
+ parse(response)
119
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
120
+ retries += 1
121
+ if retries <= max_retries
122
+ sleep(2 ** retries)
123
+ retry
124
+ end
125
+ raise TimeoutError, "请求超时(已重试 #{max_retries} 次): #{e.message}"
126
+ ensure
127
+ http.finish if http.started?
128
+ end
129
+ end
130
+
131
+ def self.parse(response)
132
+ body = response.body.to_s
133
+ code = response.code.to_i
134
+
135
+ if code >= 400
136
+ begin
137
+ err = JSON.parse(body)
138
+ msg = err.dig('error', 'message') || body
139
+ rescue
140
+ msg = body
141
+ end
142
+ raise HttpError, "HTTP #{code}: #{msg}"
143
+ end
144
+
145
+ JSON.parse(body) rescue body
146
+ end
147
+
148
+ def self.build_multipart(params)
149
+ boundary = "----RubyFormBoundary#{SecureRandom.hex(16)}"
150
+ parts = []
151
+
152
+ params.each do |key, value|
153
+ if value.is_a?(String) && File.exist?(value)
154
+ # 文件字段
155
+ filename = File.basename(value)
156
+ data = File.binread(value)
157
+ mime = mime_type(filename)
158
+ parts << "--#{boundary}\r\n" \
159
+ "Content-Disposition: form-data; name=\"#{key}\"; filename=\"#{filename}\"\r\n" \
160
+ "Content-Type: #{mime}\r\n\r\n" \
161
+ "#{data}\r\n"
162
+ else
163
+ # 普通字段
164
+ parts << "--#{boundary}\r\n" \
165
+ "Content-Disposition: form-data; name=\"#{key}\"\r\n\r\n" \
166
+ "#{value}\r\n"
167
+ end
168
+ end
169
+
170
+ body = parts.join + "--#{boundary}--\r\n"
171
+ [body, boundary]
172
+ end
173
+
174
+ def self.mime_type(filename)
175
+ ext = File.extname(filename).downcase
176
+ case ext
177
+ when '.png' then 'image/png'
178
+ when '.jpg', '.jpeg' then 'image/jpeg'
179
+ when '.gif' then 'image/gif'
180
+ when '.webp' then 'image/webp'
181
+ when '.pdf' then 'application/pdf'
182
+ when '.txt' then 'text/plain'
183
+ when '.json' then 'application/json'
184
+ else 'application/octet-stream'
185
+ end
186
+ end
187
+ end
188
+
189
+ # ============ 使用示例 ============
190
+ if __FILE__ == $0
191
+ # 示例 1: POST JSON(调用 Kimi Code API)
192
+ begin
193
+ res = HttpPoster.post_json(
194
+ 'https://api.kimi.com/coding/v1/chat/completions',
195
+ {
196
+ model: 'kimi-for-coding',
197
+ messages: [
198
+ { role: 'user', content: '用一句话解释BGP Flowspec' }
199
+ ],
200
+ temperature: 0.3
201
+ },
202
+ {
203
+ 'Authorization' => 'Bearer sk-kimi-your-key-here',
204
+ 'User-Agent' => 'my-bot/1.0'
205
+ },
206
+ timeout: 120
207
+ )
208
+ puts res.dig('choices', 0, 'message', 'content')
209
+ rescue HttpPoster::HttpError => e
210
+ puts "请求失败: #{e.message}"
211
+ end
212
+
213
+ # 示例 2: POST Form(传统表单)
214
+ # res = HttpPoster.post_form(
215
+ # 'https://httpbin.org/post',
216
+ # { username: 'admin', password: 'secret' }
217
+ # )
218
+
219
+ # 示例 3: POST Multipart(上传文件)
220
+ # res = HttpPoster.post_multipart(
221
+ # 'https://api.kimi.com/coding/v1/files',
222
+ # { file: './screenshot.png', purpose: 'image' },
223
+ # { 'Authorization' => 'Bearer sk-kimi-xxx' }
224
+ # )
225
+ end
@@ -0,0 +1,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module IceJade
8
+ module Cradle
9
+ # 简易 HTTP 测试服务器
10
+ #
11
+ # 基于 Ruby 标准库 socket + thread,零外部依赖。
12
+ # 内置常用测试路由,支持 JSON / Form / Multipart,
13
+ # 用于本地测试 ice-jade 各分支(Quantum / Poster)的 HTTP 调用。
14
+ #
15
+ # @example 启动服务器
16
+ # server = IceJade::Cradle::Server.new(port: 8765)
17
+ # server.start
18
+ #
19
+ class Server
20
+ attr_reader :port, :host, :routes
21
+
22
+ DEFAULT_PORT = 8765
23
+ DEFAULT_HOST = '0.0.0.0'
24
+
25
+ # @param port [Integer] 监听端口
26
+ # @param host [String] 监听地址
27
+ # @param silent [Boolean] 是否静默输出(测试用)
28
+ def initialize(port: DEFAULT_PORT, host: DEFAULT_HOST, silent: false)
29
+ @port = port
30
+ @host = host
31
+ @silent = silent
32
+ @routes = []
33
+ @server = nil
34
+ @running = false
35
+ setup_default_routes
36
+ end
37
+
38
+ # 注册路由
39
+ #
40
+ # @param method [String, Symbol, :all] HTTP 方法,:all 表示匹配所有
41
+ # @param path [String] 路径,支持 `:param` 占位符
42
+ # @yieldparam req [HTTPRequest]
43
+ # @yieldreturn [Array(Integer, Hash, String)] [status, headers, body]
44
+ def route(method, path, &block)
45
+ @routes << Route.new(method.to_s.upcase, path, block)
46
+ end
47
+
48
+ # 启动服务器(阻塞)
49
+ def start
50
+ @server = TCPServer.new(@host, @port)
51
+ @running = true
52
+ log "Cradle HTTP Server listening on http://#{@host}:#{@port}"
53
+ log "Press Ctrl+C to stop"
54
+
55
+ while @running
56
+ begin
57
+ socket = @server.accept
58
+ Thread.new { handle_connection(socket) }
59
+ rescue IOError
60
+ break unless @running
61
+ end
62
+ end
63
+ end
64
+
65
+ # 停止服务器
66
+ def stop
67
+ @running = false
68
+ @server&.close
69
+ log "Server stopped."
70
+ end
71
+
72
+ private
73
+
74
+ def handle_connection(socket)
75
+ req = HTTPRequest.parse(socket)
76
+ return socket.close unless req
77
+
78
+ matched = match_route(req)
79
+ status, headers, body = if matched
80
+ begin
81
+ matched.call(req)
82
+ rescue StandardError => e
83
+ [500, { 'Content-Type' => 'application/json' },
84
+ json_body(error: e.class.name, message: e.message)]
85
+ end
86
+ else
87
+ [404, { 'Content-Type' => 'application/json' },
88
+ json_body(error: 'Not Found', path: req.path, method: req.method)]
89
+ end
90
+
91
+ send_response(socket, status, headers, body)
92
+ socket.close
93
+ rescue StandardError => e
94
+ socket.close
95
+ end
96
+
97
+ def match_route(req)
98
+ route = @routes.find { |r| r.match?(req.method, req.path) }
99
+ return nil unless route
100
+
101
+ lambda do |request|
102
+ params = route.extract_params(request.path)
103
+ request.route_params = params
104
+ route.handler.call(request)
105
+ end
106
+ end
107
+
108
+ def send_response(socket, status, headers, body)
109
+ status_text = HTTP_STATUS[status] || 'Unknown'
110
+ socket.write "HTTP/1.1 #{status} #{status_text}\r\n"
111
+ headers.each { |k, v| socket.write "#{k}: #{v}\r\n" }
112
+ socket.write "Content-Length: #{body.bytesize}\r\n" unless body.nil?
113
+ socket.write "Connection: close\r\n"
114
+ socket.write "\r\n"
115
+ socket.write body.to_s
116
+ end
117
+
118
+ def json_body(hash)
119
+ JSON.generate(hash)
120
+ end
121
+
122
+ def log(msg)
123
+ return if @silent
124
+ puts "[#{Time.now.strftime('%Y-%m-%d %H:%M:%S')}] #{msg}"
125
+ end
126
+
127
+ # ------------------------------------------------------------------
128
+ # 内置测试路由
129
+ # ------------------------------------------------------------------
130
+ def setup_default_routes
131
+ # 健康检查
132
+ route(:all, '/health') do |req|
133
+ [200, { 'Content-Type' => 'application/json' },
134
+ json_body(status: 'ok', method: req.method, time: Time.now.to_i)]
135
+ end
136
+
137
+ # GET 回显查询参数
138
+ route(:get, '/echo') do |req|
139
+ [200, { 'Content-Type' => 'application/json' },
140
+ json_body(method: req.method, path: req.path, query: req.query, headers: req.headers)]
141
+ end
142
+
143
+ # POST 回显请求体(JSON / Form)
144
+ route(:post, '/echo') do |req|
145
+ [200, { 'Content-Type' => 'application/json' },
146
+ json_body(method: req.method, path: req.path,
147
+ content_type: req.content_type, body: req.parsed_body)]
148
+ end
149
+
150
+ # POST 接收 multipart 上传
151
+ route(:post, '/upload') do |req|
152
+ files = req.files.transform_values do |v|
153
+ { filename: v[:filename], size: v[:data].bytesize,
154
+ content_type: v[:content_type] }
155
+ end
156
+ fields = req.form_data.reject { |_, v| v.is_a?(Hash) && v[:filename] }
157
+ [200, { 'Content-Type' => 'application/json' },
158
+ json_body(files: files, fields: fields, content_type: req.content_type)]
159
+ end
160
+
161
+ # GET /status/:code —— 返回指定状态码
162
+ route(:get, '/status/:code') do |req|
163
+ code = req.route_params['code'].to_i
164
+ status_text = HTTP_STATUS[code] || 'Unknown'
165
+ [code, { 'Content-Type' => 'application/json' },
166
+ json_body(code: code, status: status_text)]
167
+ end
168
+
169
+ # POST /delay/:seconds —— 延迟响应
170
+ route(:post, '/delay/:seconds') do |req|
171
+ seconds = req.route_params['seconds'].to_f
172
+ sleep(seconds)
173
+ [200, { 'Content-Type' => 'application/json' },
174
+ json_body(delayed: seconds, message: 'Delayed response')]
175
+ end
176
+
177
+ # GET /headers —— 回显请求头
178
+ route(:get, '/headers') do |req|
179
+ [200, { 'Content-Type' => 'application/json' },
180
+ json_body(headers: req.headers)]
181
+ end
182
+
183
+ # POST /form —— 接收 form-urlencoded
184
+ route(:post, '/form') do |req|
185
+ [200, { 'Content-Type' => 'application/json' },
186
+ json_body(form: req.parsed_body)]
187
+ end
188
+
189
+ # GET / —— 欢迎页
190
+ route(:get, '/') do |req|
191
+ body = <<~HTML
192
+ <!DOCTYPE html>
193
+ <html><head><title>Cradle</title></head>
194
+ <body>
195
+ <h1>🍼 Cradle HTTP Test Server</h1>
196
+ <p>ice-jade 内置测试服务器,零外部依赖。</p>
197
+ <h2>内置路由</h2>
198
+ <ul>
199
+ <li><code>GET /health</code> — 健康检查</li>
200
+ <li><code>GET /echo?foo=bar</code> — 回显查询参数</li>
201
+ <li><code>POST /echo</code> — 回显请求体(JSON / Form)</li>
202
+ <li><code>POST /upload</code> — 接收 multipart 文件上传</li>
203
+ <li><code>GET /status/:code</code> — 返回指定 HTTP 状态码</li>
204
+ <li><code>POST /delay/:seconds</code> — 延迟响应</li>
205
+ <li><code>GET /headers</code> — 回显请求头</li>
206
+ <li><code>POST /form</code> — 接收 form-urlencoded</li>
207
+ </ul>
208
+ <p>监听端口: #{@port}</p>
209
+ </body></html>
210
+ HTML
211
+ [200, { 'Content-Type' => 'text/html; charset=utf-8' }, body]
212
+ end
213
+ end
214
+
215
+ # =================================================================
216
+ # Route 定义
217
+ # =================================================================
218
+ class Route
219
+ attr_reader :method, :path_pattern, :handler
220
+
221
+ def initialize(method, path_pattern, handler)
222
+ @method = method == 'ALL' ? nil : method
223
+ @path_pattern = path_pattern
224
+ @handler = handler
225
+ @regex, @param_names = compile(path_pattern)
226
+ end
227
+
228
+ def match?(http_method, path)
229
+ return false if @method && @method != http_method
230
+ path_without_query = path.split('?').first
231
+ @regex.match?(path_without_query)
232
+ end
233
+
234
+ def extract_params(path)
235
+ path_without_query = path.split('?').first
236
+ match = @regex.match(path_without_query)
237
+ return {} unless match
238
+
239
+ @param_names.each_with_object({}).with_index do |(name, hash), i|
240
+ hash[name] = match[i + 1]
241
+ end
242
+ end
243
+
244
+ private
245
+
246
+ def compile(pattern)
247
+ names = []
248
+ regex_str = pattern.split('/').map do |segment|
249
+ if segment.start_with?(':')
250
+ names << segment[1..-1]
251
+ '([^/]+)'
252
+ else
253
+ Regexp.escape(segment)
254
+ end
255
+ end.join('/')
256
+ [Regexp.new("^#{regex_str}$"), names]
257
+ end
258
+ end
259
+
260
+ # =================================================================
261
+ # HTTP 请求解析
262
+ # =================================================================
263
+ class HTTPRequest
264
+ attr_reader :method, :path, :query, :headers, :body, :content_type
265
+ attr_accessor :route_params
266
+
267
+ def initialize(method, path, query, headers, body)
268
+ @method = method
269
+ @path = path
270
+ @query = query
271
+ @headers = headers
272
+ @body = body
273
+ @content_type = headers['Content-Type']&.split(';')&.first&.strip
274
+ @route_params = {}
275
+ end
276
+
277
+ def self.parse(socket)
278
+ line = read_line(socket)
279
+ return nil unless line
280
+
281
+ method, full_path, _protocol = line.split(' ', 3)
282
+ return nil unless method && full_path
283
+
284
+ uri = URI.parse(full_path)
285
+ query = URI.decode_www_form(uri.query || '').to_h
286
+
287
+ headers = {}
288
+ while (header_line = read_line(socket))
289
+ break if header_line == "\r\n" || header_line == "\n" || header_line.empty?
290
+ key, value = header_line.split(':', 2)
291
+ headers[key.strip] = value.strip if key && value
292
+ end
293
+
294
+ body = ''
295
+ if headers['Content-Length']
296
+ length = headers['Content-Length'].to_i
297
+ body = socket.read(length) if length > 0
298
+ end
299
+
300
+ new(method, uri.path, query, headers, body)
301
+ rescue StandardError
302
+ nil
303
+ end
304
+
305
+ def parsed_body
306
+ case content_type
307
+ when 'application/json'
308
+ JSON.parse(body) rescue body
309
+ when 'application/x-www-form-urlencoded'
310
+ URI.decode_www_form(body).to_h rescue body
311
+ else
312
+ body
313
+ end
314
+ end
315
+
316
+ # multipart 解析结果
317
+ def files
318
+ return {} unless multipart?
319
+ parse_multipart[:files]
320
+ end
321
+
322
+ def form_data
323
+ return {} unless multipart?
324
+ parse_multipart[:fields]
325
+ end
326
+
327
+ def multipart?
328
+ content_type == 'multipart/form-data' ||
329
+ headers['Content-Type']&.include?('multipart/form-data')
330
+ end
331
+
332
+ private
333
+
334
+ def self.read_line(socket)
335
+ line = +""
336
+ while (char = socket.getc)
337
+ line << char
338
+ break if line.end_with?("\r\n")
339
+ end
340
+ line
341
+ rescue StandardError
342
+ nil
343
+ end
344
+
345
+ def parse_multipart
346
+ ct = headers['Content-Type'] || ''
347
+ boundary = ct[/boundary=([^\s;]+)/, 1]
348
+ boundary = boundary[1..-2] if boundary&.start_with?('"') && boundary&.end_with?('"')
349
+
350
+ files = {}
351
+ fields = {}
352
+ return { files: files, fields: fields } unless boundary
353
+
354
+ delimiter = "--#{boundary}"
355
+ parts = @body.split("#{delimiter}\r\n")
356
+
357
+ parts.each do |part|
358
+ part = part.sub(/\r\n--#{Regexp.escape(boundary)}--\r\n?\z/, '')
359
+ next if part.strip.empty?
360
+
361
+ header_end = part.index("\r\n\r\n")
362
+ next unless header_end
363
+
364
+ header_section = part[0...header_end]
365
+ data = part[(header_end + 4)..-1]
366
+ data = data.chomp("\r\n") if data
367
+
368
+ disp = header_section[/Content-Disposition: (.+)/i, 1]
369
+ next unless disp
370
+
371
+ name = disp[/name="([^"]+)"/, 1]
372
+ filename = disp[/filename="([^"]+)"/, 1]
373
+ mime = header_section[/Content-Type: (.+)/i, 1]&.strip
374
+
375
+ if filename
376
+ files[name] = { filename: filename, content_type: mime || 'application/octet-stream', data: data || '' }
377
+ else
378
+ fields[name] = data || ''
379
+ end
380
+ end
381
+
382
+ { files: files, fields: fields }
383
+ end
384
+ end
385
+
386
+ # =================================================================
387
+ # HTTP 状态码表
388
+ # =================================================================
389
+ HTTP_STATUS = {
390
+ 100 => 'Continue', 101 => 'Switching Protocols',
391
+ 200 => 'OK', 201 => 'Created', 202 => 'Accepted',
392
+ 204 => 'No Content',
393
+ 301 => 'Moved Permanently', 302 => 'Found', 304 => 'Not Modified',
394
+ 400 => 'Bad Request', 401 => 'Unauthorized', 403 => 'Forbidden',
395
+ 404 => 'Not Found', 405 => 'Method Not Allowed',
396
+ 429 => 'Too Many Requests',
397
+ 500 => 'Internal Server Error', 502 => 'Bad Gateway',
398
+ 503 => 'Service Unavailable'
399
+ }.freeze
400
+ end
401
+ end
402
+ end