morphdb 0.1.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.
- morphdb/__init__.py +7 -0
- morphdb/__main__.py +34 -0
- morphdb/apps.py +86 -0
- morphdb/associations.py +494 -0
- morphdb/db.py +168 -0
- morphdb/errors.py +36 -0
- morphdb/fieldtypes.py +351 -0
- morphdb/objects.py +395 -0
- morphdb/router.py +58 -0
- morphdb/routes.py +254 -0
- morphdb/schema.py +203 -0
- morphdb/server.py +194 -0
- morphdb/util.py +19 -0
- morphdb-0.1.0.dist-info/METADATA +324 -0
- morphdb-0.1.0.dist-info/RECORD +19 -0
- morphdb-0.1.0.dist-info/WHEEL +5 -0
- morphdb-0.1.0.dist-info/entry_points.txt +2 -0
- morphdb-0.1.0.dist-info/licenses/LICENSE +21 -0
- morphdb-0.1.0.dist-info/top_level.txt +1 -0
morphdb/objects.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Object instances — the data the website reads and writes.
|
|
2
|
+
|
|
3
|
+
Every object has a globally unique ``_guid`` and belongs to one object type
|
|
4
|
+
inside one **app**. Raw field values are stored as a JSON blob and projected
|
|
5
|
+
through the current schema on every read (lazy invalidation), so schema edits
|
|
6
|
+
never touch rows. Relations are *not* in the blob — they live as edges (see
|
|
7
|
+
:mod:`associations`) and are folded into the object body on read / exploded into
|
|
8
|
+
edges on write, so the frontend treats a link like any other field.
|
|
9
|
+
|
|
10
|
+
Every read and write is scoped to the caller's app: a guid that belongs to a
|
|
11
|
+
different app is treated as not found, so apps are fully isolated even though
|
|
12
|
+
guids are globally unique.
|
|
13
|
+
|
|
14
|
+
Output shape is flat with underscore-prefixed system fields:
|
|
15
|
+
|
|
16
|
+
{"_guid", "_type", "_created_at", "_updated_at", <field>: <value>,
|
|
17
|
+
<relation>: <neighbor-guid | [neighbor-guids]>, ...}
|
|
18
|
+
|
|
19
|
+
Schema field/relation names may not start with ``_`` (enforced at schema-define
|
|
20
|
+
time), so there is never a collision with system fields.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
|
|
25
|
+
from . import associations as assoc
|
|
26
|
+
from . import db
|
|
27
|
+
from .errors import bad_request, not_found
|
|
28
|
+
from .fieldtypes import project_data, validate_against_schema
|
|
29
|
+
from .schema import get_object_schema
|
|
30
|
+
from .util import new_guid, now_iso
|
|
31
|
+
|
|
32
|
+
RESERVED_QUERY_KEYS = {"limit", "offset", "sort", "order"}
|
|
33
|
+
DEFAULT_LIMIT = 100
|
|
34
|
+
MAX_LIMIT = 1000
|
|
35
|
+
|
|
36
|
+
_OPS = {"eq", "ne", "gt", "gte", "lt", "lte", "contains", "in", "exists"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _project_fields(row, fields):
|
|
40
|
+
data = project_data(json.loads(row["data"]), fields)
|
|
41
|
+
out = {
|
|
42
|
+
"_guid": row["guid"],
|
|
43
|
+
"_type": row["object_type"],
|
|
44
|
+
"_created_at": row["created_at"],
|
|
45
|
+
"_updated_at": row["updated_at"],
|
|
46
|
+
}
|
|
47
|
+
out.update(data)
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _project_full(app, row, fields, object_type):
|
|
52
|
+
"""A single object's full body: system fields + fields + relation values."""
|
|
53
|
+
out = _project_fields(row, fields)
|
|
54
|
+
rels = assoc.project_relations(app, [row["guid"]], object_type)[row["guid"]]
|
|
55
|
+
out.update(rels)
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _split_body(app, object_type, body, fields, c=None):
|
|
60
|
+
"""Partition an incoming write into (field values, relation values).
|
|
61
|
+
|
|
62
|
+
Keys that are neither a declared field nor a relation (and not a system
|
|
63
|
+
``_`` key echoed back from a read) are rejected, so typos surface early.
|
|
64
|
+
"""
|
|
65
|
+
rel_keys = assoc.relation_keys(app, object_type, c)
|
|
66
|
+
field_part, rel_part = {}, {}
|
|
67
|
+
for k, v in body.items():
|
|
68
|
+
if k in fields:
|
|
69
|
+
field_part[k] = v
|
|
70
|
+
elif k in rel_keys:
|
|
71
|
+
rel_part[k] = v
|
|
72
|
+
elif k.startswith("_"):
|
|
73
|
+
continue # tolerate _guid/_type echoed back from a prior read
|
|
74
|
+
else:
|
|
75
|
+
raise bad_request(
|
|
76
|
+
f"Unknown field/relation '{k}' on type '{object_type}'. "
|
|
77
|
+
f"Fields: {sorted(fields)}; relations: {sorted(rel_keys)}. "
|
|
78
|
+
"Update the schema first, or remove the stray key."
|
|
79
|
+
)
|
|
80
|
+
return field_part, rel_part
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# --- writes -------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def create_object(app, object_type, data):
|
|
87
|
+
schema = get_object_schema(app, object_type, required=True)
|
|
88
|
+
fields = schema["fields"]
|
|
89
|
+
guid = new_guid(object_type)
|
|
90
|
+
ts = now_iso()
|
|
91
|
+
with db.transaction() as c:
|
|
92
|
+
field_part, rel_part = _split_body(app, object_type, data or {}, fields, c)
|
|
93
|
+
clean = validate_against_schema(field_part, fields, partial=False)
|
|
94
|
+
c.execute(
|
|
95
|
+
"INSERT INTO objects (guid, app, object_type, data, created_at, updated_at) "
|
|
96
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
97
|
+
(guid, app, object_type, json.dumps(clean), ts, ts),
|
|
98
|
+
)
|
|
99
|
+
if rel_part:
|
|
100
|
+
assoc.apply_relation_writes(c, app, guid, object_type, rel_part)
|
|
101
|
+
row = c.execute("SELECT * FROM objects WHERE guid = ?", (guid,)).fetchone()
|
|
102
|
+
return _project_full(app, row, fields, object_type)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def upsert_object(app, object_type, guid, data, partial=True):
|
|
106
|
+
"""Create-or-update an object at a caller-supplied guid, within ``app``.
|
|
107
|
+
|
|
108
|
+
Fields follow ``partial``: PATCH (partial=True) merges the given fields, PUT
|
|
109
|
+
(partial=False) replaces the field set (and re-checks required fields).
|
|
110
|
+
Relations are always patch-style: only relation keys present in the body are
|
|
111
|
+
touched; each present relation's value becomes its full set (set-as-field).
|
|
112
|
+
"""
|
|
113
|
+
schema = get_object_schema(app, object_type, required=True)
|
|
114
|
+
fields = schema["fields"]
|
|
115
|
+
ts = now_iso()
|
|
116
|
+
with db.transaction() as c:
|
|
117
|
+
existing = c.execute(
|
|
118
|
+
"SELECT * FROM objects WHERE guid = ?", (guid,)
|
|
119
|
+
).fetchone()
|
|
120
|
+
if existing is not None and existing["app"] != app:
|
|
121
|
+
# The guid is owned by another app; from here it simply doesn't exist.
|
|
122
|
+
raise not_found(f"No object with guid '{guid}'.")
|
|
123
|
+
if existing is not None and existing["object_type"] != object_type:
|
|
124
|
+
raise bad_request(
|
|
125
|
+
f"Object '{guid}' already exists with type "
|
|
126
|
+
f"'{existing['object_type']}', not '{object_type}'."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
field_part, rel_part = _split_body(app, object_type, data or {}, fields, c)
|
|
130
|
+
clean = validate_against_schema(field_part, fields, partial=partial)
|
|
131
|
+
|
|
132
|
+
if existing is None:
|
|
133
|
+
c.execute(
|
|
134
|
+
"INSERT INTO objects (guid, app, object_type, data, created_at, updated_at) "
|
|
135
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
136
|
+
(guid, app, object_type, json.dumps(clean), ts, ts),
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
blob = json.loads(existing["data"]) if partial else {}
|
|
140
|
+
blob.update(clean)
|
|
141
|
+
c.execute(
|
|
142
|
+
"UPDATE objects SET data = ?, updated_at = ? WHERE guid = ?",
|
|
143
|
+
(json.dumps(blob), ts, guid),
|
|
144
|
+
)
|
|
145
|
+
if rel_part:
|
|
146
|
+
assoc.apply_relation_writes(c, app, guid, object_type, rel_part)
|
|
147
|
+
row = c.execute("SELECT * FROM objects WHERE guid = ?", (guid,)).fetchone()
|
|
148
|
+
return _project_full(app, row, fields, object_type)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def delete_object(app, guid):
|
|
152
|
+
with db.transaction() as c:
|
|
153
|
+
row = c.execute(
|
|
154
|
+
"SELECT * FROM objects WHERE app = ? AND guid = ?", (app, guid)
|
|
155
|
+
).fetchone()
|
|
156
|
+
if row is None:
|
|
157
|
+
raise not_found(f"No object with guid '{guid}'.")
|
|
158
|
+
c.execute("DELETE FROM objects WHERE app = ? AND guid = ?", (app, guid))
|
|
159
|
+
# Remove this object's edges only; neighbor objects are left intact.
|
|
160
|
+
c.execute(
|
|
161
|
+
"DELETE FROM associations WHERE app = ? AND (from_guid = ? OR to_guid = ?)",
|
|
162
|
+
(app, guid, guid),
|
|
163
|
+
)
|
|
164
|
+
return {"deleted": guid}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --- reads --------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_object(app, guid, object_type=None):
|
|
171
|
+
row = db.conn().execute(
|
|
172
|
+
"SELECT * FROM objects WHERE app = ? AND guid = ?", (app, guid)
|
|
173
|
+
).fetchone()
|
|
174
|
+
if row is None:
|
|
175
|
+
raise not_found(f"No object with guid '{guid}'.")
|
|
176
|
+
if object_type is not None and row["object_type"] != object_type:
|
|
177
|
+
raise not_found(
|
|
178
|
+
f"Object '{guid}' is of type '{row['object_type']}', not '{object_type}'."
|
|
179
|
+
)
|
|
180
|
+
schema = get_object_schema(app, row["object_type"], required=True)
|
|
181
|
+
return _project_full(app, row, schema["fields"], row["object_type"])
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _parse_filter_key(key, fields):
|
|
185
|
+
if "__" in key:
|
|
186
|
+
field, op = key.rsplit("__", 1)
|
|
187
|
+
if op not in _OPS:
|
|
188
|
+
# treat the whole thing as a field name with default eq
|
|
189
|
+
field, op = key, "eq"
|
|
190
|
+
else:
|
|
191
|
+
field, op = key, "eq"
|
|
192
|
+
if field not in fields:
|
|
193
|
+
raise bad_request(
|
|
194
|
+
f"Cannot filter on unknown field '{field}'. "
|
|
195
|
+
f"Declared fields: {sorted(fields)}. (Filtering is on fields, not relations.)"
|
|
196
|
+
)
|
|
197
|
+
return field, op
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _coerce_filter_value(field, op, raw, ftype):
|
|
201
|
+
from .fieldtypes import coerce_value
|
|
202
|
+
|
|
203
|
+
if op == "exists":
|
|
204
|
+
# raw is "true"/"false"
|
|
205
|
+
return coerce_value(field, raw, "boolean")
|
|
206
|
+
if ftype == "json":
|
|
207
|
+
raise bad_request(f"Cannot filter on json field '{field}'.")
|
|
208
|
+
if op == "in":
|
|
209
|
+
parts = raw.split(",") if isinstance(raw, str) else list(raw)
|
|
210
|
+
return [coerce_value(field, p, ftype) for p in parts]
|
|
211
|
+
if op == "contains":
|
|
212
|
+
return str(raw)
|
|
213
|
+
return coerce_value(field, raw, ftype)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
_INT64_MAX = 2 ** 63 - 1
|
|
217
|
+
_INT64_MIN = -(2 ** 63)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _safe_bind(v):
|
|
221
|
+
"""Make a value safe to bind as a SQLite parameter.
|
|
222
|
+
|
|
223
|
+
Python ints outside the signed-64-bit range cannot be bound (sqlite3 raises
|
|
224
|
+
OverflowError). Such magnitudes are beyond SQLite's integer precision
|
|
225
|
+
anyway, so we bind them as floats rather than 500 on a client value.
|
|
226
|
+
"""
|
|
227
|
+
if isinstance(v, bool):
|
|
228
|
+
return v
|
|
229
|
+
if isinstance(v, int) and (v > _INT64_MAX or v < _INT64_MIN):
|
|
230
|
+
return float(v)
|
|
231
|
+
return v
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# JSON storage types (per SQLite json_type) that satisfy each field type.
|
|
235
|
+
_JSON_TYPES_FOR = {
|
|
236
|
+
"number": ("'integer'", "'real'"),
|
|
237
|
+
"boolean": ("'true'", "'false'"),
|
|
238
|
+
"string": ("'text'",),
|
|
239
|
+
"datetime": ("'text'",),
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _field_expr(field, fdef, params):
|
|
244
|
+
"""SQL expression for a field that mirrors read-time projection exactly.
|
|
245
|
+
|
|
246
|
+
A stored value counts only if its JSON type matches the field's current type
|
|
247
|
+
(the same rule fieldtypes.project_data applies on read). Anything else — an
|
|
248
|
+
absent key, a stored null, or a value left over at the wrong type after a
|
|
249
|
+
retype — falls back to the field's default (or NULL). Reads and queries are
|
|
250
|
+
therefore always in lockstep, with no row rewrites.
|
|
251
|
+
|
|
252
|
+
Any parameter the expression needs (the default) is appended to ``params``
|
|
253
|
+
in evaluation order, so the caller must add comparison params *after*.
|
|
254
|
+
"""
|
|
255
|
+
raw = f"json_extract(data, '$.{field}')"
|
|
256
|
+
jt = f"json_type(data, '$.{field}')"
|
|
257
|
+
ftype = fdef["type"]
|
|
258
|
+
if ftype == "json":
|
|
259
|
+
valid = f"{jt} IS NOT NULL"
|
|
260
|
+
else:
|
|
261
|
+
valid = f"{jt} IN ({','.join(_JSON_TYPES_FOR[ftype])})"
|
|
262
|
+
default = fdef.get("default")
|
|
263
|
+
if default is not None:
|
|
264
|
+
params.append(_safe_bind(default))
|
|
265
|
+
return f"(CASE WHEN {valid} THEN {raw} ELSE ? END)"
|
|
266
|
+
return f"(CASE WHEN {valid} THEN {raw} ELSE NULL END)"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _build_where(filters, fields):
|
|
270
|
+
"""Translate a ``{key: value}`` filter mapping into a SQL WHERE fragment.
|
|
271
|
+
|
|
272
|
+
Supports operators via ``field__op`` keys: eq, ne, gt, gte, lt, lte,
|
|
273
|
+
contains, in, exists.
|
|
274
|
+
"""
|
|
275
|
+
clauses = []
|
|
276
|
+
params = []
|
|
277
|
+
for key, raw in filters.items():
|
|
278
|
+
field, op = _parse_filter_key(key, fields)
|
|
279
|
+
fdef = fields[field]
|
|
280
|
+
ftype = fdef["type"]
|
|
281
|
+
val = _coerce_filter_value(field, op, raw, ftype)
|
|
282
|
+
|
|
283
|
+
# Empty IN matches nothing; handle before building expr so we don't emit
|
|
284
|
+
# an orphan default parameter.
|
|
285
|
+
if op == "in" and not val:
|
|
286
|
+
clauses.append("0")
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
# expr is used exactly once per clause (so its COALESCE default, if any,
|
|
290
|
+
# maps to exactly one bound parameter).
|
|
291
|
+
expr = _field_expr(field, fdef, params)
|
|
292
|
+
if op == "eq":
|
|
293
|
+
clauses.append(f"{expr} = ?")
|
|
294
|
+
params.append(_safe_bind(val))
|
|
295
|
+
elif op == "ne":
|
|
296
|
+
# Null-safe inequality: also matches rows where the value is null.
|
|
297
|
+
clauses.append(f"{expr} IS NOT ?")
|
|
298
|
+
params.append(_safe_bind(val))
|
|
299
|
+
elif op == "gt":
|
|
300
|
+
clauses.append(f"{expr} > ?")
|
|
301
|
+
params.append(_safe_bind(val))
|
|
302
|
+
elif op == "gte":
|
|
303
|
+
clauses.append(f"{expr} >= ?")
|
|
304
|
+
params.append(_safe_bind(val))
|
|
305
|
+
elif op == "lt":
|
|
306
|
+
clauses.append(f"{expr} < ?")
|
|
307
|
+
params.append(_safe_bind(val))
|
|
308
|
+
elif op == "lte":
|
|
309
|
+
clauses.append(f"{expr} <= ?")
|
|
310
|
+
params.append(_safe_bind(val))
|
|
311
|
+
elif op == "contains":
|
|
312
|
+
# Escape LIKE metacharacters so the match is a literal substring,
|
|
313
|
+
# not a wildcard pattern.
|
|
314
|
+
esc = (str(val).replace("\\", "\\\\")
|
|
315
|
+
.replace("%", "\\%").replace("_", "\\_"))
|
|
316
|
+
clauses.append(f"{expr} LIKE ? ESCAPE '\\'")
|
|
317
|
+
params.append(f"%{esc}%")
|
|
318
|
+
elif op == "in":
|
|
319
|
+
qmarks = ",".join("?" * len(val))
|
|
320
|
+
clauses.append(f"{expr} IN ({qmarks})")
|
|
321
|
+
params.extend(_safe_bind(v) for v in val)
|
|
322
|
+
elif op == "exists":
|
|
323
|
+
clauses.append(f"{expr} IS NOT NULL" if val else f"{expr} IS NULL")
|
|
324
|
+
return clauses, params
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def list_objects(app, object_type, filters=None, limit=DEFAULT_LIMIT, offset=0,
|
|
328
|
+
sort=None, order="asc"):
|
|
329
|
+
schema = get_object_schema(app, object_type, required=True)
|
|
330
|
+
fields = schema["fields"]
|
|
331
|
+
filters = filters or {}
|
|
332
|
+
|
|
333
|
+
clauses = ["app = ?", "object_type = ?"]
|
|
334
|
+
params = [app, object_type]
|
|
335
|
+
fc, fp = _build_where(filters, fields)
|
|
336
|
+
clauses.extend(fc)
|
|
337
|
+
params.extend(fp)
|
|
338
|
+
where = " AND ".join(clauses)
|
|
339
|
+
|
|
340
|
+
order_l = str(order).lower()
|
|
341
|
+
if order_l not in ("asc", "desc"):
|
|
342
|
+
raise bad_request(f"Invalid order '{order}'. Use 'asc' or 'desc'.")
|
|
343
|
+
order_sql = "DESC" if order_l == "desc" else "ASC"
|
|
344
|
+
# Always append `guid ASC` as a deterministic tie-break so pagination is
|
|
345
|
+
# stable even when the primary sort key has duplicate values. The sort key
|
|
346
|
+
# uses the same projection-aware expression as filters/reads so it orders by
|
|
347
|
+
# the values the client actually sees (defaults included).
|
|
348
|
+
sort_params = []
|
|
349
|
+
if sort:
|
|
350
|
+
if sort in ("_created_at", "_updated_at", "_guid"):
|
|
351
|
+
col = {"_created_at": "created_at", "_updated_at": "updated_at",
|
|
352
|
+
"_guid": "guid"}[sort]
|
|
353
|
+
order_clause = f"{col} {order_sql}, guid ASC"
|
|
354
|
+
elif sort in fields:
|
|
355
|
+
if fields[sort]["type"] == "json":
|
|
356
|
+
raise bad_request(f"Cannot sort on json field '{sort}'.")
|
|
357
|
+
sort_expr = _field_expr(sort, fields[sort], sort_params)
|
|
358
|
+
order_clause = f"{sort_expr} {order_sql}, guid ASC"
|
|
359
|
+
else:
|
|
360
|
+
raise bad_request(f"Cannot sort on unknown field '{sort}'.")
|
|
361
|
+
else:
|
|
362
|
+
order_clause = f"created_at {order_sql}, guid ASC"
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
limit = int(limit)
|
|
366
|
+
offset = int(offset)
|
|
367
|
+
except (TypeError, ValueError):
|
|
368
|
+
raise bad_request("limit and offset must be integers.")
|
|
369
|
+
if limit < 0 or offset < 0:
|
|
370
|
+
raise bad_request("limit and offset must be non-negative.")
|
|
371
|
+
if offset > _INT64_MAX:
|
|
372
|
+
raise bad_request("offset is too large.")
|
|
373
|
+
limit = min(limit, MAX_LIMIT)
|
|
374
|
+
|
|
375
|
+
c = db.conn()
|
|
376
|
+
total = c.execute(
|
|
377
|
+
f"SELECT COUNT(*) AS n FROM objects WHERE {where}", params
|
|
378
|
+
).fetchone()["n"]
|
|
379
|
+
rows = c.execute(
|
|
380
|
+
f"SELECT * FROM objects WHERE {where} ORDER BY {order_clause} "
|
|
381
|
+
f"LIMIT ? OFFSET ?",
|
|
382
|
+
params + sort_params + [limit, offset],
|
|
383
|
+
).fetchall()
|
|
384
|
+
|
|
385
|
+
projected = [_project_fields(r, fields) for r in rows]
|
|
386
|
+
relmap = assoc.project_relations(app, [r["guid"] for r in rows], object_type)
|
|
387
|
+
for p in projected:
|
|
388
|
+
p.update(relmap[p["_guid"]])
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
"objects": projected,
|
|
392
|
+
"total": total,
|
|
393
|
+
"limit": limit,
|
|
394
|
+
"offset": offset,
|
|
395
|
+
}
|
morphdb/router.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""A tiny path-template router with no dependencies.
|
|
2
|
+
|
|
3
|
+
Templates use ``{name}`` segments, e.g. ``/objects/{type}/{guid}``. A trailing
|
|
4
|
+
slash is optional. Handlers receive a :class:`Request` and return either a
|
|
5
|
+
payload (rendered as 200) or a ``(status, payload)`` tuple.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Request:
|
|
12
|
+
def __init__(self, method, path, params, query, body, headers):
|
|
13
|
+
self.method = method
|
|
14
|
+
self.path = path
|
|
15
|
+
self.params = params # dict from path template
|
|
16
|
+
self.query = query # dict, single (last) value per key
|
|
17
|
+
self.body = body # parsed JSON (dict/list) or {}
|
|
18
|
+
self.headers = headers
|
|
19
|
+
|
|
20
|
+
def query_bool(self, key, default=False):
|
|
21
|
+
val = self.query.get(key)
|
|
22
|
+
if val is None:
|
|
23
|
+
return default
|
|
24
|
+
return str(val).strip().lower() in ("1", "true", "yes", "y", "on")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Router:
|
|
28
|
+
def __init__(self):
|
|
29
|
+
self._routes = [] # (method, compiled_regex, handler)
|
|
30
|
+
|
|
31
|
+
def add(self, method, template, handler):
|
|
32
|
+
param_names = re.findall(r"{(\w+)}", template)
|
|
33
|
+
pattern = re.sub(r"{(\w+)}", r"(?P<\1>[^/]+)", template)
|
|
34
|
+
regex = re.compile("^" + pattern + "/?$")
|
|
35
|
+
self._routes.append((method.upper(), regex, handler, param_names))
|
|
36
|
+
|
|
37
|
+
def route(self, method, template):
|
|
38
|
+
def deco(fn):
|
|
39
|
+
self.add(method, template, fn)
|
|
40
|
+
return fn
|
|
41
|
+
return deco
|
|
42
|
+
|
|
43
|
+
def match(self, method, path):
|
|
44
|
+
"""Return ``(handler, params, path_matched)``.
|
|
45
|
+
|
|
46
|
+
``path_matched`` distinguishes a 404 (no path matched) from a 405
|
|
47
|
+
(path matched but not for this method).
|
|
48
|
+
"""
|
|
49
|
+
# HEAD is served by the GET handler (the server omits the body).
|
|
50
|
+
match_method = "GET" if method == "HEAD" else method
|
|
51
|
+
path_matched = False
|
|
52
|
+
for m, regex, handler, _names in self._routes:
|
|
53
|
+
mo = regex.match(path)
|
|
54
|
+
if mo:
|
|
55
|
+
path_matched = True
|
|
56
|
+
if m == match_method:
|
|
57
|
+
return handler, mo.groupdict(), True
|
|
58
|
+
return None, None, path_matched
|