extends Node class_name SammiClient ## Creates a HTTP Client connection to communicate with SAMMI. Due to current restrictions ## Godot expects Content-Length or transfer-enoding: chunked headers to be set properly, otherwise ## Godot assumes there is no body, and doesn't read it. SAMMI's custom internal HTTP Server does ## not set the Content-Length header for response headers to the API. Hence it will fail with a ## normal Godot HTTPClient. signal request_completed(response: SammiResponse) @export_category("SAMMI Connection Information") @export var host: String = "127.0.0.1" @export var port: int = 9450 @export var user_agent: String = "GodotSammi/0.1 (%s)" % OS.get_name() @export_category("Authentication") @export_custom(PROPERTY_HINT_PASSWORD, "") var password: String = "" var client: StreamPeerTCP var state: State = State.CLOSED var buffer: PackedByteArray var headers: PackedStringArray var response: SammiResponse var query: String = "" var method: Method = Method.GET var body: String = "" enum State { CONNECTING, REQUESTING, RESPONSE, ERROR, CLOSED } enum Method { GET, POST, } func request(method: Method, api_path: String, body: String) -> void: assert(state == State.CLOSED, "A request is in the process of being made.") self.query = api_path self.body = body self.method = method self.client = StreamPeerTCP.new() self.client.connect_to_host(host, port) self.state = State.CONNECTING func _process(_delta: float) -> void: if not client: return client.poll() if client.get_status() == StreamPeerTCP.Status.STATUS_CONNECTED and state == State.CONNECTING: state = State.REQUESTING _send_request() elif client.get_status() == StreamPeerTCP.Status.STATUS_CONNECTED and state == State.REQUESTING: state = State.RESPONSE _read_response() elif client.get_status() == StreamPeerTCP.Status.STATUS_CONNECTED and state == State.RESPONSE: _read_response() elif client.get_status() == StreamPeerTCP.Status.STATUS_ERROR: state = State.ERROR print("Error: ", client.get_error()) client.disconnect_from_host() client = null func _send_request() -> void: var request_str = "" if method == Method.GET: request_str = "GET %s HTTP/1.1\r\n" % query else: request_str = "POST %s HTTP/1.1\r\n" % query request_str += "Host: %s:%d\r\n" % [host, port] request_str += "User-Agent: %s\r\n" % user_agent request_str += "Accept: */*\r\n" if password != "": request_str += "Authorization: %s\r\n" % password if method == Method.POST and body != "": request_str += "Content-Type: application/json\r\n" request_str += "Content-Length: %d\r\n" % body.length() request_str += "\r\n" if method == Method.POST and body != "": request_str += body client.put_data(request_str.to_utf8_buffer()) func _read_response() -> void: client.poll() if headers.size() == 0: while true: var byte := client.get_partial_data(1) if byte[0] == OK and byte[1].size() == 0: client.poll() continue elif byte[0] == OK: buffer.push_back(byte[1][0]) else: state = State.ERROR print("Error reading response headers.") return if buffer.size() < 4: client.poll() continue if (buffer[-2] == 10 and buffer[-1] == 10) or \ (buffer[-4] == 13 and buffer[-3] == 10 and buffer[-2] == 13 and buffer[-1] == 10): var data := buffer.get_string_from_utf8() buffer = [] headers = data.replace("\r", "").rstrip("\n\n").split("\n") break client.poll() if headers.size() == 0: return var chunk := client.get_partial_data(1024) if chunk[0] == OK and chunk[1].size() > 0: buffer.append_array(chunk[1]) else: response = SammiResponse.new(headers, buffer, query) state = State.CLOSED headers = [] buffer = [] client.disconnect_from_host() client = null request_completed.emit(response) class SammiResponse: var code: int var headers: Dictionary var body: String var query: String var error_message: String func _init(p_headers: PackedStringArray, p_buffer: PackedByteArray, p_query: String) -> void: body = p_buffer.get_string_from_utf8() headers = {} for line in p_headers: if line.contains(": "): var parts := line.split(": ") headers[parts[0]] = parts[1] else: var parts := Array(line.split(" ")) if parts.pop_front() == "HTTP/1.1": code = int(parts.pop_front()) error_message = " ".join(parts) else: code = 500 error_message = "Unknown Error" query = p_query