|
| 1 | + |
1 | 2 | from datetime import datetime |
2 | | -from typing import Any, Optional |
3 | | -from uuid import uuid4 |
| 3 | +from typing import Any, List, Dict, Optional |
4 | 4 |
|
5 | 5 | from database import schemas_collection |
6 | 6 | from datamodel import SchemaDefinition |
7 | 7 | from fastapi import FastAPI, HTTPException |
8 | 8 | from fastapi.encoders import jsonable_encoder |
9 | 9 | from fastapi.middleware.cors import CORSMiddleware |
10 | 10 |
|
| 11 | + |
11 | 12 | # ---- FastAPI app & CORS ---- |
12 | 13 | app = FastAPI() |
13 | 14 | app.add_middleware( |
|
19 | 20 | ) |
20 | 21 |
|
21 | 22 |
|
| 23 | +def _compute_hash_from_doc(doc: Dict[str, Any]) -> str: |
| 24 | + """ |
| 25 | + Helper to compute the same SHA-256 hash used by SchemaDefinition |
| 26 | + without instantiating a model (used for quick normalization paths). |
| 27 | + """ |
| 28 | + # Import locally to avoid circular imports and to use the same logic |
| 29 | + from datamodel import SchemaDefinition |
| 30 | + return SchemaDefinition._compute_hash( |
| 31 | + doc.get("name"), |
| 32 | + doc.get("version"), |
| 33 | + doc.get("content"), |
| 34 | + ) |
| 35 | + |
| 36 | + |
22 | 37 | # ---- Routes ---- |
23 | | -@app.get("/schemas", response_model=list[SchemaDefinition]) |
24 | | -async def get_all_schemas() -> list[dict[str, Any]]: |
| 38 | +@app.get("/schemas", response_model=List[SchemaDefinition]) |
| 39 | +async def get_all_schemas() -> List[Dict[str, Any]]: |
25 | 40 | """ |
26 | | - Retrieve all schemas. Ensures each document has `id` as a string and a valid `updated_at`. |
| 41 | + Retrieve all schemas. Ensures each document has `id` as the content hash |
| 42 | + and a valid `updated_at`. If the stored `id` is missing/mismatched, |
| 43 | + it will be recomputed to keep the collection consistent. |
27 | 44 | """ |
28 | 45 | docs = list(schemas_collection.find()) |
29 | | - normalized: list[dict[str, Any]] = [] |
30 | | - |
| 46 | + normalized: List[Dict[str, Any]] = [] |
31 | 47 | for d in docs: |
32 | | - # Ensure `id` exists and is a non-empty string |
33 | | - if not isinstance(d.get("id"), str) or not d["id"].strip(): |
34 | | - d["id"] = str(uuid4()) |
35 | | - |
36 | | - # Ensure `updated_at` is present (optional safeguard) |
| 48 | + # Compute the correct content hash |
| 49 | + computed_id = _compute_hash_from_doc(d) |
| 50 | + if d.get("id") != computed_id: |
| 51 | + # Heal legacy/mismatched ids |
| 52 | + d["id"] = computed_id |
| 53 | + # Do not modify updated_at during passive normalization |
| 54 | + schemas_collection.update_one({"_id": d["_id"]}, {"$set": {"id": computed_id}}) |
| 55 | + |
| 56 | + # Ensure updated_at exists (server-side default) |
37 | 57 | if d.get("updated_at") is None: |
38 | 58 | d["updated_at"] = datetime.utcnow() |
| 59 | + schemas_collection.update_one({"_id": d["_id"]}, {"$set": {"updated_at": d["updated_at"]}}) |
39 | 60 |
|
| 61 | + # Remove internal MongoDB _id from outward JSON |
| 62 | + d.pop("_id", None) |
40 | 63 | normalized.append(d) |
41 | 64 |
|
42 | | - # No BSON present; plain JSON encoding is fine |
43 | 65 | return jsonable_encoder(normalized) |
44 | 66 |
|
45 | 67 |
|
46 | 68 | @app.post("/schemas", response_model=SchemaDefinition) |
47 | | -async def add_schema(schema: SchemaDefinition) -> dict[str, Any]: |
| 69 | +async def add_schema(schema: SchemaDefinition) -> Dict[str, Any]: |
48 | 70 | """ |
49 | | - Add a new schema. If `id` is missing/empty, generate one; always refresh `updated_at`. |
| 71 | + Add a new schema. `id` is deterministically computed from {name, version, content}. |
| 72 | + Server sets `updated_at`. |
50 | 73 | """ |
51 | | - doc = schema.dict() |
52 | | - |
53 | | - # Guarantee a usable id |
54 | | - if not isinstance(doc.get("id"), str) or not doc["id"].strip(): |
55 | | - doc["id"] = str(uuid4()) |
56 | | - |
57 | | - # Server-side timestamp |
| 74 | + # Rebuild model explicitly to guarantee id is based on content, not caller-supplied id |
| 75 | + model = SchemaDefinition( |
| 76 | + name=schema.name, |
| 77 | + version=schema.version, |
| 78 | + content=schema.content, |
| 79 | + updated_at=None, |
| 80 | + id="ignored" # ignored by validator; kept for clarity |
| 81 | + ) |
| 82 | + doc = model.dict() |
58 | 83 | doc["updated_at"] = datetime.utcnow() |
59 | 84 |
|
60 | | - # Insert as-is (no ObjectId conversions) |
| 85 | + # Insert as-is (no ObjectId conversions for id) |
61 | 86 | schemas_collection.insert_one(doc) |
62 | 87 |
|
63 | 88 | # Return exactly what we stored |
64 | 89 | return jsonable_encoder(doc) |
65 | 90 |
|
66 | 91 |
|
67 | | -@app.put("/schemas/{id}", response_model=dict[str, str]) |
68 | | -async def update_schema(id: str, update: SchemaDefinition) -> dict[str, str]: |
| 92 | +@app.put("/schemas/{id}", response_model=Dict[str, str]) |
| 93 | +async def update_schema(id: str, update: SchemaDefinition) -> Dict[str, str]: |
69 | 94 | """ |
70 | | - Update schema by `id`. Ignores `id` field in the payload (primary key is immutable). |
71 | | - Only non-None fields are updated; `updated_at` is refreshed automatically. |
| 95 | + Update schema by `id`. Because `id` is a content hash, any change in |
| 96 | + {name, version, content} will produce a new `id`. This endpoint: |
| 97 | + 1) Finds the existing document by the current `id`. |
| 98 | + 2) Merges provided fields (ignores `None` and any `id` supplied). |
| 99 | + 3) Recomputes `id` from merged content. |
| 100 | + 4) Replaces the document and returns the (possibly new) `id`. |
72 | 101 | """ |
73 | 102 | if not isinstance(id, str) or not id.strip(): |
74 | | - raise HTTPException( |
75 | | - status_code=400, detail="Invalid schema id (must be a non-empty string)" |
76 | | - ) |
| 103 | + raise HTTPException(status_code=400, detail="Invalid schema id (must be a non-empty string)") |
77 | 104 |
|
78 | | - # Ignore None values and prevent changing the primary key |
79 | | - update_fields = { |
80 | | - k: v for k, v in update.dict().items() if v is not None and k != "id" |
81 | | - } |
| 105 | + existing = schemas_collection.find_one({"id": id}) |
| 106 | + if not existing: |
| 107 | + raise HTTPException(status_code=404, detail="Schema not found") |
82 | 108 |
|
83 | | - if update_fields: |
84 | | - update_fields["updated_at"] = datetime.utcnow() |
| 109 | + # Merge non-None fields from the payload (ignore any 'id' from client) |
| 110 | + payload = update.dict() |
| 111 | + merged = { |
| 112 | + "name": payload.get("name", existing.get("name")), |
| 113 | + "version": payload.get("version", existing.get("version")), |
| 114 | + "content": payload.get("content", existing.get("content")), |
| 115 | + } |
| 116 | + # Compute new hash-based id using SchemaDefinition logic |
| 117 | + new_model = SchemaDefinition(**merged, id="ignored", updated_at=None) |
| 118 | + new_id = new_model.id |
| 119 | + |
| 120 | + # Build final doc to store |
| 121 | + final_doc = { |
| 122 | + "id": new_id, |
| 123 | + "name": merged["name"], |
| 124 | + "version": merged["version"], |
| 125 | + "content": merged["content"], |
| 126 | + "updated_at": datetime.utcnow(), |
| 127 | + } |
85 | 128 |
|
86 | | - result = schemas_collection.update_one( |
87 | | - {"id": id}, {"$set": update_fields} if update_fields else {} |
88 | | - ) |
89 | | - if result.matched_count == 0: |
90 | | - raise HTTPException(status_code=404, detail="Schema not found") |
| 129 | + # Replace the existing document (matched by the previous id) |
| 130 | + replace_result = schemas_collection.replace_one({"id": id}, final_doc) |
| 131 | + if replace_result.matched_count == 0: |
| 132 | + raise HTTPException(status_code=404, detail="Schema not found during update") |
91 | 133 |
|
92 | | - return {"message": "Schema updated"} |
| 134 | + # If id changed, the caller now has to reference the new id |
| 135 | + return {"message": "Schema updated", "id": new_id} |
93 | 136 |
|
94 | 137 |
|
95 | | -@app.delete("/schemas/{id}", response_model=dict[str, str]) |
96 | | -async def delete_schema(id: str) -> dict[str, str]: |
| 138 | +@app.delete("/schemas/{id}", response_model=Dict[str, str]) |
| 139 | +async def delete_schema(id: str) -> Dict[str, str]: |
97 | 140 | """ |
98 | 141 | Delete schema by `id`. |
99 | 142 | """ |
100 | 143 | if not isinstance(id, str) or not id.strip(): |
101 | | - raise HTTPException( |
102 | | - status_code=400, detail="Invalid schema id (must be a non-empty string)" |
103 | | - ) |
| 144 | + raise HTTPException(status_code=400, detail="Invalid schema id (must be a non-empty string)") |
104 | 145 |
|
105 | 146 | result = schemas_collection.delete_one({"id": id}) |
106 | 147 | if result.deleted_count == 0: |
107 | 148 | raise HTTPException(status_code=404, detail="Schema not found") |
108 | | - |
109 | 149 | return {"message": "Schema deleted"} |
0 commit comments