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
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 0ceb5a830be4..c90a3636563b 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
@@ -35,7 +35,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 fee660a87f58..34c2bcbee54e 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
@@ -40,6 +40,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));
+ }
+}
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
----
====
======