From a5c6ccca6bafdf52f70a0878323ea9fc6ced6764 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 3 Apr 2026 17:28:21 -0400 Subject: [PATCH 1/3] Add download and get file to configsets api. Update docs. Separated out of the larger PR around SchemaDesigner. --- .../client/api/endpoint/ConfigsetsApi.java | 48 ++ .../model/ConfigSetFileContentsResponse.java | 31 ++ .../solr/handler/admin/ConfigSetsHandler.java | 9 +- .../handler/configsets/CloneConfigSet.java | 6 +- .../handler/configsets/DeleteConfigSet.java | 6 +- .../handler/configsets/DownloadConfigSet.java | 141 ++++++ .../handler/configsets/GetConfigSetFile.java | 82 ++++ .../handler/configsets/UploadConfigSet.java | 5 + .../configsets/DownloadConfigSetAPITest.java | 154 +++++++ .../configsets/GetConfigSetFileAPITest.java | 135 ++++++ .../configsets/UploadConfigSetAPITest.java | 427 ++++++++++++++++++ 11 files changed, 1041 insertions(+), 3 deletions(-) create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/ConfigSetFileContentsResponse.java create mode 100644 solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java create mode 100644 solr/core/src/java/org/apache/solr/handler/configsets/GetConfigSetFile.java create mode 100644 solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java create mode 100644 solr/core/src/test/org/apache/solr/handler/configsets/GetConfigSetFileAPITest.java create mode 100644 solr/core/src/test/org/apache/solr/handler/configsets/UploadConfigSetAPITest.java diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/ConfigsetsApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/ConfigsetsApi.java index 4bc812043e9d..ac05da1225e5 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/ConfigsetsApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/ConfigsetsApi.java @@ -16,7 +16,11 @@ */ package org.apache.solr.client.api.endpoint; +import static org.apache.solr.client.api.util.Constants.RAW_OUTPUT_PROPERTY; + import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.extensions.Extension; +import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; import io.swagger.v3.oas.annotations.parameters.RequestBody; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; @@ -24,10 +28,14 @@ import jakarta.ws.rs.PUT; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; import java.io.IOException; import java.io.InputStream; import org.apache.solr.client.api.model.CloneConfigsetRequestBody; +import org.apache.solr.client.api.model.ConfigSetFileContentsResponse; import org.apache.solr.client.api.model.ListConfigsetsResponse; import org.apache.solr.client.api.model.SolrJerseyResponse; @@ -71,6 +79,46 @@ SolrJerseyResponse deleteConfigSet(@PathParam("configSetName") String configSetN throws Exception; } + /** + * V2 API definition for downloading an existing configset as a ZIP archive. + * + *

Equivalent to GET /api/configsets/{configSetName}/download + */ + @Path("/configsets/{configSetName}") + interface Download { + @GET + @Path("/download") + @Operation( + summary = "Download a configset as a ZIP archive.", + tags = {"configsets"}, + extensions = { + @Extension(properties = {@ExtensionProperty(name = RAW_OUTPUT_PROPERTY, value = "true")}) + }) + @Produces("application/zip") + Response downloadConfigSet( + @PathParam("configSetName") String configSetName, + @QueryParam("displayName") String displayName) + throws Exception; + } + + /** + * V2 API definition for reading a single file from an existing configset. + * + *

Equivalent to GET /api/configsets/{configSetName}/file?path=... + */ + @Path("/configsets/{configSetName}") + interface GetFile { + @GET + @Path("/file") + @Produces(MediaType.TEXT_PLAIN) + @Operation( + summary = "Get the contents of a file in a configset.", + tags = {"configsets"}) + ConfigSetFileContentsResponse getConfigSetFile( + @PathParam("configSetName") String configSetName, @QueryParam("path") String filePath) + throws Exception; + } + /** * V2 API definitions for uploading a configset, in whole or part. * diff --git a/solr/api/src/java/org/apache/solr/client/api/model/ConfigSetFileContentsResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/ConfigSetFileContentsResponse.java new file mode 100644 index 000000000000..3ef99a13bbe1 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/ConfigSetFileContentsResponse.java @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.client.api.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Response type for the "get configset file contents" API. */ +public class ConfigSetFileContentsResponse extends SolrJerseyResponse { + + /** The path of the file within the configset (as requested). */ + @JsonProperty("path") + public String path; + + /** The UTF-8 text content of the file. */ + @JsonProperty("content") + public String content; +} diff --git a/solr/core/src/java/org/apache/solr/handler/admin/ConfigSetsHandler.java b/solr/core/src/java/org/apache/solr/handler/admin/ConfigSetsHandler.java index edcdc0b1088b..6180a5398309 100644 --- a/solr/core/src/java/org/apache/solr/handler/admin/ConfigSetsHandler.java +++ b/solr/core/src/java/org/apache/solr/handler/admin/ConfigSetsHandler.java @@ -39,6 +39,8 @@ import org.apache.solr.handler.configsets.CloneConfigSet; import org.apache.solr.handler.configsets.ConfigSetAPIBase; import org.apache.solr.handler.configsets.DeleteConfigSet; +import org.apache.solr.handler.configsets.DownloadConfigSet; +import org.apache.solr.handler.configsets.GetConfigSetFile; import org.apache.solr.handler.configsets.ListConfigSets; import org.apache.solr.handler.configsets.UploadConfigSet; import org.apache.solr.request.SolrQueryRequest; @@ -187,7 +189,12 @@ public Collection getApis() { @Override public Collection> getJerseyResources() { return List.of( - ListConfigSets.class, CloneConfigSet.class, DeleteConfigSet.class, UploadConfigSet.class); + ListConfigSets.class, + CloneConfigSet.class, + DeleteConfigSet.class, + UploadConfigSet.class, + DownloadConfigSet.class, + GetConfigSetFile.class); } @Override diff --git a/solr/core/src/java/org/apache/solr/handler/configsets/CloneConfigSet.java b/solr/core/src/java/org/apache/solr/handler/configsets/CloneConfigSet.java index c98442d8b44a..b21c4de8056b 100644 --- a/solr/core/src/java/org/apache/solr/handler/configsets/CloneConfigSet.java +++ b/solr/core/src/java/org/apache/solr/handler/configsets/CloneConfigSet.java @@ -34,7 +34,11 @@ import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.response.SolrQueryResponse; -/** V2 API implementation for ConfigsetsApi.Clone */ +/** + * V2 API implementation for creating a new configset form an existing one. + * + *

This API (GET /v2/configsets) is analogous to the v1 /admin/configs?action=CREATE command. + */ public class CloneConfigSet extends ConfigSetAPIBase implements ConfigsetsApi.Clone { @Inject diff --git a/solr/core/src/java/org/apache/solr/handler/configsets/DeleteConfigSet.java b/solr/core/src/java/org/apache/solr/handler/configsets/DeleteConfigSet.java index 1a4b363a8339..3b26c5e2fc2b 100644 --- a/solr/core/src/java/org/apache/solr/handler/configsets/DeleteConfigSet.java +++ b/solr/core/src/java/org/apache/solr/handler/configsets/DeleteConfigSet.java @@ -32,7 +32,11 @@ import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.response.SolrQueryResponse; -/** V2 API implementation for ConfigsetsApi.Delete */ +/** + * V2 API implementation for deleting a configset + * + *

This API (GET /v2/configsets) is analogous to the v1 /admin/configs?action=DELETE command. + */ public class DeleteConfigSet extends ConfigSetAPIBase implements ConfigsetsApi.Delete { @Inject diff --git a/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java b/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java new file mode 100644 index 000000000000..97de78113443 --- /dev/null +++ b/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java @@ -0,0 +1,141 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.handler.configsets; + +import static org.apache.solr.security.PermissionNameProvider.Name.CONFIG_READ_PERM; + +import jakarta.inject.Inject; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.StreamingOutput; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.apache.commons.io.file.PathUtils; +import org.apache.solr.client.api.endpoint.ConfigsetsApi; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.util.StrUtils; +import org.apache.solr.core.ConfigSetService; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.jersey.PermissionName; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.response.SolrQueryResponse; + +/** V2 API implementation for downloading a configset as a zip file. */ +public class DownloadConfigSet extends ConfigSetAPIBase implements ConfigsetsApi.Download { + + @Inject + public DownloadConfigSet( + CoreContainer coreContainer, + SolrQueryRequest solrQueryRequest, + SolrQueryResponse solrQueryResponse) { + super(coreContainer, solrQueryRequest, solrQueryResponse); + } + + @Override + @PermissionName(CONFIG_READ_PERM) + public Response downloadConfigSet(String configSetName, String displayName) throws Exception { + if (StrUtils.isNullOrEmpty(configSetName)) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "No configset name provided to download"); + } + if (!configSetService.checkConfigExists(configSetName)) { + throw new SolrException( + SolrException.ErrorCode.NOT_FOUND, "ConfigSet " + configSetName + " not found!"); + } + final String resolvedDisplayName = + StrUtils.isNullOrEmpty(displayName) ? configSetName : displayName; + return buildZipResponse(configSetService, configSetName, resolvedDisplayName); + } + + /** + * Build a ZIP download {@link Response} for the given configset. + * + * @param configSetService the service to use for downloading the configset files + * @param configSetId the internal configset name to download (may differ from displayName, e.g. + * for schema-designer's mutable copies) + * @param displayName the user-visible name used to derive the download filename + */ + public static Response buildZipResponse( + ConfigSetService configSetService, String configSetId, String displayName) + throws IOException { + final byte[] zipBytes = zipConfigSet(configSetService, configSetId); + final String safeName = displayName.replaceAll("[^a-zA-Z0-9_\\-.]", "_"); + final String fileName = safeName + "_configset.zip"; + return Response.ok((StreamingOutput) outputStream -> outputStream.write(zipBytes)) + .type("application/zip") + .header("Content-Disposition", "attachment; filename=\"" + fileName + "\"") + .build(); + } + + /** + * Download the named configset from {@link ConfigSetService} and return its contents as a ZIP + * archive byte array. + */ + public static byte[] zipConfigSet(ConfigSetService configSetService, String configSetId) + throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Path tmpDirectory = Files.createTempDirectory("configset-download-"); + try { + configSetService.downloadConfig(configSetId, tmpDirectory); + try (ZipOutputStream zipOut = new ZipOutputStream(baos)) { + Files.walkFileTree( + tmpDirectory, + new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) + throws IOException { + if (Files.isHidden(dir)) { + return FileVisitResult.SKIP_SUBTREE; + } + String dirName = tmpDirectory.relativize(dir).toString(); + if (!dirName.isEmpty()) { + if (!dirName.endsWith("/")) { + dirName += "/"; + } + zipOut.putNextEntry(new ZipEntry(dirName)); + zipOut.closeEntry(); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + if (!Files.isHidden(file)) { + try (InputStream fis = Files.newInputStream(file)) { + ZipEntry zipEntry = new ZipEntry(tmpDirectory.relativize(file).toString()); + zipOut.putNextEntry(zipEntry); + fis.transferTo(zipOut); + } + } + return FileVisitResult.CONTINUE; + } + }); + } + } finally { + PathUtils.deleteDirectory(tmpDirectory); + } + return baos.toByteArray(); + } +} diff --git a/solr/core/src/java/org/apache/solr/handler/configsets/GetConfigSetFile.java b/solr/core/src/java/org/apache/solr/handler/configsets/GetConfigSetFile.java new file mode 100644 index 000000000000..4c69e60b0a2c --- /dev/null +++ b/solr/core/src/java/org/apache/solr/handler/configsets/GetConfigSetFile.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.handler.configsets; + +import static org.apache.solr.security.PermissionNameProvider.Name.CONFIG_READ_PERM; + +import jakarta.inject.Inject; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.apache.solr.client.api.endpoint.ConfigsetsApi; +import org.apache.solr.client.api.model.ConfigSetFileContentsResponse; +import org.apache.solr.common.SolrException; +import org.apache.solr.common.util.StrUtils; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.jersey.PermissionName; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.response.SolrQueryResponse; + +/** V2 API implementation for reading the contents of a single file from an existing configset. */ +public class GetConfigSetFile extends ConfigSetAPIBase implements ConfigsetsApi.GetFile { + + @Inject + public GetConfigSetFile( + CoreContainer coreContainer, + SolrQueryRequest solrQueryRequest, + SolrQueryResponse solrQueryResponse) { + super(coreContainer, solrQueryRequest, solrQueryResponse); + } + + @Override + @PermissionName(CONFIG_READ_PERM) + public ConfigSetFileContentsResponse getConfigSetFile(String configSetName, String filePath) + throws Exception { + if (StrUtils.isNullOrEmpty(configSetName)) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "No configset name provided"); + } + if (StrUtils.isNullOrEmpty(filePath)) { + throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "No file path provided"); + } + if (!configSetService.checkConfigExists(configSetName)) { + throw new SolrException( + SolrException.ErrorCode.NOT_FOUND, "ConfigSet '" + configSetName + "' not found"); + } + byte[] data = downloadFileFromConfig(configSetName, filePath); + final var response = instantiateJerseyResponse(ConfigSetFileContentsResponse.class); + response.path = filePath; + response.content = + data != null && data.length > 0 ? new String(data, StandardCharsets.UTF_8) : ""; + return response; + } + + private byte[] downloadFileFromConfig(String configSetName, String filePath) { + try { + final byte[] data = configSetService.downloadFileFromConfig(configSetName, filePath); + if (data == null) { + throw new SolrException( + SolrException.ErrorCode.NOT_FOUND, + "File '" + filePath + "' not found in configset '" + configSetName + "'"); + } + return data; + } catch (IOException e) { + throw new SolrException( + SolrException.ErrorCode.NOT_FOUND, + "File '" + filePath + "' not found in configset '" + configSetName + "'", + e); + } + } +} diff --git a/solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java b/solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java index 617d5c8af223..4f1645e7ce79 100644 --- a/solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java +++ b/solr/core/src/java/org/apache/solr/handler/configsets/UploadConfigSet.java @@ -39,6 +39,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * V2 API implementation for uploading a configset as a zip file. + * + *

