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/routes.py ADDED
@@ -0,0 +1,254 @@
1
+ """HTTP route definitions. Pure glue between the router and the logic modules.
2
+
3
+ Three surfaces:
4
+
5
+ * **Apps** (one MorphDB instance, many independent websites):
6
+ POST ``/app`` to register, DELETE ``/app/{key}`` to delete (cascades). There
7
+ is deliberately no "list apps" endpoint — you only address an app you already
8
+ hold the key for.
9
+ * **Schema** (the coding agent reshapes the data model):
10
+ GET/PUT/DELETE ``/schema`` and ``/schema/{type}``.
11
+ * **Objects** (the frontend reads/writes data, including relations as fields):
12
+ ``/objects/{type}`` and ``/object/{guid}``.
13
+
14
+ Every schema and object request must carry its app via the ``X-App-Key`` header;
15
+ ``apps.require_app`` resolves and validates it. Relations are not their own
16
+ endpoints — they are declared inside a type's schema and read/written as fields
17
+ on objects.
18
+ """
19
+
20
+ from . import apps
21
+ from . import objects as objs
22
+ from . import schema as sch
23
+ from .errors import bad_request
24
+ from .router import Router
25
+
26
+ router = Router()
27
+
28
+
29
+ def _obj_body(req):
30
+ """Return the request body as a dict, or raise 400.
31
+
32
+ An empty/absent body is treated as ``{}`` (a valid empty write); a non-object
33
+ JSON value (list, string, number, ``null``) is rejected rather than silently
34
+ dropped.
35
+ """
36
+ b = req.body
37
+ if b is None or b == {}:
38
+ return {}
39
+ if not isinstance(b, dict):
40
+ raise bad_request("Request body must be a JSON object.")
41
+ return b
42
+
43
+
44
+ # --- meta ---------------------------------------------------------------------
45
+
46
+
47
+ @router.route("GET", "/")
48
+ def root(req):
49
+ return {
50
+ "name": "MorphDB",
51
+ "version": __import__("morphdb").__version__,
52
+ "description": "Schema-fluid, API-stable database for AI-generated apps.",
53
+ "docs": "GET /help for the full endpoint reference.",
54
+ }
55
+
56
+
57
+ @router.route("GET", "/health")
58
+ def health(req):
59
+ return {"status": "ok"}
60
+
61
+
62
+ @router.route("GET", "/help")
63
+ def help_(req):
64
+ return {"endpoints": ENDPOINT_REFERENCE}
65
+
66
+
67
+ # --- apps (register a website; delete one and everything under it) ------------
68
+
69
+
70
+ @router.route("POST", "/app")
71
+ def register_app(req):
72
+ body = _obj_body(req)
73
+ key = body.get("key")
74
+ if not key or not isinstance(key, str):
75
+ raise bad_request(
76
+ "Provide an app key: {\"key\": \"my-app\"}. Pick a unique, memorable "
77
+ "string and reuse it as the X-App-Key header on every request."
78
+ )
79
+ return 201, apps.register_app(key)
80
+
81
+
82
+ @router.route("DELETE", "/app/{key}")
83
+ def delete_app(req):
84
+ return apps.delete_app(req.params["key"])
85
+
86
+
87
+ # --- schema (for the coding agent) --------------------------------------------
88
+
89
+
90
+ @router.route("GET", "/schema")
91
+ def full_schema(req):
92
+ app = apps.require_app(req)
93
+ return {"types": sch.list_type_docs(app)}
94
+
95
+
96
+ @router.route("GET", "/schema/{type}")
97
+ def get_type(req):
98
+ app = apps.require_app(req)
99
+ return sch.get_type_doc(app, req.params["type"], required=True)
100
+
101
+
102
+ def _type_body(body):
103
+ """Parse a schema-write body into (fields, relations, merge).
104
+
105
+ Accepts a structured doc ``{fields?, relations?, merge?}`` or, as a
106
+ shorthand, a bare ``{name: type}`` field map. A ``None`` fields/relations
107
+ means "leave that part untouched".
108
+ """
109
+ if not isinstance(body, dict):
110
+ raise bad_request(
111
+ "Body must be a schema document {fields?, relations?, merge?} or a "
112
+ "bare field map."
113
+ )
114
+ if any(k in body for k in ("fields", "relations", "merge")):
115
+ fields = body.get("fields")
116
+ relations = body.get("relations")
117
+ if fields is not None and not isinstance(fields, dict):
118
+ raise bad_request("'fields' must be an object mapping name -> type.")
119
+ return fields, relations, bool(body.get("merge", False))
120
+ return body, None, False # bare field map
121
+
122
+
123
+ @router.route("PUT", "/schema/{type}")
124
+ def put_type(req):
125
+ app = apps.require_app(req)
126
+ fields, relations, merge = _type_body(_obj_body(req))
127
+ return sch.upsert_type(app, req.params["type"], fields=fields,
128
+ relations=relations, merge=merge)
129
+
130
+
131
+ @router.route("DELETE", "/schema/{type}")
132
+ def delete_type(req):
133
+ app = apps.require_app(req)
134
+ return sch.delete_type(app, req.params["type"])
135
+
136
+
137
+ # --- objects (for the website) ------------------------------------------------
138
+
139
+
140
+ @router.route("POST", "/objects/{type}")
141
+ def create_object(req):
142
+ app = apps.require_app(req)
143
+ return 201, objs.create_object(app, req.params["type"], _obj_body(req))
144
+
145
+
146
+ @router.route("GET", "/objects/{type}")
147
+ def list_objects(req):
148
+ app = apps.require_app(req)
149
+ q = dict(req.query)
150
+ limit = q.pop("limit", objs.DEFAULT_LIMIT)
151
+ offset = q.pop("offset", 0)
152
+ sort = q.pop("sort", None)
153
+ order = q.pop("order", "asc")
154
+ # everything left in q is a field filter
155
+ return objs.list_objects(
156
+ app, req.params["type"], filters=q, limit=limit, offset=offset,
157
+ sort=sort, order=order,
158
+ )
159
+
160
+
161
+ @router.route("GET", "/objects/{type}/{guid}")
162
+ def get_object_typed(req):
163
+ app = apps.require_app(req)
164
+ return objs.get_object(app, req.params["guid"], object_type=req.params["type"])
165
+
166
+
167
+ @router.route("PUT", "/objects/{type}/{guid}")
168
+ def put_object(req):
169
+ app = apps.require_app(req)
170
+ return objs.upsert_object(app, req.params["type"], req.params["guid"],
171
+ _obj_body(req), partial=False)
172
+
173
+
174
+ @router.route("PATCH", "/objects/{type}/{guid}")
175
+ def patch_object(req):
176
+ app = apps.require_app(req)
177
+ return objs.upsert_object(app, req.params["type"], req.params["guid"],
178
+ _obj_body(req), partial=True)
179
+
180
+
181
+ @router.route("DELETE", "/objects/{type}/{guid}")
182
+ def delete_object(req):
183
+ app = apps.require_app(req)
184
+ return objs.delete_object(app, req.params["guid"])
185
+
186
+
187
+ @router.route("GET", "/object/{guid}")
188
+ def get_object_by_guid(req):
189
+ app = apps.require_app(req)
190
+ return objs.get_object(app, req.params["guid"])
191
+
192
+
193
+ # --- self-documenting reference (served at GET /help) -------------------------
194
+
195
+ ENDPOINT_REFERENCE = {
196
+ "_apps": (
197
+ "One MorphDB instance hosts many apps (one per website). Register an app, "
198
+ "then send its key as the 'X-App-Key' header on EVERY schema and object "
199
+ "request. Apps are isolated: type names may repeat across apps. There is "
200
+ "no list-apps endpoint by design."
201
+ ),
202
+ "app endpoints (register / delete a website)": {
203
+ "POST /app": "Register an app. Body: {\"key\": \"my-app\"}. 409 if the key is taken. Remember the key — there is no way to list it back.",
204
+ "DELETE /app/{key}": "Delete an app and cascade-delete all its schemas, objects, relations, and edges.",
205
+ },
206
+ "schema endpoints (you, the agent — reshape the data model)": {
207
+ "GET /schema": "View all type schemas (fields + relations + inverse relations) for this app.",
208
+ "GET /schema/{type}": "View one type's schema.",
209
+ "PUT /schema/{type}": (
210
+ "Create/replace a type. Body: {fields?, relations?, merge?} or a bare "
211
+ "field map. merge:true adds without dropping; merge:false replaces. "
212
+ "Absent 'fields'/'relations' are left untouched."
213
+ ),
214
+ "DELETE /schema/{type}": (
215
+ "Delete a type, its objects, and edges touching them. Neighbor objects "
216
+ "of other types are NOT deleted."
217
+ ),
218
+ },
219
+ "object endpoints (your frontend — read/write data)": {
220
+ "POST /objects/{type}": "Create an object. Body: field + relation values. Returns it with _guid.",
221
+ "GET /objects/{type}": "List/query. Query: field filters (field, field__gt, field__contains, field__in, ...), limit, offset, sort, order.",
222
+ "GET /objects/{type}/{guid}": "Read one object (fields + relation guids).",
223
+ "GET /object/{guid}": "Read one object by guid alone.",
224
+ "PUT /objects/{type}/{guid}": "Replace an object's fields (create if absent). Relations present in the body are set.",
225
+ "PATCH /objects/{type}/{guid}": "Merge fields into an object (create if absent). Relations present in the body are set.",
226
+ "DELETE /objects/{type}/{guid}": "Delete an object and its edges (neighbors survive).",
227
+ },
228
+ "relations": {
229
+ "declare": (
230
+ "In a type's schema under 'relations': "
231
+ "{\"assignee\": {\"to\": \"user\", \"cardinality\": \"many_to_one\", "
232
+ "\"inverse\": \"tasks\"}}. Declared once; the inverse ('tasks') appears "
233
+ "automatically on the other type."
234
+ ),
235
+ "read": "Relation values appear in the object body: a guid (to-one) or list of guids (to-many).",
236
+ "write": "Set a relation like a field: {\"assignee\": \"<guid>\"} or {\"tags\": [\"<g1>\", \"<g2>\"]}. null/[] clears. Last write wins on conflict.",
237
+ "symmetric": "Set symmetric:true (to == this type, one_to_one|many_to_many) for mutual links like friends — one shared label, edge counted once.",
238
+ },
239
+ "headers": {
240
+ "X-App-Key": "Required on every schema and object request: the key of the app you registered. Missing -> 400, unknown -> 404.",
241
+ },
242
+ "field_types": ["string", "number", "boolean", "json", "datetime"],
243
+ "cardinalities": ["one_to_one", "one_to_many", "many_to_one", "many_to_many"],
244
+ "filter_operators": ["eq (default)", "ne", "gt", "gte", "lt", "lte", "contains", "in", "exists"],
245
+ "list_response_shape": {"objects": "[...]", "total": "int (full filtered count)",
246
+ "limit": "int", "offset": "int"},
247
+ "notes": [
248
+ "datetime values are validated as ISO-8601 (or epoch seconds) and normalized.",
249
+ "number fields reject NaN/Infinity.",
250
+ "schema edits are O(1) and lazy: after a field retype, an old-typed value reads as unset until rewritten.",
251
+ "relations are stored as single-row edges and read/written as object fields; filtering is on fields, not relations.",
252
+ "relation targets must be objects in the same app; cross-app links are rejected.",
253
+ ],
254
+ }
morphdb/schema.py ADDED
@@ -0,0 +1,203 @@
1
+ """Object type schemas — the thing a coding agent edits constantly.
2
+
3
+ A *type* bundles raw ``fields`` and ``relations`` (links to other types) into a
4
+ single schema document. Editing it is O(1): we never rewrite stored objects.
5
+ Reads reinterpret old rows through the current schema (lazy invalidation), so
6
+ adding/removing/retyping a field is instant regardless of object count.
7
+
8
+ Everything here is scoped to one **app** (passed as the first argument): a type
9
+ named ``task`` in app A is independent of a ``task`` in app B.
10
+
11
+ Relations are stored in their own table (see :mod:`associations`) because they
12
+ have cardinality and two ends, but from the agent's point of view they live
13
+ right inside the type document alongside fields — declared once, on one side.
14
+ """
15
+
16
+ import json
17
+ import re
18
+
19
+ from . import associations as assoc
20
+ from . import db
21
+ from .errors import bad_request, not_found
22
+ from .fieldtypes import normalize_fields
23
+ from .util import now_iso
24
+
25
+ # \Z (not $) so a trailing newline cannot sneak through (e.g. "task\n").
26
+ _NAME_RE = re.compile(r"\A[A-Za-z][A-Za-z0-9_]*\Z")
27
+
28
+
29
+ def _validate_type_name(name):
30
+ if not isinstance(name, str) or not _NAME_RE.match(name):
31
+ raise bad_request(
32
+ f"Invalid object type name {name!r}. Use a letter followed by "
33
+ "letters, digits, or underscores (e.g. 'task', 'blog_post')."
34
+ )
35
+ return name
36
+
37
+
38
+ # --- low-level fields access (used internally by objects/associations) --------
39
+
40
+
41
+ def get_object_schema(app, name, required=False):
42
+ """Return the raw ``{name, fields, created_at, updated_at}`` for a type.
43
+
44
+ Just the stored fields map — relations are resolved separately. This is the
45
+ hot path for object reads/writes.
46
+ """
47
+ row = db.conn().execute(
48
+ "SELECT * FROM object_schemas WHERE app = ? AND name = ?", (app, name)
49
+ ).fetchone()
50
+ if row is None:
51
+ if required:
52
+ raise not_found(f"No object type named '{name}'. Define it first.")
53
+ return None
54
+ return {
55
+ "name": row["name"],
56
+ "fields": json.loads(row["fields"]),
57
+ "created_at": row["created_at"],
58
+ "updated_at": row["updated_at"],
59
+ }
60
+
61
+
62
+ # --- full type documents (the agent-facing schema surface) --------------------
63
+
64
+
65
+ def get_type_doc(app, name, required=False):
66
+ """The full schema document for one type: fields + relations + inverses."""
67
+ base = get_object_schema(app, name, required=required)
68
+ if base is None:
69
+ return None
70
+ relations, inverse = assoc.schema_relations(app, name)
71
+ return {
72
+ "name": base["name"],
73
+ "fields": base["fields"],
74
+ "relations": relations,
75
+ "inverse_relations": inverse,
76
+ "created_at": base["created_at"],
77
+ "updated_at": base["updated_at"],
78
+ }
79
+
80
+
81
+ def list_type_docs(app):
82
+ rows = db.conn().execute(
83
+ "SELECT name FROM object_schemas WHERE app = ? ORDER BY name", (app,)
84
+ ).fetchall()
85
+ return [get_type_doc(app, r["name"], required=True) for r in rows]
86
+
87
+
88
+ def upsert_type(app, name, fields=None, relations=None, merge=False):
89
+ """Create or update a type from a schema document, within ``app``.
90
+
91
+ ``fields`` / ``relations`` that are ``None`` (absent from the request) are
92
+ left untouched, so a partial edit is natural. When present:
93
+
94
+ * ``merge=True`` — add/update the given fields/relations, drop nothing.
95
+ * ``merge=False`` — replace: fields become exactly the given map; relations
96
+ authored on this type become exactly the given set (omitted ones, and
97
+ their edges, are removed). Inverse relations (authored elsewhere) are
98
+ never affected.
99
+ """
100
+ _validate_type_name(name)
101
+ if relations is not None and not isinstance(relations, dict):
102
+ raise bad_request("'relations' must be an object mapping name -> definition.")
103
+
104
+ with db.transaction() as c:
105
+ existing = c.execute(
106
+ "SELECT * FROM object_schemas WHERE app = ? AND name = ?", (app, name)
107
+ ).fetchone()
108
+ ts = now_iso()
109
+
110
+ # --- fields ---
111
+ if fields is not None:
112
+ new_fields = normalize_fields(fields)
113
+ if existing and merge:
114
+ final = dict(json.loads(existing["fields"]))
115
+ final.update(new_fields)
116
+ else:
117
+ final = new_fields
118
+ else:
119
+ final = json.loads(existing["fields"]) if existing else {}
120
+
121
+ if existing:
122
+ c.execute(
123
+ "UPDATE object_schemas SET fields = ?, updated_at = ? "
124
+ "WHERE app = ? AND name = ?",
125
+ (json.dumps(final), ts, app, name),
126
+ )
127
+ else:
128
+ c.execute(
129
+ "INSERT INTO object_schemas (app, name, fields, created_at, updated_at) "
130
+ "VALUES (?, ?, ?, ?, ?)",
131
+ (app, name, json.dumps(final), ts, ts),
132
+ )
133
+
134
+ # --- relations ---
135
+ touched = {name}
136
+ if relations is not None:
137
+ for key, raw in relations.items():
138
+ d = assoc.upsert_relation(c, app, name, key, raw)
139
+ touched.add(d["to_type"])
140
+ if not merge:
141
+ assoc.prune_forward_relations(c, app, name, set(relations.keys()))
142
+
143
+ # Field names and relation names share the object body namespace, so they
144
+ # must not collide on any affected type.
145
+ for t in touched:
146
+ _assert_no_collisions(c, app, t)
147
+
148
+ return get_type_doc(app, name, required=True)
149
+
150
+
151
+ def _assert_no_collisions(c, app, type_name):
152
+ row = c.execute(
153
+ "SELECT fields FROM object_schemas WHERE app = ? AND name = ?", (app, type_name)
154
+ ).fetchone()
155
+ if row is None:
156
+ return
157
+ field_keys = set(json.loads(row["fields"]).keys())
158
+ seen = set()
159
+ for v in assoc.relation_views(app, type_name, c):
160
+ k = v["key"]
161
+ if k in field_keys:
162
+ raise bad_request(
163
+ f"Relation '{k}' on type '{type_name}' collides with a field of the "
164
+ "same name. Rename one of them."
165
+ )
166
+ if k in seen:
167
+ raise bad_request(
168
+ f"Type '{type_name}' ends up with two relations named '{k}'. "
169
+ "Give the inverse a distinct name."
170
+ )
171
+ seen.add(k)
172
+
173
+
174
+ def delete_type(app, name):
175
+ """Delete a type, its own objects, and every edge touching those objects.
176
+
177
+ Neighbor objects of *other* types are never deleted — only the relationship
178
+ metadata and the edge rows go. Relations where this type was an endpoint are
179
+ removed from the other types' schemas too. Scoped to ``app``.
180
+ """
181
+ with db.transaction() as c:
182
+ row = c.execute(
183
+ "SELECT * FROM object_schemas WHERE app = ? AND name = ?", (app, name)
184
+ ).fetchone()
185
+ if row is None:
186
+ raise not_found(f"No object type named '{name}'.")
187
+
188
+ guids = [
189
+ r["guid"] for r in c.execute(
190
+ "SELECT guid FROM objects WHERE app = ? AND object_type = ?", (app, name)
191
+ ).fetchall()
192
+ ]
193
+ if guids:
194
+ qmarks = ",".join("?" * len(guids))
195
+ c.execute(f"DELETE FROM objects WHERE app = ? AND guid IN ({qmarks})",
196
+ [app, *guids])
197
+
198
+ # Drop relationships (and their edges) where this type is an endpoint;
199
+ # this also clears any edges from this type's objects to neighbors.
200
+ assoc.delete_relations_touching_type(c, app, name)
201
+ c.execute("DELETE FROM object_schemas WHERE app = ? AND name = ?", (app, name))
202
+
203
+ return {"deleted": name, "objects_removed": len(guids)}
morphdb/server.py ADDED
@@ -0,0 +1,194 @@
1
+ """Dependency-free HTTP server built on the standard library.
2
+
3
+ ThreadingHTTPServer + a small dispatch shim. Every response carries permissive
4
+ CORS headers so browser frontends served from any origin (file://, a Vite dev
5
+ server, etc.) can call the API directly. All DB access is serialized by a lock
6
+ in :mod:`morphdb.db`, so threaded request handling is safe.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ import traceback
13
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
+ from urllib.parse import parse_qs, urlparse
15
+
16
+ from . import db
17
+ from .errors import ApiError
18
+ from .router import Request
19
+ from .routes import router
20
+
21
+ MAX_BODY = 16 * 1024 * 1024 # 16 MB cap to avoid runaway memory on bad input
22
+ _BODY_METHODS = ("POST", "PUT", "PATCH", "DELETE")
23
+
24
+
25
+ class Handler(BaseHTTPRequestHandler):
26
+ server_version = "MorphDB/0.1"
27
+ protocol_version = "HTTP/1.1" # keep-alive; requires correct Content-Length
28
+
29
+ # -- helpers --------------------------------------------------------------
30
+
31
+ def _set_cors(self):
32
+ self.send_header("Access-Control-Allow-Origin", "*")
33
+ self.send_header("Access-Control-Allow-Methods",
34
+ "GET, POST, PUT, PATCH, DELETE, OPTIONS")
35
+ self.send_header("Access-Control-Allow-Headers",
36
+ "Content-Type, Authorization, X-App-Key")
37
+ self.send_header("Access-Control-Max-Age", "86400")
38
+
39
+ def _send_json(self, status, payload):
40
+ try:
41
+ body = json.dumps(payload, default=str, allow_nan=False).encode("utf-8")
42
+ except (TypeError, ValueError):
43
+ status = 500
44
+ body = json.dumps(
45
+ {"error": {"code": "serialization_error",
46
+ "message": "Result was not JSON-serializable."}}
47
+ ).encode("utf-8")
48
+ self.send_response(status)
49
+ self.send_header("Content-Type", "application/json; charset=utf-8")
50
+ self.send_header("Content-Length", str(len(body)))
51
+ # If we decided to close the connection (e.g. an unread body), tell the
52
+ # client so it doesn't reuse a desynced keep-alive socket.
53
+ if getattr(self, "close_connection", False):
54
+ self.send_header("Connection", "close")
55
+ self._set_cors()
56
+ self.end_headers()
57
+ if self.command != "HEAD":
58
+ try:
59
+ self.wfile.write(body)
60
+ except BrokenPipeError:
61
+ pass
62
+
63
+ def _read_raw_body(self):
64
+ """Read (drain) the request body for ANY method and return the bytes.
65
+
66
+ Draining regardless of method is essential: an unread body on a
67
+ keep-alive connection would be misparsed as the next request.
68
+ """
69
+ te = self.headers.get("Transfer-Encoding", "")
70
+ if te and te.strip().lower() != "identity":
71
+ # We don't decode chunked bodies; we also can't know their length to
72
+ # drain them, so close the connection to avoid a keep-alive desync.
73
+ self.close_connection = True
74
+ raise ApiError(400, "bad_request",
75
+ "Transfer-Encoding is not supported; send a body with "
76
+ "Content-Length.")
77
+ length = self.headers.get("Content-Length")
78
+ if not length:
79
+ return b""
80
+ try:
81
+ n = int(length)
82
+ except ValueError:
83
+ # We cannot know how many bytes to drain — close to avoid desyncing
84
+ # the next request on a keep-alive connection.
85
+ self.close_connection = True
86
+ raise ApiError(400, "bad_request", "Invalid Content-Length header.")
87
+ if n < 0:
88
+ # A negative length is invalid and leaves the body undrained; close
89
+ # the connection so leftover bytes can't be misread as the next req.
90
+ self.close_connection = True
91
+ raise ApiError(400, "bad_request", "Invalid Content-Length header.")
92
+ if n == 0:
93
+ return b""
94
+ if n > MAX_BODY:
95
+ # Don't read a potentially huge body; close the connection so the
96
+ # unread bytes can't be misread as the next request.
97
+ self.close_connection = True
98
+ raise ApiError(413, "payload_too_large",
99
+ f"Request body exceeds {MAX_BODY} bytes.")
100
+ return self.rfile.read(n)
101
+
102
+ def _parse_body(self, raw):
103
+ if not raw:
104
+ return {}
105
+ try:
106
+ return json.loads(raw.decode("utf-8"))
107
+ except (json.JSONDecodeError, UnicodeDecodeError, RecursionError,
108
+ ValueError) as e:
109
+ # Body was fully read, so the connection stays in sync.
110
+ raise ApiError(400, "bad_request", f"Invalid JSON body: {e}")
111
+
112
+ # -- dispatch -------------------------------------------------------------
113
+
114
+ def _dispatch(self):
115
+ # Always drain the body first, for every method, so a stray body on a
116
+ # GET/HEAD/OPTIONS request cannot desync a reused keep-alive connection.
117
+ try:
118
+ raw = self._read_raw_body()
119
+ except ApiError as e:
120
+ self._send_json(e.status, e.to_dict())
121
+ return
122
+
123
+ if self.command == "OPTIONS":
124
+ self.send_response(204)
125
+ self._set_cors()
126
+ self.send_header("Content-Length", "0")
127
+ if getattr(self, "close_connection", False):
128
+ self.send_header("Connection", "close")
129
+ self.end_headers()
130
+ return
131
+
132
+ parsed = urlparse(self.path)
133
+ path = parsed.path
134
+ query = {
135
+ k: v[-1]
136
+ for k, v in parse_qs(parsed.query, keep_blank_values=True).items()
137
+ }
138
+
139
+ try:
140
+ body = self._parse_body(raw) if self.command in _BODY_METHODS else {}
141
+ handler, params, path_matched = router.match(self.command, path)
142
+ if handler is None:
143
+ if path_matched:
144
+ raise ApiError(405, "method_not_allowed",
145
+ f"{self.command} not allowed on {path}.")
146
+ raise ApiError(404, "not_found",
147
+ f"No route for {self.command} {path}. See GET /help.")
148
+ req = Request(self.command, path, params, query, body, self.headers)
149
+ result = handler(req)
150
+ status, payload = result if isinstance(result, tuple) else (200, result)
151
+ self._send_json(status, payload)
152
+ except ApiError as e:
153
+ self._send_json(e.status, e.to_dict())
154
+ except BrokenPipeError:
155
+ pass
156
+ except Exception as e: # noqa: BLE001 — last-resort guard
157
+ traceback.print_exc()
158
+ self._send_json(
159
+ 500, {"error": {"code": "internal_error", "message": str(e)}}
160
+ )
161
+
162
+ do_GET = _dispatch
163
+ do_POST = _dispatch
164
+ do_PUT = _dispatch
165
+ do_PATCH = _dispatch
166
+ do_DELETE = _dispatch
167
+ do_OPTIONS = _dispatch
168
+ do_HEAD = _dispatch
169
+
170
+ def log_message(self, fmt, *args):
171
+ if os.environ.get("MORPHDB_QUIET"):
172
+ return
173
+ sys.stderr.write("[morphdb] %s %s\n" % (self.address_string(), fmt % args))
174
+
175
+
176
+ class MorphServer(ThreadingHTTPServer):
177
+ daemon_threads = True
178
+ allow_reuse_address = True
179
+
180
+
181
+ def serve(host="127.0.0.1", port=8787, db_path="morphdb.sqlite3"):
182
+ db.init_db(db_path)
183
+ httpd = MorphServer((host, port), Handler)
184
+ sys.stderr.write(
185
+ f"MorphDB v{__import__('morphdb').__version__} listening on "
186
+ f"http://{host}:{port} (db: {db_path})\n"
187
+ f"Try: curl http://{host}:{port}/help\n"
188
+ )
189
+ try:
190
+ httpd.serve_forever()
191
+ except KeyboardInterrupt:
192
+ sys.stderr.write("\n[morphdb] shutting down\n")
193
+ finally:
194
+ httpd.server_close()
morphdb/util.py ADDED
@@ -0,0 +1,19 @@
1
+ """Small shared helpers."""
2
+
3
+ import uuid
4
+ from datetime import datetime, timezone
5
+
6
+
7
+ def now_iso():
8
+ """Current UTC time as an ISO-8601 string with a trailing Z."""
9
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
10
+
11
+
12
+ def new_guid(object_type):
13
+ """A globally unique id, prefixed with a slug of the type for readability.
14
+
15
+ e.g. ``task_3f1c9a0b8e7d4f...``. The prefix is cosmetic; the uuid carries
16
+ the uniqueness. Non-alphanumeric chars in the type are stripped.
17
+ """
18
+ slug = "".join(c for c in str(object_type).lower() if c.isalnum())[:16] or "obj"
19
+ return f"{slug}_{uuid.uuid4().hex}"