Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/unreleased/SOLR-15701_complete_configsets_api.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,23 @@
*/
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;
import jakarta.ws.rs.POST;
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 jakarta.ws.rs.core.StreamingOutput;
import java.io.IOException;
import java.io.InputStream;
import org.apache.solr.client.api.model.CloneConfigsetRequestBody;
Expand Down Expand Up @@ -71,30 +79,75 @@ SolrJerseyResponse deleteConfigSet(@PathParam("configSetName") String configSetN
throws Exception;
}

/** V2 API definition for downloading an existing configset as a ZIP archive. */
@Path("/configsets/{configSetName}")
interface Download {
@GET
@Path("/download")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[-1] /download isn't very REST-ful. The fact that we're fetching/downloading the resource is already kindof implied by the HTTP 'GET' verb/method.

Could we drop it and make this "just" GET /api/configsets/{configsetName}?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so we could, and I was thinking about it and then asked Claude for soem suggestions, and he kind of came back to "/download" is easy. Here is the write up:
RESTful Alternatives

Option 1: Keep current design ✅ (Recommended)

GET /api/configsets                          → List (JSON)
GET /api/configsets/{name}/download          → Download ZIP
GET /api/configsets/{name}/{filePath}        → Get single file

Pros:

  • Explicit and unambiguous
  • Self-documenting (/download tells you what it does)
  • No conflicts with file paths
  • Common pattern (GitHub uses /archive, GitLab uses /repository/archive, etc.)

Cons:

  • Not purely resource-oriented REST

Option 2: Use /files sub-resource

GET /api/configsets/{name}                   → Metadata (JSON) - NEW
GET /api/configsets/{name}/files             → Download all as ZIP
GET /api/configsets/{name}/files/{path}      → Get single file

Pros:

  • More RESTful hierarchy
  • Room for metadata endpoint

Cons:

  • Breaking change to existing API
  • More complex paths

Option 3: Content negotiation (Pure REST)

GET /api/configsets/{name}
  Accept: application/json  → Metadata
  Accept: application/zip   → ZIP download

Pros:

  • True REST content negotiation

Cons:

  • Solr doesn't typically use Accept headers this way
  • Harder to use with curl/browsers
  • Still conflicts with /{filePath} pattern

Recommendation

Keep the current design (/download) because:

  1. Unambiguous - no path conflicts
  2. Self-documenting - clear what you get back
  3. Pragmatic - works well without complex content negotiation
  4. Common pattern - many APIs use action-style endpoints for different representations
  5. Backward compatible - if we add metadata later, we can use GET /api/configsets/{name} for JSON

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we had another places that we did the zip download, then I could see making them all work the same, with whatever pattern we picked...

Copy link
Copy Markdown
Contributor

@gerlowskija gerlowskija Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about it and then asked Claude for soem suggestions, and he kind of came back to

I don't mean to be glib, but I don't really care what Claude thinks - I care what you think! Maybe by quoting Claude you're implying you agree? But I'd love to hear which option you think is better, and why!

If we had another places that we did the zip download, then I could see making them all work the same

Idk about ZIP downloads, that's admittedly a little more niche 🤷

What is clear to me is that we have a bunch of APIs that let you "download some thing" or "download some part of some thing", and none of those APIs take the /download approach suggested by your LLM.

  • Download a file from the filestore: GET /api/cluster/filestore/files/<path>
  • Download an index file from leader: GET /api/cores/coreName/replication/files/<path>
  • Fetch the data in a ZooKeeper node: GET /api/cluster/zookeeper/data/<path>
  • etc.

We've got an established pattern here: rely on the HTTP verb to imply "download", and offer a "files/" sub-path as a way to support individual file download. If we want to deviate from that there should be a pretty compelling reason.

And while I hear Claude's point that we may want to reserve /api/configsets/<name> for some future metadata at some point...that doesn't feel very compelling to me. "configset metadata" isn't a thing in Solr today. And as Claude pointed out there are options (e.g. Accept header) for adding that pretty seamlessly down the road.

To be transparent, if you personally feel strongly about /download I'm not gonna veto the PR or anything. But Claude's argument for /download doesn't make a ton of sense to me, and I think we should be intentional about these endpoints.

@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) throws Exception;
}

/**
* V2 API definitions for uploading a configset, in whole or part.
* V2 API definition for reading a single file from an existing configset.
*
* <p>Returns the raw bytes of the file, suitable for both text and binary files.
*
* <p>Equivalent to GET /api/configsets/{configSetName}/files/{filePath}
*/
@Path("/configsets/{configSetName}")
interface GetFile {
@GET
@Path("/files/{filePath:.+}")
@Produces(MediaType.APPLICATION_OCTET_STREAM)
@Operation(
summary = "Get the raw contents of a file in a configset.",
tags = {"configsets"},
extensions = {
@Extension(properties = {@ExtensionProperty(name = RAW_OUTPUT_PROPERTY, value = "true")})
})
StreamingOutput getConfigSetFile(
@PathParam("configSetName") String configSetName, @PathParam("filePath") String filePath)
throws Exception;
}

