Skip to content

Commit 29964b9

Browse files
feat: use aiohttp instead of basic http server (#12)
* refactor: leverage asyncio by ai * doc: improve readme * doc: update * fix: functional * feat: add requirements * test: enhance basic * tests: add more 4xx * doc: update api
1 parent 29a93fc commit 29964b9

8 files changed

+189
-175
lines changed

README.md

+34-16
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,52 @@
11
# http_server_python
22

3-
TEN extension of a Simple HTTP server, to make the running TEN graph interativeable with outside world.
4-
5-
Typical usages:
6-
- Modify properties of TEN extensions
7-
- Trigger actions of TEN extensions
8-
- Query status of TEN extensions
9-
3+
This project is a TEN extension that implements a simple HTTP server, enabling interaction with the running TEN graph from external systems.
104

115
## Features
126

13-
- Passing through any `cmd` to the running TEN graph, return result as needed
7+
- **Command Execution**: Seamlessly pass any `cmd` to the running TEN graph and receive the results.
8+
- **Asynchronous Handling**: Utilizes `asyncio` and `aiohttp` for efficient, non-blocking request processing.
9+
- **Configurable Server**: Easily configure the server's listening address and port through the TEN environment.
1410

1511

1612
## API
1713

18-
- `/cmd`
14+
### Property
1915

20-
<!-- TODO: hide internal fields and add examples -->
16+
Refer to api definition in [manifest.json](manifest.json) and default values in [property.json](property.json).
2117

22-
## Development
18+
| Property | Type | Description |
19+
| - | - | - |
20+
| `listen_addr`| `string`| address to listen on |
21+
| `listen_port` | `int32` | port to listen on|
22+
23+
## HTTP API
2324

24-
### Build
25+
### POST `/cmd/{cmd_name}`
2526

26-
<!-- build dependencies and steps -->
27+
- **Description**: Sends a command with the specified name on the TEN graph.
28+
- **Request Body**: JSON object containing the command properties.
29+
- **Response**: JSON object with the command execution result.
30+
31+
#### Example Request
32+
33+
```bash
34+
curl -X POST http://127.0.0.1:8888/cmd/example_cmd_name \
35+
-H "Content-Type: application/json" \
36+
-d '{
37+
"num_property1": 1,
38+
"str_property2": "Hello"
39+
}'
40+
```
41+
42+
## Development
2743

28-
### Unit test
44+
### Standalone testing
2945

3046
<!-- how to do unit test for the extension -->
3147

32-
## Misc
48+
```bash
49+
task install
50+
task test
51+
```
3352

34-
<!-- others if applicable -->

Taskfile.yml

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ tasks:
1616
desc: install dependencies
1717
cmds:
1818
- tman install
19+
- pip install -r requirements.txt
1920
- pip install -r tests/requirements.txt
2021

2122
lint:

http_server_extension.py

+72-106
Original file line numberDiff line numberDiff line change
@@ -1,114 +1,80 @@
1+
import asyncio
2+
from aiohttp import web
3+
import json
4+
15
from ten import (
2-
Extension,
3-
TenEnv,
6+
AsyncExtension,
7+
AsyncTenEnv,
48
Cmd,
59
StatusCode,
610
CmdResult,
711
)
8-
from http.server import HTTPServer, BaseHTTPRequestHandler
9-
import threading
10-
from functools import partial
11-
import re
12-
13-
14-
class HTTPHandler(BaseHTTPRequestHandler):
15-
def __init__(self, ten: TenEnv, *args, directory=None, **kwargs):
16-
ten.log_debug(f"new handler: {directory} {args} {kwargs}")
17-
self.ten = ten
18-
super().__init__(*args, **kwargs)
19-
20-
def do_POST(self):
21-
self.ten.log_debug(f"post request incoming {self.path}")
22-
23-
# match path /cmd/<cmd_name>
24-
match = re.match(r"^/cmd/([^/]+)$", self.path)
25-
if match:
26-
cmd_name = match.group(1)
27-
try:
28-
content_length = int(self.headers["Content-Length"])
29-
input = self.rfile.read(content_length).decode("utf-8")
30-
self.ten.log_info(f"incoming request {self.path} {input}")
31-
32-
# processing by send_cmd
33-
cmd_result_event = threading.Event()
34-
cmd_result: CmdResult
35-
36-
def cmd_callback(_, result, ten_error):
37-
nonlocal cmd_result_event
38-
nonlocal cmd_result
39-
cmd_result = result
40-
self.ten.log_info(
41-
"cmd callback result: {}".format(
42-
cmd_result.get_property_to_json("")
43-
)
44-
)
45-
cmd_result_event.set()
46-
47-
cmd = Cmd.create(cmd_name)
48-
cmd.set_property_from_json("", input)
49-
self.ten.send_cmd(cmd, cmd_callback)
50-
event_got = cmd_result_event.wait(timeout=5)
51-
52-
# return response
53-
if not event_got: # timeout
54-
self.send_response_only(504)
55-
self.end_headers()
56-
return
57-
self.send_response(
58-
200 if cmd_result.get_status_code() == StatusCode.OK else 502
59-
)
60-
self.send_header("Content-Type", "application/json")
61-
self.end_headers()
62-
self.wfile.write(
63-
cmd_result.get_property_to_json("").encode(encoding="utf_8")
64-
)
65-
except Exception as e:
66-
self.ten.log_warn("failed to handle request, err {}".format(e))
67-
self.send_response_only(500)
68-
self.end_headers()
69-
else:
70-
self.ten.log_warn(f"invalid path: {self.path}")
71-
self.send_response_only(404)
72-
self.end_headers()
73-
74-
75-
class HTTPServerExtension(Extension):
12+
13+
14+
class HTTPServerExtension(AsyncExtension):
7615
def __init__(self, name: str):
7716
super().__init__(name)
78-
self.listen_addr = "127.0.0.1"
79-
self.listen_port = 8888
80-
self.cmd_white_list = None
81-
self.server = None
82-
self.thread = None
83-
84-
def on_start(self, ten: TenEnv):
85-
self.listen_addr = ten.get_property_string("listen_addr")
86-
self.listen_port = ten.get_property_int("listen_port")
87-
"""
88-
white_list = ten.get_property_string("cmd_white_list")
89-
if len(white_list) > 0:
90-
self.cmd_white_list = white_list.split(",")
91-
"""
92-
93-
ten.log_info(
94-
f"on_start {self.listen_addr}:{self.listen_port}, {self.cmd_white_list}"
95-
)
96-
97-
self.server = HTTPServer(
98-
(self.listen_addr, self.listen_port), partial(HTTPHandler, ten)
99-
)
100-
self.thread = threading.Thread(target=self.server.serve_forever)
101-
self.thread.start()
102-
103-
ten.on_start_done()
104-
105-
def on_stop(self, ten: TenEnv):
106-
self.server.shutdown()
107-
self.thread.join()
108-
ten.on_stop_done()
109-
110-
def on_cmd(self, ten: TenEnv, cmd: Cmd):
17+
self.listen_addr: str = "127.0.0.1"
18+
self.listen_port: int = 8888
19+
20+
self.ten_env: AsyncTenEnv = None
21+
22+
# http server instances
23+
self.app = web.Application()
24+
self.runner = None
25+
26+
# POST /cmd/{cmd_name}
27+
async def handle_post_cmd(self, request):
28+
ten_env = self.ten_env
29+
30+
try:
31+
cmd_name = request.match_info.get('cmd_name')
32+
33+
req_json = await request.json()
34+
input = json.dumps(req_json, ensure_ascii=False)
35+
36+
ten_env.log_debug(
37+
f"process incoming request {request.method} {request.path} {input}")
38+
39+
cmd = Cmd.create(cmd_name)
40+
cmd.set_property_from_json("", input)
41+
[cmd_result, _] = await asyncio.wait_for(ten_env.send_cmd(cmd), 5.0)
42+
43+
# return response
44+
status = 200 if cmd_result.get_status_code() == StatusCode.OK else 502
45+
return web.json_response(
46+
cmd_result.get_property_to_json(""), status=status
47+
)
48+
except json.JSONDecodeError:
49+
return web.Response(status=400)
50+
except asyncio.TimeoutError:
51+
return web.Response(status=504)
52+
except Exception as e:
53+
ten_env.log_warn(
54+
"failed to handle request with unknown exception, err {}".format(e))
55+
return web.Response(status=500)
56+
57+
async def on_start(self, ten_env: AsyncTenEnv):
58+
if await ten_env.is_property_exist("listen_addr"):
59+
self.listen_addr = await ten_env.get_property_string("listen_addr")
60+
if await ten_env.is_property_exist("listen_port"):
61+
self.listen_port = await ten_env.get_property_int("listen_port")
62+
self.ten_env = ten_env
63+
64+
ten_env.log_info(
65+
f"http server listening on {self.listen_addr}:{self.listen_port}")
66+
67+
self.app.router.add_post("/cmd/{cmd_name}", self.handle_post_cmd)
68+
self.runner = web.AppRunner(self.app)
69+
await self.runner.setup()
70+
site = web.TCPSite(self.runner, self.listen_addr, self.listen_port)
71+
await site.start()
72+
73+
async def on_stop(self, ten_env: AsyncTenEnv):
74+
await self.runner.cleanup()
75+
self.ten_env = None
76+
77+
async def on_cmd(self, ten_env: AsyncTenEnv, cmd: Cmd):
11178
cmd_name = cmd.get_name()
112-
ten.log_info("on_cmd {cmd_name}")
113-
cmd_result = CmdResult.create(StatusCode.OK)
114-
ten.return_result(cmd_result, cmd)
79+
ten_env.log_debug(f"on_cmd {cmd_name}")
80+
ten_env.return_result(CmdResult.create(StatusCode.OK), cmd)

manifest.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
"include": [
1414
"manifest.json",
1515
"property.json",
16-
"**.py"
16+
"**.py",
17+
"requirements.txt",
18+
"README.md"
1719
]
1820
},
1921
"api": {

requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
aiohttp

tests/test_404.py

-51
This file was deleted.

tests/test_4xx.py

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#
2+
# Copyright © 2025 Agora
3+
# This file is part of TEN Framework, an open source project.
4+
# Licensed under the Apache License, Version 2.0, with certain conditions.
5+
# Refer to the "LICENSE" file in the root directory for more information.
6+
#
7+
from pathlib import Path
8+
from ten import (
9+
ExtensionTester,
10+
TenEnvTester,
11+
)
12+
import httpx
13+
14+
15+
class ExtensionTester404NotFound1(ExtensionTester):
16+
def on_start(self, ten_env: TenEnvTester) -> None:
17+
ten_env.on_start_done()
18+
19+
property_json = {"num": 1, "str": "111"}
20+
r = httpx.post("http://127.0.0.1:8888/cmd", json=property_json)
21+
print(r)
22+
if r.status_code == httpx.codes.NOT_FOUND:
23+
ten_env.stop_test()
24+
25+
26+
class ExtensionTester404NotFound2(ExtensionTester):
27+
def on_start(self, ten_env: TenEnvTester) -> None:
28+
ten_env.on_start_done()
29+
30+
property_json = {"num": 1, "str": "111"}
31+
r = httpx.post("http://127.0.0.1:8888/cmd/aaa/123", json=property_json)
32+
print(r)
33+
if r.status_code == httpx.codes.NOT_FOUND:
34+
ten_env.stop_test()
35+
36+
37+
class ExtensionTester400BadRequest(ExtensionTester):
38+
def on_start(self, ten_env: TenEnvTester) -> None:
39+
ten_env.on_start_done()
40+
41+
property_str = '{num": 1, "str": "111"}' # not a valid json
42+
r = httpx.post("http://127.0.0.1:8888/cmd/aaa", content=property_str)
43+
print(r)
44+
if r.status_code == httpx.codes.BAD_REQUEST:
45+
ten_env.stop_test()
46+
47+
48+
def test_4xx():
49+
tester_404_1 = ExtensionTester404NotFound1()
50+
tester_404_1.add_addon_base_dir(
51+
str(Path(__file__).resolve().parent.parent))
52+
tester_404_1.set_test_mode_single("http_server_python")
53+
tester_404_1.run()
54+
55+
tester_404_2 = ExtensionTester404NotFound2()
56+
tester_404_2.add_addon_base_dir(
57+
str(Path(__file__).resolve().parent.parent))
58+
tester_404_2.set_test_mode_single("http_server_python")
59+
tester_404_2.run()
60+
61+
tester_400 = ExtensionTester400BadRequest()
62+
tester_400.add_addon_base_dir(str(Path(__file__).resolve().parent.parent))
63+
tester_400.set_test_mode_single("http_server_python")
64+
tester_400.run()

0 commit comments

Comments
 (0)