GNServer 0.0.0.0.34__py3-none-any.whl → 0.0.0.0.36__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.
GNServer/_app.py CHANGED
@@ -1,11 +1,8 @@
1
1
 
2
2
 
3
3
 
4
- import re
5
4
  import os
6
5
  import sys
7
- import uuid
8
- import decimal
9
6
  import asyncio
10
7
  import inspect
11
8
  import traceback
@@ -16,16 +13,17 @@ from aioquic.asyncio.server import serve
16
13
  from aioquic.asyncio.protocol import QuicConnectionProtocol
17
14
  from aioquic.quic.configuration import QuicConfiguration
18
15
  from aioquic.quic.events import QuicEvent, StreamDataReceived
19
- from typing import Any, AsyncGenerator, Union, get_origin, get_args
16
+ from typing import Any, AsyncGenerator, Union
20
17
 
21
18
 
22
19
  from gnobjects.net.objects import GNRequest, GNResponse, FileObject, CORSObject, TemplateObject
23
20
  from gnobjects.net.fastcommands import AllGNFastCommands, GNFastCommand
24
21
 
25
22
 
23
+ from ._func_params_validation import register_schema_by_key, validate_params_by_key
24
+ from ._cors_resolver import resolve_cors
26
25
 
27
-
28
-
26
+ from ._routes import Route, _compile_path, _ensure_async, _convert_value
29
27
 
30
28
 
31
29
 
@@ -112,281 +110,11 @@ def guess_type(filename: str) -> str:
112
110
  return mime_map.get(ext, "application/octet-stream")
113
111
 
114
112
 
115
- import re
116
- from typing import List
117
-
118
- # regex для !{var}, поддерживает вложенность через точку
119
- TPL_VAR_RE = re.compile(r'(?<!\\)!\{([A-Za-z_][A-Za-z0-9_\.]*)\}')
120
-
121
- # список mime, которые считаем текстовыми
122
- TEXTUAL_MIME_PREFIXES = [
123
- "text/", # text/html, text/css, text/plain
124
- ]
125
- TEXTUAL_MIME_EXACT = {
126
- "application/javascript",
127
- "application/json",
128
- "application/xml",
129
- "application/xhtml+xml"
130
- }
131
- TEXTUAL_MIME_SUFFIXES = (
132
- "+xml", # например application/rss+xml
133
- "+json", # application/ld+json
134
- )
135
-
136
- def extract_template_vars(filedata: bytes, mime: str) -> List[str]:
137
- """
138
- Ищет все !{var} в тексте, если MIME относится к текстовым.
139
- """
140
- mime = (mime or "").lower().strip()
141
-
142
- # определяем, текстовый ли mime
143
- is_textual = (
144
- mime.startswith(tuple(TEXTUAL_MIME_PREFIXES))
145
- or mime in TEXTUAL_MIME_EXACT
146
- or mime.endswith(TEXTUAL_MIME_SUFFIXES)
147
- or "javascript" in mime
148
- or "json" in mime
149
- or "xml" in mime
150
- )
151
-
152
- if not is_textual:
153
- return []
154
-
155
- try:
156
- text = filedata.decode("utf-8", errors="ignore")
157
- except Exception:
158
- return []
159
-
160
- return list(set(m.group(1) for m in TPL_VAR_RE.finditer(text)))
161
-
162
-
163
- import re
164
- from urllib.parse import urlparse
165
-
166
- def resolve_cors(origin_url: str, rules: list[str]) -> bool:
167
- """
168
- Возвращает origin_url если он матчится хотя бы с одним правилом.
169
- Правила:
170
- - "*.example.com" -> wildcard (одна метка)
171
- - "**.example.com" -> globstar (0+ меток)
172
- - "pages.*.core.gn" -> смешанное
173
- - "gn://*.site.tld" -> с проверкой схемы
174
- - "!<regex>" -> полное соответствие по regex к origin_url
175
- """
176
-
177
- if origin_url == 'gn:proxy:sys':
178
- return True
179
-
180
-
181
-
182
113
 
183
114
 
184
- origin = origin_url.rstrip("/")
185
- pu = urlparse(origin)
186
- scheme = (pu.scheme or "").lower()
187
- host = (pu.hostname or "").lower()
188
- port = pu.port # может быть None
189
115
 
190
- if not host:
191
- return False
192
-
193
- for rule in rules:
194
- rule = rule.rstrip("/")
195
-
196
- # 1) Регекс-правило
197
- if rule.startswith("!"):
198
- pattern = rule[1:]
199
- if re.fullmatch(pattern, origin):
200
- return True
201
- continue
202
-
203
- # 2) Разбор схемы/хоста в правиле
204
- r_scheme = ""
205
- r_host = ""
206
- r_port = None
207
-
208
- if "://" in rule:
209
- pr = urlparse(rule)
210
- r_scheme = (pr.scheme or "").lower()
211
- # pr.netloc может содержать порт
212
- netloc = pr.netloc.lower()
213
- # разберём порт, если есть
214
- if ":" in netloc and not netloc.endswith("]"): # простая обработка IPv6 не требуется здесь
215
- name, _, p = netloc.rpartition(":")
216
- r_host = name
217
- try:
218
- r_port = int(p)
219
- except ValueError:
220
- r_port = None
221
- else:
222
- r_host = netloc
223
- else:
224
- r_host = rule.lower()
225
116
 
226
- # схема в правиле задана -> должна совпасть
227
- if r_scheme and r_scheme != scheme:
228
- continue
229
- # порт в правиле задан -> должен совпасть
230
- if r_port is not None and r_port != port:
231
- continue
232
117
 
