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.
- checksums.yaml +4 -4
- data/README.md +247 -69
- data/bin/cradle +72 -0
- data/bin/cradle-instance +74 -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 +15 -4
data/lib/http_poster.rb
ADDED
|
@@ -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
|