--[[ @module httpplus @summary http库的补充 @version 1.0 @date 2023.11.23 @author wendal @demo httpplus @tag LUAT_USE_NETWORK @usage -- 本库支持的功能有: -- 1. 大文件上传的问题,不限大小 -- 2. 任意长度的header设置 -- 3. 任意长度的body设置 -- 4. 鉴权URL自动识别 -- 5. body使用zbuff返回,可直接传输给uart等库 -- 与http库的差异 -- 1. 不支持文件下载 -- 2. 不支持fota -- 支持 http 1.0 和 http 1.1, 不支持http2.0 -- 支持 GET/POST/PUT/DELETE/HEAD 等常用方法,也支持自定义method -- 支持 HTTP 和 HTTPS 协议 -- 支持 IPv4 和 IPv6 -- 支持 HTTP 鉴权 -- 支持 multipart/form-data 上传文件和表单 -- 支持 application/x-www-form-urlencoded 上传表单 -- 支持 application/json 上传json数据 -- 支持 自定义 body 上传任意数据 -- 支持 自定义 headers -- 支持 大文件上传,不限大小 -- 支持 zbuff 作为 body 上传和响应返回 -- 支持 bodyfile 直接把文件内容作为body上传 -- 支持 上传时使用自定义缓冲区, 2025.9.25 新增 ]] local httpplus = {} local TAG = "httpplus" local function http_opts_parse(opts) if not opts then log.error(TAG, "opts不能为nil") return -100, "opts不能为nil" end if not opts.url or #opts.url < 5 then log.error(TAG, "URL不存在或者太短了", url) return -100, "URL不存在或者太短了" end if not opts.headers then opts.headers = {} end if opts.debug or httpplus.debug then if not opts.log then opts.log = log.debug end else opts.log = function() -- log.info(TAG, "无日志") end end -- 解析url -- 先判断协议是否加密 local is_ssl = false local tmp = "" if opts.url:startsWith("https://") then is_ssl = true tmp = opts.url:sub(9) elseif opts.url:startsWith("http://") then tmp = opts.url:sub(8) else tmp = opts.url end -- log.info("http分解阶段1", is_ssl, tmp) -- 然后判断host段 local uri = "" local host = "" local port = 0 if tmp:find("/") then uri = tmp:sub((tmp:find("/"))) -- 注意find会返回多个值 tmp = tmp:sub(1, tmp:find("/") - 1) else uri = "/" end -- log.info("http分解阶段2", is_ssl, tmp, uri) if tmp == nil or #tmp == 0 then log.error(TAG, "非法的URL", url) return -101, "非法的URL" end -- 有无鉴权信息 if tmp:find("@") then local auth = tmp:sub(1, tmp:find("@") - 1) if not opts.headers["Authorization"] then opts.headers["Authorization"] = "Basic " .. auth:toBase64() end -- log.info("http鉴权信息", auth, opts.headers["Authorization"]) tmp = tmp:sub(tmp:find("@") + 1) end -- 解析端口 if tmp:find(":") then host = tmp:sub(1, tmp:find(":") - 1) port = tmp:sub(tmp:find(":") + 1) port = tonumber(port) else host = tmp end if not port or port < 1 then if is_ssl then port = 443 else port = 80 end end -- 收尾工作 if not opts.headers["Host"] then opts.headers["Host"] = string.format("%s:%d", host, port) end -- Connection 必须关闭 opts.headers["Connection"] = "Close" -- 复位一些变量,免得判断出错 opts.is_closed = nil opts.body_len = 0 -- multipart需要boundary local boundary = "------------------------16ef6e68ef" .. tostring(os.time()) opts.boundary = boundary opts.mp = {} if opts.files then -- 强制设置为true opts.multipart = true end -- 表单数据 if opts.forms then if opts.multipart then for kk, vv in pairs(opts.forms) do local tmp = string.format("--%s\r\nContent-Disposition: form-data; name=\"%s\"\r\n\r\n", boundary, kk) table.insert(opts.mp, {vv, tmp, "form"}) opts.body_len = opts.body_len + #tmp + #vv + 2 -- log.info("当前body长度", opts.body_len, "数据长度", #vv) end else if not opts.headers["Content-Type"] then opts.headers["Content-Type"] = "application/x-www-form-urlencoded;charset=UTF-8" end local buff = zbuff.create(120) for kk, vv in pairs(opts.forms) do buff:copy(nil, kk) buff:copy(nil, "=") buff:copy(nil, string.urlEncode(tostring(vv))) buff:copy(nil, "&") end if buff:used() > 0 then buff:del(-1, 1) opts.body = buff opts.body_len = buff:used() opts.log(TAG, "普通表单", opts.body) end end end if opts.files then -- 强制设置为true opts.multipart = true local contentType = { txt = "text/plain", -- 文本 jpg = "image/jpeg", -- JPG 格式图片 jpeg = "image/jpeg", -- JPEG 格式图片 png = "image/png", -- PNG 格式图片 gif = "image/gif", -- GIF 格式图片 html = "image/html", -- HTML json = "application/json", -- JSON mp4 = "video/mp4", -- MP4 格式视频 mp3 = "audio/mp3", -- MP3 格式音频 webm = "video/webm", -- WebM 格式视频 } for kk, vv in pairs(opts.files) do local ct = contentType[vv:match("%.(%w+)$")] or "application/octet-stream" local fname = vv:match("[^%/]+%w$") local tmp = string.format("--%s\r\nContent-Disposition: form-data; name=\"%s\"; filename=\"%s\"\r\nContent-Type: %s\r\n\r\n", boundary, kk, fname, ct) -- log.info("文件传输头", tmp) table.insert(opts.mp, {vv, tmp, "file"}) opts.body_len = opts.body_len + #tmp + io.fileSize(vv) + 2 -- log.info("当前body长度", opts.body_len, "文件长度", io.fileSize(vv), fname, ct) end end -- 如果multipart模式 if opts.multipart then -- 如果没主动设置body, 那么补个结尾 if not opts.body then opts.body_len = opts.body_len + #boundary + 2 + 2 + 2 end -- Content-Type没设置? 那就设置一下 if not opts.headers["Content-Type"] then opts.headers["Content-Type"] = "multipart/form-data; boundary="..boundary end end -- 直接设置bodyfile if opts.bodyfile then local fd = io.open(opts.bodyfile, "rb") if not fd then log.error("httpplus", "bodyfile失败,文件不存在", opts.bodyfile) return -104, "bodyfile失败,文件不存在" end fd:close() opts.body_len = io.fileSize(opts.bodyfile) end -- 有设置body, 而且没设置长度 if opts.body and (not opts.body_len or opts.body_len == 0) then -- body是zbuff的情况 if type(opts.body) == "userdata" then opts.body_len = opts.body:used() -- body是json的情况 elseif type(opts.body) == "table" then opts.body = json.encode(opts.body, "7f") if opts.body then opts.body_len = #opts.body if not opts.headers["Content-Type"] then opts.headers["Content-Type"] = "application/json;charset=UTF-8" opts.log(TAG, "JSON", opts.body) end end -- 其他情况就只能当文本了 else opts.body = tostring(opts.body) opts.body_len = #opts.body end end -- 一定要设置Content-Length,而且强制覆盖客户自定义的值 -- opts.body_len = opts.body_len or 0 opts.headers["Content-Length"] = tostring(opts.body_len or 0) -- 如果没设置method, 自动补齐 if not opts.method or #opts.method == 0 then if opts.body_len > 0 then opts.method = "POST" else opts.method = "GET" end else -- 确保一定是大写字母 opts.method = opts.method:upper() end if opts.debug then opts.log(TAG, is_ssl, host, port, uri, json.encode(opts.headers)) end -- 把剩余的属性设置好 opts.host = host opts.port = port opts.uri = uri opts.is_ssl = is_ssl if not opts.timeout or opts.timeout == 0 then opts.timeout = 30 end return -- 成功完成,不需要返回值 end local function zbuff_find(buff, str) -- log.info("zbuff查找", buff:used(), #str) if buff:used() < #str then return end local maxoff = buff:used() maxoff = maxoff - #str local tmp = zbuff.create(#str) tmp:write(str) -- log.info("tmp数据", tmp:query():toHex()) for i = 0, maxoff, 1 do local flag = true for j = 0, #str - 1, 1 do -- log.info("对比", i, j, string.char(buff[i+j]):toHex(), string.char(tmp[j]):toHex(), buff[i+j] ~= tmp[j]) if buff[i+j] ~= tmp[j] then flag = false break end end if flag then return i end end end local function resp_parse(opts) -- log.info("这里--------") local header_offset = zbuff_find(opts.rx_buff, "\r\n\r\n") -- log.info("头部偏移量", header_offset) if not header_offset then log.warn(TAG, "没有检测到http响应头部,非法响应") opts.resp_code = -198 return end local state_line_offset = zbuff_find(opts.rx_buff, "\r\n") local state_line = opts.rx_buff:query(0, state_line_offset) local tmp = state_line:split(" ") if not tmp or #tmp < 2 then log.warn(TAG, "非法的响应行", state_line) opts.resp_code = -197 return end local code = tonumber(tmp[2]) if not code then log.warn(TAG, "非法的响应码", tmp[2]) opts.resp_code = -196 return end opts.resp_code = code opts.resp = { headers = {} } opts.log(TAG, "state code", code) -- TODO 解析header和body opts.rx_buff:del(0, state_line_offset + 2) -- opts.log(TAG, "剩余的响应体", opts.rx_buff:query()) -- 解析headers while 1 do local offset = zbuff_find(opts.rx_buff, "\r\n") if not offset then log.warn(TAG, "不合法的剩余headers", opts.rx_buff:query()) break end if offset == 0 then -- header的最后一个空行 opts.rx_buff:del(0, 2) break end local line = opts.rx_buff:query(0, offset) opts.rx_buff:del(0, offset + 2) local tmp2 = line:split(":") opts.log(TAG, tmp2[1]:trim(), tmp2[2]:trim()) opts.resp.headers[tmp2[1]:trim()] = tmp2[2]:trim() end -- if opts.resp_code < 299 then -- 解析body -- 有Content-Length就好办 if opts.resp.headers["Content-Length"] then opts.log(TAG, "有Content-Length", opts.resp.headers["Content-Length"]) opts.resp.body = opts.rx_buff elseif opts.resp.headers["Transfer-Encoding"] == "chunked" then -- log.info(TAG, "数据是chunked编码", opts.rx_buff[0], opts.rx_buff[1]) -- log.info(TAG, "数据是chunked编码", opts.rx_buff:query(0, 4):toHex()) local coffset = 0 local crun = true while crun and coffset < opts.rx_buff:used() do -- 从当前offset读取长度, 长度总不会超过8字节吧? local flag = true -- local coffset = zbuff_find(opts.rx_buff, "\r\n") -- if not coffset then -- end for i = 1, 8, 1 do if opts.rx_buff[coffset+i] == 0x0D and opts.rx_buff[coffset+i+1] == 0x0A then local ctmp = opts.rx_buff:query(coffset, i) -- opts.log(TAG, "chunked分片长度", ctmp, ctmp:toHex()) local clen = tonumber(ctmp, 16) -- opts.log(TAG, "chunked分片长度2", clen) if clen == nil or clen == 0 then -- 末尾了 opts.rx_buff:resize(coffset) crun = false else -- 先删除chunked块 opts.rx_buff:del(coffset, i+2) coffset = coffset + clen end flag = false break end end -- 肯定能搜到chunked if flag then log.error("非法的chunked块") break end end opts.resp.body = opts.rx_buff end -- end -- 清空rx_buff opts.rx_buff = nil -- 完结散花 end -- socket 回调函数 local function http_socket_cb(opts, event) opts.log(TAG, "tcp.event", event) if event == socket.ON_LINE then -- TCP链接已建立, 那就可以上行了 -- opts.state = "ON_LINE" sys.publish(opts.topic) elseif event == socket.TX_OK then -- 数据传输完成, 如果是文件上传就需要这个消息 -- opts.state = "TX_OK" sys.publish(opts.topic) elseif event == socket.EVENT then -- 收到数据或者链接断开了, 这里总需要读取一次才知道 local succ, data_len = socket.rx(opts.netc, opts.rx_buff) if succ and data_len > 0 then opts.log(TAG, "收到数据", data_len, "总长", #opts.rx_buff) -- opts.log(TAG, "数据", opts.rx_buff:query()) else if not opts.is_closed then opts.log(TAG, "服务器已经断开了连接或接收出错") opts.is_closed = true sys.publish(opts.topic) end end elseif event == socket.CLOSED then log.info(TAG, "连接已关闭") opts.is_closed = true sys.publish(opts.topic) end end local function http_exec(opts) local netc = socket.create(opts.adapter, function(sc, event) if opts.netc then return http_socket_cb(opts, event) end end) if not netc then log.error(TAG, "创建socket失败了!!") return -102 end opts.netc = netc opts.rx_buff = zbuff.create(1024) opts.topic = tostring(netc) socket.config(netc, nil,nil, opts.is_ssl) if opts.debug or httpplus.debug then socket.debug(netc) end if not socket.connect(netc, opts.host, opts.port, opts.try_ipv6) then log.warn(TAG, "调用socket.connect返回错误了") return -103, "调用socket.connect返回错误了" end local ret = sys.waitUntil(opts.topic, 5000) if ret == false then log.warn(TAG, "建立连接超时了!!!") return -104, "建立连接超时了!!!" end -- 首先是头部 local line = string.format("%s %s HTTP/1.1\r\n", opts.method:upper(), opts.uri) -- opts.log(TAG, line) socket.tx(netc, line) for k, v in pairs(opts.headers) do line = string.format("%s: %s\r\n", k, v) socket.tx(netc, line) end line = "\r\n" socket.tx(netc, line) -- 然后是body local rbody = "" local write_counter = 0 local fbuf = nil if (opts.mp and #opts.mp > 0) or opts.bodyfile or (opts.body and type(opts.body) == "userdata" and opts.body:used() > 4*1024) then if opts.upload_file_buff then fbuf = opts.upload_file_buff else if hmeta and hmeta.chip and hmeta.chip() == "EC718HM" then fbuf = zbuff.create(1024 * 128, 0, zbuff.HEAP_PSRAM) -- 718hm可以128k的,放手去用 else fbuf = zbuff.create(1024 * 24, 0, zbuff.HEAP_PSRAM) -- 其他模组就是小的用吧 end end if fbuf == nil then fbuf = zbuff.create(1024 * 8, 0, zbuff.HEAP_PSRAM) -- 创建一个小的,作为防御 if fbuf == nil then fbuf = zbuff.create(1500, 0, zbuff.HEAP_PSRAM) -- 创建一个最小的,最后防御 end end opts.log(TAG, "上传使用缓冲区", fbuf:len()) end if opts.mp and #opts.mp > 0 then opts.log(TAG, "执行mulitpart上传模式") for k, v in pairs(opts.mp) do socket.tx(netc, v[2]) write_counter = write_counter + #v[2] if v[3] == "file" then -- log.info("写入文件数据头", v[2]) local fd = io.open(v[1], "rb") -- log.info("写入文件数据", v[1]) if fd then while not opts.is_closed do fbuf:seek(0) local ok, flen = fd:fill(fbuf) if not ok or flen <= 0 then break end fbuf:seek(flen) -- log.info("写入文件数据", "长度", #fdata) socket.tx(netc, fbuf) write_counter = write_counter + flen -- 注意, 这里要等待TX_OK事件 sys.waitUntil(opts.topic, 300) end fd:close() end else socket.tx(netc, v[1]) write_counter = write_counter + #v[1] end socket.tx(netc, "\r\n") write_counter = write_counter + 2 end -- rbody = rbody .. "--" .. opts.boundary .. "--\r\n" socket.tx(netc, "--") socket.tx(netc, opts.boundary) socket.tx(netc, "--\r\n") write_counter = write_counter + #opts.boundary + 2 + 2 + 2 elseif opts.bodyfile then local fd = io.open(opts.bodyfile, "rb") -- log.info("写入文件数据", v[1]) if fd then while not opts.is_closed do fbuf:seek(0) local ok, flen = fd:fill(fbuf) if not ok or flen <= 0 then break end fbuf:seek(flen) -- log.info("写入文件数据", "长度", #fdata) socket.tx(netc, fbuf) write_counter = write_counter + flen -- 注意, 这里要等待TX_OK事件 sys.waitUntil(opts.topic, 300) end fd:close() end elseif opts.body then if type(opts.body) == "string" and #opts.body > 0 then socket.tx(netc, opts.body) write_counter = write_counter + #opts.body elseif type(opts.body) == "userdata" then opts.log(TAG, "使用zbuff上传数据", opts.body:used()) write_counter = write_counter + opts.body:used() if opts.body:used() <= 4*1024 then socket.tx(netc, opts.body) else local offset = 0 local tmpbuff = opts.body local tsize = tmpbuff:used() while offset < tsize do -- TODO 应该使用fbuf来做缓冲区,而不是toStr opts.log(TAG, "body(zbuff)分段写入", offset, tsize) fbuf:seek(0) if tsize - offset > fbuf:len() then fbuf:copy(0, tmpbuff, offset, fbuf:len()) fbuf:seek(fbuf:len()) socket.tx(netc, fbuf) offset = offset + fbuf:len() sys.waitUntil(opts.topic, 300) else fbuf:copy(0, tmpbuff, offset, tsize - offset) fbuf:seek(tsize - offset) socket.tx(netc, fbuf) break end end end end end -- log.info("写入长度", "期望", opts.body_len, "实际", write_counter) -- log.info("hex", rbody) -- 处理响应信息 while not opts.is_closed and opts.timeout > 0 do log.info(TAG, "等待服务器完成响应") sys.waitUntil(opts.topic, 1000) opts.timeout = opts.timeout - 1 end log.info(TAG, "服务器已完成响应,开始解析响应") resp_parse(opts) -- log.info("执行完成", "返回结果") end --[[ 执行HTTP请求 @api httpplus.request(opts) @table 请求参数,是一个table,最起码得有url属性 @return int 响应码,服务器返回的状态码>=100, 若本地检测到错误,会返回<0的值 @return 服务器正常响应时返回结果, 否则是错误信息或者nil @usage -- 请求参数介绍 local opts = { url = "https://httpbin.air32.cn/abc", -- 必选, 目标URL method = "POST", -- 可选,默认GET, 如果有body,files,forms参数,会设置成POST headers = {}, -- 可选,自定义的额外header files = {}, -- 可选,键值对的形式,文件上传,若存在本参数,会强制以multipart/form-data形式上传 forms = {}, -- 可选,键值对的形式,表单参数,若存在本参数,如果不存在files,按application/x-www-form-urlencoded上传 body = "abc=123",-- 可选,自定义body参数, 字符串/zbuff/table均可, 但不能与files和forms同时存在 debug = false, -- 可选,打开调试日志,默认false try_ipv6 = false, -- 可选,是否优先尝试ipv6地址,默认是false adapter = nil, -- 可选,网络适配器编号, 默认是自动选 timeout = 30, -- 可选,读取服务器响应的超时时间,单位秒,默认30 bodyfile = "xxx", -- 可选,直接把文件内容作为body上传, 优先级高于body参数 upload_file_buff = zbuff.create(1024*64) -- 可选,上传时使用的缓冲区,默认会根据型号创建一个buff } local code, resp = httpplus.request({url="https://httpbin.air32.cn/get"}) log.info("http", code) -- 返回值resp的说明 -- 情况1, code >= 100 时, resp会是个table, 包含2个元素 if code >= 100 then -- headers, 是个table log.info("http", "headers", json.encode(resp.headers)) -- body, 是个zbuff -- 通过query函数可以转为lua的string log.info("http", "headers", resp.body:query()) -- 也可以通过uart.tx等支持zbuff的函数转发出去 -- uart.tx(1, resp.body) end -- 情况2, code < 0 时, resp会是个错误信息字符串 -- 对upload_file_buff参数的说明 -- 1. 如果上传的文件比较大,建议传入这个参数,避免每次都创建和销毁缓冲区 -- 2. 如果不传入这个参数,本库会根据不同的模组型号创建一个合适的缓冲区 -- 3. 多个同时执行的httpplus请求,不可以共用同一个缓冲区 ]] function httpplus.request(opts) -- 参数解析 local ret = http_opts_parse(opts) if ret then return ret end -- 执行请求 local ret, msg = pcall(http_exec, opts) if opts.netc then -- 清理连接 if not opts.is_closed then socket.close(opts.netc) end socket.release(opts.netc) opts.netc = nil end -- 处理响应或错误 if not ret then log.error(TAG, msg) return -199, msg end return opts.resp_code, opts.resp end return httpplus