233
- # 3) Сопоставление хоста по шаблону с * и ** (по меткам)
234
- if _host_matches_pattern(host, r_host):
235
- return True
236
-
237
- return False
238
-
239
-
240
- def _host_matches_pattern(host: str, pattern: str) -> bool:
241
- """
242
- Матчит host против pattern по доменным меткам:
243
- - '*' -> ровно одна метка
244
- - '**' -> ноль или больше меток
245
- Остальные метки — точное совпадение (без внутр. вайлдкардов).
246
- Примеры:
247
- host=pages.static.core.gn, pattern=**.core.gn -> True
248
- host=pages.static.core.gn, pattern=pages.*.core.gn -> True
249
- host=pages.static.core.gn, pattern=*.gn.gn -> False
250
- host=abc.def.example.com, pattern=*.example.com -> False (нужно **.example.com)
251
- host=abc.example.com, pattern=*.example.com -> True
252
- """
253
- host_labels = host.split(".")
254
- pat_labels = pattern.split(".")
255
-
256
- # быстрый путь: точное совпадение без вайлдкардов
257
- if "*" not in pattern:
258
- return host == pattern
259
-
260
- # рекурсивный матч с поддержкой ** (globstar)
261
- def match(hi: int, pi: int) -> bool:
262
- # оба дошли до конца
263
- if pi == len(pat_labels) and hi == len(host_labels):
264
- return True
265
- # закончился паттерн — нет
266
- if pi == len(pat_labels):
267
- return False
268
-
269
- token = pat_labels[pi]
270
- if token == "**":
271
- # два варианта:
272
- # - пропустить '**' (ноль меток)
273
- if match(hi, pi + 1):
274
- return True
275
- # - съесть одну метку (если есть) и остаться на '**'
276
- if hi < len(host_labels) and match(hi + 1, pi):
277
- return True
278
- return False
279
- elif token == "*":
280
- # нужно съесть ровно одну метку
281
- if hi < len(host_labels):
282
- return match(hi + 1, pi + 1)
283
- return False
284
- else:
285
- # точное совпадение метки
286
- if hi < len(host_labels) and host_labels[hi] == token:
287
- return match(hi + 1, pi + 1)
288
- return False
289
-
290
- return match(0, 0)
291
-
292
-
293
-
294
-
295
- @dataclass
296
- class Route:
297
- method: str
298
- path_expr: str
299
- regex: Pattern[str]
300
- param_types: dict[str, Callable[[str], Any]]
301
- handler: Callable[..., Any]
302
- name: str
303
- cors: Optional[CORSObject]
304
-
305
- _PARAM_REGEX: dict[str, str] = {
306
- "str": r"[^/]+",
307
- "path": r".+",
308
- "int": r"\d+",
309
- "float": r"[+-]?\d+(?:\.\d+)?",
310
- "bool": r"(?:true|false|1|0)",
311
- "uuid": r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-"
312
- r"[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
313
- r"[0-9a-fA-F]{12}",
314
- "datetime": r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?",
315
- "date": r"\d{4}-\d{2}-\d{2}",
316
- "time": r"\d{2}:\d{2}:\d{2}(?:\.\d+)?",
317
- "decimal": r"[+-]?\d+(?:\.\d+)?",
318
- }
319
-
320
- _CONVERTER_FUNC: dict[str, Callable[[str], Any]] = {
321
- "int": int,
322
- "float": float,
323
- "bool": lambda s: s.lower() in {"1","true","yes","on"},
324
- "uuid": uuid.UUID,
325
- "decimal": decimal.Decimal,
326
- "datetime": datetime.datetime.fromisoformat,
327
- "date": datetime.date.fromisoformat,
328
- "time": datetime.time.fromisoformat,
329
- }
330
-
331
- def _compile_path(path: str) -> tuple[Pattern[str], dict[str, Callable[[str], Any]]]:
332
- param_types: dict[str, Callable[[str], Any]] = {}
333
- rx_parts: list[str] = ["^"]
334
- i = 0
335
- while i < len(path):
336
- if path[i] != "{":
337
- rx_parts.append(re.escape(path[i]))
338
- i += 1
339
- continue
340
- j = path.index("}", i)
341
- spec = path[i+1:j]
342
- i = j + 1
343
-
344
- if ":" in spec:
345
- name, conv = spec.split(":", 1)
346
- else:
347
- name, conv = spec, "str"
348
-
349
- if conv.startswith("^"):
350
- rx = f"(?P<{name}>{conv})"
351
- typ = str
352
- else:
353
- rx = f"(?P<{name}>{_PARAM_REGEX.get(conv, _PARAM_REGEX['str'])})"
354
- typ = _CONVERTER_FUNC.get(conv, str)
355
-
356
- rx_parts.append(rx)
357
- param_types[name] = typ
358
-
359
- rx_parts.append("$")
360
- return re.compile("".join(rx_parts)), param_types
361
-
362
- def _convert_value(raw: str | list[str], ann: Any, fallback: Callable[[str], Any]) -> Any:
363
- origin = get_origin(ann)
364
- args = get_args(ann)
365
-
366
- if isinstance(raw, list) or origin is list:
367
- subtype = args[0] if (origin is list and args) else str
368
- if not isinstance(raw, list):
369
- raw = [raw]
370
- return [_convert_value(r, subtype, fallback) for r in raw]
371
-
372
- # --- fix Union ---
373
- if origin is Union:
374
- for subtype in args:
375
- try:
376
- return _convert_value(raw, subtype, fallback)
377
- except Exception:
378
- continue
379
- return raw # если ни один тип не подошёл
380
-
381
- conv = _CONVERTER_FUNC.get(ann, ann) if ann is not inspect._empty else fallback
382
- return conv(raw) if callable(conv) else raw
383
-
384
- def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]:
385
- if inspect.iscoroutinefunction(fn) or inspect.isasyncgenfunction(fn):
386
- return fn
387
- async def wrapper(*args, **kw):
388
- return fn(*args, **kw)
389
- return wrapper
390
118
 
