luajitos

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

HTTP.lua (20839B)


      1 -- HTTP Library for LuajitOS
      2 -- Implements HTTP/1.1 client and server functionality
      3 
      4 local HTTP = {}
      5 
      6 -- HTTP Status Codes
      7 HTTP.STATUS = {
      8     -- 1xx Informational
      9     CONTINUE = 100,
     10     SWITCHING_PROTOCOLS = 101,
     11 
     12     -- 2xx Success
     13     OK = 200,
     14     CREATED = 201,
     15     ACCEPTED = 202,
     16     NO_CONTENT = 204,
     17     PARTIAL_CONTENT = 206,
     18 
     19     -- 3xx Redirection
     20     MOVED_PERMANENTLY = 301,
     21     FOUND = 302,
     22     SEE_OTHER = 303,
     23     NOT_MODIFIED = 304,
     24     TEMPORARY_REDIRECT = 307,
     25 
     26     -- 4xx Client Error
     27     BAD_REQUEST = 400,
     28     UNAUTHORIZED = 401,
     29     FORBIDDEN = 403,
     30     NOT_FOUND = 404,
     31     METHOD_NOT_ALLOWED = 405,
     32     REQUEST_TIMEOUT = 408,
     33 
     34     -- 5xx Server Error
     35     INTERNAL_SERVER_ERROR = 500,
     36     NOT_IMPLEMENTED = 501,
     37     BAD_GATEWAY = 502,
     38     SERVICE_UNAVAILABLE = 503,
     39 }
     40 
     41 -- Status Code Descriptions
     42 HTTP.STATUS_TEXT = {
     43     [100] = "Continue",
     44     [101] = "Switching Protocols",
     45     [200] = "OK",
     46     [201] = "Created",
     47     [202] = "Accepted",
     48     [204] = "No Content",
     49     [206] = "Partial Content",
     50     [301] = "Moved Permanently",
     51     [302] = "Found",
     52     [303] = "See Other",
     53     [304] = "Not Modified",
     54     [307] = "Temporary Redirect",
     55     [400] = "Bad Request",
     56     [401] = "Unauthorized",
     57     [403] = "Forbidden",
     58     [404] = "Not Found",
     59     [405] = "Method Not Allowed",
     60     [408] = "Request Timeout",
     61     [500] = "Internal Server Error",
     62     [501] = "Not Implemented",
     63     [502] = "Bad Gateway",
     64     [503] = "Service Unavailable",
     65 }
     66 
     67 -- HTTP Methods
     68 HTTP.METHOD = {
     69     GET = "GET",
     70     POST = "POST",
     71     PUT = "PUT",
     72     DELETE = "DELETE",
     73     HEAD = "HEAD",
     74     OPTIONS = "OPTIONS",
     75     PATCH = "PATCH",
     76 }
     77 
     78 ---Parse URL into components
     79 ---@param url string URL to parse
     80 ---@return table|nil parsed {scheme, host, port, path, query}
     81 function HTTP.parse_url(url)
     82     if not url then
     83         return nil
     84     end
     85 
     86     local parsed = {
     87         scheme = nil,
     88         host = nil,
     89         port = nil,
     90         path = "/",
     91         query = nil,
     92         fragment = nil
     93     }
     94 
     95     -- Extract scheme
     96     local scheme_end = url:find("://")
     97     if scheme_end then
     98         parsed.scheme = url:sub(1, scheme_end - 1):lower()
     99         url = url:sub(scheme_end + 3)
    100     end
    101 
    102     -- Extract fragment
    103     local fragment_start = url:find("#")
    104     if fragment_start then
    105         parsed.fragment = url:sub(fragment_start + 1)
    106         url = url:sub(1, fragment_start - 1)
    107     end
    108 
    109     -- Extract query
    110     local query_start = url:find("?")
    111     if query_start then
    112         parsed.query = url:sub(query_start + 1)
    113         url = url:sub(1, query_start - 1)
    114     end
    115 
    116     -- Extract path
    117     local path_start = url:find("/")
    118     if path_start then
    119         parsed.path = url:sub(path_start)
    120         url = url:sub(1, path_start - 1)
    121     end
    122 
    123     -- Extract host and port
    124     local port_start = url:find(":")
    125     if port_start then
    126         parsed.host = url:sub(1, port_start - 1)
    127         parsed.port = tonumber(url:sub(port_start + 1))
    128     else
    129         parsed.host = url
    130         -- Default ports
    131         if parsed.scheme == "http" then
    132             parsed.port = 80
    133         elseif parsed.scheme == "https" then
    134             parsed.port = 443
    135         end
    136     end
    137 
    138     return parsed
    139 end
    140 
    141 ---Parse query string into table
    142 ---@param query string Query string
    143 ---@return table params Key-value pairs
    144 function HTTP.parse_query(query)
    145     if not query then
    146         return {}
    147     end
    148 
    149     local params = {}
    150     for pair in query:gmatch("[^&]+") do
    151         local key, value = pair:match("([^=]+)=?(.*)")
    152         if key then
    153             key = HTTP.url_decode(key)
    154             value = HTTP.url_decode(value or "")
    155             params[key] = value
    156         end
    157     end
    158 
    159     return params
    160 end
    161 
    162 ---URL encode a string
    163 ---@param str string String to encode
    164 ---@return string encoded URL-encoded string
    165 function HTTP.url_encode(str)
    166     if not str then
    167         return ""
    168     end
    169 
    170     str = str:gsub("\n", "\r\n")
    171     str = str:gsub("([^%w%-%.%_%~])", function(c)
    172         return string.format("%%%02X", string.byte(c))
    173     end)
    174 
    175     return str
    176 end
    177 
    178 ---URL decode a string
    179 ---@param str string String to decode
    180 ---@return string decoded URL-decoded string
    181 function HTTP.url_decode(str)
    182     if not str then
    183         return ""
    184     end
    185 
    186     str = str:gsub("+", " ")
    187     str = str:gsub("%%(%x%x)", function(h)
    188         return string.char(tonumber(h, 16))
    189     end)
    190 
    191     return str
    192 end
    193 
    194 ---Parse HTTP headers from string
    195 ---@param header_str string Header string
    196 ---@return table headers Key-value pairs of headers
    197 function HTTP.parse_headers(header_str)
    198     local headers = {}
    199 
    200     for line in header_str:gmatch("[^\r\n]+") do
    201         local key, value = line:match("^([^:]+):%s*(.+)$")
    202         if key and value then
    203             key = key:lower()
    204             headers[key] = value
    205         end
    206     end
    207 
    208     return headers
    209 end
    210 
    211 ---Build HTTP headers string
    212 ---@param headers table Key-value pairs of headers
    213 ---@return string header_str Headers as string
    214 function HTTP.build_headers(headers)
    215     local lines = {}
    216 
    217     for key, value in pairs(headers) do
    218         lines[#lines + 1] = key .. ": " .. tostring(value)
    219     end
    220 
    221     return table.concat(lines, "\r\n")
    222 end
    223 
    224 ---Parse HTTP request
    225 ---@param request_str string Raw HTTP request
    226 ---@return table|nil request Parsed request {method, path, version, headers, body}
    227 function HTTP.parse_request(request_str)
    228     if not request_str or #request_str == 0 then
    229         return nil
    230     end
    231 
    232     -- Split headers and body
    233     local header_end = request_str:find("\r\n\r\n")
    234     if not header_end then
    235         return nil
    236     end
    237 
    238     local header_part = request_str:sub(1, header_end - 1)
    239     local body = request_str:sub(header_end + 4)
    240 
    241     -- Parse request line
    242     local lines = {}
    243     for line in header_part:gmatch("[^\r\n]+") do
    244         lines[#lines + 1] = line
    245     end
    246 
    247     if #lines == 0 then
    248         return nil
    249     end
    250 
    251     local request_line = lines[1]
    252     local method, path, version = request_line:match("^(%S+)%s+(%S+)%s+(%S+)$")
    253 
    254     if not method or not path or not version then
    255         return nil
    256     end
    257 
    258     -- Parse headers
    259     local header_str = table.concat(lines, "\r\n", 2)
    260     local headers = HTTP.parse_headers(header_str)
    261 
    262     -- Parse query string
    263     local query_start = path:find("?")
    264     local query_params = {}
    265     if query_start then
    266         query_params = HTTP.parse_query(path:sub(query_start + 1))
    267         path = path:sub(1, query_start - 1)
    268     end
    269 
    270     return {
    271         method = method,
    272         path = path,
    273         version = version,
    274         headers = headers,
    275         body = body,
    276         query = query_params,
    277     }
    278 end
    279 
    280 ---Parse HTTP response
    281 ---@param response_str string Raw HTTP response
    282 ---@return table|nil response Parsed response {version, status, reason, headers, body}
    283 function HTTP.parse_response(response_str)
    284     if not response_str or #response_str == 0 then
    285         return nil
    286     end
    287 
    288     -- Split headers and body
    289     local header_end = response_str:find("\r\n\r\n")
    290     if not header_end then
    291         return nil
    292     end
    293 
    294     local header_part = response_str:sub(1, header_end - 1)
    295     local body = request_str:sub(header_end + 4)
    296 
    297     -- Parse status line
    298     local lines = {}
    299     for line in header_part:gmatch("[^\r\n]+") do
    300         lines[#lines + 1] = line
    301     end
    302 
    303     if #lines == 0 then
    304         return nil
    305     end
    306 
    307     local status_line = lines[1]
    308     local version, status, reason = status_line:match("^(%S+)%s+(%d+)%s*(.*)$")
    309 
    310     if not version or not status then
    311         return nil
    312     end
    313 
    314     status = tonumber(status)
    315 
    316     -- Parse headers
    317     local header_str = table.concat(lines, "\r\n", 2)
    318     local headers = HTTP.parse_headers(header_str)
    319 
    320     return {
    321         version = version,
    322         status = status,
    323         reason = reason,
    324         headers = headers,
    325         body = body,
    326     }
    327 end
    328 
    329 ---Build HTTP request string
    330 ---@param method string HTTP method
    331 ---@param path string Request path
    332 ---@param headers table Request headers
    333 ---@param body string|nil Request body
    334 ---@return string request HTTP request string
    335 function HTTP.build_request(method, path, headers, body)
    336     headers = headers or {}
    337     body = body or ""
    338 
    339     -- Ensure required headers
    340     if not headers["Host"] and not headers["host"] then
    341         headers["Host"] = "localhost"
    342     end
    343 
    344     if body and #body > 0 then
    345         headers["Content-Length"] = tostring(#body)
    346     end
    347 
    348     -- Build request
    349     local lines = {}
    350     lines[#lines + 1] = method .. " " .. path .. " HTTP/1.1"
    351     lines[#lines + 1] = HTTP.build_headers(headers)
    352     lines[#lines + 1] = ""
    353     lines[#lines + 1] = body
    354 
    355     return table.concat(lines, "\r\n")
    356 end
    357 
    358 ---Build HTTP response string
    359 ---@param status number HTTP status code
    360 ---@param headers table Response headers
    361 ---@param body string|nil Response body
    362 ---@return string response HTTP response string
    363 function HTTP.build_response(status, headers, body)
    364     headers = headers or {}
    365     body = body or ""
    366 
    367     local reason = HTTP.STATUS_TEXT[status] or "Unknown"
    368 
    369     -- Set content length
    370     if body and #body > 0 then
    371         headers["Content-Length"] = tostring(#body)
    372     end
    373 
    374     -- Default headers
    375     if not headers["Server"] and not headers["server"] then
    376         headers["Server"] = "LuajitOS/1.0"
    377     end
    378 
    379     if not headers["Connection"] and not headers["connection"] then
    380         headers["Connection"] = "close"
    381     end
    382 
    383     -- Build response
    384     local lines = {}
    385     lines[#lines + 1] = "HTTP/1.1 " .. status .. " " .. reason
    386     lines[#lines + 1] = HTTP.build_headers(headers)
    387     lines[#lines + 1] = ""
    388     lines[#lines + 1] = body
    389 
    390     return table.concat(lines, "\r\n")
    391 end
    392 
    393 ---Create HTTP client
    394 ---@param NetworkStack table Network stack instance
    395 ---@return table client HTTP client object
    396 function HTTP.create_client(NetworkStack)
    397     local client = {
    398         NetworkStack = NetworkStack,
    399         Socket = nil,
    400         default_timeout = 30,
    401     }
    402 
    403     ---Send HTTP request
    404     ---@param method string HTTP method
    405     ---@param url string URL to request
    406     ---@param options table|nil Options {headers, body, timeout}
    407     ---@return table|nil response Response object or nil on error
    408     function client.request(method, url, options)
    409         options = options or {}
    410 
    411         -- Parse URL
    412         local parsed = HTTP.parse_url(url)
    413         if not parsed or not parsed.host then
    414             return nil, "Invalid URL"
    415         end
    416 
    417         -- Resolve host to IP (basic - assumes dotted decimal or known host)
    418         local ip
    419         if parsed.host:match("^%d+%.%d+%.%d+%.%d+$") then
    420             ip = NetworkStack.parse_ip(parsed.host)
    421         else
    422             -- For now, use gateway as default (real implementation would need DNS)
    423             ip = NetworkStack.config.gateway
    424         end
    425 
    426         if not ip then
    427             return nil, "Could not resolve host"
    428         end
    429 
    430         -- Load Socket library if needed
    431         if not client.Socket then
    432             local socket_handle = CRamdiskOpen("/os/libs/Socket.lua", "r")
    433             if socket_handle then
    434                 local socket_code = CRamdiskRead(socket_handle)
    435                 CRamdiskClose(socket_handle)
    436                 if socket_code then
    437                     local socket_func = load(socket_code, "/os/libs/Socket.lua", "t")
    438                     if socket_func then
    439                         client.Socket = socket_func()
    440                     end
    441                 end
    442             end
    443         end
    444 
    445         if not client.Socket then
    446             return nil, "Socket library not available"
    447         end
    448 
    449         -- Create TCP socket
    450         local sock = client.Socket.tcp(NetworkStack)
    451 
    452         -- Connect
    453         local connected = false
    454         local connect_timeout = options.timeout or client.default_timeout
    455 
    456         sock:on("connected", function()
    457             connected = true
    458         end)
    459 
    460         if not sock:connect(ip, parsed.port) then
    461             return nil, "Connection failed"
    462         end
    463 
    464         -- Wait for connection
    465         local start = os.time and os.time() or 0
    466         while not connected and (os.time and os.time() or 0) - start < connect_timeout do
    467             NetworkStack.RTL8139.poll()
    468         end
    469 
    470         if not connected then
    471             sock:close()
    472             return nil, "Connection timeout"
    473         end
    474 
    475         -- Build and send request
    476         local headers = options.headers or {}
    477         headers["Host"] = parsed.host
    478         if not headers["Connection"] then
    479             headers["Connection"] = "close"
    480         end
    481 
    482         local path = parsed.path
    483         if parsed.query then
    484             path = path .. "?" .. parsed.query
    485         end
    486 
    487         local request = HTTP.build_request(method, path, headers, options.body)
    488 
    489         if not sock:send(request) then
    490             sock:close()
    491             return nil, "Send failed"
    492         end
    493 
    494         -- Receive response
    495         local response_data = {}
    496         local receive_timeout = options.timeout or client.default_timeout
    497 
    498         sock:on("data", function(data)
    499             response_data[#response_data + 1] = data
    500         end)
    501 
    502         local closed = false
    503         sock:on("closed", function()
    504             closed = true
    505         end)
    506 
    507         -- Wait for response
    508         start = os.time and os.time() or 0
    509         while not closed and (os.time and os.time() or 0) - start < receive_timeout do
    510             NetworkStack.RTL8139.poll()
    511 
    512             -- Check if we have a complete response
    513             local current = table.concat(response_data)
    514             if current:find("\r\n\r\n") then
    515                 -- Check content-length
    516                 local headers_end = current:find("\r\n\r\n")
    517                 local header_part = current:sub(1, headers_end - 1)
    518                 local content_length = header_part:match("[Cc]ontent%-[Ll]ength:%s*(%d+)")
    519 
    520                 if content_length then
    521                     content_length = tonumber(content_length)
    522                     local body_start = headers_end + 4
    523                     if #current - body_start + 1 >= content_length then
    524                         break
    525                     end
    526                 else
    527                     -- No content-length, wait for connection close
    528                     if closed then
    529                         break
    530                     end
    531                 end
    532             end
    533         end
    534 
    535         sock:close()
    536 
    537         -- Parse response
    538         local response_str = table.concat(response_data)
    539         if #response_str == 0 then
    540             return nil, "No response"
    541         end
    542 
    543         return HTTP.parse_response(response_str)
    544     end
    545 
    546     ---GET request
    547     ---@param url string URL to request
    548     ---@param options table|nil Options
    549     ---@return table|nil response Response object
    550     function client.get(url, options)
    551         return client.request(HTTP.METHOD.GET, url, options)
    552     end
    553 
    554     ---POST request
    555     ---@param url string URL to request
    556     ---@param body string Request body
    557     ---@param options table|nil Options
    558     ---@return table|nil response Response object
    559     function client.post(url, body, options)
    560         options = options or {}
    561         options.body = body
    562         return client.request(HTTP.METHOD.POST, url, options)
    563     end
    564 
    565     return client
    566 end
    567 
    568 ---Create HTTP server
    569 ---@param NetworkStack table Network stack instance
    570 ---@param port number Port to listen on
    571 ---@return table server HTTP server object
    572 function HTTP.create_server(NetworkStack, port)
    573     port = port or 80
    574 
    575     local server = {
    576         NetworkStack = NetworkStack,
    577         Socket = nil,
    578         port = port,
    579         routes = {},
    580         running = false,
    581     }
    582 
    583     ---Register route handler
    584     ---@param method string HTTP method
    585     ---@param path string Path pattern
    586     ---@param handler function Handler(request, response)
    587     function server.route(method, path, handler)
    588         local key = method .. " " .. path
    589         server.routes[key] = handler
    590     end
    591 
    592     ---Handle incoming request
    593     ---@param request_str string Raw request
    594     ---@return string response HTTP response
    595     function server.handle_request(request_str)
    596         local request = HTTP.parse_request(request_str)
    597 
    598         if not request then
    599             return HTTP.build_response(HTTP.STATUS.BAD_REQUEST, {}, "Bad Request")
    600         end
    601 
    602         -- Find matching route
    603         local key = request.method .. " " .. request.path
    604         local handler = server.routes[key]
    605 
    606         -- Try wildcard routes
    607         if not handler then
    608             for route_key, route_handler in pairs(server.routes) do
    609                 local route_method, route_path = route_key:match("^(%S+)%s+(.+)$")
    610                 if route_method == request.method then
    611                     -- Simple wildcard matching
    612                     local pattern = route_path:gsub("*", ".*")
    613                     if request.path:match("^" .. pattern .. "$") then
    614                         handler = route_handler
    615                         break
    616                     end
    617                 end
    618             end
    619         end
    620 
    621         if not handler then
    622             return HTTP.build_response(HTTP.STATUS.NOT_FOUND, {}, "Not Found")
    623         end
    624 
    625         -- Create response object
    626         local response = {
    627             status = HTTP.STATUS.OK,
    628             headers = {},
    629             body = "",
    630         }
    631 
    632         function response.send(body, status)
    633             response.body = body or ""
    634             response.status = status or response.status
    635         end
    636 
    637         function response.json(data)
    638             response.headers["Content-Type"] = "application/json"
    639             response.body = HTTP.json_encode(data)
    640         end
    641 
    642         function response.html(html)
    643             response.headers["Content-Type"] = "text/html"
    644             response.body = html
    645         end
    646 
    647         -- Call handler
    648         local success, err = pcall(handler, request, response)
    649 
    650         if not success then
    651             return HTTP.build_response(
    652                 HTTP.STATUS.INTERNAL_SERVER_ERROR,
    653                 {},
    654                 "Internal Server Error: " .. tostring(err)
    655             )
    656         end
    657 
    658         return HTTP.build_response(response.status, response.headers, response.body)
    659     end
    660 
    661     ---Start HTTP server
    662     function server.start()
    663         -- Load Socket library if needed
    664         if not server.Socket then
    665             local socket_handle = CRamdiskOpen("/os/libs/Socket.lua", "r")
    666             if socket_handle then
    667                 local socket_code = CRamdiskRead(socket_handle)
    668                 CRamdiskClose(socket_handle)
    669                 if socket_code then
    670                     local socket_func = load(socket_code, "/os/libs/Socket.lua", "t")
    671                     if socket_func then
    672                         server.Socket = socket_func()
    673                     end
    674                 end
    675             end
    676         end
    677 
    678         if not server.Socket then
    679             return false, "Socket library not available"
    680         end
    681 
    682         -- Create TCP listening socket (simplified - real implementation needs listen/accept)
    683         -- For now, we'll use UDP for demo purposes
    684         local sock = server.Socket.udp(NetworkStack)
    685 
    686         if not sock:bind(port) then
    687             return false, "Failed to bind to port " .. port
    688         end
    689 
    690         -- Handle incoming connections
    691         sock:on("data", function(src_ip, src_port, data)
    692             local response = server.handle_request(data)
    693             sock:send(response, src_ip, src_port)
    694         end)
    695 
    696         server.running = true
    697         return true
    698     end
    699 
    700     ---Stop HTTP server
    701     function server.stop()
    702         server.running = false
    703     end
    704 
    705     return server
    706 end
    707 
    708 ---Simple JSON encoder (basic implementation)
    709 ---@param data any Data to encode
    710 ---@return string json JSON string
    711 function HTTP.json_encode(data)
    712     local t = type(data)
    713 
    714     if t == "nil" then
    715         return "null"
    716     elseif t == "boolean" then
    717         return data and "true" or "false"
    718     elseif t == "number" then
    719         return tostring(data)
    720     elseif t == "string" then
    721         return '"' .. data:gsub('"', '\\"'):gsub("\n", "\\n"):gsub("\r", "\\r") .. '"'
    722     elseif t == "table" then
    723         -- Check if array
    724         local is_array = true
    725         local max_index = 0
    726         for k, v in pairs(data) do
    727             if type(k) ~= "number" or k < 1 or k ~= math.floor(k) then
    728                 is_array = false
    729                 break
    730             end
    731             max_index = math.max(max_index, k)
    732         end
    733 
    734         if is_array then
    735             local items = {}
    736             for i = 1, max_index do
    737                 items[#items + 1] = HTTP.json_encode(data[i])
    738             end
    739             return "[" .. table.concat(items, ",") .. "]"
    740         else
    741             local items = {}
    742             for k, v in pairs(data) do
    743                 items[#items + 1] = HTTP.json_encode(tostring(k)) .. ":" .. HTTP.json_encode(v)
    744             end
    745             return "{" .. table.concat(items, ",") .. "}"
    746         end
    747     else
    748         return '"' .. tostring(data) .. '"'
    749     end
    750 end
    751 
    752 return HTTP