From 60534b087d844a574fd9b24d05a580e6c19b0427 Mon Sep 17 00:00:00 2001 From: Duda Nogueira Date: Fri, 7 Aug 2026 10:44:13 -0300 Subject: [PATCH 1/2] feat(collections): create from and read raw schema JSON Add a raw-JSON escape hatch to the collections API, in both directions, on the sync and async clients: client.collections.createFromJson(json) // POST /schema client.collections.getConfigAsJson(name) // GET /schema/{name} client.collections.listAsJson() // GET /schema Neither direction maps the document onto CollectionConfig. On write the body is forwarded byte-for-byte, so any option the server accepts works, including ones this client version does not model yet. On read the response body is returned untouched. The read side matters because CollectionConfig deserialization is lossy today. Most visibly, VectorConfig.CustomTypeAdapterFactory looks for the bq/pq/sq/rq keys at the top of vectorIndexConfig, which is correct for hnsw and flat but not for dynamic, where they sit one level deeper under hnsw/flat. A collection with a dynamic index and RQ enabled reports quantization = null. Property-level moduleConfig, moduleConfig entries that are neither reranker-* nor generative-*, and the read-only shardingConfig fields are dropped as well. getConfigAsJson gives callers that need the complete picture -- rendering or diffing a schema -- a way to get it without waiting for the mapping to be fixed. createFromJson reads exactly one key, "class", which it needs to return a collection handle. A document that is not a JSON object, or that has no non-empty string "class", raises IllegalArgumentException before the request is sent. getConfigAsJson validates nothing and maps 404 to Optional.empty(), matching the typed getConfig. --- .../integration/CollectionsITest.java | 62 ++++++++++++++ .../CreateCollectionFromJsonRequest.java | 69 +++++++++++++++ .../api/collections/GetConfigJsonRequest.java | 27 ++++++ .../ListCollectionJsonRequest.java | 20 +++++ .../WeaviateCollectionsClient.java | 85 +++++++++++++++++++ .../WeaviateCollectionsClientAsync.java | 48 +++++++++++ .../CreateCollectionFromJsonRequestTest.java | 79 +++++++++++++++++ .../collections/GetConfigJsonRequestTest.java | 70 +++++++++++++++ 8 files changed, 460 insertions(+) create mode 100644 src/main/java/io/weaviate/client6/v1/api/collections/CreateCollectionFromJsonRequest.java create mode 100644 src/main/java/io/weaviate/client6/v1/api/collections/GetConfigJsonRequest.java create mode 100644 src/main/java/io/weaviate/client6/v1/api/collections/ListCollectionJsonRequest.java create mode 100644 src/test/java/io/weaviate/client6/v1/api/collections/CreateCollectionFromJsonRequestTest.java create mode 100644 src/test/java/io/weaviate/client6/v1/api/collections/GetConfigJsonRequestTest.java diff --git a/src/it/java/io/weaviate/integration/CollectionsITest.java b/src/it/java/io/weaviate/integration/CollectionsITest.java index 03871d6cf..997fd1b6f 100644 --- a/src/it/java/io/weaviate/integration/CollectionsITest.java +++ b/src/it/java/io/weaviate/integration/CollectionsITest.java @@ -7,6 +7,8 @@ import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.Test; +import com.google.gson.JsonParser; + import io.weaviate.ConcurrentTest; import io.weaviate.client6.v1.api.WeaviateApiException; import io.weaviate.client6.v1.api.WeaviateClient; @@ -63,6 +65,66 @@ public void testCreateGetDelete() throws IOException { Assertions.assertThat(noCollection).as("after delete").isEmpty(); } + @Test + public void testCreateFromJsonAndGetConfigAsJson() throws IOException { + var collectionName = ns("Things"); + + // Act: create from a raw schema document, including a quantizer nested inside a + // "dynamic" index -- configuration CollectionConfig cannot currently round-trip. + client.collections.createFromJson(""" + { + "class": "%s", + "description": "created from raw JSON", + "properties": [ + { "name": "username", "dataType": ["text"] }, + { "name": "age", "dataType": ["int"] } + ], + "vectorConfig": { + "default": { + "vectorIndexType": "dynamic", + "vectorizer": { "none": {} }, + "vectorIndexConfig": { + "threshold": 10000, + "hnsw": { "rq": { "enabled": true, "bits": 8 } } + } + } + } + } + """.formatted(collectionName)); + + // Assert: the server accepted every option, including the ones the typed API + // would have dropped on the way out. + var raw = client.collections.getConfigAsJson(collectionName); + Assertions.assertThat(raw).as("raw schema").get(InstanceOfAssertFactories.STRING) + .contains(collectionName) + .contains("created from raw JSON"); + + var document = JsonParser.parseString(raw.get()).getAsJsonObject(); + Assertions.assertThat(document.get("class").getAsString()).isEqualTo(collectionName); + Assertions.assertThat(document.getAsJsonObject("vectorConfig") + .getAsJsonObject("default").get("vectorIndexType").getAsString()) + .as("dynamic index").isEqualTo("dynamic"); + Assertions.assertThat(document.getAsJsonObject("vectorConfig") + .getAsJsonObject("default").getAsJsonObject("vectorIndexConfig") + .getAsJsonObject("hnsw").getAsJsonObject("rq").get("enabled").getAsBoolean()) + .as("rq survives the raw round-trip").isTrue(); + + // Assert: listAsJson sees it too. + Assertions.assertThat(client.collections.listAsJson()) + .as("full schema").contains(collectionName); + + // Assert: a missing collection is empty rather than an error. + client.collections.delete(collectionName); + Assertions.assertThat(client.collections.getConfigAsJson(collectionName)) + .as("after delete").isEmpty(); + } + + @Test + public void testCreateFromJsonRejectsDocumentWithoutClass() { + Assertions.assertThatThrownBy(() -> client.collections.createFromJson("{ \"description\": \"no name\" }")) + .as("no \"class\" key").isInstanceOf(IllegalArgumentException.class); + } + @Test public void testCrossReferences() throws IOException { // Arrange: Create Owners collection diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/CreateCollectionFromJsonRequest.java b/src/main/java/io/weaviate/client6/v1/api/collections/CreateCollectionFromJsonRequest.java new file mode 100644 index 000000000..b8fc1e78a --- /dev/null +++ b/src/main/java/io/weaviate/client6/v1/api/collections/CreateCollectionFromJsonRequest.java @@ -0,0 +1,69 @@ +package io.weaviate.client6.v1.api.collections; + +import java.util.Collections; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; + +import io.weaviate.client6.v1.internal.json.JSON; +import io.weaviate.client6.v1.internal.rest.Endpoint; +import io.weaviate.client6.v1.internal.rest.SimpleEndpoint; + +/** + * Create a collection from a raw JSON schema definition. + * + *

+ * The JSON is sent to {@code POST /schema} verbatim: it is never mapped onto + * {@link CollectionConfig}, so any configuration the server understands is + * accepted, including options this client version does not model yet. The + * flip side is that nothing is validated client-side either — a typo in a + * key surfaces as a server error, not as a compile error. + * + *