391
119
  class App:
392
120
  def __init__(self):
@@ -412,6 +140,7 @@ class App:
412
140
  cors
413
141
  )
414
142
  )
143
+ register_schema_by_key(fn)
415
144
  return fn
416
145
  return decorator
417
146
 
@@ -490,7 +219,7 @@ class App:
490
219
  return GNResponse("gn:backend:801", {'error': 'Cors error. Route has cors but request has no origin url.'})
491
220
  if not resolve_cors(request._origin, r.cors.allow_origins):
492
221
  return GNResponse("gn:backend:802", {'error': 'Cors error: origin'})
493
- if request.method not in r.cors.allow_methods and '*' not in r.cors.allow_methods:
222
+ if r.cors.allow_methods is not None and request.method not in r.cors.allow_methods and '*' not in r.cors.allow_methods:
494
223
  return GNResponse("gn:backend:803", {'error': 'Cors error: method'})
495
224
 
496
225
  sig = inspect.signature(r.handler)
@@ -515,6 +244,12 @@ class App:
515
244
  params = set(sig.parameters.keys())
516
245
  kw = {k: v for k, v in kw.items() if k in params}
517
246
 
247
+
248
+ rv = validate_params_by_key(kw, r.handler)
249
+ if rv is not None:
250
+ AllGNFastCommands.UnprocessableEntity({'dev_error': rv, 'user_error': f'Server request error {self.domain}'})
251
+
252
+
518
253
  if inspect.isasyncgenfunction(r.handler):
519
254
  return r.handler(**kw)
520
255
 
@@ -532,7 +267,7 @@ class App:
532
267
  return GNResponse("gn:backend:801", {'error': 'Cors error. Route has cors but request has no origin url. [2]'})
533
268
  if not resolve_cors(request._origin, result._cors.allow_origins):
534
269
  return GNResponse("gn:backend:802", {'error': 'Cors error: origin'})
535
- if request.method not in result._cors.allow_methods and '*' not in result._cors.allow_methods:
270
+ if result._cors.allow_methods is not None and request.method not in result._cors.allow_methods and '*' not in result._cors.allow_methods:
536
271
  return GNResponse("gn:backend:803", {'error': 'Cors error: method'})
537
272
  return result
538
273
  else:
@@ -579,7 +314,6 @@ class App:
579
314
  def _init_sys_routes(self):
580
315
  @self.post('/!gn-vm-host/ping', cors=CORSObject())
581
316
  async def r_ping(request: GNRequest):
582
-
583
317
  if request.client.ip != '127.0.0.1':
584
318
  raise AllGNFastCommands.Forbidden()
585
319
  return GNResponse('ok', {'time': datetime.datetime.now(datetime.UTC).isoformat()})
@@ -615,11 +349,6 @@ class App:
615
349
  if not stream: # если не стрим, то ждем конец quic стрима и запускаем обработку ответа
616
350
  if event.end_stream:
617
351
  request = GNRequest.deserialize(buf, mode)
618
- # request.stream_id = event.stream_id
619
- # loop = asyncio.get_event_loop()
620
- # request.fut = loop.create_future()
621
-
622
-
623
352
  request.stream_id = event.stream_id # type: ignore
624
353
  asyncio.create_task(self._handle_request(request, mode))
625
354
  logger.debug(f'Отправлена задача разрешения пакета {request} route -> {request.route}')
@@ -660,9 +389,6 @@ class App:
660
389
  logger.debug(f'Закрываем стрим [{event.stream_id}]')
661
390
  return
662
391
 
663
-
664
-
665
-
666
392
  queue.put_nowait(request)
667
393
 
668
394
  # отдаем очередь в интерфейс
@@ -684,7 +410,6 @@ class App:
684
410
  request.client._data['remote_addr'] = self._quic._network_paths[0].addr
685
411
 
686
412
  try:
687
-
688
413
  response = await self._api.dispatchRequest(request)
689
414
 
690
415
  if inspect.isasyncgen(response):
@@ -764,8 +489,6 @@ class App:
764
489
  if run is not None:
765
490
  await run()
766
491
 
767
-
768
-
769
492
 
770
493
  if wait:
771
494
  await asyncio.Event().wait()
GNServer/_client.py CHANGED
@@ -76,15 +76,15 @@ class GNExceptions:
76
76
 
77
77
 
78
78
  from KeyisBTools import TTLDict
79
- from KeyisBTools.cryptography.sign import S1
80
- from KeyisBTools.cryptography import m1, hash
79
+ from KeyisBTools.cryptography.sign import s2
80
+ from KeyisBTools.cryptography import m1
81
+ from KeyisBTools.cryptography.bytes import hash
81
82
  from KeyisBTools.models.serialization import serialize, deserialize
82
83
  from gnobjects.net.objects import GNRequest, GNResponse, Url
83
84
 
84
85
  from ._crt import crt_client
85
86
 
86
87
 
87
- s1 = S1()
88
88
 
