diff --git a/README.md b/README.md index 8d333e2..5b1b0cd 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,25 @@ curl -s -X POST "$BASE_URL/admin/schema/field-groups" \ }' ``` -### Step C: Submit a form (public) +### Step C: Publish schema snapshot (admin) + +Submit and query now use only `PUBLISHED` schema snapshots. + +Publish current group schema: + +```bash +curl -s -X POST "$BASE_URL/admin/schema/field-groups/task-form/publish" \ + -H "Authorization: Bearer $ADMIN_TOKEN" +``` + +Check version history: + +```bash +curl -s "$BASE_URL/admin/schema/entities/tasks/versions" \ + -H "Authorization: Bearer $ADMIN_TOKEN" +``` + +### Step D: Submit a form (public) ```bash curl -s -X POST "$BASE_URL/form" \ @@ -146,7 +164,7 @@ curl -s -X POST "$BASE_URL/form" \ }' ``` -### Step D: Query submitted data (public) +### Step E: Query submitted data (public) ```bash curl -s -X POST "$BASE_URL/query/tasks" \ @@ -168,9 +186,13 @@ Supported filter operators include: ## 6. Main Endpoints - `POST /api/form` submit dynamic data by group +- `POST /api/forms/{groupId}/submit` submit dynamic data with group in path - `POST /api/query/{entity}` query dynamic records - `GET/POST/PUT/DELETE /api/admin/schema/field-definitions*` manage fields - `GET/POST/PUT/DELETE /api/admin/schema/field-groups*` manage groups +- `POST /api/admin/schema/field-groups/{groupId}/publish` publish immutable schema snapshot +- `GET /api/admin/schema/entities/{entity}/versions` list schema versions +- `POST /api/admin/schema/entities/{entity}/deprecate` deprecate latest published schema ## 7. Configuration diff --git a/postman/dynapi.postman_collection.json b/postman/dynapi.postman_collection.json index 57a7580..ba55786 100644 --- a/postman/dynapi.postman_collection.json +++ b/postman/dynapi.postman_collection.json @@ -33,13 +33,18 @@ ], "item": [ { - "name": "Form API", + "name": "01 Start Here - Schema Bootstrap (Admin)", + "description": "Create schema definitions and groups first before submitting/querying data.", "item": [ { - "name": "POST /form - Submit Form", + "name": "POST /admin/schema/field-definitions - Create Field Definition", "request": { "method": "POST", "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminToken}}" + }, { "key": "Content-Type", "value": "application/json" @@ -47,7 +52,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"group\": \"task-form\",\n \"data\": {\n \"title\": \"Ship v1\",\n \"priority\": 1\n }\n}", + "raw": "{\n \"fieldName\": \"priority\",\n \"type\": \"NUMBER\",\n \"required\": false,\n \"min\": 1,\n \"max\": 5,\n \"version\": 1\n}", "options": { "raw": { "language": "json" @@ -55,15 +60,16 @@ } }, "url": { - "raw": "{{baseUrl}}/form", + "raw": "{{baseUrl}}/admin/schema/field-definitions", "host": [ "{{baseUrl}}" ], "path": [ - "form" + "admin", + "schema", + "field-definitions" ] - }, - "description": "Submit dynamic payload using a predefined field group." + } }, "response": [ { @@ -71,18 +77,32 @@ "status": "OK", "code": 200, "_postman_previewlanguage": "json", + "body": "{\n \"success\": true,\n \"message\": \"Created\",\n \"data\": {\n \"fieldName\": \"priority\",\n \"type\": \"NUMBER\",\n \"required\": false,\n \"min\": 1.0,\n \"max\": 5.0,\n \"regex\": null,\n \"enumValues\": null,\n \"requiredIf\": null,\n \"subFields\": null,\n \"version\": 1,\n \"permissions\": null\n },\n \"errors\": null,\n \"metadata\": null\n}" + }, + { + "name": "Bad Request - Illegal Argument", + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], - "body": "{\n \"success\": true,\n \"message\": \"Form submitted successfully\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + "body": "{\n \"success\": false,\n \"message\": \"Invalid request\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" }, { - "name": "Bad Request - Validation Error", - "status": "Bad Request", - "code": 400, + "name": "Forbidden - Missing or Non-Admin Token", + "status": "Forbidden", + "code": 403, + "_postman_previewlanguage": "json", + "body": "{\n \"timestamp\": \"2026-02-24T00:00:00.000+00:00\",\n \"status\": 403,\n \"error\": \"Forbidden\",\n \"path\": \"/api/admin/schema/field-definitions\"\n}" + }, + { + "name": "Not Found - Entity (If Raised by Service)", + "status": "Not Found", + "code": 404, "_postman_previewlanguage": "json", "header": [ { @@ -90,10 +110,49 @@ "value": "application/json" } ], - "body": "{\n \"success\": false,\n \"message\": \"Field is required\",\n \"data\": null,\n \"errors\": {\n \"title\": \"Field is required\"\n },\n \"metadata\": null\n}" + "body": "{\n \"success\": false,\n \"message\": \"Entity not found\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" }, { - "name": "Bad Request - Group Not Found", + "name": "Internal Server Error", + "status": "Internal Server Error", + "code": 500, + "_postman_previewlanguage": "json", + "body": "{\n \"success\": false,\n \"message\": \"Internal server error\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + } + ] + }, + { + "name": "GET /admin/schema/field-definitions - List Field Definitions", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminToken}}" + } + ], + "url": { + "raw": "{{baseUrl}}/admin/schema/field-definitions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "schema", + "field-definitions" + ] + } + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "body": "{\n \"success\": true,\n \"message\": \"Fetched\",\n \"data\": [\n {\n \"fieldName\": \"priority\",\n \"type\": \"NUMBER\"\n }\n ],\n \"errors\": null,\n \"metadata\": null\n}" + }, + { + "name": "Bad Request - Illegal Argument", "status": "Bad Request", "code": 400, "_postman_previewlanguage": "json", @@ -103,7 +162,14 @@ "value": "application/json" } ], - "body": "{\n \"success\": false,\n \"message\": \"Group not found\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + "body": "{\n \"success\": false,\n \"message\": \"Invalid request\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + }, + { + "name": "Forbidden - Missing or Non-Admin Token", + "status": "Forbidden", + "code": 403, + "_postman_previewlanguage": "json", + "body": "{\n \"timestamp\": \"2026-02-24T00:00:00.000+00:00\",\n \"status\": 403,\n \"error\": \"Forbidden\",\n \"path\": \"/api/admin/schema/field-definitions\"\n}" }, { "name": "Not Found - Entity (If Raised by Service)", @@ -123,34 +189,27 @@ "status": "Internal Server Error", "code": 500, "_postman_previewlanguage": "json", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], "body": "{\n \"success\": false,\n \"message\": \"Internal server error\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" } ] }, { - "name": "POST /forms/:groupId/submit - Submit Form", + "name": "POST /admin/schema/field-groups - Create Field Group", "request": { "method": "POST", "header": [ { - "key": "Content-Type", - "value": "application/json" + "key": "Authorization", + "value": "Bearer {{adminToken}}" }, { - "key": "Accept-Language", - "value": "en-US", - "disabled": true + "key": "Content-Type", + "value": "application/json" } ], "body": { "mode": "raw", - "raw": "{\n \"title\": \"Ship v1\",\n \"priority\": 1\n}", + "raw": "{\n \"name\": \"task-form\",\n \"entity\": \"tasks\",\n \"fieldNames\": [\"title\", \"priority\"],\n \"version\": 1\n}", "options": { "raw": { "language": "json" @@ -158,17 +217,16 @@ } }, "url": { - "raw": "{{baseUrl}}/forms/{{groupId}}/submit", + "raw": "{{baseUrl}}/admin/schema/field-groups", "host": [ "{{baseUrl}}" ], "path": [ - "forms", - "{{groupId}}", - "submit" + "admin", + "schema", + "field-groups" ] - }, - "description": "Submit dynamic payload using path-based group id. Public endpoint." + } }, "response": [ { @@ -176,18 +234,32 @@ "status": "OK", "code": 200, "_postman_previewlanguage": "json", + "body": "{\n \"success\": true,\n \"message\": \"Created\",\n \"data\": {\n \"name\": \"task-form\",\n \"entity\": \"tasks\",\n \"fieldNames\": [\"title\", \"priority\"]\n },\n \"errors\": null,\n \"metadata\": null\n}" + }, + { + "name": "Bad Request - Illegal Argument", + "status": "Bad Request", + "code": 400, + "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], - "body": "{\n \"success\": true,\n \"message\": \"Form submitted successfully\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + "body": "{\n \"success\": false,\n \"message\": \"Invalid request\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" }, { - "name": "Bad Request - Validation Error", - "status": "Bad Request", - "code": 400, + "name": "Forbidden - Missing or Non-Admin Token", + "status": "Forbidden", + "code": 403, + "_postman_previewlanguage": "json", + "body": "{\n \"timestamp\": \"2026-02-24T00:00:00.000+00:00\",\n \"status\": 403,\n \"error\": \"Forbidden\",\n \"path\": \"/api/admin/schema/field-groups\"\n}" + }, + { + "name": "Not Found - Entity (If Raised by Service)", + "status": "Not Found", + "code": 404, "_postman_previewlanguage": "json", "header": [ { @@ -195,10 +267,49 @@ "value": "application/json" } ], - "body": "{\n \"success\": false,\n \"message\": \"Field is required\",\n \"data\": null,\n \"errors\": {\n \"title\": \"Field is required\"\n },\n \"metadata\": null\n}" + "body": "{\n \"success\": false,\n \"message\": \"Entity not found\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" }, { - "name": "Bad Request - Group Not Found", + "name": "Internal Server Error", + "status": "Internal Server Error", + "code": 500, + "_postman_previewlanguage": "json", + "body": "{\n \"success\": false,\n \"message\": \"Internal server error\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + } + ] + }, + { + "name": "GET /admin/schema/field-groups - List Field Groups", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminToken}}" + } + ], + "url": { + "raw": "{{baseUrl}}/admin/schema/field-groups", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "schema", + "field-groups" + ] + } + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "body": "{\n \"success\": true,\n \"message\": \"Fetched\",\n \"data\": [\n {\n \"name\": \"task-form\",\n \"entity\": \"tasks\",\n \"fieldNames\": [\"title\", \"priority\"]\n }\n ],\n \"errors\": null,\n \"metadata\": null\n}" + }, + { + "name": "Bad Request - Illegal Argument", "status": "Bad Request", "code": 400, "_postman_previewlanguage": "json", @@ -208,7 +319,14 @@ "value": "application/json" } ], - "body": "{\n \"success\": false,\n \"message\": \"Group not found\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + "body": "{\n \"success\": false,\n \"message\": \"Invalid request\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + }, + { + "name": "Forbidden - Missing or Non-Admin Token", + "status": "Forbidden", + "code": 403, + "_postman_previewlanguage": "json", + "body": "{\n \"timestamp\": \"2026-02-24T00:00:00.000+00:00\",\n \"status\": 403,\n \"error\": \"Forbidden\",\n \"path\": \"/api/admin/schema/field-groups\"\n}" }, { "name": "Not Found - Entity (If Raised by Service)", @@ -228,96 +346,206 @@ "status": "Internal Server Error", "code": 500, "_postman_previewlanguage": "json", + "body": "{\n \"success\": false,\n \"message\": \"Internal server error\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + } + ] + }, + { + "name": "POST /admin/schema/field-groups/:groupId/publish - Publish Schema", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{adminToken}}" + } + ], + "url": { + "raw": "{{baseUrl}}/admin/schema/field-groups/{{fieldGroupId}}/publish", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "admin", + "schema", + "field-groups", + "{{fieldGroupId}}", + "publish" + ] + }, + "description": "Create and publish immutable schema snapshot for the group entity. Blocks breaking changes." + }, + "response": [ + { + "name": "Success", + "code": 200, + "status": "OK", + "_postman_previewlanguage": "json", + "body": "{\n \"success\": true,\n \"message\": \"Published\",\n \"data\": {\n \"entityName\": \"tasks\",\n \"groupName\": \"task-form\",\n \"version\": 1,\n \"status\": \"PUBLISHED\",\n \"publishedAt\": \"2026-02-24T00:00:00\"\n },\n \"errors\": null,\n \"metadata\": null\n}", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ] + }, + { + "name": "Bad Request - Breaking Change", + "code": 400, + "status": "Bad Request", + "_postman_previewlanguage": "json", + "body": "{\n \"success\": false,\n \"message\": \"Breaking change: removed field path 'priority'\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ] + }, + { + "name": "Forbidden - Missing or Non-Admin Token", + "code": 403, + "status": "Forbidden", + "_postman_previewlanguage": "json", + "body": "{\n \"timestamp\": \"2026-02-24T00:00:00.000+00:00\",\n \"status\": 403,\n \"error\": \"Forbidden\",\n \"path\": \"/api/admin/schema/field-groups/task-form/publish\"\n}" + }, + { + "name": "Internal Server Error", + "code": 500, + "status": "Internal Server Error", + "_postman_previewlanguage": "json", + "body": "{\n \"success\": false,\n \"message\": \"Internal server error\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}", "header": [ { "key": "Content-Type", "value": "application/json" } - ], - "body": "{\n \"success\": false,\n \"message\": \"Internal server error\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + ] } ] - } - ] - }, - { - "name": "Query API", - "item": [ + }, { - "name": "POST /query/:entity - Query Records", + "name": "GET /admin/schema/entities/:entity/versions - List Schema Versions", "request": { - "method": "POST", + "method": "GET", "header": [ { - "key": "Content-Type", - "value": "application/json" + "key": "Authorization", + "value": "Bearer {{adminToken}}" } ], - "body": { - "mode": "raw", - "raw": "{\n \"filters\": [\n { \"field\": \"priority\", \"operator\": \"gte\", \"value\": 1 }\n ],\n \"page\": 0,\n \"size\": 10,\n \"sortBy\": \"priority\",\n \"sortDirection\": \"DESC\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, "url": { - "raw": "{{baseUrl}}/query/{{entity}}", + "raw": "{{baseUrl}}/admin/schema/entities/{{entity}}/versions", "host": [ "{{baseUrl}}" ], "path": [ - "query", - "{{entity}}" + "admin", + "schema", + "entities", + "{{entity}}", + "versions" ] }, - "description": "Query dynamic records with guarded filters, sorting, and pagination." + "description": "List schema snapshots for an entity in descending version order." }, "response": [ { "name": "Success", - "status": "OK", "code": 200, + "status": "OK", "_postman_previewlanguage": "json", + "body": "{\n \"success\": true,\n \"message\": \"Fetched\",\n \"data\": [\n {\n \"entityName\": \"tasks\",\n \"groupName\": \"task-form\",\n \"version\": 2,\n \"status\": \"PUBLISHED\"\n },\n {\n \"entityName\": \"tasks\",\n \"groupName\": \"task-form\",\n \"version\": 1,\n \"status\": \"DEPRECATED\"\n }\n ],\n \"errors\": null,\n \"metadata\": null\n}", "header": [ { "key": "Content-Type", "value": "application/json" } - ], - "body": "{\n \"success\": true,\n \"message\": \"Query successful\",\n \"data\": {\n \"page\": 0,\n \"size\": 10,\n \"totalElements\": 1,\n \"content\": [\n {\n \"id\": \"66f0f4c2d31234ab12345678\",\n \"data\": {\n \"title\": \"Ship v1\",\n \"priority\": 2\n }\n }\n ],\n \"sortBy\": \"priority\",\n \"sortDirection\": \"DESC\"\n },\n \"errors\": null,\n \"metadata\": null\n}" + ] }, { - "name": "Bad Request - Size Exceeds Max", - "status": "Bad Request", + "name": "Bad Request - Entity Missing Published Schema", "code": 400, + "status": "Bad Request", "_postman_previewlanguage": "json", + "body": "{\n \"success\": false,\n \"message\": \"No published schema found for entity: tasks\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}", "header": [ { "key": "Content-Type", "value": "application/json" } - ], - "body": "{\n \"success\": false,\n \"message\": \"Size exceeds max page size: 100\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + ] }, { - "name": "Bad Request - Filter Field Not Allowed", - "status": "Bad Request", - "code": 400, + "name": "Forbidden - Missing or Non-Admin Token", + "code": 403, + "status": "Forbidden", + "_postman_previewlanguage": "json", + "body": "{\n \"timestamp\": \"2026-02-24T00:00:00.000+00:00\",\n \"status\": 403,\n \"error\": \"Forbidden\",\n \"path\": \"/api/admin/schema/entities/tasks/versions\"\n}" + }, + { + "name": "Internal Server Error", + "code": 500, + "status": "Internal Server Error", "_postman_previewlanguage": "json", + "body": "{\n \"success\": false,\n \"message\": \"Internal server error\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}", "header": [ { "key": "Content-Type", "value": "application/json" } + ] + } + ] + } + ] + }, + { + "name": "02 Submit Data (Public)", + "description": "Submit dynamic records after schema bootstrap.", + "item": [ + { + "name": "POST /forms/:groupId/submit - Submit Form", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept-Language", + "value": "en-US", + "disabled": true + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Ship v1\",\n \"priority\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{baseUrl}}/forms/{{groupId}}/submit", + "host": [ + "{{baseUrl}}" ], - "body": "{\n \"success\": false,\n \"message\": \"Filtering by field is not allowed: unknownField\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + "path": [ + "forms", + "{{groupId}}", + "submit" + ] }, + "description": "Submit dynamic payload using path-based group id. Public endpoint." + }, + "response": [ { - "name": "Bad Request - Operator Not Allowed", - "status": "Bad Request", - "code": 400, + "name": "Success", + "status": "OK", + "code": 200, "_postman_previewlanguage": "json", "header": [ { @@ -325,10 +553,10 @@ "value": "application/json" } ], - "body": "{\n \"success\": false,\n \"message\": \"Operator 'regex' is not allowed for field 'priority' of type NUMBER\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + "body": "{\n \"success\": true,\n \"message\": \"Form submitted successfully\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" }, { - "name": "Bad Request - Filter Depth Exceeded", + "name": "Bad Request - Validation Error", "status": "Bad Request", "code": 400, "_postman_previewlanguage": "json", @@ -338,10 +566,10 @@ "value": "application/json" } ], - "body": "{\n \"success\": false,\n \"message\": \"Filter depth exceeds max: 3\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + "body": "{\n \"success\": false,\n \"message\": \"Field is required\",\n \"data\": null,\n \"errors\": {\n \"title\": \"Field is required\"\n },\n \"metadata\": null\n}" }, { - "name": "Bad Request - Schema Group Not Found", + "name": "Bad Request - Group Not Found", "status": "Bad Request", "code": 400, "_postman_previewlanguage": "json", @@ -351,7 +579,7 @@ "value": "application/json" } ], - "body": "{\n \"success\": false,\n \"message\": \"Schema group not found for entity: tasks\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + "body": "{\n \"success\": false,\n \"message\": \"Group not found\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" }, { "name": "Not Found - Entity (If Raised by Service)", @@ -380,21 +608,12 @@ "body": "{\n \"success\": false,\n \"message\": \"Internal server error\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" } ] - } - ] - }, - { - "name": "Schema Admin - Field Definitions", - "item": [ + }, { - "name": "POST /admin/schema/field-definitions - Create Field Definition", + "name": "POST /form - Submit Form", "request": { "method": "POST", "header": [ - { - "key": "Authorization", - "value": "Bearer {{adminToken}}" - }, { "key": "Content-Type", "value": "application/json" @@ -402,7 +621,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"fieldName\": \"priority\",\n \"type\": \"NUMBER\",\n \"required\": false,\n \"min\": 1,\n \"max\": 5,\n \"version\": 1\n}", + "raw": "{\n \"group\": \"task-form\",\n \"data\": {\n \"title\": \"Ship v1\",\n \"priority\": 1\n }\n}", "options": { "raw": { "language": "json" @@ -410,16 +629,15 @@ } }, "url": { - "raw": "{{baseUrl}}/admin/schema/field-definitions", + "raw": "{{baseUrl}}/form", "host": [ "{{baseUrl}}" ], "path": [ - "admin", - "schema", - "field-definitions" + "form" ] - } + }, + "description": "Submit dynamic payload using a predefined field group." }, "response": [ { @@ -427,10 +645,16 @@ "status": "OK", "code": 200, "_postman_previewlanguage": "json", - "body": "{\n \"success\": true,\n \"message\": \"Created\",\n \"data\": {\n \"fieldName\": \"priority\",\n \"type\": \"NUMBER\",\n \"required\": false,\n \"min\": 1.0,\n \"max\": 5.0,\n \"regex\": null,\n \"enumValues\": null,\n \"requiredIf\": null,\n \"subFields\": null,\n \"version\": 1,\n \"permissions\": null\n },\n \"errors\": null,\n \"metadata\": null\n}" + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\n \"success\": true,\n \"message\": \"Form submitted successfully\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" }, { - "name": "Bad Request - Illegal Argument", + "name": "Bad Request - Validation Error", "status": "Bad Request", "code": 400, "_postman_previewlanguage": "json", @@ -440,14 +664,20 @@ "value": "application/json" } ], - "body": "{\n \"success\": false,\n \"message\": \"Invalid request\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + "body": "{\n \"success\": false,\n \"message\": \"Field is required\",\n \"data\": null,\n \"errors\": {\n \"title\": \"Field is required\"\n },\n \"metadata\": null\n}" }, { - "name": "Forbidden - Missing or Non-Admin Token", - "status": "Forbidden", - "code": 403, + "name": "Bad Request - Group Not Found", + "status": "Bad Request", + "code": 400, "_postman_previewlanguage": "json", - "body": "{\n \"timestamp\": \"2026-02-24T00:00:00.000+00:00\",\n \"status\": 403,\n \"error\": \"Forbidden\",\n \"path\": \"/api/admin/schema/field-definitions\"\n}" + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\n \"success\": false,\n \"message\": \"Group not found\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" }, { "name": "Not Found - Entity (If Raised by Service)", @@ -467,19 +697,27 @@ "status": "Internal Server Error", "code": 500, "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], "body": "{\n \"success\": false,\n \"message\": \"Internal server error\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" } ] - }, + } + ] + }, + { + "name": "03 Query Data (Public)", + "description": "Query submitted records using filters, sort, and pagination.", + "item": [ { - "name": "PUT /admin/schema/field-definitions/:id - Update Field Definition", + "name": "POST /query/:entity - Query Records", "request": { - "method": "PUT", + "method": "POST", "header": [ - { - "key": "Authorization", - "value": "Bearer {{adminToken}}" - }, { "key": "Content-Type", "value": "application/json" @@ -487,7 +725,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"type\": \"NUMBER\",\n \"required\": true,\n \"min\": 1,\n \"max\": 10\n}", + "raw": "{\n \"filters\": [\n { \"field\": \"priority\", \"operator\": \"gte\", \"value\": 1 }\n ],\n \"page\": 0,\n \"size\": 10,\n \"sortBy\": \"priority\",\n \"sortDirection\": \"DESC\"\n}", "options": { "raw": { "language": "json" @@ -495,17 +733,16 @@ } }, "url": { - "raw": "{{baseUrl}}/admin/schema/field-definitions/{{fieldDefinitionId}}", + "raw": "{{baseUrl}}/query/{{entity}}", "host": [ "{{baseUrl}}" ], "path": [ - "admin", - "schema", - "field-definitions", - "{{fieldDefinitionId}}" + "query", + "{{entity}}" ] - } + }, + "description": "Query dynamic records with guarded filters, sorting, and pagination." }, "response": [ { @@ -513,10 +750,16 @@ "status": "OK", "code": 200, "_postman_previewlanguage": "json", - "body": "{\n \"success\": true,\n \"message\": \"Updated\",\n \"data\": {\n \"fieldName\": \"priority\",\n \"type\": \"NUMBER\",\n \"required\": true\n },\n \"errors\": null,\n \"metadata\": null\n}" + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\n \"success\": true,\n \"message\": \"Query successful\",\n \"data\": {\n \"page\": 0,\n \"size\": 10,\n \"totalElements\": 1,\n \"content\": [\n {\n \"id\": \"66f0f4c2d31234ab12345678\",\n \"data\": {\n \"title\": \"Ship v1\",\n \"priority\": 2\n }\n }\n ],\n \"sortBy\": \"priority\",\n \"sortDirection\": \"DESC\"\n },\n \"errors\": null,\n \"metadata\": null\n}" }, { - "name": "Bad Request - Illegal Argument", + "name": "Bad Request - Size Exceeds Max", "status": "Bad Request", "code": 400, "_postman_previewlanguage": "json", @@ -526,19 +769,12 @@ "value": "application/json" } ], - "body": "{\n \"success\": false,\n \"message\": \"Invalid request\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" - }, - { - "name": "Forbidden - Missing or Non-Admin Token", - "status": "Forbidden", - "code": 403, - "_postman_previewlanguage": "json", - "body": "{\n \"timestamp\": \"2026-02-24T00:00:00.000+00:00\",\n \"status\": 403,\n \"error\": \"Forbidden\",\n \"path\": \"/api/admin/schema/field-definitions/priority\"\n}" + "body": "{\n \"success\": false,\n \"message\": \"Size exceeds max page size: 100\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" }, { - "name": "Not Found - Entity (If Raised by Service)", - "status": "Not Found", - "code": 404, + "name": "Bad Request - Filter Field Not Allowed", + "status": "Bad Request", + "code": 400, "_postman_previewlanguage": "json", "header": [ { @@ -546,50 +782,23 @@ "value": "application/json" } ], - "body": "{\n \"success\": false,\n \"message\": \"Entity not found\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + "body": "{\n \"success\": false,\n \"message\": \"Filtering by field is not allowed: unknownField\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" }, { - "name": "Internal Server Error", - "status": "Internal Server Error", - "code": 500, + "name": "Bad Request - Operator Not Allowed", + "status": "Bad Request", + "code": 400, "_postman_previewlanguage": "json", - "body": "{\n \"success\": false,\n \"message\": \"Internal server error\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" - } - ] - }, - { - "name": "DELETE /admin/schema/field-definitions/:id - Delete Field Definition", - "request": { - "method": "DELETE", - "header": [ - { - "key": "Authorization", - "value": "Bearer {{adminToken}}" - } - ], - "url": { - "raw": "{{baseUrl}}/admin/schema/field-definitions/{{fieldDefinitionId}}", - "host": [ - "{{baseUrl}}" + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } ], - "path": [ - "admin", - "schema", - "field-definitions", - "{{fieldDefinitionId}}" - ] - } - }, - "response": [ - { - "name": "Success", - "status": "OK", - "code": 200, - "_postman_previewlanguage": "json", - "body": "{\n \"success\": true,\n \"message\": \"Deleted\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + "body": "{\n \"success\": false,\n \"message\": \"Operator 'regex' is not allowed for field 'priority' of type NUMBER\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" }, { - "name": "Bad Request - Illegal Argument", + "name": "Bad Request - Filter Depth Exceeded", "status": "Bad Request", "code": 400, "_postman_previewlanguage": "json", @@ -599,14 +808,20 @@ "value": "application/json" } ], - "body": "{\n \"success\": false,\n \"message\": \"Invalid request\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + "body": "{\n \"success\": false,\n \"message\": \"Filter depth exceeds max: 3\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" }, { - "name": "Forbidden - Missing or Non-Admin Token", - "status": "Forbidden", - "code": 403, + "name": "Bad Request - Schema Group Not Found", + "status": "Bad Request", + "code": 400, "_postman_previewlanguage": "json", - "body": "{\n \"timestamp\": \"2026-02-24T00:00:00.000+00:00\",\n \"status\": 403,\n \"error\": \"Forbidden\",\n \"path\": \"/api/admin/schema/field-definitions/priority\"\n}" + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": "{\n \"success\": false,\n \"message\": \"Schema group not found for entity: tasks\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" }, { "name": "Not Found - Entity (If Raised by Service)", @@ -626,29 +841,55 @@ "status": "Internal Server Error", "code": 500, "_postman_previewlanguage": "json", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], "body": "{\n \"success\": false,\n \"message\": \"Internal server error\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" } ] - }, + } + ] + }, + { + "name": "04 Schema Maintenance (Admin)", + "description": "Update or delete existing schema definitions and groups.", + "item": [ { - "name": "GET /admin/schema/field-definitions - List Field Definitions", + "name": "PUT /admin/schema/field-definitions/:id - Update Field Definition", "request": { - "method": "GET", + "method": "PUT", "header": [ { "key": "Authorization", "value": "Bearer {{adminToken}}" + }, + { + "key": "Content-Type", + "value": "application/json" } ], + "body": { + "mode": "raw", + "raw": "{\n \"type\": \"NUMBER\",\n \"required\": true,\n \"min\": 1,\n \"max\": 10\n}", + "options": { + "raw": { + "language": "json" + } + } + }, "url": { - "raw": "{{baseUrl}}/admin/schema/field-definitions", + "raw": "{{baseUrl}}/admin/schema/field-definitions/{{fieldDefinitionId}}", "host": [ "{{baseUrl}}" ], "path": [ "admin", "schema", - "field-definitions" + "field-definitions", + "{{fieldDefinitionId}}" ] } }, @@ -658,7 +899,7 @@ "status": "OK", "code": 200, "_postman_previewlanguage": "json", - "body": "{\n \"success\": true,\n \"message\": \"Fetched\",\n \"data\": [\n {\n \"fieldName\": \"priority\",\n \"type\": \"NUMBER\"\n }\n ],\n \"errors\": null,\n \"metadata\": null\n}" + "body": "{\n \"success\": true,\n \"message\": \"Updated\",\n \"data\": {\n \"fieldName\": \"priority\",\n \"type\": \"NUMBER\",\n \"required\": true\n },\n \"errors\": null,\n \"metadata\": null\n}" }, { "name": "Bad Request - Illegal Argument", @@ -678,7 +919,7 @@ "status": "Forbidden", "code": 403, "_postman_previewlanguage": "json", - "body": "{\n \"timestamp\": \"2026-02-24T00:00:00.000+00:00\",\n \"status\": 403,\n \"error\": \"Forbidden\",\n \"path\": \"/api/admin/schema/field-definitions\"\n}" + "body": "{\n \"timestamp\": \"2026-02-24T00:00:00.000+00:00\",\n \"status\": 403,\n \"error\": \"Forbidden\",\n \"path\": \"/api/admin/schema/field-definitions/priority\"\n}" }, { "name": "Not Found - Entity (If Raised by Service)", @@ -701,16 +942,11 @@ "body": "{\n \"success\": false,\n \"message\": \"Internal server error\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" } ] - } - ] - }, - { - "name": "Schema Admin - Field Groups", - "item": [ + }, { - "name": "POST /admin/schema/field-groups - Create Field Group", + "name": "PUT /admin/schema/field-groups/:id - Update Field Group", "request": { - "method": "POST", + "method": "PUT", "header": [ { "key": "Authorization", @@ -723,7 +959,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"name\": \"task-form\",\n \"entity\": \"tasks\",\n \"fieldNames\": [\"title\", \"priority\"],\n \"version\": 1\n}", + "raw": "{\n \"entity\": \"tasks\",\n \"fieldNames\": [\"title\", \"priority\", \"status\"],\n \"version\": 2\n}", "options": { "raw": { "language": "json" @@ -731,14 +967,15 @@ } }, "url": { - "raw": "{{baseUrl}}/admin/schema/field-groups", + "raw": "{{baseUrl}}/admin/schema/field-groups/{{fieldGroupId}}", "host": [ "{{baseUrl}}" ], "path": [ "admin", "schema", - "field-groups" + "field-groups", + "{{fieldGroupId}}" ] } }, @@ -748,7 +985,7 @@ "status": "OK", "code": 200, "_postman_previewlanguage": "json", - "body": "{\n \"success\": true,\n \"message\": \"Created\",\n \"data\": {\n \"name\": \"task-form\",\n \"entity\": \"tasks\",\n \"fieldNames\": [\"title\", \"priority\"]\n },\n \"errors\": null,\n \"metadata\": null\n}" + "body": "{\n \"success\": true,\n \"message\": \"Updated\",\n \"data\": {\n \"name\": \"task-form\",\n \"entity\": \"tasks\",\n \"fieldNames\": [\"title\", \"priority\", \"status\"]\n },\n \"errors\": null,\n \"metadata\": null\n}" }, { "name": "Bad Request - Illegal Argument", @@ -768,7 +1005,7 @@ "status": "Forbidden", "code": 403, "_postman_previewlanguage": "json", - "body": "{\n \"timestamp\": \"2026-02-24T00:00:00.000+00:00\",\n \"status\": 403,\n \"error\": \"Forbidden\",\n \"path\": \"/api/admin/schema/field-groups\"\n}" + "body": "{\n \"timestamp\": \"2026-02-24T00:00:00.000+00:00\",\n \"status\": 403,\n \"error\": \"Forbidden\",\n \"path\": \"/api/admin/schema/field-groups/task-form\"\n}" }, { "name": "Not Found - Entity (If Raised by Service)", @@ -793,38 +1030,25 @@ ] }, { - "name": "PUT /admin/schema/field-groups/:id - Update Field Group", + "name": "DELETE /admin/schema/field-definitions/:id - Delete Field Definition", "request": { - "method": "PUT", + "method": "DELETE", "header": [ { "key": "Authorization", "value": "Bearer {{adminToken}}" - }, - { - "key": "Content-Type", - "value": "application/json" } ], - "body": { - "mode": "raw", - "raw": "{\n \"entity\": \"tasks\",\n \"fieldNames\": [\"title\", \"priority\", \"status\"],\n \"version\": 2\n}", - "options": { - "raw": { - "language": "json" - } - } - }, "url": { - "raw": "{{baseUrl}}/admin/schema/field-groups/{{fieldGroupId}}", + "raw": "{{baseUrl}}/admin/schema/field-definitions/{{fieldDefinitionId}}", "host": [ "{{baseUrl}}" ], "path": [ "admin", "schema", - "field-groups", - "{{fieldGroupId}}" + "field-definitions", + "{{fieldDefinitionId}}" ] } }, @@ -834,7 +1058,7 @@ "status": "OK", "code": 200, "_postman_previewlanguage": "json", - "body": "{\n \"success\": true,\n \"message\": \"Updated\",\n \"data\": {\n \"name\": \"task-form\",\n \"entity\": \"tasks\",\n \"fieldNames\": [\"title\", \"priority\", \"status\"]\n },\n \"errors\": null,\n \"metadata\": null\n}" + "body": "{\n \"success\": true,\n \"message\": \"Deleted\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" }, { "name": "Bad Request - Illegal Argument", @@ -854,7 +1078,7 @@ "status": "Forbidden", "code": 403, "_postman_previewlanguage": "json", - "body": "{\n \"timestamp\": \"2026-02-24T00:00:00.000+00:00\",\n \"status\": 403,\n \"error\": \"Forbidden\",\n \"path\": \"/api/admin/schema/field-groups/task-form\"\n}" + "body": "{\n \"timestamp\": \"2026-02-24T00:00:00.000+00:00\",\n \"status\": 403,\n \"error\": \"Forbidden\",\n \"path\": \"/api/admin/schema/field-definitions/priority\"\n}" }, { "name": "Not Found - Entity (If Raised by Service)", @@ -952,9 +1176,9 @@ ] }, { - "name": "GET /admin/schema/field-groups - List Field Groups", + "name": "POST /admin/schema/entities/:entity/deprecate - Deprecate Latest Published", "request": { - "method": "GET", + "method": "POST", "header": [ { "key": "Authorization", @@ -962,64 +1186,66 @@ } ], "url": { - "raw": "{{baseUrl}}/admin/schema/field-groups", + "raw": "{{baseUrl}}/admin/schema/entities/{{entity}}/deprecate", "host": [ "{{baseUrl}}" ], "path": [ "admin", "schema", - "field-groups" + "entities", + "{{entity}}", + "deprecate" ] - } + }, + "description": "Deprecate the latest published schema for the entity." }, "response": [ { "name": "Success", - "status": "OK", "code": 200, + "status": "OK", "_postman_previewlanguage": "json", - "body": "{\n \"success\": true,\n \"message\": \"Fetched\",\n \"data\": [\n {\n \"name\": \"task-form\",\n \"entity\": \"tasks\",\n \"fieldNames\": [\"title\", \"priority\"]\n }\n ],\n \"errors\": null,\n \"metadata\": null\n}" + "body": "{\n \"success\": true,\n \"message\": \"Deprecated\",\n \"data\": {\n \"entityName\": \"tasks\",\n \"version\": 2,\n \"status\": \"DEPRECATED\",\n \"deprecatedAt\": \"2026-02-24T00:00:00\"\n },\n \"errors\": null,\n \"metadata\": null\n}", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ] }, { - "name": "Bad Request - Illegal Argument", - "status": "Bad Request", + "name": "Bad Request - No Published Schema", "code": 400, + "status": "Bad Request", "_postman_previewlanguage": "json", + "body": "{\n \"success\": false,\n \"message\": \"No published schema found for entity: tasks\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}", "header": [ { "key": "Content-Type", "value": "application/json" } - ], - "body": "{\n \"success\": false,\n \"message\": \"Invalid request\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + ] }, { "name": "Forbidden - Missing or Non-Admin Token", - "status": "Forbidden", "code": 403, + "status": "Forbidden", "_postman_previewlanguage": "json", - "body": "{\n \"timestamp\": \"2026-02-24T00:00:00.000+00:00\",\n \"status\": 403,\n \"error\": \"Forbidden\",\n \"path\": \"/api/admin/schema/field-groups\"\n}" + "body": "{\n \"timestamp\": \"2026-02-24T00:00:00.000+00:00\",\n \"status\": 403,\n \"error\": \"Forbidden\",\n \"path\": \"/api/admin/schema/entities/tasks/deprecate\"\n}" }, { - "name": "Not Found - Entity (If Raised by Service)", - "status": "Not Found", - "code": 404, + "name": "Internal Server Error", + "code": 500, + "status": "Internal Server Error", "_postman_previewlanguage": "json", + "body": "{\n \"success\": false,\n \"message\": \"Internal server error\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}", "header": [ { "key": "Content-Type", "value": "application/json" } - ], - "body": "{\n \"success\": false,\n \"message\": \"Entity not found\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" - }, - { - "name": "Internal Server Error", - "status": "Internal Server Error", - "code": 500, - "_postman_previewlanguage": "json", - "body": "{\n \"success\": false,\n \"message\": \"Internal server error\",\n \"data\": null,\n \"errors\": null,\n \"metadata\": null\n}" + ] } ] } diff --git a/src/main/java/com/dynapi/controller/SchemaAdminController.java b/src/main/java/com/dynapi/controller/SchemaAdminController.java index 5685e9d..725e118 100644 --- a/src/main/java/com/dynapi/controller/SchemaAdminController.java +++ b/src/main/java/com/dynapi/controller/SchemaAdminController.java @@ -3,12 +3,16 @@ import com.dynapi.dto.ApiResponse; import com.dynapi.domain.model.FieldDefinition; import com.dynapi.domain.model.FieldGroup; +import com.dynapi.domain.model.SchemaVersion; import com.dynapi.repository.FieldDefinitionRepository; import com.dynapi.repository.FieldGroupRepository; +import com.dynapi.service.SchemaLifecycleService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import java.util.Comparator; import java.util.List; +import java.util.Optional; @RestController @RequestMapping("/admin/schema") @@ -16,6 +20,7 @@ public class SchemaAdminController { private final FieldDefinitionRepository fieldDefinitionRepository; private final FieldGroupRepository fieldGroupRepository; + private final SchemaLifecycleService schemaLifecycleService; // FieldDefinition CRUD @PostMapping("/field-definitions") @@ -25,13 +30,22 @@ public ApiResponse createFieldDefinition(@RequestBody FieldDefi @PutMapping("/field-definitions/{id}") public ApiResponse updateFieldDefinition(@PathVariable String id, @RequestBody FieldDefinition def) { + FieldDefinition current = findFieldDefinitionByName(id) + .orElseThrow(() -> new IllegalArgumentException("Field definition not found: " + id)); + fieldDefinitionRepository.deleteByFieldName(id); def.setFieldName(id); + def.setVersion(def.getVersion() == null + ? ((current.getVersion() == null ? 0 : current.getVersion()) + 1) + : def.getVersion()); return new ApiResponse<>(true, "Updated", fieldDefinitionRepository.save(def)); } @DeleteMapping("/field-definitions/{id}") public ApiResponse deleteFieldDefinition(@PathVariable String id) { - fieldDefinitionRepository.deleteById(id); + long deleted = fieldDefinitionRepository.deleteByFieldName(id); + if (deleted == 0) { + throw new IllegalArgumentException("Field definition not found: " + id); + } return new ApiResponse<>(true, "Deleted", null); } @@ -48,13 +62,22 @@ public ApiResponse createFieldGroup(@RequestBody FieldGroup group) { @PutMapping("/field-groups/{id}") public ApiResponse updateFieldGroup(@PathVariable String id, @RequestBody FieldGroup group) { + FieldGroup current = findFieldGroupByName(id) + .orElseThrow(() -> new IllegalArgumentException("Field group not found: " + id)); + fieldGroupRepository.deleteByName(id); group.setName(id); + group.setVersion(group.getVersion() == null + ? ((current.getVersion() == null ? 0 : current.getVersion()) + 1) + : group.getVersion()); return new ApiResponse<>(true, "Updated", fieldGroupRepository.save(group)); } @DeleteMapping("/field-groups/{id}") public ApiResponse deleteFieldGroup(@PathVariable String id) { - fieldGroupRepository.deleteById(id); + long deleted = fieldGroupRepository.deleteByName(id); + if (deleted == 0) { + throw new IllegalArgumentException("Field group not found: " + id); + } return new ApiResponse<>(true, "Deleted", null); } @@ -62,4 +85,51 @@ public ApiResponse deleteFieldGroup(@PathVariable String id) { public ApiResponse> getAllFieldGroups() { return new ApiResponse<>(true, "Fetched", fieldGroupRepository.findAll()); } + + @PostMapping("/field-groups/{groupId}/publish") + public ApiResponse publishFieldGroup(@PathVariable String groupId) { + SchemaVersion published = schemaLifecycleService.publish(groupId); + return new ApiResponse<>(true, "Published", published); + } + + @PostMapping("/entities/{entity}/deprecate") + public ApiResponse deprecateEntity(@PathVariable String entity) { + SchemaVersion deprecated = schemaLifecycleService.deprecate(entity); + return new ApiResponse<>(true, "Deprecated", deprecated); + } + + @GetMapping("/entities/{entity}/versions") + public ApiResponse> listEntityVersions(@PathVariable String entity) { + return new ApiResponse<>(true, "Fetched", schemaLifecycleService.listVersions(entity)); + } + + private Optional findFieldDefinitionByName(String fieldName) { + Optional byRepository = fieldDefinitionRepository.findTopByFieldNameOrderByVersionDesc(fieldName); + if (byRepository != null && byRepository.isPresent()) { + return byRepository; + } + return fieldDefinitionRepository.findAll() + .stream() + .filter(definition -> fieldName.equals(definition.getFieldName())) + .max(Comparator.comparingInt(this::fieldVersion)); + } + + private Optional findFieldGroupByName(String name) { + Optional byRepository = fieldGroupRepository.findTopByNameOrderByVersionDesc(name); + if (byRepository != null && byRepository.isPresent()) { + return byRepository; + } + return fieldGroupRepository.findAll() + .stream() + .filter(group -> name.equals(group.getName())) + .max(Comparator.comparingInt(this::groupVersion)); + } + + private int fieldVersion(FieldDefinition definition) { + return definition.getVersion() == null ? 0 : definition.getVersion(); + } + + private int groupVersion(FieldGroup group) { + return group.getVersion() == null ? 0 : group.getVersion(); + } } diff --git a/src/main/java/com/dynapi/domain/model/FieldDefinition.java b/src/main/java/com/dynapi/domain/model/FieldDefinition.java index 21ad2f2..c8095e1 100644 --- a/src/main/java/com/dynapi/domain/model/FieldDefinition.java +++ b/src/main/java/com/dynapi/domain/model/FieldDefinition.java @@ -3,12 +3,15 @@ import lombok.Data; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; import java.util.List; @Data @Document public class FieldDefinition { + @Id + private String id; @NotBlank private String fieldName; @NotNull diff --git a/src/main/java/com/dynapi/domain/model/FieldGroup.java b/src/main/java/com/dynapi/domain/model/FieldGroup.java index dec66e0..339e705 100644 --- a/src/main/java/com/dynapi/domain/model/FieldGroup.java +++ b/src/main/java/com/dynapi/domain/model/FieldGroup.java @@ -1,12 +1,15 @@ package com.dynapi.domain.model; import lombok.Data; +import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; import java.util.List; @Data @Document public class FieldGroup { + @Id + private String id; private String name; private String entity; private List fieldNames; diff --git a/src/main/java/com/dynapi/domain/model/SchemaLifecycleStatus.java b/src/main/java/com/dynapi/domain/model/SchemaLifecycleStatus.java new file mode 100644 index 0000000..7842890 --- /dev/null +++ b/src/main/java/com/dynapi/domain/model/SchemaLifecycleStatus.java @@ -0,0 +1,7 @@ +package com.dynapi.domain.model; + +public enum SchemaLifecycleStatus { + DRAFT, + PUBLISHED, + DEPRECATED +} diff --git a/src/main/java/com/dynapi/domain/model/SchemaVersion.java b/src/main/java/com/dynapi/domain/model/SchemaVersion.java index b271e9d..6f0e590 100644 --- a/src/main/java/com/dynapi/domain/model/SchemaVersion.java +++ b/src/main/java/com/dynapi/domain/model/SchemaVersion.java @@ -1,18 +1,24 @@ package com.dynapi.domain.model; import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + import java.time.LocalDateTime; import java.util.List; @Data +@Document(collection = "schema_versions") public class SchemaVersion { + @Id private String id; private String entityName; + private String groupName; private Integer version; - private LocalDateTime effectiveFrom; - private LocalDateTime effectiveTo; + private SchemaLifecycleStatus status; private List fields; - private String status; // DRAFT, ACTIVE, DEPRECATED + private LocalDateTime publishedAt; + private LocalDateTime deprecatedAt; private String createdBy; private LocalDateTime createdAt; private String modifiedBy; diff --git a/src/main/java/com/dynapi/domain/service/impl/SchemaVersionServiceImpl.java b/src/main/java/com/dynapi/domain/service/impl/SchemaVersionServiceImpl.java index 23d9da6..321c27d 100644 --- a/src/main/java/com/dynapi/domain/service/impl/SchemaVersionServiceImpl.java +++ b/src/main/java/com/dynapi/domain/service/impl/SchemaVersionServiceImpl.java @@ -1,6 +1,7 @@ package com.dynapi.domain.service.impl; import com.dynapi.domain.service.SchemaVersionService; +import com.dynapi.domain.model.SchemaLifecycleStatus; import com.dynapi.domain.model.SchemaVersion; import com.dynapi.domain.model.FieldDefinition; import com.dynapi.domain.event.DomainEvent; @@ -35,7 +36,7 @@ public SchemaVersion createNewVersion(String entityName, List f schemaVersion.setEntityName(entityName); schemaVersion.setVersion(newVersion); schemaVersion.setFields(new ArrayList<>(fields)); - schemaVersion.setStatus("DRAFT"); + schemaVersion.setStatus(SchemaLifecycleStatus.DRAFT); schemaVersion.setCreatedAt(LocalDateTime.now()); SchemaVersion saved = mongoTemplate.save(schemaVersion); @@ -54,7 +55,7 @@ public SchemaVersion createNewVersion(String entityName, List f @Override public SchemaVersion getActiveVersion(String entityName) { Query query = new Query(Criteria.where("entityName").is(entityName) - .and("status").is("ACTIVE")); + .and("status").is(SchemaLifecycleStatus.PUBLISHED)); return mongoTemplate.findOne(query, SchemaVersion.class); } @@ -69,16 +70,16 @@ public SchemaVersion getVersion(String entityName, Integer version) { public void activateVersion(String entityName, Integer version) { // Deactivate current active version Query activeQuery = new Query(Criteria.where("entityName").is(entityName) - .and("status").is("ACTIVE")); - Update deactivateUpdate = new Update().set("status", "DEPRECATED") - .set("effectiveTo", LocalDateTime.now()); + .and("status").is(SchemaLifecycleStatus.PUBLISHED)); + Update deactivateUpdate = new Update().set("status", SchemaLifecycleStatus.DEPRECATED) + .set("deprecatedAt", LocalDateTime.now()); mongoTemplate.updateFirst(activeQuery, deactivateUpdate, SchemaVersion.class); // Activate new version Query newVersionQuery = new Query(Criteria.where("entityName").is(entityName) .and("version").is(version)); - Update activateUpdate = new Update().set("status", "ACTIVE") - .set("effectiveFrom", LocalDateTime.now()); + Update activateUpdate = new Update().set("status", SchemaLifecycleStatus.PUBLISHED) + .set("publishedAt", LocalDateTime.now()); mongoTemplate.updateFirst(newVersionQuery, activateUpdate, SchemaVersion.class); // Publish event @@ -94,8 +95,8 @@ public void activateVersion(String entityName, Integer version) { public void deprecateVersion(String entityName, Integer version) { Query query = new Query(Criteria.where("entityName").is(entityName) .and("version").is(version)); - Update update = new Update().set("status", "DEPRECATED") - .set("effectiveTo", LocalDateTime.now()); + Update update = new Update().set("status", SchemaLifecycleStatus.DEPRECATED) + .set("deprecatedAt", LocalDateTime.now()); mongoTemplate.updateFirst(query, update, SchemaVersion.class); } diff --git a/src/main/java/com/dynapi/repository/FieldDefinitionRepository.java b/src/main/java/com/dynapi/repository/FieldDefinitionRepository.java index d657000..1e27fa6 100644 --- a/src/main/java/com/dynapi/repository/FieldDefinitionRepository.java +++ b/src/main/java/com/dynapi/repository/FieldDefinitionRepository.java @@ -3,5 +3,12 @@ import com.dynapi.domain.model.FieldDefinition; import org.springframework.data.mongodb.repository.MongoRepository; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + public interface FieldDefinitionRepository extends MongoRepository { + List findByFieldNameIn(Collection fieldNames); + Optional findTopByFieldNameOrderByVersionDesc(String fieldName); + long deleteByFieldName(String fieldName); } diff --git a/src/main/java/com/dynapi/repository/FieldGroupRepository.java b/src/main/java/com/dynapi/repository/FieldGroupRepository.java index 534eacf..8e54f5a 100644 --- a/src/main/java/com/dynapi/repository/FieldGroupRepository.java +++ b/src/main/java/com/dynapi/repository/FieldGroupRepository.java @@ -7,4 +7,6 @@ public interface FieldGroupRepository extends MongoRepository { Optional findByEntity(String entity); + Optional findTopByNameOrderByVersionDesc(String name); + long deleteByName(String name); } diff --git a/src/main/java/com/dynapi/repository/SchemaVersionRepository.java b/src/main/java/com/dynapi/repository/SchemaVersionRepository.java new file mode 100644 index 0000000..1475769 --- /dev/null +++ b/src/main/java/com/dynapi/repository/SchemaVersionRepository.java @@ -0,0 +1,16 @@ +package com.dynapi.repository; + +import com.dynapi.domain.model.SchemaLifecycleStatus; +import com.dynapi.domain.model.SchemaVersion; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.List; +import java.util.Optional; + +public interface SchemaVersionRepository extends MongoRepository { + Optional findTopByEntityNameAndStatusOrderByVersionDesc(String entityName, SchemaLifecycleStatus status); + + List findByEntityNameOrderByVersionDesc(String entityName); + + Optional findByEntityNameAndVersion(String entityName, Integer version); +} diff --git a/src/main/java/com/dynapi/service/DynamicQueryService.java b/src/main/java/com/dynapi/service/DynamicQueryService.java index 738dbc1..e820369 100644 --- a/src/main/java/com/dynapi/service/DynamicQueryService.java +++ b/src/main/java/com/dynapi/service/DynamicQueryService.java @@ -2,14 +2,12 @@ import com.dynapi.config.QueryGuardrailProperties; import com.dynapi.domain.model.FieldDefinition; -import com.dynapi.domain.model.FieldGroup; import com.dynapi.domain.model.FieldType; +import com.dynapi.domain.model.SchemaVersion; import com.dynapi.dto.DynamicQueryRequest; import com.dynapi.dto.FilterRule; import com.dynapi.dto.FormRecordDto; import com.dynapi.dto.PaginatedResponse; -import com.dynapi.repository.FieldDefinitionRepository; -import com.dynapi.repository.FieldGroupRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -25,7 +23,6 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; -import java.util.stream.StreamSupport; @Service @RequiredArgsConstructor @@ -41,8 +38,7 @@ public class DynamicQueryService { private static final Set OBJECT_ARRAY_OPERATORS = Set.of("eq", "ne"); private final MongoTemplate mongoTemplate; - private final FieldGroupRepository fieldGroupRepository; - private final FieldDefinitionRepository fieldDefinitionRepository; + private final SchemaLifecycleService schemaLifecycleService; private final QueryGuardrailProperties guardrailProperties; public PaginatedResponse query(String entity, DynamicQueryRequest request) { @@ -128,20 +124,11 @@ private Sort.Direction resolveSortDirection(String sortDirection) { } private Map loadFieldTypesByEntity(String entity) { - FieldGroup fieldGroup = fieldGroupRepository.findByEntity(entity) - .orElseThrow(() -> new IllegalArgumentException("Schema group not found for entity: " + entity)); + SchemaVersion publishedSchema = schemaLifecycleService.latestPublished(entity); + List definitions = publishedSchema.getFields(); - List fieldNames = fieldGroup.getFieldNames(); - if (fieldNames == null || fieldNames.isEmpty()) { - throw new IllegalArgumentException("Field group has no fields for entity: " + entity); - } - - List definitions = StreamSupport - .stream(fieldDefinitionRepository.findAllById(fieldNames).spliterator(), false) - .collect(Collectors.toList()); - - if (definitions.isEmpty()) { - throw new IllegalArgumentException("No field definitions found for entity: " + entity); + if (definitions == null || definitions.isEmpty()) { + throw new IllegalArgumentException("Published schema has no fields for entity: " + entity); } Map fieldTypeByPath = new HashMap<>(); diff --git a/src/main/java/com/dynapi/service/FormSubmissionService.java b/src/main/java/com/dynapi/service/FormSubmissionService.java index a306d7a..6dc4d4c 100644 --- a/src/main/java/com/dynapi/service/FormSubmissionService.java +++ b/src/main/java/com/dynapi/service/FormSubmissionService.java @@ -3,11 +3,12 @@ import com.dynapi.dto.FormSubmissionRequest; import com.dynapi.domain.model.FieldGroup; import com.dynapi.domain.model.FieldDefinition; +import com.dynapi.domain.model.SchemaVersion; import com.dynapi.repository.FieldGroupRepository; import java.util.List; +import java.util.Comparator; import org.springframework.context.MessageSource; import org.springframework.stereotype.Service; -import org.springframework.validation.Validator; import org.springframework.data.mongodb.core.MongoTemplate; import java.util.Locale; import java.util.Optional; @@ -17,21 +18,25 @@ @RequiredArgsConstructor public class FormSubmissionService { private final com.dynapi.audit.AuditPublisher auditPublisher; - private final com.dynapi.repository.FieldGroupRepository fieldGroupRepository; + private final FieldGroupRepository fieldGroupRepository; private final MongoTemplate mongoTemplate; private final MessageSource messageSource; - private final com.dynapi.repository.FieldDefinitionRepository fieldDefinitionRepository; private final com.dynapi.domain.validation.DynamicValidator dynamicValidator; + private final SchemaLifecycleService schemaLifecycleService; public void submitForm(FormSubmissionRequest request, Locale locale) { // 1. Load schema using group - Optional groupOpt = fieldGroupRepository.findById(request.getGroup()); + Optional groupOpt = resolveGroup(request.getGroup()); if (groupOpt.isEmpty()) { throw new IllegalArgumentException(messageSource.getMessage("error.group.notfound", null, locale)); } FieldGroup group = groupOpt.get(); - // 2. Load field definitions for this group - List schema = fieldDefinitionRepository.findAllById(group.getFieldNames()); + // 2. Load latest published schema snapshot for this entity + SchemaVersion publishedSchema = schemaLifecycleService.latestPublished(group.getEntity()); + List schema = publishedSchema.getFields(); + if (schema == null || schema.isEmpty()) { + throw new IllegalArgumentException("Published schema has no fields for entity: " + group.getEntity()); + } // 3. Validate input recursively and type-safe dynamicValidator.validate(request.getData(), schema, locale); // 4. Save form data to collection by entity @@ -40,4 +45,23 @@ public void submitForm(FormSubmissionRequest request, Locale locale) { // 5. Audit event auditPublisher.publish("FORM_SUBMIT", collectionName, request.getData()); } + + private Optional resolveGroup(String groupIdOrName) { + Optional byId = fieldGroupRepository.findById(groupIdOrName); + if (byId != null && byId.isPresent()) { + return byId; + } + Optional byName = fieldGroupRepository.findTopByNameOrderByVersionDesc(groupIdOrName); + if (byName != null && byName.isPresent()) { + return byName; + } + return fieldGroupRepository.findAll() + .stream() + .filter(group -> groupIdOrName.equals(group.getName()) || groupIdOrName.equals(group.getId())) + .max(Comparator.comparingInt(this::groupVersion)); + } + + private int groupVersion(FieldGroup group) { + return group.getVersion() == null ? 0 : group.getVersion(); + } } diff --git a/src/main/java/com/dynapi/service/SchemaLifecycleService.java b/src/main/java/com/dynapi/service/SchemaLifecycleService.java new file mode 100644 index 0000000..b994ccb --- /dev/null +++ b/src/main/java/com/dynapi/service/SchemaLifecycleService.java @@ -0,0 +1,373 @@ +package com.dynapi.service; + +import com.dynapi.domain.event.DomainEvent; +import com.dynapi.domain.model.FieldDefinition; +import com.dynapi.domain.model.FieldGroup; +import com.dynapi.domain.model.SchemaLifecycleStatus; +import com.dynapi.domain.model.SchemaVersion; +import com.dynapi.infrastructure.messaging.EventPublisher; +import com.dynapi.repository.FieldDefinitionRepository; +import com.dynapi.repository.FieldGroupRepository; +import com.dynapi.repository.SchemaVersionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class SchemaLifecycleService { + private final FieldGroupRepository fieldGroupRepository; + private final FieldDefinitionRepository fieldDefinitionRepository; + private final SchemaVersionRepository schemaVersionRepository; + private final EventPublisher eventPublisher; + + public SchemaVersion publish(String groupId) { + FieldGroup group = resolveGroup(groupId) + .orElseThrow(() -> new IllegalArgumentException("Field group not found: " + groupId)); + List draftFields = loadDraftFields(group); + + Optional latestPublishedOpt = schemaVersionRepository + .findTopByEntityNameAndStatusOrderByVersionDesc(group.getEntity(), SchemaLifecycleStatus.PUBLISHED); + latestPublishedOpt.ifPresent(previous -> ensureCompatible(previous, draftFields)); + + LocalDateTime now = LocalDateTime.now(); + String actor = currentActor(); + + latestPublishedOpt.ifPresent(previous -> { + previous.setStatus(SchemaLifecycleStatus.DEPRECATED); + previous.setDeprecatedAt(now); + previous.setModifiedBy(actor); + previous.setModifiedAt(now); + schemaVersionRepository.save(previous); + }); + + int nextVersion = latestPublishedOpt.map(version -> version.getVersion() + 1).orElse(1); + + SchemaVersion snapshot = new SchemaVersion(); + snapshot.setEntityName(group.getEntity()); + snapshot.setGroupName(group.getName()); + snapshot.setVersion(nextVersion); + snapshot.setStatus(SchemaLifecycleStatus.PUBLISHED); + snapshot.setFields(copyFieldDefinitions(draftFields)); + snapshot.setPublishedAt(now); + snapshot.setCreatedAt(now); + snapshot.setCreatedBy(actor); + snapshot.setModifiedAt(now); + snapshot.setModifiedBy(actor); + + SchemaVersion saved = schemaVersionRepository.save(snapshot); + publishSchemaEvent("SCHEMA_PUBLISHED", group.getEntity(), saved, Map.of( + "groupId", groupId, + "groupName", group.getName() == null ? "" : group.getName(), + "version", String.valueOf(saved.getVersion()) + )); + return saved; + } + + public SchemaVersion deprecate(String entity) { + SchemaVersion published = schemaVersionRepository + .findTopByEntityNameAndStatusOrderByVersionDesc(entity, SchemaLifecycleStatus.PUBLISHED) + .orElseThrow(() -> new IllegalArgumentException("No published schema found for entity: " + entity)); + + LocalDateTime now = LocalDateTime.now(); + published.setStatus(SchemaLifecycleStatus.DEPRECATED); + published.setDeprecatedAt(now); + published.setModifiedAt(now); + published.setModifiedBy(currentActor()); + + SchemaVersion saved = schemaVersionRepository.save(published); + publishSchemaEvent("SCHEMA_DEPRECATED", entity, saved, Map.of( + "version", String.valueOf(saved.getVersion()) + )); + return saved; + } + + public List listVersions(String entity) { + return schemaVersionRepository.findByEntityNameOrderByVersionDesc(entity); + } + + public SchemaVersion latestPublished(String entity) { + return schemaVersionRepository + .findTopByEntityNameAndStatusOrderByVersionDesc(entity, SchemaLifecycleStatus.PUBLISHED) + .orElseThrow(() -> new IllegalArgumentException("No published schema found for entity: " + entity)); + } + + private List loadDraftFields(FieldGroup group) { + List fieldNames = group.getFieldNames(); + if (fieldNames == null || fieldNames.isEmpty()) { + throw new IllegalArgumentException("Field group has no fields: " + group.getName()); + } + + List definitions = fieldDefinitionRepository.findByFieldNameIn(fieldNames); + if (definitions == null || definitions.isEmpty()) { + definitions = fieldDefinitionRepository.findAll() + .stream() + .filter(definition -> definition.getFieldName() != null && fieldNames.contains(definition.getFieldName())) + .toList(); + } + Map latestByName = new HashMap<>(); + for (FieldDefinition definition : definitions) { + if (definition == null || definition.getFieldName() == null) { + continue; + } + FieldDefinition existing = latestByName.get(definition.getFieldName()); + if (existing == null || fieldVersion(definition) >= fieldVersion(existing)) { + latestByName.put(definition.getFieldName(), definition); + } + } + + List ordered = new ArrayList<>(); + for (String fieldName : fieldNames) { + FieldDefinition definition = latestByName.get(fieldName); + if (definition == null) { + throw new IllegalArgumentException("Field definition not found: " + fieldName); + } + ordered.add(definition); + } + return ordered; + } + + private void ensureCompatible(SchemaVersion previousPublished, List candidateFields) { + Map previous = flattenDescriptors(previousPublished.getFields()); + Map candidate = flattenDescriptors(candidateFields); + + for (String previousPath : previous.keySet()) { + if (!candidate.containsKey(previousPath)) { + throw new IllegalArgumentException("Breaking change: removed field path '" + previousPath + "'"); + } + } + + for (Map.Entry candidateEntry : candidate.entrySet()) { + String path = candidateEntry.getKey(); + FieldDescriptor next = candidateEntry.getValue(); + FieldDescriptor prev = previous.get(path); + + if (prev == null) { + if (next.required) { + throw new IllegalArgumentException("Breaking change: new required field '" + path + "'"); + } + continue; + } + + if (prev.type != next.type) { + throw new IllegalArgumentException("Breaking change: type changed for field '" + path + "'"); + } + + if (!prev.required && next.required) { + throw new IllegalArgumentException("Breaking change: optional field became required '" + path + "'"); + } + + if (isEnumNarrowed(prev.enumValues, next.enumValues)) { + throw new IllegalArgumentException("Breaking change: enum narrowed for field '" + path + "'"); + } + + if (isMinTightened(prev.min, next.min)) { + throw new IllegalArgumentException("Breaking change: min tightened for field '" + path + "'"); + } + + if (isMaxTightened(prev.max, next.max)) { + throw new IllegalArgumentException("Breaking change: max tightened for field '" + path + "'"); + } + + if (isRegexChanged(prev.regex, next.regex)) { + throw new IllegalArgumentException("Breaking change: regex changed for field '" + path + "'"); + } + } + } + + private boolean isEnumNarrowed(List previous, List next) { + if (previous == null || previous.isEmpty()) { + return false; + } + if (next == null || next.isEmpty()) { + return false; + } + for (Object oldValue : previous) { + boolean exists = next.stream().anyMatch(candidate -> valuesEqual(candidate, oldValue)); + if (!exists) { + return true; + } + } + return false; + } + + private boolean isMinTightened(Double previous, Double next) { + if (previous == null && next == null) { + return false; + } + if (previous == null && next != null) { + return true; + } + if (previous != null && next == null) { + return false; + } + return next > previous; + } + + private boolean isMaxTightened(Double previous, Double next) { + if (previous == null && next == null) { + return false; + } + if (previous == null && next != null) { + return true; + } + if (previous != null && next == null) { + return false; + } + return next < previous; + } + + private boolean isRegexChanged(String previous, String next) { + String left = normalize(previous); + String right = normalize(next); + return !Objects.equals(left, right); + } + + private String normalize(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private boolean valuesEqual(Object left, Object right) { + if (left == null || right == null) { + return Objects.equals(left, right); + } + return String.valueOf(left).equals(String.valueOf(right)); + } + + private Optional resolveGroup(String groupIdOrName) { + Optional byId = fieldGroupRepository.findById(groupIdOrName); + if (byId != null && byId.isPresent()) { + return byId; + } + Optional byName = fieldGroupRepository.findTopByNameOrderByVersionDesc(groupIdOrName); + if (byName != null && byName.isPresent()) { + return byName; + } + return fieldGroupRepository.findAll() + .stream() + .filter(group -> groupIdOrName.equals(group.getName()) || groupIdOrName.equals(group.getId())) + .max(Comparator.comparingInt(this::groupVersion)); + } + + private int fieldVersion(FieldDefinition definition) { + return definition.getVersion() == null ? 0 : definition.getVersion(); + } + + private int groupVersion(FieldGroup group) { + return group.getVersion() == null ? 0 : group.getVersion(); + } + + private Map flattenDescriptors(List fields) { + Map descriptors = new LinkedHashMap<>(); + if (fields == null) { + return descriptors; + } + for (FieldDefinition field : fields) { + addDescriptor(field, "", descriptors); + } + return descriptors; + } + + private void addDescriptor(FieldDefinition field, String parentPath, Map out) { + String path = parentPath.isEmpty() + ? field.getFieldName() + : parentPath + "." + field.getFieldName(); + + out.put(path, new FieldDescriptor( + field.getType(), + field.isRequired(), + field.getEnumValues() == null ? null : new ArrayList<>(field.getEnumValues()), + field.getMin(), + field.getMax(), + field.getRegex() + )); + + if (field.getSubFields() == null || field.getSubFields().isEmpty()) { + return; + } + for (FieldDefinition child : field.getSubFields()) { + addDescriptor(child, path, out); + } + } + + private List copyFieldDefinitions(List source) { + if (source == null) { + return List.of(); + } + return source.stream().map(this::copyFieldDefinition).collect(Collectors.toList()); + } + + private FieldDefinition copyFieldDefinition(FieldDefinition source) { + FieldDefinition target = new FieldDefinition(); + target.setFieldName(source.getFieldName()); + target.setType(source.getType()); + target.setRequired(source.isRequired()); + target.setMin(source.getMin()); + target.setMax(source.getMax()); + target.setRegex(source.getRegex()); + target.setEnumValues(source.getEnumValues() == null ? null : new ArrayList<>(source.getEnumValues())); + target.setRequiredIf(copyRequiredIf(source.getRequiredIf())); + target.setSubFields(source.getSubFields() == null + ? null + : source.getSubFields().stream().map(this::copyFieldDefinition).collect(Collectors.toList())); + target.setVersion(source.getVersion()); + target.setPermissions(source.getPermissions() == null ? null : new ArrayList<>(source.getPermissions())); + return target; + } + + private FieldDefinition.RequiredIfRule copyRequiredIf(FieldDefinition.RequiredIfRule requiredIf) { + if (requiredIf == null) { + return null; + } + FieldDefinition.RequiredIfRule copy = new FieldDefinition.RequiredIfRule(); + copy.setField(requiredIf.getField()); + copy.setValue(requiredIf.getValue()); + copy.setOperator(requiredIf.getOperator()); + return copy; + } + + private void publishSchemaEvent(String eventType, String entity, Object payload, Map metadata) { + DomainEvent event = new DomainEvent<>(); + event.setEventType(eventType); + event.setEntityName(entity); + event.setTimestamp(LocalDateTime.now()); + event.setUserId(currentActor()); + event.setPayload(payload); + event.setMetadata(new HashMap<>(metadata)); + eventPublisher.publishSchemaChange(event); + } + + private String currentActor() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || authentication.getName() == null || authentication.getName().isBlank()) { + return "system"; + } + return authentication.getName(); + } + + private record FieldDescriptor( + com.dynapi.domain.model.FieldType type, + boolean required, + List enumValues, + Double min, + Double max, + String regex + ) { + } +} diff --git a/src/test/java/com/dynapi/integration/SchemaAdminControllerSecurityIntegrationTest.java b/src/test/java/com/dynapi/integration/SchemaAdminControllerSecurityIntegrationTest.java index 8818450..bd48dad 100644 --- a/src/test/java/com/dynapi/integration/SchemaAdminControllerSecurityIntegrationTest.java +++ b/src/test/java/com/dynapi/integration/SchemaAdminControllerSecurityIntegrationTest.java @@ -4,9 +4,12 @@ import com.dynapi.DynapiApplication; import com.dynapi.domain.model.FieldDefinition; import com.dynapi.domain.model.FieldGroup; +import com.dynapi.domain.model.SchemaLifecycleStatus; +import com.dynapi.domain.model.SchemaVersion; import com.dynapi.exception.GlobalExceptionHandler; import com.dynapi.repository.FieldDefinitionRepository; import com.dynapi.repository.FieldGroupRepository; +import com.dynapi.service.SchemaLifecycleService; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; @@ -32,9 +35,12 @@ import javax.crypto.SecretKey; import java.util.List; +import java.time.LocalDateTime; +import java.util.Optional; import java.util.stream.Stream; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -60,6 +66,9 @@ class SchemaAdminControllerSecurityIntegrationTest { @MockBean private FieldGroupRepository fieldGroupRepository; + @MockBean + private SchemaLifecycleService schemaLifecycleService; + @Value("${security.jwt.secret}") private String jwtSecret; @@ -69,8 +78,28 @@ void setUp() { .thenAnswer(invocation -> invocation.getArgument(0)); when(fieldGroupRepository.save(any(FieldGroup.class))) .thenAnswer(invocation -> invocation.getArgument(0)); + FieldDefinition existingField = new FieldDefinition(); + existingField.setId("field-id"); + existingField.setFieldName("age"); + when(fieldDefinitionRepository.findTopByFieldNameOrderByVersionDesc(anyString())) + .thenReturn(Optional.of(existingField)); + when(fieldDefinitionRepository.deleteByFieldName(anyString())).thenReturn(1L); + + FieldGroup existingGroup = new FieldGroup(); + existingGroup.setId("group-id"); + existingGroup.setName("profile"); + when(fieldGroupRepository.findTopByNameOrderByVersionDesc(anyString())) + .thenReturn(Optional.of(existingGroup)); + when(fieldGroupRepository.deleteByName(anyString())).thenReturn(1L); + when(fieldDefinitionRepository.findAll()).thenReturn(List.of()); when(fieldGroupRepository.findAll()).thenReturn(List.of()); + when(schemaLifecycleService.publish(anyString())) + .thenReturn(schemaVersion("users", 1, SchemaLifecycleStatus.PUBLISHED)); + when(schemaLifecycleService.deprecate(anyString())) + .thenReturn(schemaVersion("users", 1, SchemaLifecycleStatus.DEPRECATED)); + when(schemaLifecycleService.listVersions(anyString())) + .thenReturn(List.of(schemaVersion("users", 1, SchemaLifecycleStatus.PUBLISHED))); } @ParameterizedTest @@ -151,17 +180,30 @@ private static Stream adminSchemaRequests() { } """), Arguments.of("DELETE", "/api/admin/schema/field-groups/profile", null), - Arguments.of("GET", "/api/admin/schema/field-groups", null) + Arguments.of("GET", "/api/admin/schema/field-groups", null), + Arguments.of("POST", "/api/admin/schema/field-groups/profile/publish", null), + Arguments.of("POST", "/api/admin/schema/entities/users/deprecate", null), + Arguments.of("GET", "/api/admin/schema/entities/users/versions", null) ); } + private static SchemaVersion schemaVersion(String entity, int version, SchemaLifecycleStatus status) { + SchemaVersion schemaVersion = new SchemaVersion(); + schemaVersion.setEntityName(entity); + schemaVersion.setVersion(version); + schemaVersion.setStatus(status); + schemaVersion.setCreatedAt(LocalDateTime.now()); + return schemaVersion; + } + @TestConfiguration static class SchemaAdminControllerTestConfig { @Bean SchemaAdminController schemaAdminController( FieldDefinitionRepository fieldDefinitionRepository, - FieldGroupRepository fieldGroupRepository) { - return new SchemaAdminController(fieldDefinitionRepository, fieldGroupRepository); + FieldGroupRepository fieldGroupRepository, + SchemaLifecycleService schemaLifecycleService) { + return new SchemaAdminController(fieldDefinitionRepository, fieldGroupRepository, schemaLifecycleService); } @Bean diff --git a/src/test/java/com/dynapi/service/DynamicQueryServiceTest.java b/src/test/java/com/dynapi/service/DynamicQueryServiceTest.java index b8808a2..5261153 100644 --- a/src/test/java/com/dynapi/service/DynamicQueryServiceTest.java +++ b/src/test/java/com/dynapi/service/DynamicQueryServiceTest.java @@ -2,14 +2,13 @@ import com.dynapi.config.QueryGuardrailProperties; import com.dynapi.domain.model.FieldDefinition; -import com.dynapi.domain.model.FieldGroup; +import com.dynapi.domain.model.SchemaLifecycleStatus; +import com.dynapi.domain.model.SchemaVersion; import com.dynapi.domain.model.FieldType; import com.dynapi.dto.DynamicQueryRequest; import com.dynapi.dto.FilterRule; import com.dynapi.dto.FormRecordDto; import com.dynapi.dto.PaginatedResponse; -import com.dynapi.repository.FieldDefinitionRepository; -import com.dynapi.repository.FieldGroupRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -18,9 +17,9 @@ import org.springframework.data.mongodb.core.MongoTemplate; import java.util.ArrayList; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; -import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -38,10 +37,7 @@ class DynamicQueryServiceTest { private MongoTemplate mongoTemplate; @Mock - private FieldGroupRepository fieldGroupRepository; - - @Mock - private FieldDefinitionRepository fieldDefinitionRepository; + private SchemaLifecycleService schemaLifecycleService; private DynamicQueryService dynamicQueryService; @@ -54,24 +50,24 @@ void setUp() { dynamicQueryService = new DynamicQueryService( mongoTemplate, - fieldGroupRepository, - fieldDefinitionRepository, + schemaLifecycleService, guardrails ); - FieldGroup group = new FieldGroup(); - group.setName("task-group"); - group.setEntity("tasks"); - group.setFieldNames(List.of("title", "priority", "profile")); - lenient().when(fieldGroupRepository.findByEntity("tasks")).thenReturn(Optional.of(group)); - FieldDefinition title = field("title", FieldType.STRING); FieldDefinition priority = field("priority", FieldType.NUMBER); FieldDefinition profile = field("profile", FieldType.OBJECT); FieldDefinition age = field("age", FieldType.NUMBER); profile.setSubFields(List.of(age)); - lenient().when(fieldDefinitionRepository.findAllById(group.getFieldNames())) - .thenReturn(List.of(title, priority, profile)); + + SchemaVersion published = new SchemaVersion(); + published.setEntityName("tasks"); + published.setVersion(1); + published.setStatus(SchemaLifecycleStatus.PUBLISHED); + published.setCreatedAt(LocalDateTime.now()); + published.setFields(List.of(title, priority, profile)); + + lenient().when(schemaLifecycleService.latestPublished("tasks")).thenReturn(published); } @Test diff --git a/src/test/java/com/dynapi/service/FormSubmissionServiceTest.java b/src/test/java/com/dynapi/service/FormSubmissionServiceTest.java new file mode 100644 index 0000000..0665526 --- /dev/null +++ b/src/test/java/com/dynapi/service/FormSubmissionServiceTest.java @@ -0,0 +1,146 @@ +package com.dynapi.service; + +import com.dynapi.domain.model.FieldDefinition; +import com.dynapi.domain.model.FieldGroup; +import com.dynapi.domain.model.FieldType; +import com.dynapi.domain.model.SchemaLifecycleStatus; +import com.dynapi.domain.model.SchemaVersion; +import com.dynapi.dto.FormSubmissionRequest; +import com.dynapi.repository.FieldGroupRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.MessageSource; +import org.springframework.data.mongodb.core.MongoTemplate; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FormSubmissionServiceTest { + + @Mock + private com.dynapi.audit.AuditPublisher auditPublisher; + @Mock + private FieldGroupRepository fieldGroupRepository; + @Mock + private MongoTemplate mongoTemplate; + @Mock + private MessageSource messageSource; + @Mock + private com.dynapi.domain.validation.DynamicValidator dynamicValidator; + @Mock + private SchemaLifecycleService schemaLifecycleService; + + private FormSubmissionService formSubmissionService; + + @BeforeEach + void setUp() { + formSubmissionService = new FormSubmissionService( + auditPublisher, + fieldGroupRepository, + mongoTemplate, + messageSource, + dynamicValidator, + schemaLifecycleService + ); + } + + @Test + void submitForm_throwsWhenNoPublishedSchemaExists() { + FieldGroup group = new FieldGroup(); + group.setName("task-form"); + group.setEntity("tasks"); + + FormSubmissionRequest request = new FormSubmissionRequest(); + request.setGroup("task-form"); + request.setData(Map.of("title", "Ship v1")); + + when(fieldGroupRepository.findById("task-form")).thenReturn(Optional.of(group)); + when(schemaLifecycleService.latestPublished("tasks")) + .thenThrow(new IllegalArgumentException("No published schema found for entity: tasks")); + + assertThrows( + IllegalArgumentException.class, + () -> formSubmissionService.submitForm(request, Locale.US) + ); + } + + @Test + void submitForm_validatesAgainstPublishedSchemaAndPersists() { + FieldGroup group = new FieldGroup(); + group.setName("task-form"); + group.setEntity("tasks"); + + FieldDefinition title = new FieldDefinition(); + title.setFieldName("title"); + title.setType(FieldType.STRING); + title.setRequired(true); + + SchemaVersion published = new SchemaVersion(); + published.setEntityName("tasks"); + published.setVersion(1); + published.setStatus(SchemaLifecycleStatus.PUBLISHED); + published.setCreatedAt(LocalDateTime.now()); + published.setFields(List.of(title)); + + Map payload = Map.of("title", "Ship v1"); + FormSubmissionRequest request = new FormSubmissionRequest(); + request.setGroup("task-form"); + request.setData(payload); + + when(fieldGroupRepository.findById("task-form")).thenReturn(Optional.of(group)); + when(schemaLifecycleService.latestPublished("tasks")).thenReturn(published); + + formSubmissionService.submitForm(request, Locale.US); + + verify(dynamicValidator).validate(eq(payload), eq(List.of(title)), any(Locale.class)); + verify(mongoTemplate).save(payload, "tasks"); + verify(auditPublisher).publish("FORM_SUBMIT", "tasks", payload); + } + + @Test + void submitForm_resolvesGroupByNameWhenIdLookupMisses() { + FieldGroup group = new FieldGroup(); + group.setName("task-form"); + group.setEntity("tasks"); + + FieldDefinition title = new FieldDefinition(); + title.setFieldName("title"); + title.setType(FieldType.STRING); + title.setRequired(true); + + SchemaVersion published = new SchemaVersion(); + published.setEntityName("tasks"); + published.setVersion(1); + published.setStatus(SchemaLifecycleStatus.PUBLISHED); + published.setCreatedAt(LocalDateTime.now()); + published.setFields(List.of(title)); + + Map payload = Map.of("title", "Ship v1"); + FormSubmissionRequest request = new FormSubmissionRequest(); + request.setGroup("task-form"); + request.setData(payload); + + when(fieldGroupRepository.findById("task-form")).thenReturn(Optional.empty()); + when(fieldGroupRepository.findTopByNameOrderByVersionDesc("task-form")).thenReturn(Optional.of(group)); + when(schemaLifecycleService.latestPublished("tasks")).thenReturn(published); + + formSubmissionService.submitForm(request, Locale.US); + + verify(dynamicValidator).validate(eq(payload), eq(List.of(title)), any(Locale.class)); + verify(mongoTemplate).save(payload, "tasks"); + verify(auditPublisher).publish("FORM_SUBMIT", "tasks", payload); + } +} diff --git a/src/test/java/com/dynapi/service/SchemaLifecycleServiceTest.java b/src/test/java/com/dynapi/service/SchemaLifecycleServiceTest.java new file mode 100644 index 0000000..474b3f9 --- /dev/null +++ b/src/test/java/com/dynapi/service/SchemaLifecycleServiceTest.java @@ -0,0 +1,306 @@ +package com.dynapi.service; + +import com.dynapi.domain.model.FieldDefinition; +import com.dynapi.domain.model.FieldGroup; +import com.dynapi.domain.model.FieldType; +import com.dynapi.domain.model.SchemaLifecycleStatus; +import com.dynapi.domain.model.SchemaVersion; +import com.dynapi.infrastructure.messaging.EventPublisher; +import com.dynapi.repository.FieldDefinitionRepository; +import com.dynapi.repository.FieldGroupRepository; +import com.dynapi.repository.SchemaVersionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SchemaLifecycleServiceTest { + + @Mock + private FieldGroupRepository fieldGroupRepository; + @Mock + private FieldDefinitionRepository fieldDefinitionRepository; + @Mock + private SchemaVersionRepository schemaVersionRepository; + @Mock + private EventPublisher eventPublisher; + + private SchemaLifecycleService schemaLifecycleService; + + @BeforeEach + void setUp() { + schemaLifecycleService = new SchemaLifecycleService( + fieldGroupRepository, + fieldDefinitionRepository, + schemaVersionRepository, + eventPublisher + ); + + lenient().when(schemaVersionRepository.save(any(SchemaVersion.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + void publish_createsVersionOneWhenNoPublishedSchemaExists() { + FieldGroup group = group("task-form", "tasks", List.of("title")); + FieldDefinition title = field("title", FieldType.STRING, true); + + when(fieldGroupRepository.findById("task-form")).thenReturn(Optional.of(group)); + when(fieldDefinitionRepository.findByFieldNameIn(group.getFieldNames())).thenReturn(List.of(title)); + when(schemaVersionRepository.findTopByEntityNameAndStatusOrderByVersionDesc("tasks", SchemaLifecycleStatus.PUBLISHED)) + .thenReturn(Optional.empty()); + + SchemaVersion published = schemaLifecycleService.publish("task-form"); + + assertEquals(1, published.getVersion()); + assertEquals(SchemaLifecycleStatus.PUBLISHED, published.getStatus()); + assertEquals("tasks", published.getEntityName()); + assertEquals("task-form", published.getGroupName()); + assertEquals(1, published.getFields().size()); + + verify(eventPublisher).publishSchemaChange(any()); + verify(schemaVersionRepository, times(1)).save(any(SchemaVersion.class)); + } + + @Test + void publish_resolvesGroupByNameWhenIdLookupMisses() { + FieldGroup group = group("task-form", "tasks", List.of("title")); + FieldDefinition title = field("title", FieldType.STRING, true); + + when(fieldGroupRepository.findById("task-form")).thenReturn(Optional.empty()); + when(fieldGroupRepository.findTopByNameOrderByVersionDesc("task-form")).thenReturn(Optional.of(group)); + when(fieldDefinitionRepository.findByFieldNameIn(group.getFieldNames())).thenReturn(List.of(title)); + when(schemaVersionRepository.findTopByEntityNameAndStatusOrderByVersionDesc("tasks", SchemaLifecycleStatus.PUBLISHED)) + .thenReturn(Optional.empty()); + + SchemaVersion published = schemaLifecycleService.publish("task-form"); + + assertEquals(1, published.getVersion()); + assertEquals("task-form", published.getGroupName()); + } + + @Test + void publish_createsNextVersionAndDeprecatesPreviousWhenCompatible() { + FieldGroup group = group("task-form", "tasks", List.of("title", "description")); + FieldDefinition title = field("title", FieldType.STRING, true); + FieldDefinition description = field("description", FieldType.STRING, false); + + SchemaVersion previous = schemaVersion(1, List.of(field("title", FieldType.STRING, true))); + + when(fieldGroupRepository.findById("task-form")).thenReturn(Optional.of(group)); + when(fieldDefinitionRepository.findByFieldNameIn(group.getFieldNames())).thenReturn(List.of(title, description)); + when(schemaVersionRepository.findTopByEntityNameAndStatusOrderByVersionDesc("tasks", SchemaLifecycleStatus.PUBLISHED)) + .thenReturn(Optional.of(previous)); + + SchemaVersion published = schemaLifecycleService.publish("task-form"); + + assertEquals(2, published.getVersion()); + assertEquals(SchemaLifecycleStatus.PUBLISHED, published.getStatus()); + + ArgumentCaptor saveCaptor = ArgumentCaptor.forClass(SchemaVersion.class); + verify(schemaVersionRepository, times(2)).save(saveCaptor.capture()); + List saved = saveCaptor.getAllValues(); + assertEquals(SchemaLifecycleStatus.DEPRECATED, saved.get(0).getStatus()); + assertEquals(SchemaLifecycleStatus.PUBLISHED, saved.get(1).getStatus()); + } + + @Test + void publish_rejectsRemovedFieldPath() { + runBreakingPublishScenario( + List.of(field("title", FieldType.STRING, true), field("priority", FieldType.NUMBER, false)), + List.of(field("title", FieldType.STRING, true)), + "removed field path" + ); + } + + @Test + void publish_rejectsTypeChange() { + runBreakingPublishScenario( + List.of(field("priority", FieldType.NUMBER, false)), + List.of(field("priority", FieldType.STRING, false)), + "type changed" + ); + } + + @Test + void publish_rejectsOptionalFieldBecomingRequired() { + runBreakingPublishScenario( + List.of(field("title", FieldType.STRING, false)), + List.of(field("title", FieldType.STRING, true)), + "became required" + ); + } + + @Test + void publish_rejectsNewRequiredField() { + runBreakingPublishScenario( + List.of(field("title", FieldType.STRING, true)), + List.of(field("title", FieldType.STRING, true), field("age", FieldType.NUMBER, true)), + "new required field" + ); + } + + @Test + void publish_rejectsEnumNarrowing() { + FieldDefinition previous = field("status", FieldType.STRING, true); + previous.setEnumValues(List.of("NEW", "DONE")); + + FieldDefinition candidate = field("status", FieldType.STRING, true); + candidate.setEnumValues(List.of("NEW")); + + runBreakingPublishScenario( + List.of(previous), + List.of(candidate), + "enum narrowed" + ); + } + + @Test + void publish_rejectsMinTightening() { + FieldDefinition previous = field("score", FieldType.NUMBER, false); + previous.setMin(1.0); + + FieldDefinition candidate = field("score", FieldType.NUMBER, false); + candidate.setMin(2.0); + + runBreakingPublishScenario( + List.of(previous), + List.of(candidate), + "min tightened" + ); + } + + @Test + void publish_rejectsMaxTightening() { + FieldDefinition previous = field("score", FieldType.NUMBER, false); + previous.setMax(10.0); + + FieldDefinition candidate = field("score", FieldType.NUMBER, false); + candidate.setMax(9.0); + + runBreakingPublishScenario( + List.of(previous), + List.of(candidate), + "max tightened" + ); + } + + @Test + void publish_rejectsRegexChange() { + FieldDefinition previous = field("code", FieldType.STRING, false); + previous.setRegex("^[A-Z]+$"); + + FieldDefinition candidate = field("code", FieldType.STRING, false); + candidate.setRegex("^[A-Z0-9]+$"); + + runBreakingPublishScenario( + List.of(previous), + List.of(candidate), + "regex changed" + ); + } + + @Test + void publish_rejectsRegexAddedToExistingField() { + FieldDefinition previous = field("code", FieldType.STRING, false); + previous.setRegex(null); + + FieldDefinition candidate = field("code", FieldType.STRING, false); + candidate.setRegex("^[A-Z]+$"); + + runBreakingPublishScenario( + List.of(previous), + List.of(candidate), + "regex changed" + ); + } + + @Test + void deprecate_rejectsWhenNoPublishedSchemaExists() { + when(schemaVersionRepository.findTopByEntityNameAndStatusOrderByVersionDesc("tasks", SchemaLifecycleStatus.PUBLISHED)) + .thenReturn(Optional.empty()); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> schemaLifecycleService.deprecate("tasks") + ); + + assertTrue(ex.getMessage().contains("No published schema")); + } + + @Test + void deprecate_marksLatestPublishedAsDeprecated() { + SchemaVersion published = schemaVersion(3, List.of(field("title", FieldType.STRING, true))); + when(schemaVersionRepository.findTopByEntityNameAndStatusOrderByVersionDesc("tasks", SchemaLifecycleStatus.PUBLISHED)) + .thenReturn(Optional.of(published)); + + SchemaVersion deprecated = schemaLifecycleService.deprecate("tasks"); + + assertEquals(SchemaLifecycleStatus.DEPRECATED, deprecated.getStatus()); + verify(eventPublisher).publishSchemaChange(any()); + } + + private void runBreakingPublishScenario( + List previousFields, + List candidateFields, + String expectedMessage + ) { + FieldGroup group = group("task-form", "tasks", candidateFields.stream().map(FieldDefinition::getFieldName).toList()); + SchemaVersion previous = schemaVersion(1, previousFields); + + when(fieldGroupRepository.findById("task-form")).thenReturn(Optional.of(group)); + when(fieldDefinitionRepository.findByFieldNameIn(group.getFieldNames())).thenReturn(candidateFields); + when(schemaVersionRepository.findTopByEntityNameAndStatusOrderByVersionDesc("tasks", SchemaLifecycleStatus.PUBLISHED)) + .thenReturn(Optional.of(previous)); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> schemaLifecycleService.publish("task-form") + ); + + assertTrue(ex.getMessage().contains(expectedMessage), "Expected message to contain: " + expectedMessage); + } + + private FieldGroup group(String name, String entity, List fieldNames) { + FieldGroup group = new FieldGroup(); + group.setName(name); + group.setEntity(entity); + group.setFieldNames(fieldNames); + return group; + } + + private SchemaVersion schemaVersion(int version, List fields) { + SchemaVersion previous = new SchemaVersion(); + previous.setEntityName("tasks"); + previous.setGroupName("task-form"); + previous.setVersion(version); + previous.setStatus(SchemaLifecycleStatus.PUBLISHED); + previous.setFields(fields); + return previous; + } + + private FieldDefinition field(String name, FieldType type, boolean required) { + FieldDefinition definition = new FieldDefinition(); + definition.setFieldName(name); + definition.setType(type); + definition.setRequired(required); + return definition; + } +}