Skip to content

Commit

Permalink
Complete openc3-cosmos-http-example
Browse files Browse the repository at this point in the history
  • Loading branch information
jmthomas committed Nov 27, 2024
1 parent c0c1f3c commit 773f7aa
Show file tree
Hide file tree
Showing 9 changed files with 131 additions and 89 deletions.
6 changes: 6 additions & 0 deletions docs.openc3.com/docs/getting-started/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ This example assumes an existing COSMOS project at C:\cosmos-project.
C:\cosmos-project> openc3.bat run
```

:::warning Downgrades
Downgrades are not necessarily supported. When upgrading COSMOS we need to upgrade databases and sometimes migrate internal data structures. While we perform a full regression test on every release, we recommend upgrading an individual machine with your specific plugins and do local testing before rolling out the upgrade to your production system.

In general, patch releases (x.y.Z) can be downgraded, minor releases (x.Y.z) _might_ be able to be downgraded and major releases (X.y.z) are NOT able to be downgraded.
:::

### Migrating From COSMOS 4 to COSMOS 5

COSMOS 5 is a new architecture and treats targets as independent [plugins](../configuration/plugins.md). Thus the primary effort in porting from COSMOS 4 to COSMOS 5 is converting targets to plugins. We recommend creating plugins for each independent target (with its own interface) but targets which share an interface will need to be part of the same plugin. The reason for independent plugins is it allows the plugin to be versioned separately and more easily shared outside your specific project. If you have very project specific targets (e.g. custom hardware) those can potentially be combined for ease of deployment.
Expand Down
17 changes: 13 additions & 4 deletions examples/openc3-cosmos-http-example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,22 @@ RESPONSE_TEXT=Webhook+Received%21%
RESPONSE_TEXT=Webhook+Received%21%
```

You can also post data to the server:
To update the HTTP_QUERY_TEMP value you can pass a 'temp' query parameter to the URL:

```bash
% curl -H "Content-Type: application/json" --request POST --data '{"temp":"123"}' "127.0.0.1:9090/webhook"
% curl "127.0.0.1:9090/webhook?temp=456"
RESPONSE_TEXT=Webhook+Received%21%
% curl -H "Content-Type: application/json" --request POST --data '{"temp":"123"}' "127.0.0.1:9191/webhook"
% curl "127.0.0.1:9191/webhook?temp=456"
RESPONSE_TEXT=Webhook+Received%21%
```

The request data is mirrored back to the PYTHON_SERVER / RUBY_SERVER REQUEST packet.
You can also post data to the server. Since the servers are using the FormAccessor we pass the data in the form of `key=value`.

```bash
% curl -H "Content-Type: application/json" --request POST --data 'temperature=123' "127.0.0.1:9090/webhook"
RESPONSE_TEXT=Webhook+Received%21%
% curl -H "Content-Type: application/json" --request POST --data 'temperature=123' "127.0.0.1:9191/webhook"
RESPONSE_TEXT=Webhook+Received%21%
```

The request data is mirrored back to the PYTHON_SERVER / RUBY_SERVER REQUEST packet and the TEMPERATURE field will contain 123.
2 changes: 1 addition & 1 deletion openc3/data/config/item_modifiers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ READ_CONVERSION:
super().__init__()
self.multiplier = float(multiplier)
def call(self, value, packet, buffer):
return value * multiplier
return value * self.multiplier
parameters:
- name: Class Filename
required: true
Expand Down
2 changes: 1 addition & 1 deletion openc3/data/config/parameter_modifiers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ WRITE_CONVERSION:
super().__init__()
self.multiplier = float(multiplier)
def call(self, value, packet, buffer):
return value * multiplier
return value * self.multiplier
parameters:
- name: Class Filename
required: true
Expand Down
11 changes: 1 addition & 10 deletions openc3/lib/openc3/accessors/form_accessor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,7 @@ def self.read_item(item, buffer)
value = nil
ary.each do |key, ary_value|
if key == item.key
if value
if not Array === value
value_temp = []
value_temp << value
value = value_temp
end
value << ary_value
else
value = ary_value
end
value = ary_value
end
end
return value
Expand Down
12 changes: 2 additions & 10 deletions openc3/python/openc3/accessors/form_accessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,13 @@ def read_item(cls, item, buffer):
ary = urllib.parse.parse_qsl(buffer)
value = None
for key, ary_value in ary:
if key == item.key:
if value:
if not isinstance(value, list):
value_temp = []
value_temp.append(value)
value = value_temp
value.append(ary_value)
else:
value = ary_value
if key.decode() == item.key:
value = ary_value.decode()
return value

