From 5846b61260aee92b2eec8ca87281d6807f485844 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:55:09 +0000 Subject: [PATCH 1/2] Initial plan From 153cba0f8cf6c8837b1f6dbcdb5a8e89c2185053 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:09:45 +0000 Subject: [PATCH 2/2] feat: add DELETE/update schema API, gRPC ops, frontend, tests, and docs Co-authored-by: DeltaRule <83079740+DeltaRule@users.noreply.github.com> --- README.md | 50 +++++++ api/openapi.yaml | 50 +++++++ api/proto/worker.proto | 4 +- cmd/main-worker/server.go | 77 ++++++++++- cmd/main-worker/server_test.go | 192 +++++++++++++++++++++++++++ cmd/main-worker/static/app.html | 41 +++++- cmd/main-worker/static/css/delta.css | 9 ++ docs/usage/api-reference.md | 99 ++++++++++++++ docs/usage/schemas.md | 83 +++++++++++- pkg/schema/validator.go | 31 ++++- pkg/schema/validator_test.go | 60 +++++++++ tests/test_schema_api.py | 101 ++++++++++++++ 12 files changed, 787 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 4477369..ed0e9ab 100644 --- a/README.md +++ b/README.md @@ -560,6 +560,56 @@ Create or replace a JSON Schema. Authentication required. | `400` | Invalid JSON or invalid JSON Schema | | `401` | Missing or invalid Bearer token | +**Example:** + +```bash +curl -s -X PUT http://127.0.0.1:8080/schema/product.v1 \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "product.v1", + "type": "object", + "properties": { + "name": {"type": "string"}, + "price": {"type": "number", "minimum": 0} + }, + "required": ["name", "price"] + }' +``` + +--- + +### `DELETE /schema/{schemaID}` + +Permanently delete a JSON Schema. Requires `write` permission. + +Existing entities stored under that schema ID are **not** affected. Future entity +writes will proceed without validation until a new template is uploaded. + +**Path parameter:** `schemaID` — the schema identifier (e.g., `chat.v1`). + +**Response:** + +```json +{ "status": "ok" } +``` + +**Error responses:** + +| HTTP code | Meaning | +|-----------|---------| +| `401` | Missing or invalid Bearer token | +| `403` | Token lacks `write` permission | +| `404` | Schema not found | + +**Example:** + +```bash +curl -s -X DELETE http://127.0.0.1:8080/schema/product.v1 \ + -H "Authorization: Bearer $TOKEN" +``` + --- ## JSON Schema Templates diff --git a/api/openapi.yaml b/api/openapi.yaml index adc51e5..d9b8d69 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -661,6 +661,56 @@ paths: schema: $ref: "#/components/schemas/Error" + delete: + operationId: deleteSchema + summary: Delete a JSON Schema + description: | + Permanently delete a JSON Schema document by its ID. Requires `write` + permission. + + Once deleted, entities that were previously validated against this schema + are unaffected (they remain stored), but future `PUT` requests to the + same `schema_id` will no longer be validated until a new schema is + uploaded. To update a schema instead of deleting it, use `PUT`. + + Schema IDs must not contain `/`, `\`, or `..` sequences. + tags: [schemas] + security: + - BearerAuth: [] + parameters: + - name: schemaID + in: path + required: true + description: Schema identifier (e.g., `product.v1`). + schema: + type: string + example: product.v1 + responses: + "200": + description: Schema deleted successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/StatusOK" + "401": + description: Missing or invalid Bearer token. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "403": + description: Token lacks `write` permission. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: Schema not found. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + # --------------------------------------------------------------------------- # API Keys # --------------------------------------------------------------------------- diff --git a/api/proto/worker.proto b/api/proto/worker.proto index a83442d..0170733 100644 --- a/api/proto/worker.proto +++ b/api/proto/worker.proto @@ -29,7 +29,9 @@ message ProcessRequest { string database_name = 1; string entity_key = 2; string schema_id = 3; - string operation = 4; // GET, PUT, or DELETE + // Entity operations: GET, PUT, DELETE + // Schema template operations: SCHEMA_GET, SCHEMA_PUT, SCHEMA_DELETE + string operation = 4; bytes payload = 5; string token = 6; } diff --git a/cmd/main-worker/server.go b/cmd/main-worker/server.go index 383bdf0..b7922df 100644 --- a/cmd/main-worker/server.go +++ b/cmd/main-worker/server.go @@ -6,6 +6,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "log" "net" @@ -318,11 +319,63 @@ func (s *MainWorkerServer) Process(ctx context.Context, req *proto.ProcessReques return nil, status.Error(codes.Unauthenticated, "invalid or expired token") } + // ── Schema management operations ────────────────────────────────────── + // SCHEMA_GET / SCHEMA_PUT / SCHEMA_DELETE operate on the JSON Schema + // template registry, not on entity data. + switch op { + case "SCHEMA_GET": + if s.validator == nil { + s.metrics.ProcessRequestsTotal.WithLabelValues(op, "error").Inc() + return nil, status.Error(codes.Unavailable, "schema management unavailable") + } + data, err := s.validator.GetTemplateData(req.GetSchemaId()) + if err != nil { + s.metrics.ProcessRequestsTotal.WithLabelValues(op, "error").Inc() + s.metrics.ProcessDurationSeconds.WithLabelValues(op, "error").Observe(time.Since(start).Seconds()) + return nil, status.Error(codes.NotFound, "schema not found") + } + s.metrics.ProcessRequestsTotal.WithLabelValues(op, "success").Inc() + s.metrics.ProcessDurationSeconds.WithLabelValues(op, "success").Observe(time.Since(start).Seconds()) + return &proto.ProcessResponse{Status: "OK", Result: data}, nil + + case "SCHEMA_PUT": + if s.validator == nil { + s.metrics.ProcessRequestsTotal.WithLabelValues(op, "error").Inc() + return nil, status.Error(codes.Unavailable, "schema management unavailable") + } + if err := s.validator.SaveTemplate(req.GetSchemaId(), req.GetPayload()); err != nil { + s.metrics.ProcessRequestsTotal.WithLabelValues(op, "error").Inc() + s.metrics.ProcessDurationSeconds.WithLabelValues(op, "error").Observe(time.Since(start).Seconds()) + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + s.metrics.ProcessRequestsTotal.WithLabelValues(op, "success").Inc() + s.metrics.ProcessDurationSeconds.WithLabelValues(op, "success").Observe(time.Since(start).Seconds()) + return &proto.ProcessResponse{Status: "OK"}, nil + + case "SCHEMA_DELETE": + if s.validator == nil { + s.metrics.ProcessRequestsTotal.WithLabelValues(op, "error").Inc() + return nil, status.Error(codes.Unavailable, "schema management unavailable") + } + if err := s.validator.DeleteTemplate(req.GetSchemaId()); err != nil { + s.metrics.ProcessRequestsTotal.WithLabelValues(op, "error").Inc() + s.metrics.ProcessDurationSeconds.WithLabelValues(op, "error").Observe(time.Since(start).Seconds()) + if errors.Is(err, schema.ErrSchemaNotFound) { + return nil, status.Error(codes.NotFound, err.Error()) + } + return nil, status.Error(codes.Internal, err.Error()) + } + s.metrics.ProcessRequestsTotal.WithLabelValues(op, "success").Inc() + s.metrics.ProcessDurationSeconds.WithLabelValues(op, "success").Observe(time.Since(start).Seconds()) + return &proto.ProcessResponse{Status: "OK"}, nil + } + + // ── Entity operations ───────────────────────────────────────────────── // Validate operation if op != "GET" && op != "PUT" && op != "DELETE" { s.metrics.ProcessRequestsTotal.WithLabelValues(op, "error").Inc() s.metrics.ProcessDurationSeconds.WithLabelValues(op, "error").Observe(time.Since(start).Seconds()) - return nil, status.Error(codes.InvalidArgument, "operation must be GET, PUT, or DELETE") + return nil, status.Error(codes.InvalidArgument, "operation must be GET, PUT, DELETE, SCHEMA_GET, SCHEMA_PUT, or SCHEMA_DELETE") } if op == "GET" { @@ -779,10 +832,11 @@ func (s *MainWorkerServer) handleAdminSchemas(w http.ResponseWriter, r *http.Req json.NewEncoder(w).Encode(schemas) //nolint:errcheck } -// handleSchema serves GET and PUT requests for /schema/{id}. +// handleSchema serves GET, PUT, and DELETE requests for /schema/{id}. // -// GET /schema/{id} — retrieve a schema JSON (no authentication required). -// PUT /schema/{id} — create or replace a schema (authentication required). +// GET /schema/{id} — retrieve a schema JSON (no authentication required). +// PUT /schema/{id} — create or replace a schema (write permission required). +// DELETE /schema/{id} — permanently delete a schema (write permission required). func (s *MainWorkerServer) handleSchema(w http.ResponseWriter, r *http.Request) { schemaID := strings.TrimPrefix(r.URL.Path, "/schema/") // Reject empty or path-traversal schema IDs. @@ -825,6 +879,21 @@ func (s *MainWorkerServer) handleSchema(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) //nolint:errcheck + case http.MethodDelete: + if !s.requirePermission(w, r, auth.PermWrite) { + return + } + if err := s.validator.DeleteTemplate(schemaID); err != nil { + if errors.Is(err, schema.ErrSchemaNotFound) { + http.Error(w, `{"error":"not_found"}`, http.StatusNotFound) + } else { + http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusInternalServerError) + } + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) //nolint:errcheck + default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } diff --git a/cmd/main-worker/server_test.go b/cmd/main-worker/server_test.go index 0ec35a5..0968d8b 100644 --- a/cmd/main-worker/server_test.go +++ b/cmd/main-worker/server_test.go @@ -486,6 +486,198 @@ func TestHandleSchema(t *testing.T) { }) } +func TestHandleSchemaDelete(t *testing.T) { + config := createTestConfigWithTempDir(t) + server, err := NewMainWorkerServer(config) + require.NoError(t, err) + require.NotNil(t, server.validator) + + ct, err := server.tokenManager.GenerateClientToken("test-client", []string{"read", "write"}) + require.NoError(t, err) + authHeader := "Bearer " + ct.Token + + schemaJSON := `{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}` + + // Pre-populate a schema to delete in these tests. + putReq := httptest.NewRequest(http.MethodPut, "/schema/todelete.v1", strings.NewReader(schemaJSON)) + putReq.Header.Set("Authorization", authHeader) + putW := httptest.NewRecorder() + server.handleSchema(putW, putReq) + require.Equal(t, http.StatusOK, putW.Code) + + t.Run("DELETE requires Authorization header", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/schema/todelete.v1", nil) + w := httptest.NewRecorder() + server.handleSchema(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("DELETE with write token removes schema", func(t *testing.T) { + // Re-create schema so we can delete it. + putReq2 := httptest.NewRequest(http.MethodPut, "/schema/todelete.v1", strings.NewReader(schemaJSON)) + putReq2.Header.Set("Authorization", authHeader) + putW2 := httptest.NewRecorder() + server.handleSchema(putW2, putReq2) + require.Equal(t, http.StatusOK, putW2.Code) + + req := httptest.NewRequest(http.MethodDelete, "/schema/todelete.v1", nil) + req.Header.Set("Authorization", authHeader) + w := httptest.NewRecorder() + server.handleSchema(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + var resp map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "ok", resp["status"]) + }) + + t.Run("GET returns 404 after DELETE", func(t *testing.T) { + // Ensure schema is gone. + req := httptest.NewRequest(http.MethodGet, "/schema/todelete.v1", nil) + w := httptest.NewRecorder() + server.handleSchema(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + }) + + t.Run("DELETE non-existent schema returns 404", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/schema/ghost.v99", nil) + req.Header.Set("Authorization", authHeader) + w := httptest.NewRecorder() + server.handleSchema(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + }) + + t.Run("DELETE with read-only token is forbidden", func(t *testing.T) { + readCT, err := server.tokenManager.GenerateClientToken("reader", []string{"read"}) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodDelete, "/schema/todelete.v1", nil) + req.Header.Set("Authorization", "Bearer "+readCT.Token) + w := httptest.NewRecorder() + server.handleSchema(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) + }) + + t.Run("DELETE rejects path traversal schema id", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/schema/../etc/passwd", nil) + req.Header.Set("Authorization", authHeader) + w := httptest.NewRecorder() + server.handleSchema(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("admin schemas list excludes deleted schema", func(t *testing.T) { + // Put a schema, then delete it, then verify it is absent. + putReq3 := httptest.NewRequest(http.MethodPut, "/schema/listed.v1", strings.NewReader(schemaJSON)) + putReq3.Header.Set("Authorization", authHeader) + putW3 := httptest.NewRecorder() + server.handleSchema(putW3, putReq3) + require.Equal(t, http.StatusOK, putW3.Code) + + delReq := httptest.NewRequest(http.MethodDelete, "/schema/listed.v1", nil) + delReq.Header.Set("Authorization", authHeader) + delW := httptest.NewRecorder() + server.handleSchema(delW, delReq) + require.Equal(t, http.StatusOK, delW.Code) + + listReq := httptest.NewRequest(http.MethodGet, "/admin/schemas", nil) + listW := httptest.NewRecorder() + server.handleAdminSchemas(listW, listReq) + require.Equal(t, http.StatusOK, listW.Code) + var schemas []string + require.NoError(t, json.Unmarshal(listW.Body.Bytes(), &schemas)) + assert.NotContains(t, schemas, "listed.v1") + }) +} + +func TestProcessSchemaOperations(t *testing.T) { + config := createTestConfigWithTempDir(t) + server, err := NewMainWorkerServer(config) + require.NoError(t, err) + require.NotNil(t, server.validator) + + // Get a valid worker token for Process calls. + _, pubKey, err := crypto.GenerateRSAKeyPair(2048) + require.NoError(t, err) + pubPEM, err := crypto.MarshalPublicKeyToPEM(pubKey) + require.NoError(t, err) + subResp, err := server.Subscribe(context.Background(), &proto.SubscribeRequest{ + WorkerId: "test-worker", + Pubkey: pubPEM, + }) + require.NoError(t, err) + workerToken := subResp.Token + + schemaJSON := []byte(`{"type":"object","properties":{"value":{"type":"number"}},"required":["value"]}`) + + t.Run("SCHEMA_PUT saves schema", func(t *testing.T) { + resp, err := server.Process(context.Background(), &proto.ProcessRequest{ + Operation: "SCHEMA_PUT", + SchemaId: "grpc.v1", + Payload: schemaJSON, + Token: workerToken, + }) + require.NoError(t, err) + assert.Equal(t, "OK", resp.Status) + }) + + t.Run("SCHEMA_GET retrieves saved schema", func(t *testing.T) { + resp, err := server.Process(context.Background(), &proto.ProcessRequest{ + Operation: "SCHEMA_GET", + SchemaId: "grpc.v1", + Token: workerToken, + }) + require.NoError(t, err) + assert.Equal(t, "OK", resp.Status) + assert.NotEmpty(t, resp.Result) + }) + + t.Run("SCHEMA_DELETE removes schema", func(t *testing.T) { + resp, err := server.Process(context.Background(), &proto.ProcessRequest{ + Operation: "SCHEMA_DELETE", + SchemaId: "grpc.v1", + Token: workerToken, + }) + require.NoError(t, err) + assert.Equal(t, "OK", resp.Status) + }) + + t.Run("SCHEMA_GET returns NotFound after delete", func(t *testing.T) { + _, err := server.Process(context.Background(), &proto.ProcessRequest{ + Operation: "SCHEMA_GET", + SchemaId: "grpc.v1", + Token: workerToken, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "NotFound") + }) + + t.Run("SCHEMA_DELETE non-existent schema returns NotFound", func(t *testing.T) { + _, err := server.Process(context.Background(), &proto.ProcessRequest{ + Operation: "SCHEMA_DELETE", + SchemaId: "ghost.v99", + Token: workerToken, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "NotFound") + }) + + t.Run("unknown operation returns InvalidArgument", func(t *testing.T) { + _, err := server.Process(context.Background(), &proto.ProcessRequest{ + Operation: "BOGUS", + SchemaId: "any", + Token: workerToken, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "InvalidArgument") + }) +} + func TestConcurrentSubscriptions(t *testing.T) { config := createTestConfig() server, err := NewMainWorkerServer(config) diff --git a/cmd/main-worker/static/app.html b/cmd/main-worker/static/app.html index 15a8209..d5cef40 100644 --- a/cmd/main-worker/static/app.html +++ b/cmd/main-worker/static/app.html @@ -277,6 +277,7 @@
+
@@ -728,9 +729,12 @@ wrap.innerHTML = '
No templates yet
'; } else { wrap.innerHTML = schemas.map(s => ` - `).join(''); +
+ + +
`).join(''); } } } catch {} @@ -769,6 +773,37 @@ } catch { showAlert(alertEl, 'error', 'Network error.'); } } + async function deleteSchema(id) { + if (!id) return; + if (!confirm('Permanently delete schema "' + id + '"?\n\nExisting entities are not affected.')) return; + const alertEl = document.getElementById('schemaAlert'); + alertEl.classList.add('hidden'); + try { + const r = await api('/schema/' + encodeURIComponent(id), { method: 'DELETE' }); + if (r.ok) { + // Clear editor if it was showing the deleted schema. + if (document.getElementById('schemaId').value === id) { + document.getElementById('schemaId').value = ''; + document.getElementById('schemaBody').value = ''; + } + showAlert(alertEl, 'success', 'Schema "' + id + '" deleted.'); + await loadTemplates(); + } else { + const d = await r.json().catch(() => ({})); + showAlert(alertEl, 'error', d.error || 'Delete failed.'); + } + } catch { showAlert(alertEl, 'error', 'Network error.'); } + } + + function deleteSchemaById() { + const id = document.getElementById('schemaId').value.trim(); + if (!id) { + showAlert(document.getElementById('schemaAlert'), 'error', 'Schema ID required.'); + return; + } + deleteSchema(id); + } + function exportPydantic() { const alertEl = document.getElementById('schemaAlert'); let schema; diff --git a/cmd/main-worker/static/css/delta.css b/cmd/main-worker/static/css/delta.css index 19bc6b7..97f57bb 100644 --- a/cmd/main-worker/static/css/delta.css +++ b/cmd/main-worker/static/css/delta.css @@ -370,6 +370,15 @@ input::placeholder, textarea::placeholder { color: var(--c-text-faint); } } .schema-list-item:hover { background: rgba(100,116,139,.1); color: var(--c-text); } .schema-list-item.active { background: rgba(56,189,248,.1); color: var(--c-accent-light); } +.schema-list-row { display: flex; align-items: center; gap: .375rem; } +.schema-list-btn { + flex: 1; display: flex; justify-content: space-between; + text-align: left; background: none; border: none; + padding: .375rem .25rem; cursor: pointer; color: inherit; + font-size: .825rem; font-family: monospace; color: var(--c-text-muted); +} +.schema-list-btn:hover { color: var(--c-text); } +.muted-text { color: #64748b; margin-left: auto; } /* ── Explorer ──────────────────────────────────────────────────────── */ .quick-btns { display: flex; flex-wrap: wrap; gap: .375rem; } diff --git a/docs/usage/api-reference.md b/docs/usage/api-reference.md index 72fe5b3..4c162e6 100644 --- a/docs/usage/api-reference.md +++ b/docs/usage/api-reference.md @@ -276,6 +276,105 @@ Content-Type: application/json | `401` | Missing or invalid Bearer token | | `403` | Token lacks `write` permission | +**Example:** + +```bash +curl -s -X PUT http://127.0.0.1:8080/schema/product.v1 \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "product.v1", + "type": "object", + "properties": { + "name": {"type": "string"}, + "price": {"type": "number", "minimum": 0} + }, + "required": ["name", "price"] + }' +``` + +--- + +### `DELETE /schema/{schemaID}` + +Permanently delete a JSON Schema. Requires `write` permission. + +Once deleted, entities that were previously validated against this schema are +unaffected (they remain stored), but future `PUT /entity/{schema_id}` requests +will no longer be validated until a new schema is uploaded. To update a schema +instead, use `PUT /schema/{schemaID}`. + +**Path parameter:** `schemaID` — the schema identifier. + +**Request:** + +```http +DELETE /schema/product.v1 +Authorization: Bearer +``` + +**Response `200 OK`:** + +```json +{"status": "ok"} +``` + +**Error responses:** + +| Code | Meaning | +|------|---------| +| `401` | Missing or invalid Bearer token | +| `403` | Token lacks `write` permission | +| `404` | Schema not found | + +**Example:** + +```bash +curl -s -X DELETE http://127.0.0.1:8080/schema/product.v1 \ + -H "Authorization: Bearer $TOKEN" +``` + +--- + +### gRPC Schema Operations + +All three schema operations are also available via the gRPC `Process` RPC. +Use the `operation` field of `ProcessRequest` with one of the schema-specific +operation strings: + +| Operation | Equivalent REST | `schema_id` | `payload` | +|-----------|-----------------|-------------|-----------| +| `SCHEMA_GET` | `GET /schema/{id}` | schema identifier | — | +| `SCHEMA_PUT` | `PUT /schema/{id}` | schema identifier | JSON Schema bytes | +| `SCHEMA_DELETE` | `DELETE /schema/{id}` | schema identifier | — | + +**Example (Go pseudo-code):** + +```go +// Retrieve a schema via gRPC +resp, err := client.Process(ctx, &proto.ProcessRequest{ + Operation: "SCHEMA_GET", + SchemaId: "product.v1", + Token: workerToken, +}) + +// Create / update a schema via gRPC +resp, err := client.Process(ctx, &proto.ProcessRequest{ + Operation: "SCHEMA_PUT", + SchemaId: "product.v1", + Payload: schemaJSON, + Token: workerToken, +}) + +// Delete a schema via gRPC +resp, err := client.Process(ctx, &proto.ProcessRequest{ + Operation: "SCHEMA_DELETE", + SchemaId: "product.v1", + Token: workerToken, +}) +``` + --- ## API Keys diff --git a/docs/usage/schemas.md b/docs/usage/schemas.md index 857bfdd..c67ec37 100644 --- a/docs/usage/schemas.md +++ b/docs/usage/schemas.md @@ -50,7 +50,7 @@ curl -s -X PUT http://127.0.0.1:8080/schema/chat.v1 \ ### Via the Web UI -Open **http://localhost:8080/** → **Schemas** tab → **New Schema** and paste your JSON Schema document. +Open **http://localhost:8080/** → **Templates** tab → type a Schema ID, enter your JSON Schema, and click **Save**. ### Directly on Disk @@ -74,6 +74,76 @@ The worker picks up the file automatically — no restart required. --- +## Updating a Schema + +Use `PUT /schema/{schemaID}` to replace an existing schema with a new definition. +The old schema is atomically overwritten; the in-memory cache is immediately +refreshed. Requires `write` permission. + +```bash +# Update chat.v1 to also require a session_id field +curl -s -X PUT http://127.0.0.1:8080/schema/chat.v1 \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "chat.v1", + "type": "object", + "properties": { + "session_id": {"type": "string"}, + "messages": {"type": "array"} + }, + "required": ["session_id", "messages"] + }' +``` + +> **Note:** Updating a schema does **not** re-validate existing stored entities. +> Only future `PUT /entity/{schema_id}` requests are validated against the new +> definition. Consider bumping the version (e.g., `chat.v2`) for breaking changes. + +**Via gRPC (`SCHEMA_PUT`):** + +```go +resp, err := client.Process(ctx, &proto.ProcessRequest{ + Operation: "SCHEMA_PUT", + SchemaId: "chat.v1", + Payload: updatedSchemaJSON, + Token: workerToken, +}) +``` + +--- + +## Deleting a Schema + +Use `DELETE /schema/{schemaID}` to permanently remove a schema template. +Requires `write` permission. + +```bash +curl -s -X DELETE http://127.0.0.1:8080/schema/chat.v1 \ + -H "Authorization: Bearer $TOKEN" +# → {"status":"ok"} +``` + +> **Note:** Deleting a schema does **not** delete the entities stored under that +> schema ID. Existing data is unaffected. Future entity writes to the same +> `schema_id` will succeed without validation until a new template is uploaded. + +**Via the Web UI:** open the **Templates** tab, click the schema you want to +remove from the list, and click the **Delete** button. + +**Via gRPC (`SCHEMA_DELETE`):** + +```go +resp, err := client.Process(ctx, &proto.ProcessRequest{ + Operation: "SCHEMA_DELETE", + SchemaId: "chat.v1", + Token: workerToken, +}) +``` + +--- + ## Listing and Retrieving Schemas ```bash @@ -84,6 +154,17 @@ curl http://127.0.0.1:8080/admin/schemas curl http://127.0.0.1:8080/schema/chat.v1 ``` +**Via gRPC (`SCHEMA_GET`):** + +```go +resp, err := client.Process(ctx, &proto.ProcessRequest{ + Operation: "SCHEMA_GET", + SchemaId: "chat.v1", + Token: workerToken, +}) +// resp.Result contains the raw JSON Schema bytes +``` + --- ## Schema Naming Convention diff --git a/pkg/schema/validator.go b/pkg/schema/validator.go index ba598a4..eb91269 100644 --- a/pkg/schema/validator.go +++ b/pkg/schema/validator.go @@ -2,6 +2,7 @@ package schema import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -11,6 +12,9 @@ import ( "github.com/xeipuuv/gojsonschema" ) +// ErrSchemaNotFound is returned when a requested schema template does not exist. +var ErrSchemaNotFound = errors.New("schema template not found") + // isValidSchemaID returns true when id is safe to use as a filename component. // It rejects empty strings, path separators (/ and \), and any string // containing ".." to prevent directory-traversal attacks. @@ -84,7 +88,7 @@ func (v *Validator) LoadTemplate(schemaID string) error { // Check if template file exists if _, err := os.Stat(templatePath); os.IsNotExist(err) { - return fmt.Errorf("schema template not found: %s", schemaID) + return fmt.Errorf("%w: %s", ErrSchemaNotFound, schemaID) } // Read template file @@ -245,6 +249,31 @@ func (v *Validator) GetTemplateData(schemaID string) ([]byte, error) { return os.ReadFile(templatePath) } +// DeleteTemplate removes a schema template from disk and the in-memory cache. +// Returns an error if the schema ID is invalid or the template does not exist. +func (v *Validator) DeleteTemplate(schemaID string) error { + if !isValidSchemaID(schemaID) { + return fmt.Errorf("invalid schema ID") + } + + templatePath := filepath.Join(v.templatesPath, schemaID+".json") + + // Remove from disk first; return a recognisable error if it is absent. + if _, err := os.Stat(templatePath); os.IsNotExist(err) { + return fmt.Errorf("%w: %s", ErrSchemaNotFound, schemaID) + } + if err := os.Remove(templatePath); err != nil { + return fmt.Errorf("failed to delete template file: %w", err) + } + + // Evict from the in-memory cache so stale data is never served. + v.mu.Lock() + delete(v.schemas, schemaID) + v.mu.Unlock() + + return nil +} + // SaveTemplate saves a schema template to disk. // This is useful for creating or updating templates programmatically. func (v *Validator) SaveTemplate(schemaID string, schemaData []byte) error { diff --git a/pkg/schema/validator_test.go b/pkg/schema/validator_test.go index 05b0997..b7e1e55 100644 --- a/pkg/schema/validator_test.go +++ b/pkg/schema/validator_test.go @@ -487,6 +487,66 @@ func TestSaveTemplate(t *testing.T) { }) } +func TestDeleteTemplate(t *testing.T) { + validator, templatesPath := setupTestValidator(t) + + t.Run("deletes existing template from disk and cache", func(t *testing.T) { + schemaData := createUserSchema() + require.NoError(t, validator.SaveTemplate("user.del.v1", schemaData)) + + // Confirm it exists on disk and in cache. + templatePath := filepath.Join(templatesPath, "user.del.v1.json") + assert.FileExists(t, templatePath) + assert.Contains(t, validator.GetLoadedSchemas(), "user.del.v1") + + err := validator.DeleteTemplate("user.del.v1") + assert.NoError(t, err) + + // File must be gone. + assert.NoFileExists(t, templatePath) + // Cache must be cleared. + assert.NotContains(t, validator.GetLoadedSchemas(), "user.del.v1") + }) + + t.Run("returns error for non-existent template", func(t *testing.T) { + err := validator.DeleteTemplate("ghost.v99") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") + }) + + t.Run("returns error for empty schema ID", func(t *testing.T) { + err := validator.DeleteTemplate("") + assert.Error(t, err) + }) + + t.Run("returns error for path traversal schema ID", func(t *testing.T) { + err := validator.DeleteTemplate("../etc/passwd") + assert.Error(t, err) + }) + + t.Run("deleted schema is absent from ListAvailableTemplates", func(t *testing.T) { + require.NoError(t, validator.SaveTemplate("listed.del.v1", createUserSchema())) + + listed, err := validator.ListAvailableTemplates() + require.NoError(t, err) + assert.Contains(t, listed, "listed.del.v1") + + require.NoError(t, validator.DeleteTemplate("listed.del.v1")) + + listed2, err := validator.ListAvailableTemplates() + require.NoError(t, err) + assert.NotContains(t, listed2, "listed.del.v1") + }) + + t.Run("validate returns error after template deleted", func(t *testing.T) { + require.NoError(t, validator.SaveTemplate("validate.del.v1", createUserSchema())) + require.NoError(t, validator.DeleteTemplate("validate.del.v1")) + + _, err := validator.Validate("validate.del.v1", []byte(`{"id":"1","email":"a@b.com"}`)) + assert.Error(t, err) + }) +} + func TestChatSchemaValidation(t *testing.T) { validator, templatesPath := setupTestValidator(t) diff --git a/tests/test_schema_api.py b/tests/test_schema_api.py index c30370f..44028c8 100644 --- a/tests/test_schema_api.py +++ b/tests/test_schema_api.py @@ -217,3 +217,104 @@ def test_put_schema_roundtrip(live_main_worker_schemas): get_r = requests.get(base + "/schema/roundtrip.v1", timeout=5) assert get_r.status_code == 200 assert get_r.json()["properties"]["score"]["type"] == "number" + + +def test_delete_schema_requires_auth(live_main_worker_schemas): + """DELETE /schema/{id} without a Bearer token must return 401.""" + base = live_main_worker_schemas["rest_url"] + headers = {"Authorization": f"Bearer {live_main_worker_schemas['token']}"} + + # Create the schema first so we know it exists. + requests.put(base + "/schema/delete_auth_test.v1", + json={"type": "object"}, headers=headers, timeout=5) + + r = requests.delete(base + "/schema/delete_auth_test.v1", timeout=5) + assert r.status_code == 401 + + +def test_delete_schema_with_auth_succeeds(live_main_worker_schemas): + """DELETE /schema/{id} with a valid Bearer token must return 200.""" + base = live_main_worker_schemas["rest_url"] + headers = {"Authorization": f"Bearer {live_main_worker_schemas['token']}"} + schema_id = "to_delete.v1" + + # Create it first. + put_r = requests.put(base + f"/schema/{schema_id}", + json={"type": "object"}, headers=headers, timeout=5) + assert put_r.status_code == 200 + + # Now delete it. + del_r = requests.delete(base + f"/schema/{schema_id}", + headers=headers, timeout=5) + assert del_r.status_code == 200 + assert del_r.json().get("status") == "ok" + + +def test_get_schema_returns_404_after_delete(live_main_worker_schemas): + """GET /schema/{id} must return 404 after the schema is deleted.""" + base = live_main_worker_schemas["rest_url"] + headers = {"Authorization": f"Bearer {live_main_worker_schemas['token']}"} + schema_id = "get_after_delete.v1" + + requests.put(base + f"/schema/{schema_id}", + json={"type": "object"}, headers=headers, timeout=5) + requests.delete(base + f"/schema/{schema_id}", + headers=headers, timeout=5) + + r = requests.get(base + f"/schema/{schema_id}", timeout=5) + assert r.status_code == 404 + + +def test_delete_schema_not_found(live_main_worker_schemas): + """DELETE /schema/{id} for a non-existent schema must return 404.""" + base = live_main_worker_schemas["rest_url"] + headers = {"Authorization": f"Bearer {live_main_worker_schemas['token']}"} + r = requests.delete(base + "/schema/does-not-exist-at-all.v99", + headers=headers, timeout=5) + assert r.status_code == 404 + + +def test_admin_schemas_excludes_deleted_schema(live_main_worker_schemas): + """After DELETE, /admin/schemas must no longer include the schema ID.""" + base = live_main_worker_schemas["rest_url"] + headers = {"Authorization": f"Bearer {live_main_worker_schemas['token']}"} + schema_id = "list_exclude.v1" + + requests.put(base + f"/schema/{schema_id}", + json={"type": "object"}, headers=headers, timeout=5) + listed_before = requests.get(base + "/admin/schemas", timeout=5).json() + assert schema_id in listed_before + + requests.delete(base + f"/schema/{schema_id}", + headers=headers, timeout=5) + + listed_after = requests.get(base + "/admin/schemas", timeout=5).json() + assert schema_id not in listed_after + + +def test_update_schema_roundtrip(live_main_worker_schemas): + """PUT (update) then GET must return the updated schema content.""" + base = live_main_worker_schemas["rest_url"] + headers = {"Authorization": f"Bearer {live_main_worker_schemas['token']}"} + schema_id = "update_test.v1" + + original = {"type": "object", "properties": {"name": {"type": "string"}}, + "required": ["name"]} + updated = {"type": "object", + "properties": {"name": {"type": "string"}, + "age": {"type": "integer"}}, + "required": ["name", "age"]} + + requests.put(base + f"/schema/{schema_id}", + json=original, headers=headers, timeout=5) + + put_r = requests.put(base + f"/schema/{schema_id}", + json=updated, headers=headers, timeout=5) + assert put_r.status_code == 200 + + get_r = requests.get(base + f"/schema/{schema_id}", timeout=5) + assert get_r.status_code == 200 + data = get_r.json() + assert "age" in data.get("properties", {}) + assert "age" in data.get("required", []) +