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);
+ }
+}