@classmethod
def write_item(cls, item, value, buffer):
ary = urllib.parse.parse_qsl(buffer)

# Remove existing item and bad keys from list
ary = [ary_value for ary_value in ary if (ary_value[0] != item.key) and (str(ary_value[0])[0] != "\u0000")]

Expand Down
8 changes: 4 additions & 4 deletions openc3/python/openc3/accessors/http_accessor.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def read_item(self, item, buffer):
if re.match(r"^HTTP_QUERY_", item_name):
if not self.packet.extra:
return None
if item.key == r"^HTTP_QUERY_":
if re.match(r"^HTTP_QUERY_", item.key):
query_name = item_name[11:].lower()
else:
query_name = item.key
Expand All @@ -59,7 +59,7 @@ def read_item(self, item, buffer):
if re.match(r"^HTTP_HEADER_", item_name):
if not self.packet.extra:
return None
if item.key == r"^HTTP_HEADER_":
if re.match(r"^HTTP_HEADER_", item.key):
header_name = item_name[12:].lower()
else:
header_name = item.key
Expand Down Expand Up @@ -95,7 +95,7 @@ def write_item(self, item, value, buffer):
item_name = item.name
if re.match(r"^HTTP_QUERY_", item_name):
self.packet.extra = self.packet.extra or {}
if item.key == r"^HTTP_QUERY_":
if re.match(r"^HTTP_QUERY_", item.key):
query_name = item_name[11:].lower()
else:
query_name = item.key
Expand All @@ -106,7 +106,7 @@ def write_item(self, item, value, buffer):

if re.match(r"^HTTP_HEADER_", item_name):
self.packet.extra = self.packet.extra or {}
if item.key == r"^HTTP_HEADER_":
if re.match(r"^HTTP_HEADER_", item.key):
header_name = item_name[12:].lower()
else:
header_name = item.key
Expand Down
156 changes: 99 additions & 57 deletions openc3/python/openc3/interfaces/http_client_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,32 @@


class HttpClientInterface(Interface):
# @param hostname [String] HTTP/HTTPS server to connect to
# @param port [Integer] HTTP/HTTPS port
# @param protocol [String] http or https
"""
HttpClientInterface is a class that provides an interface for making HTTP
requests using the requests library.
"""

