diff --git a/.gitignore b/.gitignore index 91a800a..2635ac1 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ nb-configuration.xml # Local environment .env +.mcp.json # Plugin directory /.quarkus/cli/plugins/ diff --git a/docs/superpowers/plans/2026-05-06-signal-endpoint.md b/docs/superpowers/plans/2026-05-06-signal-endpoint.md new file mode 100644 index 0000000..dae2d2b --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-signal-endpoint.md @@ -0,0 +1,969 @@ +# Signal Endpoint Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement POST /api/v1/cases/{caseId}/signals endpoint for sending external signals to case instances + +**Architecture:** New SignalResource JAX-RS endpoint that validates requests and delegates to CaseHubRuntime.signal(). Returns 202 Accepted on success, 404 for missing cases, 400 for validation errors. No service layer - direct runtime injection. + +**Tech Stack:** Quarkus REST, Mutiny (reactive), casehub-engine CaseHubRuntime, JUnit 5, RestAssured, Mockito + +--- + +## File Structure + +**New files to create:** +- `src/main/java/io/casehub/flow/rest/dto/SendSignalRequest.java` — Request DTO for signal payload +- `src/main/java/io/casehub/flow/rest/dto/SignalResponse.java` — Response DTO for 202 Accepted +- `src/main/java/io/casehub/flow/rest/SignalResource.java` — JAX-RS endpoint +- `src/test/java/io/casehub/flow/rest/SignalResourceTest.java` — Unit tests with mocked runtime +- `src/test/java/io/casehub/flow/rest/SignalResourceIT.java` — Integration tests with real runtime + +**Existing files to reference:** +- `src/main/java/io/casehub/flow/rest/CaseInstanceResource.java` — Pattern for error handling, ProblemDetail +- `src/main/java/io/casehub/flow/exception/CaseInstanceNotFoundException.java` — Exception for 404 errors +- `src/test/java/io/casehub/flow/rest/CaseInstanceResourceTest.java` — Test pattern reference + +**Note on exceptions:** The design spec references `CaseNotFoundException`, but the codebase uses `CaseInstanceNotFoundException`. We'll use the existing `CaseInstanceNotFoundException` for consistency. + +--- + +## Task 1: Create SendSignalRequest DTO + +**Files:** +- Create: `src/main/java/io/casehub/flow/rest/dto/SendSignalRequest.java` + +- [ ] **Step 1: Create SendSignalRequest record** + +Create file with copyright header and record definition: + +```java +/* + * Copyright 2026-Present The Case Hub Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.casehub.flow.rest.dto; + +/** + * Request payload for sending signal to case instance. + * + * @param path dot-notation path in CaseContext (e.g., "approvals.user", "orders[0].status") + * @param value signal data to set at path + */ +public record SendSignalRequest(String path, Object value) {} +``` + +- [ ] **Step 2: Verify compilation** + +Run: `./mvnw compile -DskipTests` + +Expected: SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/io/casehub/flow/rest/dto/SendSignalRequest.java +git commit -m "feat: add SendSignalRequest DTO for signal endpoint" +``` + +--- + +## Task 2: Create SignalResponse DTO + +**Files:** +- Create: `src/main/java/io/casehub/flow/rest/dto/SignalResponse.java` + +- [ ] **Step 1: Create SignalResponse record** + +Create file with copyright header and record definition: + +```java +/* + * Copyright 2026-Present The Case Hub Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.casehub.flow.rest.dto; + +import java.util.UUID; + +/** + * Response for signal acceptance. + * + * @param caseId case instance UUID + * @param status acceptance status ("accepted") + * @param message human-readable message + */ +public record SignalResponse(UUID caseId, String status, String message) {} +``` + +- [ ] **Step 2: Verify compilation** + +Run: `./mvnw compile -DskipTests` + +Expected: SUCCESS + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/io/casehub/flow/rest/dto/SignalResponse.java +git commit -m "feat: add SignalResponse DTO for signal endpoint" +``` + +--- + +## Task 3: Create SignalResource skeleton with validation test + +**Files:** +- Create: `src/main/java/io/casehub/flow/rest/SignalResource.java` +- Create: `src/test/java/io/casehub/flow/rest/SignalResourceTest.java` + +- [ ] **Step 1: Write test for null request body validation** + +Create test file: + +```java +/* + * Copyright 2026-Present The Case Hub Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.casehub.flow.rest; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.http.ContentType; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class SignalResourceTest { + + @Test + void sendSignal_nullRequestBody_returns400() { + given() + .contentType(ContentType.JSON) + .when() + .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID()) + .then() + .statusCode(400) + .body("title", equalTo("Invalid request")) + .body("status", equalTo(400)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_nullRequestBody_returns400` + +Expected: FAIL - endpoint not found (404) + +- [ ] **Step 3: Create minimal SignalResource** + +Create resource file: + +```java +/* + * Copyright 2026-Present The Case Hub Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.casehub.flow.rest; + +import io.casehub.flow.rest.dto.SendSignalRequest; +import io.smallrye.mutiny.Uni; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.UUID; + +/** + * REST API for sending signals to case instances. + * + *

Endpoints: + * + *