89
89
 
90
90
  async def chain_async(first_item, rest: AsyncIterable) -> AsyncGenerator:
@@ -124,6 +124,9 @@ class AsyncClient:
124
124
  resuilt = self._dns_cache.get(domain)
125
125
  if resuilt is not None:
126
126
  return resuilt
127
+
128
+ if domain == 'api.dns.core':
129
+ return self.__dns_core__ipv4
127
130
 
128
131
  if ':' in domain and domain.split('.')[-1].split(':')[0].isdigit() and domain.split(':')[-1].isdigit():
129
132
  return domain
@@ -133,7 +136,7 @@ class AsyncClient:
133
136
  self.__dns_client = AsyncClient()
134
137
 
135
138
  if self.__server_key is not None:
136
- s = s1.sign(self.__server_key)
139
+ s = s2.sign(self.__server_key)
137
140
  data = m1.encrypt(s, domain.encode(), serialize({'domain': domain}), hash(self.__server_key))
138
141
  payload = {'sign': {'alg': 'KeyisB-c-s-m1', 'data': s}, 'data': data}
139
142
  else:
@@ -0,0 +1,129 @@
1
+
2
+ import re
3
+ from urllib.parse import urlparse
4
+
5
+ def resolve_cors(origin_url: str, rules: list[str]) -> bool:
6
+ """
7
+ Возвращает origin_url если он матчится хотя бы с одним правилом.
8
+ Правила:
9
+ - "*.example.com" -> wildcard (одна метка)
10
+ - "**.example.com" -> globstar (0+ меток)
11
+ - "pages.*.core.gn" -> смешанное
12
+ - "gn://*.site.tld" -> с проверкой схемы
13
+ - "!<regex>" -> полное соответствие по regex к origin_url
14
+ """
15
+
16
+ if origin_url == 'gn:proxy:sys':
17
+ return True
18
+
19
+ if rules in ('*', ['*']):
20
+ return True
21
+
22
+
23
+
24
+ origin = origin_url.rstrip("/")
25
+ pu = urlparse(origin)
26
+ scheme = (pu.scheme or "").lower()
27
+ host = (pu.hostname or "").lower()
28
+ port = pu.port # может быть None
29
+
30
+ if not host:
31
+ return False
32
+
33
+ for rule in rules:
34
+ rule = rule.rstrip("/")
35
+
36
+ # 1) Регекс-правило
37
+ if rule.startswith("!"):
38
+ pattern = rule[1:]
39
+ if re.fullmatch(pattern, origin):
40
+ return True
41
+ continue
42
+
43
+ # 2) Разбор схемы/хоста в правиле
44
+ r_scheme = ""
45
+ r_host = ""
46
+ r_port = None
47
+
48
+ if "://" in rule:
49
+ pr = urlparse(rule)
50
+ r_scheme = (pr.scheme or "").lower()
51
+ # pr.netloc может содержать порт
52
+ netloc = pr.netloc.lower()
53
+ # разберём порт, если есть
54
+ if ":" in netloc and not netloc.endswith("]"): # простая обработка IPv6 не требуется здесь
55
+ name, _, p = netloc.rpartition(":")
56
+ r_host = name
57
+ try:
58
+ r_port = int(p)
59
+ except ValueError:
60
+ r_port = None
61
+ else:
62
+ r_host = netloc
63
+ else:
64
+ r_host = rule.lower()
65
+
66
+ # схема в правиле задана -> должна совпасть
67
+ if r_scheme and r_scheme != scheme:
68
+ continue
69
+ # порт в правиле задан -> должен совпасть
70
+ if r_port is not None and r_port != port:
71
+ continue
72
+
73
+ # 3) Сопоставление хоста по шаблону с * и ** (по меткам)
74
+ if _host_matches_pattern(host, r_host):
75
+ return True
76
+
77
+ return False
78
+
79
+ def _host_matches_pattern(host: str, pattern: str) -> bool:
80
+ """
81
+ Матчит host против pattern по доменным меткам:
82
+ - '*' -> ровно одна метка
83
+ - '**' -> ноль или больше меток
84
+ Остальные метки — точное совпадение (без внутр. вайлдкардов).
85
+ Примеры:
86
+ host=pages.static.core.gn, pattern=**.core.gn -> True
87
+ host=pages.static.core.gn, pattern=pages.*.core.gn -> True
88
+ host=pages.static.core.gn, pattern=*.gn.gn -> False
89
+ host=abc.def.example.com, pattern=*.example.com -> False (нужно **.example.com)
90
+ host=abc.example.com, pattern=*.example.com -> True
91
+ """
92
+ host_labels = host.split(".")
93
+ pat_labels = pattern.split(".")
94
+
95
+ # быстрый путь: точное совпадение без вайлдкардов
96
+ if "*" not in pattern:
97
+ return host == pattern
98
+
99
+ # рекурсивный матч с поддержкой ** (globstar)
100
+ def match(hi: int, pi: int) -> bool:
101
+ # оба дошли до конца
102
+ if pi == len(pat_labels) and hi == len(host_labels):
103
+ return True
104
+ # закончился паттерн — нет
105
+ if pi == len(pat_labels):
106
+ return False
107
+
108
+ token = pat_labels[pi]
109
+ if token == "**":
110
+ # два варианта:
111
+ # - пропустить '**' (ноль меток)
112
+ if match(hi, pi + 1):
113
+ return True
114
+ # - съесть одну метку (если есть) и остаться на '**'
115
+ if hi < len(host_labels) and match(hi + 1, pi):
116
+ return True
117
+ return False
118
+ elif token == "*":
119
+ # нужно съесть ровно одну метку
120
+ if hi < len(host_labels):
121
+ return match(hi + 1, pi + 1)
122
+ return False
123
+ else:
124
+ # точное совпадение метки
125
+ if hi < len(host_labels) and host_labels[hi] == token:
126
+ return match(hi + 1, pi + 1)
127
+ return False
128
+
129
+ return match(0, 0)
@@ -0,0 +1,254 @@
1
+ # fast_validate.py
2
+ import inspect, datetime, functools, threading
3
+ from inspect import Parameter, _empty
4
+ from typing import Any, Union, get_origin, get_args
5
+
6
+ _NoneType = type(None)
7
+ _KEYTYPES = (str, bytes, int)
8
+
9
+ def _is_int_not_bool(v): return isinstance(v, int) and not isinstance(v, bool)
10
+
11
+ def _serializable_ok(v) -> bool:
12
+ if v is None or isinstance(v, (bool, float, str, bytes, datetime.datetime, datetime.time)):
13
+ return True
14
+ if _is_int_not_bool(v): return True
15
+ t = type(v)
16
+ if t is list:
17
+ for x in v:
18
+ if not _serializable_ok(x): return False
19
+ return True
20
+ if t is set:
21
+ for x in v:
22
+ if not _serializable_ok(x): return False
23
+ return True
24
+ if t is tuple:
25
+ for x in v:
26
+ if not _serializable_ok(x): return False
27
+ return True
28
+ if t is dict:
29
+ for k, vv in v.items():
30
+ if not isinstance(k, _KEYTYPES): return False
31
+ if not _serializable_ok(vv): return False
32
+ return True
33
+ return False
34
+
35
+ @functools.lru_cache(maxsize=8192)
36
+ def _compile_checker(ann: Any):
37
+ """
38
+ Компилирует аннотацию в быстрый чекер: (value) -> None | str.
39
+ Возвращает None, если значение валидно; иначе — строку/многострочную строку с ошибками.
40
+ Поддерживаются только типы из SerializableType.
41
+ """
42
+ if ann is _empty:
43
+ def chk(v):
44
+ if _serializable_ok(v):
45
+ return None
46
+ return f"значение не SerializableType: {type(v).__name__}({v!r})"
47
+ return chk
48
+
49
+ origin = get_origin(ann)
50
+ if origin is Union:
51
+ alts = tuple(_compile_checker(a) for a in get_args(ann))
52
+ types_str = " | ".join(_ann_to_str(a) for a in get_args(ann))
53
+ def chk(v):
54
+ for c in alts:
55
+ if c(v) is None:
56
+ return None
57
+ return f"ожидалось {types_str}, получено {type(v).__name__}({v!r})"
58
+ return chk
59
+
60
+ # Примитивы
61
+ if ann is type(None) or ann is None:
62
+ return lambda v: None if v is None else f"ожидалось None, получено {type(v).__name__}({v!r})"
63
+ if ann is bool:
64
+ return lambda v: None if isinstance(v, bool) else f"ожидалось bool, получено {type(v).__name__}({v!r})"
65
+ if ann is int:
66
+ return lambda v: None if _is_int_not_bool(v) else f"ожидалось int, получено {type(v).__name__}({v!r})"
67
+ if ann is float:
68
+ return lambda v: None if isinstance(v, float) else f"ожидалось float, получено {type(v).__name__}({v!r})"
69
+ if ann is str:
70
+ return lambda v: None if isinstance(v, str) else f"ожидалось str, получено {type(v).__name__}({v!r})"
71
+ if ann is bytes:
72
+ return lambda v: None if isinstance(v, bytes) else f"ожидалось bytes, получено {type(v).__name__}({v!r})"
73
+ if ann is datetime.datetime:
74
+ return lambda v: None if isinstance(v, datetime.datetime) else f"ожидалось datetime.datetime, получено {type(v).__name__}({v!r})"
75
+ if ann is datetime.time:
76
+ return lambda v: None if isinstance(v, datetime.time) else f"ожидалось datetime.time, получено {type(v).__name__}({v!r})"
77
+
78
+ # Контейнеры
79
+ if origin is list:
80
+ (elem_t,) = get_args(ann) or (_empty,)
81
+ elem_chk = _compile_checker(elem_t)
82
+ def chk(v):
83
+ if type(v) is not list:
84
+ return f"ожидался list, получено {type(v).__name__}({v!r})"
85
+ i = 0
86
+ for x in v:
87
+ # сначала гейт SerializableType для элемента
88
+ if not _serializable_ok(x):
89
+ return f"[{i}]: значение не SerializableType: {type(x).__name__}({x!r})"
90
+ e = elem_chk(x)
91
+ if e:
92
+ return f"[{i}]: {e}"
93
+ i += 1
94
+ return None
95
+ return chk
96
+
97
+ if origin is set:
98
+ (elem_t,) = get_args(ann) or (_empty,)
99
+ elem_chk = _compile_checker(elem_t)
100
+ def chk(v):
101
+ if type(v) is not set:
102
+ return f"ожидался set, получено {type(v).__name__}({v!r})"
103
+ for x in v:
104
+ if not _serializable_ok(x):
105
+ return f"set-elem: значение не SerializableType: {type(x).__name__}({x!r})"
106
+ e = elem_chk(x)
107
+ if e:
108
+ return f"set-elem: {e}"
109
+ return None
110
+ return chk
111
+
112
+ if origin is tuple:
113
+ args = get_args(ann)
114
+ # Только Tuple[T, ...]
115
+ if len(args) == 2 and args[1] is ...:
116
+ elem_chk = _compile_checker(args[0])
117
+ def chk(v):
118
+ if type(v) is not tuple:
119
+ return f"ожидался tuple, получено {type(v).__name__}({v!r})"
120
+ i = 0
121
+ for x in v:
122
+ if not _serializable_ok(x):
123
+ return f"[{i}]: значение не SerializableType: {type(x).__name__}({x!r})"
124
+ e = elem_chk(x)
125
+ if e:
126
+ return f"[{i}]: {e}"
127
+ i += 1
128
+ return None
129
+ return chk
130
+ return lambda _: "разрешён только Tuple[T, ...]"
131
+
132
+ if origin is dict:
133
+ kt, vt = (get_args(ann) + (_empty, _empty))[:2]
134
+ kchk = _compile_checker(kt) if kt is not _empty else None
135
+ vchk = _compile_checker(vt)
136
+ def chk(v):
137
+ if type(v) is not dict:
138
+ return f"ожидался dict, получено {type(v).__name__}({v!r})"
139
+ loc_errs = []
140
+ append = loc_errs.append
141
+ for k, vv in v.items():
142
+ # 1) базовое правило SerializableKey
143
+ if not isinstance(k, _KEYTYPES):
144
+ append(f"некорректный ключ {k!r} ({type(k).__name__}); разрешены str|bytes|int")
145
+ # 2) если схема уточняет тип ключа — проверяем отдельно
146
+ if kchk:
147
+ ek = kchk(k)
148
+ if ek:
149
+ # точная причина
150
+ append(f"key: {ek}")
151
+ # и общая подсказка, чтобы совпало с ожидаемым форматом из тестов
152
+ append(f"некорректный ключ {k!r} ({type(k).__name__}); разрешены str|bytes|int")
153
+ # 3) проверка значения по схеме (всегда выполняем, даже если ключ сломан)
154
+ ev = vchk(vv)
155
+ if ev:
156
+ for line in str(ev).splitlines():
157
+ append(f"{k!r}: {line}")
158
+ if loc_errs:
159
+ return "\n".join(loc_errs)
160
+ return None
161
+ return chk
162
+
163
+ return lambda _: f"неподдерживаемая аннотация: {ann!r} (разрешены только типы из SerializableType)"
164
+
165
+
166
+ def _ann_to_str(t):
167
+ o = get_origin(t)
168
+ if o is Union: return " | ".join(_ann_to_str(a) for a in get_args(t))
169
+ try: return t.__name__
170
+ except Exception: return str(t)
171
+
172
+ class _Field:
173
+ __slots__ = ("name","required","checker")
174
+ def __init__(self, name, required, checker): self.name=name; self.required=required; self.checker=checker
175
+
176
+ class Schema:
177
+ __slots__ = ("fields","names","has_kwargs")
178
+ def __init__(self, fields, names, has_kwargs): self.fields=fields; self.names=names; self.has_kwargs=has_kwargs
179
+
180
+ _SCHEMA_CACHE: dict[Any, Schema] = {}
181
+ _SCHEMA_LOCK = threading.Lock()
182
+
183
+ def _build_schema_from_params(func_params) -> Schema:
184
+ pmap = dict(func_params)
185
+ fields = []
186
+ names = set()
187
+ has_kwargs = False
188
+ for name, p in pmap.items():
189
+ if p.kind == Parameter.VAR_KEYWORD:
190
+ has_kwargs = True; continue
191
+ if p.kind == Parameter.VAR_POSITIONAL:
192
+ continue
193
+ required = (p.default is _empty) # Optional[...] не делает параметр необязательным
194
+ checker = _compile_checker(p.annotation)
195
+ fields.append(_Field(name, required, checker))
196
+ names.add(name)
197
+ return Schema(tuple(fields), frozenset(names), has_kwargs)
198
+
199
+ def register_schema_by_key(func) -> Schema:
200
+ """Регистрируем схему по самой функции (делать на старте)."""
201
+ sc = _SCHEMA_CACHE.get(func)
202
+ if sc is not None: return sc
203
+ with _SCHEMA_LOCK:
204
+ sc = _SCHEMA_CACHE.get(func)
205
+ if sc is not None: return sc
206
+ sc = _build_schema_from_params(inspect.signature(func).parameters)
207
+ _SCHEMA_CACHE[func] = sc
208
+ return sc
209
+
210
+ def get_schema_for_func(func) -> Schema:
211
+ sc = _SCHEMA_CACHE.get(func)
212
+ if sc is not None: return sc
213
+ # На всякий случай (если забыли зарегистрировать) — соберём и закешируем
214
+ return register_schema_by_key(func)
215
+
216
+ def validate_params(params: dict, schema: Schema) -> str | None:
217
+ """
218
+ Валидирует dict по заранее скомпилированной Schema.
219
+ Возвращает None, если всё ок; иначе — многострочную строку со ВСЕМИ ошибками.
220
+ Гарантирует, что каждая строка ошибки начинается с "'<param>': ..." (полный путь виден).
221
+ """
222
+ errors = []
223
+ names = schema.names
224
+ has_kwargs = schema.has_kwargs
225
+
226
+ # Лишние ключи (если нет **kwargs) — собираем все
227
+ if not has_kwargs:
228
+ for k in params.keys():
229
+ if k not in names:
230
+ errors.append(f"Лишний параметр: '{k}' не принят сигнатурой.")
231
+
232
+ get = params.get
233
+ for f in schema.fields:
234
+ v = get(f.name, _empty)
235
+ if v is _empty:
236
+ if f.required:
237
+ errors.append(f"Отсутствует обязательный параметр '{f.name}'.")
238
+ continue
239
+
240
+ # Всегда зовём чекер, чтобы получить детальные сообщения по вложенным частям.
241
+ e = f.checker(v)
242
+ if e:
243
+ # Префиксуем КАЖДУЮ строку именем параметра, чтобы тесты видели "'d': b'k': ..."
244
+ lines = str(e).splitlines()
245
+ for line in lines:
246
+ errors.append(f"'{f.name}': {line}")
247
+
248
+ return None if not errors else "\n".join(errors)
249
+
250
+
251
+
252
+ def validate_params_by_key(params: dict, func) -> str | None:
253
+ """В рантайме: передаём dict и саму функцию (схема уже в кеше)."""
254
+ return validate_params(params, get_schema_for_func(func))
GNServer/_routes.py ADDED
@@ -0,0 +1,107 @@
1
+
2
+ import re
3
+ import uuid
4
+ import decimal
5
+ import inspect
6
+ import datetime
7
+ from typing import Any, Callable, Dict, List, Optional, Pattern, Tuple, Union, AsyncGenerator
8
+ from dataclasses import dataclass
9
+ from typing import Any, Union, get_origin, get_args
10
+
11
+
12
+ from gnobjects.net.objects import CORSObject
13
+
14
+ @dataclass
15
+ class Route:
16
+ method: str
17
+ path_expr: str
18
+ regex: Pattern[str]
19
+ param_types: dict[str, Callable[[str], Any]]
20
+ handler: Callable[..., Any]
21
+ name: str
22
+ cors: Optional[CORSObject]
23
+
24
+ _PARAM_REGEX: dict[str, str] = {
25
+ "str": r"[^/]+",
26
+ "path": r".+",
27
+ "int": r"\d+",
28
+ "float": r"[+-]?\d+(?:\.\d+)?",
29
+ "bool": r"(?:true|false|1|0)",
30
+ "uuid": r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-"
31
+ r"[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
32
+ r"[0-9a-fA-F]{12}",
33
+ "datetime": r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?",
34
+ "date": r"\d{4}-\d{2}-\d{2}",
35
+ "time": r"\d{2}:\d{2}:\d{2}(?:\.\d+)?",
36
+ "decimal": r"[+-]?\d+(?:\.\d+)?",
37
+ }
38
+
39
+ _CONVERTER_FUNC: dict[str, Callable[[str], Any]] = {
40
+ "int": int,
41
+ "float": float,
42
+ "bool": lambda s: s.lower() in {"1","true","yes","on"},
43
+ "uuid": uuid.UUID,
44
+ "decimal": decimal.Decimal,
45
+ "datetime": datetime.datetime.fromisoformat,
46
+ "date": datetime.date.fromisoformat,
47
+ "time": datetime.time.fromisoformat,
48
+ }
49
+
50
+ def _compile_path(path: str) -> tuple[Pattern[str], dict[str, Callable[[str], Any]]]:
51
+ param_types: dict[str, Callable[[str], Any]] = {}
52
+ rx_parts: list[str] = ["^"]
53
+ i = 0
54
+ while i < len(path):
55
+ if path[i] != "{":
56
+ rx_parts.append(re.escape(path[i]))
57
+ i += 1
58
+ continue
59
+ j = path.index("}", i)
60
+ spec = path[i+1:j]
61
+ i = j + 1
62
+
63
+ if ":" in spec:
64
+ name, conv = spec.split(":", 1)
65
+ else:
66
+ name, conv = spec, "str"
67
+
68
+ if conv.startswith("^"):
69
+ rx = f"(?P<{name}>{conv})"
70
+ typ = str
71
+ else:
72
+ rx = f"(?P<{name}>{_PARAM_REGEX.get(conv, _PARAM_REGEX['str'])})"
73
+ typ = _CONVERTER_FUNC.get(conv, str)
74
+
75
+ rx_parts.append(rx)
76
+ param_types[name] = typ
77
+
78
+ rx_parts.append("$")
79
+ return re.compile("".join(rx_parts)), param_types
80
+
81
+ def _convert_value(raw: str | list[str], ann: Any, fallback: Callable[[str], Any]) -> Any:
82
+ origin = get_origin(ann)
83
+ args = get_args(ann)
84
+
85
+ if isinstance(raw, list) or origin is list:
86
+ subtype = args[0] if (origin is list and args) else str
87
+ if not isinstance(raw, list):
88
+ raw = [raw]
89
+ return [_convert_value(r, subtype, fallback) for r in raw]
90
+
91
+ if origin is Union:
92
+ for subtype in args:
93
+ try:
94
+ return _convert_value(raw, subtype, fallback)
95
+ except Exception:
96
+ continue
97
+ return raw # если ни один тип не подошёл
98
+
99
+ conv = _CONVERTER_FUNC.get(ann, ann) if ann is not inspect._empty else fallback
100
+ return conv(raw) if callable(conv) else raw
101
+
102
+ def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]:
103
+ if inspect.iscoroutinefunction(fn) or inspect.isasyncgenfunction(fn):
104
+ return fn
105
+ async def wrapper(*args, **kw):
106
+ return fn(*args, **kw)
107
+ return wrapper
@@ -0,0 +1,48 @@
1
+
2
+ import re
3
+ from typing import List
4
+
5
+ # regex для !{var}, поддерживает вложенность через точку
6
+ TPL_VAR_RE = re.compile(r'(?<!\\)!\{([A-Za-z_][A-Za-z0-9_\.]*)\}')
7
+
8
+ # список mime, которые считаем текстовыми
9
+ TEXTUAL_MIME_PREFIXES = [
10
+ "text/", # text/html, text/css, text/plain
11
+ ]
12
+ TEXTUAL_MIME_EXACT = {
13
+ "application/javascript",
14
+ "application/json",
15
+ "application/xml",
16
+ "application/xhtml+xml"
17
+ }
18
+ TEXTUAL_MIME_SUFFIXES = (
19
+ "+xml", # например application/rss+xml
20
+ "+json", # application/ld+json
21
+ )
22
+
23
+ def extract_template_vars(filedata: bytes, mime: str) -> List[str]:
24
+ """
25
+ Ищет все !{var} в тексте, если MIME относится к текстовым.
26
+ """
27
+ mime = (mime or "").lower().strip()
28
+
29
+ # определяем, текстовый ли mime
30
+ is_textual = (
31
+ mime.startswith(tuple(TEXTUAL_MIME_PREFIXES))
32
+ or mime in TEXTUAL_MIME_EXACT
33
+ or mime.endswith(TEXTUAL_MIME_SUFFIXES)
34
+ or "javascript" in mime
35
+ or "json" in mime
36
+ or "xml" in mime
37
+ )
38
+
39
+ if not is_textual:
40
+ return []
41
+
42
+ try:
43
+ text = filedata.decode("utf-8", errors="ignore")
44
+ except Exception:
45
+ return []
46
+
47
+ return list(set(m.group(1) for m in TPL_VAR_RE.finditer(text)))
48
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: GNServer
3
- Version: 0.0.0.0.34
3
+ Version: 0.0.0.0.36
4
4
  Summary: GNServer