This API (GET /v2/configsets) is analogous to the v1 /admin/configs?action=UPLOAD command. + */ public class UploadConfigSet extends ConfigSetAPIBase implements ConfigsetsApi.Upload { private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); diff --git a/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java b/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java new file mode 100644 index 000000000000..78984198271a --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java @@ -0,0 +1,154 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.handler.configsets; + +import static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import jakarta.ws.rs.core.Response; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.solr.SolrTestCase; +import org.apache.solr.common.SolrException; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.core.FileSystemConfigSetService; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +/** Unit tests for {@link DownloadConfigSet}. */ +public class DownloadConfigSetAPITest extends SolrTestCase { + + private CoreContainer mockCoreContainer; + private FileSystemConfigSetService configSetService; + private Path configSetBase; + + @BeforeClass + public static void ensureWorkingMockito() { + assumeWorkingMockito(); + } + + @Before + public void initConfigSetService() { + configSetBase = createTempDir("configsets"); + // Use an anonymous subclass to access the protected testing constructor + configSetService = new FileSystemConfigSetService(configSetBase) {}; + mockCoreContainer = mock(CoreContainer.class); + when(mockCoreContainer.getConfigSetService()).thenReturn(configSetService); + } + + /** Creates a configset directory with a single file so the API can find and zip it. */ + private void createConfigSet(String name, String fileName, String content) throws Exception { + Path dir = configSetBase.resolve(name); + Files.createDirectories(dir); + Files.writeString(dir.resolve(fileName), content, StandardCharsets.UTF_8); + } + + @Test + @SuppressWarnings("resource") // Response never created when exception is thrown + public void testMissingConfigSetNameThrowsBadRequest() { + final var api = new DownloadConfigSet(mockCoreContainer, null, null); + final var ex = assertThrows(SolrException.class, () -> api.downloadConfigSet(null, null)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + + final var ex2 = assertThrows(SolrException.class, () -> api.downloadConfigSet("", null)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex2.code()); + } + + @Test + @SuppressWarnings("resource") // Response never created when exception is thrown + public void testNonExistentConfigSetThrowsNotFound() { + // "missing" was never created in configSetBase, so checkConfigExists returns false + final var api = new DownloadConfigSet(mockCoreContainer, null, null); + final var ex = assertThrows(SolrException.class, () -> api.downloadConfigSet("missing", null)); + assertEquals(SolrException.ErrorCode.NOT_FOUND.code, ex.code()); + } + + @Test + public void testSuccessfulDownloadReturnsZipResponse() throws Exception { + createConfigSet("myconfig", "solrconfig.xml", ""); + + final var api = new DownloadConfigSet(mockCoreContainer, null, null); + try (final Response response = api.downloadConfigSet("myconfig", null)) { + assertNotNull(response); + assertEquals(200, response.getStatus()); + assertEquals("application/zip", response.getMediaType().toString()); + assertTrue( + String.valueOf(response.getHeaderString("Content-Disposition")) + .contains("myconfig_configset.zip")); + } + } + + @Test + public void testFilenameIsSanitized() throws Exception { + // A name with spaces gets sanitized: spaces → underscores in the Content-Disposition filename + final String nameWithSpaces = "my config name"; + createConfigSet(nameWithSpaces, "schema.xml", ""); + + final var api = new DownloadConfigSet(mockCoreContainer, null, null); + try (final Response response = api.downloadConfigSet(nameWithSpaces, null)) { + assertNotNull(response); + final String disposition = response.getHeaderString("Content-Disposition"); + assertTrue( + "filename must contain the sanitized (underscored) version of the name", + disposition.contains("my_config_name_configset.zip")); + assertFalse( + "filename must not retain spaces from the original configset name", + disposition.contains("my config name")); + } + } + + @Test + public void testDisplayNameOverridesFilename() throws Exception { + final String mutableId = "._designer_films"; + createConfigSet(mutableId, "schema.xml", ""); + + final var api = new DownloadConfigSet(mockCoreContainer, null, null); + try (final Response response = api.downloadConfigSet(mutableId, "films")) { + assertNotNull(response); + assertEquals(200, response.getStatus()); + final String disposition = response.getHeaderString("Content-Disposition"); + assertTrue( + "Content-Disposition should use the displayName 'films'", + disposition.contains("films_configset.zip")); + assertFalse( + "Content-Disposition must not expose the internal mutable-ID prefix", + disposition.contains("._designer_")); + } + } + + @Test + public void testBuildZipResponseUsesDisplayName() throws Exception { + createConfigSet("_designer_films", "schema.xml", ""); + + try (final Response response = + DownloadConfigSet.buildZipResponse(configSetService, "_designer_films", "films")) { + assertNotNull(response); + assertEquals(200, response.getStatus()); + final String disposition = response.getHeaderString("Content-Disposition"); + assertTrue( + "Content-Disposition should use the display name 'films'", + disposition.contains("films_configset.zip")); + assertFalse( + "Content-Disposition must not expose internal _designer_ prefix", + disposition.contains("_designer_")); + } + } +} diff --git a/solr/core/src/test/org/apache/solr/handler/configsets/GetConfigSetFileAPITest.java b/solr/core/src/test/org/apache/solr/handler/configsets/GetConfigSetFileAPITest.java new file mode 100644 index 000000000000..072924605ae7 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/configsets/GetConfigSetFileAPITest.java @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.handler.configsets; + +import static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.apache.solr.SolrTestCase; +import org.apache.solr.client.api.model.ConfigSetFileContentsResponse; +import org.apache.solr.common.SolrException; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.core.FileSystemConfigSetService; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +/** Unit tests for {@link GetConfigSetFile}. */ +public class GetConfigSetFileAPITest extends SolrTestCase { + + private CoreContainer mockCoreContainer; + private FileSystemConfigSetService configSetService; + private Path configSetBase; + + @BeforeClass + public static void ensureWorkingMockito() { + assumeWorkingMockito(); + } + + @Before + public void initConfigSetService() { + configSetBase = createTempDir("configsets"); + // Use an anonymous subclass to access the protected testing constructor + configSetService = new FileSystemConfigSetService(configSetBase) {}; + mockCoreContainer = mock(CoreContainer.class); + when(mockCoreContainer.getConfigSetService()).thenReturn(configSetService); + } + + /** Creates a configset directory with one file. */ + private void createConfigSetWithFile(String configSetName, String filePath, String content) + throws Exception { + Path dir = configSetBase.resolve(configSetName); + Files.createDirectories(dir); + Files.writeString(dir.resolve(filePath), content, StandardCharsets.UTF_8); + } + + @Test + public void testMissingConfigSetNameThrowsBadRequest() { + final var api = new GetConfigSetFile(mockCoreContainer, null, null); + final var ex = + assertThrows(SolrException.class, () -> api.getConfigSetFile(null, "schema.xml")); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + + final var ex2 = assertThrows(SolrException.class, () -> api.getConfigSetFile("", "schema.xml")); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex2.code()); + } + + @Test + public void testMissingFilePathThrowsBadRequest() { + final var api = new GetConfigSetFile(mockCoreContainer, null, null); + final var ex = assertThrows(SolrException.class, () -> api.getConfigSetFile("myconfig", null)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + + final var ex2 = assertThrows(SolrException.class, () -> api.getConfigSetFile("myconfig", "")); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex2.code()); + } + + @Test + public void testNonExistentConfigSetThrowsNotFound() { + // "missing" was never created in configSetBase, so checkConfigExists returns false + final var api = new GetConfigSetFile(mockCoreContainer, null, null); + final var ex = + assertThrows(SolrException.class, () -> api.getConfigSetFile("missing", "schema.xml")); + assertEquals(SolrException.ErrorCode.NOT_FOUND.code, ex.code()); + } + + @Test + public void testSuccessfulFileRead() throws Exception { + final String configSetName = "myconfig"; + final String filePath = "schema.xml"; + final String fileContent = ""; + createConfigSetWithFile(configSetName, filePath, fileContent); + + final var api = new GetConfigSetFile(mockCoreContainer, null, null); + final ConfigSetFileContentsResponse response = api.getConfigSetFile(configSetName, filePath); + + assertNotNull(response); + assertEquals(filePath, response.path); + assertEquals(fileContent, response.content); + } + + @Test + public void testFileNotFoundInConfigSetThrowsNotFound() throws Exception { + final String configSetName = "myconfig"; + // Create the configset directory but do NOT add the requested file + Files.createDirectories(configSetBase.resolve(configSetName)); + + final var api = new GetConfigSetFile(mockCoreContainer, null, null); + final var ex = + assertThrows(SolrException.class, () -> api.getConfigSetFile(configSetName, "missing.xml")); + assertEquals(SolrException.ErrorCode.NOT_FOUND.code, ex.code()); + } + + @Test + public void testEmptyFileReturnsEmptyContent() throws Exception { + final String configSetName = "myconfig"; + final String filePath = "empty.xml"; + createConfigSetWithFile(configSetName, filePath, ""); + + final var api = new GetConfigSetFile(mockCoreContainer, null, null); + final ConfigSetFileContentsResponse response = api.getConfigSetFile(configSetName, filePath); + + assertNotNull(response); + assertEquals(filePath, response.path); + assertEquals("", response.content); + } +} diff --git a/solr/core/src/test/org/apache/solr/handler/configsets/UploadConfigSetAPITest.java b/solr/core/src/test/org/apache/solr/handler/configsets/UploadConfigSetAPITest.java new file mode 100644 index 000000000000..92030c0db099 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/configsets/UploadConfigSetAPITest.java @@ -0,0 +1,427 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.solr.handler.configsets; + +import static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.apache.solr.SolrTestCase; +import org.apache.solr.common.SolrException; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.core.FileSystemConfigSetService; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +/** Unit tests for {@link UploadConfigSet}. */ +public class UploadConfigSetAPITest extends SolrTestCase { + + private CoreContainer mockCoreContainer; + private FileSystemConfigSetService configSetService; + private Path configSetBase; + + @BeforeClass + public static void ensureWorkingMockito() { + assumeWorkingMockito(); + } + + @Before + public void initConfigSetService() { + configSetBase = createTempDir("configsets"); + // Use an anonymous subclass to access the protected testing constructor + configSetService = new FileSystemConfigSetService(configSetBase) {}; + mockCoreContainer = mock(CoreContainer.class); + when(mockCoreContainer.getConfigSetService()).thenReturn(configSetService); + } + + /** Creates an in-memory ZIP file with the specified files. */ + @SuppressWarnings("try") // ZipOutputStream must be closed to finalize ZIP format + private InputStream createZipStream(String... filePathAndContent) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + for (int i = 0; i < filePathAndContent.length; i += 2) { + String filePath = filePathAndContent[i]; + String content = filePathAndContent[i + 1]; + zos.putNextEntry(new ZipEntry(filePath)); + zos.write(content.getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + } + return new ByteArrayInputStream(baos.toByteArray()); + } + + /** Creates an empty ZIP file. */ + @SuppressWarnings("try") // ZipOutputStream must be closed even with no entries + private InputStream createEmptyZipStream() throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + // No entries + } + return new ByteArrayInputStream(baos.toByteArray()); + } + + /** Creates a configset with files on disk for testing overwrites and cleanup. */ + private void createExistingConfigSet(String configSetName, String... filePathAndContent) + throws Exception { + Path configDir = configSetBase.resolve(configSetName); + Files.createDirectories(configDir); + for (int i = 0; i < filePathAndContent.length; i += 2) { + String filePath = filePathAndContent[i]; + String content = filePathAndContent[i + 1]; + Path fullPath = configDir.resolve(filePath); + Files.createDirectories(fullPath.getParent()); + Files.writeString(fullPath, content, StandardCharsets.UTF_8); + } + } + + @Test + public void testSuccessfulZipUpload() throws Exception { + final String configSetName = "newconfig"; + InputStream zipStream = createZipStream("solrconfig.xml", ""); + + final var api = new UploadConfigSet(mockCoreContainer, null, null); + final var response = api.uploadConfigSet(configSetName, true, false, zipStream); + + assertNotNull(response); + assertTrue( + "ConfigSet should exist after upload", configSetService.checkConfigExists(configSetName)); + + // Verify the file was uploaded + byte[] uploadedData = configSetService.downloadFileFromConfig(configSetName, "solrconfig.xml"); + assertEquals("", new String(uploadedData, StandardCharsets.UTF_8)); + } + + @Test + public void testSuccessfulZipUploadWithMultipleFiles() throws Exception { + final String configSetName = "multifile"; + InputStream zipStream = + createZipStream( + "solrconfig.xml", "", + "schema.xml", "", + "stopwords.txt", "a\nthe"); + + final var api = new UploadConfigSet(mockCoreContainer, null, null); + final var response = api.uploadConfigSet(configSetName, true, false, zipStream); + + assertNotNull(response); + assertTrue(configSetService.checkConfigExists(configSetName)); + + // Verify all files were uploaded + byte[] solrconfig = configSetService.downloadFileFromConfig(configSetName, "solrconfig.xml"); + assertEquals("", new String(solrconfig, StandardCharsets.UTF_8)); + + byte[] schema = configSetService.downloadFileFromConfig(configSetName, "schema.xml"); + assertEquals("", new String(schema, StandardCharsets.UTF_8)); + + byte[] stopwords = configSetService.downloadFileFromConfig(configSetName, "stopwords.txt"); + assertEquals("a\nthe", new String(stopwords, StandardCharsets.UTF_8)); + } + + @Test + public void testEmptyZipThrowsBadRequest() throws Exception { + try (InputStream emptyZip = createEmptyZipStream()) { + + final var api = new UploadConfigSet(mockCoreContainer, null, null); + final var ex = + assertThrows( + SolrException.class, () -> api.uploadConfigSet("newconfig", true, false, emptyZip)); + + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + assertTrue( + "Error message should mention empty zip", + ex.getMessage().contains("empty zipped data") || ex.getMessage().contains("non-zipped")); + } + } + + @Test + public void testNonZipDataThrowsBadRequest() { + // Send plain text instead of a ZIP + InputStream notAZip = + new ByteArrayInputStream("this is not a zip file".getBytes(StandardCharsets.UTF_8)); + + final var api = new UploadConfigSet(mockCoreContainer, null, null); + // This should fail either as bad ZIP or as empty ZIP + assertThrows(Exception.class, () -> api.uploadConfigSet("newconfig", true, false, notAZip)); + } + + @Test + public void testOverwriteExistingConfigSet() throws Exception { + final String configSetName = "existing"; + // Create existing configset with old content + createExistingConfigSet(configSetName, "solrconfig.xml", ""); + + // Upload new content with overwrite=true + InputStream zipStream = createZipStream("solrconfig.xml", ""); + final var api = new UploadConfigSet(mockCoreContainer, null, null); + final var response = api.uploadConfigSet(configSetName, true, false, zipStream); + + assertNotNull(response); + + // Verify the file was overwritten + byte[] uploadedData = configSetService.downloadFileFromConfig(configSetName, "solrconfig.xml"); + assertEquals("", new String(uploadedData, StandardCharsets.UTF_8)); + } + + @Test + public void testOverwriteFalseThrowsExceptionWhenExists() throws Exception { + final String configSetName = "existing"; + createExistingConfigSet(configSetName, "solrconfig.xml", ""); + + try (InputStream zipStream = createZipStream("solrconfig.xml", "")) { + final var api = new UploadConfigSet(mockCoreContainer, null, null); + + final var ex = + assertThrows( + SolrException.class, + () -> api.uploadConfigSet(configSetName, false, false, zipStream)); + + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + assertTrue( + "Error message should mention config already exists", + ex.getMessage().contains("already")); + } + } + + @Test + public void testCleanupRemovesUnusedFiles() throws Exception { + final String configSetName = "cleanuptest"; + // Create existing configset with multiple files + createExistingConfigSet( + configSetName, + "solrconfig.xml", + "", + "schema.xml", + "", + "old-file.txt", + "to be deleted"); + + // Upload new ZIP with only one file and cleanup=true + InputStream zipStream = createZipStream("solrconfig.xml", ""); + final var api = new UploadConfigSet(mockCoreContainer, null, null); + final var response = api.uploadConfigSet(configSetName, true, true, zipStream); + + assertNotNull(response); + + // Verify solrconfig.xml was updated + byte[] solrconfig = configSetService.downloadFileFromConfig(configSetName, "solrconfig.xml"); + assertEquals("", new String(solrconfig, StandardCharsets.UTF_8)); + + // Verify old files were deleted (should throw or return null) + try { + byte[] oldSchema = configSetService.downloadFileFromConfig(configSetName, "schema.xml"); + if (oldSchema != null) { + fail("schema.xml should have been deleted during cleanup"); + } + } catch (Exception e) { + // Expected - file should not exist + } + } + + @Test + public void testCleanupFalseKeepsExistingFiles() throws Exception { + final String configSetName = "nocleanup"; + // Create existing configset with multiple files + createExistingConfigSet( + configSetName, "solrconfig.xml", "", "schema.xml", ""); + + // Upload new ZIP with only one file and cleanup=false + InputStream zipStream = createZipStream("solrconfig.xml", ""); + final var api = new UploadConfigSet(mockCoreContainer, null, null); + final var response = api.uploadConfigSet(configSetName, true, false, zipStream); + + assertNotNull(response); + + // Verify solrconfig.xml was updated + byte[] solrconfig = configSetService.downloadFileFromConfig(configSetName, "solrconfig.xml"); + assertEquals("", new String(solrconfig, StandardCharsets.UTF_8)); + + // Verify schema.xml still exists + byte[] schema = configSetService.downloadFileFromConfig(configSetName, "schema.xml"); + assertEquals("", new String(schema, StandardCharsets.UTF_8)); + } + + @Test + public void testSingleFileUploadSuccess() throws Exception { + final String configSetName = "singlefile"; + final String filePath = "solrconfig.xml"; + final String content = ""; + InputStream fileStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + + final var api = new UploadConfigSet(mockCoreContainer, null, null); + final var response = api.uploadConfigSetFile(configSetName, filePath, true, false, fileStream); + + assertNotNull(response); + + // Verify the file was uploaded + byte[] uploadedData = configSetService.downloadFileFromConfig(configSetName, filePath); + assertEquals(content, new String(uploadedData, StandardCharsets.UTF_8)); + } + + @Test + public void testSingleFileWithLeadingSlashIsNormalized() throws Exception { + final String configSetName = "leadingslash"; + final String filePath = "/solrconfig.xml"; // Leading slash + final String content = ""; + InputStream fileStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + + final var api = new UploadConfigSet(mockCoreContainer, null, null); + final var response = api.uploadConfigSetFile(configSetName, filePath, true, false, fileStream); + + assertNotNull(response); + + // Verify the file was uploaded without leading slash + byte[] uploadedData = configSetService.downloadFileFromConfig(configSetName, "solrconfig.xml"); + assertEquals(content, new String(uploadedData, StandardCharsets.UTF_8)); + } + + @Test + public void testSingleFileWithEmptyPathThrowsBadRequest() { + final String configSetName = "emptypath"; + InputStream fileStream = new ByteArrayInputStream("".getBytes(StandardCharsets.UTF_8)); + + final var api = new UploadConfigSet(mockCoreContainer, null, null); + + // Test with empty string + final var ex = + assertThrows( + SolrException.class, + () -> api.uploadConfigSetFile(configSetName, "", true, false, fileStream)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + assertTrue("Error should mention invalid path", ex.getMessage().contains("not valid")); + } + + @Test + public void testSingleFileWithNullPathThrowsBadRequest() { + final String configSetName = "nullpath"; + InputStream fileStream = new ByteArrayInputStream("".getBytes(StandardCharsets.UTF_8)); + + final var api = new UploadConfigSet(mockCoreContainer, null, null); + + // Test with null - note: null becomes empty string after processing + final var ex = + assertThrows( + SolrException.class, + () -> api.uploadConfigSetFile(configSetName, null, true, false, fileStream)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + } + + @Test + public void testCleanupWithSingleFileThrowsBadRequest() { + final String configSetName = "nocleanupallowed"; + final String filePath = "solrconfig.xml"; + InputStream fileStream = new ByteArrayInputStream("".getBytes(StandardCharsets.UTF_8)); + + final var api = new UploadConfigSet(mockCoreContainer, null, null); + + final var ex = + assertThrows( + SolrException.class, + () -> api.uploadConfigSetFile(configSetName, filePath, true, true, fileStream)); + + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + assertTrue( + "Error should mention cleanup not allowed", ex.getMessage().contains("cleanup=true")); + } + + @Test + public void testDefaultParametersWhenNull() throws Exception { + final String configSetName = "defaults"; + InputStream zipStream = createZipStream("solrconfig.xml", ""); + + final var api = new UploadConfigSet(mockCoreContainer, null, null); + // Pass null for overwrite and cleanup - should use defaults (overwrite=true, cleanup=false) + final var response = api.uploadConfigSet(configSetName, null, null, zipStream); + + assertNotNull(response); + assertTrue(configSetService.checkConfigExists(configSetName)); + } + + @Test + public void testZipWithDirectoryEntries() throws Exception { + final String configSetName = "withdirs"; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(baos)) { + // Add directory entry + zos.putNextEntry(new ZipEntry("conf/")); + zos.closeEntry(); + + // Add file in directory + zos.putNextEntry(new ZipEntry("conf/solrconfig.xml")); + zos.write("".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + InputStream zipStream = new ByteArrayInputStream(baos.toByteArray()); + + final var api = new UploadConfigSet(mockCoreContainer, null, null); + final var response = api.uploadConfigSet(configSetName, true, false, zipStream); + + assertNotNull(response); + assertTrue(configSetService.checkConfigExists(configSetName)); + + // Directory entries should be skipped, but file should be uploaded + byte[] uploadedData = + configSetService.downloadFileFromConfig(configSetName, "conf/solrconfig.xml"); + assertEquals("", new String(uploadedData, StandardCharsets.UTF_8)); + } + + @Test + public void testOverwriteExistingFile() throws Exception { + final String configSetName = "overwritefile"; + final String filePath = "solrconfig.xml"; + + // Create existing file + createExistingConfigSet(configSetName, filePath, ""); + + // Upload new content with overwrite=true + InputStream fileStream = new ByteArrayInputStream("".getBytes(StandardCharsets.UTF_8)); + final var api = new UploadConfigSet(mockCoreContainer, null, null); + final var response = api.uploadConfigSetFile(configSetName, filePath, true, false, fileStream); + + assertNotNull(response); + + // Verify file was overwritten + byte[] uploadedData = configSetService.downloadFileFromConfig(configSetName, filePath); + assertEquals("", new String(uploadedData, StandardCharsets.UTF_8)); + } + + @Test + public void testSingleFileUploadWithNestedPath() throws Exception { + final String configSetName = "nested"; + final String filePath = "lang/stopwords_en.txt"; + final String content = "a\nthe\nis"; + InputStream fileStream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + + final var api = new UploadConfigSet(mockCoreContainer, null, null); + final var response = api.uploadConfigSetFile(configSetName, filePath, true, false, fileStream); + + assertNotNull(response); + + // Verify the file was uploaded with correct path + byte[] uploadedData = configSetService.downloadFileFromConfig(configSetName, filePath); + assertEquals(content, new String(uploadedData, StandardCharsets.UTF_8)); + } +} From 8dc9e800ac8f3b4283cc1ef58aaaab1275b72745 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 3 Apr 2026 19:16:19 -0400 Subject: [PATCH 2/3] add ref guide docs --- .../pages/configsets-api.adoc | 136 +++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/solr/solr-ref-guide/modules/configuration-guide/pages/configsets-api.adoc b/solr/solr-ref-guide/modules/configuration-guide/pages/configsets-api.adoc index 74f2c6751a53..ae796d0225b4 100644 --- a/solr/solr-ref-guide/modules/configuration-guide/pages/configsets-api.adoc +++ b/solr/solr-ref-guide/modules/configuration-guide/pages/configsets-api.adoc @@ -82,6 +82,68 @@ The output will look like: } ---- +[[configsets-download]] +== Download a Configset + +The `download` command downloads an entire configset as a zipped file. +This is useful for backing up configsets, sharing them between environments, or examining their contents. + +The `download` command takes the following parameters: + +`name`:: ++ +[%autowidth,frame=none] +|=== +s|Required |Default: none +|=== ++ +The name of the configset to download. + +`displayName`:: ++ +[%autowidth,frame=none] +|=== +|Optional |Default: same as `name` +|=== ++ +An optional user-friendly name used for the downloaded filename. +This is useful when the configset has an internal name (such as `._designer_myconfig` pattern generated when using the Schema Designer) but you want the downloaded file to have a cleaner name. + +The response will be a ZIP file containing all files from the configset. +The `Content-Disposition` header will suggest a filename based on the configset name (or `displayName` if provided). + +To download a configset named "myConfigSet": + +[tabs#downloadconfigset] +====== +V1 API:: ++ +==== +The v1 API does not currently support downloading configsets. +Use the v2 API instead. +==== + +V2 API:: ++ +==== +With the v2 API, use a `GET` request to `/configsets/__config_name__/download`: + +[source,bash] +---- +curl -X GET -o myConfigSet.zip "http://localhost:8983/api/configsets/myConfigSet/download" +---- + +To specify a custom display name for the downloaded file: + +[source,bash] +---- +curl -X GET -o myConfig.zip "http://localhost:8983/api/configsets/myConfigSet/download?displayName=myConfig" +---- + +This will download the configset as a ZIP file that can be extracted and used locally, or re-uploaded to another Solr instance. +==== +====== + [[configsets-upload]] == Upload a Configset @@ -226,6 +288,78 @@ This behavior can be disabled with the parameter `overwrite=false`, in which cas ==== ====== +[[configsets-get-file]] +== Get a Single File from a Configset + +This command retrieves the contents of a single file from an existing configset. +This is useful for inspecting individual configuration files without downloading the entire configset. + +The command takes the following parameters: + +`name`:: ++ +[%autowidth,frame=none] +|=== +s|Required |Default: none +|=== ++ +The name of the configset containing the file. + +`filePath`:: ++ +[%autowidth,frame=none] +|=== +s|Required |Default: none +|=== ++ +The path to the file within the configset (e.g., `solrconfig.xml` or `lang/stopwords_en.txt`). + +The response will be a JSON object containing: + +* `path`: The file path that was requested +* `content`: The content of the file as a UTF-8 string + +To retrieve the `solrconfig.xml` file from a configset named "myConfigSet": + +[tabs#getfileconfigset] +====== +V1 API:: ++ +==== +The v1 API does not currently support retrieving individual configset files. +Use the v2 API instead. +==== + +V2 API:: ++ +==== +With the v2 API, use a `GET` request to `/configsets/__config_name__/__file_path__`: + +[source,bash] +---- +curl -X GET "http://localhost:8983/api/configsets/myConfigSet/solrconfig.xml" +---- + +For nested files within the configset: + +[source,bash] +---- +curl -X GET "http://localhost:8983/api/configsets/myConfigSet/lang/stopwords_en.txt" +---- +==== +====== + +*Example Output* + +[source,json] +---- +{ + "path": "solrconfig.xml", + "content": "\n\n ...\n" +} +---- + + [[configsets-create]] == Create a Configset @@ -347,7 +481,7 @@ The name of the configset to delete is provided as a path parameter: [source,bash] ---- -curl -X DELETE http://localhost:8983/api/configsets/myConfigSet?omitHeader=true +curl -X DELETE http://localhost:8983/api/configsets/myConfigSet ---- ==== ====== From c7f7ad48e4d7fe9769b7fb10cf00de903bcaf44b Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 3 Apr 2026 19:58:54 -0400 Subject: [PATCH 3/3] Document change --- .../unreleased/SOLR-15701_complete_configsets_api.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog/unreleased/SOLR-15701_complete_configsets_api.yml diff --git a/changelog/unreleased/SOLR-15701_complete_configsets_api.yml b/changelog/unreleased/SOLR-15701_complete_configsets_api.yml new file mode 100644 index 000000000000..3d79059b1e25 --- /dev/null +++ b/changelog/unreleased/SOLR-15701_complete_configsets_api.yml @@ -0,0 +1,7 @@ +title: Add ConfigSets.Download and ConfigSets.GetFile to SolrJ +type: added +authors: + - name: Eric Pugh +links: + - name: SOLR-15701 + url: https://issues.apache.org/jira/browse/SOLR-15701