diff --git a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/MemindClient.java b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/MemindClient.java index 583a2db0..7bc1d055 100644 --- a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/MemindClient.java +++ b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/MemindClient.java @@ -72,7 +72,7 @@ public CompletableFuture addMessageAsync(AddMessageRequest request) { ensureOpen(); return httpClient .post( - "/open/v1/memory/add-message/sync", + "/open/v1/memory/sync/add-message", Objects.requireNonNull(request, "request"), new TypeReference>() {}) .thenApply(ignored -> null); @@ -81,7 +81,7 @@ public CompletableFuture addMessageAsync(AddMessageRequest request) { public CompletableFuture extractAsync(ExtractMemoryRequest request) { ensureOpen(); return httpClient.post( - "/open/v1/memory/extract/sync", + "/open/v1/memory/sync/extract", Objects.requireNonNull(request, "request"), new TypeReference>() {}); } @@ -90,7 +90,7 @@ public CompletableFuture commitAsync(CommitMemoryRequest request) { ensureOpen(); return httpClient .post( - "/open/v1/memory/commit/sync", + "/open/v1/memory/sync/commit", Objects.requireNonNull(request, "request"), new TypeReference>() {}) .thenApply(ignored -> null); diff --git a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/exception/MemindApiException.java b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/exception/MemindApiException.java index b4182262..6debb408 100644 --- a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/exception/MemindApiException.java +++ b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/exception/MemindApiException.java @@ -13,15 +13,22 @@ */ package com.openmemind.ai.client.exception; +import com.fasterxml.jackson.databind.JsonNode; + public class MemindApiException extends MemindClientException { private final int httpStatus; private final String errorCode; private final String errorMessage; - private final String traceId; + private final String requestId; + private final JsonNode details; public MemindApiException( - int httpStatus, String errorCode, String errorMessage, String traceId) { + int httpStatus, + String errorCode, + String errorMessage, + String requestId, + JsonNode details) { super("Memind API error [" + httpStatus + "]: " @@ -31,7 +38,8 @@ public MemindApiException( this.httpStatus = httpStatus; this.errorCode = errorCode; this.errorMessage = errorMessage; - this.traceId = traceId; + this.requestId = requestId; + this.details = details; } public int getHttpStatus() { @@ -46,7 +54,11 @@ public String getErrorMessage() { return errorMessage; } - public String getTraceId() { - return traceId; + public String getRequestId() { + return requestId; + } + + public JsonNode getDetails() { + return details; } } diff --git a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/internal/ApiResult.java b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/internal/ApiResult.java index 42b8a31f..0c3634ed 100644 --- a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/internal/ApiResult.java +++ b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/internal/ApiResult.java @@ -14,12 +14,6 @@ package com.openmemind.ai.client.internal; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import java.time.Instant; @JsonIgnoreProperties(ignoreUnknown = true) -public record ApiResult(String code, String message, T data, Instant timestamp, String traceId) { - - public boolean isSuccess() { - return "200".equals(code) || "success".equals(code); - } -} +public record ApiResult(T data) {} diff --git a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/internal/ErrorResult.java b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/internal/ErrorResult.java new file mode 100644 index 00000000..0d5db697 --- /dev/null +++ b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/internal/ErrorResult.java @@ -0,0 +1,24 @@ +/* + * 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 com.openmemind.ai.client.internal; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; + +@JsonIgnoreProperties(ignoreUnknown = true) +record ErrorResult(ApiError error) { + + @JsonIgnoreProperties(ignoreUnknown = true) + record ApiError(String code, String message, JsonNode details) {} +} diff --git a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/internal/MemindHttpClient.java b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/internal/MemindHttpClient.java index 9e6710e6..5298262a 100644 --- a/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/internal/MemindHttpClient.java +++ b/memind-clients/java/memind-client/src/main/java/com/openmemind/ai/client/internal/MemindHttpClient.java @@ -39,6 +39,7 @@ public class MemindHttpClient implements AutoCloseable { private static final Logger log = LoggerFactory.getLogger(MemindHttpClient.class); private static final String USER_AGENT = "memind-java-client/0.2.0"; + private static final String REQUEST_ID_HEADER = "X-Request-Id"; private final HttpClient httpClient; private final ObjectMapper objectMapper; @@ -132,19 +133,38 @@ private T handleResponse( try { ApiResult result = objectMapper.readValue(body, responseType); - if (status >= 200 && status < 300 && result.isSuccess()) { + if (status >= 200 && status < 300) { return result.data(); } - throw new MemindApiException(status, result.code(), result.message(), result.traceId()); + throw toApiException(status, body, requestId(response)); } catch (MemindApiException e) { throw e; } catch (IOException e) { throw new MemindApiException( - status, "parse_error", "Failed to parse response: " + new String(body), null); + status, + "parse_error", + "Failed to parse response: " + new String(body), + requestId(response), + null); } } + private MemindApiException toApiException(int status, byte[] body, String requestId) + throws IOException { + ErrorResult errorResult = objectMapper.readValue(body, ErrorResult.class); + ErrorResult.ApiError error = errorResult.error(); + if (error == null) { + return new MemindApiException(status, "http_error", "HTTP " + status, requestId, null); + } + return new MemindApiException( + status, error.code(), error.message(), requestId, error.details()); + } + + private static String requestId(HttpResponse response) { + return response.headers().firstValue(REQUEST_ID_HEADER).orElse(null); + } + public ObjectMapper getObjectMapper() { return objectMapper; } diff --git a/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/MemindClientIntegrationTest.java b/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/MemindClientIntegrationTest.java new file mode 100644 index 00000000..ad302488 --- /dev/null +++ b/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/MemindClientIntegrationTest.java @@ -0,0 +1,131 @@ +/* + * 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 com.openmemind.ai.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.openmemind.ai.client.exception.MemindApiException; +import com.openmemind.ai.client.model.common.ConversationContent; +import com.openmemind.ai.client.model.common.Message; +import com.openmemind.ai.client.model.common.Strategy; +import com.openmemind.ai.client.model.request.ExtractMemoryRequest; +import com.openmemind.ai.client.model.request.RetrieveMemoryRequest; +import com.openmemind.ai.client.model.response.ExtractMemoryResponse; +import com.openmemind.ai.client.model.response.HealthResponse; +import com.openmemind.ai.client.model.response.RetrieveMemoryResponse; +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class MemindClientIntegrationTest { + + @Test + void clientCanCallRealMemindServerHealthAndErrorEnvelope() { + assumeIntegrationEnabled(); + + String baseUrl = envOrDefault("MEMIND_BASE_URL", "http://localhost:8366"); + String apiToken = System.getenv("MEMIND_API_TOKEN"); + String suffix = UUID.randomUUID().toString(); + + try (MemindClient client = client(baseUrl, apiToken)) { + HealthResponse health = client.health(); + assertThat(health.status()).isEqualToIgnoringCase("UP"); + + assertThatThrownBy( + () -> + client.retrieve( + RetrieveMemoryRequest.builder() + .userId("java-it-user-" + suffix) + .agentId("java-it-agent-" + suffix) + .query("") + .strategy(Strategy.SIMPLE) + .build())) + .isInstanceOf(MemindApiException.class) + .satisfies( + ex -> { + MemindApiException apiException = (MemindApiException) ex; + assertThat(apiException.getHttpStatus()).isEqualTo(400); + assertThat(apiException.getErrorCode()).isEqualTo("validation_failed"); + assertThat(apiException.getRequestId()).isNotBlank(); + }); + } + } + + @Test + void clientCanRunRealMemindServerMemoryFlow() { + assumeIntegrationEnabled(); + assumeTrue( + Boolean.parseBoolean(System.getenv("MEMIND_FULL_MEMORY_FLOW_TEST")), + "Set MEMIND_FULL_MEMORY_FLOW_TEST=true to run extraction/retrieval integration tests"); + + String baseUrl = envOrDefault("MEMIND_BASE_URL", "http://localhost:8366"); + String apiToken = System.getenv("MEMIND_API_TOKEN"); + String suffix = UUID.randomUUID().toString(); + String userId = "java-it-user-" + suffix; + String agentId = "java-it-agent-" + suffix; + String memoryText = "Java integration memory " + suffix; + + try (MemindClient client = client(baseUrl, apiToken)) { + HealthResponse health = client.health(); + assertThat(health.status()).isEqualToIgnoringCase("UP"); + + ExtractMemoryResponse extract = + client.extract( + ExtractMemoryRequest.builder() + .userId(userId) + .agentId(agentId) + .rawContent( + ConversationContent.of( + List.of(Message.user(memoryText)))) + .sourceClient("memind-java-integration-test") + .build()); + assertThat(extract.status()).isIn("SUCCESS", "PARTIAL_SUCCESS"); + assertThat(extract.rawDataIds()).isNotNull(); + + RetrieveMemoryResponse retrieved = + client.retrieve( + RetrieveMemoryRequest.builder() + .userId(userId) + .agentId(agentId) + .query(memoryText) + .strategy(Strategy.SIMPLE) + .trace(true) + .build()); + assertThat(retrieved.items()).isNotNull(); + } + } + + private void assumeIntegrationEnabled() { + assumeTrue( + Boolean.parseBoolean(System.getenv("MEMIND_INTEGRATION_TEST")), + "Set MEMIND_INTEGRATION_TEST=true to run real-server integration tests"); + } + + private MemindClient client(String baseUrl, String apiToken) { + return MemindClient.builder() + .baseUrl(baseUrl) + .apiToken(apiToken) + .connectTimeout(Duration.ofSeconds(5)) + .readTimeout(Duration.ofSeconds(60)) + .build(); + } + + private String envOrDefault(String name, String defaultValue) { + String value = System.getenv(name); + return value == null || value.isBlank() ? defaultValue : value; + } +} diff --git a/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/MemindClientTest.java b/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/MemindClientTest.java index a92529bb..aaf315f0 100644 --- a/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/MemindClientTest.java +++ b/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/MemindClientTest.java @@ -52,7 +52,7 @@ void health_returnsResponse(WireMockRuntimeInfo wmInfo) { .willReturn( okJson( """ - {"code":"success","data":{"status":"UP","service":"memind-server"}} + {"data":{"status":"UP","service":"memind-server"}} """))); try (MemindClient client = MemindClient.builder().baseUrl(wmInfo.getHttpBaseUrl()).build()) { @@ -64,11 +64,11 @@ void health_returnsResponse(WireMockRuntimeInfo wmInfo) { @Test void addMessage_sendsCorrectPayload(WireMockRuntimeInfo wmInfo) { stubFor( - post("/open/v1/memory/add-message/sync") + post("/open/v1/memory/sync/add-message") .willReturn( okJson( """ - {"code":"success","data":{"triggered":false}} + {"data":{"triggered":false}} """))); try (MemindClient client = MemindClient.builder().baseUrl(wmInfo.getHttpBaseUrl()).build()) { @@ -81,7 +81,7 @@ void addMessage_sendsCorrectPayload(WireMockRuntimeInfo wmInfo) { } verify( - postRequestedFor(urlEqualTo("/open/v1/memory/add-message/sync")) + postRequestedFor(urlEqualTo("/open/v1/memory/sync/add-message")) .withRequestBody(matchingJsonPath("$.userId", equalTo("user-1"))) .withRequestBody(matchingJsonPath("$.message.role", equalTo("USER")))); } @@ -89,11 +89,11 @@ void addMessage_sendsCorrectPayload(WireMockRuntimeInfo wmInfo) { @Test void extract_sendsRawContent(WireMockRuntimeInfo wmInfo) { stubFor( - post("/open/v1/memory/extract/sync") + post("/open/v1/memory/sync/extract") .willReturn( okJson( """ - {"code":"success","data":{ + {"data":{ "status":"SUCCESS", "rawDataIds":["rd-1"], "itemIds":[101], @@ -117,7 +117,7 @@ void extract_sendsRawContent(WireMockRuntimeInfo wmInfo) { } verify( - postRequestedFor(urlEqualTo("/open/v1/memory/extract/sync")) + postRequestedFor(urlEqualTo("/open/v1/memory/sync/extract")) .withRequestBody( matchingJsonPath("$.rawContent.type", equalTo("conversation")))); } @@ -125,11 +125,11 @@ void extract_sendsRawContent(WireMockRuntimeInfo wmInfo) { @Test void commit_usesSyncEndpoint(WireMockRuntimeInfo wmInfo) { stubFor( - post("/open/v1/memory/commit/sync") + post("/open/v1/memory/sync/commit") .willReturn( okJson( """ - {"code":"success","data":{ + {"data":{ "status":"SUCCESS", "rawDataIds":[], "itemIds":[], @@ -143,17 +143,17 @@ void commit_usesSyncEndpoint(WireMockRuntimeInfo wmInfo) { CommitMemoryRequest.builder().userId("user-1").agentId("agent-1").build()); } - verify(postRequestedFor(urlEqualTo("/open/v1/memory/commit/sync"))); + verify(postRequestedFor(urlEqualTo("/open/v1/memory/sync/commit"))); } @Test void extract_partialSuccessIsReturnedToCaller(WireMockRuntimeInfo wmInfo) { stubFor( - post("/open/v1/memory/extract/sync") + post("/open/v1/memory/sync/extract") .willReturn( okJson( """ - {"code":"success","data":{ + {"data":{ "status":"PARTIAL_SUCCESS", "rawDataIds":["rd-1"], "itemIds":[], @@ -180,14 +180,14 @@ void extract_partialSuccessIsReturnedToCaller(WireMockRuntimeInfo wmInfo) { @Test void extract_failureEnvelopeThrowsApiException(WireMockRuntimeInfo wmInfo) { stubFor( - post("/open/v1/memory/extract/sync") + post("/open/v1/memory/sync/extract") .willReturn( aResponse() .withStatus(500) .withHeader("Content-Type", "application/json") .withBody( """ - {"code":"extraction_failed","message":"extract failed","traceId":"t1"} + {"error":{"code":"extraction_failed","message":"extract failed"}} """))); try (MemindClient client = MemindClient.builder().baseUrl(wmInfo.getHttpBaseUrl()).build()) { @@ -214,11 +214,11 @@ void extract_failureEnvelopeThrowsApiException(WireMockRuntimeInfo wmInfo) { @Test void extractAsync_returnsExtractionResponse(WireMockRuntimeInfo wmInfo) { stubFor( - post("/open/v1/memory/extract/sync") + post("/open/v1/memory/sync/extract") .willReturn( okJson( """ - {"code":"success","data":{ + {"data":{ "status":"SUCCESS", "rawDataIds":["rd-async"], "itemIds":[], @@ -240,7 +240,7 @@ void extractAsync_returnsExtractionResponse(WireMockRuntimeInfo wmInfo) { assertThat(response.rawDataIds()).containsExactly("rd-async"); } - verify(postRequestedFor(urlEqualTo("/open/v1/memory/extract/sync"))); + verify(postRequestedFor(urlEqualTo("/open/v1/memory/sync/extract"))); } @Test @@ -250,7 +250,7 @@ void retrieve_returnsMemories(WireMockRuntimeInfo wmInfo) { .willReturn( okJson( """ - {"code":"success","data":{ + {"data":{ "status":"success","items":[{"id":"1","text":"memory text","vectorScore":0.9,"finalScore":0.85}], "insights":[],"rawData":[],"evidences":[],"strategy":"SIMPLE","query":"test" }} @@ -289,7 +289,7 @@ void syncMethod_apiError_throwsUnwrappedException(WireMockRuntimeInfo wmInfo) { .withHeader("Content-Type", "application/json") .withBody( """ - {"code":"bad_request","message":"query is required","traceId":"t1"} + {"error":{"code":"bad_request","message":"query is required"}} """))); try (MemindClient client = MemindClient.builder().baseUrl(wmInfo.getHttpBaseUrl()).build()) { diff --git a/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/internal/MemindHttpClientTest.java b/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/internal/MemindHttpClientTest.java index c8b22277..99e03a90 100644 --- a/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/internal/MemindHttpClientTest.java +++ b/memind-clients/java/memind-client/src/test/java/com/openmemind/ai/client/internal/MemindHttpClientTest.java @@ -48,7 +48,7 @@ void get_successfulResponse_returnsData(WireMockRuntimeInfo wmInfo) { .willReturn( okJson( """ - {"code":"success","data":{"status":"UP","service":"memind-server"},"timestamp":"2026-01-01T00:00:00Z"} + {"data":{"status":"UP","service":"memind-server"}} """))); MemindHttpClient httpClient = @@ -72,9 +72,10 @@ void post_apiError_throwsMemindApiException(WireMockRuntimeInfo wmInfo) { aResponse() .withStatus(400) .withHeader("Content-Type", "application/json") + .withHeader("X-Request-Id", "request-123") .withBody( """ - {"code":"bad_request","message":"query is required","timestamp":"2026-01-01T00:00:00Z","traceId":"abc123"} + {"error":{"code":"bad_request","message":"query is required","details":{"fieldErrors":{"query":"must not be blank"}}}} """))); MemindHttpClient httpClient = @@ -95,18 +96,19 @@ void post_apiError_throwsMemindApiException(WireMockRuntimeInfo wmInfo) { ex -> { assertThat(ex.getHttpStatus()).isEqualTo(400); assertThat(ex.getErrorCode()).isEqualTo("bad_request"); - assertThat(ex.getTraceId()).isEqualTo("abc123"); + assertThat(ex.getRequestId()).isEqualTo("request-123"); + assertThat(ex.getDetails()).isNotNull(); }); } @Test void post_withApiToken_sendsAuthorizationHeader(WireMockRuntimeInfo wmInfo) { stubFor( - post("/open/v1/memory/commit") + post("/open/v1/memory/sync/commit") .willReturn( okJson( """ - {"code":"200","timestamp":"2026-01-01T00:00:00Z"} + {"data":null} """))); MemindHttpClient httpClient = @@ -118,13 +120,13 @@ void post_withApiToken_sendsAuthorizationHeader(WireMockRuntimeInfo wmInfo) { httpClient .post( - "/open/v1/memory/commit", + "/open/v1/memory/sync/commit", Map.of("userId", "u1", "agentId", "a1"), new TypeReference>() {}) .join(); verify( - postRequestedFor(urlEqualTo("/open/v1/memory/commit")) + postRequestedFor(urlEqualTo("/open/v1/memory/sync/commit")) .withHeader("Authorization", equalTo("Bearer mk-test-token")) .withHeader("User-Agent", matching("memind-java-client/.*"))); } @@ -154,7 +156,7 @@ void get_withBaseUrlPath_preservesContextPath(WireMockRuntimeInfo wmInfo) { .willReturn( okJson( """ - {"code":"success","data":{"status":"UP","service":"memind-server"}} + {"data":{"status":"UP","service":"memind-server"}} """))); MemindHttpClient httpClient = diff --git a/memind-clients/python/src/memind/_base_client.py b/memind-clients/python/src/memind/_base_client.py index 78bc5445..a6c50f7c 100644 --- a/memind-clients/python/src/memind/_base_client.py +++ b/memind-clients/python/src/memind/_base_client.py @@ -107,11 +107,14 @@ def _process_response( f"Failed to parse response data: {response.text}", status_code=response.status_code, error_code="parse_error", - trace_id=result.trace_id, body=result.data if isinstance(result.data, dict) else None, ) from exc - message = result.message or f"Memind API error: HTTP {response.status_code}" + message = ( + result.error.message + if result.error is not None + else f"Memind API error: HTTP {response.status_code}" + ) raise self._build_api_error(response, result, body, message) def _ensure_open(self) -> None: @@ -129,28 +132,28 @@ def _build_api_error( message: str, ) -> MemindAPIError: body_dict = body if isinstance(body, dict) else None + error = result.error + error_code = error.code if error is not None else None + error_message = error.message if error is not None else message if response.status_code == 401: return MemindAuthenticationError( - message, + error_message, status_code=response.status_code, - error_code=result.code, - trace_id=result.trace_id, + error_code=error_code, body=body_dict, ) if response.status_code == 429: return MemindRateLimitError( - message, + error_message, status_code=response.status_code, - error_code=result.code, - trace_id=result.trace_id, + error_code=error_code, body=body_dict, retry_after=_retry_after_seconds(response), ) return MemindAPIError( - message, + error_message, status_code=response.status_code, - error_code=result.code, - trace_id=result.trace_id, + error_code=error_code, body=body_dict, ) diff --git a/memind-clients/python/src/memind/resources/async_memory.py b/memind-clients/python/src/memind/resources/async_memory.py index e43654c6..f8237f8a 100644 --- a/memind-clients/python/src/memind/resources/async_memory.py +++ b/memind-clients/python/src/memind/resources/async_memory.py @@ -51,7 +51,7 @@ async def extract( raw_content=raw_content, source_client=source_client, ) - result = await self._client._post("/memory/extract/sync", payload, ExtractMemoryResponse) + result = await self._client._post("/memory/sync/extract", payload, ExtractMemoryResponse) assert result is not None return result @@ -70,7 +70,7 @@ async def add_message( message=_required(message, "message"), source_client=source_client, ) - await self._client._post("/memory/add-message/sync", payload, AddMessageResponse) + await self._client._post("/memory/sync/add-message", payload, AddMessageResponse) async def commit( self, @@ -85,7 +85,7 @@ async def commit( agent_id=_required(agent_id, "agent_id"), source_client=source_client, ) - await self._client._post("/memory/commit/sync", payload, ExtractMemoryResponse) + await self._client._post("/memory/sync/commit", payload, ExtractMemoryResponse) async def retrieve( self, diff --git a/memind-clients/python/src/memind/resources/memory.py b/memind-clients/python/src/memind/resources/memory.py index 3e82413e..f5f9aa6e 100644 --- a/memind-clients/python/src/memind/resources/memory.py +++ b/memind-clients/python/src/memind/resources/memory.py @@ -51,7 +51,7 @@ def extract( raw_content=raw_content, source_client=source_client, ) - result = self._client._post("/memory/extract/sync", payload, ExtractMemoryResponse) + result = self._client._post("/memory/sync/extract", payload, ExtractMemoryResponse) assert result is not None return result @@ -70,7 +70,7 @@ def add_message( message=_required(message, "message"), source_client=source_client, ) - self._client._post("/memory/add-message/sync", payload, AddMessageResponse) + self._client._post("/memory/sync/add-message", payload, AddMessageResponse) def commit( self, @@ -85,7 +85,7 @@ def commit( agent_id=_required(agent_id, "agent_id"), source_client=source_client, ) - self._client._post("/memory/commit/sync", payload, ExtractMemoryResponse) + self._client._post("/memory/sync/commit", payload, ExtractMemoryResponse) def retrieve( self, diff --git a/memind-clients/python/src/memind/types/common.py b/memind-clients/python/src/memind/types/common.py index 3b4700c2..6f648841 100644 --- a/memind-clients/python/src/memind/types/common.py +++ b/memind-clients/python/src/memind/types/common.py @@ -15,7 +15,7 @@ from __future__ import annotations from enum import Enum -from typing import Generic, TypeVar +from typing import Any, Generic, TypeVar from memind._models import MemindModel @@ -33,11 +33,14 @@ class Strategy(str, Enum): class ApiResult(MemindModel, Generic[T]): - code: str - message: str | None = None data: T | None = None - timestamp: str | None = None - trace_id: str | None = None + error: ApiError | None = None def is_success(self) -> bool: - return self.code in ("200", "success") + return self.error is None + + +class ApiError(MemindModel): + code: str + message: str + details: Any | None = None diff --git a/memind-clients/python/tests/test_async_client.py b/memind-clients/python/tests/test_async_client.py index f9cefaa4..343e1b93 100644 --- a/memind-clients/python/tests/test_async_client.py +++ b/memind-clients/python/tests/test_async_client.py @@ -26,7 +26,7 @@ async def test_async_health_returns_response(httpx_mock) -> None: httpx_mock.add_response( method="GET", url="https://api.example.test/open/v1/health", - json={"code": "success", "data": {"status": "UP", "service": "memind-server"}}, + json={"data": {"status": "UP", "service": "memind-server"}}, ) async with AsyncMemindClient(base_url="https://api.example.test") as client: @@ -39,9 +39,8 @@ async def test_async_health_returns_response(httpx_mock) -> None: async def test_async_memory_methods_send_payloads(httpx_mock) -> None: httpx_mock.add_response( method="POST", - url="https://api.example.test/open/v1/memory/extract/sync", + url="https://api.example.test/open/v1/memory/sync/extract", json={ - "code": "success", "data": { "status": "SUCCESS", "rawDataIds": ["rd-1"], @@ -53,14 +52,13 @@ async def test_async_memory_methods_send_payloads(httpx_mock) -> None: ) httpx_mock.add_response( method="POST", - url="https://api.example.test/open/v1/memory/add-message/sync", - json={"code": "success", "data": {"triggered": False}}, + url="https://api.example.test/open/v1/memory/sync/add-message", + json={"data": {"triggered": False}}, ) httpx_mock.add_response( method="POST", - url="https://api.example.test/open/v1/memory/commit/sync", + url="https://api.example.test/open/v1/memory/sync/commit", json={ - "code": "success", "data": { "status": "SUCCESS", "rawDataIds": [], @@ -95,7 +93,6 @@ async def test_async_retrieve_returns_response(httpx_mock) -> None: method="POST", url="https://api.example.test/open/v1/memory/retrieve", json={ - "code": "success", "data": { "status": "success", "items": [{"id": "1", "text": "likes coffee"}], @@ -123,7 +120,7 @@ async def test_async_api_error_is_raised_unwrapped(httpx_mock) -> None: method="POST", url="https://api.example.test/open/v1/memory/retrieve", status_code=400, - json={"code": "bad_request", "message": "query is required"}, + json={"error": {"code": "bad_request", "message": "query is required"}}, ) client = AsyncMemindClient(base_url="https://api.example.test") @@ -147,9 +144,9 @@ async def test_async_close_then_call_raises_memind_error() -> None: async def test_async_mutating_post_methods_do_not_retry_by_default(httpx_mock) -> None: httpx_mock.add_response( method="POST", - url="https://api.example.test/open/v1/memory/add-message/sync", + url="https://api.example.test/open/v1/memory/sync/add-message", status_code=503, - json={"code": "unavailable"}, + json={"error": {"code": "unavailable", "message": "try later"}}, ) client = AsyncMemindClient(base_url="https://api.example.test", max_retries=2) @@ -167,15 +164,12 @@ async def test_async_retrieve_retries_by_default(httpx_mock) -> None: method="POST", url="https://api.example.test/open/v1/memory/retrieve", status_code=503, - json={"code": "unavailable"}, + json={"error": {"code": "unavailable", "message": "try later"}}, ) httpx_mock.add_response( method="POST", url="https://api.example.test/open/v1/memory/retrieve", - json={ - "code": "success", - "data": {"items": [], "insights": [], "rawData": [], "evidences": []}, - }, + json={"data": {"items": [], "insights": [], "rawData": [], "evidences": []}}, ) client = AsyncMemindClient(base_url="https://api.example.test", max_retries=1) diff --git a/memind-clients/python/tests/test_base_client.py b/memind-clients/python/tests/test_base_client.py index 421c72f2..d7d692a7 100644 --- a/memind-clients/python/tests/test_base_client.py +++ b/memind-clients/python/tests/test_base_client.py @@ -104,7 +104,7 @@ def test_process_success_response_returns_typed_data() -> None: client = InspectableClient(base_url="https://api.example.test") response = make_response( 200, - {"code": "success", "data": {"status": "UP", "service": "memind-server"}}, + {"data": {"status": "UP", "service": "memind-server"}}, ) result = client.process(response, HealthResponse) assert isinstance(result, HealthResponse) @@ -113,13 +113,13 @@ def test_process_success_response_returns_typed_data() -> None: def test_process_success_response_without_data_returns_none() -> None: client = InspectableClient(base_url="https://api.example.test") - response = make_response(200, {"code": "200"}) + response = make_response(200, {"data": None}) assert client.process(response, None) is None def test_process_success_response_data_validation_error_is_api_error() -> None: client = InspectableClient(base_url="https://api.example.test") - response = make_response(200, {"code": "success", "data": {"unexpected": "value"}}) + response = make_response(200, {"data": {"unexpected": "value"}}) with pytest.raises(MemindAPIError) as exc_info: client.process(response, RequiredFieldResponse) @@ -132,20 +132,20 @@ def test_process_api_error_raises_api_error() -> None: client = InspectableClient(base_url="https://api.example.test") response = make_response( 400, - {"code": "bad_request", "message": "query is required", "traceId": "trace-1"}, + {"error": {"code": "bad_request", "message": "query is required"}}, ) with pytest.raises(MemindAPIError) as exc_info: client.process(response, HealthResponse) assert exc_info.value.status_code == 400 assert exc_info.value.error_code == "bad_request" - assert exc_info.value.trace_id == "trace-1" - assert exc_info.value.body["message"] == "query is required" + assert exc_info.value.body is not None + assert exc_info.value.body["error"]["message"] == "query is required" def test_process_authentication_error() -> None: client = InspectableClient(base_url="https://api.example.test") - response = make_response(401, {"code": "unauthorized", "message": "bad token"}) + response = make_response(401, {"error": {"code": "unauthorized", "message": "bad token"}}) with pytest.raises(MemindAuthenticationError): client.process(response, None) @@ -154,7 +154,7 @@ def test_process_rate_limit_error() -> None: client = InspectableClient(base_url="https://api.example.test") response = httpx.Response( 429, - json={"code": "rate_limited", "message": "slow down"}, + json={"error": {"code": "rate_limited", "message": "slow down"}}, headers={"Retry-After": "3"}, request=httpx.Request("POST", "https://x"), ) @@ -168,7 +168,7 @@ def test_process_rate_limit_error_accepts_http_date_retry_after() -> None: client = InspectableClient(base_url="https://api.example.test") response = httpx.Response( 429, - json={"code": "rate_limited", "message": "slow down"}, + json={"error": {"code": "rate_limited", "message": "slow down"}}, headers={"Retry-After": "Wed, 21 Oct 2099 07:28:00 GMT"}, request=httpx.Request("POST", "https://x"), ) diff --git a/memind-clients/python/tests/test_client.py b/memind-clients/python/tests/test_client.py index ebf1f618..8bdefc89 100644 --- a/memind-clients/python/tests/test_client.py +++ b/memind-clients/python/tests/test_client.py @@ -31,7 +31,7 @@ def test_health_returns_response(httpx_mock) -> None: httpx_mock.add_response( method="GET", url="https://api.example.test/open/v1/health", - json={"code": "success", "data": {"status": "UP", "service": "memind-server"}}, + json={"data": {"status": "UP", "service": "memind-server"}}, ) with MemindClient(base_url="https://api.example.test") as client: @@ -45,8 +45,8 @@ def test_health_returns_response(httpx_mock) -> None: def test_add_message_sends_payload_and_auth_header(httpx_mock) -> None: httpx_mock.add_response( method="POST", - url="https://api.example.test/open/v1/memory/add-message/sync", - json={"code": "success", "data": {"triggered": False}}, + url="https://api.example.test/open/v1/memory/sync/add-message", + json={"data": {"triggered": False}}, ) client = MemindClient(base_url="https://api.example.test", api_token="sk-test") @@ -62,9 +62,8 @@ def test_add_message_sends_payload_and_auth_header(httpx_mock) -> None: def test_extract_sends_raw_content(httpx_mock) -> None: httpx_mock.add_response( method="POST", - url="https://api.example.test/open/v1/memory/extract/sync", + url="https://api.example.test/open/v1/memory/sync/extract", json={ - "code": "success", "data": { "status": "SUCCESS", "rawDataIds": ["rd-1"], @@ -93,9 +92,8 @@ def test_extract_sends_raw_content(httpx_mock) -> None: def test_commit_sends_payload(httpx_mock) -> None: httpx_mock.add_response( method="POST", - url="https://api.example.test/open/v1/memory/commit/sync", + url="https://api.example.test/open/v1/memory/sync/commit", json={ - "code": "success", "data": { "status": "SUCCESS", "rawDataIds": [], @@ -116,9 +114,8 @@ def test_commit_sends_payload(httpx_mock) -> None: def test_extract_partial_success_is_returned(httpx_mock) -> None: httpx_mock.add_response( method="POST", - url="https://api.example.test/open/v1/memory/extract/sync", + url="https://api.example.test/open/v1/memory/sync/extract", json={ - "code": "success", "data": { "status": "PARTIAL_SUCCESS", "rawDataIds": ["rd-1"], @@ -145,12 +142,10 @@ def test_extract_partial_success_is_returned(httpx_mock) -> None: def test_extract_failure_envelope_raises_api_error(httpx_mock) -> None: httpx_mock.add_response( method="POST", - url="https://api.example.test/open/v1/memory/extract/sync", + url="https://api.example.test/open/v1/memory/sync/extract", status_code=500, json={ - "code": "extraction_failed", - "message": "extract failed", - "traceId": "t1", + "error": {"code": "extraction_failed", "message": "extract failed"}, }, ) @@ -172,7 +167,6 @@ def test_retrieve_accepts_expanded_parameters(httpx_mock) -> None: method="POST", url="https://api.example.test/open/v1/memory/retrieve", json={ - "code": "success", "data": { "status": "success", "items": [{"id": "1", "text": "likes coffee", "vectorScore": 0.9}], @@ -199,10 +193,7 @@ def test_retrieve_accepts_request_object(httpx_mock) -> None: httpx_mock.add_response( method="POST", url="https://api.example.test/open/v1/memory/retrieve", - json={ - "code": "success", - "data": {"items": [], "insights": [], "rawData": [], "evidences": []}, - }, + json={"data": {"items": [], "insights": [], "rawData": [], "evidences": []}}, ) client = MemindClient(base_url="https://api.example.test") @@ -220,7 +211,14 @@ def test_api_error_is_raised_unwrapped(httpx_mock) -> None: method="POST", url="https://api.example.test/open/v1/memory/retrieve", status_code=400, - json={"code": "bad_request", "message": "query is required", "traceId": "t1"}, + headers={"X-Request-Id": "rid-1"}, + json={ + "error": { + "code": "bad_request", + "message": "query is required", + "details": {"fieldErrors": {"query": "must not be blank"}}, + } + }, ) client = MemindClient(base_url="https://api.example.test") @@ -229,6 +227,8 @@ def test_api_error_is_raised_unwrapped(httpx_mock) -> None: assert exc_info.value.status_code == 400 assert exc_info.value.error_code == "bad_request" + assert exc_info.value.body is not None + assert exc_info.value.body["error"]["details"]["fieldErrors"]["query"] == "must not be blank" client.close() @@ -243,9 +243,9 @@ def test_close_then_call_raises_memind_error() -> None: def test_mutating_post_methods_do_not_retry_by_default(httpx_mock) -> None: httpx_mock.add_response( method="POST", - url="https://api.example.test/open/v1/memory/add-message/sync", + url="https://api.example.test/open/v1/memory/sync/add-message", status_code=503, - json={"code": "unavailable"}, + json={"error": {"code": "unavailable", "message": "try later"}}, ) client = MemindClient(base_url="https://api.example.test", max_retries=2) @@ -262,15 +262,12 @@ def test_retrieve_retries_by_default(httpx_mock) -> None: method="POST", url="https://api.example.test/open/v1/memory/retrieve", status_code=503, - json={"code": "unavailable"}, + json={"error": {"code": "unavailable", "message": "try later"}}, ) httpx_mock.add_response( method="POST", url="https://api.example.test/open/v1/memory/retrieve", - json={ - "code": "success", - "data": {"items": [], "insights": [], "rawData": [], "evidences": []}, - }, + json={"data": {"items": [], "insights": [], "rawData": [], "evidences": []}}, ) client = MemindClient(base_url="https://api.example.test", max_retries=1) diff --git a/memind-clients/python/tests/test_integration.py b/memind-clients/python/tests/test_integration.py new file mode 100644 index 00000000..2d4efc2f --- /dev/null +++ b/memind-clients/python/tests/test_integration.py @@ -0,0 +1,86 @@ +# +# 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. +# + +from __future__ import annotations + +import os +from uuid import uuid4 + +import pytest + +from memind import ConversationContent, MemindAPIError, MemindClient, Message, Strategy + +pytestmark = pytest.mark.skipif( + os.getenv("MEMIND_INTEGRATION_TEST", "").lower() != "true", + reason="Set MEMIND_INTEGRATION_TEST=true to run real-server integration tests", +) + + +def test_client_can_call_real_memind_server_health_and_error_envelope() -> None: + base_url = os.getenv("MEMIND_BASE_URL", "http://localhost:8366") + api_token = os.getenv("MEMIND_API_TOKEN") + suffix = uuid4().hex + + with MemindClient(base_url=base_url, api_token=api_token, timeout=60.0) as client: + health = client.health() + assert health.status.upper() == "UP" + + with pytest.raises(MemindAPIError) as exc_info: + client.memory.retrieve( + user_id=f"python-it-user-{suffix}", + agent_id=f"python-it-agent-{suffix}", + query="", + strategy=Strategy.SIMPLE, + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.error_code == "validation_failed" + assert exc_info.value.body is not None + assert "error" in exc_info.value.body + + +@pytest.mark.skipif( + os.getenv("MEMIND_FULL_MEMORY_FLOW_TEST", "").lower() != "true", + reason="Set MEMIND_FULL_MEMORY_FLOW_TEST=true to run extraction/retrieval integration tests", +) +def test_client_can_run_real_memind_server_memory_flow() -> None: + base_url = os.getenv("MEMIND_BASE_URL", "http://localhost:8366") + api_token = os.getenv("MEMIND_API_TOKEN") + suffix = uuid4().hex + user_id = f"python-it-user-{suffix}" + agent_id = f"python-it-agent-{suffix}" + memory_text = f"Python integration memory {suffix}" + + with MemindClient(base_url=base_url, api_token=api_token, timeout=60.0) as client: + health = client.health() + assert health.status.upper() == "UP" + + extract = client.memory.extract( + user_id=user_id, + agent_id=agent_id, + raw_content=ConversationContent(messages=[Message.user(memory_text)]), + source_client="memind-python-integration-test", + ) + assert extract.status in {"SUCCESS", "PARTIAL_SUCCESS"} + assert extract.raw_data_ids is not None + + retrieved = client.memory.retrieve( + user_id=user_id, + agent_id=agent_id, + query=memory_text, + strategy=Strategy.SIMPLE, + trace=True, + ) + assert retrieved.items + assert any(item.text for item in retrieved.items) diff --git a/memind-clients/python/tests/test_models.py b/memind-clients/python/tests/test_models.py index aa170946..509d87f4 100644 --- a/memind-clients/python/tests/test_models.py +++ b/memind-clients/python/tests/test_models.py @@ -96,22 +96,16 @@ def test_strategy_json_serialization(self) -> None: def test_success_result(self) -> None: data: dict[str, Any] = { - "code": "200", - "message": None, "data": {"status": "UP", "service": "memind-server"}, - "timestamp": "2026-01-01T00:00:00Z", - "traceId": "trace-1", } result = ApiResult[dict[str, str]].model_validate(data) - assert result.code == "200" assert result.data == {"status": "UP", "service": "memind-server"} - assert result.trace_id == "trace-1" assert result.is_success() def test_unknown_fields_ignored(self) -> None: - data: dict[str, Any] = {"code": "200", "data": None, "extraField": "ignored"} + data: dict[str, Any] = {"data": None, "extraField": "ignored"} result = ApiResult[None].model_validate(data) - assert result.code == "200" + assert result.data is None class TestMessageTypes: diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/configuration/MemindServerCorsConfiguration.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/configuration/MemindServerCorsConfiguration.java index 04c7dc6e..7e05aa23 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/configuration/MemindServerCorsConfiguration.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/configuration/MemindServerCorsConfiguration.java @@ -34,6 +34,7 @@ static CorsConfiguration openCorsConfiguration() { configuration.addAllowedOrigin(CorsConfiguration.ALL); configuration.addAllowedMethod(CorsConfiguration.ALL); configuration.addAllowedHeader(CorsConfiguration.ALL); + configuration.addExposedHeader(RequestIdFilter.HEADER); configuration.setAllowCredentials(false); return configuration; } diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/configuration/RequestIdFilter.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/configuration/RequestIdFilter.java new file mode 100644 index 00000000..74fb771e --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/configuration/RequestIdFilter.java @@ -0,0 +1,50 @@ +/* + * 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 com.openmemind.ai.memory.server.configuration; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.UUID; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class RequestIdFilter extends OncePerRequestFilter { + + public static final String HEADER = "X-Request-Id"; + public static final String MDC_KEY = "requestId"; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String requestId = request.getHeader(HEADER); + if (!StringUtils.hasText(requestId)) { + requestId = UUID.randomUUID().toString(); + } + + MDC.put(MDC_KEY, requestId); + response.setHeader(HEADER, requestId); + try { + filterChain.doFilter(request, response); + } finally { + MDC.remove(MDC_KEY); + } + } +} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/buffer/AdminBufferController.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/buffer/AdminBufferController.java index 61a6ccae..0a2ee256 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/buffer/AdminBufferController.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/buffer/AdminBufferController.java @@ -23,9 +23,9 @@ import com.openmemind.ai.memory.server.domain.buffer.view.InsightBufferGroupView; import com.openmemind.ai.memory.server.domain.buffer.view.InsightBufferView; import com.openmemind.ai.memory.server.domain.common.AdminUpdateResult; -import com.openmemind.ai.memory.server.domain.common.ApiResult; import com.openmemind.ai.memory.server.domain.common.BatchDeleteResult; import com.openmemind.ai.memory.server.domain.common.PageResult; +import com.openmemind.ai.memory.server.domain.common.SuccessResult; import com.openmemind.ai.memory.server.service.buffer.BufferManagementService; import com.openmemind.ai.memory.server.service.buffer.BufferQueryService; import jakarta.validation.Valid; @@ -57,74 +57,74 @@ public AdminBufferController( } @GetMapping("/conversations") - public ApiResult> conversations( - @RequestParam(defaultValue = "1") @Min(1) int pageNo, + public SuccessResult> conversations( + @RequestParam(name = "page", defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int pageSize, @RequestParam(required = false) String memoryId, @RequestParam(required = false) String sessionId, @RequestParam(defaultValue = "pending") String state) { - return ApiResult.success( + return new SuccessResult<>( PageResult.from( queryService.listConversations( ConversationBufferPageQuery.of( - pageNo, pageSize, memoryId, sessionId, state)))); + page, pageSize, memoryId, sessionId, state)))); } @GetMapping("/conversations/{id}") - public ApiResult conversationDetail(@PathVariable Long id) { - return ApiResult.success(queryService.getConversation(id)); + public SuccessResult conversationDetail(@PathVariable Long id) { + return new SuccessResult<>(queryService.getConversation(id)); } @PatchMapping("/conversations/extracted") - public ApiResult markConversationsExtracted( + public SuccessResult markConversationsExtracted( @Valid @RequestBody AdminLongIdsRequest request) { - return ApiResult.success(managementService.markConversationsExtracted(request.ids())); + return new SuccessResult<>(managementService.markConversationsExtracted(request.ids())); } @DeleteMapping("/conversations") - public ApiResult deleteConversations( + public SuccessResult deleteConversations( @Valid @RequestBody AdminLongIdsRequest request) { - return ApiResult.success(managementService.deleteConversations(request.ids())); + return new SuccessResult<>(managementService.deleteConversations(request.ids())); } @GetMapping("/insights") - public ApiResult> insights( - @RequestParam(defaultValue = "1") @Min(1) int pageNo, + public SuccessResult> insights( + @RequestParam(name = "page", defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int pageSize, @RequestParam(required = false) String memoryId, @RequestParam(required = false) String insightTypeName, @RequestParam(defaultValue = "unbuilt") String state) { - return ApiResult.success( + return new SuccessResult<>( PageResult.from( queryService.listInsights( InsightBufferPageQuery.of( - pageNo, pageSize, memoryId, insightTypeName, state)))); + page, pageSize, memoryId, insightTypeName, state)))); } @GetMapping("/insights/groups") - public ApiResult> insightGroups( + public SuccessResult> insightGroups( @RequestParam(required = false) String memoryId, @RequestParam(required = false) String insightTypeName) { - return ApiResult.success(queryService.listInsightGroups(memoryId, insightTypeName)); + return new SuccessResult<>(queryService.listInsightGroups(memoryId, insightTypeName)); } @PatchMapping("/insights/group") - public ApiResult updateInsightGroup( + public SuccessResult updateInsightGroup( @Valid @RequestBody InsightBufferGroupUpdateRequest request) { - return ApiResult.success( + return new SuccessResult<>( managementService.updateInsightGroup(request.ids(), request.groupName())); } @PatchMapping("/insights/built") - public ApiResult updateInsightBuilt( + public SuccessResult updateInsightBuilt( @Valid @RequestBody InsightBufferBuiltUpdateRequest request) { - return ApiResult.success( + return new SuccessResult<>( managementService.updateInsightBuilt(request.ids(), request.built())); } @DeleteMapping("/insights") - public ApiResult deleteInsights( + public SuccessResult deleteInsights( @Valid @RequestBody AdminIdsRequest request) { - return ApiResult.success(managementService.deleteInsightBuffers(request.ids())); + return new SuccessResult<>(managementService.deleteInsightBuffers(request.ids())); } } diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/dashboard/AdminDashboardController.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/dashboard/AdminDashboardController.java index 4aea3336..862a4e90 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/dashboard/AdminDashboardController.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/dashboard/AdminDashboardController.java @@ -13,7 +13,7 @@ */ package com.openmemind.ai.memory.server.controller.admin.dashboard; -import com.openmemind.ai.memory.server.domain.common.ApiResult; +import com.openmemind.ai.memory.server.domain.common.SuccessResult; import com.openmemind.ai.memory.server.domain.dashboard.view.AdminDashboardView; import com.openmemind.ai.memory.server.service.dashboard.DashboardQueryService; import jakarta.validation.constraints.Max; @@ -36,12 +36,12 @@ public AdminDashboardController(DashboardQueryService queryService) { } @GetMapping - public ApiResult get( + public SuccessResult get( @RequestParam(required = false) String memoryId, @RequestParam(defaultValue = "7") @Min(1) @Max(30) int days) { if (days < 1 || days > 30) { throw new IllegalArgumentException("days must be between 1 and 30"); } - return ApiResult.success(queryService.getDashboard(memoryId, days)); + return new SuccessResult<>(queryService.getDashboard(memoryId, days)); } } diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/insight/AdminInsightController.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/insight/AdminInsightController.java index ed1da990..91e8063b 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/insight/AdminInsightController.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/insight/AdminInsightController.java @@ -13,9 +13,9 @@ */ package com.openmemind.ai.memory.server.controller.admin.insight; -import com.openmemind.ai.memory.server.domain.common.ApiResult; import com.openmemind.ai.memory.server.domain.common.BatchDeleteResult; import com.openmemind.ai.memory.server.domain.common.PageResult; +import com.openmemind.ai.memory.server.domain.common.SuccessResult; import com.openmemind.ai.memory.server.domain.insight.query.InsightPageQuery; import com.openmemind.ai.memory.server.domain.insight.request.InsightDeleteRequest; import com.openmemind.ai.memory.server.domain.insight.view.AdminInsightView; @@ -48,28 +48,29 @@ public AdminInsightController( } @GetMapping - public ApiResult> page( - @RequestParam(defaultValue = "1") @Min(1) int pageNo, + public SuccessResult> page( + @RequestParam(name = "page", defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int pageSize, @RequestParam(required = false) String userId, @RequestParam(required = false) String agentId, @RequestParam(required = false) String scope, @RequestParam(required = false) String type, @RequestParam(required = false) String tier) { - return ApiResult.success( + return new SuccessResult<>( PageResult.from( queryService.listInsights( InsightPageQuery.of( - pageNo, pageSize, userId, agentId, scope, type, tier)))); + page, pageSize, userId, agentId, scope, type, tier)))); } @GetMapping("/{insightId}") - public ApiResult detail(@PathVariable Long insightId) { - return ApiResult.success(queryService.getInsight(insightId)); + public SuccessResult detail(@PathVariable Long insightId) { + return new SuccessResult<>(queryService.getInsight(insightId)); } @DeleteMapping - public ApiResult delete(@Valid @RequestBody InsightDeleteRequest request) { - return ApiResult.success(deleteService.deleteInsights(request.insightIds())); + public SuccessResult delete( + @Valid @RequestBody InsightDeleteRequest request) { + return new SuccessResult<>(deleteService.deleteInsights(request.insightIds())); } } diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/item/AdminItemController.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/item/AdminItemController.java index 45c52070..f4dc7c33 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/item/AdminItemController.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/item/AdminItemController.java @@ -13,9 +13,9 @@ */ package com.openmemind.ai.memory.server.controller.admin.item; -import com.openmemind.ai.memory.server.domain.common.ApiResult; import com.openmemind.ai.memory.server.domain.common.BatchDeleteResult; import com.openmemind.ai.memory.server.domain.common.PageResult; +import com.openmemind.ai.memory.server.domain.common.SuccessResult; import com.openmemind.ai.memory.server.domain.item.query.ItemPageQuery; import com.openmemind.ai.memory.server.domain.item.request.ItemDeleteRequest; import com.openmemind.ai.memory.server.domain.item.view.AdminItemView; @@ -54,8 +54,8 @@ public AdminItemController( } @GetMapping - public ApiResult> page( - @RequestParam(defaultValue = "1") @Min(1) int pageNo, + public SuccessResult> page( + @RequestParam(name = "page", defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int pageSize, @RequestParam(required = false) String userId, @RequestParam(required = false) String agentId, @@ -63,30 +63,30 @@ public ApiResult> page( @RequestParam(required = false) String category, @RequestParam(required = false) String type, @RequestParam(required = false) String rawDataId) { - return ApiResult.success( + return new SuccessResult<>( PageResult.from( queryService.listItems( ItemPageQuery.of( - pageNo, pageSize, userId, agentId, scope, category, type, + page, pageSize, userId, agentId, scope, category, type, rawDataId)))); } @GetMapping("/{itemId}") - public ApiResult detail(@PathVariable Long itemId) { - return ApiResult.success(queryService.getItem(itemId)); + public SuccessResult detail(@PathVariable Long itemId) { + return new SuccessResult<>(queryService.getItem(itemId)); } @GetMapping("/{itemId}/memory-threads") - public ApiResult> itemThreads( + public SuccessResult> itemThreads( @PathVariable Long itemId, @RequestParam String userId, @RequestParam(required = false) String agentId) { - return ApiResult.success( + return new SuccessResult<>( memoryThreadQueryService.listThreadsByItemId(userId, agentId, itemId)); } @DeleteMapping - public ApiResult delete(@Valid @RequestBody ItemDeleteRequest request) { - return ApiResult.success(deleteService.deleteItems(request.itemIds())); + public SuccessResult delete(@Valid @RequestBody ItemDeleteRequest request) { + return new SuccessResult<>(deleteService.deleteItems(request.itemIds())); } } diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/itemgraph/AdminItemGraphController.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/itemgraph/AdminItemGraphController.java index 69117135..8444ddf7 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/itemgraph/AdminItemGraphController.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/itemgraph/AdminItemGraphController.java @@ -13,9 +13,9 @@ */ package com.openmemind.ai.memory.server.controller.admin.itemgraph; -import com.openmemind.ai.memory.server.domain.common.ApiResult; import com.openmemind.ai.memory.server.domain.common.BatchDeleteResult; import com.openmemind.ai.memory.server.domain.common.PageResult; +import com.openmemind.ai.memory.server.domain.common.SuccessResult; import com.openmemind.ai.memory.server.domain.itemgraph.query.ItemGraphPageQueries; import com.openmemind.ai.memory.server.domain.itemgraph.request.GraphEntityDeleteRequest; import com.openmemind.ai.memory.server.domain.itemgraph.request.GraphIdsRequest; @@ -51,89 +51,90 @@ public AdminItemGraphController( } @GetMapping("/summary") - public ApiResult summary( + public SuccessResult summary( @RequestParam(required = false) String memoryId) { - return ApiResult.success(queryService.summary(memoryId)); + return new SuccessResult<>(queryService.summary(memoryId)); } @GetMapping("/entities") - public ApiResult> entities( - @RequestParam(defaultValue = "1") @Min(1) int pageNo, + public SuccessResult> entities( + @RequestParam(name = "page", defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int pageSize, @RequestParam(required = false) String memoryId, @RequestParam(required = false) String entityType, @RequestParam(required = false) String q) { - return ApiResult.success( + return new SuccessResult<>( PageResult.from( queryService.listEntities( new ItemGraphPageQueries.EntityPageQuery( - pageNo, pageSize, memoryId, entityType, q)))); + page, pageSize, memoryId, entityType, q)))); } @GetMapping("/entities/{id}") - public ApiResult entityDetail(@PathVariable Integer id) { - return ApiResult.success(queryService.getEntity(id)); + public SuccessResult entityDetail(@PathVariable Integer id) { + return new SuccessResult<>(queryService.getEntity(id)); } @DeleteMapping("/entities") - public ApiResult deleteEntities( + public SuccessResult deleteEntities( @Valid @RequestBody GraphEntityDeleteRequest request) { - return ApiResult.success( + return new SuccessResult<>( managementService.deleteEntities(request.memoryId(), request.entityKeys())); } @GetMapping("/aliases") - public ApiResult> aliases( - @RequestParam(defaultValue = "1") @Min(1) int pageNo, + public SuccessResult> aliases( + @RequestParam(name = "page", defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int pageSize, @RequestParam(required = false) String memoryId, @RequestParam(required = false) String entityKey, @RequestParam(required = false) String q) { - return ApiResult.success( + return new SuccessResult<>( PageResult.from( queryService.listAliases( new ItemGraphPageQueries.AliasPageQuery( - pageNo, pageSize, memoryId, entityKey, q)))); + page, pageSize, memoryId, entityKey, q)))); } @DeleteMapping("/aliases") - public ApiResult deleteAliases(@Valid @RequestBody GraphIdsRequest request) { - return ApiResult.success(managementService.deleteAliases(request.ids())); + public SuccessResult deleteAliases( + @Valid @RequestBody GraphIdsRequest request) { + return new SuccessResult<>(managementService.deleteAliases(request.ids())); } @GetMapping("/mentions") - public ApiResult> mentions( - @RequestParam(defaultValue = "1") @Min(1) int pageNo, + public SuccessResult> mentions( + @RequestParam(name = "page", defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int pageSize, @RequestParam(required = false) String memoryId, @RequestParam(required = false) Long itemId, @RequestParam(required = false) String entityKey) { - return ApiResult.success( + return new SuccessResult<>( PageResult.from( queryService.listMentions( new ItemGraphPageQueries.MentionPageQuery( - pageNo, pageSize, memoryId, itemId, entityKey)))); + page, pageSize, memoryId, itemId, entityKey)))); } @DeleteMapping("/mentions") - public ApiResult deleteMentions( + public SuccessResult deleteMentions( @Valid @RequestBody GraphIdsRequest request) { - return ApiResult.success(managementService.deleteMentions(request.ids())); + return new SuccessResult<>(managementService.deleteMentions(request.ids())); } @GetMapping("/item-links") - public ApiResult> itemLinks( - @RequestParam(defaultValue = "1") @Min(1) int pageNo, + public SuccessResult> itemLinks( + @RequestParam(name = "page", defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int pageSize, @RequestParam(required = false) String memoryId, @RequestParam(required = false) Long itemId, @RequestParam(required = false) String linkType, @RequestParam(required = false) String evidenceSource) { - return ApiResult.success( + return new SuccessResult<>( PageResult.from( queryService.listItemLinks( new ItemGraphPageQueries.ItemLinkPageQuery( - pageNo, + page, pageSize, memoryId, itemId, @@ -142,40 +143,40 @@ public ApiResult> itemLinks( } @DeleteMapping("/item-links") - public ApiResult deleteItemLinks( + public SuccessResult deleteItemLinks( @Valid @RequestBody GraphIdsRequest request) { - return ApiResult.success(managementService.deleteItemLinks(request.ids())); + return new SuccessResult<>(managementService.deleteItemLinks(request.ids())); } @GetMapping("/cooccurrences") - public ApiResult> cooccurrences( - @RequestParam(defaultValue = "1") @Min(1) int pageNo, + public SuccessResult> cooccurrences( + @RequestParam(name = "page", defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int pageSize, @RequestParam(required = false) String memoryId, @RequestParam(required = false) String entityKey) { - return ApiResult.success( + return new SuccessResult<>( PageResult.from( queryService.listCooccurrences( new ItemGraphPageQueries.CooccurrencePageQuery( - pageNo, pageSize, memoryId, entityKey)))); + page, pageSize, memoryId, entityKey)))); } @DeleteMapping("/cooccurrences") - public ApiResult deleteCooccurrences( + public SuccessResult deleteCooccurrences( @Valid @RequestBody GraphIdsRequest request) { - return ApiResult.success(managementService.deleteCooccurrences(request.ids())); + return new SuccessResult<>(managementService.deleteCooccurrences(request.ids())); } @GetMapping("/batches") - public ApiResult> batches( - @RequestParam(defaultValue = "1") @Min(1) int pageNo, + public SuccessResult> batches( + @RequestParam(name = "page", defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int pageSize, @RequestParam(required = false) String memoryId, @RequestParam(required = false) String state) { - return ApiResult.success( + return new SuccessResult<>( PageResult.from( queryService.listBatches( new ItemGraphPageQueries.BatchPageQuery( - pageNo, pageSize, memoryId, state)))); + page, pageSize, memoryId, state)))); } } diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/memoryoption/MemoryOptionsController.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/memoryoption/MemoryOptionsController.java index 00af2730..3e1f4ea2 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/memoryoption/MemoryOptionsController.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/memoryoption/MemoryOptionsController.java @@ -13,7 +13,7 @@ */ package com.openmemind.ai.memory.server.controller.admin.memoryoption; -import com.openmemind.ai.memory.server.domain.common.ApiResult; +import com.openmemind.ai.memory.server.domain.common.SuccessResult; import com.openmemind.ai.memory.server.domain.config.request.MemoryOptionsPutRequest; import com.openmemind.ai.memory.server.domain.config.response.MemoryOptionsGetResponse; import com.openmemind.ai.memory.server.service.config.MemoryOptionService; @@ -35,17 +35,17 @@ public MemoryOptionsController(MemoryOptionService memoryOptionService) { } @GetMapping - public ApiResult get() { + public SuccessResult get() { var snapshot = memoryOptionService.getCurrent(); - return ApiResult.success( + return new SuccessResult<>( new MemoryOptionsGetResponse(snapshot.version(), snapshot.config())); } @PutMapping - public ApiResult update( + public SuccessResult update( @Valid @RequestBody MemoryOptionsPutRequest request) { var snapshot = memoryOptionService.update(request.expectedVersion(), request.config()); - return ApiResult.success( + return new SuccessResult<>( new MemoryOptionsGetResponse(snapshot.version(), snapshot.config())); } } diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/memorythread/AdminMemoryThreadController.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/memorythread/AdminMemoryThreadController.java index caaef693..27db3c13 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/memorythread/AdminMemoryThreadController.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/memorythread/AdminMemoryThreadController.java @@ -13,8 +13,8 @@ */ package com.openmemind.ai.memory.server.controller.admin.memorythread; -import com.openmemind.ai.memory.server.domain.common.ApiResult; import com.openmemind.ai.memory.server.domain.common.PageResult; +import com.openmemind.ai.memory.server.domain.common.SuccessResult; import com.openmemind.ai.memory.server.domain.memorythread.query.MemoryThreadPageQuery; import com.openmemind.ai.memory.server.domain.memorythread.view.AdminMemoryThreadItemView; import com.openmemind.ai.memory.server.domain.memorythread.view.AdminMemoryThreadStatusView; @@ -47,44 +47,44 @@ public AdminMemoryThreadController( } @GetMapping - public ApiResult> page( - @RequestParam(defaultValue = "1") @Min(1) int pageNo, + public SuccessResult> page( + @RequestParam(name = "page", defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int pageSize, @RequestParam(required = false) String userId, @RequestParam(required = false) String agentId, @RequestParam(required = false) String status) { - return ApiResult.success( + return new SuccessResult<>( PageResult.from( queryService.listThreads( MemoryThreadPageQuery.of( - pageNo, pageSize, userId, agentId, status)))); + page, pageSize, userId, agentId, status)))); } @GetMapping("/{threadKey}") - public ApiResult detail( + public SuccessResult detail( @PathVariable String threadKey, @RequestParam String userId, @RequestParam(required = false) String agentId) { - return ApiResult.success(queryService.getThread(userId, agentId, threadKey)); + return new SuccessResult<>(queryService.getThread(userId, agentId, threadKey)); } @GetMapping("/{threadKey}/items") - public ApiResult> items( + public SuccessResult> items( @PathVariable String threadKey, @RequestParam String userId, @RequestParam(required = false) String agentId) { - return ApiResult.success(queryService.listThreadItems(userId, agentId, threadKey)); + return new SuccessResult<>(queryService.listThreadItems(userId, agentId, threadKey)); } @GetMapping("/status") - public ApiResult status( + public SuccessResult status( @RequestParam String userId, @RequestParam(required = false) String agentId) { - return ApiResult.success(queryService.getStatus(userId, agentId)); + return new SuccessResult<>(queryService.getStatus(userId, agentId)); } @PostMapping("/rebuild") - public ApiResult rebuild( + public SuccessResult rebuild( @RequestParam String userId, @RequestParam(required = false) String agentId) { - return ApiResult.success(rebuildService.rebuild(userId, agentId)); + return new SuccessResult<>(rebuildService.rebuild(userId, agentId)); } } diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/rawdata/AdminRawDataController.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/rawdata/AdminRawDataController.java index 5d790e83..9e50b7d6 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/rawdata/AdminRawDataController.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/admin/rawdata/AdminRawDataController.java @@ -13,8 +13,8 @@ */ package com.openmemind.ai.memory.server.controller.admin.rawdata; -import com.openmemind.ai.memory.server.domain.common.ApiResult; import com.openmemind.ai.memory.server.domain.common.PageResult; +import com.openmemind.ai.memory.server.domain.common.SuccessResult; import com.openmemind.ai.memory.server.domain.rawdata.query.RawDataPageQuery; import com.openmemind.ai.memory.server.domain.rawdata.request.RawDataDeleteRequest; import com.openmemind.ai.memory.server.domain.rawdata.response.RawDataDeleteResult; @@ -49,18 +49,18 @@ public AdminRawDataController( } @GetMapping - public ApiResult> page( - @RequestParam(defaultValue = "1") @Min(1) int pageNo, + public SuccessResult> page( + @RequestParam(name = "page", defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int pageSize, @RequestParam(required = false) String userId, @RequestParam(required = false) String agentId, @RequestParam(required = false) Instant startTimeFrom, @RequestParam(required = false) Instant startTimeTo) { - return ApiResult.success( + return new SuccessResult<>( PageResult.from( queryService.listRawData( RawDataPageQuery.of( - pageNo, + page, pageSize, userId, agentId, @@ -69,12 +69,13 @@ public ApiResult> page( } @GetMapping("/{rawDataId}") - public ApiResult detail(@PathVariable String rawDataId) { - return ApiResult.success(queryService.getRawData(rawDataId)); + public SuccessResult detail(@PathVariable String rawDataId) { + return new SuccessResult<>(queryService.getRawData(rawDataId)); } @DeleteMapping - public ApiResult delete(@Valid @RequestBody RawDataDeleteRequest request) { - return ApiResult.success(deleteService.deleteRawData(request.rawDataIds())); + public SuccessResult delete( + @Valid @RequestBody RawDataDeleteRequest request) { + return new SuccessResult<>(deleteService.deleteRawData(request.rawDataIds())); } } diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenHealthController.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenHealthController.java index 81f80bca..9bbf9a8e 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenHealthController.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenHealthController.java @@ -13,7 +13,7 @@ */ package com.openmemind.ai.memory.server.controller.openapi; -import com.openmemind.ai.memory.server.domain.common.ApiResult; +import com.openmemind.ai.memory.server.domain.common.SuccessResult; import com.openmemind.ai.memory.server.domain.memory.response.OpenMemoryHealthResponse; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -24,7 +24,7 @@ public class OpenHealthController { @GetMapping("/health") - public ApiResult health() { - return ApiResult.success(new OpenMemoryHealthResponse("UP", "memind-server")); + public SuccessResult health() { + return new SuccessResult<>(new OpenMemoryHealthResponse("UP", "memind-server")); } } diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryAsyncController.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryAsyncController.java new file mode 100644 index 00000000..58a57bb0 --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryAsyncController.java @@ -0,0 +1,68 @@ +/* + * 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 com.openmemind.ai.memory.server.controller.openapi; + +import com.openmemind.ai.memory.server.domain.common.OperationAccepted; +import com.openmemind.ai.memory.server.domain.common.SuccessResult; +import com.openmemind.ai.memory.server.domain.memory.request.AddMessageRequest; +import com.openmemind.ai.memory.server.domain.memory.request.CommitMemoryRequest; +import com.openmemind.ai.memory.server.domain.memory.request.ExtractMemoryRequest; +import com.openmemind.ai.memory.server.service.memory.OpenMemoryApplicationService; +import jakarta.validation.Valid; +import java.util.UUID; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/open/v1/memory/async") +public class OpenMemoryAsyncController { + + private final OpenMemoryApplicationService service; + + public OpenMemoryAsyncController(OpenMemoryApplicationService service) { + this.service = service; + } + + @PostMapping("/extract") + @ResponseStatus(HttpStatus.ACCEPTED) + public SuccessResult extract( + @Valid @RequestBody ExtractMemoryRequest request) { + service.extractAsync(request); + return new SuccessResult<>(accepted()); + } + + @PostMapping("/add-message") + @ResponseStatus(HttpStatus.ACCEPTED) + public SuccessResult addMessage( + @Valid @RequestBody AddMessageRequest request) { + service.addMessageAsync(request); + return new SuccessResult<>(accepted()); + } + + @PostMapping("/commit") + @ResponseStatus(HttpStatus.ACCEPTED) + public SuccessResult commit( + @Valid @RequestBody CommitMemoryRequest request) { + service.commitAsync(request); + return new SuccessResult<>(accepted()); + } + + private static OperationAccepted accepted() { + return new OperationAccepted("op_" + UUID.randomUUID(), "accepted", "async"); + } +} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryController.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryController.java deleted file mode 100644 index 04da1311..00000000 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryController.java +++ /dev/null @@ -1,114 +0,0 @@ -/* - * 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 com.openmemind.ai.memory.server.controller.openapi; - -import com.openmemind.ai.memory.server.domain.common.ApiResult; -import com.openmemind.ai.memory.server.domain.memory.request.AddMessageRequest; -import com.openmemind.ai.memory.server.domain.memory.request.CommitMemoryRequest; -import com.openmemind.ai.memory.server.domain.memory.request.ExtractMemoryRequest; -import com.openmemind.ai.memory.server.domain.memory.request.RetrieveMemoryRequest; -import com.openmemind.ai.memory.server.domain.memory.response.AddMessageResponse; -import com.openmemind.ai.memory.server.domain.memory.response.ExtractMemoryResponse; -import com.openmemind.ai.memory.server.domain.memory.response.RetrieveMemoryResponse; -import com.openmemind.ai.memory.server.service.memory.OpenMemoryApplicationService; -import jakarta.validation.Valid; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import reactor.core.publisher.Mono; - -@RestController -@RequestMapping("/open/v1/memory") -public class OpenMemoryController { - - private final OpenMemoryApplicationService service; - - public OpenMemoryController(OpenMemoryApplicationService service) { - this.service = service; - } - - @PostMapping("/extract") - public Mono> extract(@Valid @RequestBody ExtractMemoryRequest request) { - service.extractAsync(request); - return Mono.just(ApiResult.ok()); - } - - @PostMapping("/add-message") - public Mono> addMessage(@Valid @RequestBody AddMessageRequest request) { - service.addMessageAsync(request); - return Mono.just(ApiResult.ok()); - } - - @PostMapping("/commit") - public Mono> commit(@Valid @RequestBody CommitMemoryRequest request) { - service.commitAsync(request); - return Mono.just(ApiResult.ok()); - } - - @PostMapping("/extract/sync") - public ResponseEntity> extractSync( - @Valid @RequestBody ExtractMemoryRequest request) { - return extractionResponse(service.extract(request)); - } - - @PostMapping("/add-message/sync") - public ResponseEntity> addMessageSync( - @Valid @RequestBody AddMessageRequest request) { - AddMessageResponse response = service.addMessage(request); - if (!response.triggered() || response.result() == null) { - return ResponseEntity.ok(ApiResult.success(response)); - } - if (isFailed(response.result())) { - return failedExtraction(response.result()); - } - return ResponseEntity.ok(ApiResult.success(response)); - } - - @PostMapping("/commit/sync") - public ResponseEntity> commitSync( - @Valid @RequestBody CommitMemoryRequest request) { - return extractionResponse(service.commit(request)); - } - - private static ResponseEntity> extractionResponse( - ExtractMemoryResponse response) { - if (isFailed(response)) { - return failedExtraction(response); - } - return ResponseEntity.ok(ApiResult.success(response)); - } - - private static ResponseEntity> failedExtraction( - ExtractMemoryResponse response) { - String message = - response.errorMessage() == null || response.errorMessage().isBlank() - ? "Memory extraction failed" - : response.errorMessage(); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResult.failure("extraction_failed", message, null, null)); - } - - private static boolean isFailed(ExtractMemoryResponse response) { - return response != null && "FAILED".equals(response.status()); - } - - @PostMapping("/retrieve") - public ApiResult retrieve( - @Valid @RequestBody RetrieveMemoryRequest request) { - return ApiResult.success(service.retrieve(request)); - } -} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryQueryController.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryQueryController.java new file mode 100644 index 00000000..0e7a6533 --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryQueryController.java @@ -0,0 +1,41 @@ +/* + * 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 com.openmemind.ai.memory.server.controller.openapi; + +import com.openmemind.ai.memory.server.domain.common.SuccessResult; +import com.openmemind.ai.memory.server.domain.memory.request.RetrieveMemoryRequest; +import com.openmemind.ai.memory.server.domain.memory.response.RetrieveMemoryResponse; +import com.openmemind.ai.memory.server.service.memory.OpenMemoryApplicationService; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/open/v1/memory") +public class OpenMemoryQueryController { + + private final OpenMemoryApplicationService service; + + public OpenMemoryQueryController(OpenMemoryApplicationService service) { + this.service = service; + } + + @PostMapping("/retrieve") + public SuccessResult retrieve( + @Valid @RequestBody RetrieveMemoryRequest request) { + return new SuccessResult<>(service.retrieve(request)); + } +} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemorySyncController.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemorySyncController.java new file mode 100644 index 00000000..b3590db4 --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemorySyncController.java @@ -0,0 +1,95 @@ +/* + * 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 com.openmemind.ai.memory.server.controller.openapi; + +import com.openmemind.ai.memory.server.domain.common.ApiError; +import com.openmemind.ai.memory.server.domain.common.ApiErrorCode; +import com.openmemind.ai.memory.server.domain.common.ApiResult; +import com.openmemind.ai.memory.server.domain.common.ErrorResult; +import com.openmemind.ai.memory.server.domain.common.OperationErrorDetails; +import com.openmemind.ai.memory.server.domain.common.SuccessResult; +import com.openmemind.ai.memory.server.domain.memory.request.AddMessageRequest; +import com.openmemind.ai.memory.server.domain.memory.request.CommitMemoryRequest; +import com.openmemind.ai.memory.server.domain.memory.request.ExtractMemoryRequest; +import com.openmemind.ai.memory.server.domain.memory.response.AddMessageResponse; +import com.openmemind.ai.memory.server.domain.memory.response.ExtractMemoryResponse; +import com.openmemind.ai.memory.server.service.memory.OpenMemoryApplicationService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/open/v1/memory/sync") +public class OpenMemorySyncController { + + private static final String FAILURE_MESSAGE = "Memory extraction failed"; + + private final OpenMemoryApplicationService service; + + public OpenMemorySyncController(OpenMemoryApplicationService service) { + this.service = service; + } + + @PostMapping("/extract") + public ResponseEntity> extract(@Valid @RequestBody ExtractMemoryRequest request) { + return extractionResponse("extract", service.extract(request)); + } + + @PostMapping("/add-message") + public ResponseEntity> addMessage(@Valid @RequestBody AddMessageRequest request) { + AddMessageResponse response = service.addMessage(request); + if (!response.triggered() || response.result() == null) { + return ResponseEntity.ok(new SuccessResult<>(response)); + } + if (isFailed(response.result())) { + return failedExtraction("add-message", response.result()); + } + return ResponseEntity.ok(new SuccessResult<>(response)); + } + + @PostMapping("/commit") + public ResponseEntity> commit(@Valid @RequestBody CommitMemoryRequest request) { + return extractionResponse("commit", service.commit(request)); + } + + private static ResponseEntity> extractionResponse( + String operation, ExtractMemoryResponse response) { + if (isFailed(response)) { + return failedExtraction(operation, response); + } + return ResponseEntity.ok(new SuccessResult<>(response)); + } + + private static ResponseEntity> failedExtraction( + String operation, ExtractMemoryResponse response) { + String reason = + response.errorMessage() == null || response.errorMessage().isBlank() + ? FAILURE_MESSAGE + : response.errorMessage(); + OperationErrorDetails details = new OperationErrorDetails(operation, reason); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body( + new ErrorResult<>( + new ApiError<>( + ApiErrorCode.INTERNAL_ERROR, FAILURE_MESSAGE, details))); + } + + private static boolean isFailed(ExtractMemoryResponse response) { + return response != null && "FAILED".equals(response.status()); + } +} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/ApiError.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/ApiError.java new file mode 100644 index 00000000..beafbbe4 --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/ApiError.java @@ -0,0 +1,19 @@ +/* + * 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 com.openmemind.ai.memory.server.domain.common; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ApiError(ApiErrorCode code, String message, T details) {} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/ApiErrorCode.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/ApiErrorCode.java new file mode 100644 index 00000000..b84ff8ec --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/ApiErrorCode.java @@ -0,0 +1,46 @@ +/* + * 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 com.openmemind.ai.memory.server.domain.common; + +import com.fasterxml.jackson.annotation.JsonValue; +import java.util.Arrays; + +public enum ApiErrorCode { + BAD_REQUEST("bad_request"), + VALIDATION_FAILED("validation_failed"), + MALFORMED_JSON("malformed_json"), + NOT_FOUND("not_found"), + VERSION_CONFLICT("version_conflict"), + RUNTIME_UNAVAILABLE("runtime_unavailable"), + DEPENDENCY_UNAVAILABLE("dependency_unavailable"), + INTERNAL_ERROR("internal_error"); + + private final String value; + + ApiErrorCode(String value) { + this.value = value; + } + + public static ApiErrorCode fromValue(String value) { + return Arrays.stream(values()) + .filter(code -> code.value.equals(value)) + .findFirst() + .orElse(INTERNAL_ERROR); + } + + @JsonValue + public String value() { + return value; + } +} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/ApiResult.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/ApiResult.java index c540c3fd..725dc688 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/ApiResult.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/ApiResult.java @@ -13,24 +13,4 @@ */ package com.openmemind.ai.memory.server.domain.common; -import com.fasterxml.jackson.annotation.JsonInclude; -import java.time.Instant; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public record ApiResult(String code, String message, T data, Instant timestamp, String traceId) { - - public static final String OK_CODE = "200"; - public static final String SUCCESS_CODE = "success"; - - public static ApiResult ok() { - return new ApiResult<>(OK_CODE, null, null, Instant.now(), null); - } - - public static ApiResult success(T data) { - return new ApiResult<>(SUCCESS_CODE, null, data, Instant.now(), null); - } - - public static ApiResult failure(String code, String message, T data, String traceId) { - return new ApiResult<>(code, message, data, Instant.now(), traceId); - } -} +public sealed interface ApiResult permits SuccessResult, ErrorResult {} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/ErrorResult.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/ErrorResult.java new file mode 100644 index 00000000..d7c54850 --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/ErrorResult.java @@ -0,0 +1,19 @@ +/* + * 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 com.openmemind.ai.memory.server.domain.common; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ErrorResult(ApiError error) implements ApiResult {} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/OperationAccepted.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/OperationAccepted.java new file mode 100644 index 00000000..6cf1bf83 --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/OperationAccepted.java @@ -0,0 +1,16 @@ +/* + * 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 com.openmemind.ai.memory.server.domain.common; + +public record OperationAccepted(String operationId, String status, String mode) {} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/OperationErrorDetails.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/OperationErrorDetails.java new file mode 100644 index 00000000..ec003abc --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/OperationErrorDetails.java @@ -0,0 +1,16 @@ +/* + * 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 com.openmemind.ai.memory.server.domain.common; + +public record OperationErrorDetails(String operation, String reason) {} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/PageMeta.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/PageMeta.java new file mode 100644 index 00000000..3674edd0 --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/PageMeta.java @@ -0,0 +1,22 @@ +/* + * 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 com.openmemind.ai.memory.server.domain.common; + +public record PageMeta( + int page, + int pageSize, + long totalItems, + long totalPages, + boolean hasPrevious, + boolean hasNext) {} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/PageResult.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/PageResult.java index 5ac014f1..0b54732f 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/PageResult.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/PageResult.java @@ -15,9 +15,22 @@ import java.util.List; -public record PageResult(long total, List list, long current) { +public record PageResult(List items, PageMeta page) { public static PageResult from(PageResponse pageResponse) { - return new PageResult<>(pageResponse.total(), pageResponse.items(), pageResponse.pageNo()); + long totalPages = + pageResponse.pageSize() <= 0 + ? 0 + : (pageResponse.total() + pageResponse.pageSize() - 1) + / pageResponse.pageSize(); + return new PageResult<>( + pageResponse.items(), + new PageMeta( + pageResponse.pageNo(), + pageResponse.pageSize(), + pageResponse.total(), + totalPages, + pageResponse.pageNo() > 1, + pageResponse.pageNo() < totalPages)); } } diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/SuccessResult.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/SuccessResult.java new file mode 100644 index 00000000..8cff9f89 --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/SuccessResult.java @@ -0,0 +1,19 @@ +/* + * 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 com.openmemind.ai.memory.server.domain.common; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.ALWAYS) +public record SuccessResult(T data) implements ApiResult {} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/ValidationErrorDetails.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/ValidationErrorDetails.java new file mode 100644 index 00000000..3bdc1475 --- /dev/null +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/domain/common/ValidationErrorDetails.java @@ -0,0 +1,18 @@ +/* + * 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 com.openmemind.ai.memory.server.domain.common; + +import java.util.Map; + +public record ValidationErrorDetails(Map fieldErrors) {} diff --git a/memind-server/src/main/java/com/openmemind/ai/memory/server/handler/ApiExceptionHandler.java b/memind-server/src/main/java/com/openmemind/ai/memory/server/handler/ApiExceptionHandler.java index d17db43a..0ac45b6f 100644 --- a/memind-server/src/main/java/com/openmemind/ai/memory/server/handler/ApiExceptionHandler.java +++ b/memind-server/src/main/java/com/openmemind/ai/memory/server/handler/ApiExceptionHandler.java @@ -13,16 +13,21 @@ */ package com.openmemind.ai.memory.server.handler; -import com.openmemind.ai.memory.server.domain.common.ApiResult; +import com.openmemind.ai.memory.server.configuration.RequestIdFilter; +import com.openmemind.ai.memory.server.domain.common.ApiError; +import com.openmemind.ai.memory.server.domain.common.ApiErrorCode; +import com.openmemind.ai.memory.server.domain.common.ErrorResult; +import com.openmemind.ai.memory.server.domain.common.ValidationErrorDetails; import com.openmemind.ai.memory.server.runtime.MemoryRuntimeUnavailableException; import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import java.util.LinkedHashMap; import java.util.Map; import java.util.NoSuchElementException; -import java.util.UUID; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -40,11 +45,11 @@ public class ApiExceptionHandler { private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class); @ExceptionHandler(MemoryRuntimeUnavailableException.class) - public ResponseEntity> handleServiceUnavailable( + public ResponseEntity> handleServiceUnavailable( MemoryRuntimeUnavailableException exception, HttpServletRequest request) { return response( HttpStatus.SERVICE_UNAVAILABLE, - "service_unavailable", + ApiErrorCode.RUNTIME_UNAVAILABLE, exception.getMessage(), null, request, @@ -52,37 +57,66 @@ public ResponseEntity> handleServiceUnavailable( } @ExceptionHandler(OptimisticLockingFailureException.class) - public ResponseEntity> handleConflict( + public ResponseEntity> handleConflict( OptimisticLockingFailureException exception, HttpServletRequest request) { return response( - HttpStatus.CONFLICT, "conflict", exception.getMessage(), null, request, exception); + HttpStatus.CONFLICT, + ApiErrorCode.VERSION_CONFLICT, + exception.getMessage(), + null, + request, + exception); } @ExceptionHandler({ MethodArgumentNotValidException.class, HandlerMethodValidationException.class, - ConstraintViolationException.class, - HttpMessageNotReadableException.class, + ConstraintViolationException.class + }) + public ResponseEntity> handleValidationFailure( + Exception exception, HttpServletRequest request) { + return response( + HttpStatus.BAD_REQUEST, + ApiErrorCode.VALIDATION_FAILED, + "Request validation failed", + validationDetails(exception), + request, + exception); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleMalformedJson( + HttpMessageNotReadableException exception, HttpServletRequest request) { + return response( + HttpStatus.BAD_REQUEST, + ApiErrorCode.MALFORMED_JSON, + "Malformed JSON request body", + null, + request, + exception); + } + + @ExceptionHandler({ MissingServletRequestParameterException.class, IllegalArgumentException.class }) - public ResponseEntity> handleBadRequest( + public ResponseEntity> handleBadRequest( Exception exception, HttpServletRequest request) { return response( HttpStatus.BAD_REQUEST, - "bad_request", + ApiErrorCode.BAD_REQUEST, exception.getMessage(), - validationDetails(exception), + null, request, exception); } @ExceptionHandler(NoSuchElementException.class) - public ResponseEntity> handleNotFound( + public ResponseEntity> handleNotFound( NoSuchElementException exception, HttpServletRequest request) { return response( HttpStatus.NOT_FOUND, - "not_found", + ApiErrorCode.NOT_FOUND, exception.getMessage(), null, request, @@ -90,34 +124,37 @@ public ResponseEntity> handleNotFound( } @ExceptionHandler(Exception.class) - public ResponseEntity> handleInternalError( + public ResponseEntity> handleInternalError( Exception exception, HttpServletRequest request) { return response( HttpStatus.INTERNAL_SERVER_ERROR, - "internal_error", + ApiErrorCode.INTERNAL_ERROR, "Internal server error", null, request, exception); } - private ResponseEntity> response( + private ResponseEntity> response( HttpStatus status, - String code, + ApiErrorCode code, String message, - Object details, + T details, HttpServletRequest request, Exception exception) { - String traceId = resolveTraceId(request); - logException(status, code, request, traceId, exception); + String requestId = resolveRequestId(request); + logException(status, code.value(), request, requestId, exception); return ResponseEntity.status(status) - .body(ApiResult.failure(code, message, details, traceId)); + .body(new ErrorResult<>(new ApiError<>(code, message, details))); } - private static String resolveTraceId(HttpServletRequest request) { - String requestId = request.getHeader("X-Request-Id"); + private static String resolveRequestId(HttpServletRequest request) { + String requestId = MDC.get(RequestIdFilter.MDC_KEY); if (requestId == null || requestId.isBlank()) { - return UUID.randomUUID().toString(); + requestId = request.getHeader(RequestIdFilter.HEADER); + } + if (requestId == null || requestId.isBlank()) { + return "-"; } return requestId; } @@ -126,47 +163,52 @@ private void logException( HttpStatus status, String code, HttpServletRequest request, - String traceId, + String requestId, Exception exception) { String requestSummary = request.getMethod() + " " + request.getRequestURI(); if (status.is5xxServerError()) { if (status == HttpStatus.SERVICE_UNAVAILABLE) { log.warn( - "Request failed: status={}, code={}, request={}, traceId={}, message={}", + "Request failed: status={}, code={}, request={}, requestId={}, message={}", status.value(), code, requestSummary, - traceId, + requestId, exception.getMessage()); return; } log.error( - "Request failed: status={}, code={}, request={}, traceId={}, message={}", + "Request failed: status={}, code={}, request={}, requestId={}, message={}", status.value(), code, requestSummary, - traceId, + requestId, exception.getMessage(), exception); return; } log.warn( - "Request failed: status={}, code={}, request={}, traceId={}, message={}", + "Request failed: status={}, code={}, request={}, requestId={}, message={}", status.value(), code, requestSummary, - traceId, + requestId, exception.getMessage()); } - private static Object validationDetails(Exception exception) { + private static ValidationErrorDetails validationDetails(Exception exception) { + Map fieldErrors = new LinkedHashMap<>(); if (exception instanceof MethodArgumentNotValidException validationException) { - Map fieldErrors = new LinkedHashMap<>(); for (FieldError fieldError : validationException.getBindingResult().getFieldErrors()) { fieldErrors.put(fieldError.getField(), fieldError.getDefaultMessage()); } - return Map.of("fieldErrors", fieldErrors); + return new ValidationErrorDetails(fieldErrors); + } + if (exception instanceof ConstraintViolationException validationException) { + for (ConstraintViolation violation : validationException.getConstraintViolations()) { + fieldErrors.put(violation.getPropertyPath().toString(), violation.getMessage()); + } } - return null; + return new ValidationErrorDetails(fieldErrors); } } diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java index 468a18ac..99280e97 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerApplicationTest.java @@ -16,6 +16,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.openmemind.ai.memory.core.llm.StructuredChatClient; @@ -107,7 +108,7 @@ void contextLoadsWithRuntimeDependencies() { @Test void commitApiAcceptsRequestWhenRuntimeIsAvailable() throws Exception { mockMvc.perform( - post("/open/v1/memory/commit") + post("/open/v1/memory/sync/commit") .contentType(APPLICATION_JSON) .content( """ @@ -116,7 +117,9 @@ void commitApiAcceptsRequestWhenRuntimeIsAvailable() throws Exception { "agentId": "a1" } """)) - .andExpect(status().isOk()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.status").value("SUCCESS")); } @Test @@ -124,7 +127,7 @@ void commitApiAcceptsRequestWhenRuntimeIsAvailable() throws Exception { extractApiAcceptsPluginOwnedImageAudioDocumentAndToolCallRawContentViaApplicationObjectMapper() throws Exception { mockMvc.perform( - post("/open/v1/memory/extract") + post("/open/v1/memory/async/extract") .contentType(APPLICATION_JSON) .content( """ @@ -148,10 +151,12 @@ void commitApiAcceptsRequestWhenRuntimeIsAvailable() throws Exception { } } """)) - .andExpect(status().isOk()); + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.status").value("accepted")); mockMvc.perform( - post("/open/v1/memory/extract") + post("/open/v1/memory/async/extract") .contentType(APPLICATION_JSON) .content( """ @@ -167,10 +172,11 @@ void commitApiAcceptsRequestWhenRuntimeIsAvailable() throws Exception { } } """)) - .andExpect(status().isOk()); + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.data.status").value("accepted")); mockMvc.perform( - post("/open/v1/memory/extract") + post("/open/v1/memory/async/extract") .contentType(APPLICATION_JSON) .content( """ @@ -186,10 +192,11 @@ void commitApiAcceptsRequestWhenRuntimeIsAvailable() throws Exception { } } """)) - .andExpect(status().isOk()); + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.data.status").value("accepted")); mockMvc.perform( - post("/open/v1/memory/extract") + post("/open/v1/memory/async/extract") .contentType(APPLICATION_JSON) .content( """ @@ -206,10 +213,11 @@ void commitApiAcceptsRequestWhenRuntimeIsAvailable() throws Exception { } } """)) - .andExpect(status().isOk()); + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.data.status").value("accepted")); mockMvc.perform( - post("/open/v1/memory/extract") + post("/open/v1/memory/async/extract") .contentType(APPLICATION_JSON) .content( """ @@ -234,6 +242,7 @@ void commitApiAcceptsRequestWhenRuntimeIsAvailable() throws Exception { } } """)) - .andExpect(status().isOk()); + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.data.status").value("accepted")); } } diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerIntegrationTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerIntegrationTest.java index 5a2568ca..d2abd409 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerIntegrationTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerIntegrationTest.java @@ -167,8 +167,8 @@ void configIsInitializedOnEmptyDatabase() throws Exception { mockMvc.perform(get("/admin/v1/config/memory-options")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) .andExpect(jsonPath("$.data.version").value(1)) .andExpect(jsonPath("$.data.config['extraction.common']").isArray()) .andExpect(jsonPath("$.data.config['extraction.itemGraph']").isArray()) @@ -184,9 +184,10 @@ void configIsInitializedOnEmptyDatabase() throws Exception { void pageValidationIsAppliedInRealMvcContext() throws Exception { mockMvc.perform(get("/admin/v1/items").queryParam("pageSize", "101")) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("bad_request")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) - .andExpect(jsonPath("$.traceId").isNotEmpty()); + .andExpect(jsonPath("$.error.code").value("validation_failed")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.traceId").doesNotExist()); } @Test @@ -231,9 +232,9 @@ void adminQueriesApplyApprovedFilters() throws Exception { .queryParam("startTimeFrom", "2026-03-31T10:30:00Z") .queryParam("startTimeTo", "2026-03-31T11:30:00Z")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.data.total").value(1)) - .andExpect(jsonPath("$.data.list[0].rawDataId").value("rd-match")); + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.page.totalItems").value(1)) + .andExpect(jsonPath("$.data.items[0].rawDataId").value("rd-match")); mockMvc.perform( get("/admin/v1/items") @@ -244,9 +245,9 @@ void adminQueriesApplyApprovedFilters() throws Exception { .queryParam("type", "FACT") .queryParam("rawDataId", "rd-match")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.data.total").value(1)) - .andExpect(jsonPath("$.data.list[0].itemId").value(101)); + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.page.totalItems").value(1)) + .andExpect(jsonPath("$.data.items[0].itemId").value(101)); mockMvc.perform( get("/admin/v1/insights") @@ -256,9 +257,9 @@ void adminQueriesApplyApprovedFilters() throws Exception { .queryParam("type", "profile") .queryParam("tier", "LEAF")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.data.total").value(1)) - .andExpect(jsonPath("$.data.list[0].insightId").value(201)); + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.page.totalItems").value(1)) + .andExpect(jsonPath("$.data.items[0].insightId").value(201)); } @Test @@ -279,7 +280,7 @@ void rawDataDeletionRemovesLinkedItemsButKeepsInsights() throws Exception { .contentType(APPLICATION_JSON) .content("{\"rawDataIds\":[\"rd-1\"]}")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.deletedRawDataCount").value(1)) .andExpect(jsonPath("$.data.deletedItemCount").value(1)) .andExpect(jsonPath("$.data.affectedMemoryIds[0]").value("u1:a1")) @@ -341,16 +342,16 @@ void memoryThreadAdminEndpointsAreVisibleInRunningServer() throws Exception { mockMvc.perform(get("/admin/v1/memory-threads")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.data.total").value(2)) - .andExpect(jsonPath("$.data.list[0].threadId").doesNotExist()); + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.page.totalItems").value(2)) + .andExpect(jsonPath("$.data.items[0].threadId").doesNotExist()); mockMvc.perform( get("/admin/v1/memory-threads/{threadKey}", "topic:concept:travel") .queryParam("userId", "u1") .queryParam("agentId", "a1")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.threadKey").value("topic:concept:travel")) .andExpect(jsonPath("$.data.threadId").doesNotExist()); @@ -359,7 +360,7 @@ void memoryThreadAdminEndpointsAreVisibleInRunningServer() throws Exception { .queryParam("userId", "u1") .queryParam("agentId", "a1")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data[0].itemId").value(101)) .andExpect(jsonPath("$.data[0].threadKey").value("topic:concept:travel")) .andExpect(jsonPath("$.data[0].threadId").doesNotExist()); @@ -369,7 +370,7 @@ void memoryThreadAdminEndpointsAreVisibleInRunningServer() throws Exception { .queryParam("userId", "u1") .queryParam("agentId", "a1")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.length()").value(2)) .andExpect(jsonPath("$.data[0].threadId").doesNotExist()); @@ -378,7 +379,7 @@ void memoryThreadAdminEndpointsAreVisibleInRunningServer() throws Exception { .queryParam("userId", "u1") .queryParam("agentId", "a1")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.projectionState").value("available")) .andExpect(jsonPath("$.data.pendingCount").value(0)); @@ -387,7 +388,7 @@ void memoryThreadAdminEndpointsAreVisibleInRunningServer() throws Exception { .queryParam("userId", "u1") .queryParam("agentId", "a1")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data").value(1)); } @@ -398,10 +399,10 @@ void adminBufferApisExposeAndCorrectRows() throws Exception { mockMvc.perform(get("/admin/v1/buffers/conversations").queryParam("memoryId", "u1:a1")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.data.total").value(1)) - .andExpect(jsonPath("$.data.list[0].id").value(1001)) - .andExpect(jsonPath("$.data.list[0].extracted").value(false)); + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.page.totalItems").value(1)) + .andExpect(jsonPath("$.data.items[0].id").value(1001)) + .andExpect(jsonPath("$.data.items[0].extracted").value(false)); mockMvc.perform( patch("/admin/v1/buffers/conversations/extracted") @@ -474,7 +475,7 @@ void adminDashboardAggregatesPersistedData() throws Exception { mockMvc.perform(get("/admin/v1/dashboard").queryParam("memoryId", "u1:a1")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.totals.rawData").value(1)) .andExpect(jsonPath("$.data.totals.items").value(1)) .andExpect(jsonPath("$.data.totals.memoryThreads").value(1)) diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerSqliteBootstrapTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerSqliteBootstrapTest.java index 427f5dab..47a8a344 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerSqliteBootstrapTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/MemindServerSqliteBootstrapTest.java @@ -90,7 +90,7 @@ void startupCreatesMissingSqliteParentDirectory() { void configEndpointWorksWhenSqliteParentDirectoryWasMissing() throws Exception { mockMvc.perform(get("/admin/v1/config/memory-options")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.version").value(1)); } diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/configuration/MemindServerCorsConfigurationTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/configuration/MemindServerCorsConfigurationTest.java index b60b8c9f..9681e2a3 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/configuration/MemindServerCorsConfigurationTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/configuration/MemindServerCorsConfigurationTest.java @@ -35,6 +35,7 @@ void corsConfigurationIsOpenForAllOriginsMethodsAndHeaders() { assertThat(cors.getAllowedOrigins()).containsExactly(CorsConfiguration.ALL); assertThat(cors.getAllowedMethods()).containsExactly(CorsConfiguration.ALL); assertThat(cors.getAllowedHeaders()).containsExactly(CorsConfiguration.ALL); + assertThat(cors.getExposedHeaders()).containsExactly(RequestIdFilter.HEADER); assertThat(cors.getAllowCredentials()).isFalse(); } @@ -53,6 +54,10 @@ void preflightRequestsReceiveOpenCorsHeaders() throws Exception { HttpMethod.GET.name())) .andExpect(status().isOk()) .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "*")) - .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET")); + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET")) + .andExpect( + header().string( + HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, + RequestIdFilter.HEADER)); } } diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/configuration/RequestIdFilterTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/configuration/RequestIdFilterTest.java new file mode 100644 index 00000000..5b0116f5 --- /dev/null +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/configuration/RequestIdFilterTest.java @@ -0,0 +1,75 @@ +/* + * 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 com.openmemind.ai.memory.server.configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.openmemind.ai.memory.core.utils.JsonUtils; +import com.openmemind.ai.memory.server.controller.openapi.OpenHealthController; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +class RequestIdFilterTest { + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + this.mockMvc = + MockMvcBuilders.standaloneSetup(new OpenHealthController()) + .addFilters(new RequestIdFilter()) + .setMessageConverters( + new JacksonJsonHttpMessageConverter(JsonUtils.mapper())) + .build(); + } + + @Test + void echoesExistingRequestIdHeader() throws Exception { + mockMvc.perform(get("/open/v1/health").header(RequestIdFilter.HEADER, "rid-1")) + .andExpect(status().isOk()) + .andExpect(header().string(RequestIdFilter.HEADER, "rid-1")); + } + + @Test + void generatesMissingRequestIdHeader() throws Exception { + mockMvc.perform(get("/open/v1/health")) + .andExpect(status().isOk()) + .andExpect(header().exists(RequestIdFilter.HEADER)); + } + + @Test + void clearsRequestIdFromMdcAfterRequest() throws Exception { + mockMvc.perform(get("/open/v1/health").header(RequestIdFilter.HEADER, "rid-2")) + .andExpect(status().isOk()); + + assertThat(MDC.get(RequestIdFilter.MDC_KEY)).isNull(); + } + + @Test + void doesNotAddRequestIdToResponseBody() throws Exception { + mockMvc.perform(get("/open/v1/health").header(RequestIdFilter.HEADER, "rid-3")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.requestId").doesNotExist()) + .andExpect(jsonPath("$.traceId").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()); + } +} diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/contract/ApiContractTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/contract/ApiContractTest.java new file mode 100644 index 00000000..dad2d4f2 --- /dev/null +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/contract/ApiContractTest.java @@ -0,0 +1,213 @@ +/* + * 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 com.openmemind.ai.memory.server.contract; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.openmemind.ai.memory.core.utils.JsonUtils; +import com.openmemind.ai.memory.server.configuration.RequestIdFilter; +import com.openmemind.ai.memory.server.controller.admin.buffer.AdminBufferController; +import com.openmemind.ai.memory.server.controller.admin.dashboard.AdminDashboardController; +import com.openmemind.ai.memory.server.controller.admin.insight.AdminInsightController; +import com.openmemind.ai.memory.server.controller.admin.item.AdminItemController; +import com.openmemind.ai.memory.server.controller.admin.itemgraph.AdminItemGraphController; +import com.openmemind.ai.memory.server.controller.admin.memoryoption.MemoryOptionsController; +import com.openmemind.ai.memory.server.controller.admin.memorythread.AdminMemoryThreadController; +import com.openmemind.ai.memory.server.controller.admin.rawdata.AdminRawDataController; +import com.openmemind.ai.memory.server.controller.openapi.OpenHealthController; +import com.openmemind.ai.memory.server.controller.openapi.OpenMemoryAsyncController; +import com.openmemind.ai.memory.server.controller.openapi.OpenMemoryQueryController; +import com.openmemind.ai.memory.server.controller.openapi.OpenMemorySyncController; +import com.openmemind.ai.memory.server.domain.common.ApiResult; +import com.openmemind.ai.memory.server.domain.common.ErrorResult; +import com.openmemind.ai.memory.server.domain.common.SuccessResult; +import com.openmemind.ai.memory.server.handler.ApiExceptionHandler; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +class ApiContractTest { + + @Test + void controllerHandlersReturnOnlyApiResultEnvelopes() { + List violations = + controllerTypes() + .flatMap(type -> handlerMethods(type).map(this::describeViolation)) + .flatMap(Stream::ofNullable) + .toList(); + + assertThat(violations).isEmpty(); + } + + @Test + void representativeSuccessResponseUsesDataEnvelopeAndRequestIdHeader() throws Exception { + MockMvc mockMvc = + MockMvcBuilders.standaloneSetup(new ContractController()) + .addFilters(new RequestIdFilter()) + .setMessageConverters( + new JacksonJsonHttpMessageConverter(JsonUtils.mapper())) + .build(); + + mockMvc.perform(get("/contract/success").header("X-Request-Id", "rid-contract")) + .andExpect(status().isOk()) + .andExpect(header().string("X-Request-Id", "rid-contract")) + .andExpect(jsonPath("$.data.status").value("ok")) + .andExpect(jsonPath("$.error").doesNotExist()) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.message").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.traceId").doesNotExist()) + .andExpect(jsonPath("$.requestId").doesNotExist()) + .andExpect(jsonPath("$.meta").doesNotExist()); + } + + @Test + void representativeErrorResponseUsesErrorEnvelopeAndRequestIdHeader() throws Exception { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + MockMvc mockMvc = + MockMvcBuilders.standaloneSetup(new ContractController()) + .setControllerAdvice(new ApiExceptionHandler()) + .addFilters(new RequestIdFilter()) + .setMessageConverters( + new JacksonJsonHttpMessageConverter(JsonUtils.mapper())) + .setValidator(validator) + .build(); + + mockMvc.perform( + post("/contract/validation") + .header("X-Request-Id", "rid-error") + .contentType(APPLICATION_JSON) + .content("{\"userId\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(header().string("X-Request-Id", "rid-error")) + .andExpect(jsonPath("$.error.code").value("validation_failed")) + .andExpect(jsonPath("$.error.message").value("Request validation failed")) + .andExpect(jsonPath("$.error.details.fieldErrors.userId").exists()) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.traceId").doesNotExist()) + .andExpect(jsonPath("$.requestId").doesNotExist()) + .andExpect(jsonPath("$.meta").doesNotExist()); + } + + private Stream> controllerTypes() { + return Stream.of( + AdminBufferController.class, + AdminDashboardController.class, + AdminInsightController.class, + AdminItemController.class, + AdminItemGraphController.class, + MemoryOptionsController.class, + AdminMemoryThreadController.class, + AdminRawDataController.class, + OpenHealthController.class, + OpenMemoryAsyncController.class, + OpenMemoryQueryController.class, + OpenMemorySyncController.class); + } + + private Stream handlerMethods(Class type) { + return Stream.of(type.getDeclaredMethods()).filter(this::isHandlerMethod); + } + + private String describeViolation(Method method) { + return isContractReturnType(method.getGenericReturnType()) + ? null + : method.getDeclaringClass().getSimpleName() + + "#" + + method.getName() + + " returns " + + method.getGenericReturnType().getTypeName(); + } + + private boolean isContractReturnType(Type returnType) { + if (returnType instanceof Class clazz) { + return SuccessResult.class.equals(clazz) || ErrorResult.class.equals(clazz); + } + if (!(returnType instanceof ParameterizedType parameterizedType)) { + return false; + } + Type rawType = parameterizedType.getRawType(); + if (SuccessResult.class.equals(rawType) || ErrorResult.class.equals(rawType)) { + return true; + } + if (!ResponseEntity.class.equals(rawType)) { + return false; + } + Type bodyType = parameterizedType.getActualTypeArguments()[0]; + if (bodyType instanceof Class bodyClass) { + return SuccessResult.class.equals(bodyClass) + || ErrorResult.class.equals(bodyClass) + || ApiResult.class.equals(bodyClass); + } + if (bodyType instanceof ParameterizedType bodyParameterizedType) { + Type bodyRawType = bodyParameterizedType.getRawType(); + return SuccessResult.class.equals(bodyRawType) + || ErrorResult.class.equals(bodyRawType) + || ApiResult.class.equals(bodyRawType); + } + return false; + } + + private boolean isHandlerMethod(Method method) { + return method.isAnnotationPresent(GetMapping.class) + || method.isAnnotationPresent(PostMapping.class) + || method.isAnnotationPresent(PatchMapping.class) + || method.isAnnotationPresent(PutMapping.class) + || method.isAnnotationPresent(DeleteMapping.class); + } + + @RestController + private static final class ContractController { + + @GetMapping("/contract/success") + SuccessResult success() { + return new SuccessResult<>(new StatusResponse("ok")); + } + + @PostMapping("/contract/validation") + SuccessResult validation(@Valid @RequestBody ValidationRequest request) { + return new SuccessResult<>(null); + } + } + + private record StatusResponse(String status) {} + + private record ValidationRequest(@NotBlank String userId) {} +} diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/buffer/AdminBufferControllerTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/buffer/AdminBufferControllerTest.java index 3a71777d..1c543e8b 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/buffer/AdminBufferControllerTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/buffer/AdminBufferControllerTest.java @@ -18,9 +18,11 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.openmemind.ai.memory.server.configuration.RequestIdFilter; import com.openmemind.ai.memory.server.domain.buffer.query.ConversationBufferPageQuery; import com.openmemind.ai.memory.server.domain.buffer.query.InsightBufferPageQuery; import com.openmemind.ai.memory.server.domain.buffer.view.ConversationBufferView; @@ -55,21 +57,24 @@ void setUp() { MockMvcBuilders.standaloneSetup( new AdminBufferController(queryService, managementService)) .setControllerAdvice(new ApiExceptionHandler()) + .addFilters(new RequestIdFilter()) .setValidator(validator) .build(); } @Test void conversationListDefaultsToPendingAndReturnsPagePayload() throws Exception { - mockMvc.perform(get("/admin/v1/buffers/conversations")) + mockMvc.perform(get("/admin/v1/buffers/conversations").param("page", "2")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.data.current").value(1)) - .andExpect(jsonPath("$.data.list[0].sessionId").value("s1")) - .andExpect(jsonPath("$.data.list[0].extracted").value(false)); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.data.page.page").value(2)) + .andExpect(jsonPath("$.data.items[0].sessionId").value("s1")) + .andExpect(jsonPath("$.data.items[0].extracted").value(false)); assertThat(queryService.recordedConversationQuery.state()).isEqualTo("pending"); - assertThat(queryService.recordedConversationQuery.pageNo()).isEqualTo(1); + assertThat(queryService.recordedConversationQuery.pageNo()).isEqualTo(2); assertThat(queryService.recordedConversationQuery.pageSize()).isEqualTo(20); } @@ -77,21 +82,24 @@ void conversationListDefaultsToPendingAndReturnsPagePayload() throws Exception { void conversationDetailReturnsRow() throws Exception { mockMvc.perform(get("/admin/v1/buffers/conversations/1")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.id").value(1)) .andExpect(jsonPath("$.data.content").value("hello")); } @Test void insightListDefaultsToUnbuiltAndReturnsPagePayload() throws Exception { - mockMvc.perform(get("/admin/v1/buffers/insights")) + mockMvc.perform(get("/admin/v1/buffers/insights").param("page", "2")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.data.current").value(1)) - .andExpect(jsonPath("$.data.list[0].insightTypeName").value("preference")) - .andExpect(jsonPath("$.data.list[0].built").value(false)); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.page.page").value(2)) + .andExpect(jsonPath("$.data.items[0].insightTypeName").value("preference")) + .andExpect(jsonPath("$.data.items[0].built").value(false)); assertThat(queryService.recordedInsightQuery.state()).isEqualTo("unbuilt"); + assertThat(queryService.recordedInsightQuery.pageNo()).isEqualTo(2); } @Test @@ -101,7 +109,8 @@ void insightGroupsReturnAggregates() throws Exception { .param("memoryId", "u1:a1") .param("insightTypeName", "preference")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data[0].memoryId").value("u1:a1")) .andExpect(jsonPath("$.data[0].groupName").value("project")) .andExpect(jsonPath("$.data[0].unbuilt").value(2)); @@ -114,7 +123,9 @@ void markConversationExtractedRequiresIds() throws Exception { .contentType(APPLICATION_JSON) .content("{\"ids\":[]}")) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("bad_request")); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.error.code").value("validation_failed")) + .andExpect(jsonPath("$.code").doesNotExist()); } @Test diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/dashboard/AdminDashboardControllerTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/dashboard/AdminDashboardControllerTest.java index b68b7679..2a94e3f0 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/dashboard/AdminDashboardControllerTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/dashboard/AdminDashboardControllerTest.java @@ -15,9 +15,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.openmemind.ai.memory.server.configuration.RequestIdFilter; import com.openmemind.ai.memory.server.domain.dashboard.view.AdminDashboardView; import com.openmemind.ai.memory.server.handler.ApiExceptionHandler; import com.openmemind.ai.memory.server.service.dashboard.DashboardQueryService; @@ -41,6 +43,7 @@ void setUp() { this.mockMvc = MockMvcBuilders.standaloneSetup(new AdminDashboardController(queryService)) .setControllerAdvice(new ApiExceptionHandler()) + .addFilters(new RequestIdFilter()) .setValidator(validator) .build(); } @@ -49,7 +52,9 @@ void setUp() { void dashboardDefaultsToSevenDaysAndReturnsSections() throws Exception { mockMvc.perform(get("/admin/v1/dashboard")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) .andExpect(jsonPath("$.data.totals.rawData").value(3)) .andExpect(jsonPath("$.data.backlog.conversationPending").value(1)) .andExpect(jsonPath("$.data.activity.days").value(7)) @@ -67,7 +72,9 @@ void dashboardDefaultsToSevenDaysAndReturnsSections() throws Exception { void dashboardRejectsTooManyDays() throws Exception { mockMvc.perform(get("/admin/v1/dashboard").param("days", "31")) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("bad_request")); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.error.code").value("bad_request")) + .andExpect(jsonPath("$.code").doesNotExist()); } @Test diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/insight/AdminInsightControllerTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/insight/AdminInsightControllerTest.java index 54633998..8fe1f076 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/insight/AdminInsightControllerTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/insight/AdminInsightControllerTest.java @@ -17,10 +17,12 @@ import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.openmemind.ai.memory.core.data.InsightPoint; +import com.openmemind.ai.memory.server.configuration.RequestIdFilter; import com.openmemind.ai.memory.server.domain.common.BatchDeleteResult; import com.openmemind.ai.memory.server.domain.common.PageResponse; import com.openmemind.ai.memory.server.domain.insight.query.InsightPageQuery; @@ -54,21 +56,23 @@ void setUp() { MockMvcBuilders.standaloneSetup( new AdminInsightController(queryService, deleteService)) .setControllerAdvice(new ApiExceptionHandler()) + .addFilters(new RequestIdFilter()) .setValidator(validator) .build(); } @Test - void pageUsesDefaultPaginationAndReturnsPagePayload() throws Exception { - mockMvc.perform(get("/admin/v1/insights")) + void pageUsesPageParameterAndReturnsPagePayload() throws Exception { + mockMvc.perform(get("/admin/v1/insights").param("page", "2")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) - .andExpect(jsonPath("$.data.current").value(1)) - .andExpect(jsonPath("$.data.total").value(1)) - .andExpect(jsonPath("$.data.list[0].insightId").value(201)); - - assertThat(queryService.recordedQuery.pageNo()).isEqualTo(1); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.data.page.page").value(2)) + .andExpect(jsonPath("$.data.page.totalItems").value(1)) + .andExpect(jsonPath("$.data.items[0].insightId").value(201)); + + assertThat(queryService.recordedQuery.pageNo()).isEqualTo(2); assertThat(queryService.recordedQuery.pageSize()).isEqualTo(20); } @@ -79,9 +83,11 @@ void deleteRequiresIds() throws Exception { .contentType(APPLICATION_JSON) .content("{\"insightIds\":[]}")) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("bad_request")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) - .andExpect(jsonPath("$.traceId").isNotEmpty()); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.error.code").value("validation_failed")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.traceId").doesNotExist()); } @Test @@ -93,8 +99,9 @@ void deleteReturnsAffectedMemoryIds() throws Exception { objectMapper.writeValueAsBytes( Map.of("insightIds", List.of(201L))))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) .andExpect(jsonPath("$.data.deletedCount").value(1)) .andExpect(jsonPath("$.data.affectedMemoryIds[0]").value("u1:a1")); } @@ -105,9 +112,11 @@ void missingInsightReturnsNotFound() throws Exception { mockMvc.perform(get("/admin/v1/insights/201")) .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value("not_found")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) - .andExpect(jsonPath("$.traceId").isNotEmpty()); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.error.code").value("not_found")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.traceId").doesNotExist()); } private static final class StubInsightQueryService extends InsightQueryService { diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/item/AdminItemControllerTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/item/AdminItemControllerTest.java index ccc85875..260ae7af 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/item/AdminItemControllerTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/item/AdminItemControllerTest.java @@ -17,9 +17,11 @@ import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.openmemind.ai.memory.server.configuration.RequestIdFilter; import com.openmemind.ai.memory.server.domain.common.BatchDeleteResult; import com.openmemind.ai.memory.server.domain.common.PageResponse; import com.openmemind.ai.memory.server.domain.item.query.ItemPageQuery; @@ -58,21 +60,23 @@ void setUp() { new AdminItemController( queryService, deleteService, memoryThreadQueryService)) .setControllerAdvice(new ApiExceptionHandler()) + .addFilters(new RequestIdFilter()) .setValidator(validator) .build(); } @Test - void pageUsesDefaultPaginationAndReturnsPagePayload() throws Exception { - mockMvc.perform(get("/admin/v1/items")) + void pageUsesPageParameterAndReturnsPagePayload() throws Exception { + mockMvc.perform(get("/admin/v1/items").param("page", "2")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) - .andExpect(jsonPath("$.data.current").value(1)) - .andExpect(jsonPath("$.data.total").value(1)) - .andExpect(jsonPath("$.data.list[0].itemId").value(101)); - - assertThat(queryService.recordedQuery.pageNo()).isEqualTo(1); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.data.page.page").value(2)) + .andExpect(jsonPath("$.data.page.totalItems").value(1)) + .andExpect(jsonPath("$.data.items[0].itemId").value(101)); + + assertThat(queryService.recordedQuery.pageNo()).isEqualTo(2); assertThat(queryService.recordedQuery.pageSize()).isEqualTo(20); } @@ -83,9 +87,11 @@ void deleteRequiresIds() throws Exception { .contentType(APPLICATION_JSON) .content("{\"itemIds\":[]}")) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("bad_request")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) - .andExpect(jsonPath("$.traceId").isNotEmpty()); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.error.code").value("validation_failed")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.traceId").doesNotExist()); } @Test @@ -97,8 +103,9 @@ void deleteReturnsAffectedMemoryIds() throws Exception { objectMapper.writeValueAsBytes( Map.of("itemIds", List.of(101L))))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) .andExpect(jsonPath("$.data.deletedCount").value(1)) .andExpect(jsonPath("$.data.affectedMemoryIds[0]").value("u1:a1")); } @@ -109,9 +116,11 @@ void missingItemReturnsNotFound() throws Exception { mockMvc.perform(get("/admin/v1/items/101")) .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value("not_found")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) - .andExpect(jsonPath("$.traceId").isNotEmpty()); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.error.code").value("not_found")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.traceId").doesNotExist()); } @Test @@ -121,8 +130,9 @@ void memoryThreadsReturnAllThreadsForItem() throws Exception { .param("userId", "u1") .param("agentId", "a1")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) .andExpect(jsonPath("$.data.length()").value(2)) .andExpect(jsonPath("$.data[0].threadKey").value("topic:concept:travel")) .andExpect(jsonPath("$.data[0].threadId").doesNotExist()) diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/itemgraph/AdminItemGraphControllerTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/itemgraph/AdminItemGraphControllerTest.java index 362b0486..06f3769c 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/itemgraph/AdminItemGraphControllerTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/itemgraph/AdminItemGraphControllerTest.java @@ -17,9 +17,11 @@ import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.openmemind.ai.memory.server.configuration.RequestIdFilter; import com.openmemind.ai.memory.server.domain.common.BatchDeleteResult; import com.openmemind.ai.memory.server.domain.common.PageResponse; import com.openmemind.ai.memory.server.domain.itemgraph.query.ItemGraphPageQueries; @@ -54,6 +56,7 @@ void setUp() { MockMvcBuilders.standaloneSetup( new AdminItemGraphController(queryService, managementService)) .setControllerAdvice(new ApiExceptionHandler()) + .addFilters(new RequestIdFilter()) .setValidator(validator) .build(); } @@ -62,6 +65,8 @@ void setUp() { void summaryReturnsGraphCounts() throws Exception { mockMvc.perform(get("/admin/v1/item-graph/summary").param("memoryId", "u1:a1")) .andExpect(status().isOk()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.entityCount").value(2)) .andExpect(jsonPath("$.data.itemLinkCountByType[0].name").value("SEMANTIC")); } @@ -70,16 +75,22 @@ void summaryReturnsGraphCounts() throws Exception { void entityListReturnsPage() throws Exception { mockMvc.perform( get("/admin/v1/item-graph/entities") + .param("page", "2") .param("entityType", "PERSON") .param("q", "alice")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.list[0].entityKey").value("person:alice")); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.page.page").value(2)) + .andExpect(jsonPath("$.data.items[0].entityKey").value("person:alice")); } @Test void entityDetailReturnsRelatedCounts() throws Exception { mockMvc.perform(get("/admin/v1/item-graph/entities/1")) .andExpect(status().isOk()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.entity.entityKey").value("person:alice")) .andExpect(jsonPath("$.data.mentionCount").value(3)) .andExpect(jsonPath("$.data.entityOverlapItemLinkCount").value(1)); @@ -89,21 +100,30 @@ void entityDetailReturnsRelatedCounts() throws Exception { void itemLinksCanFilterByEvidenceSource() throws Exception { mockMvc.perform( get("/admin/v1/item-graph/item-links") + .param("page", "2") .param("evidenceSource", "entity_overlap")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.list[0].evidenceSource").value("entity_overlap")); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.page.page").value(2)) + .andExpect(jsonPath("$.data.items[0].evidenceSource").value("entity_overlap")); } @Test void aliasListUsesSpecFilters() throws Exception { mockMvc.perform( get("/admin/v1/item-graph/aliases") + .param("page", "2") .param("memoryId", "u1:a1") .param("entityKey", "person:alice") .param("q", "ali")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.list[0].normalizedAlias").value("alice")); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.data.page.page").value(2)) + .andExpect(jsonPath("$.data.items[0].normalizedAlias").value("alice")); + assertThat(queryService.recordedAliasQuery.pageNo()).isEqualTo(2); assertThat(queryService.recordedAliasQuery.memoryId()).isEqualTo("u1:a1"); assertThat(queryService.recordedAliasQuery.entityKey()).isEqualTo("person:alice"); assertThat(queryService.recordedAliasQuery.q()).isEqualTo("ali"); @@ -115,7 +135,9 @@ void entityDeleteRequiresMemoryId() throws Exception { delete("/admin/v1/item-graph/entities") .contentType(APPLICATION_JSON) .content("{\"entityKeys\":[\"person:alice\"]}")) - .andExpect(status().isBadRequest()); + .andExpect(status().isBadRequest()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.error.code").value("validation_failed")); } @Test @@ -127,6 +149,8 @@ void entityDeleteReturnsCascadeCounts() throws Exception { "{\"memoryId\":\"u1:a1\"," + "\"entityKeys\":[\"person:alice\"]}")) .andExpect(status().isOk()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.deletedCount").value(1)) .andExpect(jsonPath("$.data.deletedAliases").value(2)) .andExpect(jsonPath("$.data.possiblyStaleEntityOverlapLinks").value(1)); @@ -138,7 +162,9 @@ void deleteItemLinksRequiresIds() throws Exception { delete("/admin/v1/item-graph/item-links") .contentType(APPLICATION_JSON) .content("{\"ids\":[]}")) - .andExpect(status().isBadRequest()); + .andExpect(status().isBadRequest()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.error.code").value("validation_failed")); } @Test @@ -148,6 +174,8 @@ void deleteAliasesReturnsBatchDeleteResult() throws Exception { .contentType(APPLICATION_JSON) .content("{\"ids\":[10,11]}")) .andExpect(status().isOk()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.deletedCount").value(2)) .andExpect(jsonPath("$.data.affectedMemoryIds[0]").value("u1:a1")); } @@ -159,6 +187,8 @@ void deleteMentionsReturnsBatchDeleteResult() throws Exception { .contentType(APPLICATION_JSON) .content("{\"ids\":[20]}")) .andExpect(status().isOk()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.deletedCount").value(1)); } @@ -169,6 +199,8 @@ void deleteItemLinksReturnsBatchDeleteResult() throws Exception { .contentType(APPLICATION_JSON) .content("{\"ids\":[30]}")) .andExpect(status().isOk()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.deletedCount").value(1)); } @@ -179,6 +211,8 @@ void deleteCooccurrencesReturnsBatchDeleteResult() throws Exception { .contentType(APPLICATION_JSON) .content("{\"ids\":[40]}")) .andExpect(status().isOk()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.deletedCount").value(1)); } diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/memoryoption/MemoryOptionsControllerTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/memoryoption/MemoryOptionsControllerTest.java index 9d6711c0..d6539b40 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/memoryoption/MemoryOptionsControllerTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/memoryoption/MemoryOptionsControllerTest.java @@ -17,9 +17,11 @@ import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.openmemind.ai.memory.server.configuration.RequestIdFilter; import com.openmemind.ai.memory.server.domain.config.response.MemoryOptionsSnapshot; import com.openmemind.ai.memory.server.domain.config.view.MemoryOptionItemView; import com.openmemind.ai.memory.server.handler.ApiExceptionHandler; @@ -52,6 +54,7 @@ void setUp() { this.mockMvc = MockMvcBuilders.standaloneSetup(new MemoryOptionsController(configService)) .setControllerAdvice(new ApiExceptionHandler()) + .addFilters(new RequestIdFilter()) .setValidator(validator) .build(); } @@ -60,8 +63,9 @@ void setUp() { void getReturnsVersionAndGroupedConfig() throws Exception { mockMvc.perform(get("/admin/v1/config/memory-options")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) .andExpect(jsonPath("$.data.version").value(3)) .andExpect( jsonPath("$.data.config['extraction.common'][0].key") @@ -75,8 +79,9 @@ void putDelegatesUpdateAndReturnsUpdatedSnapshot() throws Exception { .contentType(APPLICATION_JSON) .content(objectMapper.writeValueAsBytes(updateRequestBody()))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) .andExpect(jsonPath("$.data.version").value(4)) .andExpect(jsonPath("$.data.config['extraction.common'][0].value").value("PT45S")); @@ -122,9 +127,11 @@ void putReturnsConflictWhenExpectedVersionIsStale() throws Exception { .contentType(APPLICATION_JSON) .content(objectMapper.writeValueAsBytes(updateRequestBody()))) .andExpect(status().isConflict()) - .andExpect(jsonPath("$.code").value("conflict")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) - .andExpect(jsonPath("$.traceId").isNotEmpty()); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.error.code").value("version_conflict")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.traceId").doesNotExist()); } private static Map updateRequestBody() { diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/memorythread/AdminMemoryThreadControllerTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/memorythread/AdminMemoryThreadControllerTest.java index 3630865f..bbaa599a 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/memorythread/AdminMemoryThreadControllerTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/memorythread/AdminMemoryThreadControllerTest.java @@ -16,9 +16,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.openmemind.ai.memory.server.configuration.RequestIdFilter; import com.openmemind.ai.memory.server.domain.common.PageResponse; import com.openmemind.ai.memory.server.domain.memorythread.query.MemoryThreadPageQuery; import com.openmemind.ai.memory.server.domain.memorythread.view.AdminMemoryThreadItemView; @@ -52,20 +54,24 @@ void setUp() { MockMvcBuilders.standaloneSetup( new AdminMemoryThreadController(queryService, rebuildService)) .setControllerAdvice(new ApiExceptionHandler()) + .addFilters(new RequestIdFilter()) .setValidator(validator) .build(); } @Test void pageEndpointReturnsMemoryThreads() throws Exception { - mockMvc.perform(get("/admin/v1/memory-threads")) + mockMvc.perform(get("/admin/v1/memory-threads").param("page", "2")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.data.total").value(1)) - .andExpect(jsonPath("$.data.list[0].threadKey").value("topic:concept:travel")) - .andExpect(jsonPath("$.data.list[0].threadId").doesNotExist()); - - assertThat(queryService.recordedQuery.pageNo()).isEqualTo(1); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.data.page.page").value(2)) + .andExpect(jsonPath("$.data.page.totalItems").value(1)) + .andExpect(jsonPath("$.data.items[0].threadKey").value("topic:concept:travel")) + .andExpect(jsonPath("$.data.items[0].threadId").doesNotExist()); + + assertThat(queryService.recordedQuery.pageNo()).isEqualTo(2); assertThat(queryService.recordedQuery.pageSize()).isEqualTo(20); } @@ -76,7 +82,8 @@ void detailEndpointUsesThreadKeyAsTheOnlyPublicIdentifier() throws Exception { .param("userId", "u1") .param("agentId", "a1")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.threadKey").value("topic:concept:travel")) .andExpect(jsonPath("$.data.threadId").doesNotExist()); } @@ -88,7 +95,8 @@ void threadMembersEndpointUsesThreadKeyAndDoesNotExposeThreadId() throws Excepti .param("userId", "u1") .param("agentId", "a1")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data[0].threadKey").value("topic:concept:travel")) .andExpect(jsonPath("$.data[0].itemId").value(301)) .andExpect(jsonPath("$.data[0].role").value("core")) @@ -102,7 +110,8 @@ void statusEndpointIsMemoryScoped() throws Exception { .param("userId", "u1") .param("agentId", "a1")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.projectionState").value("available")) .andExpect(jsonPath("$.data.pendingCount").value(0)); } @@ -114,7 +123,8 @@ void rebuildEndpointRequiresMemoryScope() throws Exception { .param("userId", "u1") .param("agentId", "a1")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data").value(1)); } diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/rawdata/AdminRawDataControllerTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/rawdata/AdminRawDataControllerTest.java index 258731c6..78631ae1 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/rawdata/AdminRawDataControllerTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/admin/rawdata/AdminRawDataControllerTest.java @@ -17,9 +17,11 @@ import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.openmemind.ai.memory.server.configuration.RequestIdFilter; import com.openmemind.ai.memory.server.domain.common.PageResponse; import com.openmemind.ai.memory.server.domain.rawdata.query.RawDataPageQuery; import com.openmemind.ai.memory.server.domain.rawdata.response.RawDataDeleteResult; @@ -53,21 +55,23 @@ void setUp() { MockMvcBuilders.standaloneSetup( new AdminRawDataController(queryService, deleteService)) .setControllerAdvice(new ApiExceptionHandler()) + .addFilters(new RequestIdFilter()) .setValidator(validator) .build(); } @Test - void pageUsesDefaultPaginationAndReturnsPagePayload() throws Exception { - mockMvc.perform(get("/admin/v1/raw-data")) + void pageUsesPageParameterAndReturnsPagePayload() throws Exception { + mockMvc.perform(get("/admin/v1/raw-data").param("page", "2")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) - .andExpect(jsonPath("$.data.current").value(1)) - .andExpect(jsonPath("$.data.total").value(1)) - .andExpect(jsonPath("$.data.list[0].rawDataId").value("rd-1")); - - assertThat(queryService.recordedQuery.pageNo()).isEqualTo(1); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.data.page.page").value(2)) + .andExpect(jsonPath("$.data.page.totalItems").value(1)) + .andExpect(jsonPath("$.data.items[0].rawDataId").value("rd-1")); + + assertThat(queryService.recordedQuery.pageNo()).isEqualTo(2); assertThat(queryService.recordedQuery.pageSize()).isEqualTo(20); } @@ -78,9 +82,11 @@ void deleteRequiresIds() throws Exception { .contentType(APPLICATION_JSON) .content("{\"rawDataIds\":[]}")) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value("bad_request")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) - .andExpect(jsonPath("$.traceId").isNotEmpty()); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.error.code").value("validation_failed")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.traceId").doesNotExist()); } @Test @@ -92,8 +98,9 @@ void deleteReturnsCleanupFlag() throws Exception { objectMapper.writeValueAsBytes( Map.of("rawDataIds", List.of("rd-1"))))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) .andExpect(jsonPath("$.data.deletedRawDataCount").value(1)) .andExpect(jsonPath("$.data.deletedItemCount").value(2)) .andExpect(jsonPath("$.data.insightCleanupRequired").value(true)); @@ -110,7 +117,7 @@ private StubRawDataQueryService() { @Override public PageResponse listRawData(RawDataPageQuery query) { this.recordedQuery = query; - return new PageResponse<>(1, 20, 1, List.of(rawDataView())); + return new PageResponse<>(query.pageNo(), query.pageSize(), 1, List.of(rawDataView())); } @Override diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/openapi/OpenHealthControllerTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/openapi/OpenHealthControllerTest.java index 245569e5..2e52801e 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/openapi/OpenHealthControllerTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/openapi/OpenHealthControllerTest.java @@ -14,10 +14,12 @@ package com.openmemind.ai.memory.server.controller.openapi; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.openmemind.ai.memory.core.utils.JsonUtils; +import com.openmemind.ai.memory.server.configuration.RequestIdFilter; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; @@ -32,6 +34,7 @@ class OpenHealthControllerTest { void setUp() { this.mockMvc = MockMvcBuilders.standaloneSetup(new OpenHealthController()) + .addFilters(new RequestIdFilter()) .setMessageConverters( new JacksonJsonHttpMessageConverter(JsonUtils.mapper())) .build(); @@ -41,8 +44,9 @@ void setUp() { void healthReturnsSuccessEnvelope() throws Exception { mockMvc.perform(get("/open/v1/health")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) .andExpect(jsonPath("$.data.status").value("UP")) .andExpect(jsonPath("$.data.service").value("memind-server")); } diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryControllerTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryControllerTest.java index e23930e3..f132eec5 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryControllerTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/controller/openapi/OpenMemoryControllerTest.java @@ -14,13 +14,13 @@ package com.openmemind.ai.memory.server.controller.openapi; import static org.springframework.http.MediaType.APPLICATION_JSON; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.openmemind.ai.memory.core.utils.JsonUtils; +import com.openmemind.ai.memory.server.configuration.RequestIdFilter; import com.openmemind.ai.memory.server.domain.memory.request.AddMessageRequest; import com.openmemind.ai.memory.server.domain.memory.request.CommitMemoryRequest; import com.openmemind.ai.memory.server.domain.memory.request.ExtractMemoryRequest; @@ -36,7 +36,6 @@ import org.junit.jupiter.api.Test; import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; import tools.jackson.databind.json.JsonMapper; @@ -53,109 +52,62 @@ void setUp() { LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); validator.afterPropertiesSet(); this.mockMvc = - MockMvcBuilders.standaloneSetup(new OpenMemoryController(service)) + MockMvcBuilders.standaloneSetup( + new OpenMemoryQueryController(service), + new OpenMemorySyncController(service), + new OpenMemoryAsyncController(service)) .setControllerAdvice(new ApiExceptionHandler()) + .addFilters(new RequestIdFilter()) .setMessageConverters(new JacksonJsonHttpMessageConverter(objectMapper)) .setValidator(validator) .build(); } @Test - void extractAcceptsConversationRawContent() throws Exception { - MvcResult result = - mockMvc.perform( - post("/open/v1/memory/extract") - .contentType(APPLICATION_JSON) - .content( - """ - { - "userId": "u1", - "agentId": "a1", - "rawContent": { - "type": "conversation", - "messages": [ - { - "role": "USER", - "content": [ - { - "type": "text", - "text": "hello" - } - ], - "timestamp": "2026-03-31T10:00:00Z" - } - ] - } - } - """)) - .andExpect(request().asyncStarted()) - .andReturn(); - - mockMvc.perform(asyncDispatch(result)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("200")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) - .andExpect(jsonPath("$.data").doesNotExist()); + void asyncExtractAcceptsConversationRawContent() throws Exception { + mockMvc.perform( + post("/open/v1/memory/async/extract") + .contentType(APPLICATION_JSON) + .content(validExtractJson())) + .andExpect(status().isAccepted()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.data.operationId").exists()) + .andExpect(jsonPath("$.data.status").value("accepted")) + .andExpect(jsonPath("$.data.mode").value("async")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()); org.assertj.core.api.Assertions.assertThat(service.lastExtractRequest).isNotNull(); } @Test - void addMessageReturnsOkWithoutPayload() throws Exception { - MvcResult result = - mockMvc.perform( - post("/open/v1/memory/add-message") - .contentType(APPLICATION_JSON) - .content( - """ - { - "userId": "u1", - "agentId": "a1", - "message": { - "role": "USER", - "content": [ - { - "type": "text", - "text": "hello" - } - ], - "timestamp": "2026-03-31T10:00:00Z" - } - } - """)) - .andExpect(request().asyncStarted()) - .andReturn(); - - mockMvc.perform(asyncDispatch(result)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("200")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) - .andExpect(jsonPath("$.data").doesNotExist()); + void asyncAddMessageReturnsAccepted() throws Exception { + mockMvc.perform( + post("/open/v1/memory/async/add-message") + .contentType(APPLICATION_JSON) + .content(validAddMessageJson())) + .andExpect(status().isAccepted()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.data.operationId").exists()) + .andExpect(jsonPath("$.data.status").value("accepted")) + .andExpect(jsonPath("$.data.mode").value("async")) + .andExpect(jsonPath("$.code").doesNotExist()); org.assertj.core.api.Assertions.assertThat(service.lastAddMessageRequest).isNotNull(); } @Test - void commitReturnsOkWithoutPayload() throws Exception { - MvcResult result = - mockMvc.perform( - post("/open/v1/memory/commit") - .contentType(APPLICATION_JSON) - .content( - """ - { - "userId": "u1", - "agentId": "a1" - } - """)) - .andExpect(request().asyncStarted()) - .andReturn(); - - mockMvc.perform(asyncDispatch(result)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("200")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) - .andExpect(jsonPath("$.data").doesNotExist()); + void asyncCommitReturnsAccepted() throws Exception { + mockMvc.perform( + post("/open/v1/memory/async/commit") + .contentType(APPLICATION_JSON) + .content(validCommitJson())) + .andExpect(status().isAccepted()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.data.operationId").exists()) + .andExpect(jsonPath("$.data.status").value("accepted")) + .andExpect(jsonPath("$.data.mode").value("async")) + .andExpect(jsonPath("$.code").doesNotExist()); org.assertj.core.api.Assertions.assertThat(service.lastCommitRequest).isNotNull(); } @@ -173,26 +125,13 @@ void extractSyncReturnsResponseOnSuccess() throws Exception { null); mockMvc.perform( - post("/open/v1/memory/extract/sync") + post("/open/v1/memory/sync/extract") .contentType(APPLICATION_JSON) - .content( - """ - { - "userId": "u1", - "agentId": "a1", - "rawContent": { - "type": "conversation", - "messages": [ - { - "role": "USER", - "content": [{"type": "text", "text": "hello"}] - } - ] - } - } - """)) + .content(validExtractJson())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) .andExpect(jsonPath("$.data.status").value("SUCCESS")) .andExpect(jsonPath("$.data.rawDataIds[0]").value("rd-1")) .andExpect(jsonPath("$.data.itemIds[0]").value(101)) @@ -215,26 +154,11 @@ void extractSyncPreservesPartialSuccessAndInsightPending() throws Exception { "insight scheduling deferred"); mockMvc.perform( - post("/open/v1/memory/extract/sync") + post("/open/v1/memory/sync/extract") .contentType(APPLICATION_JSON) - .content( - """ - { - "userId": "u1", - "agentId": "a1", - "rawContent": { - "type": "conversation", - "messages": [ - { - "role": "USER", - "content": [{"type": "text", "text": "hello"}] - } - ] - } - } - """)) + .content(validExtractJson())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.status").value("PARTIAL_SUCCESS")) .andExpect(jsonPath("$.data.insightPending").value(true)) .andExpect(jsonPath("$.data.errorMessage").value("insight scheduling deferred")); @@ -247,27 +171,16 @@ void extractSyncReturnsFailureEnvelopeOnFailedStatus() throws Exception { "FAILED", List.of(), List.of(), List.of(), false, 50L, "extraction failed"); mockMvc.perform( - post("/open/v1/memory/extract/sync") + post("/open/v1/memory/sync/extract") .contentType(APPLICATION_JSON) - .content( - """ - { - "userId": "u1", - "agentId": "a1", - "rawContent": { - "type": "conversation", - "messages": [ - { - "role": "USER", - "content": [{"type": "text", "text": "hello"}] - } - ] - } - } - """)) + .content(validExtractJson())) .andExpect(status().is5xxServerError()) - .andExpect(jsonPath("$.code").value("extraction_failed")) - .andExpect(jsonPath("$.message").value("extraction failed")); + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.error.code").value("internal_error")) + .andExpect(jsonPath("$.error.message").value("Memory extraction failed")) + .andExpect(jsonPath("$.error.details.operation").value("extract")) + .andExpect(jsonPath("$.error.details.reason").value("extraction failed")) + .andExpect(jsonPath("$.code").doesNotExist()); } @Test @@ -275,21 +188,11 @@ void addMessageSyncReturnsSuccessWhenNoExtractionTriggered() throws Exception { service.addMessageResponse = new AddMessageResponse(false, null); mockMvc.perform( - post("/open/v1/memory/add-message/sync") + post("/open/v1/memory/sync/add-message") .contentType(APPLICATION_JSON) - .content( - """ - { - "userId": "u1", - "agentId": "a1", - "message": { - "role": "USER", - "content": [{"type": "text", "text": "hello"}] - } - } - """)) + .content(validAddMessageJson())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.triggered").value(false)) .andExpect(jsonPath("$.data.result").doesNotExist()); @@ -311,22 +214,14 @@ void addMessageSyncReturnsFailureWhenTriggeredExtractionFailed() throws Exceptio "boundary extraction failed")); mockMvc.perform( - post("/open/v1/memory/add-message/sync") + post("/open/v1/memory/sync/add-message") .contentType(APPLICATION_JSON) - .content( - """ - { - "userId": "u1", - "agentId": "a1", - "message": { - "role": "USER", - "content": [{"type": "text", "text": "hello"}] - } - } - """)) + .content(validAddMessageJson())) .andExpect(status().is5xxServerError()) - .andExpect(jsonPath("$.code").value("extraction_failed")) - .andExpect(jsonPath("$.message").value("boundary extraction failed")); + .andExpect(jsonPath("$.error.code").value("internal_error")) + .andExpect(jsonPath("$.error.message").value("Memory extraction failed")) + .andExpect(jsonPath("$.error.details.operation").value("add-message")) + .andExpect(jsonPath("$.error.details.reason").value("boundary extraction failed")); } @Test @@ -336,17 +231,11 @@ void commitSyncReturnsExtractionResponse() throws Exception { "SUCCESS", List.of("rd-2"), List.of(102L), List.of(), false, 77L, null); mockMvc.perform( - post("/open/v1/memory/commit/sync") + post("/open/v1/memory/sync/commit") .contentType(APPLICATION_JSON) - .content( - """ - { - "userId": "u1", - "agentId": "a1" - } - """)) + .content(validCommitJson())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) + .andExpect(jsonPath("$.code").doesNotExist()) .andExpect(jsonPath("$.data.status").value("SUCCESS")) .andExpect(jsonPath("$.data.rawDataIds[0]").value("rd-2")); @@ -366,18 +255,14 @@ void commitSyncReturnsFailureEnvelopeOnFailedStatus() throws Exception { "commit extraction failed"); mockMvc.perform( - post("/open/v1/memory/commit/sync") + post("/open/v1/memory/sync/commit") .contentType(APPLICATION_JSON) - .content( - """ - { - "userId": "u1", - "agentId": "a1" - } - """)) + .content(validCommitJson())) .andExpect(status().is5xxServerError()) - .andExpect(jsonPath("$.code").value("extraction_failed")) - .andExpect(jsonPath("$.message").value("commit extraction failed")); + .andExpect(jsonPath("$.error.code").value("internal_error")) + .andExpect(jsonPath("$.error.message").value("Memory extraction failed")) + .andExpect(jsonPath("$.error.details.operation").value("commit")) + .andExpect(jsonPath("$.error.details.reason").value("commit extraction failed")); } @Test @@ -395,13 +280,56 @@ void retrieveReturnsRankedMemoryPayload() throws Exception { } """)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value("success")) - .andExpect(jsonPath("$.timestamp").isNotEmpty()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) .andExpect(jsonPath("$.data.items[0].id").value("item-1")) .andExpect(jsonPath("$.data.insights[0].tier").value("LEAF")) .andExpect(jsonPath("$.data.rawData[0].rawDataId").value("rd-1")); } + private static String validExtractJson() { + return """ + { + "userId": "u1", + "agentId": "a1", + "rawContent": { + "type": "conversation", + "messages": [ + { + "role": "USER", + "content": [{"type": "text", "text": "hello"}], + "timestamp": "2026-03-31T10:00:00Z" + } + ] + } + } + """; + } + + private static String validAddMessageJson() { + return """ + { + "userId": "u1", + "agentId": "a1", + "message": { + "role": "USER", + "content": [{"type": "text", "text": "hello"}], + "timestamp": "2026-03-31T10:00:00Z" + } + } + """; + } + + private static String validCommitJson() { + return """ + { + "userId": "u1", + "agentId": "a1" + } + """; + } + private static final class StubOpenMemoryApplicationService extends OpenMemoryApplicationService { diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/domain/common/ApiResultSerializationTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/domain/common/ApiResultSerializationTest.java new file mode 100644 index 00000000..33176c6b --- /dev/null +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/domain/common/ApiResultSerializationTest.java @@ -0,0 +1,51 @@ +/* + * 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 com.openmemind.ai.memory.server.domain.common; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.openmemind.ai.memory.core.utils.JsonUtils; +import java.util.Map; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; + +class ApiResultSerializationTest { + + private final JsonMapper mapper = JsonUtils.mapper(); + + @Test + void successResultSerializesOnlyData() throws Exception { + String json = mapper.writeValueAsString(new SuccessResult<>(Map.of("status", "up"))); + + assertThat(json).contains("\"data\""); + assertThat(json).doesNotContain("code", "message", "timestamp", "traceId", "meta"); + } + + @Test + void errorResultSerializesSnakeCaseCodeAndGenericDetails() throws Exception { + var details = new ValidationErrorDetails(Map.of("userId", "must not be blank")); + + String json = + mapper.writeValueAsString( + new ErrorResult<>( + new ApiError<>( + ApiErrorCode.VALIDATION_FAILED, + "Request validation failed", + details))); + + assertThat(json).contains("\"code\":\"validation_failed\""); + assertThat(json).contains("\"fieldErrors\""); + assertThat(json).doesNotContain("timestamp", "traceId", "meta"); + } +} diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/domain/common/PageResultTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/domain/common/PageResultTest.java new file mode 100644 index 00000000..01c13286 --- /dev/null +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/domain/common/PageResultTest.java @@ -0,0 +1,43 @@ +/* + * 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 com.openmemind.ai.memory.server.domain.common; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class PageResultTest { + + @Test + void convertsPageResponseToItemsAndPageMetadata() { + PageResult page = PageResult.from(new PageResponse<>(2, 20, 45, List.of("a"))); + + assertThat(page.items()).containsExactly("a"); + assertThat(page.page().page()).isEqualTo(2); + assertThat(page.page().pageSize()).isEqualTo(20); + assertThat(page.page().totalItems()).isEqualTo(45); + assertThat(page.page().totalPages()).isEqualTo(3); + assertThat(page.page().hasPrevious()).isTrue(); + assertThat(page.page().hasNext()).isTrue(); + } + + @Test + void reportsNoNextPageForLastPage() { + PageResult page = PageResult.from(new PageResponse<>(3, 20, 45, List.of("a"))); + + assertThat(page.page().hasPrevious()).isTrue(); + assertThat(page.page().hasNext()).isFalse(); + } +} diff --git a/memind-server/src/test/java/com/openmemind/ai/memory/server/handler/ApiExceptionHandlerTest.java b/memind-server/src/test/java/com/openmemind/ai/memory/server/handler/ApiExceptionHandlerTest.java index 2ac0b619..9e96bc1e 100644 --- a/memind-server/src/test/java/com/openmemind/ai/memory/server/handler/ApiExceptionHandlerTest.java +++ b/memind-server/src/test/java/com/openmemind/ai/memory/server/handler/ApiExceptionHandlerTest.java @@ -14,18 +14,32 @@ package com.openmemind.ai.memory.server.handler; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.openmemind.ai.memory.core.utils.JsonUtils; +import com.openmemind.ai.memory.server.configuration.RequestIdFilter; import com.openmemind.ai.memory.server.runtime.MemoryRuntimeUnavailableException; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import java.util.NoSuchElementException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.system.CapturedOutput; import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @ExtendWith(OutputCaptureExtension.class) @@ -35,27 +49,96 @@ class ApiExceptionHandlerTest { @BeforeEach void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); this.mockMvc = MockMvcBuilders.standaloneSetup(new ThrowingController()) .setControllerAdvice(new ApiExceptionHandler()) + .addFilters(new RequestIdFilter()) + .setMessageConverters( + new JacksonJsonHttpMessageConverter(JsonUtils.mapper())) + .setValidator(validator) .build(); } @Test - void serviceUnavailableExceptionsAreLogged(CapturedOutput output) throws Exception { + void serviceUnavailableExceptionsReturnRuntimeUnavailableError(CapturedOutput output) + throws Exception { mockMvc.perform(get("/boom/service-unavailable").header("X-Request-Id", "rid-1")) - .andExpect(status().isServiceUnavailable()); + .andExpect(status().isServiceUnavailable()) + .andExpect(header().string("X-Request-Id", "rid-1")) + .andExpect(jsonPath("$.error.code").value("runtime_unavailable")) + .andExpect(jsonPath("$.error.message").value("runtime missing")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.traceId").doesNotExist()); assertThat(output.getOut()) - .contains("service_unavailable") + .contains("runtime_unavailable") .contains("GET /boom/service-unavailable") .contains("rid-1"); } @Test - void internalErrorsAreLogged(CapturedOutput output) throws Exception { + void conflictExceptionsReturnVersionConflictError() throws Exception { + mockMvc.perform(get("/boom/conflict").header("X-Request-Id", "rid-conflict")) + .andExpect(status().isConflict()) + .andExpect(header().string("X-Request-Id", "rid-conflict")) + .andExpect(jsonPath("$.error.code").value("version_conflict")) + .andExpect(jsonPath("$.error.message").value("stale version")) + .andExpect(jsonPath("$.traceId").doesNotExist()); + } + + @Test + void validationExceptionsReturnFieldErrors() throws Exception { + mockMvc.perform( + post("/boom/validation") + .contentType(APPLICATION_JSON) + .content("{\"userId\":\"\"}")) + .andExpect(status().isBadRequest()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.error.code").value("validation_failed")) + .andExpect(jsonPath("$.error.message").value("Request validation failed")) + .andExpect(jsonPath("$.error.details.fieldErrors.userId").exists()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.traceId").doesNotExist()); + } + + @Test + void malformedJsonReturnsMalformedJsonError() throws Exception { + mockMvc.perform( + post("/boom/validation") + .contentType(APPLICATION_JSON) + .content("{\"userId\":")) + .andExpect(status().isBadRequest()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.error.code").value("malformed_json")) + .andExpect(jsonPath("$.error.message").value("Malformed JSON request body")) + .andExpect(jsonPath("$.error.details").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.traceId").doesNotExist()); + } + + @Test + void notFoundExceptionsReturnNotFoundError() throws Exception { + mockMvc.perform(get("/boom/not-found")) + .andExpect(status().isNotFound()) + .andExpect(header().exists("X-Request-Id")) + .andExpect(jsonPath("$.error.code").value("not_found")) + .andExpect(jsonPath("$.error.message").value("missing resource")) + .andExpect(jsonPath("$.traceId").doesNotExist()); + } + + @Test + void internalErrorsReturnInternalErrorEnvelope(CapturedOutput output) throws Exception { mockMvc.perform(get("/boom/internal").header("X-Request-Id", "rid-2")) - .andExpect(status().isInternalServerError()); + .andExpect(status().isInternalServerError()) + .andExpect(header().string("X-Request-Id", "rid-2")) + .andExpect(jsonPath("$.error.code").value("internal_error")) + .andExpect(jsonPath("$.error.message").value("Internal server error")) + .andExpect(jsonPath("$.code").doesNotExist()) + .andExpect(jsonPath("$.timestamp").doesNotExist()) + .andExpect(jsonPath("$.traceId").doesNotExist()); assertThat(output.getOut()) .contains("internal_error") @@ -71,9 +154,24 @@ void serviceUnavailable() { throw new MemoryRuntimeUnavailableException("runtime missing"); } + @GetMapping("/boom/conflict") + void conflict() { + throw new OptimisticLockingFailureException("stale version"); + } + + @PostMapping("/boom/validation") + void validation(@Valid @RequestBody ValidationRequest request) {} + + @GetMapping("/boom/not-found") + void notFound() { + throw new NoSuchElementException("missing resource"); + } + @GetMapping("/boom/internal") void internal() { throw new IllegalStateException("boom"); } } + + private record ValidationRequest(@NotBlank String userId) {} } diff --git a/memind-ui/.gitignore b/memind-ui/.gitignore index ccc9a735..b7f55067 100644 --- a/memind-ui/.gitignore +++ b/memind-ui/.gitignore @@ -8,6 +8,7 @@ pnpm-debug.log* lerna-debug.log* node_modules +.pnpm-store dist dist-ssr *.local diff --git a/memind-ui/README.md b/memind-ui/README.md index f2f2fc71..c8d7ac87 100644 --- a/memind-ui/README.md +++ b/memind-ui/README.md @@ -81,7 +81,7 @@ With `memind-server` running at `http://127.0.0.1:8366`, verify: - Server status changes to `connected`. - Dashboard loads real data or real zero counts. -- List pages load with `pageNo` and `pageSize`; filters change request query strings. +- List pages load with `page` and `pageSize`; filters change request query strings. - Items, Raw Data, Insights, Memory Threads, and Item Graph detail drawers load detail data. - Buffers and Item Graph tab links preserve the expected `tab` and filter search params. - Delete dialogs send selected row ids only; run destructive checks only on disposable test data. diff --git a/memind-ui/src/features/api/common.ts b/memind-ui/src/features/api/common.ts index 5ae9bd6c..f740fd2c 100644 --- a/memind-ui/src/features/api/common.ts +++ b/memind-ui/src/features/api/common.ts @@ -1,5 +1,5 @@ export type PageParams = { - pageNo?: number + page?: number pageSize?: number } diff --git a/memind-ui/src/features/buffers/buffers-page.test.tsx b/memind-ui/src/features/buffers/buffers-page.test.tsx index 40cc0865..0fbbf652 100644 --- a/memind-ui/src/features/buffers/buffers-page.test.tsx +++ b/memind-ui/src/features/buffers/buffers-page.test.tsx @@ -45,11 +45,25 @@ const insightGroup = { function api(data: unknown) { return new Response( - JSON.stringify({ code: 'success', data, timestamp: '2026-04-30T00:00:00Z' }), + JSON.stringify({ data }), { headers: { 'content-type': 'application/json' } } ) } +function page(items: unknown[]) { + return { + items, + page: { + page: 1, + pageSize: 10, + totalItems: items.length, + totalPages: 1, + hasPrevious: false, + hasNext: false, + }, + } +} + function renderPage() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, @@ -78,7 +92,7 @@ describe('BuffersPage', () => { }) it('default tab is conversations and conversation state defaults to pending', async () => { - fetchMock.mockResolvedValueOnce(api({ total: 1, current: 1, list: [conversation] })) + fetchMock.mockResolvedValueOnce(api(page([conversation]))) const { getByRole, getByText } = await renderPage() @@ -86,7 +100,7 @@ describe('BuffersPage', () => { await expect.element(getByText('session-1')).toBeInTheDocument() await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledWith( - '/admin/v1/buffers/conversations?pageNo=1&pageSize=10&state=pending', + '/admin/v1/buffers/conversations?page=1&pageSize=10&state=pending', expect.objectContaining({ method: 'GET' }) ) ) @@ -94,7 +108,7 @@ describe('BuffersPage', () => { it('tab=insights opens insight buffers and insight state defaults to unbuilt', async () => { window.history.replaceState(null, '', '/buffers?tab=insights') - fetchMock.mockResolvedValueOnce(api({ total: 1, current: 1, list: [insightBuffer] })) + fetchMock.mockResolvedValueOnce(api(page([insightBuffer]))) const { getByLabelText, getByRole, getByText } = await renderPage() @@ -103,7 +117,7 @@ describe('BuffersPage', () => { await expect.element(getByText('preference')).toBeInTheDocument() await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledWith( - '/admin/v1/buffers/insights?pageNo=1&pageSize=10&state=unbuilt', + '/admin/v1/buffers/insights?page=1&pageSize=10&state=unbuilt', expect.objectContaining({ method: 'GET' }) ) ) @@ -121,7 +135,7 @@ describe('BuffersPage', () => { it('insight state options include ungrouped and grouped', async () => { window.history.replaceState(null, '', '/buffers?tab=insights') - fetchMock.mockResolvedValueOnce(api({ total: 0, current: 1, list: [] })) + fetchMock.mockResolvedValueOnce(api(page([]))) const { getByRole } = await renderPage() @@ -131,9 +145,9 @@ describe('BuffersPage', () => { it('mark extracted sends selected conversation ids', async () => { fetchMock - .mockResolvedValueOnce(api({ total: 1, current: 1, list: [conversation] })) + .mockResolvedValueOnce(api(page([conversation]))) .mockResolvedValueOnce(api({ updatedCount: 1, affectedMemoryIds: ['alice:agent-a'] })) - .mockResolvedValue(api({ total: 0, current: 1, list: [] })) + .mockResolvedValue(api(page([]))) const { getByLabelText, getByRole } = await renderPage() await userEvent.click(getByLabelText('Select conversation 1')) @@ -153,9 +167,9 @@ describe('BuffersPage', () => { it('mark built sends selected insight buffer ids and built flag', async () => { window.history.replaceState(null, '', '/buffers?tab=insights') fetchMock - .mockResolvedValueOnce(api({ total: 1, current: 1, list: [insightBuffer] })) + .mockResolvedValueOnce(api(page([insightBuffer]))) .mockResolvedValueOnce(api({ updatedCount: 1, affectedMemoryIds: ['alice:agent-a'] })) - .mockResolvedValue(api({ total: 0, current: 1, list: [] })) + .mockResolvedValue(api(page([]))) const { getByLabelText, getByRole } = await renderPage() await userEvent.click(getByLabelText('Select insight buffer 11')) @@ -174,7 +188,7 @@ describe('BuffersPage', () => { it('conversation row opens detail drawer with full content', async () => { fetchMock - .mockResolvedValueOnce(api({ total: 1, current: 1, list: [conversation] })) + .mockResolvedValueOnce(api(page([conversation]))) .mockResolvedValueOnce(api({ ...conversation, content: 'Full conversation content' })) const { getByRole, getByText } = await renderPage() diff --git a/memind-ui/src/features/buffers/index.tsx b/memind-ui/src/features/buffers/index.tsx index 82edce62..b4bedf29 100644 --- a/memind-ui/src/features/buffers/index.tsx +++ b/memind-ui/src/features/buffers/index.tsx @@ -87,7 +87,7 @@ export function BuffersPage() { setTab(nextTab) writeSearch({ tab: nextTab === 'conversations' ? undefined : nextTab, - pageNo: undefined, + page: undefined, state: undefined, }) }} @@ -159,7 +159,7 @@ function ConversationBuffersPanel({ }, }) - const rows = conversationsQuery.data?.list ?? [] + const rows = conversationsQuery.data?.items ?? [] const selectedConversationIds = [...selectedIds] return ( @@ -176,7 +176,7 @@ function ConversationBuffersPanel({ className='h-9 rounded-md border bg-background px-3 text-sm' onChange={(event) => onSearchChange({ - pageNo: undefined, + page: undefined, state: event.target.value === DEFAULT_CONVERSATION_STATE ? undefined @@ -235,7 +235,7 @@ function ConversationBuffersPanel({ ) : null} {conversationsQuery.data ? (

- Total rows: {conversationsQuery.data.total} + Total rows: {conversationsQuery.data.page.totalItems}

) : null} @@ -306,7 +306,7 @@ function InsightBuffersPanel({ }, }) - const rows = insightsQuery.data?.list ?? [] + const rows = insightsQuery.data?.items ?? [] const selectedInsightIds = [...selectedIds] return ( @@ -321,7 +321,7 @@ function InsightBuffersPanel({ className='h-9 rounded-md border bg-background px-3 text-sm' onChange={(event) => onSearchChange({ - pageNo: undefined, + page: undefined, state: event.target.value === DEFAULT_INSIGHT_STATE ? undefined @@ -416,7 +416,7 @@ function InsightBuffersPanel({ ) : null} {insightsQuery.data ? (

- Total rows: {insightsQuery.data.total} + Total rows: {insightsQuery.data.page.totalItems}

) : null} @@ -541,7 +541,7 @@ function buildConversationParams( state: ConversationBufferState ): ConversationBufferListParams { return { - pageNo: search.pageNo, + page: search.page, pageSize: search.pageSize, memoryId: memoryId || undefined, sessionId: search.sessionId, @@ -555,7 +555,7 @@ function buildInsightParams( state: InsightBufferState ): InsightBufferListParams { return { - pageNo: search.pageNo, + page: search.page, pageSize: search.pageSize, memoryId: memoryId || undefined, insightTypeName: search.insightTypeName, @@ -564,7 +564,7 @@ function buildInsightParams( } type BuffersSearch = { - pageNo: number + page: number pageSize: number sessionId?: string insightTypeName?: string @@ -574,7 +574,7 @@ type BuffersSearch = { function readBuffersSearch(): BuffersSearch { const params = readSearchParams() return { - pageNo: readNumberParam(params, 'pageNo', DEFAULT_PAGE), + page: readNumberParam(params, 'page', DEFAULT_PAGE), pageSize: readNumberParam(params, 'pageSize', DEFAULT_PAGE_SIZE), sessionId: readStringParam(params, 'sessionId'), insightTypeName: readStringParam(params, 'insightTypeName'), diff --git a/memind-ui/src/features/components/data-state.tsx b/memind-ui/src/features/components/data-state.tsx index bbab883b..d6a95837 100644 --- a/memind-ui/src/features/components/data-state.tsx +++ b/memind-ui/src/features/components/data-state.tsx @@ -50,11 +50,11 @@ export function EmptyState({ export function PageError({ message, - traceId, + requestId, onRetry, }: { message: string - traceId?: string + requestId?: string onRetry?: () => void }) { return ( @@ -64,7 +64,7 @@ export function PageError({

{message}

- {traceId ?

traceId: {traceId}

: null} + {requestId ?

requestId: {requestId}

: null} {onRetry ? (
@@ -324,7 +324,7 @@ function buildInsightListParams( memoryScope: string ): InsightListParams { return { - pageNo: search.pageNo, + page: search.page, pageSize: search.pageSize, ...toUserAgentQuery(memoryScope), scope: search.scope, @@ -334,7 +334,7 @@ function buildInsightListParams( } type InsightSearch = { - pageNo: number + page: number pageSize: number scope?: string type?: string @@ -344,7 +344,7 @@ type InsightSearch = { function readInsightsSearch(): InsightSearch { const params = readSearchParams() return { - pageNo: readNumberParam(params, 'pageNo', DEFAULT_PAGE), + page: readNumberParam(params, 'page', DEFAULT_PAGE), pageSize: readNumberParam(params, 'pageSize', DEFAULT_PAGE_SIZE), scope: readStringParam(params, 'scope'), type: readStringParam(params, 'type'), diff --git a/memind-ui/src/features/insights/insights-page.test.tsx b/memind-ui/src/features/insights/insights-page.test.tsx index 8746d437..37953e87 100644 --- a/memind-ui/src/features/insights/insights-page.test.tsx +++ b/memind-ui/src/features/insights/insights-page.test.tsx @@ -29,11 +29,25 @@ const insight = { function api(data: unknown) { return new Response( - JSON.stringify({ code: 'success', data, timestamp: '2026-04-30T00:00:00Z' }), + JSON.stringify({ data }), { headers: { 'content-type': 'application/json' } } ) } +function page(items: unknown[]) { + return { + items, + page: { + page: 1, + pageSize: 10, + totalItems: items.length, + totalPages: 1, + hasPrevious: false, + hasNext: false, + }, + } +} + function renderPage() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, @@ -61,7 +75,7 @@ describe('InsightsPage', () => { }) it('uses the Insight Tree page title', async () => { - fetchMock.mockResolvedValueOnce(api({ total: 0, current: 1, list: [] })) + fetchMock.mockResolvedValueOnce(api(page([]))) const { getByRole } = await renderPage() @@ -72,9 +86,9 @@ describe('InsightsPage', () => { it('selecting two rows sends only the selected insight ids', async () => { fetchMock - .mockResolvedValueOnce(api({ total: 2, current: 1, list: [insight, { ...insight, insightId: 202 }] })) + .mockResolvedValueOnce(api(page([insight, { ...insight, insightId: 202 }]))) .mockResolvedValueOnce(api({ deletedCount: 2, affectedMemoryIds: ['alice:agent-a'] })) - .mockResolvedValue(api({ total: 0, current: 1, list: [] })) + .mockResolvedValue(api(page([]))) const { getByLabelText, getByRole } = await renderPage() await userEvent.click(getByLabelText('Select insight 201')) @@ -95,7 +109,7 @@ describe('InsightsPage', () => { it('detail drawer shows content, points, categories, relations, embedding, version, and timestamps', async () => { fetchMock - .mockResolvedValueOnce(api({ total: 1, current: 1, list: [insight] })) + .mockResolvedValueOnce(api(page([insight]))) .mockResolvedValueOnce(api(insight)) const { getByRole, getByText } = await renderPage() diff --git a/memind-ui/src/features/item-graph/index.tsx b/memind-ui/src/features/item-graph/index.tsx index 2e4a5edb..f0468126 100644 --- a/memind-ui/src/features/item-graph/index.tsx +++ b/memind-ui/src/features/item-graph/index.tsx @@ -98,7 +98,7 @@ export function ItemGraphPage() { setTab(next) writeSearch({ tab: next === 'summary' ? undefined : next, - pageNo: undefined, + page: undefined, state: undefined, }) }} @@ -158,11 +158,11 @@ function EntitiesTab({ const [detailId, setDetailId] = useState(null) const params = useMemo( () => ({ - pageNo: search.pageNo, + page: search.page, pageSize: search.pageSize, memoryId: memoryId || undefined, }), - [memoryId, search.pageNo, search.pageSize] + [memoryId, search.page, search.pageSize] ) const query = useQuery({ queryKey: ['item-graph', 'entities', params], @@ -176,7 +176,7 @@ function EntitiesTab({ await invalidateGraph(queryClient) }, }) - const rows = query.data?.list ?? [] + const rows = query.data?.items ?? [] const selectedEntityKeys = [...selectedKeys] return ( @@ -282,7 +282,7 @@ function AliasesTab(props: GraphTabProps) { }) return ( `Select alias ${row.id}`} deleteLabel='Delete selected aliases' @@ -311,7 +311,7 @@ function MentionsTab(props: GraphTabProps) { }) return ( `Select mention ${row.id}`} deleteLabel='Delete selected mentions' @@ -339,7 +339,7 @@ function ItemLinksTab(props: GraphTabProps) { }) return ( `Select item link ${row.id}`} deleteLabel='Delete selected item links' @@ -370,7 +370,7 @@ function CooccurrencesTab(props: GraphTabProps) { }) return ( `Select cooccurrence ${row.id}`} @@ -401,18 +401,18 @@ function BatchesTab({ const state = normalizeBatchState(search.state) const params = useMemo( () => ({ - pageNo: search.pageNo, + page: search.page, pageSize: search.pageSize, memoryId: memoryId || undefined, ...(state === 'all' ? {} : { state }), }), - [memoryId, search.pageNo, search.pageSize, state] + [memoryId, search.page, search.pageSize, state] ) const query = useQuery({ queryKey: ['item-graph', 'batches', params], queryFn: () => listGraphBatches(params), }) - const rows = query.data?.list ?? [] + const rows = query.data?.items ?? [] return (
@@ -425,7 +425,7 @@ function BatchesTab({ className='h-9 rounded-md border bg-background px-3 text-sm' onChange={(event) => onSearchChange({ - pageNo: undefined, + page: undefined, state: event.target.value === 'all' ? undefined : event.target.value, }) @@ -629,14 +629,14 @@ type GraphTabProps = { } type GraphSearch = { - pageNo: number + page: number pageSize: number state?: string } function buildPageParams({ search, memoryId }: GraphTabProps) { return { - pageNo: search.pageNo, + page: search.page, pageSize: search.pageSize, memoryId: memoryId || undefined, } @@ -645,7 +645,7 @@ function buildPageParams({ search, memoryId }: GraphTabProps) { function readGraphSearch(): GraphSearch { const params = readSearchParams() return { - pageNo: readNumberParam(params, 'pageNo', DEFAULT_PAGE), + page: readNumberParam(params, 'page', DEFAULT_PAGE), pageSize: readNumberParam(params, 'pageSize', DEFAULT_PAGE_SIZE), state: readStringParam(params, 'state'), } diff --git a/memind-ui/src/features/item-graph/item-graph-page.test.tsx b/memind-ui/src/features/item-graph/item-graph-page.test.tsx index f7094350..db6722de 100644 --- a/memind-ui/src/features/item-graph/item-graph-page.test.tsx +++ b/memind-ui/src/features/item-graph/item-graph-page.test.tsx @@ -100,13 +100,23 @@ const batch = { function api(data: unknown) { return new Response( - JSON.stringify({ code: 'success', data, timestamp: '2026-04-30T00:00:00Z' }), + JSON.stringify({ data }), { headers: { 'content-type': 'application/json' } } ) } -function page(list: unknown[]) { - return { total: list.length, current: 1, list } +function page(items: unknown[]) { + return { + items, + page: { + page: 1, + pageSize: 10, + totalItems: items.length, + totalPages: 1, + hasPrevious: false, + hasNext: false, + }, + } } function renderPage() { @@ -157,7 +167,7 @@ describe('ItemGraphPage', () => { await expect.element(getByText('batch-1')).toBeInTheDocument() await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledWith( - '/admin/v1/item-graph/batches?pageNo=1&pageSize=10', + '/admin/v1/item-graph/batches?page=1&pageSize=10', expect.objectContaining({ method: 'GET' }) ) ) diff --git a/memind-ui/src/features/items/index.tsx b/memind-ui/src/features/items/index.tsx index 59bf89c6..b40bf2c5 100644 --- a/memind-ui/src/features/items/index.tsx +++ b/memind-ui/src/features/items/index.tsx @@ -74,7 +74,7 @@ export function ItemsPage() { }, }) - const rows = itemsQuery.data?.list ?? [] + const rows = itemsQuery.data?.items ?? [] const selectedItemIds = [...selectedIds] return ( @@ -136,7 +136,7 @@ export function ItemsPage() { {itemsQuery.data ? (

- Total rows: {itemsQuery.data.total} + Total rows: {itemsQuery.data.page.totalItems}

) : null}
@@ -396,7 +396,7 @@ function buildItemListParams( memoryScope: string ): ItemListParams { return { - pageNo: search.pageNo, + page: search.page, pageSize: search.pageSize, ...toUserAgentQuery(memoryScope), scope: search.scope, @@ -407,7 +407,7 @@ function buildItemListParams( } type ItemSearch = { - pageNo: number + page: number pageSize: number scope?: string category?: string @@ -418,7 +418,7 @@ type ItemSearch = { function readItemsSearch(): ItemSearch { const params = readSearchParams() return { - pageNo: readNumberParam(params, 'pageNo', DEFAULT_PAGE), + page: readNumberParam(params, 'page', DEFAULT_PAGE), pageSize: readNumberParam(params, 'pageSize', DEFAULT_PAGE_SIZE), scope: readStringParam(params, 'scope'), category: readStringParam(params, 'category'), diff --git a/memind-ui/src/features/items/items-page.test.tsx b/memind-ui/src/features/items/items-page.test.tsx index 444a2347..79bf2d8b 100644 --- a/memind-ui/src/features/items/items-page.test.tsx +++ b/memind-ui/src/features/items/items-page.test.tsx @@ -28,11 +28,25 @@ const item = { function api(data: unknown) { return new Response( - JSON.stringify({ code: 'success', data, timestamp: '2026-04-30T00:00:00Z' }), + JSON.stringify({ data }), { headers: { 'content-type': 'application/json' } } ) } +function page(items: unknown[]) { + return { + items, + page: { + page: 1, + pageSize: 10, + totalItems: items.length, + totalPages: 1, + hasPrevious: false, + hasNext: false, + }, + } +} + function renderPage() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, @@ -61,9 +75,9 @@ describe('ItemsPage', () => { it('selecting two rows sends only the selected item ids', async () => { fetchMock - .mockResolvedValueOnce(api({ total: 2, current: 1, list: [item, { ...item, itemId: 102 }] })) + .mockResolvedValueOnce(api(page([item, { ...item, itemId: 102 }]))) .mockResolvedValueOnce(api({ deletedCount: 2, affectedMemoryIds: ['alice:agent-a'] })) - .mockResolvedValue(api({ total: 0, current: 1, list: [] })) + .mockResolvedValue(api(page([]))) const { getByLabelText, getByRole } = await renderPage() await userEvent.click(getByLabelText('Select item 101')) @@ -83,7 +97,7 @@ describe('ItemsPage', () => { }) it('empty state is visible for an empty list', async () => { - fetchMock.mockResolvedValueOnce(api({ total: 0, current: 1, list: [] })) + fetchMock.mockResolvedValueOnce(api(page([]))) const { getByText } = await renderPage() @@ -92,7 +106,7 @@ describe('ItemsPage', () => { it('detail drawer shows full content and scope prompt when userId is missing', async () => { fetchMock - .mockResolvedValueOnce(api({ total: 1, current: 1, list: [item] })) + .mockResolvedValueOnce(api(page([item]))) .mockResolvedValueOnce(api(item)) const { getByRole, getByText } = await renderPage() @@ -114,7 +128,7 @@ describe('ItemsPage', () => { it('associated threads load when userId is present', async () => { window.history.replaceState(null, '', '/items?memoryId=alice%3Aagent-a') fetchMock - .mockResolvedValueOnce(api({ total: 1, current: 1, list: [item] })) + .mockResolvedValueOnce(api(page([item]))) .mockResolvedValueOnce(api(item)) .mockResolvedValueOnce(api([{ threadKey: 'thread-1', role: 'PRIMARY' }])) diff --git a/memind-ui/src/features/memory-threads/index.tsx b/memind-ui/src/features/memory-threads/index.tsx index efb6481c..5caf937c 100644 --- a/memind-ui/src/features/memory-threads/index.tsx +++ b/memind-ui/src/features/memory-threads/index.tsx @@ -48,7 +48,7 @@ export function MemoryThreadsPage() { queryKey: ['memory-threads', 'list', params], queryFn: () => listMemoryThreads(params), }) - const rows = threadsQuery.data?.list ?? [] + const rows = threadsQuery.data?.items ?? [] const writeSearch = (patch: Record) => { writeUrlSearch(patch) @@ -82,7 +82,7 @@ export function MemoryThreadsPage() { className='h-9 rounded-md border bg-background px-3 text-sm' onChange={(event) => writeSearch({ - pageNo: undefined, + page: undefined, status: event.target.value === 'all' ? undefined @@ -113,7 +113,7 @@ export function MemoryThreadsPage() { ) : null} {threadsQuery.data ? (

- Total rows: {threadsQuery.data.total} + Total rows: {threadsQuery.data.page.totalItems}

) : null} @@ -191,7 +191,7 @@ function buildMemoryThreadParams( status: StatusFilter ): MemoryThreadListParams { return { - pageNo: search.pageNo, + page: search.page, pageSize: search.pageSize, ...toUserAgentQuery(memoryScope), ...(status === 'all' ? {} : { status }), @@ -199,7 +199,7 @@ function buildMemoryThreadParams( } type MemoryThreadsSearch = { - pageNo: number + page: number pageSize: number status?: string } @@ -207,7 +207,7 @@ type MemoryThreadsSearch = { function readMemoryThreadsSearch(): MemoryThreadsSearch { const params = readSearchParams() return { - pageNo: readNumberParam(params, 'pageNo', DEFAULT_PAGE), + page: readNumberParam(params, 'page', DEFAULT_PAGE), pageSize: readNumberParam(params, 'pageSize', DEFAULT_PAGE_SIZE), status: readStringParam(params, 'status'), } diff --git a/memind-ui/src/features/memory-threads/memory-threads-page.test.tsx b/memind-ui/src/features/memory-threads/memory-threads-page.test.tsx index fa3c5dcd..627d00f5 100644 --- a/memind-ui/src/features/memory-threads/memory-threads-page.test.tsx +++ b/memind-ui/src/features/memory-threads/memory-threads-page.test.tsx @@ -55,11 +55,25 @@ const status = { function api(data: unknown) { return new Response( - JSON.stringify({ code: 'success', data, timestamp: '2026-04-30T00:00:00Z' }), + JSON.stringify({ data }), { headers: { 'content-type': 'application/json' } } ) } +function page(items: unknown[]) { + return { + items, + page: { + page: 1, + pageSize: 10, + totalItems: items.length, + totalPages: 1, + hasPrevious: false, + hasNext: false, + }, + } +} + function renderPage() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, @@ -88,21 +102,21 @@ describe('MemoryThreadsPage', () => { }) it('status filter default omits status query param', async () => { - fetchMock.mockResolvedValueOnce(api({ total: 1, current: 1, list: [thread] })) + fetchMock.mockResolvedValueOnce(api(page([thread]))) const { getByText } = await renderPage() await expect.element(getByText('thread-1')).toBeInTheDocument() await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledWith( - '/admin/v1/memory-threads?pageNo=1&pageSize=10', + '/admin/v1/memory-threads?page=1&pageSize=10', expect.objectContaining({ method: 'GET' }) ) ) }) it('status options include ACTIVE DORMANT CLOSED', async () => { - fetchMock.mockResolvedValueOnce(api({ total: 0, current: 1, list: [] })) + fetchMock.mockResolvedValueOnce(api(page([]))) const { getByRole } = await renderPage() @@ -112,7 +126,7 @@ describe('MemoryThreadsPage', () => { }) it('detail drawer is blocked when userId is missing', async () => { - fetchMock.mockResolvedValueOnce(api({ total: 1, current: 1, list: [thread] })) + fetchMock.mockResolvedValueOnce(api(page([thread]))) const { getByRole, getByText } = await renderPage() await userEvent.click(getByRole('button', { name: 'View thread thread-1' })) @@ -128,7 +142,7 @@ describe('MemoryThreadsPage', () => { window.history.replaceState(null, '', '/memory-threads?memoryId=alice%3Aagent-a') fetchMock .mockResolvedValueOnce(api(status)) - .mockResolvedValueOnce(api({ total: 1, current: 1, list: [thread] })) + .mockResolvedValueOnce(api(page([thread]))) .mockResolvedValueOnce(api(thread)) .mockResolvedValueOnce(api([membership])) @@ -155,7 +169,7 @@ describe('MemoryThreadsPage', () => { ) fetchMock .mockResolvedValueOnce(api(status)) - .mockResolvedValueOnce(api({ total: 0, current: 1, list: [] })) + .mockResolvedValueOnce(api(page([]))) const { getByText } = await renderPage() @@ -164,7 +178,7 @@ describe('MemoryThreadsPage', () => { }) it('rebuild is blocked when userId is missing', async () => { - fetchMock.mockResolvedValueOnce(api({ total: 0, current: 1, list: [] })) + fetchMock.mockResolvedValueOnce(api(page([]))) const { getByRole, getByText } = await renderPage() @@ -176,9 +190,9 @@ describe('MemoryThreadsPage', () => { window.history.replaceState(null, '', '/memory-threads?memoryId=alice%3Aagent-a') fetchMock .mockResolvedValueOnce(api(status)) - .mockResolvedValueOnce(api({ total: 1, current: 1, list: [thread] })) + .mockResolvedValueOnce(api(page([thread]))) .mockResolvedValueOnce(api(1)) - .mockResolvedValue(api({ total: 1, current: 1, list: [thread] })) + .mockResolvedValue(api(page([thread]))) const { getByRole } = await renderPage() await userEvent.click(getByRole('button', { name: 'Rebuild projections' })) diff --git a/memind-ui/src/features/raw-data/index.tsx b/memind-ui/src/features/raw-data/index.tsx index d9f43d2c..3ea492a3 100644 --- a/memind-ui/src/features/raw-data/index.tsx +++ b/memind-ui/src/features/raw-data/index.tsx @@ -72,7 +72,7 @@ export function RawDataPage() { }, }) - const rows = rawDataQuery.data?.list ?? [] + const rows = rawDataQuery.data?.items ?? [] const selectedRawDataIds = [...selectedIds] return ( @@ -133,7 +133,7 @@ export function RawDataPage() { {rawDataQuery.data ? (

- Total rows: {rawDataQuery.data.total} + Total rows: {rawDataQuery.data.page.totalItems}

) : null} @@ -302,7 +302,7 @@ function buildRawDataListParams( memoryScope: string ): RawDataListParams { return { - pageNo: search.pageNo, + page: search.page, pageSize: search.pageSize, ...toUserAgentQuery(memoryScope), startTimeFrom: search.startTimeFrom, @@ -311,7 +311,7 @@ function buildRawDataListParams( } type RawDataSearch = { - pageNo: number + page: number pageSize: number startTimeFrom?: string startTimeTo?: string @@ -320,7 +320,7 @@ type RawDataSearch = { function readRawDataSearch(): RawDataSearch { const params = readSearchParams() return { - pageNo: readNumberParam(params, 'pageNo', DEFAULT_PAGE), + page: readNumberParam(params, 'page', DEFAULT_PAGE), pageSize: readNumberParam(params, 'pageSize', DEFAULT_PAGE_SIZE), startTimeFrom: readStringParam(params, 'startTimeFrom'), startTimeTo: readStringParam(params, 'startTimeTo'), diff --git a/memind-ui/src/features/raw-data/raw-data-page.test.tsx b/memind-ui/src/features/raw-data/raw-data-page.test.tsx index 1d544cff..52c64531 100644 --- a/memind-ui/src/features/raw-data/raw-data-page.test.tsx +++ b/memind-ui/src/features/raw-data/raw-data-page.test.tsx @@ -25,11 +25,25 @@ const rawData = { function api(data: unknown) { return new Response( - JSON.stringify({ code: 'success', data, timestamp: '2026-04-30T00:00:00Z' }), + JSON.stringify({ data }), { headers: { 'content-type': 'application/json' } } ) } +function page(items: unknown[]) { + return { + items, + page: { + page: 1, + pageSize: 10, + totalItems: items.length, + totalPages: 1, + hasPrevious: false, + hasNext: false, + }, + } +} + function renderPage() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, @@ -58,9 +72,9 @@ describe('RawDataPage', () => { it('selecting two rows sends only the selected raw data ids', async () => { fetchMock - .mockResolvedValueOnce(api({ total: 2, current: 1, list: [rawData, { ...rawData, rawDataId: 'raw-2' }] })) + .mockResolvedValueOnce(api(page([rawData, { ...rawData, rawDataId: 'raw-2' }]))) .mockResolvedValueOnce(api({ deletedRawDataCount: 2, deletedItemCount: 3, affectedMemoryIds: ['alice:agent-a'], insightCleanupRequired: true })) - .mockResolvedValue(api({ total: 0, current: 1, list: [] })) + .mockResolvedValue(api(page([]))) const { getByLabelText, getByRole, getByText } = await renderPage() await userEvent.click(getByLabelText('Select raw data raw-1')) @@ -84,7 +98,7 @@ describe('RawDataPage', () => { it('detail drawer shows caption, JSON fields, content id, vector id, and timestamps', async () => { fetchMock - .mockResolvedValueOnce(api({ total: 1, current: 1, list: [rawData] })) + .mockResolvedValueOnce(api(page([rawData]))) .mockResolvedValueOnce(api(rawData)) const { getByRole, getByText } = await renderPage() diff --git a/memind-ui/src/features/retrieve/retrieve-page.test.tsx b/memind-ui/src/features/retrieve/retrieve-page.test.tsx index 730fa1f0..874581e0 100644 --- a/memind-ui/src/features/retrieve/retrieve-page.test.tsx +++ b/memind-ui/src/features/retrieve/retrieve-page.test.tsx @@ -69,7 +69,7 @@ const retrieveResponse = { function api(data: unknown) { return new Response( - JSON.stringify({ code: 'success', data, timestamp: '2026-04-30T00:00:00Z' }), + JSON.stringify({ data }), { headers: { 'content-type': 'application/json' } } ) } diff --git a/memind-ui/src/hooks/use-table-url-state.test.ts b/memind-ui/src/hooks/use-table-url-state.test.ts index 58d003df..9b9fcedf 100644 --- a/memind-ui/src/hooks/use-table-url-state.test.ts +++ b/memind-ui/src/hooks/use-table-url-state.test.ts @@ -25,7 +25,7 @@ describe('useTableUrlState', () => { const navigate = vi.fn() as Mock const { result } = await renderHook(() => useTableUrlState({ - search: { pageNo: 3, pageSize: 25 }, + search: { page: 3, pageSize: 25 }, navigate, pagination: { defaultPage: 1, defaultPageSize: 10 }, }) @@ -37,7 +37,7 @@ describe('useTableUrlState', () => { }) }) - it('uses default pageNo and pageSize when search omits them', async () => { + it('uses default page and pageSize when search omits them', async () => { const navigate = vi.fn() as Mock const { result } = await renderHook(() => useTableUrlState({ @@ -53,11 +53,11 @@ describe('useTableUrlState', () => { }) }) - it('clamps negative effective pageNo via pageIndex', async () => { + it('clamps negative effective page via pageIndex', async () => { const navigate = vi.fn() as Mock const { result } = await renderHook(() => useTableUrlState({ - search: { pageNo: 0 }, + search: { page: 0 }, navigate, pagination: { defaultPage: 1, defaultPageSize: 10 }, }) @@ -66,9 +66,9 @@ describe('useTableUrlState', () => { expect(result.current.pagination.pageIndex).toBe(0) }) - it('onPaginationChange omits pageNo and pageSize from search when they match defaults', async () => { + it('onPaginationChange omits page and pageSize from search when they match defaults', async () => { const navigate = vi.fn() as Mock - const prev = { pageNo: 2, pageSize: 20, filter: 'q' } + const prev = { page: 2, pageSize: 20, filter: 'q' } const { result, act } = await renderHook(() => useTableUrlState({ search: prev, @@ -85,18 +85,18 @@ describe('useTableUrlState', () => { }) expect(applyLastSearchFn(navigate, prev)).toMatchObject({ - pageNo: undefined, + page: undefined, pageSize: undefined, filter: 'q', }) }) - it('onPaginationChange writes non-default pageNo and pageSize', async () => { + it('onPaginationChange writes non-default page and pageSize', async () => { const navigate = vi.fn() as Mock const prev = { filter: 'x' } const { result, act } = await renderHook(() => useTableUrlState({ - search: { ...prev, pageNo: 1, pageSize: 10 }, + search: { ...prev, page: 1, pageSize: 10 }, navigate, pagination: { defaultPage: 1, defaultPageSize: 10 }, }) @@ -110,7 +110,7 @@ describe('useTableUrlState', () => { }) expect(applyLastSearchFn(navigate, prev)).toMatchObject({ - pageNo: 3, + page: 3, pageSize: 25, filter: 'x', }) @@ -137,11 +137,11 @@ describe('useTableUrlState', () => { }) }) - it('reads globalFilter from search and onGlobalFilterChange updates URL and clears pageNo', async () => { + it('reads globalFilter from search and onGlobalFilterChange updates URL and clears page', async () => { const navigate = vi.fn() as Mock const { result, act } = await renderHook(() => useTableUrlState({ - search: { pageNo: 2, filter: 'hello' }, + search: { page: 2, filter: 'hello' }, navigate, pagination: { defaultPage: 1, defaultPageSize: 10 }, globalFilter: { enabled: true, key: 'filter' }, @@ -154,8 +154,8 @@ describe('useTableUrlState', () => { result.current.onGlobalFilterChange?.(' next ') }) - expect(applyLastSearchFn(navigate, { pageNo: 2, filter: 'hello' })).toEqual({ - pageNo: undefined, + expect(applyLastSearchFn(navigate, { page: 2, filter: 'hello' })).toEqual({ + page: undefined, filter: 'next', }) }) @@ -251,9 +251,9 @@ describe('useTableUrlState', () => { ]) }) - it('onColumnFiltersChange merges serialized filters into search and clears pageNo', async () => { + it('onColumnFiltersChange merges serialized filters into search and clears page', async () => { const navigate = vi.fn() as Mock - const prev = { pageNo: 3, status: ['old'], other: 1 } + const prev = { page: 3, status: ['old'], other: 1 } const { result, act } = await renderHook(() => useTableUrlState({ search: prev, @@ -274,7 +274,7 @@ describe('useTableUrlState', () => { }) expect(applyLastSearchFn(navigate, prev)).toEqual({ - pageNo: undefined, + page: undefined, status: ['todo'], priority: undefined, other: 1, @@ -283,7 +283,7 @@ describe('useTableUrlState', () => { it('preserves tab search param when table filters change', async () => { const navigate = vi.fn() as Mock - const prev = { tab: 'entities', pageNo: 4, q: 'old' } + const prev = { tab: 'entities', page: 4, q: 'old' } const { result, act } = await renderHook(() => useTableUrlState({ search: prev, @@ -299,16 +299,16 @@ describe('useTableUrlState', () => { expect(applyLastSearchFn(navigate, prev)).toEqual({ tab: 'entities', - pageNo: undefined, + page: undefined, q: 'alice', }) }) - it('ensurePageInRange navigates with replace when current pageNo exceeds pageCount', async () => { + it('ensurePageInRange navigates with replace when current page exceeds pageCount', async () => { const navigate = vi.fn() as Mock const { result, act } = await renderHook(() => useTableUrlState({ - search: { pageNo: 5 }, + search: { page: 5 }, navigate, pagination: { defaultPage: 1, defaultPageSize: 10 }, }) @@ -320,19 +320,19 @@ describe('useTableUrlState', () => { expect(navigate).toHaveBeenCalledTimes(1) expect(lastNavigateOpts(navigate)?.replace).toBe(true) - expect(applyLastSearchFn(navigate, { pageNo: 5, filter: 'x' })).toMatchObject( + expect(applyLastSearchFn(navigate, { page: 5, filter: 'x' })).toMatchObject( { - pageNo: undefined, + page: undefined, filter: 'x', } ) }) - it('ensurePageInRange resets to last pageNo when resetTo is last', async () => { + it('ensurePageInRange resets to last page when resetTo is last', async () => { const navigate = vi.fn() as Mock const { result, act } = await renderHook(() => useTableUrlState({ - search: { pageNo: 9 }, + search: { page: 9 }, navigate, pagination: { defaultPage: 1, defaultPageSize: 10 }, }) @@ -343,16 +343,16 @@ describe('useTableUrlState', () => { }) expect(lastNavigateOpts(navigate)?.replace).toBe(true) - expect(applyLastSearchFn(navigate, { pageNo: 9 })).toMatchObject({ - pageNo: 3, + expect(applyLastSearchFn(navigate, { page: 9 })).toMatchObject({ + page: 3, }) }) - it('ensurePageInRange does not navigate when pageNo is in range', async () => { + it('ensurePageInRange does not navigate when page is in range', async () => { const navigate = vi.fn() as Mock const { result, act } = await renderHook(() => useTableUrlState({ - search: { pageNo: 2 }, + search: { page: 2 }, navigate, pagination: { defaultPage: 1, defaultPageSize: 10 }, }) diff --git a/memind-ui/src/hooks/use-table-url-state.ts b/memind-ui/src/hooks/use-table-url-state.ts index 39b4d29f..347d1467 100644 --- a/memind-ui/src/hooks/use-table-url-state.ts +++ b/memind-ui/src/hooks/use-table-url-state.ts @@ -76,7 +76,7 @@ export function useTableUrlState( columnFilters: columnFiltersCfg = [], } = params - const pageKey = paginationCfg?.pageKey ?? ('pageNo' as string) + const pageKey = paginationCfg?.pageKey ?? ('page' as string) const pageSizeKey = paginationCfg?.pageSizeKey ?? ('pageSize' as string) const defaultPage = paginationCfg?.defaultPage ?? 1 const defaultPageSize = paginationCfg?.defaultPageSize ?? 10 diff --git a/memind-ui/src/lib/api-client.test.ts b/memind-ui/src/lib/api-client.test.ts index 769afdcf..5ab50326 100644 --- a/memind-ui/src/lib/api-client.test.ts +++ b/memind-ui/src/lib/api-client.test.ts @@ -21,12 +21,10 @@ describe('api-client', () => { vi.clearAllMocks() }) - it('unwraps success ApiResult data', async () => { + it('unwraps success envelope data', async () => { fetchMock.mockResolvedValueOnce( jsonResponse({ - code: 'success', data: { status: 'UP' }, - timestamp: '2026-04-30T00:00:00Z', }) ) @@ -37,30 +35,29 @@ describe('api-client', () => { }) }) - it('allows async success code 200 without data', async () => { + it('allows command success with null data', async () => { fetchMock.mockResolvedValueOnce( jsonResponse({ - code: '200', - timestamp: '2026-04-30T00:00:00Z', + data: null, }) ) await expect( - apiPost('/open/v1/memory/extract') - ).resolves.toBeUndefined() + apiPost('/open/v1/memory/sync/extract') + ).resolves.toBeNull() }) - it('throws ApiError with traceId for server errors', async () => { + it('throws ApiError with requestId for server errors', async () => { fetchMock.mockResolvedValueOnce( jsonResponse( { - code: 'validation_failed', - message: 'userId is required', - data: { field: 'userId' }, - timestamp: '2026-04-30T00:00:00Z', - traceId: 'trace-1', + error: { + code: 'validation_failed', + message: 'userId is required', + details: { fieldErrors: { userId: 'must not be blank' } }, + }, }, - { status: 400 } + { status: 400, headers: { 'X-Request-Id': 'request-1' } } ) ) @@ -68,8 +65,8 @@ describe('api-client', () => { status: 400, code: 'validation_failed', message: 'userId is required', - traceId: 'trace-1', - details: { field: 'userId' }, + requestId: 'request-1', + details: { fieldErrors: { userId: 'must not be blank' } }, }) }) @@ -84,14 +81,12 @@ describe('api-client', () => { it('serializes query params without undefined values', async () => { fetchMock.mockResolvedValueOnce( jsonResponse({ - code: 'success', data: [], - timestamp: '2026-04-30T00:00:00Z', }) ) await apiGet('/admin/v1/items', { - pageNo: 2, + page: 2, pageSize: 20, userId: 'alice', agentId: '', @@ -101,7 +96,7 @@ describe('api-client', () => { }) expect(fetchMock).toHaveBeenCalledWith( - '/admin/v1/items?pageNo=2&pageSize=20&userId=alice&include=item&include=raw', + '/admin/v1/items?page=2&pageSize=20&userId=alice&include=item&include=raw', expect.objectContaining({ method: 'GET' }) ) }) diff --git a/memind-ui/src/lib/api-client.ts b/memind-ui/src/lib/api-client.ts index bd9ab695..2ab2a684 100644 --- a/memind-ui/src/lib/api-client.ts +++ b/memind-ui/src/lib/api-client.ts @@ -1,27 +1,37 @@ -type ApiResult = { - code: string - message?: string - data?: T - timestamp: string - traceId?: string +type SuccessEnvelope = { + data: T +} + +type ErrorEnvelope = { + error?: { + code?: string + message?: string + details?: unknown + } } export type PageResult = { - total: number - list: T[] - current: number + items: T[] + page: PageMeta +} + +export type PageMeta = { + page: number + pageSize: number + totalItems: number + totalPages: number + hasPrevious: boolean + hasNext: boolean } export type ApiError = { status?: number code?: string message: string - traceId?: string + requestId?: string details?: unknown } -const SUCCESS_CODES = new Set(['success', '200']) - type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' export async function apiGet( @@ -102,8 +112,8 @@ async function apiRequest( query?: Record ): Promise { if (import.meta.env.VITE_MEMIND_MOCK_API === 'true') { - const { mockApiRequest } = await import('./mock-api') - return mockApiRequest(method, path, body, query) + const { mockApiResponse } = await import('./mock-api') + return handleResponse(await mockApiResponse(method, path, body, query)) } const init: RequestInit = { @@ -122,13 +132,21 @@ async function apiRequest( throw toNetworkError(error) } - const payload = await readJson>(response) + return handleResponse(response) +} + +async function handleResponse(response: Response): Promise { + const payload = await readJson | ErrorEnvelope>(response) - if (!payload || !response.ok || !SUCCESS_CODES.has(payload.code)) { + if (!response.ok || isErrorEnvelope(payload)) { throw toApiError(response, payload) } - return payload.data as T + if (isSuccessEnvelope(payload)) { + return payload.data + } + + return undefined as T } async function readJson(response: Response): Promise { @@ -144,14 +162,25 @@ async function readJson(response: Response): Promise { function toApiError( response: Response, - payload: ApiResult | undefined + payload: SuccessEnvelope | ErrorEnvelope | undefined ): ApiError { + const requestId = response.headers.get('X-Request-Id') ?? undefined + if (isErrorEnvelope(payload)) { + return { + status: response.status, + code: payload.error?.code, + message: + payload.error?.message || response.statusText || 'Request failed', + requestId, + details: payload.error?.details, + } + } + return { status: response.status, - code: payload?.code, - message: payload?.message || response.statusText || 'Request failed', - traceId: payload?.traceId, - details: payload?.data, + message: response.statusText || 'Request failed', + requestId, + details: payload, } } @@ -162,3 +191,22 @@ function toNetworkError(error: unknown): ApiError { return { message: 'Network request failed', details: error } } + +function isSuccessEnvelope( + payload: SuccessEnvelope | ErrorEnvelope | undefined +): payload is SuccessEnvelope { + return Boolean( + payload && typeof payload === 'object' && 'data' in payload + ) +} + +function isErrorEnvelope( + payload: SuccessEnvelope | ErrorEnvelope | undefined +): payload is ErrorEnvelope { + return Boolean( + payload && + typeof payload === 'object' && + 'error' in payload && + payload.error + ) +} diff --git a/memind-ui/src/lib/mock-api.ts b/memind-ui/src/lib/mock-api.ts index 86795fb5..1dde7d42 100644 --- a/memind-ui/src/lib/mock-api.ts +++ b/memind-ui/src/lib/mock-api.ts @@ -36,15 +36,19 @@ const memoryIds = ['alice:agent-a', 'alice:agent-b', 'bob:agent-a'] const longText = 'This is deliberately long mock content used to validate table wrapping, drawer spacing, JSON sections, and dense operational review workflows in Memind UI.' -export async function mockApiRequest( +export async function mockApiResponse( method: MockMethod, path: string, body?: unknown, query?: Record -): Promise { +): Promise { await delay(80) - const data = routeMockRequest(method, path, body, normalizeQuery(query)) - return clone(data) as T + try { + const data = routeMockRequest(method, path, body, normalizeQuery(query)) + return jsonResponse({ data }) + } catch (error) { + return mockErrorResponse(error) + } } function routeMockRequest( @@ -949,13 +953,20 @@ function filterByUserAgent< } function page(rows: T[], query: URLSearchParams): PageResult { - const pageNo = numberParam(query, 'pageNo', 1) + const pageNumber = numberParam(query, 'page', 1) const pageSize = numberParam(query, 'pageSize', 10) - const start = (pageNo - 1) * pageSize + const totalPages = Math.ceil(rows.length / pageSize) + const start = (pageNumber - 1) * pageSize return { - total: rows.length, - current: pageNo, - list: rows.slice(start, start + pageSize), + items: rows.slice(start, start + pageSize), + page: { + page: pageNumber, + pageSize, + totalItems: rows.length, + totalPages, + hasPrevious: pageNumber > 1, + hasNext: pageNumber < totalPages, + }, } } @@ -1105,6 +1116,53 @@ function clone(value: T): T { return JSON.parse(JSON.stringify(value)) as T } +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(clone(body)), { + status, + headers: { + 'content-type': 'application/json', + 'X-Request-Id': 'mock-request-id', + }, + }) +} + +function mockErrorResponse(error: unknown) { + const value = isMockError(error) + ? error + : { + status: 500, + code: 'internal_error', + message: 'Mock request failed', + details: error, + } + + return jsonResponse( + { + error: { + code: value.code, + message: value.message, + details: value.details, + }, + }, + value.status + ) +} + +function isMockError(error: unknown): error is { + status: number + code: string + message: string + details?: unknown +} { + return Boolean( + error && + typeof error === 'object' && + 'status' in error && + 'code' in error && + 'message' in error + ) +} + function delay(ms: number) { return new Promise((resolve) => globalThis.setTimeout(resolve, ms)) } diff --git a/memind-ui/src/routes/_app/buffers.tsx b/memind-ui/src/routes/_app/buffers.tsx index a1e2a656..3f1a529e 100644 --- a/memind-ui/src/routes/_app/buffers.tsx +++ b/memind-ui/src/routes/_app/buffers.tsx @@ -4,7 +4,7 @@ import { BuffersPage } from '@/features/buffers' type BuffersSearch = { memoryId?: string tab?: string - pageNo?: number + page?: number pageSize?: number sessionId?: string insightTypeName?: string @@ -15,7 +15,7 @@ export const Route = createFileRoute('/_app/buffers')({ validateSearch: (search): BuffersSearch => ({ memoryId: readString(search.memoryId), tab: readString(search.tab), - pageNo: readNumber(search.pageNo), + page: readNumber(search.page), pageSize: readNumber(search.pageSize), sessionId: readString(search.sessionId), insightTypeName: readString(search.insightTypeName), diff --git a/memind-ui/src/routes/_app/insights.tsx b/memind-ui/src/routes/_app/insights.tsx index 0bc50a3f..7c30a82e 100644 --- a/memind-ui/src/routes/_app/insights.tsx +++ b/memind-ui/src/routes/_app/insights.tsx @@ -3,7 +3,7 @@ import { InsightsPage } from '@/features/insights' type InsightsSearch = { memoryId?: string - pageNo?: number + page?: number pageSize?: number scope?: string type?: string @@ -13,7 +13,7 @@ type InsightsSearch = { export const Route = createFileRoute('/_app/insights')({ validateSearch: (search): InsightsSearch => ({ memoryId: readString(search.memoryId), - pageNo: readNumber(search.pageNo), + page: readNumber(search.page), pageSize: readNumber(search.pageSize), scope: readString(search.scope), type: readString(search.type), diff --git a/memind-ui/src/routes/_app/item-graph.tsx b/memind-ui/src/routes/_app/item-graph.tsx index 09a4fb3d..7f8f73d4 100644 --- a/memind-ui/src/routes/_app/item-graph.tsx +++ b/memind-ui/src/routes/_app/item-graph.tsx @@ -4,7 +4,7 @@ import { ItemGraphPage } from '@/features/item-graph' type ItemGraphSearch = { memoryId?: string tab?: string - pageNo?: number + page?: number pageSize?: number entityType?: string entityKey?: string @@ -19,7 +19,7 @@ export const Route = createFileRoute('/_app/item-graph')({ validateSearch: (search): ItemGraphSearch => ({ memoryId: readString(search.memoryId), tab: readString(search.tab), - pageNo: readNumber(search.pageNo), + page: readNumber(search.page), pageSize: readNumber(search.pageSize), entityType: readString(search.entityType), entityKey: readString(search.entityKey), diff --git a/memind-ui/src/routes/_app/items.tsx b/memind-ui/src/routes/_app/items.tsx index b5cdf7fa..ed2d7e89 100644 --- a/memind-ui/src/routes/_app/items.tsx +++ b/memind-ui/src/routes/_app/items.tsx @@ -3,7 +3,7 @@ import { ItemsPage } from '@/features/items' type ItemsSearch = { memoryId?: string - pageNo?: number + page?: number pageSize?: number scope?: string category?: string @@ -14,7 +14,7 @@ type ItemsSearch = { export const Route = createFileRoute('/_app/items')({ validateSearch: (search): ItemsSearch => ({ memoryId: readString(search.memoryId), - pageNo: readNumber(search.pageNo), + page: readNumber(search.page), pageSize: readNumber(search.pageSize), scope: readString(search.scope), category: readString(search.category), diff --git a/memind-ui/src/routes/_app/memory-threads.tsx b/memind-ui/src/routes/_app/memory-threads.tsx index 98044000..9140669a 100644 --- a/memind-ui/src/routes/_app/memory-threads.tsx +++ b/memind-ui/src/routes/_app/memory-threads.tsx @@ -3,7 +3,7 @@ import { MemoryThreadsPage } from '@/features/memory-threads' type MemoryThreadsSearch = { memoryId?: string - pageNo?: number + page?: number pageSize?: number status?: string focus?: string @@ -12,7 +12,7 @@ type MemoryThreadsSearch = { export const Route = createFileRoute('/_app/memory-threads')({ validateSearch: (search): MemoryThreadsSearch => ({ memoryId: readString(search.memoryId), - pageNo: readNumber(search.pageNo), + page: readNumber(search.page), pageSize: readNumber(search.pageSize), status: readString(search.status), focus: readString(search.focus), diff --git a/memind-ui/src/routes/_app/raw-data.tsx b/memind-ui/src/routes/_app/raw-data.tsx index 49a254fd..2e763348 100644 --- a/memind-ui/src/routes/_app/raw-data.tsx +++ b/memind-ui/src/routes/_app/raw-data.tsx @@ -3,7 +3,7 @@ import { RawDataPage } from '@/features/raw-data' type RawDataSearch = { memoryId?: string - pageNo?: number + page?: number pageSize?: number startTimeFrom?: string startTimeTo?: string @@ -12,7 +12,7 @@ type RawDataSearch = { export const Route = createFileRoute('/_app/raw-data')({ validateSearch: (search): RawDataSearch => ({ memoryId: readString(search.memoryId), - pageNo: readNumber(search.pageNo), + page: readNumber(search.page), pageSize: readNumber(search.pageSize), startTimeFrom: readString(search.startTimeFrom), startTimeTo: readString(search.startTimeTo),