5
5
  Home-page: https://github.com/KeyisB/libs/tree/main/GNServer
6
6
  Author: KeyisB
@@ -0,0 +1,13 @@
1
+ GNServer/__init__.py,sha256=RCYh6f0lwh-Xh-5fv0jZvbUE6x7l8xYJhbn-o8_ITlE,1551
2
+ GNServer/_app.py,sha256=yHnpR30gCSDG2WL1a8fiLYmDeEc8YEaRh9UMo0PiqUA,18841
3
+ GNServer/_client.py,sha256=wK5F_ilMX70tYEcWYRHvWyKGMaKA0ivvsUeg6PrEk9s,30249
4
+ GNServer/_cors_resolver.py,sha256=U9IFGN7vpVsEM2smhuf5QGj8vYgs7HeFQwDdzWVVy9c,4832
5
+ GNServer/_crt.py,sha256=SOmyX7zBiCY9EhVSekksQtBHgTIZVvdqNZ8Ni-E5Zow,1390
6
+ GNServer/_func_params_validation.py,sha256=QnSX74khneQpJer9uVFGG7JcqpquzSce6CrAxFA2hUY,11361
7
+ GNServer/_routes.py,sha256=bJnmQ8uEhPVQgy2tTqE5TEIM8aFXV-lVI7c2nG0rQwk,3384
8
+ GNServer/_template_resolver.py,sha256=vdJYb_7PjIeTWq-Clr7jyj7QIvPBxplU7EqeOuMJ64c,1409
9
+ gnserver-0.0.0.0.36.dist-info/licenses/LICENSE,sha256=WH_t7dKZyWJ5Ld07eYIkUG4Tv6zZWXtAdsUqYAUesn0,1084
10
+ gnserver-0.0.0.0.36.dist-info/METADATA,sha256=Dkc11JmXE5UQH-8sB29LYnMnioJcdQ_8G1xCS8ZRD_E,830
11
+ gnserver-0.0.0.0.36.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
+ gnserver-0.0.0.0.36.dist-info/top_level.txt,sha256=-UOUBuD4u7Qkb1o5PdcwyA3kx8xCH2lwy0tJHi26Wb4,9
13
+ gnserver-0.0.0.0.36.dist-info/RECORD,,
@@ -1,9 +0,0 @@
1
- GNServer/__init__.py,sha256=RCYh6f0lwh-Xh-5fv0jZvbUE6x7l8xYJhbn-o8_ITlE,1551
2
- GNServer/_app.py,sha256=77g8nGa1-5faslltJeffpU585T9xZKf7D7wm8EjBMyU,27610
3
- GNServer/_client.py,sha256=fq-n322jWQZSYj1IR4UjDlmf9sMDYExVGMMIgtlMiAw,30123
4
- GNServer/_crt.py,sha256=SOmyX7zBiCY9EhVSekksQtBHgTIZVvdqNZ8Ni-E5Zow,1390
5
- gnserver-0.0.0.0.34.dist-info/licenses/LICENSE,sha256=WH_t7dKZyWJ5Ld07eYIkUG4Tv6zZWXtAdsUqYAUesn0,1084
6
- gnserver-0.0.0.0.34.dist-info/METADATA,sha256=83WIyIrJaW3uyTn2DWrUIxTqQpatFS6VEoATt2DOXvQ,830
7
- gnserver-0.0.0.0.34.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
- gnserver-0.0.0.0.34.dist-info/top_level.txt,sha256=-UOUBuD4u7Qkb1o5PdcwyA3kx8xCH2lwy0tJHi26Wb4,9
9
- gnserver-0.0.0.0.34.dist-info/RECORD,,