diff --git a/openc3/lib/openc3/interfaces/http_server_interface.rb b/openc3/lib/openc3/interfaces/http_server_interface.rb index 450a537147..c65e31f8db 100644 --- a/openc3/lib/openc3/interfaces/http_server_interface.rb +++ b/openc3/lib/openc3/interfaces/http_server_interface.rb @@ -75,6 +75,7 @@ def connect # No HTTP_STATUS - Leave at default end + # http_accessor stores all the pseudo-derived HTTP configuration in extra if packet.extra headers = packet.extra['HTTP_HEADERS'] if headers diff --git a/openc3/spec/install/config/targets/INST/cmd_tlm/inst_cmds.txt b/openc3/spec/install/config/targets/INST/cmd_tlm/inst_cmds.txt index 3306498d5b..82a9e51cbb 100644 --- a/openc3/spec/install/config/targets/INST/cmd_tlm/inst_cmds.txt +++ b/openc3/spec/install/config/targets/INST/cmd_tlm/inst_cmds.txt @@ -135,3 +135,8 @@ COMMAND INST DISABLED BIG_ENDIAN "Disabled command" PARAMETER CCSDSSEQCNT 18 14 UINT 0 16383 0 "CCSDS primary header sequence count" PARAMETER CCSDSLENGTH 32 16 UINT 0 65535 0 "CCSDS primary header packet length" ID_PARAMETER PKTID 48 16 UINT 0 65535 11 "Packet id" + +COMMAND INST HTTP_SERVER BIG_ENDIAN "OPTIONAL" + APPEND_PARAMETER HTTP_STATUS 256 STRING "ok" + APPEND_PARAMETER HTTP_PATH 256 STRING "/test" + APPEND_PARAMETER HTTP_PACKET 256 STRING "packet_name" \ No newline at end of file diff --git a/openc3/spec/interfaces/http_client_interface_spec.rb b/openc3/spec/interfaces/http_client_interface_spec.rb index 20f515430c..f43eebe4d5 100644 --- a/openc3/spec/interfaces/http_client_interface_spec.rb +++ b/openc3/spec/interfaces/http_client_interface_spec.rb @@ -21,16 +21,49 @@ module OpenC3 describe HttpClientInterface do - describe "initialize" do + before(:all) do + @api_resource = '/api/resource' + @application_json = 'application/json' + @content_type = 'Content-Type' + @example_com = 'example.com' + @packet_data = 'packet data' + end + + before(:each) do + @interface = HttpClientInterface.new(@example_com, 8080, 'https', 10, 15, 5, true) + end + + describe "#initialize" do it "sets all the instance variables" do i = HttpClientInterface.new('localhost', '8080', 'https', '10', '11', '12') expect(i.name).to eql "HttpClientInterface" expect(i.instance_variable_get(:@hostname)).to eql 'localhost' expect(i.instance_variable_get(:@port)).to eql 8080 end + + it "initializes with default parameters" do + interface = HttpClientInterface.new(@example_com) + expect(interface.instance_variable_get(:@hostname)).to eq(@example_com) + expect(interface.instance_variable_get(:@port)).to eq(80) + expect(interface.instance_variable_get(:@protocol)).to eq('http') + end + + it "initializes with custom parameters" do + expect(@interface.instance_variable_get(:@hostname)).to eq(@example_com) + expect(@interface.instance_variable_get(:@port)).to eq(8080) + expect(@interface.instance_variable_get(:@protocol)).to eq('https') + expect(@interface.instance_variable_get(:@write_timeout)).to eq(10.0) + expect(@interface.instance_variable_get(:@read_timeout)).to eq(15.0) + expect(@interface.instance_variable_get(:@connect_timeout)).to eq(5.0) + expect(@interface.instance_variable_get(:@include_request_in_response)).to be true + end end - describe "connection_string" do + describe "#connection_string" do + it "returns the correct URL" do + expect(@interface.connection_string).to eq('https://example.com:8080') + end + it "builds a human readable connection string" do i = HttpClientInterface.new('localhost', '80', 'http', '10', '11', '12') expect(i.connection_string).to eql "http://localhost" @@ -43,6 +76,194 @@ module OpenC3 end end - # TODO: This needs more testing + describe "#connect" do + it "creates a Faraday connection" do + allow(Faraday).to receive(:new).and_return(double('faraday')) + @interface.connect + expect(@interface.instance_variable_get(:@http)).to_not be_nil + end + end + + describe "#connected?" do + it "returns true when connected" do + @interface.instance_variable_set(:@http, double('faraday')) + expect(@interface.connected?).to be true + end + + it "returns false when not connected" do + expect(@interface.connected?).to be false + end + end + + describe "#disconnect" do + it "closes the connection and clears the queue" do + mock_http = double('faraday') + expect(mock_http).to receive(:close) + @interface.instance_variable_set(:@http, mock_http) + @interface.instance_variable_get(:@response_queue).push(['data', {}]) + @interface.disconnect + expect(@interface.instance_variable_get(:@http)).to be_nil + expect(@interface.instance_variable_get(:@response_queue).empty?).to be false + expect(@interface.instance_variable_get(:@response_queue).pop).to be_nil + end + end + + describe "#read_interface" do + it "returns data from the queue" do + @interface.instance_variable_get(:@response_queue).push(['test_data', { 'extra' => 'info' }]) + data, extra = @interface.read_interface + expect(data).to eq('test_data') + expect(extra).to eq({ 'extra' => 'info' }) + end + end + + describe "#write_interface" do + before(:each) do + @mock_http = double('faraday') + @interface.instance_variable_set(:@http, @mock_http) + end + + it "handles DELETE requests" do + data = "" + extra = { + 'HTTP_METHOD' => 'delete', + 'HTTP_URI' => '/api/resource/1', + 'HTTP_QUERIES' => { 'confirm' => 'true' }, + 'HTTP_HEADERS' => { 'Authorization' => 'Bearer token' } + } + + mock_response = double('response') + allow(mock_response).to receive(:headers).and_return({}) + allow(mock_response).to receive(:status).and_return(204) + allow(mock_response).to receive(:body).and_return('') + + expect(@interface.instance_variable_get(:@http)).to receive(:delete) + .with("#{@api_resource}/1", { 'confirm' => 'true' }, { 'Authorization' => 'Bearer token' }) + .and_return(mock_response) + + @interface.write_interface(data, extra) + expect(@interface.instance_variable_get(:@response_queue).pop).to eq(['', { + 'HTTP_REQUEST' => [data, extra], + 'HTTP_STATUS' => 204 + }]) + end + + it "handles GET requests" do + expect(@mock_http).to receive(:get).and_return(double('response', headers: {}, status: 200, body: 'response')) + data, extra = @interface.write_interface('', { 'HTTP_METHOD' => 'get', 'HTTP_URI' => '/test' }) + expect(@interface.instance_variable_get(:@response_queue).pop).to eq(['response', { 'HTTP_REQUEST' => ['', { 'HTTP_METHOD' => 'get', 'HTTP_URI' => '/test' }], 'HTTP_STATUS' => 200 }]) + end + + it "handles POST requests" do + data = '{"post": "data"}' + extra = { + 'HTTP_METHOD' => 'post', + 'HTTP_URI' => @api_resource, + 'HTTP_QUERIES' => { 'param' => 'value' }, + 'HTTP_HEADERS' => { @content_type => 'application/json' } + } + + mock_response = double('response') + allow(mock_response).to receive(:headers).and_return({@content_type => @application_json}) + allow(mock_response).to receive(:status).and_return(201) + allow(mock_response).to receive(:body).and_return('{"id": 1}') + + expect(@interface.instance_variable_get(:@http)).to receive(:post) + .and_yield(double('request').as_null_object) + .and_return(mock_response) + + @interface.write_interface(data, extra) + expect(@interface.instance_variable_get(:@response_queue).pop).to eq(['{"id": 1}', { + 'HTTP_REQUEST' => [data, extra], + 'HTTP_HEADERS' => {@content_type => @application_json}, + 'HTTP_STATUS' => 201 + }]) + end + + it "handles PUT requests" do + data = '{"put": "data"}' + extra = { + 'HTTP_METHOD' => 'put', + 'HTTP_URI' => "#{@api_resource}/1", + 'HTTP_QUERIES' => { 'param' => 'value' }, + 'HTTP_HEADERS' => { @content_type => @application_json } + } + + mock_response = double('response') + allow(mock_response).to receive(:headers).and_return({@content_type => @application_json}) + allow(mock_response).to receive(:status).and_return(200) + allow(mock_response).to receive(:body).and_return('{"updated": true}') + + expect(@interface.instance_variable_get(:@http)).to receive(:put) + .and_yield(double('request').as_null_object) + .and_return(mock_response) + + @interface.write_interface(data, extra) + expect(@interface.instance_variable_get(:@response_queue).pop).to eq(['{"updated": true}', { + 'HTTP_REQUEST' => [data, extra], + 'HTTP_HEADERS' => {@content_type => @application_json}, + 'HTTP_STATUS' => 200 + }]) + end + end + + describe "#convert_data_to_packet" do + it "creates a packet with HttpAccessor" do + packet = @interface.convert_data_to_packet('test_data', { 'HTTP_STATUS' => 200 }) + expect(packet).to be_a(Packet) + expect(packet.accessor).to be_a(HttpAccessor) + expect(packet.buffer).to eq('test_data') + end + + it "sets target and packet names for successful responses" do + packet = @interface.convert_data_to_packet('success', { 'HTTP_STATUS' => 200, 'HTTP_REQUEST_TARGET_NAME' => 'TARGET', 'HTTP_PACKET' => 'SUCCESS' }) + expect(packet.target_name).to eq('TARGET') + expect(packet.packet_name).to eq('SUCCESS') + end + + it "sets error packet name for error responses" do + packet = @interface.convert_data_to_packet('error', { 'HTTP_STATUS' => 404, 'HTTP_REQUEST_TARGET_NAME' => 'TARGET', 'HTTP_ERROR_PACKET' => 'ERROR' }) + expect(packet.target_name).to eq('TARGET') + expect(packet.packet_name).to eq('ERROR') + end + end + + describe "#convert_packet_to_data" do + it "converts a packet to data and extra hash" do + packet = double('packet') + allow(packet).to receive(:buffer).and_return( @packet_data ) + allow(packet).to receive(:read).with('HTTP_PATH').and_return(@api_resource) + allow(packet).to receive(:target_name).and_return('TARGET') + allow(packet).to receive(:packet_name).and_return('PACKET') + allow(packet).to receive(:extra).and_return(nil) + + data, extra = @interface.convert_packet_to_data(packet) + + expect(data).to eq( @packet_data ) + expect(extra['HTTP_REQUEST_TARGET_NAME']).to eq('TARGET') + expect(extra['HTTP_REQUEST_PACKET_NAME']).to eq('PACKET') + uri_str = extra['HTTP_URI'].encode('ASCII-8BIT', 'UTF-8') + expect(uri_str).to eq("https://example.com:8080#{@api_resource}") + end + + it "preserves existing extra data" do + packet = double('packet') + allow(packet).to receive(:buffer).and_return( @packet_data ) + allow(packet).to receive(:read).with('HTTP_PATH').and_return(@api_resource) + allow(packet).to receive(:target_name).and_return('TARGET') + allow(packet).to receive(:packet_name).and_return('PACKET') + allow(packet).to receive(:extra).and_return({'EXISTING' => 'DATA'}) + + data, extra = @interface.convert_packet_to_data(packet) + + expect(data).to eq( @packet_data ) + expect(extra['EXISTING']).to eq('DATA') + uri_str = extra['HTTP_URI'].encode('ASCII-8BIT', 'UTF-8') + + expect(uri_str).to eq("https://example.com:8080#{@api_resource}") + expect(extra['HTTP_REQUEST_TARGET_NAME']).to eq('TARGET') + expect(extra['HTTP_REQUEST_PACKET_NAME']).to eq('PACKET') + end + end end end diff --git a/openc3/spec/interfaces/http_server_interface_spec.rb b/openc3/spec/interfaces/http_server_interface_spec.rb index d77cd7d5a8..324c1faab6 100644 --- a/openc3/spec/interfaces/http_server_interface_spec.rb +++ b/openc3/spec/interfaces/http_server_interface_spec.rb @@ -18,42 +18,144 @@ require 'spec_helper' require 'openc3/interfaces/http_server_interface' +require 'openc3/packets/packet' +require 'openc3/system/system' module OpenC3 describe HttpServerInterface do - describe "initialize" do - it "uses a default port of 80" do - i = HttpServerInterface.new() - expect(i.name).to eql "HttpServerInterface" - expect(i.instance_variable_get(:@port)).to eql 80 + before(:all) do + setup_system() + @localhost = '127.0.0.1' + @socket_80 = 80 # make desktop spec not collide with dev/CI env where Cosmos is already running + begin + sock = Socket.new(Socket::Constants::AF_INET, Socket::Constants::SOCK_STREAM, 0) + sock.bind(Socket.pack_sockaddr_in(80, @localhost )) #raise if listening + sock.close + rescue Errno::EACCES => e; + Logger.info("Found listener on port 80; presumably in CI\n#{e.message}") + @socket_80 = 81 + end + end + + before(:each) do + @interface = HttpServerInterface.new(8000+@socket_80) + end + + after(:each) do + kill_leftover_threads() + end + + describe "#initialize" do + it "initializes with default port" do # above the privileged port range + expect(@interface.instance_variable_get(:@port)).to eq(8000+@socket_80) + end + + it "initializes with custom port" do + interface = HttpServerInterface.new(8000+@socket_80) + expect(interface.instance_variable_get(:@port)).to eq(8000+@socket_80) + end + end + + describe "#set_option" do + it "sets the listen address" do + @interface.set_option("LISTEN_ADDRESS", [ @localhost ]) + expect(@interface.instance_variable_get(:@listen_address)).to eq( @localhost ) + end + end + + describe "#connection_string" do + it "returns the correct connection string" do + expect(@interface.connection_string).to eq("listening on 0.0.0.0:#{8000+@socket_80}") + end + end + + describe "#connect" do + it "connects to a web server and mounts routes" do + allow_any_instance_of(Packet).to receive(:read).with('HTTP_PATH').and_return('/test') + allow_any_instance_of(Packet).to receive(:read).with('HTTP_STATUS').and_return(200) + @interface.instance_variable_set(:@port, 8000+@socket_80) + @interface.connect + expect(@interface.instance_variable_get(:@server)).to_not be_nil end - it "sets the listen port" do - i = HttpServerInterface.new('8080') - expect(i.name).to eql "HttpServerInterface" - expect(i.instance_variable_get(:@port)).to eql 8080 + it "creates a response hook for every command packet" do + @interface.target_names = ['INST'] + server_double = double + allow(WEBrick::HTTPServer).to receive(:new).and_return(server_double) + request = OpenStruct.new(header: {alpha: "bet"}, query: {what: "is"}, status: nil, body: nil) + response = OpenStruct.new(status: nil, body: nil) + allow(server_double).to receive(:mount_proc).with('/test').and_yield(request, response) + expect(server_double).to receive(:start) do + sleep(0.1) + end + @interface.instance_variable_set(:@port, 8000+@socket_80) + @interface.connect + sleep 0.2 end end - describe "connection_string" do - it "builds a human readable connection string" do - i = HttpServerInterface.new() - expect(i.connection_string).to eql "listening on 0.0.0.0:80" + describe "#connected?" do + it "returns true when server is present" do + @interface.instance_variable_set(:@server, double('server')) + expect(@interface.connected?).to be true + end + + it "returns false when server is not present" do + expect(@interface.connected?).to be false + end + end - i = HttpServerInterface.new('8080') - expect(i.connection_string).to eql "listening on 0.0.0.0:8080" + describe "#disconnect" do + it "shuts down the server and clears the queue" do + server = double('server') + expect(server).to receive(:shutdown) + @interface.instance_variable_set(:@server, server) + @interface.instance_variable_set(:@request_queue, Queue.new) + @interface.instance_variable_get(:@request_queue).push("test") + @interface.disconnect + expect(@interface.instance_variable_get(:@server)).to be_nil + expect(@interface.instance_variable_get(:@request_queue).size).to eq(1) + expect(@interface.instance_variable_get(:@request_queue).pop).to be_nil end end - describe "set_option" do - it "sets the listen address for the tcpip_server" do - i = HttpServerInterface.new('8888') - i.set_option('LISTEN_ADDRESS', ['127.0.0.1']) - expect(i.instance_variable_get(:@listen_address)).to eq '127.0.0.1' - expect(i.connection_string).to eql "listening on 127.0.0.1:8888" + describe "#read_interface" do + it "reads from the request queue" do + @interface.instance_variable_set(:@request_queue, Queue.new) + @interface.instance_variable_get(:@request_queue).push(["data", {}]) + expect(@interface).to receive(:read_interface_base).with("data", {}) + data, extra = @interface.read_interface + expect(data).to eq("data") + expect(extra).to eq({}) end end - # TODO: This needs more testing + describe "#write_interface" do + it "raises an error" do + expect { @interface.write_interface({}) }.to raise_error(RuntimeError, "Commands cannot be sent to HttpServerInterface") + end + end + + describe "#convert_data_to_packet" do + it "converts data to a packet" do + data = "test data" + extra = { + 'HTTP_REQUEST_TARGET_NAME' => 'TARGET', + 'HTTP_REQUEST_PACKET_NAME' => 'PACKET', + 'EXTRA_INFO' => 'value' + } + packet = @interface.convert_data_to_packet(data, extra) + expect(packet.target_name).to eq('TARGET') + expect(packet.packet_name).to eq('PACKET') + expect(packet.buffer).to eq(data) + expect(packet.extra).to eq({'EXTRA_INFO' => 'value'}) + end + end + + describe "#convert_packet_to_data" do + it "raises an error" do + expect { @interface.convert_packet_to_data(Packet.new('TGT', 'PKT')) }.to raise_error(RuntimeError, "Commands cannot be sent to HttpServerInterface") + end + end end end diff --git a/openc3/spec/packets/packet_item_spec.rb b/openc3/spec/packets/packet_item_spec.rb index 17a3035e6e..339af2e174 100644 --- a/openc3/spec/packets/packet_item_spec.rb +++ b/openc3/spec/packets/packet_item_spec.rb @@ -14,7 +14,7 @@ # GNU Affero General Public License for more details. # Modified by OpenC3, Inc. -# All changes Copyright 2022, OpenC3, Inc. +# All changes Copyright 2024, OpenC3, Inc. # All Rights Reserved # # This file may also be used under the terms of a commercial license