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 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}")
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.