Skip to content

Commit 3f6c5b7

Browse files
committed
[stalker] init checker
1 parent c13baa2 commit 3f6c5b7

File tree

4 files changed

+516
-0
lines changed

4 files changed

+516
-0
lines changed

checkers/stalker/client.py

+211
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
#!/usr/bin/env python3
2+
3+
import json
4+
import dataclasses
5+
from typing import Self, Dict, Any
6+
7+
import gornilo.http_clients
8+
9+
import model
10+
11+
12+
MAX_BODY_SIZE = 1 << 20 # 1 MiB
13+
CHUNK_SIZE = 1 << 10 # 1 KiB
14+
15+
TOKEN_HEADER_NAME = 'X-Token'
16+
17+
18+
@dataclasses.dataclass
19+
class Response:
20+
code: int
21+
content: Dict[str, Any]
22+
23+
24+
class Api:
25+
def __init__(self: Self, hostname: str, port: int) -> None:
26+
self.url = f'http://{hostname}:{port}'
27+
self.token = 'x'
28+
29+
def user_get(self: Self, username: str) -> model.User:
30+
url = self.url + f'/users/profile/{username}'
31+
32+
response = self.http_request('GET', url)
33+
34+
if isinstance(response, Response) and response.code == 200:
35+
return model.User.parse(response.content)
36+
37+
raise model.ProtocolError('invalid response on user/profile')
38+
39+
def user_register(self: Self, username: str, password: str) -> bool:
40+
url = self.url + '/users/register'
41+
body = {
42+
'name': username,
43+
'password': password,
44+
}
45+
46+
response = self.http_request('POST', url, body)
47+
48+
if isinstance(response, Response) and response.code == 200:
49+
return True
50+
51+
if isinstance(response, model.ServiceError) and response.name == 'AlreadyExistsError':
52+
return False
53+
54+
raise model.ProtocolError('invalid response on user/register')
55+
56+
def user_login(self: Self, username: str, password: str) -> bool:
57+
url = self.url + '/users/login'
58+
body = {
59+
'name': username,
60+
'password': password,
61+
}
62+
63+
response = self.http_request('POST', url, body)
64+
65+
if isinstance(response, Response) and response.code == 200:
66+
return True
67+
68+
if isinstance(response, model.ServiceError) and response.name == 'InvalidCredentialsError':
69+
return False
70+
71+
raise model.ProtocolError('invalid response on user/login')
72+
73+
def user_logout(self: Self) -> bool:
74+
url = self.url + '/users/logout'
75+
body = {}
76+
77+
response = self.http_request('POST', url, body)
78+
79+
if isinstance(response, Response) and response.code == 200:
80+
return True
81+
82+
raise model.ProtocolError('invalid response on user/logout')
83+
84+
def note_get(self: Self, title: str) -> model.Note:
85+
url = self.url + f'/notes/{title}'
86+
87+
response = self.http_request('GET', url)
88+
89+
if isinstance(response, Response) and response.code == 200:
90+
return model.Note.parse(response.content)
91+
92+
raise model.ProtocolError('invalid response on note/get')
93+
94+
def note_create(self: Self, title: str, visible: bool, content: str) -> bool:
95+
url = self.url + '/notes'
96+
body = {
97+
'title': title,
98+
'visible': visible,
99+
'content': content,
100+
}
101+
102+
response = self.http_request('POST', url, body)
103+
104+
if isinstance(response, Response) and response.code == 200:
105+
return True
106+
107+
if isinstance(response, model.ServiceError) and response.name == 'AlreadyExistsError':
108+
return False
109+
110+
raise model.ProtocolError('invalid response on note/create')
111+
112+
def note_share(self: Self, title: str, viewer: str) -> bool:
113+
url = self.url + f'/notes/{title}/share'
114+
body = {
115+
'viewer': viewer,
116+
}
117+
118+
response = self.http_request('POST', url, body)
119+
120+
if isinstance(response, Response) and response.code == 200:
121+
return True
122+
123+
if isinstance(response, model.ServiceError) and response.name in ('OwnerMismatchError', 'UserNotFoundError'):
124+
return False
125+
126+
raise model.ProtocolError('invalid response on note/share')
127+
128+
def note_deny(self: Self, title: str, viewer: str) -> bool:
129+
url = self.url + f'/notes/{title}/deny'
130+
body = {
131+
'viewer': viewer,
132+
}
133+
134+
response = self.http_request('POST', url, body)
135+
136+
if isinstance(response, Response) and response.code == 200:
137+
return True
138+
139+
if isinstance(response, model.ServiceError) and response.name in ('OwnerMismatchError', 'UserNotFoundError'):
140+
return False
141+
142+
raise model.ProtocolError('invalid response on note/deny')
143+
144+
def note_destroy(self: Self, title: str) -> bool:
145+
url = self.url + f'/notes/{title}/destroy'
146+
body = {}
147+
148+
response = self.http_request('POST', url, body)
149+
150+
if isinstance(response, Response) and response.code == 200:
151+
return True
152+
153+
if isinstance(response, model.ServiceError) and response.name in ('OwnerMismatchError'):
154+
return False
155+
156+
raise model.ProtocolError('invalid response on note/destroy')
157+
158+
def http_request(
159+
self: Self, method: str, url: str, body: Dict[str, Any] = {},
160+
) -> Response | model.ServiceError:
161+
session = gornilo.http_clients.requests_with_retries(
162+
status_forcelist = (500, 502),
163+
)
164+
165+
response = session.request(
166+
method = method,
167+
url = url,
168+
json = body,
169+
allow_redirects = False,
170+
stream = True,
171+
headers = {
172+
TOKEN_HEADER_NAME: self.token,
173+
},
174+
)
175+
176+
self.token = response.headers.get(TOKEN_HEADER_NAME)
177+
178+
try:
179+
content_length = int(response.headers.get('Content-Length', '0'))
180+
except Exception:
181+
raise model.ProtocolError('invalid http headers')
182+
183+
if content_length > MAX_BODY_SIZE:
184+
raise model.ProtocolError('body size is too big')
185+
186+
chunks = []
187+
total_length = 0
188+
189+
for chunk in response.iter_content(CHUNK_SIZE, decode_unicode = False):
190+
chunks.append(chunk)
191+
total_length += len(chunk)
192+
193+
if total_length > MAX_BODY_SIZE:
194+
raise model.ProtocolError('body size is too big')
195+
196+
data = b''.join(chunks)
197+
198+
try:
199+
content = json.loads(data)
200+
except Exception:
201+
raise model.ProtocolError('failed to parse json response')
202+
203+
if 'error' in content:
204+
error = content['error']
205+
206+
if not isinstance(error, dict):
207+
raise model.ProtocolError('failed to parse error')
208+
209+
return model.ServiceError.parse(error)
210+
211+
return Response(response.status_code, content)