/**
* V2 API definition for uploading an entire configset as a ZIP archive.
*
* <p>Equivalent to the existing v1 API /admin/configs?action=UPLOAD
*/
@Path("/configsets/{configSetName}")
interface Upload {
@PUT
@Operation(summary = "Create a new configset.", tags = "configsets")
@Operation(summary = "Upload a configset as a ZIP archive.", tags = "configsets")
SolrJerseyResponse uploadConfigSet(
@PathParam("configSetName") String configSetName,
@QueryParam("overwrite") Boolean overwrite,
@QueryParam("cleanup") Boolean cleanup,
@RequestBody(required = true) InputStream requestBody)
throws IOException;
}

/**
* V2 API definition for putting a single file to an existing configset.
*
* <p>This endpoint allows updating individual configuration files without re-uploading the entire
* configset. The file path is specified as part of the URL path.
*/
@Path("/configsets/{configSetName}")
interface PutFile {
@PUT
@Path("{filePath:.+}")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[-0] We should probably add /files to the path here to align with what we now have for getConfigSetFile above.

@Operation(summary = "Create a new configset.", tags = "configsets")
SolrJerseyResponse uploadConfigSetFile(
@Operation(summary = "Upload a single file to a configset.", tags = "configsets")
SolrJerseyResponse putConfigSetFile(
@PathParam("configSetName") String configSetName,
@PathParam("filePath") String filePath,
@QueryParam("overwrite") Boolean overwrite,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Q] Can you please add some context on why these boolean flags are going away?

'Overwrite' in particular seems like a nice safety check that I imagine users would like to use.

@QueryParam("cleanup") Boolean cleanup,
@RequestBody(required = true) InputStream requestBody)
throws IOException;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
26 changes: 13 additions & 13 deletions solr/core/src/java/org/apache/solr/core/ConfigSetService.java
Original file line number Diff line number Diff line change
Expand Up @@ -381,8 +381,8 @@ protected NamedList<Object> loadConfigSetFlags(SolrResourceLoader loader) throws
public abstract void uploadConfig(String configName, Path dir) throws IOException;

/**
* Upload a file to config If file does not exist, it will be uploaded If overwriteOnExists is set
* to true then file will be overwritten
* Upload a file to config. If file does not exist, it will be uploaded. If overwriteOnExists is
* set to true then file will be overwritten.
*
* @param configName the name to give the config
* @param fileName the name of the file with '/' used as the file path separator
Expand All @@ -395,15 +395,15 @@ public abstract void uploadFileToConfig(
throws IOException;

/**
* Download all files from this config to the filesystem at dir
* Download all files from this config to the filesystem at dir.
*
* @param configName the config to download
* @param dir the {@link Path} to write files under
*/
public abstract void downloadConfig(String configName, Path dir) throws IOException;

/**
* Download a file from config If the file does not exist, it returns null
* Download a file from config. If the file does not exist, it returns null.
*
* @param configName the name of the config
* @param filePath the file to download with '/' as the separator
Expand All @@ -413,30 +413,30 @@ public abstract byte[] downloadFileFromConfig(String configName, String filePath
throws IOException;

/**
* Copy a config
* Copy a config.
*
* @param fromConfig the config to copy from
* @param toConfig the config to copy to
*/
public abstract void copyConfig(String fromConfig, String toConfig) throws IOException;

/**
* Check whether a config exists
* Check whether a config exists.
*
* @param configName the config to check if it exists
* @return whether the config exists or not
*/
public abstract boolean checkConfigExists(String configName) throws IOException;

/**
* Delete a config (recursively deletes its files if not empty)
* Delete a config (recursively deletes its files if not empty).
*
* @param configName the config to delete
*/
public abstract void deleteConfig(String configName) throws IOException;

/**
* Delete files in config
* Delete files in config.
*
* @param configName the name of the config
* @param filesToDelete a list of file paths to delete using '/' as file path separator
Expand All @@ -445,8 +445,8 @@ public abstract void deleteFilesFromConfig(String configName, List<String> files
throws IOException;

/**
* Set the config metadata If config does not exist, it will be created and set metadata on it
* Else metadata will be replaced with the provided metadata
* Set the config metadata. If config does not exist, it will be created and set metadata on it.
* Else metadata will be replaced with the provided metadata.
*
* @param configName the config name
* @param data the metadata to be set on config
Expand All @@ -455,23 +455,23 @@ protected abstract void setConfigMetadata(String configName, Map<String, Object>
throws IOException;

/**
* Get the config metadata (mutable, non-null)
* Get the config metadata (mutable, non-null).
*
* @param configName the config name
* @return the config metadata
*/
public abstract Map<String, Object> getConfigMetadata(String configName) throws IOException;

/**
* List the names of configs (non-null)
* List the names of configs (non-null).
*
* @return list of config names
*/
public abstract List<String> listConfigs() throws IOException;

/**
* Get the names of the files in config including dirs (mutable, non-null) sorted
* lexicographically e.g. solrconfig.xml, lang/, lang/stopwords_en.txt
* lexicographically e.g. solrconfig.xml, lang/, lang/stopwords_en.txt.
*
* @param configName the config name
* @return list of file name paths in the config with '/' uses as file path separators
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
import org.slf4j.LoggerFactory;

/**
* FileSystem ConfigSetService impl.
* File system based ConfigSetService implementation.
*
* <p>Loads a ConfigSet defined by the core's configSet property, looking for a directory named for
* the configSet property value underneath a base directory. If no configSet property is set, loads
Expand Down Expand Up @@ -182,6 +182,11 @@ public void uploadFileToConfig(
}

if (overwriteOnExists || !Files.exists(configsetFilePath)) {
// Create parent directories if they don't exist (similar to ZK's makePath)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Q] Why is this code here?

This feels kindof suspect at a glance, from a security perspective. The risk is a bit lower in ZK, but since we're on the filesystem here we probably shouldn't blindly be creating directories based on a user-specified parameter. If we're 100% certain that there's no path-escape or any other funny-business possible here, maybe this is fine. But I'd think this through really carefully if you haven't already.

Path parent = configsetFilePath.getParent();
if (parent != null && !Files.exists(parent)) {
Files.createDirectories(parent);
}
Files.write(configsetFilePath, data);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@

import static org.apache.solr.common.params.CommonParams.NAME;

import java.lang.invoke.MethodHandles;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
Expand All @@ -39,21 +38,20 @@
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;
import org.apache.solr.response.SolrQueryResponse;
import org.apache.solr.security.AuthorizationContext;
import org.apache.solr.security.PermissionNameProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** A {@link org.apache.solr.request.SolrRequestHandler} for ConfigSets API requests. */
public class ConfigSetsHandler extends RequestHandlerBase implements PermissionNameProvider {
// TODO refactor into o.a.s.handler.configsets package to live alongside actual API logic
public static final String DEFAULT_CONFIGSET_NAME = "_default";
public static final String AUTOCREATED_CONFIGSET_SUFFIX = ".AUTOCREATED";
private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
protected final CoreContainer coreContainer;
public static long CONFIG_SET_TIMEOUT = 300 * 1000;

Expand Down Expand Up @@ -106,10 +104,15 @@ public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throw
uploadResponse =
uploadApi.uploadConfigSet(configSetName, overwrite, cleanup, configSetData);
} else { // Uploading a single file
// Single file uploads do not support cleanup or overwrite parameters
if (cleanup) {
throw new SolrException(
ErrorCode.BAD_REQUEST,
"ConfigSet uploads do not allow cleanup=true when filePath is used.");
}
// Note: overwrite parameter is ignored for single file uploads (always overwrites)
final var filePath = req.getParams().get(ConfigSetParams.FILE_PATH);
uploadResponse =
uploadApi.uploadConfigSetFile(
configSetName, filePath, overwrite, cleanup, configSetData);
uploadResponse = uploadApi.putConfigSetFile(configSetName, filePath, configSetData);
}
V2ApiUtils.squashIntoSolrResponseWithoutHeader(rsp, uploadResponse);
break;
Expand All @@ -119,7 +122,7 @@ public void handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp) throw
break;
case CREATE:
final String newConfigSetName = req.getParams().get(NAME);
if (newConfigSetName == null || newConfigSetName.length() == 0) {
if (newConfigSetName == null || newConfigSetName.isEmpty()) {
throw new SolrException(ErrorCode.BAD_REQUEST, "ConfigSet name not specified");
}

Expand Down Expand Up @@ -187,7 +190,12 @@ public Collection<Api> getApis() {
@Override
public Collection<Class<? extends JerseyResource>> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>This API (GET /v2/configsets) is analogous to the v1 /admin/configs?action=CREATE command.
Comment thread
gerlowskija marked this conversation as resolved.
*/
public class CloneConfigSet extends ConfigSetAPIBase implements ConfigsetsApi.Clone {

@Inject
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
* Parent class for all APIs that manipulate configsets
*
* <p>Contains utilities for tasks common in configset manipulation, including running configset
* "commands" and checking configset "trusted-ness".
* "commands".
*/
public class ConfigSetAPIBase extends JerseyResource {

Expand Down Expand Up @@ -112,20 +112,6 @@ public static InputStream ensureNonEmptyInputStream(SolrQueryRequest req) throws
return contentStreamsIterator.next().getStream();
}

protected void createBaseNode(
ConfigSetService configSetService,
boolean overwritesExisting,
boolean requestIsTrusted,
String configName)
throws IOException {
if (overwritesExisting) {
if (!requestIsTrusted) {
throw new SolrException(
SolrException.ErrorCode.BAD_REQUEST, "Trying to make an untrusted ConfigSet update");
}
}
}

private void sendToOverseer(
SolrQueryResponse rsp, ConfigSetParams.ConfigSetAction action, Map<String, Object> result)
throws KeeperException, InterruptedException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
* <p>This API (GET /v2/configsets) is analogous to the v1 /admin/configs?action=DELETE command.
*/
public class DeleteConfigSet extends ConfigSetAPIBase implements ConfigsetsApi.Delete {

@Inject
Expand Down
Loading
Loading