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