checkers/stalker/model.py

+118
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env python3
2+
3+
import dataclasses
4+
from typing import List, Dict, Any
5+
6+
7+
class ProtocolError(Exception):
8+
pass
9+
10+
11+
class ValidationError(Exception):
12+
pass
13+
14+
15+
@dataclasses.dataclass
16+
class ServiceError:
17+
name: str
18+
message: str | None
19+
20+
@staticmethod
21+
def parse(obj: Dict[str, Any]) -> 'ServiceError':
22+
name = obj.get('name')
23+
message = obj.get('message')
24+
25+
assertions = [
26+
isinstance(name, str),
27+
any([
28+
message is None,
29+
isinstance(message, str),
30+
]),
31+
]
32+
33+
if not all(assertions):
34+
raise ValidationError('invalid error structure')
35+
36+
return ServiceError(name = name, message = message)
37+
38+
39+
@dataclasses.dataclass
40+
class Note:
41+
title: str
42+
visible: bool
43+
content: str | None
44+
viewers: List[str] | None
45+
owner: str
46+
47+
@staticmethod
48+
def parse(obj: Dict[str, Any]) -> 'Note':
49+
title = obj.get('title')
50+
visible = obj.get('visible')
51+
content = obj.get('content')
52+
viewers = obj.get('viewers')
53+
owner = obj.get('owner')
54+
55+
assertions = [
56+
isinstance(title, str),
57+
isinstance(visible, bool),
58+
any([
59+
content is None,
60+
isinstance(content, str),
61+
]),
62+
any([
63+
viewers is None,
64+
all([
65+
isinstance(viewers, list),
66+
all(isinstance(viewer, str) for viewer in viewers),
67+
]),
68+
]),
69+
isinstance(owner, str),
70+
]
71+
72+
if not all(assertions):
73+
raise ValidationError('invalid note structure')
74+
75+
return Note(
76+
title = title,
77+
visible = visible,
78+
content = content,
79+
viewers = viewers,
80+
owner = owner,
81+
)
82+
83+
84+
@dataclasses.dataclass
85+
class User:
86+
name: str
87+
owned_notes: List[str]
88+
shared_notes: List[str] | None
89+
90+
@staticmethod
91+
def parse(obj: Dict[str, Any]) -> 'User':
92+
name = obj.get('name')
93+
owned_notes = obj.get('ownedNotes')
94+
shared_notes = obj.get('sharedNotes')
95+
96+
assertions = [
97+
isinstance(name, str),
98+
all([
99+
isinstance(owned_notes, list),
100+
all(isinstance(owned_note, str) for owned_note in owned_notes),
101+
]),
102+
any([
103+
shared_notes is None,
104+
all([
105+
isinstance(shared_notes, list),
106+
all(isinstance(shared_note, str) for shared_note in shared_notes),
107+
]),
108+
]),
109+
]
110+
111+
if not all(assertions):
112+
raise ValidationError('invalid user structure')
113+
114+
return User(
115+
name = name,
116+
owned_notes = owned_notes,
117+
shared_notes = shared_notes,
118+
)

checkers/stalker/requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
gornilo==0.9.3
2+
requests==2.28.2

0 commit comments

Comments
 (0)