diff --git a/addons/http_server/http_server.gd b/addons/http_server/http_server.gd new file mode 100644 index 0000000..ad527a2 --- /dev/null +++ b/addons/http_server/http_server.gd @@ -0,0 +1,175 @@ +class_name HTTPServer extends TCP_Server + + +# 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: [FuncRef, Variant], index 0 = reference to function to call, index 1 = binds to pass to func +} +var __fallback: FuncRef = null +var __server: TCP_Server = null + + +# Public methods + +func endpoint(type: int, endpoint: String, function: FuncRef, binds = null) -> 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, binds] + + +func fallback(function: FuncRef) -> 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: PoolByteArray = PoolByteArray([]) + + 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.empty(): + return + + var content_string: String = content.get_string_from_utf8() + var content_parts: Array = content_string.split("\r\n") + + if content_parts.empty(): + connection.put_data(__response_from_status(Status.BAD_REQUEST).to_utf8()) + 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" % [String(content)] + ) + connection.put_data(__response_from_status(Status.BAD_REQUEST).to_utf8()) + 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 = PoolStringArray(body_parts).join("\r\n") + + var response: Response = __process_request(method, endpoint, headers, body) + connection.put_data(response.to_utf8()) + + +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: FuncRef = null + var endpoint_parts: PoolStringArray = endpoint.split("/", false) + var binds + + # special case for if endpoint is just root + if endpoint == "/": + var endpoint_hash: Array = [type, "/"] + if __endpoints.has(endpoint_hash): + endpoint_func = __endpoints[endpoint_hash][0] + binds = __endpoints[endpoint_hash][1] + else: + while (!endpoint_func && !endpoint_parts.empty()): + var endpoint_hash: Array = [type, "/" + endpoint_parts.join("/")] + if __endpoints.has(endpoint_hash): + endpoint_func = __endpoints[endpoint_hash][0] + binds = __endpoints[endpoint_hash][1] + else: + endpoint_parts.remove(endpoint_parts.size() - 1) + + + if !endpoint_func: + 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_valid(): + print( + "[ERR] FuncRef for endpoint not valid, method: %s, endpoint: %s" % [method, endpoint] + ) + else: + print( + "[INF] Recieved request method: %s, endpoint: %s" % [method, endpoint] + ) + + if !binds: + endpoint_func.call_func(request, response) + else: + endpoint_func.call_func(request, response, binds) + + 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..b582dd9 --- /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..3209645 --- /dev/null +++ b/addons/http_server/request.gd @@ -0,0 +1,68 @@ + +# 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) -> void: + __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 on a request with content-type: %s" % [content_type] + ) + return null + + var result = JSON.parse(__body) + if result.error: + print( + "[ERR] Error parsing request json: %s" % [result.error_string] + ) + + __json_data = result.result + + 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..e7d3424 --- /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() -> PoolByteArray: + var content = PoolStringArray() + + content.append(Status.code_to_status_line(__status)) + + var data = __data + if !data: + data = Status.code_to_description(__status) + + if __headers.get("content-type", "") == "application/json": + data = JSON.print(data) + + __headers['content-length'] = len(data) + + for header in __headers: + content.append("%s: %s" % [header, String(__headers[header])]) + + content.append("") + + if data: + content.append(data) + + return content.join("\r\n").to_utf8() diff --git a/addons/http_server/status.gd b/addons/http_server/status.gd new file mode 100644 index 0000000..d854c84 --- /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)] + diff --git a/project.godot b/project.godot index 1e9da9a..619b742 100644 --- a/project.godot +++ b/project.godot @@ -8,11 +8,25 @@ config_version=4 +_global_script_classes=[ { +"base": "TCP_Server", +"class": "HTTPServer", +"language": "GDScript", +"path": "res://addons/http_server/http_server.gd" +} ] +_global_script_class_icons={ +"HTTPServer": "" +} + [application] config/name="Ticle Frontend" config/icon="res://icon.png" +[editor_plugins] + +enabled=PoolStringArray( "res://addons/http_server/plugin.cfg" ) + [gui] common/drop_mouse_on_gui_input_disabled=true