def __init__(
self,
hostname,
port=80,
protocol="http",
write_timeout=5,
write_timeout=None,
read_timeout=None,
connect_timeout=5,
include_request_in_response=False,
):
"""
Initializes the HTTPClientInterface with the given parameters.
Args:
hostname (str): The hostname of the server.
port (int, optional): The port number to connect to. Defaults to 80.
protocol (str, optional): The protocol to use ('http' or 'https'). Defaults to "http".
write_timeout (None, optional): Present to match Ruby parameters but not used.
read_timeout (float or None, optional): The timeout for reading operations in seconds. Defaults to None.
connect_timeout (float or None, optional): The timeout for connection operations in seconds. Defaults to 5.
include_request_in_response (bool, optional): Whether to include the request in the response. Defaults to False.
"""
super().__init__()
self.hostname = hostname
self.port = int(port)
Expand All @@ -44,9 +57,6 @@ def __init__(
self.url = f"{self.protocol}://{self.hostname}"
else:
self.url = f"{self.protocol}://{self.hostname}:{self.port}"
self.write_timeout = ConfigParser.handle_none(write_timeout)
if self.write_timeout:
self.write_timeout = float(self.write_timeout)
self.read_timeout = ConfigParser.handle_none(read_timeout)
if self.read_timeout:
self.read_timeout = float(self.read_timeout)
Expand All @@ -58,33 +68,37 @@ def __init__(
self.response_queue = queue.Queue()

def connection_string(self):
"""Returns the url."""
return self.url

# Connects the interface to its target(s)
def connect(self):
# Per https://github.com/lostisland/faraday/blob/main/lib/faraday/options/env.rb
# :timeout - time limit for the entire request (Integer in seconds)
# :open_timeout - time limit for just the connection phase (e.g. handshake) (Integer in seconds)
# :read_timeout - time limit for the first response byte received from the server (Integer in seconds)
# :write_timeout - time limit for the client to send the request to the server (Integer in seconds)
request = {}
if self.connect_timeout:
request["open_timeout"] = self.connect_timeout
if self.read_timeout:
request["read_timeout"] = self.read_timeout
if self.write_timeout:
request["write_timeout"] = self.write_timeout
"""
Initializes an HTTP session and then calls the parent class's connect method.
"""
self.http = requests.Session()
super().connect()

def connected(self):
"""
Check if the HTTP client is connected.
Returns:
bool: True if the HTTP client is connected, False otherwise.
"""
if self.http:
return True
else:
return False

# Disconnects the interface from its target(s)
def disconnect(self):
"""
Disconnects the HTTP client interface.
This method closes the HTTP connection if it exists, sets the HTTP client to None,
clears the response queue, calls the superclass's disconnect method, and unblocks
the response queue to allow the read_interface method to return.
"""
if self.http:
self.http.close
self.http = None
Expand All @@ -93,13 +107,20 @@ def disconnect(self):
super().disconnect()
self.response_queue.put((None, None))

# Called to convert a packet into a data buffer. Write protocols then
# potentially modify the data in their write_data methods. Finally
# write_interface is called to send the data to the target.
#
# @param packet [Packet] Packet to extract data from
# @return data, extra
def convert_packet_to_data(self, packet):
"""
Converts a packet to data and extracts additional information.
Args:
packet (Packet): The packet to be converted.
Returns:
tuple: A tuple containing:
- data (bytes): The buffer data from the packet.
- extra (dict): A dictionary containing additional information extracted from the packet.
- "HTTP_URI" (str): The full HTTP URI constructed from the base URL and the packet's HTTP path.
- "HTTP_REQUEST_TARGET_NAME" (str): The target name of the HTTP request.
"""

extra = packet.extra
extra = extra or {}
data = packet.buffer # Copy buffer so logged command isn't modified
Expand All @@ -109,6 +130,20 @@ def convert_packet_to_data(self, packet):

# Calls the http request method to send the data to the target
def write_interface(self, data, extra=None):
"""
Sends the data to the target using an HTTP request.
Args:
data (bytes): The data to be sent.
extra (dict, optional): Additional parameters for the HTTP request. Defaults to None.
- HTTP_QUERIES: Query parameters for the HTTP request.
- HTTP_HEADERS: Headers for the HTTP request.
- HTTP_URI: The URI for the HTTP request.
- HTTP_METHOD: The HTTP method (e.g., GET, POST).
Returns:
tuple: The data and extra parameters.
"""
extra = extra or {}
params = extra.get("HTTP_QUERIES")
headers = extra.get("HTTP_HEADERS")
Expand All @@ -128,50 +163,57 @@ def write_interface(self, data, extra=None):
# Normalize Response into simple hash
response_data = None
response_extra = {}
if resp:
response_extra["HTTP_REQUEST"] = [data, extra]
if resp.headers and len(resp.headers) > 0:
# Cast headers to a dictionary so it can be serialized
# because the requst library returns CaseInsensitiveDict
response_extra["HTTP_HEADERS"] = dict(resp.headers)
response_extra["HTTP_STATUS"] = resp.status_code
response_data = bytearray(resp.text, encoding="utf-8")
response_data = response_data or b"" # Ensure an empty string
response_extra["HTTP_REQUEST"] = [data, extra]
if resp.headers and len(resp.headers) > 0:
# Cast headers to a dictionary so it can be serialized
# because the requst library returns CaseInsensitiveDict

Check failure on line 169 in openc3/python/openc3/interfaces/http_client_interface.py

View workflow job for this annotation

GitHub Actions / Codespell

requst ==> request
response_extra["HTTP_HEADERS"] = dict(resp.headers)
response_extra["HTTP_STATUS"] = resp.status_code
response_data = bytearray(resp.text, encoding="utf-8")

self.response_queue.put((response_data, response_extra))
self.write_interface_base(data, extra)
return data, extra

# Returns the response data and extra from the interface
# which was queued up by the write_interface method.
# Read protocols can then potentially modify the data in their read_data methods.
# Then convert_data_to_packet is called to convert the data into a Packet object.
# Finally the read protocols read_packet methods are called.
def read_interface(self):
"""
Returns the response data and extra parameters from the interface,
which were queued up by the write_interface method.
Read protocols can then potentially modify the data in their read_data methods.
Then convert_data_to_packet is called to convert the data into a Packet object.
Finally the read protocols read_packet methods are called.
Returns:
tuple: The response data and extra parameters.
"""
data, extra = self.response_queue.get(block=True)
if data is None:
return data, extra
self.read_interface_base(data, extra)
return data, extra

# Called to convert the read data into a OpenC3 Packet object
#
# @param data [String] Raw packet data
# @param extra [dict] Contains the following keys:
# HTTP_HEADERS - Hash of response headers
# HTTP_STATUS - Integer response status code
# HTTP_REQUEST - [data, extra]
# where data is the request data and extra contains:
# HTTP_REQUEST_TARGET_NAME - String request target name
# HTTP_URI - String request URI based on HTTP_PATH
# HTTP_PATH - String request path
# HTTP_METHOD - String request method
# HTTP_PACKET - String response packet name
# HTTP_ERROR_PACKET - Optional string error packet name
# HTTP_QUERIES - Optional hash of request queries
# HTTP_HEADERS - Optional hash of request headers
# @return [Packet] OpenC3 Packet with buffer filled with data
def convert_data_to_packet(self, data, extra=None):
"""
Converts the read data into an OpenC3 Packet object.
Args:
data (str): Raw packet data.
extra (dict, optional): Additional parameters for the packet. Defaults to None.
- HTTP_HEADERS: Hash of response headers.
- HTTP_STATUS: Integer response status code.
- HTTP_REQUEST: [data, extra] where data is the request data and extra contains:
- HTTP_REQUEST_TARGET_NAME: String request target name.
- HTTP_URI: String request URI based on HTTP_PATH.
- HTTP_PATH: String request path.
- HTTP_METHOD: String request method.
- HTTP_PACKET: String response packet name.
- HTTP_ERROR_PACKET: Optional string error packet name.
- HTTP_QUERIES: Optional hash of request queries.
- HTTP_HEADERS: Optional hash of request headers.
Returns:
Packet: OpenC3 Packet with buffer filled with data.
"""
packet = Packet(None, None, "BIG_ENDIAN", None, data)
packet.accessor = HttpAccessor(packet)
# Grab the request extra set in the write_interface method
Expand Down
6 changes: 4 additions & 2 deletions openc3/python/openc3/interfaces/http_server_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ def do_GET(self):
self.handle_request()

def handle_request(self):
if self.server.lookup.get(self.path):
packets = self.server.lookup[self.path]
base = self.path.split("?")[0]
print("base:", base)
if self.server.lookup.get(base):
packets = self.server.lookup[base]
status = 200

for packet in packets:
Expand Down

0 comments on commit 773f7aa

Please sign in to comment.