From c7392c4d96d667014bad5bbdb0174e0591b08033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lera=20Elvo=C3=A9?= Date: Tue, 9 May 2023 18:03:30 +0300 Subject: [PATCH] add http server --- addons/http_server/http_server.gd | 162 ++++++++++++++++++++++++++++++ addons/http_server/method.gd | 61 +++++++++++ addons/http_server/plugin.cfg | 7 ++ addons/http_server/plugin.gd | 10 ++ addons/http_server/request.gd | 69 +++++++++++++ addons/http_server/response.gd | 57 +++++++++++ addons/http_server/status.gd | 147 +++++++++++++++++++++++++++ 7 files changed, 513 insertions(+) create mode 100644 addons/http_server/http_server.gd create mode 100644 addons/http_server/method.gd create mode 100644 addons/http_server/plugin.cfg create mode 100644 addons/http_server/plugin.gd create mode 100644 addons/http_server/request.gd create mode 100644 addons/http_server/response.gd create mode 100644 addons/http_server/status.gd diff --git a/addons/http_server/http_server.gd b/addons/http_server/http_server.gd new file mode 100644 index 0000000..965e7ab --- /dev/null +++ b/addons/http_server/http_server.gd @@ -0,0 +1,162 @@ +class_name HTTPServer extends TCPServer + + +# Public constants + +const Method = preload("res://addons/http_server/method.gd") +const Request = preload("res://addons/http_server/request.gd") +const Response = preload("res://addons/http_server/response.gd") +const Status = preload("res://addons/http_server/status.gd") + + +# Private variables + +var __endpoints: Dictionary = { + # key: [Int, String], array with 0 index representing method, 1 index representing endpoint + # value: Callable, reference to function to call +} +var __fallback: Callable +var __server: TCPServer = null + + +# Public methods + +func endpoint(type: int, endpoint: String, function: Callable) -> void: + var endpoint_hash: Array = [type, endpoint] + if endpoint_hash in __endpoints: + print( + "[ERR] Endpoint already defined type: %s, endpoint: %s" % [ + Method.type_to_identifier(type), + endpoint, + ] + ) + return + + __endpoints[endpoint_hash] = function + + +func fallback(function: Callable) -> void: + __fallback = function + + +func __take_connection() -> StreamPeerTCP: + if !is_listening(): + print( + "[ERR] Server is not listening, please initialize and listen before calling `take_connection`" + ) + return null + + var connection: StreamPeerTCP = take_connection() + + if connection: + __process_connection(connection) + + return connection + + +# Private methods + +func __process_connection(connection: StreamPeerTCP) -> void: + var content: PackedByteArray = PackedByteArray([]) + + while true: + var bytes = connection.get_available_bytes() + if bytes == 0: + break + + var data = connection.get_partial_data(bytes) + content.append_array(data[1]) + + if content.is_empty(): + return + + var content_string: String = content.get_string_from_utf8() + var content_parts: Array = content_string.split("\r\n") + + if content_parts.is_empty(): + connection.put_data(__response_from_status(Status.BAD_REQUEST).to_utf8_buffer()) + return + + var request_line = content_parts[0] + var request_line_parts = request_line.split(" ") + + var method: String = request_line_parts[0] + var endpoint: String = request_line_parts[1] + + var headers: Dictionary = {} + var header_index: int = content_parts.find("") + + if header_index == -1: + print( + "[ERR] Error parsing request data: %s" % [str(content)] + ) + connection.put_data(__response_from_status(Status.BAD_REQUEST).to_utf8_buffer()) + return + + for i in range(1, header_index): + var header_parts: Array = content_parts[i].split(":", true, 1) + var header = header_parts[0].strip_edges().to_lower() + var value = header_parts[1].strip_edges() + + headers[header] = value + + var body: String = "" + if header_index != content_parts.size() - 1: + var body_parts: Array = content_parts.slice(header_index + 1, content_parts.size()) + body = "\r\n".join(PackedStringArray(body_parts)) + + var response: Response = __process_request(method, endpoint, headers, body) + connection.put_data(response.to_utf8_buffer()) + + +func __process_request(method: String, endpoint: String, headers: Dictionary, body: String) -> Response: + var type: int = Method.description_to_type(method) + + var request: Request = Request.new( + type, + endpoint, + headers, + body + ) + + var endpoint_func: Callable + var endpoint_parts: PackedStringArray = endpoint.split("/", false) + + while endpoint_func.is_null(): + var endpoint_hash: Array = [type, "/" + "/".join(endpoint_parts)] + if __endpoints.has(endpoint_hash): + endpoint_func = __endpoints[endpoint_hash] + elif endpoint_parts.is_empty(): + break + else: + endpoint_parts.remove_at(endpoint_parts.size() - 1) + + if endpoint_func.is_null(): + print( + "[WRN] Recieved request for unknown endpoint, method: %s, endpoint: %s" % [method, endpoint] + ) + if __fallback: + endpoint_func = __fallback + else: + return __response_from_status(Status.NOT_FOUND) + + var response: Response = Response.new() + + if endpoint_func.is_null(): + print( + "[ERR] Callable for endpoint not valid, method: %s, endpoint: %s" % [method, endpoint] + ) + else: + print( + "[INF] Recieved request method: %s, endpoint: %s" % [method, endpoint] + ) + endpoint_func.call(request, response) + + return response + + +func __response_from_status(code: int) -> Response: + var response: Response = Response.new() + response.status(code) + + return response diff --git a/addons/http_server/method.gd b/addons/http_server/method.gd new file mode 100644 index 0000000..319b6e6 --- /dev/null +++ b/addons/http_server/method.gd @@ -0,0 +1,61 @@ +# Public Constants + +enum { + GET = 0, + HEAD, + POST, + PUT, + DELETE, + CONNECT, + OPTIONS, + TRACE, + PATCH +} + + +# Private constants + +const __DESCRIPTIONS: Dictionary = { + GET: "Get", + HEAD: "Head", + POST: "Post", + PUT: "Put", + DELETE: "Delete", + CONNECT: "Connect", + OPTIONS: "Options", + TRACE: "Trace", + PATCH: "Patch", +} + +const __TYPES: Dictionary = { + "GET": GET, + "HEAD": HEAD, + "POST": POST, + "PUT": PUT, + "DELETE": DELETE, + "CONNECT": CONNECT, + "OPTIONS": OPTIONS, + "TRACE": TRACE, + "PATCH": PATCH, +} + + +# Public methods + +static func description_to_type(description: String) -> int: + return identifier_to_type(description.to_upper()) + + +static func identifier_to_type(identifier: String) -> int: + if __TYPES.has(identifier): + return __TYPES[identifier] + + return -1 + + +static func type_to_description(type: int) -> String: + return __DESCRIPTIONS[type] + + +static func type_to_identifier(type: int) -> String: + return type_to_description(type).to_upper() diff --git a/addons/http_server/plugin.cfg b/addons/http_server/plugin.cfg new file mode 100644 index 0000000..e87debb --- /dev/null +++ b/addons/http_server/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="HTTPServer" +description="HTTP Server implementation for receiving HTTP requests from external sources." +author="velopman" +version="0.1" +script="plugin.gd" diff --git a/addons/http_server/plugin.gd b/addons/http_server/plugin.gd new file mode 100644 index 0000000..fcebda0 --- /dev/null +++ b/addons/http_server/plugin.gd @@ -0,0 +1,10 @@ +@tool +extends EditorPlugin + + +func _enter_tree() -> void: + pass + + +func _exit_tree() -> void: + pass diff --git a/addons/http_server/request.gd b/addons/http_server/request.gd new file mode 100644 index 0000000..2553610 --- /dev/null +++ b/addons/http_server/request.gd @@ -0,0 +1,69 @@ + +# Public constants + +const Method = preload("res://addons/http_server/method.gd") + +# Private variables + +var __body: String = "" +var __endpoint: String = "" +var __headers: Dictionary = { + # key: String, header name + # value: Variant, header value +} +var __json_data = null # Variant +var __type: int = Method.GET + + +# Lifecyle methods + +func _init(type: int,endpoint: String,headers: Dictionary,body: String): + __body = body + __endpoint = endpoint + __headers = headers + __type = type + + +# Public methods + +func body() -> String: + return __body + + +func endpoint() -> String: + return __endpoint + + +func header(name: String = "", default = null): # Variant + return __headers.get(name, default) + + +func headers() -> Dictionary: + return __headers + + +func json(): # Variant + if __json_data != null: + return __json_data + + var content_type = header("content-type") + if content_type != "application/json": + print( + "[WRN] Attempting to call get_json checked a request with content-type: %s" % [content_type] + ) + return null + + var json = JSON.new() + var test_json_conv = json.parse(__body) + if test_json_conv != OK: + print( + "[ERR] Error parsing request json at line %s: %s" % [json.get_error_line(), json.get_error_message()] + ) + + __json_data = json.get_data() + + return __json_data + + +func type() -> int: + return __type diff --git a/addons/http_server/response.gd b/addons/http_server/response.gd new file mode 100644 index 0000000..67bc8d9 --- /dev/null +++ b/addons/http_server/response.gd @@ -0,0 +1,57 @@ +# Public Constants + +const Status = preload("res://addons/http_server/status.gd") + + +# Private variables + +var __data = "" # variant +var __headers: Dictionary = { + # key: String, header name + # value: Variant, header value +} +var __status: int = 200 + + +# Public methods + +func data(data) -> void: # data: Variant + __data = data + + +func header(name: String, value) -> void: # value: Variant + __headers[name.to_lower()] = value + + +func json(data) -> void: # data: Variant + header("content-type", "application/json") + __data = data + + +func status(status: int) -> void: + __status = status + + +func to_utf8_buffer() -> PackedByteArray: + var content = PackedStringArray() + + content.append(Status.code_to_status_line(__status)) + + var data = __data + if data.is_empty(): + data = Status.code_to_description(__status) + + if __headers.get("content-type", "") == "application/json": + data = JSON.stringify(data) + + __headers['content-length'] = len(data) + + for header in __headers: + content.append("%s: %s" % [header, str(__headers[header])]) + + content.append("") + + if data: + content.append(data) + + return "\r\n".join(content).to_utf8_buffer() diff --git a/addons/http_server/status.gd b/addons/http_server/status.gd new file mode 100644 index 0000000..b739927 --- /dev/null +++ b/addons/http_server/status.gd @@ -0,0 +1,147 @@ +# Public constants + +enum { + CONTINUE = 100, + SWITCHING_PROTOCOLS = 101, + PROCESSING = 102, + EARLY_HINTS = 103, + OK = 200, + CREATED = 201, + ACCEPTED = 202, + NON_AUTHORITATIVE_INFORMATION = 203, + NO_CONTENT = 204, + RESET_CONTENT = 205, + PARTIAL_CONTENT = 206, + MULTI_STATUS = 207, + ALREADY_REPORTED = 208, + IM_USED = 226, + MULTIPLE_CHOICE = 300, + MOVED_PERMANENTLY = 301, + FOUND = 302, + SEE_OTHER = 303, + NOT_MODIFIED = 304, + TEMPORARY_REDIRECT = 307, + PERMANENT_REDIRECT = 308, + BAD_REQUEST = 400, + UNAUTHORIZED = 401, + PAYMENT_REQUIRED = 402, + FORBIDDEN = 403, + NOT_FOUND = 404, + METHOD_NOT_ALLOWED = 405, + NOT_ACCEPTABLE = 406, + PROXY_AUTHENTICATION_REQUIRED = 407, + REQUEST_TIMEOUT = 408, + CONFLICT = 409, + GONE = 410, + LENGTH_REQUIRED = 411, + PRECONDITION_FAILED = 412, + PAYLOAD_TOO_LARGE = 413, + URI_TOO_LONG = 414, + UNSUPPORTED_MEDIA_TYPE = 415, + RANGE_NOT_SATISFIABLE = 416, + EXPECTATION_FAILED = 417, + IM_A_TEAPOT = 418, + MISDIRECTED_REQUEST = 421, + UNPROCESSABLE_ENTITY = 422, + LOCKED = 423, + FAILED_DEPENDENCY = 424, + TOO_EARLY = 425, + UPGRADE_REQUIRED = 426, + PRECONDITION_REQUIRED = 428, + TOO_MANY_REQUESTS = 429, + REQUEST_HEADER_FIELDS_TOO_LARGE = 431, + UNAVAILABLE_FOR_LEGAL_REASONS = 451, + INTERNAL_SERVER_ERROR = 500, + NOT_IMPLEMENTED = 501, + BAD_GATEWAY = 502, + SERVICE_UNAVAILABLE = 503, + GATEWAY_TIMEOUT = 504, + HTTP_VERSION_NOT_SUPPORTED = 505, + VARIANT_ALSO_NEGOTIATES = 506, + INSUFFICIENT_STORAGE = 507, + LOOP_DETECTED = 508, + NOT_EXTENDED = 510, + NETWORK_AUTHENTICATION_REQUIRED = 511, +} + + +# Private constants + +const __DESCRIPTIONS: Dictionary = { + CONTINUE: "Continue", + SWITCHING_PROTOCOLS: "Switching Protocols", + PROCESSING: "Processing", + EARLY_HINTS: "Early Hints", + OK: "Ok", + CREATED: "Created", + ACCEPTED: "Accepted", + NON_AUTHORITATIVE_INFORMATION: "Non-Authoritative Information", + NO_CONTENT: "No Content", + RESET_CONTENT: "Reset Content", + PARTIAL_CONTENT: "Partial Content", + MULTI_STATUS: "Multi-Status", + ALREADY_REPORTED: "Already Reported", + IM_USED: "IM Used", + MULTIPLE_CHOICE: "Multiple Choice", + MOVED_PERMANENTLY: "Moved Permanently", + FOUND: "Found", + SEE_OTHER: "See Other", + NOT_MODIFIED: "Not Modified", + TEMPORARY_REDIRECT: "Temporary Redirect", + PERMANENT_REDIRECT: "Permanent Redirect", + BAD_REQUEST: "Bad Request", + UNAUTHORIZED: "Unauthorized", + PAYMENT_REQUIRED: "Payment Required", + FORBIDDEN: "Forbidden", + NOT_FOUND: "Not Found", + METHOD_NOT_ALLOWED: "Method Not Allowed", + NOT_ACCEPTABLE: "Not Acceptable", + PROXY_AUTHENTICATION_REQUIRED: "Proxy Authentication Requested", + REQUEST_TIMEOUT: "Request Timeout", + CONFLICT: "Conflict", + GONE: "Gone", + LENGTH_REQUIRED: "Length Required", + PRECONDITION_FAILED: "Precondition Failed", + PAYLOAD_TOO_LARGE: "Payload Too Large", + URI_TOO_LONG: "URI Too long", + UNSUPPORTED_MEDIA_TYPE: "Unsupported Media Type", + RANGE_NOT_SATISFIABLE: "Range Not Satisfiable", + EXPECTATION_FAILED: "Expectation Failed", + IM_A_TEAPOT: "I'm A Teapot", + MISDIRECTED_REQUEST: "Misdirected Request", + UNPROCESSABLE_ENTITY: "Unprocessable Entity", + LOCKED: "Locked", + FAILED_DEPENDENCY: "Failed Dependency", + TOO_EARLY: "Too Early", + UPGRADE_REQUIRED: "Upgrade Required", + PRECONDITION_REQUIRED: "Precondition Required", + TOO_MANY_REQUESTS: "Too Many Requests", + REQUEST_HEADER_FIELDS_TOO_LARGE: "Request Header Fields Too Large", + UNAVAILABLE_FOR_LEGAL_REASONS: "Unavailable For Legal Reasons", + INTERNAL_SERVER_ERROR: "Internal Server Error", + NOT_IMPLEMENTED: "Not Implemented", + BAD_GATEWAY: "Bad Gateway", + SERVICE_UNAVAILABLE: "Service Unavailable", + GATEWAY_TIMEOUT: "Gateway Timeout", + HTTP_VERSION_NOT_SUPPORTED: "HTTP Version Not Supported", + VARIANT_ALSO_NEGOTIATES: "Variant Also Negotiates", + INSUFFICIENT_STORAGE: "Insufficient Storage", + LOOP_DETECTED: "Loop detected", + NOT_EXTENDED: "Not Extended", + NETWORK_AUTHENTICATION_REQUIRED: "Network Authentication Required", +} + + +# Public methods + +static func code_to_description(code: int) -> String: + return __DESCRIPTIONS[code] + + +static func code_to_identifier(code: int) -> String: + return code_to_description(code).to_upper().replace(" ", "_").replace("'", "") + + +static func code_to_status_line(code: int) -> String: + return "HTTP/1.1 %d %s" % [code, code_to_identifier(code)] +