korm 2.0.0a3__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.
- korm/__init__.py +32 -0
- korm/cli/__init__.py +110 -0
- korm/cli/__main__.py +3 -0
- korm/compat/__init__.py +13 -0
- korm/compat/v1.py +249 -0
- korm/exec/__init__.py +4 -0
- korm/exec/cursor.py +64 -0
- korm/facade.py +1020 -0
- korm/fastapi.py +4 -0
- korm/hooks.py +65 -0
- korm/integrations/__init__.py +1 -0
- korm/integrations/fastapi.py +82 -0
- korm/mcp/__init__.py +4 -0
- korm/mcp/server.py +133 -0
- korm/policy.py +98 -0
- korm/protocol/__init__.py +6 -0
- korm/protocol/errors.py +59 -0
- korm/protocol/request.py +184 -0
- korm/query/__init__.py +5 -0
- korm/query/expr.py +86 -0
- korm/query/includes.py +278 -0
- korm/query/operators.py +165 -0
- korm/query/where.py +148 -0
- korm/schema/__init__.py +7 -0
- korm/schema/introspect.py +166 -0
- korm/schema/registry.py +231 -0
- korm/schema/validate.py +79 -0
- korm-2.0.0a3.dist-info/METADATA +186 -0
- korm-2.0.0a3.dist-info/RECORD +32 -0
- korm-2.0.0a3.dist-info/WHEEL +4 -0
- korm-2.0.0a3.dist-info/entry_points.txt +2 -0
- korm-2.0.0a3.dist-info/licenses/LICENSE +21 -0
korm/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""korm — reference implementation of the KORM Protocol v2.
|
|
2
|
+
|
|
3
|
+
KORM is a JSON request/response protocol for dynamic database operations::
|
|
4
|
+
|
|
5
|
+
import korm
|
|
6
|
+
|
|
7
|
+
db = korm.initialize_korm(url="sqlite:///app.db", schema="korm.schema.json")
|
|
8
|
+
result = db.process_sync({"korm": 2, "action": "list", "model": "users",
|
|
9
|
+
"where": {"age": {"gte": 18}}, "limit": 20})
|
|
10
|
+
"""
|
|
11
|
+
from .facade import PROTOCOL_VERSION, SPEC_VERSION, Korm, initialize_korm
|
|
12
|
+
from .protocol.errors import ErrorCode, KormError
|
|
13
|
+
from .schema.introspect import diff, generate_schema, sync_database
|
|
14
|
+
from .policy import Policy
|
|
15
|
+
from .schema import Schema
|
|
16
|
+
|
|
17
|
+
__version__ = "2.0.0a3"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Korm",
|
|
21
|
+
"initialize_korm",
|
|
22
|
+
"Schema",
|
|
23
|
+
"Policy",
|
|
24
|
+
"ErrorCode",
|
|
25
|
+
"KormError",
|
|
26
|
+
"generate_schema",
|
|
27
|
+
"sync_database",
|
|
28
|
+
"diff",
|
|
29
|
+
"PROTOCOL_VERSION",
|
|
30
|
+
"SPEC_VERSION",
|
|
31
|
+
"__version__",
|
|
32
|
+
]
|
korm/cli/__init__.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""korm CLI: init, generate-schema, sync, diff, serve-mcp (spec §13)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import sqlalchemy as sa
|
|
10
|
+
|
|
11
|
+
from .. import __version__
|
|
12
|
+
from ..schema.introspect import diff as schema_diff
|
|
13
|
+
from ..schema.introspect import generate_schema, sync_database
|
|
14
|
+
from ..schema import Schema
|
|
15
|
+
|
|
16
|
+
_EXAMPLE_SCHEMA = {
|
|
17
|
+
"kormSchema": 2,
|
|
18
|
+
"models": {
|
|
19
|
+
"users": {
|
|
20
|
+
"table": "users",
|
|
21
|
+
"primaryKey": ["id"],
|
|
22
|
+
"softDelete": {"column": "deleted_at"},
|
|
23
|
+
"timestamps": {"createdAt": "created_at", "updatedAt": "updated_at"},
|
|
24
|
+
"columns": {
|
|
25
|
+
"id": {"type": "increments"},
|
|
26
|
+
"username": {"type": "string", "length": 80, "unique": True, "required": True},
|
|
27
|
+
"email": {"type": "string", "format": "email", "required": True},
|
|
28
|
+
"is_active": {"type": "boolean", "default": True},
|
|
29
|
+
"created_at": {"type": "datetime", "nullable": True},
|
|
30
|
+
"updated_at": {"type": "datetime", "nullable": True},
|
|
31
|
+
"deleted_at": {"type": "datetime", "nullable": True},
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def main(argv: list[str] | None = None) -> int:
|
|
39
|
+
parser = argparse.ArgumentParser(prog="korm", description="KORM Protocol v2 tooling")
|
|
40
|
+
parser.add_argument("--version", action="version", version=f"korm {__version__}")
|
|
41
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
42
|
+
|
|
43
|
+
p_init = sub.add_parser("init", help="scaffold a korm.schema.json in the current directory")
|
|
44
|
+
p_init.add_argument("--ai", help="AI provider preset for agent tooling (recorded in the scaffold)")
|
|
45
|
+
p_init.add_argument("--out", default="korm.schema.json")
|
|
46
|
+
|
|
47
|
+
p_gen = sub.add_parser("generate-schema", help="introspect a database into korm.schema.json")
|
|
48
|
+
p_gen.add_argument("--url", required=True)
|
|
49
|
+
p_gen.add_argument("--out", default="korm.schema.json")
|
|
50
|
+
|
|
51
|
+
p_sync = sub.add_parser("sync", help="create missing tables from the schema (dev tool)")
|
|
52
|
+
p_sync.add_argument("--url", required=True)
|
|
53
|
+
p_sync.add_argument("--schema", default="korm.schema.json")
|
|
54
|
+
|
|
55
|
+
p_diff = sub.add_parser("diff", help="print a migration skeleton: schema vs live database")
|
|
56
|
+
p_diff.add_argument("--url", required=True)
|
|
57
|
+
p_diff.add_argument("--schema", default="korm.schema.json")
|
|
58
|
+
|
|
59
|
+
p_mcp = sub.add_parser("serve-mcp", help="serve per-model MCP tools over stdio")
|
|
60
|
+
p_mcp.add_argument("--url", required=True)
|
|
61
|
+
p_mcp.add_argument("--schema", default="korm.schema.json")
|
|
62
|
+
|
|
63
|
+
args = parser.parse_args(argv)
|
|
64
|
+
|
|
65
|
+
if args.command == "init":
|
|
66
|
+
out = Path(args.out)
|
|
67
|
+
if out.exists():
|
|
68
|
+
print(f"{out} already exists; not overwriting", file=sys.stderr)
|
|
69
|
+
return 1
|
|
70
|
+
scaffold = dict(_EXAMPLE_SCHEMA)
|
|
71
|
+
if args.ai:
|
|
72
|
+
scaffold = {**scaffold, "x-ai-provider": args.ai}
|
|
73
|
+
out.write_text(json.dumps(scaffold, indent=2) + "\n")
|
|
74
|
+
print(f"wrote {out}")
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
if args.command == "generate-schema":
|
|
78
|
+
engine = sa.create_engine(args.url)
|
|
79
|
+
schema = generate_schema(engine)
|
|
80
|
+
Path(args.out).write_text(json.dumps(schema, indent=2) + "\n")
|
|
81
|
+
print(f"wrote {args.out} ({len(schema['models'])} models)")
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
if args.command == "sync":
|
|
85
|
+
engine = sa.create_engine(args.url)
|
|
86
|
+
sync_database(engine, Schema.load(args.schema))
|
|
87
|
+
print("database synced (dev tool — use migrations in production)")
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
if args.command == "diff":
|
|
91
|
+
engine = sa.create_engine(args.url)
|
|
92
|
+
lines = schema_diff(engine, Schema.load(args.schema))
|
|
93
|
+
if not lines:
|
|
94
|
+
print("-- no differences")
|
|
95
|
+
for line in lines:
|
|
96
|
+
print(line)
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
if args.command == "serve-mcp":
|
|
100
|
+
from ..facade import initialize_korm
|
|
101
|
+
from ..mcp import serve_mcp
|
|
102
|
+
korm = initialize_korm(url=args.url, schema=args.schema)
|
|
103
|
+
serve_mcp(korm)
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
return 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
raise SystemExit(main())
|
korm/cli/__main__.py
ADDED
korm/compat/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""v1 compatibility layer (spec §12) — pure adapter over the v2 surface."""
|
|
2
|
+
from .v1 import (
|
|
3
|
+
convert_v1_join_on,
|
|
4
|
+
convert_v1_request,
|
|
5
|
+
convert_v1_value,
|
|
6
|
+
convert_v1_where,
|
|
7
|
+
downconvert_response,
|
|
8
|
+
parse_formula,
|
|
9
|
+
resolve_refs,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = ["convert_v1_request", "convert_v1_value", "convert_v1_where",
|
|
13
|
+
"convert_v1_join_on", "downconvert_response", "parse_formula", "resolve_refs"]
|
korm/compat/v1.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""v1 → v2 compatibility converter (spec §12) and batch $ref resolution (§8).
|
|
2
|
+
|
|
3
|
+
The converter is pure and deterministic: any valid v1 request maps to one v2
|
|
4
|
+
request. Responses to v1-marked requests are down-converted (bare count number,
|
|
5
|
+
bare object for show) so existing clients keep working.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..protocol.errors import field_error, validation_failed
|
|
13
|
+
|
|
14
|
+
_V1_ACTION_ALIASES = {"count", "sum"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------- values
|
|
18
|
+
def _parse_scalar(s: str) -> Any:
|
|
19
|
+
try:
|
|
20
|
+
return int(s)
|
|
21
|
+
except ValueError:
|
|
22
|
+
try:
|
|
23
|
+
return float(s)
|
|
24
|
+
except ValueError:
|
|
25
|
+
return s.strip()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def convert_v1_value(value: Any) -> Any:
|
|
29
|
+
"""Convert one v1 stringly-typed operator value to a v2 operator object."""
|
|
30
|
+
if not isinstance(value, str):
|
|
31
|
+
return value
|
|
32
|
+
if value.startswith(">="):
|
|
33
|
+
return {"gte": _parse_scalar(value[2:])}
|
|
34
|
+
if value.startswith("<="):
|
|
35
|
+
return {"lte": _parse_scalar(value[2:])}
|
|
36
|
+
if value.startswith("><"):
|
|
37
|
+
parts = value[2:].split(",")
|
|
38
|
+
if len(parts) != 2:
|
|
39
|
+
raise validation_failed([field_error("where", "INVALID_VALUE", f"malformed v1 between {value!r}")])
|
|
40
|
+
return {"between": [_parse_scalar(parts[0]), _parse_scalar(parts[1])]}
|
|
41
|
+
if value.startswith(">"):
|
|
42
|
+
return {"gt": _parse_scalar(value[1:])}
|
|
43
|
+
if value.startswith("<"):
|
|
44
|
+
return {"lt": _parse_scalar(value[1:])}
|
|
45
|
+
if value.startswith("[]"):
|
|
46
|
+
return {"in": [_parse_scalar(p) for p in value[2:].split(",")]}
|
|
47
|
+
if value.startswith("!"):
|
|
48
|
+
return {"ne": _parse_scalar(value[1:])}
|
|
49
|
+
if "%" in value:
|
|
50
|
+
return {"like": value}
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def convert_v1_where(where: Any) -> Any:
|
|
55
|
+
"""Convert a v1 where object, honoring "Or:col" keys (left-to-right OR grouping)."""
|
|
56
|
+
if not isinstance(where, dict):
|
|
57
|
+
return where
|
|
58
|
+
and_part: dict[str, Any] = {}
|
|
59
|
+
or_parts: list[dict[str, Any]] = []
|
|
60
|
+
for key, value in where.items():
|
|
61
|
+
if key.startswith("Or:") or key.startswith("or:"):
|
|
62
|
+
col = key.split(":", 1)[1]
|
|
63
|
+
if isinstance(value, list):
|
|
64
|
+
or_parts.extend({col: convert_v1_value(v)} for v in value)
|
|
65
|
+
else:
|
|
66
|
+
or_parts.append({col: convert_v1_value(value)})
|
|
67
|
+
else:
|
|
68
|
+
and_part[key] = convert_v1_value(value)
|
|
69
|
+
if not or_parts:
|
|
70
|
+
return and_part
|
|
71
|
+
groups: list[dict[str, Any]] = []
|
|
72
|
+
if and_part:
|
|
73
|
+
groups.append(and_part)
|
|
74
|
+
groups.extend(or_parts)
|
|
75
|
+
return {"or": groups}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------- sumFormula
|
|
79
|
+
_TOKEN_RE = re.compile(r"\s*(?:(\d+\.\d+|\d+)|([A-Za-z_][A-Za-z0-9_]*)|([+\-*/()]))")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_formula(formula: str) -> Any:
|
|
83
|
+
"""Parse a v1 sumFormula string ("salary + bonus * 2") into a v2 expression tree."""
|
|
84
|
+
tokens: list[Any] = []
|
|
85
|
+
pos = 0
|
|
86
|
+
while pos < len(formula):
|
|
87
|
+
m = _TOKEN_RE.match(formula, pos)
|
|
88
|
+
if not m or m.end() == pos:
|
|
89
|
+
if formula[pos:].strip():
|
|
90
|
+
raise validation_failed([
|
|
91
|
+
field_error("sumFormula", "INVALID_VALUE", f"cannot parse formula at {formula[pos:]!r}"),
|
|
92
|
+
])
|
|
93
|
+
break
|
|
94
|
+
num, ident, op = m.groups()
|
|
95
|
+
if num is not None:
|
|
96
|
+
tokens.append(float(num) if "." in num else int(num))
|
|
97
|
+
elif ident is not None:
|
|
98
|
+
tokens.append(ident)
|
|
99
|
+
else:
|
|
100
|
+
tokens.append(op)
|
|
101
|
+
pos = m.end()
|
|
102
|
+
|
|
103
|
+
idx = 0
|
|
104
|
+
|
|
105
|
+
def peek():
|
|
106
|
+
return tokens[idx] if idx < len(tokens) else None
|
|
107
|
+
|
|
108
|
+
def parse_expr():
|
|
109
|
+
nonlocal idx
|
|
110
|
+
node = parse_term()
|
|
111
|
+
while peek() in ("+", "-"):
|
|
112
|
+
op = tokens[idx]; idx += 1
|
|
113
|
+
rhs = parse_term()
|
|
114
|
+
node = {"add" if op == "+" else "sub": [node, rhs]}
|
|
115
|
+
return node
|
|
116
|
+
|
|
117
|
+
def parse_term():
|
|
118
|
+
nonlocal idx
|
|
119
|
+
node = parse_atom()
|
|
120
|
+
while peek() in ("*", "/"):
|
|
121
|
+
op = tokens[idx]; idx += 1
|
|
122
|
+
rhs = parse_atom()
|
|
123
|
+
node = {"mul" if op == "*" else "div": [node, rhs]}
|
|
124
|
+
return node
|
|
125
|
+
|
|
126
|
+
def parse_atom():
|
|
127
|
+
nonlocal idx
|
|
128
|
+
tok = peek()
|
|
129
|
+
if tok == "(":
|
|
130
|
+
idx += 1
|
|
131
|
+
node = parse_expr()
|
|
132
|
+
if peek() != ")":
|
|
133
|
+
raise validation_failed([field_error("sumFormula", "INVALID_VALUE", "unbalanced parentheses")])
|
|
134
|
+
idx += 1
|
|
135
|
+
return node
|
|
136
|
+
if isinstance(tok, (int, float, str)) and tok not in ("+", "-", "*", "/", ")", None):
|
|
137
|
+
idx += 1
|
|
138
|
+
return tok
|
|
139
|
+
raise validation_failed([field_error("sumFormula", "INVALID_VALUE", "unexpected token in formula")])
|
|
140
|
+
|
|
141
|
+
if not tokens:
|
|
142
|
+
raise validation_failed([field_error("sumFormula", "INVALID_VALUE", "empty formula")])
|
|
143
|
+
tree = parse_expr()
|
|
144
|
+
if idx != len(tokens):
|
|
145
|
+
raise validation_failed([field_error("sumFormula", "INVALID_VALUE", "trailing tokens in formula")])
|
|
146
|
+
return tree
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------- joins
|
|
150
|
+
_JOIN_ON_RE = re.compile(
|
|
151
|
+
r"^\s*([A-Za-z_][\w]*)\.([A-Za-z_][\w]*)\s*=\s*([A-Za-z_][\w]*)\.([A-Za-z_][\w]*)\s*$"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def convert_v1_join_on(on: str) -> dict[str, str]:
|
|
156
|
+
"""Parse a v1 string join condition into structured on; failure -> VALIDATION_FAILED (§12)."""
|
|
157
|
+
m = _JOIN_ON_RE.match(on)
|
|
158
|
+
if not m:
|
|
159
|
+
raise validation_failed([
|
|
160
|
+
field_error("join.on", "INVALID_VALUE", "v1 string join conditions must be \"a.col = b.col\""),
|
|
161
|
+
])
|
|
162
|
+
lt, lc, rt, rc = m.groups()
|
|
163
|
+
return {"left": f"{lt}.{lc}", "right": f"{rt}.{rc}", "op": "eq"}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------- requests
|
|
167
|
+
def convert_v1_request(request: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
|
168
|
+
"""Up-convert a request lacking the "korm": 2 marker. Returns (v2_request, downconvert_info)."""
|
|
169
|
+
req = dict(request)
|
|
170
|
+
downconvert: dict[str, Any] | None = None
|
|
171
|
+
action = req.get("action")
|
|
172
|
+
|
|
173
|
+
if req.get("where") is not None:
|
|
174
|
+
req["where"] = convert_v1_where(req["where"])
|
|
175
|
+
|
|
176
|
+
if action == "count":
|
|
177
|
+
req["action"] = "aggregate"
|
|
178
|
+
req["aggregates"] = [{"fn": "count", "as": "count"}]
|
|
179
|
+
downconvert = {"bare_number": "count"}
|
|
180
|
+
elif action == "sum":
|
|
181
|
+
formula = req.pop("sumFormula", None) or req.pop("column", None)
|
|
182
|
+
if formula is None:
|
|
183
|
+
raise validation_failed([field_error("sumFormula", "REQUIRED", "v1 sum requires sumFormula")])
|
|
184
|
+
req["action"] = "aggregate"
|
|
185
|
+
req["aggregates"] = [{"fn": "sum", "expr": parse_formula(str(formula)), "as": "sum"}]
|
|
186
|
+
downconvert = {"bare_number": "sum"}
|
|
187
|
+
elif action == "show":
|
|
188
|
+
downconvert = {"bare_object": True}
|
|
189
|
+
|
|
190
|
+
joins = req.get("join")
|
|
191
|
+
if isinstance(joins, list):
|
|
192
|
+
req["join"] = [
|
|
193
|
+
{**j, "on": convert_v1_join_on(j["on"])} if isinstance(j.get("on"), str) else j
|
|
194
|
+
for j in joins if isinstance(j, dict)
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
req["korm"] = 2
|
|
198
|
+
return req, downconvert
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def downconvert_response(response: dict[str, Any], info: dict[str, Any]) -> dict[str, Any]:
|
|
202
|
+
"""Down-convert a v2 response for a v1-marked request (§12, §14)."""
|
|
203
|
+
if not response.get("ok"):
|
|
204
|
+
return response
|
|
205
|
+
data = response.get("data")
|
|
206
|
+
if info.get("bare_number") and isinstance(data, list):
|
|
207
|
+
alias = info["bare_number"]
|
|
208
|
+
response = dict(response)
|
|
209
|
+
response["data"] = data[0].get(alias, 0) if data else 0
|
|
210
|
+
elif info.get("bare_object") and isinstance(data, list):
|
|
211
|
+
response = dict(response)
|
|
212
|
+
response["data"] = data[0] if data else None
|
|
213
|
+
return response
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------- batch $ref (§8)
|
|
217
|
+
def resolve_refs(node: Any, by_id: dict[str, dict[str, Any]], path: str) -> Any:
|
|
218
|
+
"""Replace {"$ref": "subId.json.path"} pointers with values from earlier sub-responses."""
|
|
219
|
+
if isinstance(node, dict):
|
|
220
|
+
if set(node) == {"$ref"} and isinstance(node["$ref"], str):
|
|
221
|
+
return _lookup_ref(node["$ref"], by_id, path)
|
|
222
|
+
return {k: resolve_refs(v, by_id, f"{path}.{k}") for k, v in node.items()}
|
|
223
|
+
if isinstance(node, list):
|
|
224
|
+
return [resolve_refs(v, by_id, f"{path}[{i}]") for i, v in enumerate(node)]
|
|
225
|
+
return node
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _lookup_ref(ref: str, by_id: dict[str, dict[str, Any]], path: str) -> Any:
|
|
229
|
+
parts = ref.split(".")
|
|
230
|
+
sub_id = parts[0]
|
|
231
|
+
if sub_id not in by_id:
|
|
232
|
+
raise validation_failed([
|
|
233
|
+
field_error(path, "UNRESOLVED_REF", f"$ref {ref!r}: no earlier sub-request with id {sub_id!r}"),
|
|
234
|
+
])
|
|
235
|
+
value: Any = by_id[sub_id]
|
|
236
|
+
for part in parts[1:]:
|
|
237
|
+
if isinstance(value, list):
|
|
238
|
+
try:
|
|
239
|
+
value = value[int(part)]
|
|
240
|
+
continue
|
|
241
|
+
except (ValueError, IndexError):
|
|
242
|
+
raise validation_failed([field_error(path, "UNRESOLVED_REF", f"$ref {ref!r}: bad index {part!r}")])
|
|
243
|
+
if isinstance(value, dict) and part in value:
|
|
244
|
+
value = value[part]
|
|
245
|
+
else:
|
|
246
|
+
raise validation_failed([
|
|
247
|
+
field_error(path, "UNRESOLVED_REF", f"$ref {ref!r}: path segment {part!r} not found"),
|
|
248
|
+
])
|
|
249
|
+
return value
|
korm/exec/__init__.py
ADDED
korm/exec/cursor.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Opaque cursor tokens for keyset pagination (spec §5.3, §14).
|
|
2
|
+
|
|
3
|
+
Cursors are base64url(JSON) with strict validation on decode: unknown keys,
|
|
4
|
+
type mismatches, or columns not present in the request's orderBy return
|
|
5
|
+
VALIDATION_FAILED — never a 500. Optional HMAC-SHA256 signing via cursor_secret.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ..protocol.errors import field_error, validation_failed
|
|
16
|
+
|
|
17
|
+
_SCALARS = (str, int, float, bool, type(None))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _b64encode(data: bytes) -> str:
|
|
21
|
+
return base64.urlsafe_b64encode(data).decode().rstrip("=")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _b64decode(token: str) -> bytes:
|
|
25
|
+
pad = "=" * (-len(token) % 4)
|
|
26
|
+
return base64.urlsafe_b64decode(token + pad)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _sign(payload: str, secret: str) -> str:
|
|
30
|
+
return _b64encode(hmac.new(secret.encode(), payload.encode(), hashlib.sha256).digest())[:32]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def encode_cursor(keys: dict[str, Any], secret: str | None = None) -> str:
|
|
34
|
+
payload = json.dumps({"k": keys}, separators=(",", ":"), default=str)
|
|
35
|
+
token = _b64encode(payload.encode())
|
|
36
|
+
if secret:
|
|
37
|
+
token = f"{token}.{_sign(payload, secret)}"
|
|
38
|
+
return token
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def decode_cursor(token: str, order_columns: list[str], secret: str | None = None) -> dict[str, Any]:
|
|
42
|
+
"""Decode and strictly validate a cursor against the request's orderBy columns."""
|
|
43
|
+
err = lambda msg: validation_failed([field_error("cursor", "INVALID_CURSOR", msg)]) # noqa: E731
|
|
44
|
+
body, sig = (token.split(".", 1) + [None])[:2] if "." in token else (token, None)
|
|
45
|
+
try:
|
|
46
|
+
payload = _b64decode(body).decode()
|
|
47
|
+
except Exception:
|
|
48
|
+
raise err("cursor is not valid base64")
|
|
49
|
+
if secret:
|
|
50
|
+
if sig is None or not hmac.compare_digest(sig, _sign(payload, secret)):
|
|
51
|
+
raise err("cursor signature mismatch")
|
|
52
|
+
try:
|
|
53
|
+
obj = json.loads(payload)
|
|
54
|
+
except ValueError:
|
|
55
|
+
raise err("cursor payload is not valid JSON")
|
|
56
|
+
if not isinstance(obj, dict) or set(obj) != {"k"} or not isinstance(obj["k"], dict):
|
|
57
|
+
raise err("cursor payload has unexpected structure")
|
|
58
|
+
keys = obj["k"]
|
|
59
|
+
if set(keys) != set(order_columns):
|
|
60
|
+
raise err("cursor keys do not match the request's orderBy columns")
|
|
61
|
+
for k, v in keys.items():
|
|
62
|
+
if not isinstance(v, _SCALARS):
|
|
63
|
+
raise err(f"cursor value for {k!r} has unsupported type")
|
|
64
|
+
return keys
|