lapis-api 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lapis/__init__.py +10 -0
- lapis/lapis.py +273 -0
- lapis/protocols/http1.py +251 -0
- lapis/protocols/websocket.py +544 -0
- lapis/server_types.py +172 -0
- lapis_api-0.2.0.dist-info/METADATA +47 -0
- lapis_api-0.2.0.dist-info/RECORD +9 -0
- lapis_api-0.2.0.dist-info/WHEEL +4 -0
- lapis_api-0.2.0.dist-info/licenses/LICENSE +21 -0
lapis/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Lapis web framework core exports.
|
|
2
|
+
|
|
3
|
+
This module exposes the primary public classes for consumers of the
|
|
4
|
+
lapis package.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .lapis import Lapis
|
|
8
|
+
from .server_types import ServerConfig, Protocol
|
|
9
|
+
from .protocols.http1 import Request, Response, StreamedResponse
|
|
10
|
+
from .protocols.websocket import WebSocketProtocol, WSPortal
|
lapis/lapis.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The main script for the Lapis server handling initial request handling and response
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import select
|
|
8
|
+
import socket
|
|
9
|
+
import pathlib
|
|
10
|
+
import runpy
|
|
11
|
+
import sys
|
|
12
|
+
from threading import Thread
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
from lapis.protocols.websocket import WebSocketProtocol
|
|
16
|
+
from lapis.protocols.http1 import HTTP1Protocol, Request, Response
|
|
17
|
+
from .server_types import (
|
|
18
|
+
BadAPIDirectory,
|
|
19
|
+
BadConfigError,
|
|
20
|
+
BadRequest,
|
|
21
|
+
Protocol,
|
|
22
|
+
ServerConfig,
|
|
23
|
+
ProtocolEndpointError
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
class Lapis:
|
|
27
|
+
"""
|
|
28
|
+
The Lapis class implements the centeral object used to run a Lapis REST server
|
|
29
|
+
"""
|
|
30
|
+
cfg: ServerConfig = ServerConfig()
|
|
31
|
+
|
|
32
|
+
__s: socket.socket = None
|
|
33
|
+
__paths: dict = {}
|
|
34
|
+
__taken_endpoints : list[str] = []
|
|
35
|
+
__protocols : list[type[Protocol]] = []
|
|
36
|
+
|
|
37
|
+
__running : bool = False
|
|
38
|
+
|
|
39
|
+
def __init__(self, config: ServerConfig | None = None):
|
|
40
|
+
|
|
41
|
+
if config is not None:
|
|
42
|
+
self.cfg = config
|
|
43
|
+
|
|
44
|
+
self.__register_protocol(HTTP1Protocol)
|
|
45
|
+
self.__register_protocol(WebSocketProtocol)
|
|
46
|
+
|
|
47
|
+
self.__paths = self._bake_paths()
|
|
48
|
+
|
|
49
|
+
def run(self, ip: str, port: int):
|
|
50
|
+
"""
|
|
51
|
+
Starts the Lapis server to listen on a given ip and port
|
|
52
|
+
|
|
53
|
+
:param ip: The ip for the server to listen on
|
|
54
|
+
:type ip: str
|
|
55
|
+
:param port: The port for the server to listen on
|
|
56
|
+
:type port: int
|
|
57
|
+
"""
|
|
58
|
+
self.__s = socket.socket()
|
|
59
|
+
self.__s.bind((ip, port))
|
|
60
|
+
self.__s.listen()
|
|
61
|
+
|
|
62
|
+
self.__running = True
|
|
63
|
+
print(f"{self.cfg.server_name} is now listening on http://{ip}:{port}")
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
while True:
|
|
67
|
+
readable, _, _ = select.select([self.__s], [], [], 0.1)
|
|
68
|
+
if self.__s in readable:
|
|
69
|
+
client, _ = self.__s.accept()
|
|
70
|
+
t = Thread(target=self._handle_request, args=(client,), daemon=True)
|
|
71
|
+
t.start()
|
|
72
|
+
except KeyboardInterrupt:
|
|
73
|
+
pass
|
|
74
|
+
finally:
|
|
75
|
+
self.__close()
|
|
76
|
+
|
|
77
|
+
def __register_protocol(self, protocol : type[Protocol]):
|
|
78
|
+
|
|
79
|
+
if self.__running:
|
|
80
|
+
raise RuntimeError("Cannot register new Protocol while server is running")
|
|
81
|
+
|
|
82
|
+
endpoints : list[str] = protocol().get_target_endpoints()
|
|
83
|
+
if bool(set(endpoints) & set(self.__taken_endpoints)):
|
|
84
|
+
raise ProtocolEndpointError("Cannot reuse target endpoint method!")
|
|
85
|
+
|
|
86
|
+
self.__protocols.insert(0, protocol)
|
|
87
|
+
self.__taken_endpoints.extend(endpoints)
|
|
88
|
+
|
|
89
|
+
def register_protocol(self, protocol : type[Protocol]):
|
|
90
|
+
"""
|
|
91
|
+
Registers new protocol for the server to use to communicate with clients
|
|
92
|
+
|
|
93
|
+
Cannot be called while server is running
|
|
94
|
+
|
|
95
|
+
:param protocol: Description
|
|
96
|
+
:type protocol: type[Protocol]
|
|
97
|
+
"""
|
|
98
|
+
self.__register_protocol(protocol=protocol)
|
|
99
|
+
|
|
100
|
+
self.__paths = self._bake_paths()
|
|
101
|
+
|
|
102
|
+
def _get_dynamic_dirs(self, directory: pathlib.Path):
|
|
103
|
+
return [
|
|
104
|
+
p for p in directory.iterdir()
|
|
105
|
+
if p.is_dir() and p.name.startswith("[") and p.name.endswith("]")
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
def _bake_paths(self) -> dict:
|
|
109
|
+
|
|
110
|
+
server_path = pathlib.Path(sys.argv[0]).resolve()
|
|
111
|
+
root : pathlib.Path = server_path.parent / pathlib.Path(self.cfg.api_directory)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
root.parent.resolve(strict=False)
|
|
115
|
+
except (OSError, RuntimeError) as err:
|
|
116
|
+
raise BadConfigError("\"api_directory\" in config must be a valid file path") from err
|
|
117
|
+
|
|
118
|
+
if not root.exists():
|
|
119
|
+
raise BadAPIDirectory(f"api directory \"{root}\" does not exist")
|
|
120
|
+
|
|
121
|
+
result = {}
|
|
122
|
+
|
|
123
|
+
for path in root.rglob(f"{self.cfg.path_script_name}.py"):
|
|
124
|
+
if not path.is_file():
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
parts = path.relative_to(root).parts
|
|
128
|
+
current_level = result
|
|
129
|
+
current_fs_level = root
|
|
130
|
+
|
|
131
|
+
for part in parts[:-1]:
|
|
132
|
+
dynamic_dirs = self._get_dynamic_dirs(current_fs_level)
|
|
133
|
+
if len(dynamic_dirs) > 1:
|
|
134
|
+
raise BadAPIDirectory(
|
|
135
|
+
f"Multiple dynamic route folders in {current_fs_level}: "
|
|
136
|
+
f"{', '.join(d.name for d in dynamic_dirs)}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# move filesystem pointer
|
|
140
|
+
current_fs_level = current_fs_level / part
|
|
141
|
+
|
|
142
|
+
current_level = current_level.setdefault(part, {})
|
|
143
|
+
|
|
144
|
+
script_globals = runpy.run_path(str(path.absolute()))
|
|
145
|
+
|
|
146
|
+
# Grab just endpoint methods
|
|
147
|
+
api_routes = {
|
|
148
|
+
f"/{k}": v
|
|
149
|
+
for k, v in script_globals.items()
|
|
150
|
+
if k in self.__taken_endpoints
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# Add endpoints
|
|
154
|
+
current_level.update(api_routes)
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
def __has_endpoint_path(self, base_url : str) -> tuple[dict[str, any] | None, dict[str,str]]:
|
|
158
|
+
# Digs through api cache map to find the correct endpoint directory
|
|
159
|
+
slugs = {}
|
|
160
|
+
path = pathlib.Path(f"{self.cfg.api_directory}{base_url}")
|
|
161
|
+
parts : list[str] = path.relative_to(self.cfg.api_directory).parts
|
|
162
|
+
|
|
163
|
+
leaf : dict = self.__paths
|
|
164
|
+
for part in parts:
|
|
165
|
+
if part in leaf:
|
|
166
|
+
leaf = leaf[part]
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
# checks if there are dynamic routes available
|
|
170
|
+
dynamic_routes: list[str] = list(
|
|
171
|
+
{
|
|
172
|
+
key
|
|
173
|
+
for key in leaf
|
|
174
|
+
if key.startswith("[") and key.endswith("]")
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
if len(dynamic_routes) == 1:
|
|
179
|
+
slugs[dynamic_routes[0].strip("[]")] = part
|
|
180
|
+
leaf = leaf[dynamic_routes[0]]
|
|
181
|
+
else:
|
|
182
|
+
return (None, {})
|
|
183
|
+
|
|
184
|
+
if len(leaf) == 0:
|
|
185
|
+
return (None, {})
|
|
186
|
+
|
|
187
|
+
return (leaf, slugs)
|
|
188
|
+
|
|
189
|
+
def _handle_request(self, client: socket.socket):
|
|
190
|
+
data = client.recv(self.cfg.max_request_size)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
request : Request = Request(data)
|
|
194
|
+
|
|
195
|
+
except BadRequest:
|
|
196
|
+
self.__send_response(client, Response(status_code=400, body="400 Bad Request"))
|
|
197
|
+
client.close()
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
(endpoint, request.slugs) = self.__has_endpoint_path(request.base_url)
|
|
202
|
+
|
|
203
|
+
if endpoint is None:
|
|
204
|
+
raise FileNotFoundError()
|
|
205
|
+
|
|
206
|
+
# Finds the correct protocol based on the inital request
|
|
207
|
+
for protocol_cls in self.__protocols:
|
|
208
|
+
protocol: Protocol = protocol_cls()
|
|
209
|
+
|
|
210
|
+
if not protocol.identify(initial_data=data):
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
if not protocol.handshake(client=client):
|
|
214
|
+
raise BadRequest("Failed Handshake with protocol!")
|
|
215
|
+
|
|
216
|
+
target_endpoints = protocol.get_target_endpoints()
|
|
217
|
+
|
|
218
|
+
endpoints = {
|
|
219
|
+
f"/{k}": endpoint[f"/{k}"]
|
|
220
|
+
for k in target_endpoints
|
|
221
|
+
if f"/{k}" in endpoint
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
endpoints = { key.lstrip("/"): value for key, value in endpoints.items() }
|
|
225
|
+
|
|
226
|
+
if inspect.iscoroutinefunction(protocol.handle):
|
|
227
|
+
asyncio.run(protocol.handle(
|
|
228
|
+
client=client,
|
|
229
|
+
slugs=request.slugs,
|
|
230
|
+
endpoints=endpoints,
|
|
231
|
+
))
|
|
232
|
+
else:
|
|
233
|
+
protocol.handle(
|
|
234
|
+
client=client,
|
|
235
|
+
slugs=request.slugs,
|
|
236
|
+
endpoints=endpoints,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
break
|
|
240
|
+
else: # No Protocol was found to be compatible
|
|
241
|
+
raise BadRequest("No Compatible Protocol Found!")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
except BadRequest:
|
|
245
|
+
response : Response = Response(status_code=400, body="400 Bad Request")
|
|
246
|
+
self.__send_response(client=client, response=response)
|
|
247
|
+
|
|
248
|
+
except FileNotFoundError:
|
|
249
|
+
response : Response = Response(status_code=404, body="404 Not Found")
|
|
250
|
+
self.__send_response(client, response)
|
|
251
|
+
|
|
252
|
+
except RuntimeError as e:
|
|
253
|
+
print(f"Error handling request: {e}")
|
|
254
|
+
response = Response(status_code=500, body="Internal Server Error")
|
|
255
|
+
self.__send_response(client, response)
|
|
256
|
+
|
|
257
|
+
finally:
|
|
258
|
+
client.close()
|
|
259
|
+
|
|
260
|
+
def __send_response(self, client : socket.socket, response : Response):
|
|
261
|
+
client.sendall(response.to_bytes())
|
|
262
|
+
current_time = datetime.now().strftime("%H:%M:%S")
|
|
263
|
+
ip, _ = client.getpeername()
|
|
264
|
+
print(f"{current_time} {response.status_code.value} -> {ip}")
|
|
265
|
+
|
|
266
|
+
def __close(self):
|
|
267
|
+
if self.__s is not None:
|
|
268
|
+
try:
|
|
269
|
+
print("Closing Server...")
|
|
270
|
+
self.__running = False
|
|
271
|
+
self.__s.close()
|
|
272
|
+
except socket.error as e:
|
|
273
|
+
print(f"Error when closing socket: {e}")
|
lapis/protocols/http1.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module containing the HTTP 1/1.1 protocol implementation for Lapis server
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from http import HTTPMethod, HTTPStatus
|
|
8
|
+
import socket
|
|
9
|
+
from typing import AsyncGenerator, Callable
|
|
10
|
+
from urllib.parse import parse_qsl, urlparse
|
|
11
|
+
|
|
12
|
+
from lapis.server_types import BadRequest, Protocol
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class RequestHeader:
|
|
16
|
+
"""
|
|
17
|
+
Utility class to store the request header data
|
|
18
|
+
"""
|
|
19
|
+
method: HTTPMethod
|
|
20
|
+
base_url: str
|
|
21
|
+
query_params: dict[str, str]
|
|
22
|
+
headers: dict[str, str]
|
|
23
|
+
protocol: str
|
|
24
|
+
|
|
25
|
+
class Request:
|
|
26
|
+
"""
|
|
27
|
+
The object class for handling HTTP 1/1.1 requests from clients
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, data: bytes):
|
|
31
|
+
try:
|
|
32
|
+
text = data.decode("iso-8859-1")
|
|
33
|
+
except UnicodeDecodeError as err:
|
|
34
|
+
raise BadRequest("Invalid encoding") from err
|
|
35
|
+
|
|
36
|
+
if "\r\n\r\n" not in text:
|
|
37
|
+
raise BadRequest("Malformed HTTP request")
|
|
38
|
+
|
|
39
|
+
head, self.__body = text.split("\r\n\r\n", 1)
|
|
40
|
+
lines = head.split("\r\n")
|
|
41
|
+
|
|
42
|
+
method_str, url, protocol = lines[0].split(" ", 2)
|
|
43
|
+
if protocol not in ("HTTP/1.0", "HTTP/1.1"):
|
|
44
|
+
raise BadRequest("Unsupported protocol")
|
|
45
|
+
|
|
46
|
+
headers_dict = {}
|
|
47
|
+
for line in lines[1:]:
|
|
48
|
+
if ":" not in line:
|
|
49
|
+
raise BadRequest("Malformed header")
|
|
50
|
+
key, value = line.split(":", 1)
|
|
51
|
+
headers_dict[key.strip()] = value.strip()
|
|
52
|
+
|
|
53
|
+
if protocol == "HTTP/1.1" and "Host" not in headers_dict:
|
|
54
|
+
raise BadRequest("Missing Host header")
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
parsed = urlparse(url)
|
|
58
|
+
except ValueError as exc:
|
|
59
|
+
raise BadRequest("Bad URL") from exc
|
|
60
|
+
|
|
61
|
+
self.__header_data = RequestHeader(
|
|
62
|
+
method=HTTPMethod[method_str.upper()],
|
|
63
|
+
base_url=parsed.path,
|
|
64
|
+
query_params=dict(parse_qsl(parsed.query)),
|
|
65
|
+
headers=headers_dict,
|
|
66
|
+
protocol=protocol
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
self.cookies = {}
|
|
70
|
+
self.slugs = {}
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def method(self) -> HTTPMethod:
|
|
74
|
+
"""
|
|
75
|
+
Returns the HTTP method (e.g., GET, POST) of the request.
|
|
76
|
+
"""
|
|
77
|
+
return self.__header_data.method
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def protocol(self) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Returns the HTTP protocol version (e.g., 'HTTP/1.1').
|
|
83
|
+
"""
|
|
84
|
+
return self.__header_data.protocol
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def headers(self) -> dict[str, str]:
|
|
88
|
+
"""
|
|
89
|
+
Returns a dictionary of the HTTP headers sent by the client.
|
|
90
|
+
Keys are case-sensitive as parsed from the request.
|
|
91
|
+
"""
|
|
92
|
+
return self.__header_data.headers
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def base_url(self) -> str:
|
|
96
|
+
"""
|
|
97
|
+
Returns the path component of the requested URL (e.g., '/api/users').
|
|
98
|
+
"""
|
|
99
|
+
return self.__header_data.base_url
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def query_params(self) -> dict[str, str]:
|
|
103
|
+
"""
|
|
104
|
+
Returns a dictionary containing the URL query string parameters.
|
|
105
|
+
"""
|
|
106
|
+
return self.__header_data.query_params
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def body(self) -> str:
|
|
110
|
+
"""
|
|
111
|
+
Returns the raw entity body of the HTTP request as a string.
|
|
112
|
+
"""
|
|
113
|
+
return self.__body
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Response:
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
The object class for forming a HTTP 1/1.1 response to the client from the server
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(self,
|
|
123
|
+
status_code : int | HTTPStatus = HTTPStatus.OK,
|
|
124
|
+
body : str = "",
|
|
125
|
+
headers : dict[str, any] = None,
|
|
126
|
+
):
|
|
127
|
+
self.status_code = (status_code
|
|
128
|
+
if isinstance(status_code, HTTPStatus)
|
|
129
|
+
else HTTPStatus(status_code))
|
|
130
|
+
|
|
131
|
+
self.protocol = "HTTP/1.1"
|
|
132
|
+
self.headers = headers if headers is not None else {
|
|
133
|
+
"Content-Type": "text/plain",
|
|
134
|
+
}
|
|
135
|
+
self.cookies = {}
|
|
136
|
+
self.body = body
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def reason_phrase(self):
|
|
140
|
+
"""
|
|
141
|
+
Returns the reasoning behind the status code
|
|
142
|
+
"""
|
|
143
|
+
return self.status_code.phrase
|
|
144
|
+
|
|
145
|
+
def to_bytes(self):
|
|
146
|
+
"""
|
|
147
|
+
Returns the raw byte format of the Response class
|
|
148
|
+
"""
|
|
149
|
+
body_bytes = self.body.encode('utf-8')
|
|
150
|
+
if "Content-Length" not in self.headers:
|
|
151
|
+
self.headers["Content-Length"] = len(body_bytes)
|
|
152
|
+
|
|
153
|
+
response_line = f"{self.protocol} {self.status_code.value} {self.reason_phrase}\r\n"
|
|
154
|
+
headers = "".join(f"{k}: {v}\r\n" for k, v in self.headers.items())
|
|
155
|
+
cookies = "".join(f"Set-Cookie: {k}={v}\r\n" for k, v in self.cookies.items())
|
|
156
|
+
|
|
157
|
+
return (response_line + headers + cookies + "\r\n").encode('utf-8') + body_bytes
|
|
158
|
+
|
|
159
|
+
class StreamedResponse(Response):
|
|
160
|
+
|
|
161
|
+
"""
|
|
162
|
+
A variant of the Response class that allows the server to stream back a response to the client
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
stream : Callable[[Request], AsyncGenerator[bytes, None]],
|
|
168
|
+
status_code = HTTPStatus.OK,
|
|
169
|
+
headers : dict[str, str] = None):
|
|
170
|
+
|
|
171
|
+
super().__init__(status_code, "", headers)
|
|
172
|
+
|
|
173
|
+
self.stream = stream
|
|
174
|
+
|
|
175
|
+
self.headers["Transfer-Encoding"] = "chunked"
|
|
176
|
+
|
|
177
|
+
def get_head(self) -> bytes:
|
|
178
|
+
"""
|
|
179
|
+
:return: The inital head of the streamed response from the server
|
|
180
|
+
:rtype: bytes
|
|
181
|
+
"""
|
|
182
|
+
response_line = f"{self.protocol} {self.status_code.value} {self.reason_phrase}\r\n"
|
|
183
|
+
headers = "".join(f"{k}: {v}\r\n" for k, v in self.headers.items())
|
|
184
|
+
cookies = "".join(f"Set-Cookie: {k}={v}\r\n" for k, v in self.cookies.items())
|
|
185
|
+
|
|
186
|
+
return (response_line + headers + cookies + "\r\n").encode('utf-8')
|
|
187
|
+
|
|
188
|
+
class HTTP1Protocol(Protocol):
|
|
189
|
+
|
|
190
|
+
"""
|
|
191
|
+
The protocol created to handle HTTP 1/1.1 communications between server and client
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
request : Request = None
|
|
195
|
+
|
|
196
|
+
def get_config_key(self):
|
|
197
|
+
return "http1.x_config"
|
|
198
|
+
|
|
199
|
+
def get_target_endpoints(self) -> list[str]:
|
|
200
|
+
return [method.name for method in HTTPMethod]
|
|
201
|
+
|
|
202
|
+
def identify(self, initial_data):
|
|
203
|
+
try:
|
|
204
|
+
self.request = Request(initial_data)
|
|
205
|
+
return True
|
|
206
|
+
except BadRequest:
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
def handshake(self, client : socket.socket):
|
|
210
|
+
# don't know how this would create an exception but its here just to be safe
|
|
211
|
+
|
|
212
|
+
current_time = datetime.now().strftime("%H:%M:%S")
|
|
213
|
+
ip, _ = client.getpeername()
|
|
214
|
+
print(f"{current_time} {self.request.method} {self.request.base_url} {ip}")
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
async def handle(self, client : socket.socket, slugs, endpoints):
|
|
218
|
+
|
|
219
|
+
self.request.slugs = slugs
|
|
220
|
+
|
|
221
|
+
if self.request.method in endpoints:
|
|
222
|
+
response : Response = await endpoints[self.request.method](self.request)
|
|
223
|
+
|
|
224
|
+
ip, _ = client.getpeername()
|
|
225
|
+
|
|
226
|
+
if isinstance(response, StreamedResponse):
|
|
227
|
+
client.sendall(response.get_head())
|
|
228
|
+
|
|
229
|
+
current_time = datetime.now().strftime("%H:%M:%S")
|
|
230
|
+
|
|
231
|
+
print(f"{current_time} {response.status_code.value} STREAM -> {ip}")
|
|
232
|
+
|
|
233
|
+
async for packet in response.stream(self.request):
|
|
234
|
+
chunk_len = f"{len(packet):X}\r\n".encode('utf-8')
|
|
235
|
+
client.sendall(chunk_len + packet + b"\r\n")
|
|
236
|
+
|
|
237
|
+
current_time = datetime.now().strftime("%H:%M:%S")
|
|
238
|
+
|
|
239
|
+
client.sendall(b"0\r\n\r\n")
|
|
240
|
+
|
|
241
|
+
print(f"{current_time} {response.status_code.value} STREAM FINISHED -> {ip}")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
else:
|
|
245
|
+
client.sendall(response.to_bytes())
|
|
246
|
+
|
|
247
|
+
current_time = datetime.now().strftime("%H:%M:%S")
|
|
248
|
+
|
|
249
|
+
print(f"{current_time} {response.status_code.value} -> {ip}")
|
|
250
|
+
else:
|
|
251
|
+
raise FileNotFoundError()
|
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The Module containing Lapis' native WebSocket Protocol Handler
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import binascii
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
import socket
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
from enum import Enum
|
|
12
|
+
|
|
13
|
+
from lapis.server_types import Protocol
|
|
14
|
+
from lapis.protocols.http1 import Request, Response
|
|
15
|
+
|
|
16
|
+
class WSRecvTimeoutError(Exception):
|
|
17
|
+
"""
|
|
18
|
+
The Exception raised when recieve request times out
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
class WSRecvInvalidFrameError(Exception):
|
|
22
|
+
"""
|
|
23
|
+
The Exception raised when the recieved frame is invalid
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
class WSPortalClosedError(Exception):
|
|
27
|
+
"""
|
|
28
|
+
The Exception raised when trying to recieve/send from a closed portal
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
class WSOpcode(Enum):
|
|
32
|
+
"""
|
|
33
|
+
The class containing all opcodes for a WSFrame to contain
|
|
34
|
+
"""
|
|
35
|
+
CONTINUATION = 0x0
|
|
36
|
+
TEXT = 0x1
|
|
37
|
+
BINARY = 0x2
|
|
38
|
+
CLOSE = 0x8
|
|
39
|
+
PING = 0x9
|
|
40
|
+
PONG = 0xA
|
|
41
|
+
|
|
42
|
+
class WSFrame():
|
|
43
|
+
"""
|
|
44
|
+
The class used to handle frame data between server and client
|
|
45
|
+
"""
|
|
46
|
+
__data: bytes
|
|
47
|
+
|
|
48
|
+
def __init__(self, data: bytes):
|
|
49
|
+
if len(data) < 2:
|
|
50
|
+
raise ValueError("Data too short to be a WebSocket frame")
|
|
51
|
+
|
|
52
|
+
self.__data = data
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def fin(self) -> bool:
|
|
56
|
+
"""
|
|
57
|
+
Returns if this frame is the final frame of the payload
|
|
58
|
+
|
|
59
|
+
Used for fragmented data frames
|
|
60
|
+
|
|
61
|
+
:rtype: bool
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
return bool(self.__data[0] & 0x80)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def opcode(self) -> WSOpcode:
|
|
68
|
+
"""
|
|
69
|
+
Returns the frame's opcode; the operation the frame is supposed to communicate
|
|
70
|
+
|
|
71
|
+
:rtype: WSOpcode
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
return WSOpcode(self.__data[0] & 0x0F)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def masked(self) -> bool:
|
|
78
|
+
"""
|
|
79
|
+
Returns of the payload of the frame is masked
|
|
80
|
+
|
|
81
|
+
:rtype: bool
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
return bool(self.__data[1] & 0x80)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def payload_length(self) -> int:
|
|
88
|
+
"""
|
|
89
|
+
Returns the length of the payload specified in the frame header
|
|
90
|
+
|
|
91
|
+
:rtype: int
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
length = self.__data[1] & 0x7F
|
|
95
|
+
|
|
96
|
+
if length < 126:
|
|
97
|
+
return length
|
|
98
|
+
if length == 126:
|
|
99
|
+
return int.from_bytes(self.__data[2:4], "big")
|
|
100
|
+
return int.from_bytes(self.__data[2:10], "big")
|
|
101
|
+
|
|
102
|
+
def __header_length(self) -> int:
|
|
103
|
+
"""
|
|
104
|
+
Returns the length of the header of the frame
|
|
105
|
+
|
|
106
|
+
:rtype: int
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
length = self.__data[1] & 0x7F
|
|
110
|
+
|
|
111
|
+
if length < 126:
|
|
112
|
+
return 2
|
|
113
|
+
if length == 126:
|
|
114
|
+
return 4
|
|
115
|
+
return 10
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def masking_key(self) -> bytes | None:
|
|
119
|
+
"""
|
|
120
|
+
Returns the masking key of the frame used to mask the payload
|
|
121
|
+
|
|
122
|
+
returns None if the frame isn't masked
|
|
123
|
+
|
|
124
|
+
:rtype: bytes | None
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
if not self.masked:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
start = self.__header_length()
|
|
131
|
+
return self.__data[start:start + 4]
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def data(self) -> str | bytes:
|
|
135
|
+
"""
|
|
136
|
+
Returns the payload data of the frame
|
|
137
|
+
|
|
138
|
+
returns either string or bytes depending on the opcode
|
|
139
|
+
|
|
140
|
+
:rtype: str | bytes
|
|
141
|
+
"""
|
|
142
|
+
header_len = self.__header_length()
|
|
143
|
+
offset = header_len + (4 if self.masked else 0)
|
|
144
|
+
|
|
145
|
+
payload = self.__data[offset:offset + self.payload_length]
|
|
146
|
+
|
|
147
|
+
# Unmask if needed
|
|
148
|
+
if self.masked:
|
|
149
|
+
payload = bytes(b ^ self.masking_key[i % 4] for i, b in enumerate(payload))
|
|
150
|
+
|
|
151
|
+
# Decode based on opcode
|
|
152
|
+
if self.opcode == WSOpcode.TEXT:
|
|
153
|
+
try:
|
|
154
|
+
return payload.decode("utf-8")
|
|
155
|
+
except UnicodeDecodeError as e:
|
|
156
|
+
raise ValueError("Invalid UTF-8 in TEXT frame") from e
|
|
157
|
+
|
|
158
|
+
return payload
|
|
159
|
+
|
|
160
|
+
def __str__(self) -> str:
|
|
161
|
+
"""
|
|
162
|
+
Returns a stringified version of WSFrame for debugging purposes
|
|
163
|
+
|
|
164
|
+
:rtype: str
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
payload_preview = self.data
|
|
168
|
+
# Truncate if payload is too long for readability
|
|
169
|
+
if isinstance(payload_preview, bytes):
|
|
170
|
+
payload_preview = payload_preview[:50]
|
|
171
|
+
payload_preview = payload_preview.hex() + ("..." if len(self.data) > 50 else "")
|
|
172
|
+
elif isinstance(payload_preview, str):
|
|
173
|
+
payload_preview = payload_preview[:50] + ("..." if len(self.data) > 50 else "")
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
f"WSFrame(fin={self.fin}, opcode={self.opcode}, masked={self.masked}, "
|
|
177
|
+
f"payload_length={self.payload_length}, payload={payload_preview})"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
class WSPortal():
|
|
181
|
+
"""
|
|
182
|
+
The interface the Websocket endpoint function uses to communicate between server and client
|
|
183
|
+
"""
|
|
184
|
+
slugs : dict[str, str] = {}
|
|
185
|
+
|
|
186
|
+
def __init__(self, slugs, client : socket.socket):
|
|
187
|
+
|
|
188
|
+
self.__client : socket.socket = client
|
|
189
|
+
self.__client.setblocking(False)
|
|
190
|
+
|
|
191
|
+
self.inital_req : Request = None
|
|
192
|
+
|
|
193
|
+
self.__recv_queue : asyncio.Queue[WSFrame] = asyncio.Queue[WSFrame]()
|
|
194
|
+
self.__pong_waiters = None
|
|
195
|
+
|
|
196
|
+
self.__closed : bool = False
|
|
197
|
+
self.slugs : dict[str, str] = slugs
|
|
198
|
+
|
|
199
|
+
asyncio.create_task(self.__reader())
|
|
200
|
+
|
|
201
|
+
def __send_frame(self, opcode: WSOpcode, payload: str | bytes = b"", fin: bool = True):
|
|
202
|
+
"""
|
|
203
|
+
Utility function used to send frames from server to client
|
|
204
|
+
|
|
205
|
+
:param opcode: The type of frame sent
|
|
206
|
+
:type opcode: WSOpcode
|
|
207
|
+
:param payload: The data sent with the frame
|
|
208
|
+
:type payload: str | bytes
|
|
209
|
+
:param fin: If the frame is fragmented
|
|
210
|
+
:type fin: bool
|
|
211
|
+
"""
|
|
212
|
+
if self.__closed:
|
|
213
|
+
raise WSPortalClosedError()
|
|
214
|
+
|
|
215
|
+
first_byte = (0x80 if fin else 0) | opcode.value
|
|
216
|
+
|
|
217
|
+
length = len(payload)
|
|
218
|
+
header = bytearray()
|
|
219
|
+
header.append(first_byte)
|
|
220
|
+
|
|
221
|
+
if length < 126:
|
|
222
|
+
header.append(length)
|
|
223
|
+
elif length < (1 << 16):
|
|
224
|
+
header.append(126)
|
|
225
|
+
header.extend(length.to_bytes(2, "big"))
|
|
226
|
+
else:
|
|
227
|
+
header.append(127)
|
|
228
|
+
header.extend(length.to_bytes(8, "big"))
|
|
229
|
+
|
|
230
|
+
data = payload if isinstance(payload, bytes) else payload.encode()
|
|
231
|
+
|
|
232
|
+
self.__client.sendall(bytes(header) + data)
|
|
233
|
+
|
|
234
|
+
async def __read_exact(self, bufsize : int):
|
|
235
|
+
loop = asyncio.get_running_loop()
|
|
236
|
+
data = b""
|
|
237
|
+
try:
|
|
238
|
+
while len(data) < bufsize:
|
|
239
|
+
chunk = await loop.sock_recv(self.__client, bufsize - len(data))
|
|
240
|
+
if not chunk:
|
|
241
|
+
raise ConnectionResetError("Connection Was Reset!")
|
|
242
|
+
data += chunk
|
|
243
|
+
return data
|
|
244
|
+
except Exception as err:
|
|
245
|
+
raise ConnectionError("Connection Error or Timeout") from err
|
|
246
|
+
|
|
247
|
+
async def __reader(self):
|
|
248
|
+
try:
|
|
249
|
+
while not self.__closed:
|
|
250
|
+
|
|
251
|
+
# Get First part of Header
|
|
252
|
+
header : bytes = await self.__read_exact(2)
|
|
253
|
+
length_bytes : bytes = header[1] & 0x7F
|
|
254
|
+
|
|
255
|
+
payload_len : int = 0
|
|
256
|
+
|
|
257
|
+
# Get payload length
|
|
258
|
+
if length_bytes == 126:
|
|
259
|
+
length_bytes = await self.__read_exact(2)
|
|
260
|
+
payload_len = int.from_bytes(length_bytes, "big")
|
|
261
|
+
elif length_bytes == 127:
|
|
262
|
+
length_bytes = await self.__read_exact(8)
|
|
263
|
+
payload_len = int.from_bytes(length_bytes, "big")
|
|
264
|
+
else:
|
|
265
|
+
payload_len = length_bytes
|
|
266
|
+
length_bytes = b""
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# recieve mask if required
|
|
270
|
+
has_mask : bool = bool(header[1] & 0x80)
|
|
271
|
+
mask : bytes = await self.__read_exact(4) if has_mask else b""
|
|
272
|
+
|
|
273
|
+
# recieve body
|
|
274
|
+
body : bytes = await self.__read_exact(payload_len)
|
|
275
|
+
|
|
276
|
+
# Build WSFrame correctly
|
|
277
|
+
frame : WSFrame = WSFrame(header + length_bytes + mask + body)
|
|
278
|
+
|
|
279
|
+
# react based on opcode
|
|
280
|
+
if frame.opcode == WSOpcode.PING:
|
|
281
|
+
if not frame.fin: # Cannot send fragmented control frames
|
|
282
|
+
self.close(1002)
|
|
283
|
+
else:
|
|
284
|
+
self.__send_frame(
|
|
285
|
+
opcode=WSOpcode.PONG,
|
|
286
|
+
payload=frame.data
|
|
287
|
+
if isinstance(frame.data, bytes)
|
|
288
|
+
else frame.data.encode()
|
|
289
|
+
)
|
|
290
|
+
elif frame.opcode == WSOpcode.CLOSE:
|
|
291
|
+
self.close()
|
|
292
|
+
elif frame.opcode == WSOpcode.PONG:
|
|
293
|
+
if not self.__pong_waiters.done():
|
|
294
|
+
self.__pong_waiters.set_result(True)
|
|
295
|
+
else:
|
|
296
|
+
await self.__recv_queue.put(frame)
|
|
297
|
+
except Exception:
|
|
298
|
+
self.close(1011)
|
|
299
|
+
raise
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def closed(self):
|
|
303
|
+
"""
|
|
304
|
+
Returns if the connection between the client and server is open
|
|
305
|
+
"""
|
|
306
|
+
return self.__closed
|
|
307
|
+
|
|
308
|
+
async def recv(self, timeout: float = None) -> str | bytes:
|
|
309
|
+
"""
|
|
310
|
+
Recieves a full frame from the client
|
|
311
|
+
|
|
312
|
+
If the frame is fragmented, WSPortal.recv() will combine the fragments into a full payload
|
|
313
|
+
|
|
314
|
+
:param timeout: If specified, the max time before server raises a WSRecvTimeoutError
|
|
315
|
+
:type timeout: float
|
|
316
|
+
:return: The data of the full frame
|
|
317
|
+
:rtype: str | bytes
|
|
318
|
+
"""
|
|
319
|
+
|
|
320
|
+
if self.closed:
|
|
321
|
+
raise WSPortalClosedError("Tried to recieve from a closed portal!")
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
frame: WSFrame = await asyncio.wait_for(self.__recv_queue.get(), timeout=timeout)
|
|
325
|
+
|
|
326
|
+
if frame.fin: # Unfragmented frame
|
|
327
|
+
current_time = datetime.now().strftime("%H:%M:%S")
|
|
328
|
+
ip, _ = self.__client.getpeername()
|
|
329
|
+
|
|
330
|
+
print(f"{current_time} Server <-WS- {ip}")
|
|
331
|
+
return frame.data
|
|
332
|
+
|
|
333
|
+
result = frame.data
|
|
334
|
+
is_text = isinstance(result, str)
|
|
335
|
+
|
|
336
|
+
while True:
|
|
337
|
+
frame = await asyncio.wait_for(self.__recv_queue.get(), timeout=timeout)
|
|
338
|
+
|
|
339
|
+
if frame.opcode != WSOpcode.CONTINUATION:
|
|
340
|
+
self.close(1002)
|
|
341
|
+
raise WSRecvInvalidFrameError("Expected continuation frame")
|
|
342
|
+
|
|
343
|
+
result += frame.data if is_text else frame.data.decode()
|
|
344
|
+
|
|
345
|
+
if frame.fin:
|
|
346
|
+
break
|
|
347
|
+
|
|
348
|
+
current_time = datetime.now().strftime("%H:%M:%S")
|
|
349
|
+
ip, _ = self.__client.getpeername()
|
|
350
|
+
|
|
351
|
+
print(f"{current_time} Server <-WS- {ip}")
|
|
352
|
+
|
|
353
|
+
return result
|
|
354
|
+
|
|
355
|
+
except asyncio.TimeoutError as err:
|
|
356
|
+
raise WSRecvTimeoutError() from err
|
|
357
|
+
|
|
358
|
+
def send(self, payload : str | bytes):
|
|
359
|
+
"""
|
|
360
|
+
Sends a payload for the client to recieve
|
|
361
|
+
|
|
362
|
+
:param payload: Data to send to the client
|
|
363
|
+
:type payload: str | bytes
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
if self.closed:
|
|
367
|
+
raise WSPortalClosedError("Tried to send through a closed portal!")
|
|
368
|
+
|
|
369
|
+
opcode = WSOpcode.BINARY if isinstance(payload, bytes) else WSOpcode.TEXT
|
|
370
|
+
|
|
371
|
+
self.__send_frame(opcode=opcode, payload=payload)
|
|
372
|
+
|
|
373
|
+
current_time = datetime.now().strftime("%H:%M:%S")
|
|
374
|
+
ip, _ = self.__client.getpeername()
|
|
375
|
+
|
|
376
|
+
print(f"{current_time} Server -WS-> {ip}")
|
|
377
|
+
|
|
378
|
+
async def ping(self, timeout : float) -> bool:
|
|
379
|
+
"""
|
|
380
|
+
Pings client to confirm that client is still connected
|
|
381
|
+
|
|
382
|
+
:param timeout: How long before ping times out and returns false
|
|
383
|
+
:type timeout: float
|
|
384
|
+
:return: If client returns with a *Pong* client frame
|
|
385
|
+
:rtype: bool
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
self.__pong_waiters = asyncio.get_running_loop().create_future()
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
self.__send_frame(WSOpcode.PING)
|
|
392
|
+
|
|
393
|
+
result = await asyncio.wait_for(asyncio.shield(self.__pong_waiters), timeout=timeout)
|
|
394
|
+
return result
|
|
395
|
+
except asyncio.TimeoutError:
|
|
396
|
+
return False
|
|
397
|
+
|
|
398
|
+
finally:
|
|
399
|
+
self.__pong_waiters = None
|
|
400
|
+
|
|
401
|
+
def close(self, code : int = 1000):
|
|
402
|
+
"""
|
|
403
|
+
Closes the connection between the server and client using the given close code
|
|
404
|
+
|
|
405
|
+
:param code: The close code the server will send to the client (default 1000)
|
|
406
|
+
:type code: int
|
|
407
|
+
"""
|
|
408
|
+
|
|
409
|
+
if self.closed:
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
self.__send_frame(
|
|
413
|
+
opcode=WSOpcode.CLOSE,
|
|
414
|
+
payload=code.to_bytes(2,"big")
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
self.__closed = True
|
|
418
|
+
self.__client.close()
|
|
419
|
+
|
|
420
|
+
current_time = datetime.now().strftime("%H:%M:%S")
|
|
421
|
+
ip, _ = self.__client.getpeername()
|
|
422
|
+
|
|
423
|
+
arrow = "-X->" if code == 1000 else "-!X!->"
|
|
424
|
+
|
|
425
|
+
print(f"{current_time} Server {arrow} {ip}")
|
|
426
|
+
|
|
427
|
+
class WebSocketProtocol(Protocol):
|
|
428
|
+
|
|
429
|
+
"""
|
|
430
|
+
The protocol created to handle websocket connections between server and client
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
__WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
434
|
+
|
|
435
|
+
def __init__(self):
|
|
436
|
+
self.inital_req : Request = None
|
|
437
|
+
|
|
438
|
+
def __compute_accept_key(self, sec_key: str) -> str:
|
|
439
|
+
sha1 = hashlib.sha1((sec_key + self.__WS_GUID).encode("ascii")).digest()
|
|
440
|
+
return base64.b64encode(sha1).decode("ascii")
|
|
441
|
+
|
|
442
|
+
def get_config_key(self):
|
|
443
|
+
return "websocket13_config"
|
|
444
|
+
|
|
445
|
+
def get_target_endpoints(self) -> list[str]:
|
|
446
|
+
"""
|
|
447
|
+
:return: All Endpoint functions the WebSocket Protocol looks for
|
|
448
|
+
:rtype: list[str]
|
|
449
|
+
"""
|
|
450
|
+
return ["WEBSOCKET"]
|
|
451
|
+
|
|
452
|
+
def identify(self, initial_data) -> bool:
|
|
453
|
+
self.inital_req : Request = Request(initial_data)
|
|
454
|
+
|
|
455
|
+
if self.inital_req.headers.get("Connection") != "Upgrade":
|
|
456
|
+
return False
|
|
457
|
+
|
|
458
|
+
if self.inital_req.headers.get("Upgrade", "").lower() != "websocket":
|
|
459
|
+
return False
|
|
460
|
+
|
|
461
|
+
return True
|
|
462
|
+
|
|
463
|
+
def handshake(self, client) -> bool:
|
|
464
|
+
req = self.inital_req
|
|
465
|
+
|
|
466
|
+
if req.method != "GET":
|
|
467
|
+
client.send(Response(400).to_bytes())
|
|
468
|
+
return False
|
|
469
|
+
|
|
470
|
+
if "Host" not in req.headers:
|
|
471
|
+
client.send(Response(400).to_bytes())
|
|
472
|
+
return False
|
|
473
|
+
|
|
474
|
+
version = req.headers.get("Sec-WebSocket-Version")
|
|
475
|
+
|
|
476
|
+
if version != "13":
|
|
477
|
+
resp = Response(
|
|
478
|
+
426,
|
|
479
|
+
headers={
|
|
480
|
+
"Upgrade": "websocket",
|
|
481
|
+
"Sec-WebSocket-Version": "13"
|
|
482
|
+
}
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
client.send(resp.to_bytes())
|
|
486
|
+
return False
|
|
487
|
+
|
|
488
|
+
# Create accept key
|
|
489
|
+
|
|
490
|
+
key = req.headers.get("Sec-WebSocket-Key")
|
|
491
|
+
if not key:
|
|
492
|
+
client.send(Response(400).to_bytes())
|
|
493
|
+
return False
|
|
494
|
+
|
|
495
|
+
try:
|
|
496
|
+
raw = base64.b64decode(key, validate=True)
|
|
497
|
+
if len(raw) != 16:
|
|
498
|
+
raise ValueError("Invalid key")
|
|
499
|
+
except (binascii.Error, ValueError):
|
|
500
|
+
client.send(Response(400).to_bytes())
|
|
501
|
+
return False
|
|
502
|
+
|
|
503
|
+
accept_key = self.__compute_accept_key(key)
|
|
504
|
+
|
|
505
|
+
# Send protocol transfer success message
|
|
506
|
+
resp = Response(
|
|
507
|
+
status_code=101,
|
|
508
|
+
headers={
|
|
509
|
+
"Upgrade": "websocket",
|
|
510
|
+
"Connection": "Upgrade",
|
|
511
|
+
"Sec-WebSocket-Accept": accept_key,
|
|
512
|
+
}
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
client.send(resp.to_bytes())
|
|
516
|
+
|
|
517
|
+
current_time = datetime.now().strftime("%H:%M:%S")
|
|
518
|
+
ip, _ = client.getpeername()
|
|
519
|
+
print(f"{current_time} {self.inital_req.method} {self.inital_req.base_url} <-WS-> {ip}")
|
|
520
|
+
|
|
521
|
+
return True
|
|
522
|
+
|
|
523
|
+
async def handle(
|
|
524
|
+
self,
|
|
525
|
+
client : socket.socket,
|
|
526
|
+
slugs : dict[str, str],
|
|
527
|
+
endpoints : dict[str, any]
|
|
528
|
+
):
|
|
529
|
+
|
|
530
|
+
"""
|
|
531
|
+
Handles connection from client until socket is closed
|
|
532
|
+
|
|
533
|
+
:param client: the socket connection between client and server
|
|
534
|
+
:param slugs: any slugs they used to get
|
|
535
|
+
:param endpoints: Description
|
|
536
|
+
"""
|
|
537
|
+
|
|
538
|
+
for endpoint in endpoints:
|
|
539
|
+
if endpoint in self.get_target_endpoints():
|
|
540
|
+
portal : WSPortal = WSPortal(slugs=slugs, client=client)
|
|
541
|
+
await endpoints[endpoint](portal)
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
raise FileNotFoundError("No Websocket Endpoint Found!")
|
lapis/server_types.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module containing all server related types and exceptions
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
import socket
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from typing import get_origin, get_type_hints
|
|
11
|
+
import pathlib
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ServerConfig:
|
|
15
|
+
"""
|
|
16
|
+
The class containing all configuration settings for a Lapis server to operate with
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
api_directory : str = "./api"
|
|
20
|
+
max_request_size : int = 4096
|
|
21
|
+
server_name : str = "Server"
|
|
22
|
+
path_script_name : str = "path"
|
|
23
|
+
|
|
24
|
+
protocol_configs : dict[str, dict] = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_json(cls, file_path : str | pathlib.Path) -> "ServerConfig":
|
|
28
|
+
"""
|
|
29
|
+
Generates a ServerConfig object from the json file found at the file path
|
|
30
|
+
|
|
31
|
+
:param file_path: The file path of the json server config
|
|
32
|
+
:type file_path: str
|
|
33
|
+
:return: The resulting ServerConfig object generated from the json file
|
|
34
|
+
:rtype: ServerConfig
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
base_dir = pathlib.Path(sys.argv[0]).parent.resolve()
|
|
38
|
+
path = (base_dir / file_path).resolve()
|
|
39
|
+
|
|
40
|
+
config : ServerConfig = ServerConfig()
|
|
41
|
+
|
|
42
|
+
with open(path, 'r', encoding='utf-8') as file:
|
|
43
|
+
# Read and parse the JSON content from the file
|
|
44
|
+
data = json.load(file)
|
|
45
|
+
|
|
46
|
+
hints = get_type_hints(config.__class__)
|
|
47
|
+
|
|
48
|
+
for key, expected_type in hints.items():
|
|
49
|
+
if key not in data:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
value = data[key]
|
|
53
|
+
|
|
54
|
+
if not cls._check_type(value, expected_type):
|
|
55
|
+
raise BadConfigError(
|
|
56
|
+
f"\"{key}\" must be of type {expected_type.__name__}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
setattr(config, key, value)
|
|
60
|
+
|
|
61
|
+
return config
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def _check_type(cls, value, expected_type) -> bool:
|
|
65
|
+
"""
|
|
66
|
+
Returns if a value is an instance of a type while accounting for generics
|
|
67
|
+
"""
|
|
68
|
+
origin = get_origin(expected_type)
|
|
69
|
+
|
|
70
|
+
if origin is None:
|
|
71
|
+
return isinstance(value, expected_type)
|
|
72
|
+
|
|
73
|
+
if origin is dict:
|
|
74
|
+
return isinstance(value, dict)
|
|
75
|
+
|
|
76
|
+
if origin is list:
|
|
77
|
+
return isinstance(value, list)
|
|
78
|
+
|
|
79
|
+
return isinstance(value, origin)
|
|
80
|
+
|
|
81
|
+
# region Exceptions
|
|
82
|
+
|
|
83
|
+
class BadRequest(Exception):
|
|
84
|
+
"""
|
|
85
|
+
An Exception raised when a client request is not formatted correctly
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
class BadAPIDirectory(Exception):
|
|
89
|
+
"""
|
|
90
|
+
An Exception raised when the format of the directory containing all endpoints is incorrect
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
class BadConfigError(Exception):
|
|
94
|
+
"""
|
|
95
|
+
An Exception raised when the format or typing of a Config is given
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
class ProtocolEndpointError(Exception):
|
|
99
|
+
"""
|
|
100
|
+
The exception raised when there is an error with protocol target endpoint functions
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
# endregion
|
|
104
|
+
|
|
105
|
+
class Protocol(ABC):
|
|
106
|
+
"""
|
|
107
|
+
An abstract class used for the server to be able to handle different protocals (ex: HTTP/1.1)
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
@abstractmethod
|
|
111
|
+
def get_config_key(self) -> str:
|
|
112
|
+
"""
|
|
113
|
+
Gathers the keyword used to get the protocol's config from ServerConfig.protocol_configs
|
|
114
|
+
|
|
115
|
+
:return: Description
|
|
116
|
+
:rtype: str
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
@abstractmethod
|
|
120
|
+
def get_target_endpoints(self) -> list[str]:
|
|
121
|
+
'''
|
|
122
|
+
:return: A list of all possible target function names of the protocol
|
|
123
|
+
:rtype: list[str]
|
|
124
|
+
'''
|
|
125
|
+
|
|
126
|
+
raise NotImplementedError
|
|
127
|
+
|
|
128
|
+
@abstractmethod
|
|
129
|
+
def identify(self, initial_data: bytes) -> bool:
|
|
130
|
+
"""
|
|
131
|
+
Function called so see if initial request is attempting to upgrade to the given protocol
|
|
132
|
+
|
|
133
|
+
:param initial_data: The initial request from the client
|
|
134
|
+
:type initial_data: bytes
|
|
135
|
+
:return: If the initial request is for the given protocol
|
|
136
|
+
:rtype: bool
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
raise NotImplementedError
|
|
140
|
+
|
|
141
|
+
@abstractmethod
|
|
142
|
+
def handshake(self, client : socket.socket) -> bool:
|
|
143
|
+
'''
|
|
144
|
+
Handles the transfering logic between the initial protocol (HTTP/1.1) to the new protocol
|
|
145
|
+
|
|
146
|
+
:param client: The socket connecting the server to the client
|
|
147
|
+
:type client: socket.socket
|
|
148
|
+
:return: If the handshake was successful
|
|
149
|
+
:rtype: bool
|
|
150
|
+
'''
|
|
151
|
+
|
|
152
|
+
raise NotImplementedError
|
|
153
|
+
|
|
154
|
+
@abstractmethod
|
|
155
|
+
async def handle(
|
|
156
|
+
self,
|
|
157
|
+
client : socket.socket,
|
|
158
|
+
slugs: dict[str, str],
|
|
159
|
+
endpoints: dict[str, any]
|
|
160
|
+
):
|
|
161
|
+
'''
|
|
162
|
+
Handles the protocol logic and server to client communication
|
|
163
|
+
|
|
164
|
+
:param client: The socket connecting the server to the client
|
|
165
|
+
:type client: socket.socket
|
|
166
|
+
:param slugs: Any slugs in the url used to reach the endpoints
|
|
167
|
+
:type slugs: dict[str, str]
|
|
168
|
+
:param endpoints: All endpoints of a given url
|
|
169
|
+
:type endpoints: dict[str, any]
|
|
170
|
+
'''
|
|
171
|
+
|
|
172
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lapis-api
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: An organized way to create REST APIs
|
|
5
|
+
Project-URL: Homepage, https://github.com/CQVan/Lapis
|
|
6
|
+
Project-URL: Issues, https://github.com/CQVan/Lapis/issues
|
|
7
|
+
Author: Chandler Van
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
Lapis is a file-based REST API framework inspired by Modern Serverless Cloud Services
|
|
17
|
+
|
|
18
|
+
To create a basic Lapis server, create a folder called *api* and create python script named *path.py*
|
|
19
|
+
then within your main folder create a python script to start the server (we will call this script *main.py* in our example)
|
|
20
|
+
|
|
21
|
+
Your project directory should look like this:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
project-root/
|
|
25
|
+
|-- api/
|
|
26
|
+
| |-- path.py
|
|
27
|
+
`-- main.py
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Then within the *api/path.py* file create your first GET api endpoint by adding the following code:
|
|
31
|
+
```py
|
|
32
|
+
from lapis import Response, Request
|
|
33
|
+
|
|
34
|
+
async def GET (req : Request) -> Response:
|
|
35
|
+
return Response(status_code=200, body="Hello World!")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Finally by adding the following code to *main.py* and running it:
|
|
39
|
+
```py
|
|
40
|
+
from lapis import Lapis
|
|
41
|
+
|
|
42
|
+
server = Lapis()
|
|
43
|
+
|
|
44
|
+
server.run("localhost", 80)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
You can now send an HTTP GET request to localhost:80 and recieve the famous **Hello World!** response!
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
lapis/__init__.py,sha256=5Owb4_8N_pzDnjtfmLErgEvbWcuTYiWWrnvAZwu3aTQ,336
|
|
2
|
+
lapis/lapis.py,sha256=CZNQLdpQmuAbOC2wLbQ6mvaML1OKZ8lqnE4Gm48lbhw,9156
|
|
3
|
+
lapis/server_types.py,sha256=1DuYpgao2uacgHci3c5M0x7dhBpJIyysDx6Bu0DyT1g,5062
|
|
4
|
+
lapis/protocols/http1.py,sha256=EyQWeAPM5tWp4z9z0bi5BgPQ4JFOexDfI9RM9lBNpb4,7920
|
|
5
|
+
lapis/protocols/websocket.py,sha256=Kz0XL8rgxfveU7fy7Xwc7pkFrtj5ywLPXiGpQFjR2AA,16630
|
|
6
|
+
lapis_api-0.2.0.dist-info/METADATA,sha256=bc0OmL62Ryu0zipHknCxVDsIw0sy-_lin9p3MCIJqFU,1398
|
|
7
|
+
lapis_api-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
8
|
+
lapis_api-0.2.0.dist-info/licenses/LICENSE,sha256=sA2W0Je4lZ-KPmY6VUZ0p4_O6IPLLHJkyHahJnhM_5M,1092
|
|
9
|
+
lapis_api-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) [2025] [Chandler Van]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|