diff --git a/topology_api/app.py b/topology_api/app.py new file mode 100644 index 0000000..43d3db7 --- /dev/null +++ b/topology_api/app.py @@ -0,0 +1,20 @@ +# topology_api/app.py +from flask import Flask +from topology_api.config import API_HOST, API_PORT, API_DEBUG +from topology_api.routes.snapshot import snapshot_bp + + +def create_app(): + app = Flask(__name__) + app.register_blueprint(snapshot_bp) + + @app.route("/health") + def health(): + return {"status": "ok"} + + return app + + +if __name__ == "__main__": + app = create_app() + app.run(host=API_HOST, port=API_PORT, debug=API_DEBUG) diff --git a/topology_api/queries/snapshot_queries.py b/topology_api/queries/snapshot_queries.py new file mode 100644 index 0000000..7ce9376 --- /dev/null +++ b/topology_api/queries/snapshot_queries.py @@ -0,0 +1,203 @@ +# topology_api/queries/snapshot_queries.py +"""Snapshot resolution and data fetching logic.""" +from topology_api.db import read_cursor + + +def resolve_snapshot_time(requested_mission=None, requested_at=None): + """Determine the snapshot timestamp per spec. + + Returns: (snapshot_at: str | None, anchor_source: str) + """ + with read_cursor() as cur: + if requested_at is not None: + return requested_at, "explicit_time" + + if requested_mission is not None: + cur.execute(""" + SELECT id, occurrence_status + FROM missions + WHERE code = %(code)s + """, {"code": requested_mission}) + mission = cur.fetchone() + if mission is None: + return None, "mission_not_found" + + cur.execute(""" + SELECT effective_at + FROM mission_events + WHERE mission_id = %(mid)s + ORDER BY effective_at DESC, event_order DESC + LIMIT 1 + """, {"mid": mission["id"]}) + event = cur.fetchone() + if event is None: + return None, "mission_has_no_timed_event" + + return event["effective_at"].isoformat(), "mission_latest_event" + + # Neither provided: latest event across all occurred missions + cur.execute(""" + SELECT me.effective_at + FROM mission_events me + JOIN missions m ON m.id = me.mission_id + WHERE m.occurrence_status = 'occurred' + ORDER BY me.effective_at DESC, me.event_order DESC + LIMIT 1 + """) + event = cur.fetchone() + if event is None: + return None, "no_events" + return event["effective_at"].isoformat(), "latest_recorded_state" + + +def fetch_snapshot(snapshot_at): + """Fetch complete base topology at a given point in time. + + Returns dict with keys: base, components, connections + """ + result = {"base": None, "components": [], "connections": []} + params = {"at": snapshot_at} + + with read_cursor() as cur: + # --- base --- + cur.execute("SELECT code, name FROM bases WHERE code = 'guanghan'") + base = cur.fetchone() + if base is None: + return result + result["base"] = dict(base) + + # --- components present at snapshot_at --- + cur.execute(""" + SELECT + c.id, + c.component_key, + c.component_type, + cv.display_name, + cv.summary, + cv.description, + cv.length_m, + cv.width_m, + cv.height_m, + cv.diameter_m, + cv.mass_t, + cv.detail_url, + cv.properties + FROM components c + JOIN mission_events e_intro + ON e_intro.id = c.introduced_by_event_id + LEFT JOIN mission_events e_retire + ON e_retire.id = c.retired_by_event_id + LEFT JOIN LATERAL ( + SELECT cv_sub.* + FROM component_versions cv_sub + WHERE cv_sub.component_id = c.id + AND cv_sub.valid_from <= %(at)s::timestamptz + AND (cv_sub.valid_to IS NULL OR %(at)s::timestamptz < cv_sub.valid_to) + ORDER BY cv_sub.valid_from DESC + LIMIT 1 + ) cv ON true + WHERE c.base_id = (SELECT id FROM bases WHERE code = 'guanghan') + AND e_intro.effective_at <= %(at)s::timestamptz + AND (c.retired_by_event_id IS NULL + OR (SELECT effective_at FROM mission_events WHERE id = c.retired_by_event_id) > %(at)s::timestamptz) + ORDER BY c.component_key + """, params) + component_rows = cur.fetchall() + + component_map = {} + for row in component_rows: + comp = { + "key": row["component_key"], + "type": row["component_type"], + "name": row["display_name"], + "summary": row["summary"], + "description": row["description"], + "detailUrl": row["detail_url"], + "dimensions": {}, + "properties": row["properties"] or {}, + "ports": [], + "states": {}, + } + for dim in ["length_m", "width_m", "height_m", "diameter_m"]: + val = row[dim] + if val is not None: + comp["dimensions"][dim.replace("_m", "M")] = float(val) + if row["mass_t"] is not None: + comp["dimensions"]["massT"] = float(row["mass_t"]) + result["components"].append(comp) + component_map[row["id"]] = comp + + if not component_map: + return result + + # --- ports --- + component_ids = tuple(component_map.keys()) + cur.execute(""" + SELECT id, component_id, port_key, display_name, direction, interface_type + FROM ports + WHERE component_id IN %(ids)s + ORDER BY component_id, port_key + """, {"ids": component_ids}) + port_map = {} + for row in cur.fetchall(): + port = { + "key": row["port_key"], + "displayName": row["display_name"], + "direction": row["direction"], + "interfaceType": row["interface_type"], + } + cid = row["component_id"] + if cid in component_map: + component_map[cid]["ports"].append(port) + port_map[row["id"]] = { + "component_key": component_map.get(cid, {}).get("key"), + **port, + } + + # --- states --- + cur.execute(""" + SELECT component_id, state_kind, state_value, details + FROM component_states + WHERE component_id IN %(ids)s + AND valid_from <= %(at)s::timestamptz + AND (valid_to IS NULL OR %(at)s::timestamptz < valid_to) + ORDER BY component_id, state_kind + """, {"ids": component_ids, "at": snapshot_at}) + for row in cur.fetchall(): + comp = component_map.get(row["component_id"]) + if comp: + comp["states"][row["state_kind"]] = row["state_value"] + if row["details"]: + comp["states"][row["state_kind"] + "Details"] = row["details"] + + # --- connections --- + cur.execute(""" + SELECT + tc.connection_type, + tc.properties, + pa.component_id AS comp_a_id, + pa.port_key AS port_a_key, + pb.component_id AS comp_b_id, + pb.port_key AS port_b_key + FROM topology_connections tc + JOIN ports pa ON pa.id = tc.port_a_id + JOIN ports pb ON pb.id = tc.port_b_id + WHERE tc.base_id = (SELECT id FROM bases WHERE code = 'guanghan') + AND tc.valid_from <= %(at)s::timestamptz + AND (tc.valid_to IS NULL OR %(at)s::timestamptz < tc.valid_to) + AND pa.component_id IN %(ids)s + AND pb.component_id IN %(ids)s + ORDER BY tc.connection_type, pa.port_key + """, {"ids": component_ids, "at": snapshot_at}) + for row in cur.fetchall(): + comp_a = component_map.get(row["comp_a_id"]) + comp_b = component_map.get(row["comp_b_id"]) + if comp_a and comp_b: + result["connections"].append({ + "type": row["connection_type"], + "fromPort": f"{comp_a['key']}.{row['port_a_key']}", + "toPort": f"{comp_b['key']}.{row['port_b_key']}", + "properties": row["properties"], + }) + + return result diff --git a/topology_api/routes/snapshot.py b/topology_api/routes/snapshot.py new file mode 100644 index 0000000..85674bc --- /dev/null +++ b/topology_api/routes/snapshot.py @@ -0,0 +1,61 @@ +# topology_api/routes/snapshot.py +from flask import Blueprint, request, jsonify +from topology_api.queries.snapshot_queries import resolve_snapshot_time, fetch_snapshot + +snapshot_bp = Blueprint("snapshot", __name__) + + +@snapshot_bp.route("/api/v1/bases//snapshot") +def get_snapshot(base_code): + if base_code != "guanghan": + return jsonify({ + "error": "base_not_found", + "message": f"Base '{base_code}' not found. Only 'guanghan' is available.", + }), 404 + + requested_mission = request.args.get("mission") + requested_at = request.args.get("at") + + snapshot_at, anchor_source = resolve_snapshot_time( + requested_mission=requested_mission, + requested_at=requested_at, + ) + + if anchor_source == "mission_not_found": + return jsonify({ + "error": "mission_not_found", + "message": f"Mission '{requested_mission}' not found.", + }), 404 + + if anchor_source == "mission_has_no_timed_event": + return jsonify({ + "error": "mission_has_no_timed_event", + "message": f"Mission '{requested_mission}' has no timed events.", + }), 409 + + if anchor_source == "no_events": + return jsonify({ + "base": {"code": "guanghan", "name": "广寒基地"}, + "query": { + "requestedAt": requested_at, + "requestedMission": requested_mission, + "snapshotAt": None, + "anchorSource": "no_events", + }, + "components": [], + "connections": [], + }) + + data = fetch_snapshot(snapshot_at) + + return jsonify({ + "base": data["base"], + "query": { + "requestedAt": requested_at, + "requestedMission": requested_mission, + "snapshotAt": snapshot_at, + "anchorSource": anchor_source, + }, + "components": data["components"], + "connections": data["connections"], + })