diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializer.java index a3294cf9a0..cf36fc2246 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializer.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializer.java @@ -22,8 +22,7 @@ /** * {@link StdDeserializer} for the {@link FindAndRerankSort}. * - *

Note: This only checks the shape of the sort clause, the other validation is handled when - * resolving the command to an operation. + *

Note: Validation of field combinations is handled when resolving the command to an operation. */ public class FindAndRerankSortClauseDeserializer extends StdDeserializer { @@ -199,8 +198,23 @@ private static FindAndRerankSort deserializeHybridObject( ArrayNode.class, ObjectNode.class); } + String binaryPath = "sort.$hybrid.$vector.$binary"; + if (!ejson.value().isTextual() && !ejson.value().isBinary()) { + throw new JsonMappingException( + jsonParser, binaryPath + " must be a Base64-encoded string"); + } + float[] binaryVector; + try { + binaryVector = ejson.getVectorValueForBinary(); + } catch (IllegalArgumentException e) { + throw new JsonMappingException(jsonParser, binaryPath + ": " + e.getMessage()); + } + if (binaryVector.length == 0) { + throw new JsonMappingException( + jsonParser, binaryPath + " must encode a non-empty vector"); + } commandFeatures.addFeature(CommandFeature.VECTOR); - yield ejson.getVectorValueForBinary(); + yield binaryVector; } case JsonNode node -> throw JsonFieldMatcher.errorForWrongType( diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializerTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializerTest.java index e3a66280f2..9ea5ff0d6e 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializerTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/model/command/clause/sort/FindAndRerankSortClauseDeserializerTest.java @@ -137,8 +137,6 @@ public void validSorts(String jsonString, FindAndRerankSort expected) throws Exc private static Stream validSortsTestCases() { - float[] emptyVector = new float[] {}; - String emptyVectorBase64 = encodeAsMimeBase64(floatsToBytes(emptyVector)); float[] vector = new float[] {1.1f, 2.2f, 3.3f}; String vectorBase64 = encodeAsMimeBase64(floatsToBytes(vector)); @@ -318,18 +316,6 @@ private static Stream validSortsTestCases() { "lexical", new float[] {0.1f, 0.2f, 0.3f}, CommandFeatures.of(HYBRID, LEXICAL, VECTORIZE, VECTOR))), - Arguments.of( - """ - { "$hybrid" : { "$vectorize" : "vectorize", "$lexical" : "lexical", "$vector" : {"$binary": "%s"}} } - """ - .formatted(emptyVectorBase64), - new FindAndRerankSort( - RerankLegMode.MULTI_LEG, - null, - "vectorize", - "lexical", - emptyVector, - CommandFeatures.of(HYBRID, LEXICAL, VECTORIZE, VECTOR))), Arguments.of( """ { "$hybrid" : { "$vectorize" : "vectorize", "$lexical" : "lexical", "$vector" : {"$binary": "%s"}} } @@ -344,6 +330,40 @@ private static Stream validSortsTestCases() { CommandFeatures.of(HYBRID, LEXICAL, VECTORIZE, VECTOR)))); } + @ParameterizedTest + @MethodSource("invalidBinaryVectorsTestCases") + public void invalidBinaryVectors(String binaryValue, String message) throws Exception { + ObjectMapper mapper = new ObjectMapper(); + String sort = + """ + {"$hybrid": {"$vector": {"$binary": %s}}} + """ + .formatted(binaryValue); + when(deserializationContext.readTree(jsonParser)).thenReturn(mapper.readTree(sort)); + + var error = + assertThrows( + JsonMappingException.class, + () -> deserializer.deserialize(jsonParser, deserializationContext)); + + assertThat(error) + .hasMessageContaining("sort.$hybrid.$vector.$binary") + .hasMessageContaining(message); + } + + private static Stream invalidBinaryVectorsTestCases() { + return Stream.of( + Arguments.of("\"\"", "non-empty vector"), + Arguments.of("\" \"", "non-empty vector"), + Arguments.of("\"not base64!\"", "Base64"), + Arguments.of("\"AAA=\"", "multiple of 4 bytes"), + Arguments.of("123", "Base64-encoded string"), + Arguments.of("true", "Base64-encoded string"), + Arguments.of("null", "Base64-encoded string"), + Arguments.of("[]", "Base64-encoded string"), + Arguments.of("{}", "Base64-encoded string")); + } + @ParameterizedTest @MethodSource("invalidSortsTestCases") public void invalidSorts(String jsonString, String message) throws Exception { diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindAndRerankBinaryVectorTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindAndRerankBinaryVectorTest.java new file mode 100644 index 0000000000..4be29e5518 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindAndRerankBinaryVectorTest.java @@ -0,0 +1,72 @@ +package io.stargate.sgv2.jsonapi.api.v1; + +import static io.restassured.RestAssured.given; +import static io.stargate.sgv2.jsonapi.api.v1.ResponseAssertions.responseIsError; +import static io.stargate.sgv2.jsonapi.util.Base64Util.encodeAsMimeBase64; +import static io.stargate.sgv2.jsonapi.util.CqlVectorUtil.floatsToBytes; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.restassured.http.ContentType; +import io.stargate.sgv2.jsonapi.api.model.command.impl.FindAndRerankCommand; +import io.stargate.sgv2.jsonapi.config.constants.HttpConstants; +import io.stargate.sgv2.jsonapi.exception.RequestException; +import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** Tests request parsing and HTTP error mapping without a database or reranking provider. */ +@QuarkusTest +@TestProfile(NoGlobalResourcesTestProfile.Impl.class) +class FindAndRerankBinaryVectorTest { + + @Inject ObjectMapper objectMapper; + + @ParameterizedTest + @ValueSource( + strings = {"\"\"", "\" \"", "\"not base64!\"", "\"AAA=\"", "123", "true", "null", "[]", "{}"}) + void invalidBinaryVectorReturnsRequestError(String binaryValue) { + // Parsing must reject the request before looking up the collection or calling a provider. + given() + .header(HttpConstants.AUTHENTICATION_TOKEN_HEADER_NAME, "test-token") + .contentType(ContentType.JSON) + .body(commandWithBinaryVector(binaryValue)) + .when() + .post(CollectionResource.BASE_PATH, "test_keyspace", "test_collection") + .then() + .statusCode(200) + .body("$", responseIsError()) + .body("errors.size()", is(1)) + .body("errors[0].family", is("REQUEST")) + .body("errors[0].errorCode", is(RequestException.Code.REQUEST_STRUCTURE_MISMATCH.name())) + .body("errors[0].message", containsString("sort.$hybrid.$vector.$binary")); + } + + @Test + void validBinaryVectorIsDecoded() throws Exception { + float[] vector = new float[] {1.1f, 2.2f, 3.3f}; + String binaryValue = objectMapper.writeValueAsString(encodeAsMimeBase64(floatsToBytes(vector))); + + FindAndRerankCommand command = + objectMapper.readValue(commandWithBinaryVector(binaryValue), FindAndRerankCommand.class); + + assertThat(command.sortClause().vectorSort()).containsExactly(vector); + } + + private static String commandWithBinaryVector(String binaryValue) { + return + """ + {"findAndRerank": { + "sort": {"$hybrid": {"$vector": {"$binary": %s}}}, + "options": {"rerankQuery": "I like cheese!", "rerankOn": "content", "limit": 10} + }} + """ + .formatted(binaryValue); + } +}