--- 模块功能:HTTP客户端 -- @module http -- @author openLuat -- @license MIT -- @copyright openLuat -- @release 2017.10.23 require"socket" require"utils" module(..., package.seeall) local Content_type = {'application/x-www-form-urlencoded', 'application/json', 'application/octet-stream'} -- 处理表的url编码 function urlencodeTab(params) local msg = {} for k, v in pairs(params) do table.insert(msg, string.urlencode(k) .. '=' .. string.urlencode(v)) table.insert(msg, '&') end table.remove(msg) return table.concat(msg) end local function response(client,cbFnc,result,prompt,head,body) if not result then log.error("http.response",result,prompt) end if cbFnc then cbFnc(result,prompt,head,body) end if client then client:close() end end local function receive(client,timeout,cbFnc,result,prompt,head,body) local res,data = client:recv(timeout) if not res then response(client,cbFnc,result,prompt or "receive timeout",head,body) end return res,data end local function getFileBase64Len(s) if s then return (io.fileSize(s)+2)/3*4 end end local function taskClient(method,protocal,auth,host,port,path,cert,head,body,timeout,cbFnc,rcvFilePath) while not socket.isReady() do if not sys.waitUntil("IP_READY_IND",timeout) then return response(nil,cbFnc,false,"network not ready") end end --计算body长度 local bodyLen = 0 if body then if type(body)=="string" then bodyLen = body:len() elseif type(body)=="table" then for i=1,#body do bodyLen = bodyLen + (type(body[i])=="string" and string.len(body[i]) or getFileBase64Len(body[i].file_base64) or io.fileSize(body[i].file)) end end end --重构head local heads = head or {} if not heads.Host then heads["Host"] = host end if not heads.Connection then heads["Connection"] = "short" end if bodyLen>0 and bodyLen~=tonumber(heads["Content-Length"] or "0") then heads["Content-Length"] = bodyLen end if auth~="" and not heads.Authorization then heads["Authorization"] = ("Basic "..crypto.base64_encode(auth,#auth)) end local headStr = "" for k,v in pairs(heads) do headStr = headStr..k..": "..v.."\r\n" end headStr = headStr.."\r\n" local client = socket.tcp(protocal=="https",cert) if not client then return response(nil,cbFnc,false,"create socket error") end if not client:connect(host,port) then return response(client,cbFnc,false,"connect fail") end --发送请求行+请求头+string类型的body if not client:send(method.." "..path.." HTTP/1.1".."\r\n"..headStr..(type(body)=="string" and body or "")) then return response(client,cbFnc,false,"send head fail") end --发送table类型的body if type(body)=="table" then for i=1,#body do if type(body[i])=="string" then if not client:send(body[i]) then return response(client,cbFnc,false,"send body fail") end else local file = io.open(body[i].file or body[i].file_base64,"rb") if file then while true do local dat = file:read(body[i].file and 1460 or 1095) if not dat then io.close(file) break end if body[i].file_base64 then dat=crypto.base64_encode(dat,#dat) end if not client:send(dat) then io.close(file) return response(client,cbFnc,false,"send file fail") end end else return response(client,cbFnc,false,"send file open fail") end end end end local rcvCache,rspHead,rspBody,d1,d2,result,data,statusCode,rcvChunked,contentLen = "",{},{} --接收数据,解析状态行和头 while true do result,data = receive(client,timeout,cbFnc,false,nil,rspHead,rcvFilePath or table.concat(rspBody)) if not result then return end rcvCache = rcvCache..data d1,d2 = rcvCache:find("\r\n\r\n") if d2 then --状态行 _,d1,statusCode = rcvCache:find("%s(%d+)%s.-\r\n") if not statusCode then return response(client,cbFnc,false,"parse received status error",rspHead,rcvFilePath or table.concat(rspBody)) end --应答头 for k,v in string.gmatch(rcvCache:sub(d1+1,d2-2),"(.-):%s*(.-)\r\n") do rspHead[k] = v if (k=="Transfer-Encoding") and (v=="chunked") then rcvChunked = true end end if not rcvChunked then contentLen = tonumber(rspHead["Content-Length"] or "2147483647") end --未处理的body数据 rcvCache = rcvCache:sub(d2+1,-1) break end end --解析body if rcvChunked then local chunkSize --循环处理每个chunk while true do --解析chunk size if not chunkSize then d1,d2,chunkSize = rcvCache:find("(%x+)\r\n") if chunkSize then chunkSize = tonumber(chunkSize,16) rcvCache = rcvCache:sub(d2+1,-1) else result,data = receive(client,timeout,cbFnc,false,nil,rspHead,rcvFilePath or table.concat(rspBody)) if not result then return end rcvCache = rcvCache..data end end --log.info("http.taskClient chunkSize",chunkSize) --解析chunk data if chunkSize then if rcvCache:len()0 then local chunkData = rcvCache:sub(1,chunkSize) --保存到文件中 if rcvFilePath then local file = io.open(rcvFilePath,"a+") if not file then return response(client,cbFnc,false,"receive:open file error",rspHead,rcvFilePath or table.concat(rspBody)) end if not file:write(chunkData) then response(client,cbFnc,false,"receive:write file error",rspHead,rcvFilePath or table.concat(rspBody)) end file:close() --保存到缓冲区中 else table.insert(rspBody,chunkData) end rcvCache = rcvCache:sub(chunkSize+3,-1) chunkSize = nil elseif chunkSize==0 then return response(client,cbFnc,true,statusCode,rspHead,rcvFilePath or table.concat(rspBody)) end end end end else local rmnLen = contentLen while true do data = rcvCache:len()<=rmnLen and rcvCache or rcvCache:sub(1,rmnLen) --保存到文件中 if rcvFilePath then if data:len()>0 then local file = io.open(rcvFilePath,"a+") if not file then return response(client,cbFnc,false,"receive:open file error",rspHead,rcvFilePath or table.concat(rspBody)) end if not file:write(data) then response(client,cbFnc,false,"receive:write file error",rspHead,rcvFilePath or table.concat(rspBody)) end file:close() end else table.insert(rspBody,data) end rmnLen = rmnLen-data:len() if rmnLen==0 then break end result,rcvCache = receive(client,timeout,cbFnc,contentLen==0x7FFFFFFF,contentLen==0x7FFFFFFF and statusCode or nil,rspHead,rcvFilePath or table.concat(rspBody)) if not result then return end end return response(client,cbFnc,true,statusCode,rspHead,rcvFilePath or table.concat(rspBody)) end end --- 发送HTTP请求 -- @string method HTTP请求方法 -- 支持"GET","HEAD","POST","OPTIONS","PUT","DELETE","TRACE","CONNECT" -- @string url HTTP请求url -- url格式(除hostname外,其余字段可选;目前的实现不支持hash) -- |------------------------------------------------------------------------------| -- | protocol ||| auth | host | path | hash | -- |----------|||-----------|-----------------|---------------------------|-------| -- | ||| | hostname | port | pathname | search | | -- | ||| |----------|------|----------|----------------| | -- " http[s] :// user:pass @ host.com : 8080 /p/a/t/h ? query=string # hash " -- | ||| | | | | | | -- |------------------------------------------------------------------------------| -- @table[opt=nil] cert,table或者nil类型,ssl证书,当url为https类型时,此参数才有意义。cert格式如下: -- { -- caCert = "ca.crt", --CA证书文件(Base64编码 X.509格式),如果存在此参数,则表示客户端会对服务器的证书进行校验;不存在则不校验 -- clientCert = "client.crt", --客户端证书文件(Base64编码 X.509格式),服务器对客户端的证书进行校验时会用到此参数 -- clientKey = "client.key", --客户端私钥文件(Base64编码 X.509格式) -- clientPassword = "123456", --客户端证书文件密码[可选] -- } -- @table[opt=nil] head,nil或者table类型,自定义请求头 -- http.lua会自动添加Host: XXX、Connection: short、Content-Length: XXX三个请求头 -- 如果这三个请求头满足不了需求,head参数传入自定义请求头,如果自定义请求头中存在Host、Connection、Content-Length三个请求头,将覆盖http.lua中自动添加的同名请求头 -- head格式如下: -- 如果没有自定义请求头,传入nil或者{};否则传入{head1="value1", head2="value2", head3="value3"},value中不能有\r\n -- @param[opt=nil] body,nil、string或者table类型,请求实体 -- 如果body仅仅是一串数据,可以直接传入一个string类型的body即可 -- -- 如果body的数据比较复杂,包括字符串数据和文件,则传入table类型的数据,格式如下: -- { -- [1] = "string1", -- [2] = {file="/ldata/test.jpg"}, -- [3] = "string2" -- } -- 例如上面的这个body,索引必须为连续的数字(从1开始),实际传输时,先发送字符串"string1",再发送文件/ldata/test.jpg的内容,最后发送字符串"string2" -- -- 如果传输的文件内容需要进行base64编码再上传,请把file改成file_base64,格式如下: -- { -- [1] = "string1", -- [2] = {file_base64="/ldata/test.jpg"}, -- [3] = "string2" -- } -- 例如上面的这个body,索引必须为连续的数字(从1开始),实际传输时,先发送字符串"string1",再发送文件/ldata/test.jpg经过base64编码后的内容,最后发送字符串"string2" -- @number[opt=30000] timeout,请求发送成功后,接收服务器返回应答数据的超时时间,单位毫秒,默认为30秒 -- @function[opt=nil] cbFnc,执行HTTP请求的回调函数(请求发送结果以及应答数据接收结果都通过此函数通知用户),回调函数的调用形式为: -- cbFnc(result,prompt,head,body) -- result:true或者false,true表示成功收到了服务器的应答,false表示请求发送失败或者接收服务器应答失败 -- prompt:string类型,result为true时,表示服务器的应答码;result为false时,表示错误信息 -- head:table或者nil类型,表示服务器的应答头;result为true时,此参数为{head1="value1", head2="value2", head3="value3"},value中不包含\r\n;result为false时,此参数为nil -- body:string类型,如果调用request接口时传入了rcvFileName,此参数表示下载文件的完整路径;否则表示接收到的应答实体数据 -- @string[opt=nil] rcvFileName,保存“服务器应答实体数据”的文件名,可以传入完整的文件路径,也可以传入单独的文件名,如果是文件名,http.lua会自动生成一个完整路径,通过cbFnc的参数body传出 -- @return string rcvFilePath,如果传入了rcvFileName,则返回对应的完整路径;其余情况都返回nil -- @usage -- http.request("GET","www.lua.org",nil,nil,nil,30000,cbFnc) -- http.request("GET","http://www.lua.org",nil,nil,nil,30000,cbFnc) -- http.request("GET","http://www.lua.org:80",nil,nil,nil,30000,cbFnc,"download.bin") -- http.request("GET","www.lua.org/about.html",nil,nil,nil,30000,cbFnc) -- http.request("GET","www.lua.org:80/about.html",nil,nil,nil,30000,cbFnc) -- http.request("GET","http://wiki.openluat.com/search.html?q=123",nil,nil,nil,30000,cbFnc) -- http.request("POST","www.test.com/report.html",nil,{Head1="ValueData1"},"BodyData",30000,cbFnc) -- http.request("POST","www.test.com/report.html",nil,{Head1="ValueData1",Head2="ValueData2"},{[1]="string1",[2] ={file="/ldata/test.jpg"},[3]="string2"},30000,cbFnc) -- http.request("GET","https://www.baidu.com",{caCert="ca.crt"}) -- http.request("GET","https://www.baidu.com",{caCert="ca.crt",clientCert = "client.crt",clientKey = "client.key"}) -- http.request("GET","https://www.baidu.com",{caCert="ca.crt",clientCert = "client.crt",clientKey = "client.key",clientPassword = "123456"}) function request(method,url,cert,head,body,timeout,cbFnc,rcvFileName) local protocal,auth,hostName,port,path,d1,d2,offset,rcvFilePath d1,d2,protocal = url:find("^(%a+)://") if not protocal then protocal = "http" end offset = d2 or 0 d1,d2,auth = url:find("(.-:.-)@",offset+1) offset = d2 or offset if url:match("^[^/]+:(%d+)",offset+1) then d1,d2,hostName,port = url:find("^([^/]+):(%d+)",offset+1) else d1,d2,hostName = url:find("(.-)/",offset+1) if hostName then d2 = d2-1 else hostName = url:sub(offset+1,-1) offset = url:len() end end if not hostName then return response(nil,cbFnc,false,"Invalid url, can't get host") end if port=="" or not port then port = (protocal=="https" and 443 or 80) end offset = d2 or offset path = url:sub(offset+1,-1) if rcvFileName and rcvFileName:sub(1,1)~="/" and rtos.make_dir and rtos.make_dir("/http_down") then rcvFilePath = "/http_down/"..rcvFileName end sys.taskInit(taskClient,method,protocal,auth or "",hostName,port,path=="" and "/" or path,cert,head,body or "",timeout or 30000,cbFnc,rcvFilePath or rcvFileName) return rcvFilePath or rcvFileName end --- HTTP客户端 -- @string method,提交方式"GET" or "POST" -- @string url,HTTP请求超链接 -- @number timeout,超时时间 -- @param params,table类型,请求发送的查询字符串,通常为键值对表 -- @param data,table类型,正文提交的body,通常为键值对、json或文件对象类似的表 -- @number ctype,Content-Type的类型(可选1,2,3),默认1:"urlencode",2:"json",3:"octet-stream" -- @string basic,HTTP客户端的authorization basic验证的"username:password" -- @param headers,table类型,HTTP headers部分 -- @return string,table,string,正常返回response_code, response_header, response_body -- @return string,string,错误返回 response_code, error_message -- @usage local c, h, b = http.request(url, method, headers, body) -- @usage local r, e = http.request("http://wrong.url/ ") function my_request(method, url, timeout, params, data, ctype, basic, headers) local response_header, response_code, response_message, response_body, host, port, path, str, sub, len = {} local headers = headers or { ['User-Agent'] = 'Mozilla/4.0', ['Accept'] = '*/*', ['Accept-Language'] = 'zh-CN,zh,cn', ['Content-Type'] = 'application/x-www-form-urlencoded', ['Content-Length'] = '0', ['Connection'] = 'close' } -- 判断SSL支持是否满足 local ssl, https = string.find(rtos.get_version(), 'SSL'), url:find('https://') if ssl == nil and https then return '401', 'SOCKET_SSL_ERROR' end -- 对host:port整形 if url:find('://') then url = url:sub(8) end sub = url:find('/') if not sub then url = url .. '/' sub = -1 end str = url:match('([%w%.%-%:]+)/') port = str:match(':(%d+)') or 80 host = str:match('[%w%.%-]+') path = url:sub(sub) sub = '' -- 处理查询字符串 if params ~= nil and type(params) == 'table' then path = path .. '?' .. urlencodeTab(params) end -- 处理HTTP协议body部分的数据 ctype = ctype or 2 headers['Content-Type'] = Content_type[ctype] if ctype == 1 and data ~= nil then if type(data) == 'table' then data = table.concat(data) end sub = urlencodeTab(data) len = string.len(sub) headers['Content-Length'] = len or 0 elseif ctype == 2 and data ~= nil then if type(data) == 'table' then sub = json.encode(data) elseif type(data) == 'string' then sub = data end len = string.len(sub) headers['Content-Length'] = len or 0 elseif ctype == 3 and type(data) == 'string' then len = io.filesize(data) headers['Content-Length'] = len or 0 end -- 处理HTTP Basic Authorization 验证 if basic ~= nil and type(basic) == 'string' then headers['Authorization'] = 'Basic ' .. crypto.base64_encode(basic, #basic) end -- 处理headers部分 local msg = {} for k, v in pairs(headers) do table.insert(msg, k .. ': ' .. v) end -- 合并request报文 str = str .. '\r\n' .. table.concat(msg, '\r\n') .. '\r\n\r\n' -- log.info("http.request send:", str:tohex()) -- 发送请求报文 local c = socket.tcp() if not c:connect(host, port) then c:close() return '502', 'SOCKET_CONN_ERROR' end if ctype ~= 3 then str = method .. ' ' .. path .. ' HTTP/1.0\r\nHost: ' .. str .. sub .. '\r\n' if not c:send(str) then c:close() return '426', 'SOCKET_SEND_ERROR' end else str = method .. ' ' .. path .. ' HTTP/1.0\r\nHost: ' .. str if not c:send(str) then c:close() return '426', 'SOCKET_SEND_ERROR' end local file = io.open(data, 'r') if file then while true do local dat = file:read(1024) if dat == nil then io.close(file) break end log.info('http.request dat:', dat:tohex()) if not c:send(dat) then io.close(file) c:close() return '426', 'SOCKET_SEND_ERROR' end end end if not c:send('\r\n') then c:close() return '426', 'SOCKET_SEND_ERROR' end end msg = {} r, s = c:recv(timeout) if not r then return '503', 'SOCKET_RECV_TIMOUT' end response_code = s:match(' (%d+) ') response_message = s:match(' (%a+)') log.info('http.response code and message:\t', response_code, response_message) for k, v in s:gmatch('([%a%-]+): (%C+)') do response_header[k] = v end gzip = s:match('%aontent%-%ancoding: (%a+)') while true do table.insert(msg, s) r, s = c:recv(timeout) if not r then break end end c:close() str = table.concat(msg) sub, len = str:find('\r?\n\r?\n') if gzip then return response_code, response_header, ((zlib.inflate(table.concat(msg))):read()) end return response_code, response_header, str:sub(len + 1, -1) end