+ * The only part of the document this client reads is the {@code "class"} key, + * which is needed to return a handle for the created collection. + */ +public record CreateCollectionFromJsonRequest(String json) { + public static final Endpoint _ENDPOINT = SimpleEndpoint.sideEffect( + request -> "POST", + request -> "/schema/", + request -> Collections.emptyMap(), + CreateCollectionFromJsonRequest::json); + + public CreateCollectionFromJsonRequest { + // Fail before the request leaves the process rather than after a round-trip. + parseCollectionName(json); + } + + /** Name of the collection defined by this document ({@code "class"} key). */ + public String collectionName() { + return parseCollectionName(json); + } + + private static String parseCollectionName(String json) { + if (json == null || json.isBlank()) { + throw new IllegalArgumentException("collection JSON must not be null or blank"); + } + + JsonElement document; + try { + document = JSON.toJsonElement(json); + } catch (JsonParseException e) { + throw new IllegalArgumentException("collection JSON is not valid JSON", e); + } + + if (!document.isJsonObject()) { + throw new IllegalArgumentException("collection JSON must be a JSON object"); + } + + var collectionName = document.getAsJsonObject().get("class"); + if (collectionName == null + || !collectionName.isJsonPrimitive() + || !collectionName.getAsJsonPrimitive().isString() + || collectionName.getAsString().isBlank()) { + throw new IllegalArgumentException( + "collection JSON must have a non-empty string \"class\" key with the collection name"); + } + return collectionName.getAsString(); + } +} diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/GetConfigJsonRequest.java b/src/main/java/io/weaviate/client6/v1/api/collections/GetConfigJsonRequest.java new file mode 100644 index 000000000..9d67d3b4a --- /dev/null +++ b/src/main/java/io/weaviate/client6/v1/api/collections/GetConfigJsonRequest.java @@ -0,0 +1,27 @@ +package io.weaviate.client6.v1.api.collections; + +import java.util.Collections; +import java.util.Optional; + +import io.weaviate.client6.v1.internal.rest.Endpoint; +import io.weaviate.client6.v1.internal.rest.OptionalEndpoint; + +/** + * Fetch a collection's schema as the server returned it, without mapping it + * onto {@link CollectionConfig}. + * + *

+ * {@link GetConfigRequest} loses everything this client version does not model + * — unknown module options, server-computed fields, and quantizer configs + * nested inside a {@code dynamic} index. This request keeps the response body + * intact, which is what you want when rendering or diffing a schema rather than + * reading individual settings. + */ +public record GetConfigJsonRequest(String collectionName) { + public static final Endpoint> _ENDPOINT = OptionalEndpoint + .noBodyOptional( + request -> "GET", + request -> "/schema/" + request.collectionName, + request -> Collections.emptyMap(), + (statusCode, response) -> response); +} diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/ListCollectionJsonRequest.java b/src/main/java/io/weaviate/client6/v1/api/collections/ListCollectionJsonRequest.java new file mode 100644 index 000000000..2e1a3c17b --- /dev/null +++ b/src/main/java/io/weaviate/client6/v1/api/collections/ListCollectionJsonRequest.java @@ -0,0 +1,20 @@ +package io.weaviate.client6.v1.api.collections; + +import java.util.Collections; + +import io.weaviate.client6.v1.internal.rest.Endpoint; +import io.weaviate.client6.v1.internal.rest.SimpleEndpoint; + +/** + * Fetch the full schema as the server returned it, without mapping it onto + * {@link CollectionConfig}. + * + * @see GetConfigJsonRequest + */ +public record ListCollectionJsonRequest() { + public static final Endpoint _ENDPOINT = SimpleEndpoint.noBody( + request -> "GET", + request -> "/schema", + request -> Collections.emptyMap(), + (statusCode, response) -> response); +} diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/WeaviateCollectionsClient.java b/src/main/java/io/weaviate/client6/v1/api/collections/WeaviateCollectionsClient.java index e6101f682..32566a5fd 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/WeaviateCollectionsClient.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/WeaviateCollectionsClient.java @@ -184,6 +184,52 @@ public CollectionHandle> create(CollectionConfig collection) return use(collection.collectionName()); } + /** + * Create a new Weaviate collection from a raw JSON schema definition. + * + *

+ * The document is forwarded to the server verbatim, so it may use any option + * the server accepts — including ones this client version does not model. + * Nothing is validated client-side: invalid configuration surfaces as a + * {@link WeaviateApiException}. Use {@link #create(CollectionConfig)} when you + * want the type-checked builder API instead. + * + *

{@code
+   * client.collections.createFromJson("""
+   *     {
+   *       "class": "Song",
+   *       "properties": [
+   *         { "name": "title", "dataType": ["text"] },
+   *         { "name": "yearReleased", "dataType": ["int"] }
+   *       ],
+   *       "vectorConfig": {
+   *         "default": {
+   *           "vectorizer": { "none": {} },
+   *           "vectorIndexType": "hnsw"
+   *         }
+   *       }
+   *     }
+   *     """);
+   * }
+ * + * @param json JSON object describing the collection. Must contain a + * {@code "class"} key with the collection name. + * @return Handle for the created collection. + * @throws IllegalArgumentException in case the string is not a JSON object or + * does not carry a {@code "class"} name. + * @throws WeaviateApiException in case the server returned with an + * error status code. + * @throws IOException in case the request was not sent + * successfully due to a malformed request, a + * networking error or the server being + * unavailable. + */ + public CollectionHandle> createFromJson(String json) throws IOException { + var request = new CreateCollectionFromJsonRequest(json); + this.restTransport.performRequest(request, CreateCollectionFromJsonRequest._ENDPOINT); + return use(request.collectionName()); + } + /** * Fetch Weaviate collection configuration. * @@ -199,6 +245,45 @@ public Optional getConfig(String collectionName) throws IOExce return this.restTransport.performRequest(new GetConfigRequest(collectionName), GetConfigRequest._ENDPOINT); } + /** + * Fetch a collection's schema exactly as the server returned it. + * + *

+ * {@link #getConfig(String)} maps the response onto {@link CollectionConfig} + * and therefore drops anything this client version does not model. Use this + * method when you need the complete picture — rendering a schema, + * diffing two collections, or reading a module option that has no typed + * accessor yet. + * + * @param collectionName Collection name. + * @return the raw JSON body if a collection with this name exists. + * @throws WeaviateApiException in case the server returned with an + * error status code. + * @throws IOException in case the request was not sent successfully + * due to a malformed request, a networking error + * or the server being unavailable. + * @see #createFromJson(String) + */ + public Optional getConfigAsJson(String collectionName) throws IOException { + return this.restTransport.performRequest(new GetConfigJsonRequest(collectionName), + GetConfigJsonRequest._ENDPOINT); + } + + /** + * Fetch the schema of all collections exactly as the server returned it. + * + * @return the raw JSON body, an object with a {@code "classes"} array. + * @throws WeaviateApiException in case the server returned with an + * error status code. + * @throws IOException in case the request was not sent successfully + * due to a malformed request, a networking error + * or the server being unavailable. + * @see #getConfigAsJson(String) + */ + public String listAsJson() throws IOException { + return this.restTransport.performRequest(new ListCollectionJsonRequest(), ListCollectionJsonRequest._ENDPOINT); + } + /** * Fetch configurations for all collections in Weaviate. * diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/WeaviateCollectionsClientAsync.java b/src/main/java/io/weaviate/client6/v1/api/collections/WeaviateCollectionsClientAsync.java index ba80f715e..47b558522 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/WeaviateCollectionsClientAsync.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/WeaviateCollectionsClientAsync.java @@ -151,6 +151,29 @@ public CompletableFuture>> create(Coll CreateCollectionRequest._ENDPOINT).thenApply(__ -> use(collection.collectionName())); } + /** + * Create a new Weaviate collection from a raw JSON schema definition. + * + *

+ * The document is forwarded to the server verbatim, so it may use any option + * the server accepts — including ones this client version does not model. + * Nothing is validated client-side: invalid configuration surfaces as a + * {@link io.weaviate.client6.v1.api.WeaviateApiException}. Use + * {@link #create(CollectionConfig)} when you want the type-checked builder API + * instead. + * + * @param json JSON object describing the collection. Must contain a + * {@code "class"} key with the collection name. + * @throws IllegalArgumentException in case the string is not a JSON object or + * does not carry a {@code "class"} name. + * @see WeaviateCollectionsClient#createFromJson(String) + */ + public CompletableFuture>> createFromJson(String json) { + var request = new CreateCollectionFromJsonRequest(json); + return this.restTransport.performRequestAsync(request, CreateCollectionFromJsonRequest._ENDPOINT) + .thenApply(__ -> use(request.collectionName())); + } + /** * Fetch Weaviate collection configuration. * @@ -160,6 +183,31 @@ public CompletableFuture> getConfig(String collection return this.restTransport.performRequestAsync(new GetConfigRequest(collectionName), GetConfigRequest._ENDPOINT); } + /** + * Fetch a collection's schema exactly as the server returned it. + * + *

+ * {@link #getConfig(String)} maps the response onto {@link CollectionConfig} + * and therefore drops anything this client version does not model. + * + * @param collectionName Collection name. + * @see WeaviateCollectionsClient#getConfigAsJson(String) + */ + public CompletableFuture> getConfigAsJson(String collectionName) { + return this.restTransport.performRequestAsync(new GetConfigJsonRequest(collectionName), + GetConfigJsonRequest._ENDPOINT); + } + + /** + * Fetch the schema of all collections exactly as the server returned it. + * + * @see WeaviateCollectionsClient#listAsJson() + */ + public CompletableFuture listAsJson() { + return this.restTransport.performRequestAsync(new ListCollectionJsonRequest(), + ListCollectionJsonRequest._ENDPOINT); + } + public CompletableFuture> list() { return this.restTransport.performRequestAsync(new ListCollectionRequest(), ListCollectionRequest._ENDPOINT); } diff --git a/src/test/java/io/weaviate/client6/v1/api/collections/CreateCollectionFromJsonRequestTest.java b/src/test/java/io/weaviate/client6/v1/api/collections/CreateCollectionFromJsonRequestTest.java new file mode 100644 index 000000000..31e85dae6 --- /dev/null +++ b/src/test/java/io/weaviate/client6/v1/api/collections/CreateCollectionFromJsonRequestTest.java @@ -0,0 +1,79 @@ +package io.weaviate.client6.v1.api.collections; + +import org.assertj.core.api.Assertions; +import org.junit.Test; + +public class CreateCollectionFromJsonRequestTest { + + @Test + public void test_endpoint_passesBodyThroughVerbatim() { + // Includes a key the client does not model to prove nothing is stripped. + var json = """ + { "class": "Things", "someFutureOption": { "enabled": true } } + """; + var request = new CreateCollectionFromJsonRequest(json); + + Assertions.assertThat(CreateCollectionFromJsonRequest._ENDPOINT.method(request)).isEqualTo("POST"); + Assertions.assertThat(CreateCollectionFromJsonRequest._ENDPOINT.requestUrl(request)).isEqualTo("/schema/"); + Assertions.assertThat(CreateCollectionFromJsonRequest._ENDPOINT.queryParameters(request)).isEmpty(); + Assertions.assertThat(CreateCollectionFromJsonRequest._ENDPOINT.body(request)) + .as("body is forwarded byte-for-byte").isEqualTo(json); + } + + @Test + public void test_collectionName() { + var request = new CreateCollectionFromJsonRequest(""" + { "class": "Things" } + """); + Assertions.assertThat(request.collectionName()).isEqualTo("Things"); + } + + @Test + public void test_rejects_blankInput() { + Assertions.assertThatThrownBy(() -> new CreateCollectionFromJsonRequest(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not be null or blank"); + } + + @Test + public void test_rejects_nullInput() { + Assertions.assertThatThrownBy(() -> new CreateCollectionFromJsonRequest(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not be null or blank"); + } + + @Test + public void test_rejects_malformedJson() { + Assertions.assertThatThrownBy(() -> new CreateCollectionFromJsonRequest("{ \"class\": ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not valid JSON"); + } + + @Test + public void test_rejects_nonObjectJson() { + Assertions.assertThatThrownBy(() -> new CreateCollectionFromJsonRequest("[ { \"class\": \"Things\" } ]")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be a JSON object"); + } + + @Test + public void test_rejects_missingClass() { + Assertions.assertThatThrownBy(() -> new CreateCollectionFromJsonRequest("{ \"description\": \"no name\" }")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("\"class\""); + } + + @Test + public void test_rejects_blankClass() { + Assertions.assertThatThrownBy(() -> new CreateCollectionFromJsonRequest("{ \"class\": \" \" }")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("\"class\""); + } + + @Test + public void test_rejects_nonStringClass() { + Assertions.assertThatThrownBy(() -> new CreateCollectionFromJsonRequest("{ \"class\": 42 }")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("\"class\""); + } +} diff --git a/src/test/java/io/weaviate/client6/v1/api/collections/GetConfigJsonRequestTest.java b/src/test/java/io/weaviate/client6/v1/api/collections/GetConfigJsonRequestTest.java new file mode 100644 index 000000000..240dbb81d --- /dev/null +++ b/src/test/java/io/weaviate/client6/v1/api/collections/GetConfigJsonRequestTest.java @@ -0,0 +1,70 @@ +package io.weaviate.client6.v1.api.collections; + +import java.util.Optional; + +import org.assertj.core.api.Assertions; +import org.junit.Test; + +import io.weaviate.client6.v1.internal.rest.JsonEndpoint; + +public class GetConfigJsonRequestTest { + @SuppressWarnings("unchecked") + private static final JsonEndpoint> GET = // + (JsonEndpoint>) GetConfigJsonRequest._ENDPOINT; + + @SuppressWarnings("unchecked") + private static final JsonEndpoint LIST = // + (JsonEndpoint) ListCollectionJsonRequest._ENDPOINT; + + @Test + public void test_endpoint() { + var request = new GetConfigJsonRequest("Things"); + + Assertions.assertThat(GET.method(request)).isEqualTo("GET"); + Assertions.assertThat(GET.requestUrl(request)).isEqualTo("/schema/Things"); + Assertions.assertThat(GET.queryParameters(request)).isEmpty(); + Assertions.assertThat(GET.body(request)).isNull(); + } + + @Test + public void test_responseIsNotParsed() { + // Includes a key the client does not model, and a quantizer nested inside a + // "dynamic" index, neither of which survives CollectionConfig deserialization. + var raw = """ + { + "class": "Things", + "someFutureOption": { "enabled": true }, + "vectorConfig": { "default": { + "vectorIndexType": "dynamic", + "vectorIndexConfig": { "hnsw": { "rq": { "enabled": true, "bits": 8 } } } + }} + } + """; + + var got = GET.deserializeResponse(200, raw); + + Assertions.assertThat(got).as("body is returned verbatim").contains(raw); + } + + @Test + public void test_missingCollectionIsEmpty() { + Assertions.assertThat(GET.deserializeResponse(404, "{\"error\":[]}")) + .isEqualTo(Optional.empty()); + Assertions.assertThat(GET.isError(404)) + .as("404 is not an error, it means 'no such collection'").isFalse(); + } + + @Test + public void test_listEndpoint() { + var request = new ListCollectionJsonRequest(); + + Assertions.assertThat(LIST.method(request)).isEqualTo("GET"); + Assertions.assertThat(LIST.requestUrl(request)).isEqualTo("/schema"); + Assertions.assertThat(LIST.queryParameters(request)).isEmpty(); + Assertions.assertThat(LIST.body(request)).isNull(); + + var raw = "{ \"classes\": [ { \"class\": \"Things\" } ] }"; + Assertions.assertThat(LIST.deserializeResponse(200, raw)) + .as("body is returned verbatim").isEqualTo(raw); + } +} From 94de2ef02b20fa8287eacfb589582817892afa06 Mon Sep 17 00:00:00 2001 From: Duda Nogueira Date: Mon, 10 Aug 2026 16:24:18 -0300 Subject: [PATCH 2/2] refactor(collections): parameterize schema endpoints by payload type Address review feedback on #598: the raw-JSON escape hatch no longer needs request classes of its own. The only thing that differed between create(CollectionConfig) and createFromJson(String) was the payload, so the existing request records now take the type as a parameter: GetConfigRequest.endpoint(CollectionConfig.class | String.class) ListCollectionRequest.endpoint(ListCollectionResponse.class | String.class) CreateCollectionRequest.endpoint() CreateCollectionFromJsonRequest, GetConfigJsonRequest and ListCollectionJsonRequest are removed; the public client API is unchanged. SimpleEndpoint.deserializeClass special-cases String.class by returning the response body unparsed, so any endpoint parameterized by response type gets the raw option for free. On the write side, a String payload is forwarded byte-for-byte and anything else is serialized first. The create endpoint now returns Void instead of parsing the echoed configuration into CollectionConfig: both clients discarded it, and a raw payload may describe a collection CollectionConfig cannot represent, which would have made that parse throw. The "class" lookup moved to CreateCollectionRequest.collectionNameFromJson, which both clients call before sending, so an unusable document still fails before the request leaves the process. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PJ9gPAiDmKMN15VyRbw5UR --- .../CreateCollectionFromJsonRequest.java | 69 ------------- .../collections/CreateCollectionRequest.java | 81 ++++++++++++++-- .../api/collections/GetConfigJsonRequest.java | 27 ------ .../v1/api/collections/GetConfigRequest.java | 23 +++-- .../ListCollectionJsonRequest.java | 20 ---- .../collections/ListCollectionRequest.java | 24 +++-- .../WeaviateCollectionsClient.java | 25 +++-- .../WeaviateCollectionsClientAsync.java | 27 +++--- .../v1/internal/rest/SimpleEndpoint.java | 13 +++ .../CreateCollectionFromJsonRequestTest.java | 79 --------------- .../CreateCollectionRequestTest.java | 97 +++++++++++++++++++ .../collections/GetConfigJsonRequestTest.java | 70 ------------- .../api/collections/GetConfigRequestTest.java | 86 ++++++++++++++++ 13 files changed, 334 insertions(+), 307 deletions(-) delete mode 100644 src/main/java/io/weaviate/client6/v1/api/collections/CreateCollectionFromJsonRequest.java delete mode 100644 src/main/java/io/weaviate/client6/v1/api/collections/GetConfigJsonRequest.java delete mode 100644 src/main/java/io/weaviate/client6/v1/api/collections/ListCollectionJsonRequest.java delete mode 100644 src/test/java/io/weaviate/client6/v1/api/collections/CreateCollectionFromJsonRequestTest.java create mode 100644 src/test/java/io/weaviate/client6/v1/api/collections/CreateCollectionRequestTest.java delete mode 100644 src/test/java/io/weaviate/client6/v1/api/collections/GetConfigJsonRequestTest.java create mode 100644 src/test/java/io/weaviate/client6/v1/api/collections/GetConfigRequestTest.java diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/CreateCollectionFromJsonRequest.java b/src/main/java/io/weaviate/client6/v1/api/collections/CreateCollectionFromJsonRequest.java deleted file mode 100644 index b8fc1e78a..000000000 --- a/src/main/java/io/weaviate/client6/v1/api/collections/CreateCollectionFromJsonRequest.java +++ /dev/null @@ -1,69 +0,0 @@ -package io.weaviate.client6.v1.api.collections; - -import java.util.Collections; - -import com.google.gson.JsonElement; -import com.google.gson.JsonParseException; - -import io.weaviate.client6.v1.internal.json.JSON; -import io.weaviate.client6.v1.internal.rest.Endpoint; -import io.weaviate.client6.v1.internal.rest.SimpleEndpoint; - -/** - * Create a collection from a raw JSON schema definition. - * - *

- * The JSON is sent to {@code POST /schema} verbatim: it is never mapped onto - * {@link CollectionConfig}, so any configuration the server understands is - * accepted, including options this client version does not model yet. The - * flip side is that nothing is validated client-side either — a typo in a - * key surfaces as a server error, not as a compile error. - * - *

- * The only part of the document this client reads is the {@code "class"} key, - * which is needed to return a handle for the created collection. - */ -public record CreateCollectionFromJsonRequest(String json) { - public static final Endpoint _ENDPOINT = SimpleEndpoint.sideEffect( - request -> "POST", - request -> "/schema/", - request -> Collections.emptyMap(), - CreateCollectionFromJsonRequest::json); - - public CreateCollectionFromJsonRequest { - // Fail before the request leaves the process rather than after a round-trip. - parseCollectionName(json); - } - - /** Name of the collection defined by this document ({@code "class"} key). */ - public String collectionName() { - return parseCollectionName(json); - } - - private static String parseCollectionName(String json) { - if (json == null || json.isBlank()) { - throw new IllegalArgumentException("collection JSON must not be null or blank"); - } - - JsonElement document; - try { - document = JSON.toJsonElement(json); - } catch (JsonParseException e) { - throw new IllegalArgumentException("collection JSON is not valid JSON", e); - } - - if (!document.isJsonObject()) { - throw new IllegalArgumentException("collection JSON must be a JSON object"); - } - - var collectionName = document.getAsJsonObject().get("class"); - if (collectionName == null - || !collectionName.isJsonPrimitive() - || !collectionName.getAsJsonPrimitive().isString() - || collectionName.getAsString().isBlank()) { - throw new IllegalArgumentException( - "collection JSON must have a non-empty string \"class\" key with the collection name"); - } - return collectionName.getAsString(); - } -} diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/CreateCollectionRequest.java b/src/main/java/io/weaviate/client6/v1/api/collections/CreateCollectionRequest.java index a22957328..cff6a1f3e 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/CreateCollectionRequest.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/CreateCollectionRequest.java @@ -2,15 +2,82 @@ import java.util.Collections; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; + import io.weaviate.client6.v1.internal.json.JSON; import io.weaviate.client6.v1.internal.rest.Endpoint; import io.weaviate.client6.v1.internal.rest.SimpleEndpoint; -public record CreateCollectionRequest(CollectionConfig collection) { - public static final Endpoint _ENDPOINT = new SimpleEndpoint<>( - request -> "POST", - request -> "/schema/", - request -> Collections.emptyMap(), - request -> JSON.serialize(request.collection), - (statusCode, response) -> JSON.deserialize(response, CollectionConfig.class)); +/** + * Create a collection from a {@link CollectionConfig} or from a raw JSON schema + * definition. + * + * @param Type of the payload. A {@code String} is sent verbatim, anything + * else is serialized first. + */ +public record CreateCollectionRequest(T collection) { + /** + * Endpoint which sends the payload to {@code POST /schema}. + * + *

+ * A {@code String} payload is forwarded byte-for-byte, so it may use any + * option the server accepts — including ones this client version does not + * model. Nothing is validated client-side either: a typo in a key surfaces as a + * server error rather than a compile error. + * + *

+ * The server echoes the stored configuration back, which this endpoint discards + * — a raw payload may well describe a collection that + * {@link CollectionConfig} cannot represent. + */ + public static Endpoint, Void> endpoint() { + return SimpleEndpoint.sideEffect( + request -> "POST", + request -> "/schema/", + request -> Collections.emptyMap(), + request -> request.collection instanceof String json + ? json + : JSON.serialize(request.collection)); + } + + /** + * Name of the collection defined by a raw JSON document (its {@code "class"} + * key), which the client needs in order to return a handle for it. + * + *

+ * This is the only part of the document the client reads. Calling it before + * sending doubles as validation: a document that is not usable as a + * {@code POST /schema} payload fails before the request leaves the process + * rather than after a round-trip. + * + * @throws IllegalArgumentException in case the string is not a JSON object or + * does not carry a {@code "class"} name. + */ + public static String collectionNameFromJson(String json) { + if (json == null || json.isBlank()) { + throw new IllegalArgumentException("collection JSON must not be null or blank"); + } + + JsonElement document; + try { + document = JSON.toJsonElement(json); + } catch (JsonParseException e) { + throw new IllegalArgumentException("collection JSON is not valid JSON", e); + } + + if (!document.isJsonObject()) { + throw new IllegalArgumentException("collection JSON must be a JSON object"); + } + + var collectionName = document.getAsJsonObject().get("class"); + if (collectionName == null + || !collectionName.isJsonPrimitive() + || !collectionName.getAsJsonPrimitive().isString() + || collectionName.getAsString().isBlank()) { + throw new IllegalArgumentException( + "collection JSON must have a non-empty string \"class\" key with the collection name"); + } + return collectionName.getAsString(); + } } diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/GetConfigJsonRequest.java b/src/main/java/io/weaviate/client6/v1/api/collections/GetConfigJsonRequest.java deleted file mode 100644 index 9d67d3b4a..000000000 --- a/src/main/java/io/weaviate/client6/v1/api/collections/GetConfigJsonRequest.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.weaviate.client6.v1.api.collections; - -import java.util.Collections; -import java.util.Optional; - -import io.weaviate.client6.v1.internal.rest.Endpoint; -import io.weaviate.client6.v1.internal.rest.OptionalEndpoint; - -/** - * Fetch a collection's schema as the server returned it, without mapping it - * onto {@link CollectionConfig}. - * - *

- * {@link GetConfigRequest} loses everything this client version does not model - * — unknown module options, server-computed fields, and quantizer configs - * nested inside a {@code dynamic} index. This request keeps the response body - * intact, which is what you want when rendering or diffing a schema rather than - * reading individual settings. - */ -public record GetConfigJsonRequest(String collectionName) { - public static final Endpoint> _ENDPOINT = OptionalEndpoint - .noBodyOptional( - request -> "GET", - request -> "/schema/" + request.collectionName, - request -> Collections.emptyMap(), - (statusCode, response) -> response); -} diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/GetConfigRequest.java b/src/main/java/io/weaviate/client6/v1/api/collections/GetConfigRequest.java index d4c73aa58..c096d206e 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/GetConfigRequest.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/GetConfigRequest.java @@ -3,15 +3,24 @@ import java.util.Collections; import java.util.Optional; -import io.weaviate.client6.v1.internal.json.JSON; import io.weaviate.client6.v1.internal.rest.Endpoint; import io.weaviate.client6.v1.internal.rest.OptionalEndpoint; public record GetConfigRequest(String collectionName) { - public static final Endpoint> _ENDPOINT = OptionalEndpoint - .noBodyOptional( - request -> "GET", - request -> "/schema/" + request.collectionName, - request -> Collections.emptyMap(), - (statusCode, response) -> JSON.deserialize(response, CollectionConfig.class)); + /** + * Endpoint which deserializes the schema document into {@code cls}. + * + *

+ * Pass {@code String.class} to get the response body exactly as the server sent + * it. {@link CollectionConfig} only carries what this client version models, so + * the raw document is what you want when rendering or diffing a schema rather + * than reading individual settings. + */ + public static Endpoint> endpoint(Class cls) { + return OptionalEndpoint.noBodyOptional( + request -> "GET", + request -> "/schema/" + request.collectionName, + request -> Collections.emptyMap(), + cls); + } } diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/ListCollectionJsonRequest.java b/src/main/java/io/weaviate/client6/v1/api/collections/ListCollectionJsonRequest.java deleted file mode 100644 index 2e1a3c17b..000000000 --- a/src/main/java/io/weaviate/client6/v1/api/collections/ListCollectionJsonRequest.java +++ /dev/null @@ -1,20 +0,0 @@ -package io.weaviate.client6.v1.api.collections; - -import java.util.Collections; - -import io.weaviate.client6.v1.internal.rest.Endpoint; -import io.weaviate.client6.v1.internal.rest.SimpleEndpoint; - -/** - * Fetch the full schema as the server returned it, without mapping it onto - * {@link CollectionConfig}. - * - * @see GetConfigJsonRequest - */ -public record ListCollectionJsonRequest() { - public static final Endpoint _ENDPOINT = SimpleEndpoint.noBody( - request -> "GET", - request -> "/schema", - request -> Collections.emptyMap(), - (statusCode, response) -> response); -} diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/ListCollectionRequest.java b/src/main/java/io/weaviate/client6/v1/api/collections/ListCollectionRequest.java index 9cc6f87e8..804c5460c 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/ListCollectionRequest.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/ListCollectionRequest.java @@ -1,16 +1,26 @@ package io.weaviate.client6.v1.api.collections; import java.util.Collections; -import java.util.List; -import io.weaviate.client6.v1.internal.json.JSON; import io.weaviate.client6.v1.internal.rest.Endpoint; import io.weaviate.client6.v1.internal.rest.SimpleEndpoint; public record ListCollectionRequest() { - public static final Endpoint> _ENDPOINT = SimpleEndpoint.noBody( - request -> "GET", - request -> "/schema", - request -> Collections.emptyMap(), - (gson, response) -> JSON.deserialize(response, ListCollectionResponse.class).collections()); + /** + * Endpoint which deserializes the schema document into {@code cls}. + * + *

+ * Pass {@link ListCollectionResponse} to get the collections mapped onto + * {@link CollectionConfig}, or {@code String.class} for the response body + * exactly as the server sent it. + * + * @see GetConfigRequest#endpoint(Class) + */ + public static Endpoint endpoint(Class cls) { + return SimpleEndpoint.noBody( + request -> "GET", + request -> "/schema", + request -> Collections.emptyMap(), + cls); + } } diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/WeaviateCollectionsClient.java b/src/main/java/io/weaviate/client6/v1/api/collections/WeaviateCollectionsClient.java index 32566a5fd..cafb58f13 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/WeaviateCollectionsClient.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/WeaviateCollectionsClient.java @@ -179,8 +179,8 @@ public CollectionHandle> create(String collectionName, * or the server being unavailable. */ public CollectionHandle> create(CollectionConfig collection) throws IOException { - this.restTransport.performRequest(new CreateCollectionRequest(collection), - CreateCollectionRequest._ENDPOINT); + this.restTransport.performRequest(new CreateCollectionRequest<>(collection), + CreateCollectionRequest.endpoint()); return use(collection.collectionName()); } @@ -225,9 +225,11 @@ public CollectionHandle> create(CollectionConfig collection) * unavailable. */ public CollectionHandle> createFromJson(String json) throws IOException { - var request = new CreateCollectionFromJsonRequest(json); - this.restTransport.performRequest(request, CreateCollectionFromJsonRequest._ENDPOINT); - return use(request.collectionName()); + // Read the name -- and reject an unusable document -- before sending anything. + var collectionName = CreateCollectionRequest.collectionNameFromJson(json); + this.restTransport.performRequest(new CreateCollectionRequest<>(json), + CreateCollectionRequest.endpoint()); + return use(collectionName); } /** @@ -242,7 +244,8 @@ public CollectionHandle> createFromJson(String json) throws * or the server being unavailable. */ public Optional getConfig(String collectionName) throws IOException { - return this.restTransport.performRequest(new GetConfigRequest(collectionName), GetConfigRequest._ENDPOINT); + return this.restTransport.performRequest(new GetConfigRequest(collectionName), + GetConfigRequest.endpoint(CollectionConfig.class)); } /** @@ -265,8 +268,8 @@ public Optional getConfig(String collectionName) throws IOExce * @see #createFromJson(String) */ public Optional getConfigAsJson(String collectionName) throws IOException { - return this.restTransport.performRequest(new GetConfigJsonRequest(collectionName), - GetConfigJsonRequest._ENDPOINT); + return this.restTransport.performRequest(new GetConfigRequest(collectionName), + GetConfigRequest.endpoint(String.class)); } /** @@ -281,7 +284,8 @@ public Optional getConfigAsJson(String collectionName) throws IOExceptio * @see #getConfigAsJson(String) */ public String listAsJson() throws IOException { - return this.restTransport.performRequest(new ListCollectionJsonRequest(), ListCollectionJsonRequest._ENDPOINT); + return this.restTransport.performRequest(new ListCollectionRequest(), + ListCollectionRequest.endpoint(String.class)); } /** @@ -295,7 +299,8 @@ public String listAsJson() throws IOException { * or the server being unavailable. */ public List list() throws IOException { - return this.restTransport.performRequest(new ListCollectionRequest(), ListCollectionRequest._ENDPOINT); + return this.restTransport.performRequest(new ListCollectionRequest(), + ListCollectionRequest.endpoint(ListCollectionResponse.class)).collections(); } /** diff --git a/src/main/java/io/weaviate/client6/v1/api/collections/WeaviateCollectionsClientAsync.java b/src/main/java/io/weaviate/client6/v1/api/collections/WeaviateCollectionsClientAsync.java index 47b558522..30328d932 100644 --- a/src/main/java/io/weaviate/client6/v1/api/collections/WeaviateCollectionsClientAsync.java +++ b/src/main/java/io/weaviate/client6/v1/api/collections/WeaviateCollectionsClientAsync.java @@ -147,8 +147,9 @@ public CompletableFuture>> create(Stri * Create a new Weaviate collection with {@link CollectionConfig}. */ public CompletableFuture>> create(CollectionConfig collection) { - return this.restTransport.performRequestAsync(new CreateCollectionRequest(collection), - CreateCollectionRequest._ENDPOINT).thenApply(__ -> use(collection.collectionName())); + return this.restTransport.performRequestAsync(new CreateCollectionRequest<>(collection), + CreateCollectionRequest.endpoint()) + .thenApply(__ -> use(collection.collectionName())); } /** @@ -169,9 +170,11 @@ public CompletableFuture>> create(Coll * @see WeaviateCollectionsClient#createFromJson(String) */ public CompletableFuture>> createFromJson(String json) { - var request = new CreateCollectionFromJsonRequest(json); - return this.restTransport.performRequestAsync(request, CreateCollectionFromJsonRequest._ENDPOINT) - .thenApply(__ -> use(request.collectionName())); + // Read the name -- and reject an unusable document -- before sending anything. + var collectionName = CreateCollectionRequest.collectionNameFromJson(json); + return this.restTransport.performRequestAsync(new CreateCollectionRequest<>(json), + CreateCollectionRequest.endpoint()) + .thenApply(__ -> use(collectionName)); } /** @@ -180,7 +183,8 @@ public CompletableFuture>> createFromJ * @param collectionName Collection name. */ public CompletableFuture> getConfig(String collectionName) { - return this.restTransport.performRequestAsync(new GetConfigRequest(collectionName), GetConfigRequest._ENDPOINT); + return this.restTransport.performRequestAsync(new GetConfigRequest(collectionName), + GetConfigRequest.endpoint(CollectionConfig.class)); } /** @@ -194,8 +198,8 @@ public CompletableFuture> getConfig(String collection * @see WeaviateCollectionsClient#getConfigAsJson(String) */ public CompletableFuture> getConfigAsJson(String collectionName) { - return this.restTransport.performRequestAsync(new GetConfigJsonRequest(collectionName), - GetConfigJsonRequest._ENDPOINT); + return this.restTransport.performRequestAsync(new GetConfigRequest(collectionName), + GetConfigRequest.endpoint(String.class)); } /** @@ -204,12 +208,13 @@ public CompletableFuture> getConfigAsJson(String collectionName * @see WeaviateCollectionsClient#listAsJson() */ public CompletableFuture listAsJson() { - return this.restTransport.performRequestAsync(new ListCollectionJsonRequest(), - ListCollectionJsonRequest._ENDPOINT); + return this.restTransport.performRequestAsync(new ListCollectionRequest(), + ListCollectionRequest.endpoint(String.class)); } public CompletableFuture> list() { - return this.restTransport.performRequestAsync(new ListCollectionRequest(), ListCollectionRequest._ENDPOINT); + return this.restTransport.performRequestAsync(new ListCollectionRequest(), + ListCollectionRequest.endpoint(ListCollectionResponse.class)).thenApply(ListCollectionResponse::collections); } /** diff --git a/src/main/java/io/weaviate/client6/v1/internal/rest/SimpleEndpoint.java b/src/main/java/io/weaviate/client6/v1/internal/rest/SimpleEndpoint.java index b65dc6130..439dbc7f8 100644 --- a/src/main/java/io/weaviate/client6/v1/internal/rest/SimpleEndpoint.java +++ b/src/main/java/io/weaviate/client6/v1/internal/rest/SimpleEndpoint.java @@ -16,7 +16,20 @@ protected static BiFunction nullResponse() { return NULL_RESPONSE; } + /** + * Response deserializer for {@code cls}. + * + *

+ * {@code String.class} is a special case: the body is returned as the server + * sent it, without being parsed. Endpoints parameterized by response type use + * this to offer a raw-JSON alternative to a model class, which is the only way + * to see fields the client does not model yet. + */ + @SuppressWarnings("unchecked") protected static BiFunction deserializeClass(Class cls) { + if (cls == String.class) { + return (statusCode, response) -> (T) response; + } return (statusCode, response) -> JSON.deserialize(response, cls); } diff --git a/src/test/java/io/weaviate/client6/v1/api/collections/CreateCollectionFromJsonRequestTest.java b/src/test/java/io/weaviate/client6/v1/api/collections/CreateCollectionFromJsonRequestTest.java deleted file mode 100644 index 31e85dae6..000000000 --- a/src/test/java/io/weaviate/client6/v1/api/collections/CreateCollectionFromJsonRequestTest.java +++ /dev/null @@ -1,79 +0,0 @@ -package io.weaviate.client6.v1.api.collections; - -import org.assertj.core.api.Assertions; -import org.junit.Test; - -public class CreateCollectionFromJsonRequestTest { - - @Test - public void test_endpoint_passesBodyThroughVerbatim() { - // Includes a key the client does not model to prove nothing is stripped. - var json = """ - { "class": "Things", "someFutureOption": { "enabled": true } } - """; - var request = new CreateCollectionFromJsonRequest(json); - - Assertions.assertThat(CreateCollectionFromJsonRequest._ENDPOINT.method(request)).isEqualTo("POST"); - Assertions.assertThat(CreateCollectionFromJsonRequest._ENDPOINT.requestUrl(request)).isEqualTo("/schema/"); - Assertions.assertThat(CreateCollectionFromJsonRequest._ENDPOINT.queryParameters(request)).isEmpty(); - Assertions.assertThat(CreateCollectionFromJsonRequest._ENDPOINT.body(request)) - .as("body is forwarded byte-for-byte").isEqualTo(json); - } - - @Test - public void test_collectionName() { - var request = new CreateCollectionFromJsonRequest(""" - { "class": "Things" } - """); - Assertions.assertThat(request.collectionName()).isEqualTo("Things"); - } - - @Test - public void test_rejects_blankInput() { - Assertions.assertThatThrownBy(() -> new CreateCollectionFromJsonRequest(" ")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("must not be null or blank"); - } - - @Test - public void test_rejects_nullInput() { - Assertions.assertThatThrownBy(() -> new CreateCollectionFromJsonRequest(null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("must not be null or blank"); - } - - @Test - public void test_rejects_malformedJson() { - Assertions.assertThatThrownBy(() -> new CreateCollectionFromJsonRequest("{ \"class\": ")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("not valid JSON"); - } - - @Test - public void test_rejects_nonObjectJson() { - Assertions.assertThatThrownBy(() -> new CreateCollectionFromJsonRequest("[ { \"class\": \"Things\" } ]")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("must be a JSON object"); - } - - @Test - public void test_rejects_missingClass() { - Assertions.assertThatThrownBy(() -> new CreateCollectionFromJsonRequest("{ \"description\": \"no name\" }")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("\"class\""); - } - - @Test - public void test_rejects_blankClass() { - Assertions.assertThatThrownBy(() -> new CreateCollectionFromJsonRequest("{ \"class\": \" \" }")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("\"class\""); - } - - @Test - public void test_rejects_nonStringClass() { - Assertions.assertThatThrownBy(() -> new CreateCollectionFromJsonRequest("{ \"class\": 42 }")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("\"class\""); - } -} diff --git a/src/test/java/io/weaviate/client6/v1/api/collections/CreateCollectionRequestTest.java b/src/test/java/io/weaviate/client6/v1/api/collections/CreateCollectionRequestTest.java new file mode 100644 index 000000000..d5118e48d --- /dev/null +++ b/src/test/java/io/weaviate/client6/v1/api/collections/CreateCollectionRequestTest.java @@ -0,0 +1,97 @@ +package io.weaviate.client6.v1.api.collections; + +import org.assertj.core.api.Assertions; +import org.junit.Test; + +import com.google.gson.JsonParser; + +import io.weaviate.client6.v1.internal.rest.Endpoint; + +public class CreateCollectionRequestTest { + private static final Endpoint, Void> RAW = // + CreateCollectionRequest.endpoint(); + + private static final Endpoint, Void> TYPED = // + CreateCollectionRequest.endpoint(); + + @Test + public void test_endpoint_passesRawBodyThroughVerbatim() { + // Includes a key the client does not model to prove nothing is stripped. + var json = """ + { "class": "Things", "someFutureOption": { "enabled": true } } + """; + var request = new CreateCollectionRequest<>(json); + + Assertions.assertThat(RAW.method(request)).isEqualTo("POST"); + Assertions.assertThat(RAW.requestUrl(request)).isEqualTo("/schema/"); + Assertions.assertThat(RAW.queryParameters(request)).isEmpty(); + Assertions.assertThat(RAW.body(request)).as("body is forwarded byte-for-byte").isEqualTo(json); + } + + @Test + public void test_endpoint_serializesTypedPayload() { + var request = new CreateCollectionRequest<>(CollectionConfig.of("Things")); + + Assertions.assertThat(TYPED.method(request)).isEqualTo("POST"); + Assertions.assertThat(TYPED.requestUrl(request)).isEqualTo("/schema/"); + + var body = JsonParser.parseString(TYPED.body(request)).getAsJsonObject(); + Assertions.assertThat(body.get("class").getAsString()).isEqualTo("Things"); + } + + @Test + public void test_collectionNameFromJson() { + Assertions.assertThat(CreateCollectionRequest.collectionNameFromJson(""" + { "class": "Things" } + """)).isEqualTo("Things"); + } + + @Test + public void test_rejects_blankInput() { + Assertions.assertThatThrownBy(() -> CreateCollectionRequest.collectionNameFromJson(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not be null or blank"); + } + + @Test + public void test_rejects_nullInput() { + Assertions.assertThatThrownBy(() -> CreateCollectionRequest.collectionNameFromJson(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not be null or blank"); + } + + @Test + public void test_rejects_malformedJson() { + Assertions.assertThatThrownBy(() -> CreateCollectionRequest.collectionNameFromJson("{ \"class\": ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not valid JSON"); + } + + @Test + public void test_rejects_nonObjectJson() { + Assertions.assertThatThrownBy(() -> CreateCollectionRequest.collectionNameFromJson("[ { \"class\": \"Things\" } ]")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be a JSON object"); + } + + @Test + public void test_rejects_missingClass() { + Assertions.assertThatThrownBy(() -> CreateCollectionRequest.collectionNameFromJson("{ \"description\": \"x\" }")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("\"class\""); + } + + @Test + public void test_rejects_blankClass() { + Assertions.assertThatThrownBy(() -> CreateCollectionRequest.collectionNameFromJson("{ \"class\": \" \" }")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("\"class\""); + } + + @Test + public void test_rejects_nonStringClass() { + Assertions.assertThatThrownBy(() -> CreateCollectionRequest.collectionNameFromJson("{ \"class\": 42 }")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("\"class\""); + } +} diff --git a/src/test/java/io/weaviate/client6/v1/api/collections/GetConfigJsonRequestTest.java b/src/test/java/io/weaviate/client6/v1/api/collections/GetConfigJsonRequestTest.java deleted file mode 100644 index 240dbb81d..000000000 --- a/src/test/java/io/weaviate/client6/v1/api/collections/GetConfigJsonRequestTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package io.weaviate.client6.v1.api.collections; - -import java.util.Optional; - -import org.assertj.core.api.Assertions; -import org.junit.Test; - -import io.weaviate.client6.v1.internal.rest.JsonEndpoint; - -public class GetConfigJsonRequestTest { - @SuppressWarnings("unchecked") - private static final JsonEndpoint> GET = // - (JsonEndpoint>) GetConfigJsonRequest._ENDPOINT; - - @SuppressWarnings("unchecked") - private static final JsonEndpoint LIST = // - (JsonEndpoint) ListCollectionJsonRequest._ENDPOINT; - - @Test - public void test_endpoint() { - var request = new GetConfigJsonRequest("Things"); - - Assertions.assertThat(GET.method(request)).isEqualTo("GET"); - Assertions.assertThat(GET.requestUrl(request)).isEqualTo("/schema/Things"); - Assertions.assertThat(GET.queryParameters(request)).isEmpty(); - Assertions.assertThat(GET.body(request)).isNull(); - } - - @Test - public void test_responseIsNotParsed() { - // Includes a key the client does not model, and a quantizer nested inside a - // "dynamic" index, neither of which survives CollectionConfig deserialization. - var raw = """ - { - "class": "Things", - "someFutureOption": { "enabled": true }, - "vectorConfig": { "default": { - "vectorIndexType": "dynamic", - "vectorIndexConfig": { "hnsw": { "rq": { "enabled": true, "bits": 8 } } } - }} - } - """; - - var got = GET.deserializeResponse(200, raw); - - Assertions.assertThat(got).as("body is returned verbatim").contains(raw); - } - - @Test - public void test_missingCollectionIsEmpty() { - Assertions.assertThat(GET.deserializeResponse(404, "{\"error\":[]}")) - .isEqualTo(Optional.empty()); - Assertions.assertThat(GET.isError(404)) - .as("404 is not an error, it means 'no such collection'").isFalse(); - } - - @Test - public void test_listEndpoint() { - var request = new ListCollectionJsonRequest(); - - Assertions.assertThat(LIST.method(request)).isEqualTo("GET"); - Assertions.assertThat(LIST.requestUrl(request)).isEqualTo("/schema"); - Assertions.assertThat(LIST.queryParameters(request)).isEmpty(); - Assertions.assertThat(LIST.body(request)).isNull(); - - var raw = "{ \"classes\": [ { \"class\": \"Things\" } ] }"; - Assertions.assertThat(LIST.deserializeResponse(200, raw)) - .as("body is returned verbatim").isEqualTo(raw); - } -} diff --git a/src/test/java/io/weaviate/client6/v1/api/collections/GetConfigRequestTest.java b/src/test/java/io/weaviate/client6/v1/api/collections/GetConfigRequestTest.java new file mode 100644 index 000000000..b83e4dc39 --- /dev/null +++ b/src/test/java/io/weaviate/client6/v1/api/collections/GetConfigRequestTest.java @@ -0,0 +1,86 @@ +package io.weaviate.client6.v1.api.collections; + +import java.util.Optional; + +import org.assertj.core.api.Assertions; +import org.junit.Test; + +import io.weaviate.client6.v1.internal.rest.JsonEndpoint; + +public class GetConfigRequestTest { + @SuppressWarnings("unchecked") + private static final JsonEndpoint> GET_RAW = // + (JsonEndpoint>) GetConfigRequest.endpoint(String.class); + + @SuppressWarnings("unchecked") + private static final JsonEndpoint> GET_TYPED = // + (JsonEndpoint>) GetConfigRequest.endpoint(CollectionConfig.class); + + @SuppressWarnings("unchecked") + private static final JsonEndpoint LIST_RAW = // + (JsonEndpoint) ListCollectionRequest.endpoint(String.class); + + @SuppressWarnings("unchecked") + private static final JsonEndpoint LIST_TYPED = // + (JsonEndpoint) ListCollectionRequest + .endpoint(ListCollectionResponse.class); + + // Includes a key the client does not model, and a quantizer nested inside a + // "dynamic" index, neither of which survives CollectionConfig deserialization. + private static final String RAW_SCHEMA = """ + { + "class": "Things", + "someFutureOption": { "enabled": true }, + "vectorConfig": { "default": { + "vectorIndexType": "dynamic", + "vectorIndexConfig": { "hnsw": { "rq": { "enabled": true, "bits": 8 } } } + }} + } + """; + + @Test + public void test_endpoint() { + var request = new GetConfigRequest("Things"); + + Assertions.assertThat(GET_RAW.method(request)).isEqualTo("GET"); + Assertions.assertThat(GET_RAW.requestUrl(request)).isEqualTo("/schema/Things"); + Assertions.assertThat(GET_RAW.queryParameters(request)).isEmpty(); + Assertions.assertThat(GET_RAW.body(request)).isNull(); + } + + @Test + public void test_stringResponseIsNotParsed() { + Assertions.assertThat(GET_RAW.deserializeResponse(200, RAW_SCHEMA)) + .as("body is returned verbatim").contains(RAW_SCHEMA); + } + + @Test + public void test_typedResponseIsDeserialized() { + Assertions.assertThat(GET_TYPED.deserializeResponse(200, "{ \"class\": \"Things\" }")).get() + .extracting(CollectionConfig::collectionName).isEqualTo("Things"); + } + + @Test + public void test_missingCollectionIsEmpty() { + Assertions.assertThat(GET_RAW.deserializeResponse(404, "{\"error\":[]}")) + .isEqualTo(Optional.empty()); + Assertions.assertThat(GET_RAW.isError(404)) + .as("404 is not an error, it means 'no such collection'").isFalse(); + } + + @Test + public void test_listEndpoint() { + var request = new ListCollectionRequest(); + + Assertions.assertThat(LIST_RAW.method(request)).isEqualTo("GET"); + Assertions.assertThat(LIST_RAW.requestUrl(request)).isEqualTo("/schema"); + Assertions.assertThat(LIST_RAW.queryParameters(request)).isEmpty(); + Assertions.assertThat(LIST_RAW.body(request)).isNull(); + + var raw = "{ \"classes\": [ { \"class\": \"Things\" } ] }"; + Assertions.assertThat(LIST_RAW.deserializeResponse(200, raw)) + .as("body is returned verbatim").isEqualTo(raw); + Assertions.assertThat(LIST_TYPED.deserializeResponse(200, raw).collections()) + .extracting(CollectionConfig::collectionName).containsExactly("Things"); + } +}