+ */ +@Path("/api/v1/cases/{caseId}/signals") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class SignalResource { + + @POST + public Uni sendSignal( + @PathParam("caseId") UUID caseId, SendSignalRequest request) { + + if (request == null || request.path() == null || request.value() == null) { + return Uni.createFrom() + .item( + Response.status(400) + .entity( + new ProblemDetail( + "Invalid request", + 400, + "Request body, path, and value are required")) + .build()); + } + + return Uni.createFrom().item(Response.status(202).build()); + } + + /** + * RFC 7807 Problem Details for HTTP APIs. + * + * @param title a short, human-readable summary of the problem type + * @param status the HTTP status code + * @param detail a human-readable explanation specific to this occurrence + */ + public record ProblemDetail(String title, int status, String detail) {} +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_nullRequestBody_returns400` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/io/casehub/flow/rest/SignalResource.java src/test/java/io/casehub/flow/rest/SignalResourceTest.java +git commit -m "feat: add SignalResource with null request validation" +``` + +--- + +## Task 4: Add null path validation + +**Files:** +- Modify: `src/test/java/io/casehub/flow/rest/SignalResourceTest.java` + +- [ ] **Step 1: Write test for null path** + +Add test method to SignalResourceTest: + +```java +@Test +void sendSignal_nullPath_returns400() { + given() + .contentType(ContentType.JSON) + .body( + """ + { + "path": null, + "value": {"approved": true} + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID()) + .then() + .statusCode(400) + .body("title", equalTo("Invalid request")) + .body("status", equalTo(400)); +} +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_nullPath_returns400` + +Expected: PASS (already handled by existing validation) + +- [ ] **Step 3: Commit** + +```bash +git add src/test/java/io/casehub/flow/rest/SignalResourceTest.java +git commit -m "test: add null path validation test" +``` + +--- + +## Task 5: Add null value validation + +**Files:** +- Modify: `src/test/java/io/casehub/flow/rest/SignalResourceTest.java` + +- [ ] **Step 1: Write test for null value** + +Add test method to SignalResourceTest: + +```java +@Test +void sendSignal_nullValue_returns400() { + given() + .contentType(ContentType.JSON) + .body( + """ + { + "path": "approvals.user", + "value": null + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID()) + .then() + .statusCode(400) + .body("title", equalTo("Invalid request")); +} +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_nullValue_returns400` + +Expected: PASS (already handled by existing validation) + +- [ ] **Step 3: Commit** + +```bash +git add src/test/java/io/casehub/flow/rest/SignalResourceTest.java +git commit -m "test: add null value validation test" +``` + +--- + +## Task 6: Add CaseHubRuntime injection and happy path test + +**Files:** +- Modify: `src/main/java/io/casehub/flow/rest/SignalResource.java` +- Modify: `src/test/java/io/casehub/flow/rest/SignalResourceTest.java` + +- [ ] **Step 1: Write happy path test with mocked runtime** + +Add imports and mock setup to SignalResourceTest: + +```java +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; + +import io.casehub.api.engine.CaseHubRuntime; +import io.quarkus.test.InjectMock; +import static org.hamcrest.Matchers.containsString; +``` + +Add test method: + +```java +@InjectMock CaseHubRuntime caseHubRuntime; + +@Test +void sendSignal_validRequest_returns202() { + UUID caseId = UUID.randomUUID(); + + given() + .contentType(ContentType.JSON) + .body( + """ + { + "path": "approvals.user", + "value": {"approved": true} + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", caseId) + .then() + .statusCode(202) + .body("caseId", equalTo(caseId.toString())) + .body("status", equalTo("accepted")) + .body("message", containsString("queued")); + + verify(caseHubRuntime).signal(eq(caseId), eq("approvals.user"), any()); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_validRequest_returns202` + +Expected: FAIL - response body doesn't match (empty 202 response) + +- [ ] **Step 3: Inject CaseHubRuntime and implement signal call** + +Modify SignalResource: + +```java +import io.casehub.api.engine.CaseHubRuntime; +import io.casehub.flow.rest.dto.SignalResponse; +import jakarta.inject.Inject; +import org.jboss.logging.Logger; +``` + +Add fields and update sendSignal method: + +```java +private static final Logger LOG = Logger.getLogger(SignalResource.class); + +@Inject CaseHubRuntime caseHubRuntime; + +@POST +public Uni sendSignal( + @PathParam("caseId") UUID caseId, SendSignalRequest request) { + + // Validation + if (request == null || request.path() == null || request.value() == null) { + return Uni.createFrom() + .item( + Response.status(400) + .entity( + new ProblemDetail( + "Invalid request", + 400, + "Request body, path, and value are required")) + .build()); + } + + // Send signal to engine + return Uni.createFrom() + .item( + () -> { + caseHubRuntime.signal(caseId, request.path(), request.value()); + return new SignalResponse(caseId, "accepted", "Signal queued for processing"); + }) + .map(response -> Response.status(202).entity(response).build()); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_validRequest_returns202` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/io/casehub/flow/rest/SignalResource.java src/test/java/io/casehub/flow/rest/SignalResourceTest.java +git commit -m "feat: add CaseHubRuntime integration and signal delivery" +``` + +--- + +## Task 7: Add case not found error handling + +**Files:** +- Modify: `src/main/java/io/casehub/flow/rest/SignalResource.java` +- Modify: `src/test/java/io/casehub/flow/rest/SignalResourceTest.java` + +- [ ] **Step 1: Write test for case not found** + +Add imports to SignalResourceTest: + +```java +import static org.mockito.Mockito.doThrow; +import io.casehub.flow.exception.CaseInstanceNotFoundException; +``` + +Add test method: + +```java +@Test +void sendSignal_caseNotFound_returns404() { + UUID caseId = UUID.randomUUID(); + doThrow(new CaseInstanceNotFoundException(caseId)) + .when(caseHubRuntime) + .signal(any(), any(), any()); + + given() + .contentType(ContentType.JSON) + .body( + """ + { + "path": "test.path", + "value": "test" + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", caseId) + .then() + .statusCode(404) + .body("title", equalTo("Case not found")); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_caseNotFound_returns404` + +Expected: FAIL - no error handling, returns 500 or exception + +- [ ] **Step 3: Add error handling for CaseInstanceNotFoundException** + +Modify SignalResource sendSignal method to add error recovery: + +```java +import io.casehub.flow.exception.CaseInstanceNotFoundException; +``` + +Update the return statement in sendSignal: + +```java +// Send signal to engine +return Uni.createFrom() + .item( + () -> { + caseHubRuntime.signal(caseId, request.path(), request.value()); + return new SignalResponse(caseId, "accepted", "Signal queued for processing"); + }) + .map(response -> Response.status(202).entity(response).build()) + .onFailure(CaseInstanceNotFoundException.class) + .recoverWithItem( + ex -> { + LOG.warnf(ex, "Case not found: %s", caseId); + return Response.status(404) + .entity(new ProblemDetail("Case not found", 404, ex.getMessage())) + .build(); + }); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_caseNotFound_returns404` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/io/casehub/flow/rest/SignalResource.java src/test/java/io/casehub/flow/rest/SignalResourceTest.java +git commit -m "feat: add 404 error handling for non-existent cases" +``` + +--- + +## Task 8: Add generic runtime error handling + +**Files:** +- Modify: `src/main/java/io/casehub/flow/rest/SignalResource.java` +- Modify: `src/test/java/io/casehub/flow/rest/SignalResourceTest.java` + +- [ ] **Step 1: Write test for runtime exception** + +Add test method to SignalResourceTest: + +```java +@Test +void sendSignal_runtimeException_returns500() { + doThrow(new RuntimeException("Database error")) + .when(caseHubRuntime) + .signal(any(), any(), any()); + + given() + .contentType(ContentType.JSON) + .body( + """ + { + "path": "test.path", + "value": "test" + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID()) + .then() + .statusCode(500) + .body("title", equalTo("Internal server error")) + .body("detail", containsString("Failed to send signal")); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_runtimeException_returns500` + +Expected: FAIL - no generic error handling + +- [ ] **Step 3: Add generic error recovery** + +Modify SignalResource sendSignal method to add final error handler: + +```java +// Send signal to engine +return Uni.createFrom() + .item( + () -> { + caseHubRuntime.signal(caseId, request.path(), request.value()); + return new SignalResponse(caseId, "accepted", "Signal queued for processing"); + }) + .map(response -> Response.status(202).entity(response).build()) + .onFailure(CaseInstanceNotFoundException.class) + .recoverWithItem( + ex -> { + LOG.warnf(ex, "Case not found: %s", caseId); + return Response.status(404) + .entity(new ProblemDetail("Case not found", 404, ex.getMessage())) + .build(); + }) + .onFailure() + .recoverWithItem( + ex -> { + LOG.errorf( + ex, "Failed to send signal to case %s at path %s", caseId, request.path()); + return Response.status(500) + .entity( + new ProblemDetail( + "Internal server error", + 500, + "Failed to send signal: " + ex.getMessage())) + .build(); + }); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_runtimeException_returns500` + +Expected: PASS + +- [ ] **Step 5: Run all unit tests** + +Run: `./mvnw test -Dtest=SignalResourceTest` + +Expected: All tests PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/io/casehub/flow/rest/SignalResource.java src/test/java/io/casehub/flow/rest/SignalResourceTest.java +git commit -m "feat: add 500 error handling for runtime exceptions" +``` + +--- + +## Task 9: Add integration test for end-to-end signal processing + +**Files:** +- Create: `src/test/java/io/casehub/flow/rest/SignalResourceIT.java` + +- [ ] **Step 1: Create integration test skeleton** + +Create test file: + +```java +/* + * Copyright 2026-Present The Case Hub Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.casehub.flow.rest; + +import static io.restassured.RestAssured.given; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.equalTo; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.http.ContentType; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class SignalResourceIT { + + @Test + void sendSignal_nonExistentCase_returns404() { + UUID nonExistentCaseId = UUID.randomUUID(); + + given() + .contentType(ContentType.JSON) + .body( + """ + { + "path": "test.path", + "value": "test" + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", nonExistentCaseId) + .then() + .statusCode(404) + .body("title", equalTo("Case not found")); + } + + @Test + void sendSignal_updatesContextAndTriggersWorkers() { + // 1. Start a test case + UUID caseId = startTestCase(); + + // 2. Send signal + given() + .contentType(ContentType.JSON) + .body( + """ + { + "path": "approval.status", + "value": "approved" + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", caseId) + .then() + .statusCode(202); + + // 3. Wait for async worker processing + await() + .atMost(5, SECONDS) + .untilAsserted( + () -> { + // 4. Verify context updated + String contextValue = + given() + .when() + .get("/api/v1/cases/{caseId}/context/approval.status", caseId) + .then() + .statusCode(200) + .extract() + .asString(); + + assertThat(contextValue).isEqualTo("\"approved\""); + }); + } + + private UUID startTestCase() { + Map request = + Map.of( + "definition", + Map.of("namespace", "test-api", "name", "Document Approval", "version", "1.0.0"), + "context", + Map.of("documentId", "DOC-123", "submittedBy", "alice@example.com")); + + String response = + given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post("/api/v1/cases") + .then() + .statusCode(200) + .extract() + .path("caseId"); + + return UUID.fromString(response); + } +} +``` + +- [ ] **Step 2: Run integration tests** + +Run: `./mvnw verify -Dit.test=SignalResourceIT` + +Expected: Tests PASS (verifies end-to-end signal delivery and context update) + +- [ ] **Step 3: Commit** + +```bash +git add src/test/java/io/casehub/flow/rest/SignalResourceIT.java +git commit -m "test: add integration tests for signal endpoint" +``` + +--- + +## Task 10: Run full test suite and verify + +**Files:** +- N/A (verification task) + +- [ ] **Step 1: Run all tests** + +Run: `./mvnw verify` + +Expected: All tests PASS + +- [ ] **Step 2: Verify compilation without warnings** + +Run: `./mvnw clean compile` + +Expected: BUILD SUCCESS with no warnings + +- [ ] **Step 3: Manual smoke test with curl** + +Start dev mode: +```bash +./mvnw quarkus:dev +``` + +In another terminal, start a case and send a signal: + +```bash +# Start a case +CASE_ID=$(curl -s -X POST http://localhost:8080/api/v1/cases \ + -H "Content-Type: application/json" \ + -d '{ + "definition": { + "namespace": "test-api", + "name": "Document Approval", + "version": "1.0.0" + }, + "context": { + "documentId": "DOC-999" + } + }' | jq -r '.caseId') + +echo "Started case: $CASE_ID" + +# Send signal +curl -X POST http://localhost:8080/api/v1/cases/$CASE_ID/signals \ + -H "Content-Type: application/json" \ + -d '{ + "path": "approval.status", + "value": "approved" + }' + +# Check context +curl -s http://localhost:8080/api/v1/cases/$CASE_ID/context/approval.status +``` + +Expected: +- Signal returns 202 with `{"caseId":"...","status":"accepted","message":"Signal queued for processing"}` +- Context endpoint returns `"approved"` + +- [ ] **Step 4: Test error cases manually** + +Test 404: +```bash +curl -X POST http://localhost:8080/api/v1/cases/00000000-0000-0000-0000-000000000000/signals \ + -H "Content-Type: application/json" \ + -d '{"path": "test", "value": "test"}' +``` + +Expected: 404 with RFC 7807 format + +Test 400: +```bash +curl -X POST http://localhost:8080/api/v1/cases/$CASE_ID/signals \ + -H "Content-Type: application/json" \ + -d '{"path": null, "value": "test"}' +``` + +Expected: 400 with "Invalid request" + +Stop dev mode: `Ctrl+C` + +- [ ] **Step 5: Final commit** + +```bash +git add -A +git commit -m "feat: complete signal endpoint implementation + +Implements POST /api/v1/cases/{caseId}/signals endpoint for sending +external signals to case instances (issue #5). + +Features: +- Accepts {path, value} payload +- Returns 202 Accepted on success +- 404 for missing cases, 400 for validation errors +- RFC 7807 error format +- Full unit and integration test coverage + +Manual testing verified with curl." +``` + +--- + +## Self-Review Checklist + +**Spec coverage:** +- ✅ POST /api/v1/cases/{caseId}/signals endpoint +- ✅ Accepts path and value in request body +- ✅ Returns 202 Accepted on success +- ✅ Returns 404 if caseId doesn't exist +- ✅ Returns 400 for validation errors (null path/value) +- ✅ RFC 7807 error format +- ✅ Unit tests with mocked runtime +- ✅ Integration tests with real runtime +- ✅ Logging at appropriate levels + +**Placeholders:** None - all code is complete + +**Type consistency:** +- SendSignalRequest(String path, Object value) - used consistently +- SignalResponse(UUID caseId, String status, String message) - used consistently +- CaseHubRuntime.signal(UUID caseId, String path, Object value) - matches engine API + +**Missing from spec:** None - all requirements covered + +--- + +## Execution Notes + +**Estimated time:** 45-60 minutes for full implementation + +**Dependencies:** +- Requires existing CaseInstanceResource for ProblemDetail pattern reference +- Requires existing test infrastructure (QuarkusTest, RestAssured) +- Requires casehub-engine with CaseHubRuntime.signal() method + +**Known issues:** None anticipated + +**Testing strategy:** +- Unit tests use @InjectMock for CaseHubRuntime +- Integration tests use real runtime with test case definitions +- Manual testing verifies full workflow + +**Future work (not in this plan):** +- Idempotency support (Idempotency-Key header) +- Signal history endpoint (issue #7) +- Metrics/observability (Micrometer) +- OpenAPI spec update (issue #11) diff --git a/docs/superpowers/specs/2026-05-06-signal-endpoint-design.md b/docs/superpowers/specs/2026-05-06-signal-endpoint-design.md new file mode 100644 index 0000000..b8bb00f --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-signal-endpoint-design.md @@ -0,0 +1,709 @@ +# Signal Endpoint REST API Design + +**Date:** 2026-05-06 +**Issue:** #5 - Implement REST API v1 — signal endpoint for external events +**Status:** Design Approved +**Author:** Claude Code + Dmitrii Tikhomirov + +## Overview + +Implement REST API endpoint for sending external signals/events into running case instances. Signals update the CaseContext at a specified path, triggering worker execution and potential state transitions in casehub-engine. + +## Requirements Summary + +From issue #5: + +**Endpoint:** +- `POST /api/v1/cases/{caseId}/signals` — send external signal to case instance + +**Acceptance Criteria:** +- POST endpoint accepts path (location in CaseContext) and value (signal data) +- Signal is routed to casehub-engine's signal processing mechanism via `CaseHubRuntime.signal()` +- Returns 202 Accepted if signal queued successfully +- Returns 404 if caseId doesn't exist +- Returns 400 if signal payload validation fails (null path or value) +- Error responses follow RFC 7807 format +- Integration tests verify signal triggers worker execution and context updates + +**Deferred (not in MVP):** +- Idempotency: duplicate signal deduplication (can be added later with Idempotency-Key header) +- Signal history/audit trail (covered by issue #7) + +**Key Decisions:** +- **Async model:** 202 Accepted response (signal queued, not processed) +- **Direct mapping:** REST API `{ path, value }` maps directly to `CaseHubRuntime.signal(caseId, path, value)` +- **Minimal validation:** Null-checks only; rely on engine for semantic validation +- **Reactive error handling:** Catch exceptions from runtime, don't pre-validate case existence +- **Separate resource:** New `SignalResource` class for clean isolation (future: signal history) + +## Architecture + +### Component Layers + +``` +┌─────────────────────────────────────────┐ +│ REST Layer │ +│ SignalResource │ +│ POST /api/v1/cases/{caseId}/signals │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Runtime Layer │ +│ CaseHubRuntime.signal(caseId, path, value) │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ casehub-engine │ +│ - Updates CaseContext at path │ +│ - Triggers worker execution │ +│ - Processes state transitions │ +└─────────────────────────────────────────┘ +``` + +**Design Rationale:** + +- **Separate SignalResource**: Isolates signal functionality from CaseInstanceResource, allows future expansion (GET /signals for history) +- **No service layer**: `signal()` is a simple void method on runtime; adding a service would be over-engineering +- **Direct runtime injection**: Minimal indirection for straightforward operation + +## Components + +### 1. SignalResource + +**Purpose:** JAX-RS endpoint for receiving signal requests and delegating to casehub-engine runtime. + +**Location:** `src/main/java/io/casehub/flow/rest/SignalResource.java` + +**Implementation:** + +```java +package io.casehub.flow.rest; + +import io.casehub.api.engine.CaseHubRuntime; +import io.casehub.flow.exception.CaseNotFoundException; +import io.casehub.flow.rest.dto.SendSignalRequest; +import io.casehub.flow.rest.dto.SignalResponse; +import io.smallrye.mutiny.Uni; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * REST API for sending signals to case instances. + * + *

Endpoints: + * + *

    + *
  • POST /api/v1/cases/{caseId}/signals — send signal to case + *
+ */ +@Path("/api/v1/cases/{caseId}/signals") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class SignalResource { + + private static final Logger LOG = Logger.getLogger(SignalResource.class); + + @Inject CaseHubRuntime caseHubRuntime; + + /** + * Send signal to case instance. + * + * @param caseId case instance UUID + * @param request signal request with path and value + * @return 202 Accepted if signal queued, 404 if case not found, 400 for invalid request + */ + @POST + public Uni sendSignal( + @PathParam("caseId") UUID caseId, + SendSignalRequest request) { + + // Validation + if (request == null || request.path() == null || request.value() == null) { + return Uni.createFrom() + .item( + Response.status(400) + .entity( + new ProblemDetail( + "Invalid request", + 400, + "Request body, path, and value are required")) + .build()); + } + + // Send signal to engine + return Uni.createFrom() + .item( + () -> { + caseHubRuntime.signal(caseId, request.path(), request.value()); + return new SignalResponse(caseId, "accepted", "Signal queued for processing"); + }) + .map(response -> Response.status(202).entity(response).build()) + .onFailure(CaseNotFoundException.class) + .recoverWithItem( + ex -> { + LOG.warnf(ex, "Case not found: %s", caseId); + return Response.status(404) + .entity(new ProblemDetail("Case not found", 404, ex.getMessage())) + .build(); + }) + .onFailure() + .recoverWithItem( + ex -> { + LOG.errorf( + ex, "Failed to send signal to case %s at path %s", caseId, request.path()); + return Response.status(500) + .entity( + new ProblemDetail( + "Internal server error", + 500, + "Failed to send signal: " + ex.getMessage())) + .build(); + }); + } + + /** + * RFC 7807 Problem Details for HTTP APIs. + * + * @param title a short, human-readable summary of the problem type + * @param status the HTTP status code + * @param detail a human-readable explanation specific to this occurrence + */ + public record ProblemDetail(String title, int status, String detail) {} +} +``` + +**Key Points:** + +- `Uni.createFrom().item(() -> ...)` wraps the void `signal()` call +- Minimal validation: null-checks for request, path, and value +- Reactive error handling: catch exceptions from `caseHubRuntime.signal()` +- Logging at appropriate levels: WARN for 404, ERROR for 500 +- RFC 7807 ProblemDetail for all error responses + +### 2. SendSignalRequest (DTO) + +**Purpose:** Request payload for signal endpoint. + +**Location:** `src/main/java/io/casehub/flow/rest/dto/SendSignalRequest.java` + +**Implementation:** + +```java +package io.casehub.flow.rest.dto; + +/** + * Request payload for sending signal to case instance. + * + * @param path dot-notation path in CaseContext (e.g., "approvals.user", "orders[0].status") + * @param value signal data to set at path + */ +public record SendSignalRequest(String path, Object value) {} +``` + +**Example Request:** + +```json +POST /api/v1/cases/550e8400-e29b-41d4-a716-446655440000/signals +Content-Type: application/json + +{ + "path": "approvals.user", + "value": { + "approved": true, + "userId": "123", + "timestamp": "2026-05-06T10:30:00Z" + } +} +``` + +### 3. SignalResponse (DTO) + +**Purpose:** Response payload for successful signal acceptance. + +**Location:** `src/main/java/io/casehub/flow/rest/dto/SignalResponse.java` + +**Implementation:** + +```java +package io.casehub.flow.rest.dto; + +import java.util.UUID; + +/** + * Response for signal acceptance. + * + * @param caseId case instance UUID + * @param status acceptance status ("accepted") + * @param message human-readable message + */ +public record SignalResponse(UUID caseId, String status, String message) {} +``` + +**Example Response (202 Accepted):** + +```json +HTTP/1.1 202 Accepted +Content-Type: application/json + +{ + "caseId": "550e8400-e29b-41d4-a716-446655440000", + "status": "accepted", + "message": "Signal queued for processing" +} +``` + +## Error Handling + +### Validation Errors (400 Bad Request) + +**Triggers:** +- Request body is null +- `path` field is null +- `value` field is null + +**Response:** + +```json +HTTP/1.1 400 Bad Request +Content-Type: application/json + +{ + "title": "Invalid request", + "status": 400, + "detail": "Request body, path, and value are required" +} +``` + +### Case Not Found (404 Not Found) + +**Triggers:** +- `CaseNotFoundException` thrown by `caseHubRuntime.signal()` + +**Response:** + +```json +HTTP/1.1 404 Not Found +Content-Type: application/json + +{ + "title": "Case not found", + "status": 404, + "detail": "Case instance with UUID 550e8400-... not found" +} +``` + +**Logging:** WARN level with caseId + +### Runtime Errors (500 Internal Server Error) + +**Triggers:** +- Any other `RuntimeException` from `caseHubRuntime.signal()` + +**Response:** + +```json +HTTP/1.1 500 Internal Server Error +Content-Type: application/json + +{ + "title": "Internal server error", + "status": 500, + "detail": "Failed to send signal: " +} +``` + +**Logging:** ERROR level with full exception stack trace, caseId, and path + +## Data Flow + +``` +1. Client sends POST /api/v1/cases/{caseId}/signals + ↓ +2. SignalResource validates request (null checks) + ↓ +3. SignalResource calls caseHubRuntime.signal(caseId, path, value) + ↓ +4. CaseHubRuntime updates CaseContext at path + ↓ +5. casehub-engine triggers worker execution (async) + ↓ +6. Workers process context change, update state + ↓ +7. SignalResource returns 202 Accepted immediately (before workers finish) +``` + +**Important:** The 202 response indicates signal acceptance, NOT completion of processing. Workers execute asynchronously after the HTTP response is returned. + +## Testing Strategy + +### Unit Tests (SignalResourceTest) + +**Test Coverage:** + +1. **Happy path** — 202 Accepted with valid request + - Verify `caseHubRuntime.signal()` called with correct arguments + - Verify response status and body + +2. **Null path validation** — 400 Bad Request + - Request with `path: null` + - Verify error response + +3. **Null value validation** — 400 Bad Request + - Request with `value: null` + - Verify error response + +4. **Null request body** — 400 Bad Request + - No request body + - Verify error response + +5. **Case not found** — 404 Not Found + - Mock `CaseNotFoundException` from runtime + - Verify error response and logging + +6. **Runtime exception** — 500 Internal Server Error + - Mock generic `RuntimeException` from runtime + - Verify error response and logging + +**Implementation:** + +```java +@QuarkusTest +class SignalResourceTest { + + @InjectMock CaseHubRuntime caseHubRuntime; + + @Test + void sendSignal_success_returns202() { + UUID caseId = UUID.randomUUID(); + + given() + .contentType(MediaType.APPLICATION_JSON) + .body( + """ + { + "path": "approvals.user", + "value": {"approved": true} + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", caseId) + .then() + .statusCode(202) + .body("caseId", equalTo(caseId.toString())) + .body("status", equalTo("accepted")) + .body("message", containsString("queued")); + + verify(caseHubRuntime).signal(eq(caseId), eq("approvals.user"), any()); + } + + @Test + void sendSignal_nullPath_returns400() { + given() + .contentType(MediaType.APPLICATION_JSON) + .body( + """ + { + "path": null, + "value": {"approved": true} + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID()) + .then() + .statusCode(400) + .body("title", equalTo("Invalid request")) + .body("status", equalTo(400)); + } + + @Test + void sendSignal_nullValue_returns400() { + given() + .contentType(MediaType.APPLICATION_JSON) + .body( + """ + { + "path": "approvals.user", + "value": null + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID()) + .then() + .statusCode(400) + .body("title", equalTo("Invalid request")); + } + + @Test + void sendSignal_caseNotFound_returns404() { + UUID caseId = UUID.randomUUID(); + doThrow(new CaseNotFoundException(caseId)) + .when(caseHubRuntime) + .signal(any(), any(), any()); + + given() + .contentType(MediaType.APPLICATION_JSON) + .body( + """ + { + "path": "test.path", + "value": "test" + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", caseId) + .then() + .statusCode(404) + .body("title", equalTo("Case not found")); + } + + @Test + void sendSignal_runtimeException_returns500() { + doThrow(new RuntimeException("Database error")) + .when(caseHubRuntime) + .signal(any(), any(), any()); + + given() + .contentType(MediaType.APPLICATION_JSON) + .body( + """ + { + "path": "test.path", + "value": "test" + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID()) + .then() + .statusCode(500) + .body("title", equalTo("Internal server error")) + .body("detail", containsString("Failed to send signal")); + } +} +``` + +### Integration Tests (SignalResourceIT) + +**Test Coverage:** + +1. **End-to-end signal processing** + - Start a real case instance + - Send signal via REST API + - Wait for worker execution (async) + - Verify context updated via GET /context endpoint + +2. **Signal triggers state transition** + - Start case in initial state + - Send signal that should trigger transition + - Verify case moved to new state + +**Implementation:** + +```java +@QuarkusTest +class SignalResourceIT { + + @Inject CaseDefinitionService definitionService; + @Inject CaseInstanceService caseInstanceService; + + @Test + void sendSignal_updatesContextAndTriggersWorkers() { + // 1. Start a test case + UUID caseId = startTestCase(); + + // 2. Send signal + given() + .contentType(MediaType.APPLICATION_JSON) + .body( + """ + { + "path": "approval.status", + "value": "approved" + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", caseId) + .then() + .statusCode(202); + + // 3. Wait for async worker processing + await() + .atMost(5, SECONDS) + .untilAsserted( + () -> { + // 4. Verify context updated + String contextValue = + given() + .when() + .get("/api/v1/cases/{caseId}/context/approval.status", caseId) + .then() + .statusCode(200) + .extract() + .asString(); + + assertThat(contextValue).isEqualTo("\"approved\""); + }); + } + + @Test + void sendSignal_nonExistentCase_returns404() { + UUID nonExistentCaseId = UUID.randomUUID(); + + given() + .contentType(MediaType.APPLICATION_JSON) + .body( + """ + { + "path": "test.path", + "value": "test" + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", nonExistentCaseId) + .then() + .statusCode(404) + .body("title", equalTo("Case not found")); + } + + private UUID startTestCase() { + // Helper method to start a test case instance + // Implementation depends on test case definition setup + } +} +``` + +## Future Enhancements + +### 1. Idempotency (Deferred) + +**Approach:** Client-side idempotency key + +**Implementation:** +```java +@POST +public Uni sendSignal( + @PathParam("caseId") UUID caseId, + @HeaderParam("Idempotency-Key") String idempotencyKey, + SendSignalRequest request) { + + if (idempotencyKey != null) { + // Check idempotency table + return checkIdempotency(caseId, idempotencyKey) + .flatMap(isDuplicate -> { + if (isDuplicate) { + return Uni.createFrom().item(202 Accepted); + } + // Store idempotency key, send signal + return storeAndSendSignal(caseId, idempotencyKey, request); + }); + } + + // No idempotency key, send signal directly + return sendSignalDirect(caseId, request); +} +``` + +**Database Schema:** +```sql +CREATE TABLE signal_idempotency ( + case_id UUID NOT NULL, + idempotency_key VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (case_id, idempotency_key) +); + +CREATE INDEX idx_signal_idempotency_created_at + ON signal_idempotency(created_at); + +-- Cleanup job: DELETE WHERE created_at < NOW() - INTERVAL '24 hours' +``` + +### 2. Signal History (Issue #7) + +**Endpoint:** +``` +GET /api/v1/cases/{caseId}/signals +``` + +**Response:** +```json +{ + "signals": [ + { + "path": "approvals.user", + "value": {"approved": true}, + "timestamp": "2026-05-06T10:30:00Z", + "source": "external-api" + } + ] +} +``` + +**Implementation:** Query casehub-engine ledger/event log + +### 3. Observability + +**Metrics (Micrometer/Prometheus):** +- `signals_sent_total` — counter, labels: {caseId, path, status} +- `signals_errors_total` — counter, labels: {errorType} +- `signal_processing_duration_seconds` — histogram + +**Tracing (OpenTelemetry):** +- Span: `POST /api/v1/cases/{caseId}/signals` +- Attributes: caseId, path, responseStatus + +**Logging:** +- Already included: WARN for 404, ERROR for 500 +- Future: structured logging with correlation IDs + +### 4. Security (Future) + +- **Authentication:** JWT/OAuth2 integration +- **Authorization:** Case-level permissions (who can send signals to which cases) +- **Rate Limiting:** Prevent signal flooding/abuse +- **Input Sanitization:** Additional validation for path format, value size limits + +### 5. casehub-engine DLQ Integration + +From issue #5 notes: +> Check if casehub-engine has built-in signal deduplication (DLQ module from #194, #193) + +**Action:** Investigate casehubio/engine issues #194, #193 to understand: +- Does DLQ provide signal deduplication? +- Is it automatic or opt-in? +- What configuration is needed? + +**If DLQ provides deduplication:** Document behavior in API docs, no need to implement in REST layer + +**If DLQ doesn't provide deduplication:** Implement idempotency as described above + +## Implementation Checklist + +- [ ] Create `SignalResource.java` +- [ ] Create `SendSignalRequest.java` DTO +- [ ] Create `SignalResponse.java` DTO +- [ ] Add `CaseNotFoundException` if not already exists +- [ ] Write unit tests (`SignalResourceTest`) +- [ ] Write integration tests (`SignalResourceIT`) +- [ ] Update API documentation / OpenAPI spec (issue #11) +- [ ] Verify error logging works correctly +- [ ] Manual testing: send signals via curl/Postman +- [ ] Update README with signal endpoint examples + +## Open Questions + +None — all questions resolved during design phase. + +## References + +- Issue #5: https://github.com/casehubio/flow/issues/5 +- Epic #1: https://github.com/casehubio/flow/issues/1 +- casehub-engine API: `CaseHubRuntime.signal(UUID caseId, String path, Object value)` +- Previous design: `2026-05-05-case-lifecycle-api-design.md` diff --git a/pom.xml b/pom.xml index c6c8a89..67fd5cb 100644 --- a/pom.xml +++ b/pom.xml @@ -74,6 +74,10 @@ io.quarkus quarkus-rest + + io.quarkus + quarkus-hibernate-validator + io.smallrye jandex @@ -121,6 +125,11 @@ 4.2.2 test + + io.quarkus + quarkus-junit-mockito + test + diff --git a/src/main/java/io/casehub/flow/rest/CaseDefinitionResource.java b/src/main/java/io/casehub/flow/rest/CaseDefinitionResource.java index f4991d6..7ac1b9c 100644 --- a/src/main/java/io/casehub/flow/rest/CaseDefinitionResource.java +++ b/src/main/java/io/casehub/flow/rest/CaseDefinitionResource.java @@ -16,6 +16,7 @@ package io.casehub.flow.rest; import io.casehub.api.model.CaseDefinition; +import io.casehub.flow.rest.dto.ProblemDetail; import io.casehub.flow.service.CaseDefinitionService; import io.smallrye.mutiny.Uni; import org.jboss.resteasy.reactive.RestQuery; @@ -159,13 +160,4 @@ public Uni getByNamespaceAndNameAndVersion( return Response.ok(definition).build(); }); } - - /** - * RFC 7807 Problem Detail for error responses. - * - * @param title short, human-readable summary - * @param status HTTP status code - * @param detail human-readable explanation specific to this occurrence - */ - public record ProblemDetail(String title, int status, String detail) {} } diff --git a/src/main/java/io/casehub/flow/rest/CaseInstanceResource.java b/src/main/java/io/casehub/flow/rest/CaseInstanceResource.java index e043a72..d114a93 100644 --- a/src/main/java/io/casehub/flow/rest/CaseInstanceResource.java +++ b/src/main/java/io/casehub/flow/rest/CaseInstanceResource.java @@ -17,6 +17,7 @@ import io.casehub.flow.exception.CaseInstanceNotFoundException; import io.casehub.flow.exception.DefinitionNotFoundException; +import io.casehub.flow.rest.dto.ProblemDetail; import io.casehub.flow.rest.dto.StartCaseRequest; import io.casehub.flow.service.CaseInstanceService; import io.smallrye.mutiny.Uni; @@ -183,13 +184,4 @@ public Uni getContextPath(@PathParam("caseId") UUID caseId, @PathParam "Internal server error", 500, ex.getMessage())) .build()); } - - /** - * RFC 7807 Problem Details for HTTP APIs. - * - * @param title a short, human-readable summary of the problem type - * @param status the HTTP status code - * @param detail a human-readable explanation specific to this occurrence - */ - public record ProblemDetail(String title, int status, String detail) {} } diff --git a/src/main/java/io/casehub/flow/rest/SignalResource.java b/src/main/java/io/casehub/flow/rest/SignalResource.java new file mode 100644 index 0000000..19f91a2 --- /dev/null +++ b/src/main/java/io/casehub/flow/rest/SignalResource.java @@ -0,0 +1,115 @@ +/* + * Copyright 2026-Present The Case Hub Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.casehub.flow.rest; + +import io.casehub.api.engine.CaseHubRuntime; +import io.casehub.flow.exception.CaseInstanceNotFoundException; +import io.casehub.flow.rest.dto.ProblemDetail; +import io.casehub.flow.rest.dto.SendSignalRequest; +import io.casehub.flow.rest.dto.SignalResponse; +import io.smallrye.mutiny.Uni; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.UUID; +import org.jboss.logging.Logger; + +/** + * REST API for sending signals to case instances. + * + *

Endpoints: + * + *

    + *
  • POST /api/v1/cases/{caseId}/signals — send signal to case + *
+ */ +@Path("/api/v1/cases/{caseId}/signals") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +public class SignalResource { + + private static final Logger LOG = Logger.getLogger(SignalResource.class); + + @Inject CaseHubRuntime caseHubRuntime; + + @POST + public Uni sendSignal( + @PathParam("caseId") UUID caseId, @Valid SendSignalRequest request) { + + if (request == null) { + return Uni.createFrom() + .item( + Response.status(400) + .entity( + new ProblemDetail( + "Invalid request", + 400, + "Request body is required")) + .build()); + } + + // Validate case exists and send signal + return Uni.createFrom() + .completionStage(() -> caseHubRuntime.query(caseId, ".", Object.class)) + .map( + ignored -> { + caseHubRuntime.signal(caseId, request.path(), request.value()); + return new SignalResponse(caseId, "accepted", "Signal queued for processing"); + }) + .map(response -> Response.status(202).entity(response).build()) + .onFailure(CaseInstanceNotFoundException.class) + .recoverWithItem( + ex -> { + LOG.warnf(ex, "Case not found: %s", caseId); + return Response.status(404) + .entity(new ProblemDetail("Case not found", 404, ex.getMessage())) + .build(); + }) + .onFailure(RuntimeException.class) + .recoverWithUni( + ex -> { + if (ex.getMessage() != null && ex.getMessage().contains("not found")) { + LOG.warnf(ex, "Case not found: %s", caseId); + return Uni.createFrom() + .item( + Response.status(404) + .entity( + new ProblemDetail( + "Case not found", + 404, + ex.getMessage())) + .build()); + } + LOG.errorf( + ex, "Failed to send signal to case %s at path %s", caseId, request.path()); + return Uni.createFrom() + .item( + Response.status(500) + .entity( + new ProblemDetail( + "Internal server error", + 500, + "Failed to send signal: " + ex.getMessage())) + .build()); + }); + } +} diff --git a/src/main/java/io/casehub/flow/rest/PagedResponse.java b/src/main/java/io/casehub/flow/rest/dto/PagedResponse.java similarity index 96% rename from src/main/java/io/casehub/flow/rest/PagedResponse.java rename to src/main/java/io/casehub/flow/rest/dto/PagedResponse.java index ef94f61..d3b9be0 100644 --- a/src/main/java/io/casehub/flow/rest/PagedResponse.java +++ b/src/main/java/io/casehub/flow/rest/dto/PagedResponse.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.casehub.flow.rest; +package io.casehub.flow.rest.dto; import java.util.List; diff --git a/src/main/java/io/casehub/flow/rest/dto/ProblemDetail.java b/src/main/java/io/casehub/flow/rest/dto/ProblemDetail.java new file mode 100644 index 0000000..1a58a02 --- /dev/null +++ b/src/main/java/io/casehub/flow/rest/dto/ProblemDetail.java @@ -0,0 +1,25 @@ +/* + * Copyright 2026-Present The Case Hub Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.casehub.flow.rest.dto; + +/** + * RFC 7807 Problem Details for HTTP APIs. + * + * @param title a short, human-readable summary of the problem type + * @param status the HTTP status code + * @param detail a human-readable explanation specific to this occurrence + */ +public record ProblemDetail(String title, int status, String detail) {} diff --git a/src/main/java/io/casehub/flow/rest/dto/SendSignalRequest.java b/src/main/java/io/casehub/flow/rest/dto/SendSignalRequest.java new file mode 100644 index 0000000..24cc9ba --- /dev/null +++ b/src/main/java/io/casehub/flow/rest/dto/SendSignalRequest.java @@ -0,0 +1,28 @@ +/* + * Copyright 2026-Present The Case Hub Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.casehub.flow.rest.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * Request payload for sending signal to case instance. + * + * @param path dot-notation path in CaseContext (e.g., "approvals.user", "orders[0].status") + * @param value signal data to set at path. Acceptable value types: String, Number, Boolean, + * null, Map, List. + */ +public record SendSignalRequest(@NotBlank String path, @NotNull Object value) {} diff --git a/src/main/java/io/casehub/flow/rest/dto/SignalResponse.java b/src/main/java/io/casehub/flow/rest/dto/SignalResponse.java new file mode 100644 index 0000000..847319c --- /dev/null +++ b/src/main/java/io/casehub/flow/rest/dto/SignalResponse.java @@ -0,0 +1,27 @@ +/* + * Copyright 2026-Present The Case Hub Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.casehub.flow.rest.dto; + +import java.util.UUID; + +/** + * Response for signal acceptance. + * + * @param caseId case instance UUID + * @param status acceptance status ("accepted") + * @param message human-readable message + */ +public record SignalResponse(UUID caseId, String status, String message) {} diff --git a/src/main/java/io/casehub/flow/rest/validation/ConstraintViolationExceptionMapper.java b/src/main/java/io/casehub/flow/rest/validation/ConstraintViolationExceptionMapper.java new file mode 100644 index 0000000..a93ad91 --- /dev/null +++ b/src/main/java/io/casehub/flow/rest/validation/ConstraintViolationExceptionMapper.java @@ -0,0 +1,37 @@ +/* + * Copyright 2026-Present The Case Hub Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.casehub.flow.rest.validation; + +import io.casehub.flow.rest.dto.ProblemDetail; +import jakarta.validation.ConstraintViolationException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; + +/** + * Maps validation constraint violations to HTTP 400 responses. + */ +@Provider +public class ConstraintViolationExceptionMapper + implements ExceptionMapper { + + @Override + public Response toResponse(ConstraintViolationException exception) { + return Response.status(400) + .entity(new ProblemDetail("Invalid request", 400, exception.getMessage())) + .build(); + } +} diff --git a/src/main/java/io/casehub/flow/service/CaseDefinitionService.java b/src/main/java/io/casehub/flow/service/CaseDefinitionService.java index 0c6d687..1d658c0 100644 --- a/src/main/java/io/casehub/flow/service/CaseDefinitionService.java +++ b/src/main/java/io/casehub/flow/service/CaseDefinitionService.java @@ -20,7 +20,7 @@ import io.casehub.api.model.CaseDefinition; import io.casehub.engine.internal.model.CaseMetaModel; import io.casehub.engine.spi.CaseDefinitionRegistry; -import io.casehub.flow.rest.PagedResponse; +import io.casehub.flow.rest.dto.PagedResponse; import io.casehub.persistence.jpa.CaseMetaModelEntity; import io.quarkus.hibernate.reactive.panache.Panache; import io.quarkus.panache.common.Page; diff --git a/src/test/java/io/casehub/flow/rest/SignalResourceIT.java b/src/test/java/io/casehub/flow/rest/SignalResourceIT.java new file mode 100644 index 0000000..49fe009 --- /dev/null +++ b/src/test/java/io/casehub/flow/rest/SignalResourceIT.java @@ -0,0 +1,113 @@ +/* + * Copyright 2026-Present The Case Hub Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.casehub.flow.rest; + +import static io.restassured.RestAssured.given; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.equalTo; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.http.ContentType; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class SignalResourceIT { + + @Test + void sendSignal_nonExistentCase_returns404() { + UUID nonExistentCaseId = UUID.randomUUID(); + + given() + .contentType(ContentType.JSON) + .body( + """ + { + "path": "test.path", + "value": "test" + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", nonExistentCaseId) + .then() + .statusCode(404) + .body("title", equalTo("Case not found")); + } + + @Test + void sendSignal_updatesContextAndTriggersWorkers() { + // 1. Start a test case + UUID caseId = startTestCase(); + + // 2. Send signal + given() + .contentType(ContentType.JSON) + .body( + """ + { + "path": "approval.status", + "value": "approved" + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", caseId) + .then() + .statusCode(202); + + // 3. Wait for async worker processing + await() + .atMost(5, SECONDS) + .untilAsserted( + () -> { + // 4. Verify context updated + String contextValue = + given() + .when() + .get("/api/v1/cases/{caseId}/context/approval.status", caseId) + .then() + .statusCode(200) + .extract() + .asString(); + + assertThat(contextValue).isEqualTo("approved"); + }); + } + + private UUID startTestCase() { + Map request = + Map.of( + "definition", + Map.of("namespace", "test-api", "name", "Document Approval", "version", "1.0.0"), + "context", + Map.of("documentId", "DOC-123", "submittedBy", "alice@example.com")); + + String response = + given() + .contentType(ContentType.JSON) + .body(request) + .when() + .post("/api/v1/cases") + .then() + .statusCode(200) + .extract() + .path("caseId"); + + return UUID.fromString(response); + } +} diff --git a/src/test/java/io/casehub/flow/rest/SignalResourceTest.java b/src/test/java/io/casehub/flow/rest/SignalResourceTest.java new file mode 100644 index 0000000..b56360d --- /dev/null +++ b/src/test/java/io/casehub/flow/rest/SignalResourceTest.java @@ -0,0 +1,165 @@ +/* + * Copyright 2026-Present The Case Hub Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.casehub.flow.rest; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.casehub.api.engine.CaseHubRuntime; +import io.casehub.flow.exception.CaseInstanceNotFoundException; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.InjectMock; +import io.restassured.http.ContentType; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; + +@QuarkusTest +class SignalResourceTest { + + @InjectMock CaseHubRuntime caseHubRuntime; + + @Test + void sendSignal_validRequest_returns202() { + UUID caseId = UUID.randomUUID(); + + // Mock query to validate case exists + when(caseHubRuntime.query(eq(caseId), eq("."), eq(Object.class))) + .thenReturn(CompletableFuture.completedFuture(new Object())); + + given() + .contentType(ContentType.JSON) + .body( + """ + { + "path": "approvals.user", + "value": {"approved": true} + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", caseId) + .then() + .statusCode(202) + .body("caseId", equalTo(caseId.toString())) + .body("status", equalTo("accepted")) + .body("message", containsString("queued")); + + verify(caseHubRuntime).signal(eq(caseId), eq("approvals.user"), any()); + } + + @Test + void sendSignal_nullRequestBody_returns400() { + given() + .contentType(ContentType.JSON) + .when() + .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID()) + .then() + .statusCode(400) + .body("title", equalTo("Invalid request")) + .body("status", equalTo(400)); + } + + @Test + void sendSignal_nullPath_returns400() { + given() + .contentType(ContentType.JSON) + .body( + """ + { + "path": null, + "value": {"approved": true} + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID()) + .then() + .statusCode(400) + .body("title", equalTo("Invalid request")) + .body("status", equalTo(400)); + } + + @Test + void sendSignal_nullValue_returns400() { + given() + .contentType(ContentType.JSON) + .body( + """ + { + "path": "approvals.user", + "value": null + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID()) + .then() + .statusCode(400) + .body("title", equalTo("Invalid request")); + } + + @Test + void sendSignal_caseNotFound_returns404() { + UUID caseId = UUID.randomUUID(); + doThrow(new CaseInstanceNotFoundException(caseId)) + .when(caseHubRuntime) + .query(eq(caseId), eq("."), eq(Object.class)); + + given() + .contentType(ContentType.JSON) + .body( + """ + { + "path": "test.path", + "value": "test" + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", caseId) + .then() + .statusCode(404) + .body("title", equalTo("Case not found")); + } + + @Test + void sendSignal_runtimeException_returns500() { + UUID caseId = UUID.randomUUID(); + doThrow(new RuntimeException("Database error")) + .when(caseHubRuntime) + .query(eq(caseId), eq("."), eq(Object.class)); + + given() + .contentType(ContentType.JSON) + .body( + """ + { + "path": "test.path", + "value": "test" + } + """) + .when() + .post("/api/v1/cases/{caseId}/signals", caseId) + .then() + .statusCode(500) + .body("title", equalTo("Internal server error")) + .body("detail", containsString("Failed to send signal")); + } +}