diff --git a/constructorio-client/src/main/java/io/constructor/client/ConstructorIO.java b/constructorio-client/src/main/java/io/constructor/client/ConstructorIO.java index 0ac7968a..c60ad280 100644 --- a/constructorio-client/src/main/java/io/constructor/client/ConstructorIO.java +++ b/constructorio-client/src/main/java/io/constructor/client/ConstructorIO.java @@ -2031,6 +2031,50 @@ protected static String getResponseBody(Response response) throws ConstructorExc throw new ConstructorException(errorMessage, errorCode); } + /** + * Validates and extracts the file extension from a File object for catalog uploads. + * Only .csv, .json, and .jsonl extensions are supported. + * + * @param file the File object containing the actual file + * @param fileName the logical file name (items, variations, item_groups) + * @return the validated file extension (including the dot, e.g., ".csv", ".json", or ".jsonl") + * @throws ConstructorException if the file extension is invalid or missing + */ + private static String getValidatedFileExtension(File file, String fileName) + throws ConstructorException { + if (file == null) { + throw new ConstructorException( + "Invalid file for '" + fileName + "': file cannot be null."); + } + + String actualFileName = file.getName(); + if (actualFileName == null || actualFileName.isEmpty()) { + throw new ConstructorException( + "Invalid file for '" + fileName + "': file name cannot be empty."); + } + + int lastDotIndex = actualFileName.lastIndexOf('.'); + if (lastDotIndex == -1 || lastDotIndex == actualFileName.length() - 1) { + throw new ConstructorException( + "Invalid file for '" + + fileName + + "': file must have .csv, .json, or .jsonl extension. Found: " + + actualFileName); + } + + String extension = actualFileName.substring(lastDotIndex).toLowerCase(); + + if (!extension.equals(".csv") && !extension.equals(".json") && !extension.equals(".jsonl")) { + throw new ConstructorException( + "Invalid file type for '" + + fileName + + "': file must have .csv, .json, or .jsonl extension. Found: " + + actualFileName); + } + + return extension; + } + /** * Grabs the version number (hard coded ATM) * @@ -2308,9 +2352,12 @@ protected static JSONArray transformItemsAPIV2Response(JSONArray results) { /** * Send a full catalog to replace the current one (sync) * - * @param req the catalog request - * @return a string of JSON - * @throws ConstructorException if the request is invalid. + * Supports CSV, JSON, and JSONL file formats. The file type is automatically + * detected from the file extension (.csv, .json, or .jsonl). + * + * @param req the catalog request containing files with .csv, .json, or .jsonl extensions + * @return a string of JSON containing task information + * @throws ConstructorException if the request is invalid or file extensions are not supported */ public String replaceCatalog(CatalogRequest req) throws ConstructorException { try { @@ -2336,10 +2383,11 @@ public String replaceCatalog(CatalogRequest req) throws ConstructorException { for (Map.Entry entry : files.entrySet()) { String fileName = entry.getKey(); File file = entry.getValue(); + String fileExtension = getValidatedFileExtension(file, fileName); multipartBuilder.addFormDataPart( fileName, - fileName + ".csv", + fileName + fileExtension, RequestBody.create(MediaType.parse("application/octet-stream"), file)); } } @@ -2365,9 +2413,12 @@ public String replaceCatalog(CatalogRequest req) throws ConstructorException { /** * Send a partial catalog to update specific items (delta) * - * @param req the catalog request - * @return a string of JSON - * @throws ConstructorException if the request is invalid. + * Supports CSV, JSON, and JSONL file formats. The file type is automatically + * detected from the file extension (.csv, .json, or .jsonl). + * + * @param req the catalog request containing files with .csv, .json, or .jsonl extensions + * @return a string of JSON containing task information + * @throws ConstructorException if the request is invalid or file extensions are not supported */ public String updateCatalog(CatalogRequest req) throws ConstructorException { try { @@ -2393,10 +2444,11 @@ public String updateCatalog(CatalogRequest req) throws ConstructorException { for (Map.Entry entry : files.entrySet()) { String fileName = entry.getKey(); File file = entry.getValue(); + String fileExtension = getValidatedFileExtension(file, fileName); multipartBuilder.addFormDataPart( fileName, - fileName + ".csv", + fileName + fileExtension, RequestBody.create(MediaType.parse("application/octet-stream"), file)); } } @@ -2423,9 +2475,12 @@ public String updateCatalog(CatalogRequest req) throws ConstructorException { /** * Send a patch delta catalog to update specific items (delta) * - * @param req the catalog request - * @return a string of JSON - * @throws ConstructorException if the request is invalid. + * Supports CSV, JSON, and JSONL file formats. The file type is automatically + * detected from the file extension (.csv, .json, or .jsonl). + * + * @param req the catalog request containing files with .csv, .json, or .jsonl extensions + * @return a string of JSON containing task information + * @throws ConstructorException if the request is invalid or file extensions are not supported */ public String patchCatalog(CatalogRequest req) throws ConstructorException { try { @@ -2456,10 +2511,11 @@ public String patchCatalog(CatalogRequest req) throws ConstructorException { for (Map.Entry entry : files.entrySet()) { String fileName = entry.getKey(); File file = entry.getValue(); + String fileExtension = getValidatedFileExtension(file, fileName); multipartBuilder.addFormDataPart( fileName, - fileName + ".csv", + fileName + fileExtension, RequestBody.create(MediaType.parse("application/octet-stream"), file)); } } diff --git a/constructorio-client/src/test/java/io/constructor/client/ConstructorIOCatalogTest.java b/constructorio-client/src/test/java/io/constructor/client/ConstructorIOCatalogTest.java index 260b5513..a6e388cc 100644 --- a/constructorio-client/src/test/java/io/constructor/client/ConstructorIOCatalogTest.java +++ b/constructorio-client/src/test/java/io/constructor/client/ConstructorIOCatalogTest.java @@ -405,4 +405,213 @@ public void PatchCatalogWithItemsAndVariationsAndItemGroupsFilesShouldReturnTask assertTrue("task_id exists", jsonObj.has("task_id") == true); assertTrue("task_status_path exists", jsonObj.has("task_status_path") == true); } + + // JSONL Format Tests + + @Test + public void ReplaceCatalogWithJsonlItemsFileShouldReturnTaskInfo() throws Exception { + ConstructorIO constructor = new ConstructorIO(token, apiKey, true, null); + Map files = new HashMap(); + + files.put("items", new File("src/test/resources/jsonl/items.jsonl")); + + CatalogRequest req = new CatalogRequest(files, "Products"); + String response = constructor.replaceCatalog(req); + JSONObject jsonObj = new JSONObject(response); + + assertTrue("task_id exists", jsonObj.has("task_id") == true); + assertTrue("task_status_path exists", jsonObj.has("task_status_path") == true); + } + + @Test + public void UpdateCatalogWithJsonlItemsFileShouldReturnTaskInfo() throws Exception { + ConstructorIO constructor = new ConstructorIO(token, apiKey, true, null); + Map files = new HashMap(); + + files.put("items", new File("src/test/resources/jsonl/items.jsonl")); + + CatalogRequest req = new CatalogRequest(files, "Products"); + String response = constructor.updateCatalog(req); + JSONObject jsonObj = new JSONObject(response); + + assertTrue("task_id exists", jsonObj.has("task_id") == true); + assertTrue("task_status_path exists", jsonObj.has("task_status_path") == true); + } + + @Test + public void PatchCatalogWithJsonlItemsFileShouldReturnTaskInfo() throws Exception { + ConstructorIO constructor = new ConstructorIO(token, apiKey, true, null); + Map files = new HashMap(); + + files.put("items", new File("src/test/resources/jsonl/items.jsonl")); + + CatalogRequest req = new CatalogRequest(files, "Products"); + String response = constructor.patchCatalog(req); + JSONObject jsonObj = new JSONObject(response); + + assertTrue("task_id exists", jsonObj.has("task_id") == true); + assertTrue("task_status_path exists", jsonObj.has("task_status_path") == true); + } + + // Invalid Extension Tests + + @Test + public void ReplaceCatalogWithInvalidExtensionShouldError() throws Exception { + ConstructorIO constructor = new ConstructorIO(token, apiKey, true, null); + Map files = new HashMap(); + + files.put("items", new File("src/test/resources/invalid/items.txt")); + + CatalogRequest req = new CatalogRequest(files, "Products"); + + thrown.expect(ConstructorException.class); + thrown.expectMessage("Invalid file type for 'items'"); + thrown.expectMessage("must have .csv or .jsonl extension"); + constructor.replaceCatalog(req); + } + + @Test + public void UpdateCatalogWithNoExtensionShouldError() throws Exception { + ConstructorIO constructor = new ConstructorIO(token, apiKey, true, null); + Map files = new HashMap(); + + files.put("items", new File("src/test/resources/invalid/items")); + + CatalogRequest req = new CatalogRequest(files, "Products"); + + thrown.expect(ConstructorException.class); + thrown.expectMessage("Invalid file for 'items'"); + thrown.expectMessage("must have .csv or .jsonl extension"); + constructor.updateCatalog(req); + } + + @Test + public void PatchCatalogWithInvalidExtensionShouldError() throws Exception { + ConstructorIO constructor = new ConstructorIO(token, apiKey, true, null); + Map files = new HashMap(); + + files.put("items", new File("src/test/resources/invalid/items.txt")); + + CatalogRequest req = new CatalogRequest(files, "Products"); + + thrown.expect(ConstructorException.class); + thrown.expectMessage("Invalid file type for 'items'"); + thrown.expectMessage("must have .csv or .jsonl extension"); + constructor.patchCatalog(req); + } + + // Edge Case Tests + + @Test + public void ReplaceCatalogWithMixedFileTypesShouldSucceed() throws Exception { + ConstructorIO constructor = new ConstructorIO(token, apiKey, true, null); + Map files = new HashMap(); + + files.put("items", new File("src/test/resources/csv/items.csv")); + files.put("variations", new File("src/test/resources/jsonl/variations.jsonl")); + + CatalogRequest req = new CatalogRequest(files, "Products"); + String response = constructor.replaceCatalog(req); + JSONObject jsonObj = new JSONObject(response); + + assertTrue("task_id exists", jsonObj.has("task_id") == true); + assertTrue("task_status_path exists", jsonObj.has("task_status_path") == true); + } + + @Test + public void UpdateCatalogWithJsonlVariationsAndItemGroupsShouldSucceed() throws Exception { + ConstructorIO constructor = new ConstructorIO(token, apiKey, true, null); + Map files = new HashMap(); + + files.put("variations", new File("src/test/resources/jsonl/variations.jsonl")); + files.put("item_groups", new File("src/test/resources/jsonl/item_groups.jsonl")); + + CatalogRequest req = new CatalogRequest(files, "Products"); + String response = constructor.updateCatalog(req); + JSONObject jsonObj = new JSONObject(response); + + assertTrue("task_id exists", jsonObj.has("task_id") == true); + assertTrue("task_status_path exists", jsonObj.has("task_status_path") == true); + } + + @Test + public void PatchCatalogWithAllJsonlFilesShouldSucceed() throws Exception { + ConstructorIO constructor = new ConstructorIO(token, apiKey, true, null); + Map files = new HashMap(); + + files.put("items", new File("src/test/resources/jsonl/items.jsonl")); + files.put("variations", new File("src/test/resources/jsonl/variations.jsonl")); + files.put("item_groups", new File("src/test/resources/jsonl/item_groups.jsonl")); + + CatalogRequest req = new CatalogRequest(files, "Products"); + String response = constructor.patchCatalog(req); + JSONObject jsonObj = new JSONObject(response); + + assertTrue("task_id exists", jsonObj.has("task_id") == true); + assertTrue("task_status_path exists", jsonObj.has("task_status_path") == true); + } + + // JSON Format Tests + + @Test + public void ReplaceCatalogWithJsonItemsFileShouldReturnTaskInfo() throws Exception { + ConstructorIO constructor = new ConstructorIO(token, apiKey, true, null); + Map files = new HashMap(); + + files.put("items", new File("src/test/resources/json/items.json")); + + CatalogRequest req = new CatalogRequest(files, "Products"); + String response = constructor.replaceCatalog(req); + JSONObject jsonObj = new JSONObject(response); + + assertTrue("task_id exists", jsonObj.has("task_id") == true); + assertTrue("task_status_path exists", jsonObj.has("task_status_path") == true); + } + + @Test + public void UpdateCatalogWithJsonItemsFileShouldReturnTaskInfo() throws Exception { + ConstructorIO constructor = new ConstructorIO(token, apiKey, true, null); + Map files = new HashMap(); + + files.put("items", new File("src/test/resources/json/items.json")); + + CatalogRequest req = new CatalogRequest(files, "Products"); + String response = constructor.updateCatalog(req); + JSONObject jsonObj = new JSONObject(response); + + assertTrue("task_id exists", jsonObj.has("task_id") == true); + assertTrue("task_status_path exists", jsonObj.has("task_status_path") == true); + } + + @Test + public void PatchCatalogWithJsonItemsFileShouldReturnTaskInfo() throws Exception { + ConstructorIO constructor = new ConstructorIO(token, apiKey, true, null); + Map files = new HashMap(); + + files.put("items", new File("src/test/resources/json/items.json")); + + CatalogRequest req = new CatalogRequest(files, "Products"); + String response = constructor.patchCatalog(req); + JSONObject jsonObj = new JSONObject(response); + + assertTrue("task_id exists", jsonObj.has("task_id") == true); + assertTrue("task_status_path exists", jsonObj.has("task_status_path") == true); + } + + @Test + public void ReplaceCatalogWithMixedCsvJsonJsonlShouldSucceed() throws Exception { + ConstructorIO constructor = new ConstructorIO(token, apiKey, true, null); + Map files = new HashMap(); + + files.put("items", new File("src/test/resources/csv/items.csv")); + files.put("variations", new File("src/test/resources/json/variations.json")); + files.put("item_groups", new File("src/test/resources/jsonl/item_groups.jsonl")); + + CatalogRequest req = new CatalogRequest(files, "Products"); + String response = constructor.replaceCatalog(req); + JSONObject jsonObj = new JSONObject(response); + + assertTrue("task_id exists", jsonObj.has("task_id") == true); + assertTrue("task_status_path exists", jsonObj.has("task_status_path") == true); + } } diff --git a/constructorio-client/src/test/resources/invalid/items b/constructorio-client/src/test/resources/invalid/items new file mode 100644 index 00000000..a992b237 --- /dev/null +++ b/constructorio-client/src/test/resources/invalid/items @@ -0,0 +1 @@ +This file has no extension for testing validation. diff --git a/constructorio-client/src/test/resources/invalid/items.txt b/constructorio-client/src/test/resources/invalid/items.txt new file mode 100644 index 00000000..887c87ec --- /dev/null +++ b/constructorio-client/src/test/resources/invalid/items.txt @@ -0,0 +1 @@ +This is a text file with invalid extension for catalog upload testing. diff --git a/constructorio-client/src/test/resources/json/items.json b/constructorio-client/src/test/resources/json/items.json new file mode 100644 index 00000000..69b7f47f --- /dev/null +++ b/constructorio-client/src/test/resources/json/items.json @@ -0,0 +1,23 @@ +[ + { + "id": "item1", + "value": "Product 1", + "data": { + "url": "https://example.com/product1" + } + }, + { + "id": "item2", + "value": "Product 2", + "data": { + "url": "https://example.com/product2" + } + }, + { + "id": "item3", + "value": "Product 3", + "data": { + "url": "https://example.com/product3" + } + } +] diff --git a/constructorio-client/src/test/resources/json/variations.json b/constructorio-client/src/test/resources/json/variations.json new file mode 100644 index 00000000..45fb9621 --- /dev/null +++ b/constructorio-client/src/test/resources/json/variations.json @@ -0,0 +1,18 @@ +[ + { + "id": "var1", + "item_id": "item1", + "value": "Product 1 Variation A", + "data": { + "color": "red" + } + }, + { + "id": "var2", + "item_id": "item1", + "value": "Product 1 Variation B", + "data": { + "color": "blue" + } + } +] diff --git a/constructorio-client/src/test/resources/jsonl/item_groups.jsonl b/constructorio-client/src/test/resources/jsonl/item_groups.jsonl new file mode 100644 index 00000000..2809502e --- /dev/null +++ b/constructorio-client/src/test/resources/jsonl/item_groups.jsonl @@ -0,0 +1,2 @@ +{"id":"group1","value":"Group 1","data":{"parent_id":"root"}} +{"id":"group2","value":"Group 2","data":{"parent_id":"root"}} diff --git a/constructorio-client/src/test/resources/jsonl/items.jsonl b/constructorio-client/src/test/resources/jsonl/items.jsonl new file mode 100644 index 00000000..b3c5f58b --- /dev/null +++ b/constructorio-client/src/test/resources/jsonl/items.jsonl @@ -0,0 +1,3 @@ +{"id":"item1","value":"Product 1","data":{"url":"https://example.com/product1"}} +{"id":"item2","value":"Product 2","data":{"url":"https://example.com/product2"}} +{"id":"item3","value":"Product 3","data":{"url":"https://example.com/product3"}} diff --git a/constructorio-client/src/test/resources/jsonl/variations.jsonl b/constructorio-client/src/test/resources/jsonl/variations.jsonl new file mode 100644 index 00000000..5b17177f --- /dev/null +++ b/constructorio-client/src/test/resources/jsonl/variations.jsonl @@ -0,0 +1,3 @@ +{"id":"var1","item_id":"item1","value":"Product 1 Variation A","data":{"color":"red"}} +{"id":"var2","item_id":"item1","value":"Product 1 Variation B","data":{"color":"blue"}} +{"id":"var3","item_id":"item2","value":"Product 2 Variation A","data":{"color":"green"}}