From 88121297871b000f5daad9d4415260484863c490 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 14:50:00 +0000 Subject: [PATCH 01/69] Initial plan From 42395bc1ba7e5f5683503cf792f98a645cc36f82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:26:45 +0000 Subject: [PATCH 02/69] Migrate SchemaDesignerAPI to JAX-RS V2 annotations Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../api/endpoint/SchemaDesignerApi.java | 182 ++++++ .../org/apache/solr/core/CoreContainer.java | 2 +- .../handler/designer/SchemaDesignerAPI.java | 347 ++++++----- .../designer/TestSchemaDesignerAPI.java | 574 ++++++------------ 4 files changed, 551 insertions(+), 554 deletions(-) create mode 100644 solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java new file mode 100644 index 000000000000..295a77f2d70a --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -0,0 +1,182 @@ +/* + * 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.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 jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.StreamingOutput; +import java.util.List; +import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; +import org.apache.solr.client.api.model.SolrJerseyResponse; + +/** V2 API definitions for the Solr Schema Designer. */ +@Path("/schema-designer") +public interface SchemaDesignerApi { + + @GET + @Path("/info") + @Operation( + summary = "Get info about a configSet being designed.", + tags = {"schema-designer"}) + FlexibleSolrJerseyResponse getInfo(@QueryParam("configSet") String configSet) throws Exception; + + @POST + @Path("/prep") + @Operation( + summary = "Prepare a mutable configSet copy for schema design.", + tags = {"schema-designer"}) + FlexibleSolrJerseyResponse prepNewSchema( + @QueryParam("configSet") String configSet, @QueryParam("copyFrom") String copyFrom) + throws Exception; + + @PUT + @Path("/cleanup") + @Operation( + summary = "Clean up temporary resources for a schema being designed.", + tags = {"schema-designer"}) + SolrJerseyResponse cleanupTempSchema(@QueryParam("configSet") String configSet) throws Exception; + + @GET + @Path("/file") + @Operation( + summary = "Get the contents of a file in a configSet being designed.", + tags = {"schema-designer"}) + FlexibleSolrJerseyResponse getFileContents( + @QueryParam("configSet") String configSet, @QueryParam("file") String file) throws Exception; + + @POST + @Path("/file") + @Operation( + summary = "Update the contents of a file in a configSet being designed.", + tags = {"schema-designer"}) + FlexibleSolrJerseyResponse updateFileContents( + @QueryParam("configSet") String configSet, @QueryParam("file") String file) throws Exception; + + @GET + @Path("/sample") + @Operation( + summary = "Get a sample value and analysis for a field.", + tags = {"schema-designer"}) + FlexibleSolrJerseyResponse getSampleValue( + @QueryParam("configSet") String configSet, + @QueryParam("field") String fieldName, + @QueryParam("uniqueKeyField") String idField, + @QueryParam("docId") String docId) + throws Exception; + + @GET + @Path("/collectionsForConfig") + @Operation( + summary = "List collections that use a given configSet.", + tags = {"schema-designer"}) + FlexibleSolrJerseyResponse listCollectionsForConfig(@QueryParam("configSet") String configSet) + throws Exception; + + @GET + @Path("/configs") + @Operation( + summary = "List all configSets available for schema design.", + tags = {"schema-designer"}) + FlexibleSolrJerseyResponse listConfigs() throws Exception; + + @GET + @Path("/download") + @Operation( + summary = "Download a configSet as a ZIP archive.", + tags = {"schema-designer"}, + extensions = { + @Extension(properties = {@ExtensionProperty(name = RAW_OUTPUT_PROPERTY, value = "true")}) + }) + @Produces("application/zip") + StreamingOutput downloadConfig(@QueryParam("configSet") String configSet) throws Exception; + + @POST + @Path("/add") + @Operation( + summary = "Add a new field, field type, or dynamic field to the schema being designed.", + tags = {"schema-designer"}) + FlexibleSolrJerseyResponse addSchemaObject( + @QueryParam("configSet") String configSet, @QueryParam("schemaVersion") Integer schemaVersion) + throws Exception; + + @PUT + @Path("/update") + @Operation( + summary = "Update an existing field or field type in the schema being designed.", + tags = {"schema-designer"}) + FlexibleSolrJerseyResponse updateSchemaObject( + @QueryParam("configSet") String configSet, @QueryParam("schemaVersion") Integer schemaVersion) + throws Exception; + + @PUT + @Path("/publish") + @Operation( + summary = "Publish the designed schema to a live configSet.", + tags = {"schema-designer"}) + FlexibleSolrJerseyResponse publish( + @QueryParam("configSet") String configSet, + @QueryParam("schemaVersion") Integer schemaVersion, + @QueryParam("newCollection") String newCollection, + @QueryParam("reloadCollections") @DefaultValue("false") Boolean reloadCollections, + @QueryParam("numShards") @DefaultValue("1") Integer numShards, + @QueryParam("replicationFactor") @DefaultValue("1") Integer replicationFactor, + @QueryParam("indexToCollection") @DefaultValue("false") Boolean indexToCollection, + @QueryParam("cleanupTemp") @DefaultValue("true") Boolean cleanupTempParam, + @QueryParam("disableDesigner") @DefaultValue("false") Boolean disableDesigner) + throws Exception; + + @POST + @Path("/analyze") + @Operation( + summary = "Analyze sample documents and suggest a schema.", + tags = {"schema-designer"}) + FlexibleSolrJerseyResponse analyze( + @QueryParam("configSet") String configSet, + @QueryParam("schemaVersion") Integer schemaVersion, + @QueryParam("copyFrom") String copyFrom, + @QueryParam("uniqueKeyField") String uniqueKeyField, + @QueryParam("languages") List languages, + @QueryParam("enableDynamicFields") Boolean enableDynamicFields, + @QueryParam("enableFieldGuessing") Boolean enableFieldGuessing, + @QueryParam("enableNestedDocs") Boolean enableNestedDocs) + throws Exception; + + @GET + @Path("/query") + @Operation( + summary = "Query the temporary collection used during schema design.", + tags = {"schema-designer"}) + FlexibleSolrJerseyResponse query(@QueryParam("configSet") String configSet) throws Exception; + + @GET + @Path("/diff") + @Operation( + summary = "Get the diff between the designed schema and the published schema.", + tags = {"schema-designer"}) + FlexibleSolrJerseyResponse getSchemaDiff(@QueryParam("configSet") String configSet) + throws Exception; +} diff --git a/solr/core/src/java/org/apache/solr/core/CoreContainer.java b/solr/core/src/java/org/apache/solr/core/CoreContainer.java index f6bf1dfb36ab..f57e5029d200 100644 --- a/solr/core/src/java/org/apache/solr/core/CoreContainer.java +++ b/solr/core/src/java/org/apache/solr/core/CoreContainer.java @@ -869,7 +869,7 @@ private void loadInternal() { registerV2ApiIfEnabled(clusterAPI.commands); if (isZooKeeperAware()) { - registerV2ApiIfEnabled(new SchemaDesignerAPI(this)); + registerV2ApiIfEnabled(SchemaDesignerAPI.class); } // else Schema Designer not available in standalone (non-cloud) mode /* diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java index 426ec449cb45..7a12f4d1f9ad 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java @@ -17,14 +17,13 @@ package org.apache.solr.handler.designer; -import static org.apache.solr.client.solrj.SolrRequest.METHOD.GET; -import static org.apache.solr.client.solrj.SolrRequest.METHOD.POST; -import static org.apache.solr.client.solrj.SolrRequest.METHOD.PUT; import static org.apache.solr.common.params.CommonParams.JSON_MIME; import static org.apache.solr.handler.admin.ConfigSetsHandler.DEFAULT_CONFIGSET_NAME; import static org.apache.solr.security.PermissionNameProvider.Name.CONFIG_EDIT_PERM; import static org.apache.solr.security.PermissionNameProvider.Name.CONFIG_READ_PERM; +import jakarta.inject.Inject; +import jakarta.ws.rs.core.StreamingOutput; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -49,7 +48,10 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; -import org.apache.solr.api.EndPoint; +import org.apache.solr.api.JerseyResource; +import org.apache.solr.client.api.endpoint.SchemaDesignerApi; +import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; +import org.apache.solr.client.api.model.SolrJerseyResponse; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.impl.CloudSolrClient; import org.apache.solr.client.solrj.request.CollectionAdminRequest; @@ -66,16 +68,14 @@ import org.apache.solr.common.cloud.ZkMaintenanceUtils; import org.apache.solr.common.cloud.ZkStateReader; import org.apache.solr.common.util.ContentStream; -import org.apache.solr.common.util.ContentStreamBase; import org.apache.solr.common.util.NamedList; import org.apache.solr.common.util.SimpleOrderedMap; import org.apache.solr.common.util.StrUtils; import org.apache.solr.core.CoreContainer; import org.apache.solr.core.SolrConfig; import org.apache.solr.core.SolrResourceLoader; +import org.apache.solr.jersey.PermissionName; import org.apache.solr.request.SolrQueryRequest; -import org.apache.solr.response.RawResponseWriter; -import org.apache.solr.response.SolrQueryResponse; import org.apache.solr.schema.ManagedIndexSchema; import org.apache.solr.schema.SchemaField; import org.apache.solr.util.RTimer; @@ -86,7 +86,8 @@ import org.slf4j.LoggerFactory; /** All V2 APIs have a prefix of /api/schema-designer/ */ -public class SchemaDesignerAPI implements SchemaDesignerConstants { +public class SchemaDesignerAPI extends JerseyResource + implements SchemaDesignerApi, SchemaDesignerConstants { private static final Set excludeConfigSetNames = Set.of(DEFAULT_CONFIGSET_NAME); @@ -98,21 +99,26 @@ public class SchemaDesignerAPI implements SchemaDesignerConstants { private final SchemaDesignerSettingsDAO settingsDAO; private final SchemaDesignerConfigSetHelper configSetHelper; private final Map indexedVersion = new ConcurrentHashMap<>(); + private final SolrQueryRequest solrQueryRequest; - public SchemaDesignerAPI(CoreContainer coreContainer) { + @Inject + public SchemaDesignerAPI(CoreContainer coreContainer, SolrQueryRequest solrQueryRequest) { this( coreContainer, SchemaDesignerAPI.newSchemaSuggester(), - SchemaDesignerAPI.newSampleDocumentsLoader()); + SchemaDesignerAPI.newSampleDocumentsLoader(), + solrQueryRequest); } SchemaDesignerAPI( CoreContainer coreContainer, SchemaSuggester schemaSuggester, - SampleDocumentsLoader sampleDocLoader) { + SampleDocumentsLoader sampleDocLoader, + SolrQueryRequest solrQueryRequest) { this.coreContainer = coreContainer; this.schemaSuggester = schemaSuggester; this.sampleDocLoader = sampleDocLoader; + this.solrQueryRequest = solrQueryRequest; this.configSetHelper = new SchemaDesignerConfigSetHelper(this.coreContainer, this.schemaSuggester); this.settingsDAO = new SchemaDesignerSettingsDAO(coreContainer, configSetHelper); @@ -146,9 +152,10 @@ static String getMutableId(final String configSet) { return DESIGNER_PREFIX + configSet; } - @EndPoint(method = GET, path = "/schema-designer/info", permission = CONFIG_READ_PERM) - public void getInfo(SolrQueryRequest req, SolrQueryResponse rsp) throws IOException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); + @Override + @PermissionName(CONFIG_READ_PERM) + public FlexibleSolrJerseyResponse getInfo(String configSet) throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); Map responseMap = new HashMap<>(); responseMap.put(CONFIG_SET_PARAM, configSet); @@ -178,16 +185,19 @@ public void getInfo(SolrQueryRequest req, SolrQueryResponse rsp) throws IOExcept log.warn("Failed to load sample docs from blob store for {}", configSet, exc); } - rsp.getValues().addAll(responseMap); + return buildFlexibleResponse(responseMap); } - @EndPoint(method = POST, path = "/schema-designer/prep", permission = CONFIG_EDIT_PERM) - public void prepNewSchema(SolrQueryRequest req, SolrQueryResponse rsp) - throws IOException, SolrServerException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); + @Override + @PermissionName(CONFIG_EDIT_PERM) + public FlexibleSolrJerseyResponse prepNewSchema(String configSet, String copyFrom) + throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); validateNewConfigSetName(configSet); - final String copyFrom = req.getParams().get(COPY_FROM_PARAM, DEFAULT_CONFIGSET_NAME); + if (copyFrom == null) { + copyFrom = DEFAULT_CONFIGSET_NAME; + } SchemaDesignerSettings settings = getMutableSchemaForConfigSet(configSet, -1, copyFrom); ManagedIndexSchema schema = settings.getSchema(); @@ -201,19 +211,23 @@ public void prepNewSchema(SolrQueryRequest req, SolrQueryResponse rsp) settingsDAO.persistIfChanged(mutableId, settings); - rsp.getValues().addAll(buildResponse(configSet, schema, settings, null)); + return buildFlexibleResponse(buildResponse(configSet, schema, settings, null)); } - @EndPoint(method = PUT, path = "/schema-designer/cleanup", permission = CONFIG_EDIT_PERM) - public void cleanupTemp(SolrQueryRequest req, SolrQueryResponse rsp) - throws IOException, SolrServerException { - cleanupTemp(getRequiredParam(CONFIG_SET_PARAM, req)); + @Override + @PermissionName(CONFIG_EDIT_PERM) + public SolrJerseyResponse cleanupTempSchema(String configSet) throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); + doCleanupTemp(configSet); + return instantiateJerseyResponse(SolrJerseyResponse.class); } - @EndPoint(method = GET, path = "/schema-designer/file", permission = CONFIG_READ_PERM) - public void getFileContents(SolrQueryRequest req, SolrQueryResponse rsp) throws IOException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); - final String file = getRequiredParam("file", req); + @Override + @PermissionName(CONFIG_READ_PERM) + public FlexibleSolrJerseyResponse getFileContents(String configSet, String file) + throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); + requireNotEmpty("file", file); String filePath = getConfigSetZkPath(getMutableId(configSet), file); byte[] data; try { @@ -223,14 +237,15 @@ public void getFileContents(SolrQueryRequest req, SolrQueryResponse rsp) throws } String stringData = data != null && data.length > 0 ? new String(data, StandardCharsets.UTF_8) : ""; - rsp.getValues().addAll(Collections.singletonMap(file, stringData)); + return buildFlexibleResponse(Collections.singletonMap(file, stringData)); } - @EndPoint(method = POST, path = "/schema-designer/file", permission = CONFIG_EDIT_PERM) - public void updateFileContents(SolrQueryRequest req, SolrQueryResponse rsp) - throws IOException, SolrServerException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); - final String file = getRequiredParam("file", req); + @Override + @PermissionName(CONFIG_EDIT_PERM) + public FlexibleSolrJerseyResponse updateFileContents(String configSet, String file) + throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); + requireNotEmpty("file", file); String mutableId = getMutableId(configSet); String zkPath = getConfigSetZkPath(mutableId, file); @@ -241,7 +256,7 @@ public void updateFileContents(SolrQueryRequest req, SolrQueryResponse rsp) } byte[] data; - try (InputStream in = extractSingleContentStream(req, true).getStream()) { + try (InputStream in = extractSingleContentStream(true).getStream()) { data = in.readAllBytes(); } Exception updateFileError = null; @@ -265,8 +280,7 @@ public void updateFileContents(SolrQueryRequest req, SolrQueryResponse rsp) Map response = new HashMap<>(); response.put("updateFileError", causedBy.getMessage()); response.put(file, new String(data, StandardCharsets.UTF_8)); - rsp.getValues().addAll(response); - return; + return buildFlexibleResponse(response); } // apply the update and reload the temp collection / re-index sample docs @@ -309,15 +323,16 @@ public void updateFileContents(SolrQueryRequest req, SolrQueryResponse rsp) response, "Failed to re-index sample documents after update to the " + file + " file"); - rsp.getValues().addAll(response); + return buildFlexibleResponse(response); } - @EndPoint(method = GET, path = "/schema-designer/sample", permission = CONFIG_READ_PERM) - public void getSampleValue(SolrQueryRequest req, SolrQueryResponse rsp) throws IOException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); - final String fieldName = getRequiredParam(FIELD_PARAM, req); - final String idField = getRequiredParam(UNIQUE_KEY_FIELD_PARAM, req); - String docId = req.getParams().get(DOC_ID_PARAM); + @Override + @PermissionName(CONFIG_READ_PERM) + public FlexibleSolrJerseyResponse getSampleValue( + String configSet, String fieldName, String idField, String docId) throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); + requireNotEmpty(FIELD_PARAM, fieldName); + requireNotEmpty(UNIQUE_KEY_FIELD_PARAM, idField); final List docs = configSetHelper.retrieveSampleDocs(configSet); String textValue = null; @@ -348,27 +363,27 @@ public void getSampleValue(SolrQueryRequest req, SolrQueryResponse rsp) throws I if (textValue != null) { var analysis = configSetHelper.analyzeField(configSet, fieldName, textValue); - rsp.getValues().addAll(Map.of(idField, docId, fieldName, textValue, "analysis", analysis)); + return buildFlexibleResponse( + Map.of(idField, docId, fieldName, textValue, "analysis", analysis)); } + return instantiateJerseyResponse(FlexibleSolrJerseyResponse.class); } - @EndPoint( - method = GET, - path = "/schema-designer/collectionsForConfig", - permission = CONFIG_READ_PERM) - public void listCollectionsForConfig(SolrQueryRequest req, SolrQueryResponse rsp) { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); - rsp.getValues() - .addAll( - Collections.singletonMap( - "collections", configSetHelper.listCollectionsForConfig(configSet))); + @Override + @PermissionName(CONFIG_READ_PERM) + public FlexibleSolrJerseyResponse listCollectionsForConfig(String configSet) throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); + return buildFlexibleResponse( + Collections.singletonMap( + "collections", configSetHelper.listCollectionsForConfig(configSet))); } // CONFIG_EDIT_PERM is required here since this endpoint is used by the UI to determine if the // user has access to the Schema Designer UI - @EndPoint(method = GET, path = "/schema-designer/configs", permission = CONFIG_EDIT_PERM) - public void listConfigs(SolrQueryRequest req, SolrQueryResponse rsp) throws IOException { - rsp.getValues().addAll(Collections.singletonMap("configSets", listEnabledConfigs())); + @Override + @PermissionName(CONFIG_EDIT_PERM) + public FlexibleSolrJerseyResponse listConfigs() throws Exception { + return buildFlexibleResponse(Collections.singletonMap("configSets", listEnabledConfigs())); } protected Map listEnabledConfigs() throws IOException { @@ -387,9 +402,10 @@ protected Map listEnabledConfigs() throws IOException { return configs; } - @EndPoint(method = GET, path = "/schema-designer/download/*", permission = CONFIG_READ_PERM) - public void downloadConfig(SolrQueryRequest req, SolrQueryResponse rsp) throws IOException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); + @Override + @PermissionName(CONFIG_READ_PERM) + public StreamingOutput downloadConfig(String configSet) throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); String mutableId = getMutableId(configSet); // find the configSet to download @@ -408,21 +424,19 @@ public void downloadConfig(SolrQueryRequest req, SolrQueryResponse rsp) throws I throw new IOException("Error reading config from ZK", SolrZkClient.checkInterrupted(e)); } - ContentStreamBase content = - new ContentStreamBase.ByteArrayStream( - configSetHelper.downloadAndZipConfigSet(configId), - configSet + ".zip", - "application/zip"); - rsp.add(RawResponseWriter.CONTENT, content); + final byte[] zipBytes = configSetHelper.downloadAndZipConfigSet(configId); + return outputStream -> outputStream.write(zipBytes); } - @EndPoint(method = POST, path = "/schema-designer/add", permission = CONFIG_EDIT_PERM) - public void addSchemaObject(SolrQueryRequest req, SolrQueryResponse rsp) - throws IOException, SolrServerException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); - final String mutableId = checkMutable(configSet, req); + @Override + @PermissionName(CONFIG_EDIT_PERM) + public FlexibleSolrJerseyResponse addSchemaObject(String configSet, Integer schemaVersion) + throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); + requireSchemaVersion(schemaVersion); + final String mutableId = checkMutable(configSet, schemaVersion); - Map addJson = readJsonFromRequest(req); + Map addJson = readJsonFromRequest(); log.info("Adding new schema object from JSON: {}", addJson); String objectName = configSetHelper.addSchemaObject(configSet, addJson); @@ -432,17 +446,19 @@ public void addSchemaObject(SolrQueryRequest req, SolrQueryResponse rsp) Map response = buildResponse(configSet, schema, null, configSetHelper.retrieveSampleDocs(configSet)); response.put(action, objectName); - rsp.getValues().addAll(response); + return buildFlexibleResponse(response); } - @EndPoint(method = PUT, path = "/schema-designer/update", permission = CONFIG_EDIT_PERM) - public void updateSchemaObject(SolrQueryRequest req, SolrQueryResponse rsp) - throws IOException, SolrServerException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); - final String mutableId = checkMutable(configSet, req); + @Override + @PermissionName(CONFIG_EDIT_PERM) + public FlexibleSolrJerseyResponse updateSchemaObject(String configSet, Integer schemaVersion) + throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); + requireSchemaVersion(schemaVersion); + final String mutableId = checkMutable(configSet, schemaVersion); // Updated field definition is in the request body as JSON - Map updateField = readJsonFromRequest(req); + Map updateField = readJsonFromRequest(); String name = (String) updateField.get("name"); if (StrUtils.isNullOrEmpty(name)) { throw new SolrException( @@ -504,14 +520,25 @@ public void updateSchemaObject(SolrQueryRequest req, SolrQueryResponse rsp) addErrorToResponse(mutableId, solrExc, errorsDuringIndexing, response, updateError); response.put("rebuild", needsRebuild); - rsp.getValues().addAll(response); + return buildFlexibleResponse(response); } - @EndPoint(method = PUT, path = "/schema-designer/publish", permission = CONFIG_EDIT_PERM) - public void publish(SolrQueryRequest req, SolrQueryResponse rsp) - throws IOException, SolrServerException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); - final String mutableId = checkMutable(configSet, req); + @Override + @PermissionName(CONFIG_EDIT_PERM) + public FlexibleSolrJerseyResponse publish( + String configSet, + Integer schemaVersion, + String newCollection, + Boolean reloadCollections, + Integer numShards, + Integer replicationFactor, + Boolean indexToCollection, + Boolean cleanupTempParam, + Boolean disableDesigner) + throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); + requireSchemaVersion(schemaVersion); + final String mutableId = checkMutable(configSet, schemaVersion); // verify the configSet we're going to apply changes to hasn't been changed since being loaded // for @@ -534,7 +561,6 @@ public void publish(SolrQueryRequest req, SolrQueryResponse rsp) } } - String newCollection = req.getParams().get(NEW_COLLECTION_PARAM); if (StrUtils.isNotNullOrEmpty(newCollection) && zkStateReader().getClusterState().hasCollection(newCollection)) { throw new SolrException( @@ -555,7 +581,6 @@ && zkStateReader().getClusterState().hasCollection(newCollection)) { copyConfig(mutableId, configSet); } - boolean reloadCollections = req.getParams().getBool(RELOAD_COLLECTIONS_PARAM, false); if (reloadCollections) { log.debug("Reloading collections after update to configSet: {}", configSet); List collectionsForConfig = configSetHelper.listCollectionsForConfig(configSet); @@ -568,10 +593,8 @@ && zkStateReader().getClusterState().hasCollection(newCollection)) { // create new collection Map errorsDuringIndexing = null; if (StrUtils.isNotNullOrEmpty(newCollection)) { - int numShards = req.getParams().getInt("numShards", 1); - int rf = req.getParams().getInt("replicationFactor", 1); - configSetHelper.createCollection(newCollection, configSet, numShards, rf); - if (req.getParams().getBool(INDEX_TO_COLLECTION_PARAM, false)) { + configSetHelper.createCollection(newCollection, configSet, numShards, replicationFactor); + if (indexToCollection) { List docs = configSetHelper.retrieveSampleDocs(configSet); if (!docs.isEmpty()) { ManagedIndexSchema schema = loadLatestSchema(mutableId); @@ -581,16 +604,16 @@ && zkStateReader().getClusterState().hasCollection(newCollection)) { } } - if (req.getParams().getBool(CLEANUP_TEMP_PARAM, true)) { + if (cleanupTempParam) { try { - cleanupTemp(configSet); + doCleanupTemp(configSet); } catch (IOException | SolrServerException | SolrException exc) { final String excStr = exc.toString(); log.warn("Failed to clean-up temp collection {} due to: {}", mutableId, excStr); } } - settings.setDisabled(req.getParams().getBool(DISABLE_DESIGNER_PARAM, false)); + settings.setDisabled(disableDesigner); settingsDAO.persistIfChanged(configSet, settings); Map response = new HashMap<>(); @@ -602,14 +625,23 @@ && zkStateReader().getClusterState().hasCollection(newCollection)) { addErrorToResponse(newCollection, null, errorsDuringIndexing, response, null); - rsp.getValues().addAll(response); + return buildFlexibleResponse(response); } - @EndPoint(method = POST, path = "/schema-designer/analyze", permission = CONFIG_EDIT_PERM) - public void analyze(SolrQueryRequest req, SolrQueryResponse rsp) - throws IOException, SolrServerException { - final int schemaVersion = req.getParams().getInt(SCHEMA_VERSION_PARAM, -1); - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); + @Override + @PermissionName(CONFIG_EDIT_PERM) + public FlexibleSolrJerseyResponse analyze( + String configSet, + Integer schemaVersion, + String copyFrom, + String uniqueKeyField, + List languages, + Boolean enableDynamicFields, + Boolean enableFieldGuessing, + Boolean enableNestedDocs) + throws Exception { + final int schemaVersionInt = schemaVersion != null ? schemaVersion : -1; + requireNotEmpty(CONFIG_SET_PARAM, configSet); // don't let the user edit the _default configSet with the designer (for now) if (DEFAULT_CONFIGSET_NAME.equals(configSet)) { @@ -623,27 +655,25 @@ public void analyze(SolrQueryRequest req, SolrQueryResponse rsp) // Get the sample documents to analyze, preferring those in the request but falling back to // previously stored - SampleDocuments sampleDocuments = loadSampleDocuments(req, configSet); + SampleDocuments sampleDocuments = loadSampleDocuments(configSet); // Get a mutable "temp" schema either from the specified copy source or configSet if it already // exists. - String copyFrom = - configExists(configSet) - ? configSet - : req.getParams().get(COPY_FROM_PARAM, DEFAULT_CONFIGSET_NAME); + if (copyFrom == null) { + copyFrom = configExists(configSet) ? configSet : DEFAULT_CONFIGSET_NAME; + } String mutableId = getMutableId(configSet); // holds additional settings needed by the designer to maintain state SchemaDesignerSettings settings = - getMutableSchemaForConfigSet(configSet, schemaVersion, copyFrom); + getMutableSchemaForConfigSet(configSet, schemaVersionInt, copyFrom); ManagedIndexSchema schema = settings.getSchema(); - String uniqueKeyFieldParam = req.getParams().get(UNIQUE_KEY_FIELD_PARAM); - if (StrUtils.isNotNullOrEmpty(uniqueKeyFieldParam)) { - String uniqueKeyField = + if (StrUtils.isNotNullOrEmpty(uniqueKeyField)) { + String existingKeyField = schema.getUniqueKeyField() != null ? schema.getUniqueKeyField().getName() : null; - if (!uniqueKeyFieldParam.equals(uniqueKeyField)) { + if (!uniqueKeyField.equals(existingKeyField)) { // The Schema API doesn't support changing the ID field so would have to use XML directly throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, @@ -652,13 +682,12 @@ public void analyze(SolrQueryRequest req, SolrQueryResponse rsp) } boolean langsUpdated = false; - String[] languages = req.getParams().getParams(LANGUAGES_PARAM); List langs; if (languages != null) { langs = - languages.length == 0 || (languages.length == 1 && "*".equals(languages[0])) + languages.isEmpty() || (languages.size() == 1 && "*".equals(languages.get(0))) ? Collections.emptyList() - : Arrays.asList(languages); + : languages; if (!langs.equals(settings.getLanguages())) { settings.setLanguages(langs); langsUpdated = true; @@ -669,7 +698,6 @@ public void analyze(SolrQueryRequest req, SolrQueryResponse rsp) } boolean dynamicUpdated = false; - Boolean enableDynamicFields = req.getParams().getBool(ENABLE_DYNAMIC_FIELDS_PARAM); if (enableDynamicFields != null && enableDynamicFields != settings.dynamicFieldsEnabled()) { settings.setDynamicFieldsEnabled(enableDynamicFields); dynamicUpdated = true; @@ -700,7 +728,6 @@ public void analyze(SolrQueryRequest req, SolrQueryResponse rsp) // persist the updated schema schema.persistManagedSchema(false); - Boolean enableFieldGuessing = req.getParams().getBool(ENABLE_FIELD_GUESSING_PARAM); if (enableFieldGuessing != null && enableFieldGuessing != settings.fieldGuessingEnabled()) { settings.setFieldGuessingEnabled(enableFieldGuessing); } @@ -715,7 +742,6 @@ public void analyze(SolrQueryRequest req, SolrQueryResponse rsp) } // nested docs - Boolean enableNestedDocs = req.getParams().getBool(ENABLE_NESTED_DOCS_PARAM); if (enableNestedDocs != null && enableNestedDocs != settings.nestedDocsEnabled()) { settings.setNestedDocsEnabled(enableNestedDocs); configSetHelper.toggleNestedDocsFields(schema, enableNestedDocs); @@ -742,13 +768,13 @@ public void analyze(SolrQueryRequest req, SolrQueryResponse rsp) response.put(ANALYSIS_ERROR, analysisErrorHolder[0]); } addErrorToResponse(mutableId, null, errorsDuringIndexing, response, null); - rsp.getValues().addAll(response); + return buildFlexibleResponse(response); } - @EndPoint(method = GET, path = "/schema-designer/query", permission = CONFIG_READ_PERM) - public void query(SolrQueryRequest req, SolrQueryResponse rsp) - throws IOException, SolrServerException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); + @Override + @PermissionName(CONFIG_READ_PERM) + public FlexibleSolrJerseyResponse query(String configSet) throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); String mutableId = getMutableId(configSet); if (!configExists(mutableId)) { throw new SolrException( @@ -785,28 +811,26 @@ public void query(SolrQueryRequest req, SolrQueryResponse rsp) } if (errorsDuringIndexing != null) { - Map response = new HashMap<>(); - rsp.setException( - new SolrException( - SolrException.ErrorCode.BAD_REQUEST, - "Failed to re-index sample documents after schema updated.")); - response.put(ERROR_DETAILS, errorsDuringIndexing); - rsp.getValues().addAll(response); - return; + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Failed to re-index sample documents after schema updated."); } // execute the user's query against the temp collection - QueryResponse qr = cloudClient().query(mutableId, req.getParams()); - rsp.getValues().addAll(qr.getResponse()); + QueryResponse qr = cloudClient().query(mutableId, solrQueryRequest.getParams()); + Map response = new HashMap<>(); + qr.getResponse().forEach((name, val) -> response.put(name, val)); + return buildFlexibleResponse(response); } /** * Return the diff of designer schema with the source schema (either previously published or the * copyFrom). */ - @EndPoint(method = GET, path = "/schema-designer/diff", permission = CONFIG_READ_PERM) - public void getSchemaDiff(SolrQueryRequest req, SolrQueryResponse rsp) throws IOException { - final String configSet = getRequiredParam(CONFIG_SET_PARAM, req); + @Override + @PermissionName(CONFIG_READ_PERM) + public FlexibleSolrJerseyResponse getSchemaDiff(String configSet) throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); SchemaDesignerSettings settings = getMutableSchemaForConfigSet(configSet, -1, null); // diff the published if found, else use the original source schema @@ -816,16 +840,17 @@ public void getSchemaDiff(SolrQueryRequest req, SolrQueryResponse rsp) throws IO "diff", ManagedSchemaDiff.diff(loadLatestSchema(sourceSchema), settings.getSchema())); response.put("diff-source", sourceSchema); addSettingsToResponse(settings, response); - rsp.getValues().addAll(response); + return buildFlexibleResponse(response); } - protected SampleDocuments loadSampleDocuments(SolrQueryRequest req, String configSet) - throws IOException { + protected SampleDocuments loadSampleDocuments(String configSet) throws IOException { List docs = null; - ContentStream stream = extractSingleContentStream(req, false); + ContentStream stream = extractSingleContentStream(false); SampleDocuments sampleDocs = null; if (stream != null && stream.getContentType() != null) { - sampleDocs = sampleDocLoader.parseDocsFromStream(req.getParams(), stream, MAX_SAMPLE_DOCS); + sampleDocs = + sampleDocLoader.parseDocsFromStream( + solrQueryRequest.getParams(), stream, MAX_SAMPLE_DOCS); docs = sampleDocs.parsed; if (!docs.isEmpty()) { // user posted in some docs, if there are already docs stored in the blob store, then add @@ -966,8 +991,8 @@ ManagedIndexSchema loadLatestSchema(String configSet) { return configSetHelper.loadLatestSchema(configSet); } - protected ContentStream extractSingleContentStream(final SolrQueryRequest req, boolean required) { - Iterable streams = req.getContentStreams(); + protected ContentStream extractSingleContentStream(boolean required) { + Iterable streams = solrQueryRequest.getContentStreams(); Iterator iter = streams != null ? streams.iterator() : null; ContentStream stream = iter != null && iter.hasNext() ? iter.next() : null; if (required && stream == null) @@ -1247,8 +1272,8 @@ protected SimpleOrderedMap fieldToMap(SchemaField f, ManagedIndexSchema } @SuppressWarnings("unchecked") - protected Map readJsonFromRequest(SolrQueryRequest req) throws IOException { - ContentStream stream = extractSingleContentStream(req, true); + protected Map readJsonFromRequest() throws IOException { + ContentStream stream = extractSingleContentStream(true); String contentType = stream.getContentType(); if (StrUtils.isNullOrEmpty(contentType) || !contentType.toLowerCase(Locale.ROOT).contains(JSON_MIME)) { @@ -1276,7 +1301,7 @@ protected void addSettingsToResponse( } } - protected String checkMutable(String configSet, SolrQueryRequest req) throws IOException { + protected String checkMutable(String configSet, int clientSchemaVersion) throws IOException { // an apply just copies over the temp config to the "live" location String mutableId = getMutableId(configSet); if (!configExists(mutableId)) { @@ -1291,34 +1316,27 @@ protected String checkMutable(String configSet, SolrQueryRequest req) throws IOE final int schemaVersionInZk = configSetHelper.getCurrentSchemaVersion(mutableId); if (schemaVersionInZk != -1) { // check the versions agree - configSetHelper.checkSchemaVersion( - mutableId, requireSchemaVersionFromClient(req), schemaVersionInZk); + configSetHelper.checkSchemaVersion(mutableId, clientSchemaVersion, schemaVersionInZk); } // else the stored is -1, can't really enforce here return mutableId; } - protected int requireSchemaVersionFromClient(SolrQueryRequest req) { - final int schemaVersion = req.getParams().getInt(SCHEMA_VERSION_PARAM, -1); - if (schemaVersion == -1) { + protected void requireSchemaVersion(Integer schemaVersion) { + if (schemaVersion == null) { throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, - SCHEMA_VERSION_PARAM + " is a required parameter for the " + req.getPath() + " endpoint"); + SolrException.ErrorCode.BAD_REQUEST, SCHEMA_VERSION_PARAM + " is a required parameter!"); } - return schemaVersion; } - protected String getRequiredParam(final String param, final SolrQueryRequest req) { - final String paramValue = req.getParams().get(param); - if (StrUtils.isNullOrEmpty(paramValue)) { + protected void requireNotEmpty(final String param, final String value) { + if (StrUtils.isNullOrEmpty(value)) { throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, - param + " is a required parameter for the " + req.getPath() + " endpoint!"); + SolrException.ErrorCode.BAD_REQUEST, param + " is a required parameter!"); } - return paramValue; } - protected void cleanupTemp(String configSet) throws IOException, SolrServerException { + protected void doCleanupTemp(String configSet) throws IOException, SolrServerException { String mutableId = getMutableId(configSet); indexedVersion.remove(mutableId); CollectionAdminRequest.deleteCollection(mutableId).process(cloudClient()); @@ -1326,6 +1344,13 @@ protected void cleanupTemp(String configSet) throws IOException, SolrServerExcep deleteConfig(mutableId); } + protected FlexibleSolrJerseyResponse buildFlexibleResponse(Map responseMap) { + FlexibleSolrJerseyResponse response = + instantiateJerseyResponse(FlexibleSolrJerseyResponse.class); + responseMap.forEach(response::setUnknownProperty); + return response; + } + private boolean configExists(String configSet) throws IOException { return coreContainer.getConfigSetService().checkConfigExists(configSet); } diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java index 0822693db8d7..50ebe9b9abc2 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java @@ -20,7 +20,6 @@ import static org.apache.solr.common.params.CommonParams.JSON_MIME; import static org.apache.solr.handler.admin.ConfigSetsHandler.DEFAULT_CONFIGSET_NAME; import static org.apache.solr.handler.designer.SchemaDesignerAPI.getMutableId; -import static org.apache.solr.response.RawResponseWriter.CONTENT; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -34,6 +33,7 @@ import java.util.Map; import java.util.Optional; import java.util.stream.Stream; +import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; import org.apache.solr.client.solrj.request.SolrQuery; import org.apache.solr.client.solrj.response.QueryResponse; import org.apache.solr.cloud.SolrCloudTestCase; @@ -42,15 +42,12 @@ import org.apache.solr.common.cloud.SolrZkClient; import org.apache.solr.common.params.CommonParams; import org.apache.solr.common.params.ModifiableSolrParams; -import org.apache.solr.common.params.SolrParams; import org.apache.solr.common.util.ContentStream; import org.apache.solr.common.util.ContentStreamBase; -import org.apache.solr.common.util.NamedList; import org.apache.solr.common.util.SimpleOrderedMap; import org.apache.solr.core.CoreContainer; import org.apache.solr.handler.TestSampleDocumentsLoader; import org.apache.solr.request.SolrQueryRequest; -import org.apache.solr.response.SolrQueryResponse; import org.apache.solr.schema.ManagedIndexSchema; import org.apache.solr.schema.SchemaField; import org.apache.solr.util.ExternalPaths; @@ -64,6 +61,7 @@ public class TestSchemaDesignerAPI extends SolrCloudTestCase implements SchemaDe private CoreContainer cc; private SchemaDesignerAPI schemaDesignerAPI; + private SolrQueryRequest mockReq; @BeforeClass public static void createCluster() throws Exception { @@ -87,39 +85,41 @@ public void setupTest() { assertNotNull(cluster); cc = cluster.getJettySolrRunner(0).getCoreContainer(); assertNotNull(cc); - schemaDesignerAPI = new SchemaDesignerAPI(cc); + mockReq = mock(SolrQueryRequest.class); + schemaDesignerAPI = + new SchemaDesignerAPI( + cc, + SchemaDesignerAPI.newSchemaSuggester(), + SchemaDesignerAPI.newSampleDocumentsLoader(), + mockReq); } public void testTSV() throws Exception { String configSet = "testTSV"; ModifiableSolrParams reqParams = new ModifiableSolrParams(); - - // GET /schema-designer/info - SolrQueryResponse rsp = new SolrQueryResponse(); - SolrQueryRequest req = mock(SolrQueryRequest.class); - reqParams.set(CONFIG_SET_PARAM, configSet); reqParams.set(LANGUAGES_PARAM, "en"); reqParams.set(ENABLE_DYNAMIC_FIELDS_PARAM, false); - when(req.getParams()).thenReturn(reqParams); + when(mockReq.getParams()).thenReturn(reqParams); String tsv = "id\tcol1\tcol2\n1\tfoo\tbar\n2\tbaz\tbah\n"; // POST some sample TSV docs ContentStream stream = new ContentStreamBase.StringStream(tsv, "text/csv"); - when(req.getContentStreams()).thenReturn(Collections.singletonList(stream)); + when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); // POST /schema-designer/analyze - schemaDesignerAPI.analyze(req, rsp); - assertNotNull(rsp.getValues().get(CONFIG_SET_PARAM)); - assertNotNull(rsp.getValues().get(SCHEMA_VERSION_PARAM)); - assertEquals(2, rsp.getValues().get("numDocs")); + FlexibleSolrJerseyResponse response = + schemaDesignerAPI.analyze(configSet, null, null, null, List.of("en"), false, null, null); + assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); + assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); + assertEquals(2, response.unknownProperties().get("numDocs")); reqParams.clear(); reqParams.set(CONFIG_SET_PARAM, configSet); - rsp = new SolrQueryResponse(); - schemaDesignerAPI.cleanupTemp(req, rsp); + when(mockReq.getContentStreams()).thenReturn(null); + schemaDesignerAPI.cleanupTempSchema(configSet); String mutableId = getMutableId(configSet); assertFalse(cc.getZkController().getClusterState().hasCollection(mutableId)); @@ -150,14 +150,8 @@ public void testAddTechproductsProgressively() throws Exception { String configSet = "techproducts"; - ModifiableSolrParams reqParams = new ModifiableSolrParams(); - // GET /schema-designer/info - SolrQueryResponse rsp = new SolrQueryResponse(); - SolrQueryRequest req = mock(SolrQueryRequest.class); - reqParams.set(CONFIG_SET_PARAM, configSet); - when(req.getParams()).thenReturn(reqParams); - schemaDesignerAPI.getInfo(req, rsp); + FlexibleSolrJerseyResponse response = schemaDesignerAPI.getInfo(configSet); // response should just be the default values Map expSettings = Map.of( @@ -165,64 +159,46 @@ public void testAddTechproductsProgressively() throws Exception { ENABLE_FIELD_GUESSING_PARAM, true, ENABLE_NESTED_DOCS_PARAM, false, LANGUAGES_PARAM, Collections.emptyList()); - assertDesignerSettings(expSettings, rsp.getValues()); - SolrParams rspData = rsp.getValues().toSolrParams(); - int schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); + assertDesignerSettings(expSettings, response.unknownProperties()); + int schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); assertEquals(schemaVersion, -1); // shouldn't exist yet // Use the prep endpoint to prepare the new schema - reqParams.clear(); - reqParams.set(CONFIG_SET_PARAM, configSet); - rsp = new SolrQueryResponse(); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - schemaDesignerAPI.prepNewSchema(req, rsp); - assertNotNull(rsp.getValues().get(CONFIG_SET_PARAM)); - assertNotNull(rsp.getValues().get(SCHEMA_VERSION_PARAM)); - rspData = rsp.getValues().toSolrParams(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); + response = schemaDesignerAPI.prepNewSchema(configSet, null); + assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); + assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); + schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); for (Path next : toAdd) { // Analyze some sample documents to refine the schema - reqParams.clear(); + ModifiableSolrParams reqParams = new ModifiableSolrParams(); reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set(LANGUAGES_PARAM, "en"); - reqParams.set(ENABLE_DYNAMIC_FIELDS_PARAM, false); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); + when(mockReq.getParams()).thenReturn(reqParams); // POST some sample JSON docs ContentStreamBase.FileStream stream = new ContentStreamBase.FileStream(next); stream.setContentType( TestSampleDocumentsLoader.guessContentTypeFromFilename(next.getFileName().toString())); - when(req.getContentStreams()).thenReturn(Collections.singletonList(stream)); - - rsp = new SolrQueryResponse(); + when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); // POST /schema-designer/analyze - schemaDesignerAPI.analyze(req, rsp); + response = + schemaDesignerAPI.analyze( + configSet, schemaVersion, null, null, List.of("en"), false, null, null); - assertNotNull(rsp.getValues().get(CONFIG_SET_PARAM)); - assertNotNull(rsp.getValues().get(SCHEMA_VERSION_PARAM)); - assertNotNull(rsp.getValues().get("fields")); - assertNotNull(rsp.getValues().get("fieldTypes")); - assertNotNull(rsp.getValues().get("docIds")); + assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); + assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); + assertNotNull(response.unknownProperties().get("fields")); + assertNotNull(response.unknownProperties().get("fieldTypes")); + assertNotNull(response.unknownProperties().get("docIds")); // capture the schema version for MVCC - rspData = rsp.getValues().toSolrParams(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); + schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); } // get info (from the temp) - reqParams.clear(); - reqParams.set(CONFIG_SET_PARAM, configSet); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - rsp = new SolrQueryResponse(); - // GET /schema-designer/info - schemaDesignerAPI.getInfo(req, rsp); + response = schemaDesignerAPI.getInfo(configSet); expSettings = Map.of( ENABLE_DYNAMIC_FIELDS_PARAM, false, @@ -230,57 +206,37 @@ public void testAddTechproductsProgressively() throws Exception { ENABLE_NESTED_DOCS_PARAM, false, LANGUAGES_PARAM, Collections.singletonList("en"), COPY_FROM_PARAM, "_default"); - assertDesignerSettings(expSettings, rsp.getValues()); + assertDesignerSettings(expSettings, response.unknownProperties()); // query to see how the schema decisions impact retrieval / ranking - reqParams.clear(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set(CommonParams.Q, "*:*"); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - rsp = new SolrQueryResponse(); + ModifiableSolrParams queryParams = new ModifiableSolrParams(); + queryParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); + queryParams.set(CONFIG_SET_PARAM, configSet); + queryParams.set(CommonParams.Q, "*:*"); + when(mockReq.getParams()).thenReturn(queryParams); + when(mockReq.getContentStreams()).thenReturn(null); // GET /schema-designer/query - schemaDesignerAPI.query(req, rsp); - assertNotNull(rsp.getResponseHeader()); - SolrDocumentList results = (SolrDocumentList) rsp.getResponse(); + response = schemaDesignerAPI.query(configSet); + assertNotNull(response.unknownProperties().get("responseHeader")); + SolrDocumentList results = (SolrDocumentList) response.unknownProperties().get("response"); assertEquals(47, results.getNumFound()); // publish schema to a config set that can be used by real collections - reqParams.clear(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - String collection = "techproducts"; - reqParams.set(NEW_COLLECTION_PARAM, collection); - reqParams.set(INDEX_TO_COLLECTION_PARAM, true); - reqParams.set(RELOAD_COLLECTIONS_PARAM, true); - reqParams.set(CLEANUP_TEMP_PARAM, true); - reqParams.set(DISABLE_DESIGNER_PARAM, true); - - rsp = new SolrQueryResponse(); - schemaDesignerAPI.publish(req, rsp); + schemaDesignerAPI.publish(configSet, schemaVersion, collection, true, 1, 1, true, true, true); assertNotNull(cc.getZkController().zkStateReader.getCollection(collection)); // listCollectionsForConfig - reqParams.clear(); - reqParams.set(CONFIG_SET_PARAM, configSet); - rsp = new SolrQueryResponse(); - schemaDesignerAPI.listCollectionsForConfig(req, rsp); - List collections = (List) rsp.getValues().get("collections"); + response = schemaDesignerAPI.listCollectionsForConfig(configSet); + List collections = (List) response.unknownProperties().get("collections"); assertNotNull(collections); assertTrue(collections.contains(collection)); // now try to create another temp, which should fail since designer is disabled for this // configSet now - reqParams.clear(); - reqParams.set(CONFIG_SET_PARAM, configSet); - rsp = new SolrQueryResponse(); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); try { - schemaDesignerAPI.prepNewSchema(req, rsp); + schemaDesignerAPI.prepNewSchema(configSet, null); fail("Prep should fail for locked schema " + configSet); } catch (SolrException solrExc) { assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, solrExc.code()); @@ -292,38 +248,32 @@ public void testAddTechproductsProgressively() throws Exception { public void testSuggestFilmsXml() throws Exception { String configSet = "films"; - ModifiableSolrParams reqParams = new ModifiableSolrParams(); - Path filmsDir = ExternalPaths.SOURCE_HOME.resolve("example/films"); assertTrue(filmsDir + " not found!", Files.isDirectory(filmsDir)); Path filmsXml = filmsDir.resolve("films.xml"); assertTrue("example/films/films.xml not found", Files.isRegularFile(filmsXml)); - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set(ENABLE_DYNAMIC_FIELDS_PARAM, "true"); - - SolrQueryRequest req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - // POST some sample XML docs ContentStreamBase.FileStream stream = new ContentStreamBase.FileStream(filmsXml); stream.setContentType("application/xml"); - when(req.getContentStreams()).thenReturn(Collections.singletonList(stream)); - - SolrQueryResponse rsp = new SolrQueryResponse(); + ModifiableSolrParams reqParams = new ModifiableSolrParams(); + reqParams.set(CONFIG_SET_PARAM, configSet); + when(mockReq.getParams()).thenReturn(reqParams); + when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); // POST /schema-designer/analyze - schemaDesignerAPI.analyze(req, rsp); - - assertNotNull(rsp.getValues().get(CONFIG_SET_PARAM)); - assertNotNull(rsp.getValues().get(SCHEMA_VERSION_PARAM)); - assertNotNull(rsp.getValues().get("fields")); - assertNotNull(rsp.getValues().get("fieldTypes")); - List docIds = (List) rsp.getValues().get("docIds"); + FlexibleSolrJerseyResponse response = + schemaDesignerAPI.analyze(configSet, null, null, null, null, true, null, null); + + assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); + assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); + assertNotNull(response.unknownProperties().get("fields")); + assertNotNull(response.unknownProperties().get("fieldTypes")); + List docIds = (List) response.unknownProperties().get("docIds"); assertNotNull(docIds); assertEquals(100, docIds.size()); // designer limits the doc ids to top 100 - String idField = rsp.getValues()._getStr(UNIQUE_KEY_FIELD_PARAM); + String idField = (String) response.unknownProperties().get(UNIQUE_KEY_FIELD_PARAM); assertNotNull(idField); } @@ -332,16 +282,10 @@ public void testSuggestFilmsXml() throws Exception { public void testBasicUserWorkflow() throws Exception { String configSet = "testJson"; - ModifiableSolrParams reqParams = new ModifiableSolrParams(); - // Use the prep endpoint to prepare the new schema - reqParams.set(CONFIG_SET_PARAM, configSet); - SolrQueryResponse rsp = new SolrQueryResponse(); - SolrQueryRequest req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - schemaDesignerAPI.prepNewSchema(req, rsp); - assertNotNull(rsp.getValues().get(CONFIG_SET_PARAM)); - assertNotNull(rsp.getValues().get(SCHEMA_VERSION_PARAM)); + FlexibleSolrJerseyResponse response = schemaDesignerAPI.prepNewSchema(configSet, null); + assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); + assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); Map expSettings = Map.of( @@ -350,44 +294,36 @@ public void testBasicUserWorkflow() throws Exception { ENABLE_NESTED_DOCS_PARAM, false, LANGUAGES_PARAM, Collections.emptyList(), COPY_FROM_PARAM, "_default"); - assertDesignerSettings(expSettings, rsp.getValues()); + assertDesignerSettings(expSettings, response.unknownProperties()); // Analyze some sample documents to refine the schema - reqParams.clear(); - reqParams.set(CONFIG_SET_PARAM, configSet); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - - // POST some sample JSON docs Path booksJson = ExternalPaths.SOURCE_HOME.resolve("example/exampledocs/books.json"); ContentStreamBase.FileStream stream = new ContentStreamBase.FileStream(booksJson); stream.setContentType(JSON_MIME); - when(req.getContentStreams()).thenReturn(Collections.singletonList(stream)); - - rsp = new SolrQueryResponse(); + ModifiableSolrParams reqParams = new ModifiableSolrParams(); + reqParams.set(CONFIG_SET_PARAM, configSet); + when(mockReq.getParams()).thenReturn(reqParams); + when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); // POST /schema-designer/analyze - schemaDesignerAPI.analyze(req, rsp); - - assertNotNull(rsp.getValues().get(CONFIG_SET_PARAM)); - assertNotNull(rsp.getValues().get(SCHEMA_VERSION_PARAM)); - assertNotNull(rsp.getValues().get("fields")); - assertNotNull(rsp.getValues().get("fieldTypes")); - assertNotNull(rsp.getValues().get("docIds")); - String idField = rsp.getValues()._getStr(UNIQUE_KEY_FIELD_PARAM); + response = schemaDesignerAPI.analyze(configSet, null, null, null, null, null, null, null); + + assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); + assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); + assertNotNull(response.unknownProperties().get("fields")); + assertNotNull(response.unknownProperties().get("fieldTypes")); + assertNotNull(response.unknownProperties().get("docIds")); + String idField = (String) response.unknownProperties().get(UNIQUE_KEY_FIELD_PARAM); assertNotNull(idField); - assertDesignerSettings(expSettings, rsp.getValues()); + assertDesignerSettings(expSettings, response.unknownProperties()); // capture the schema version for MVCC - SolrParams rspData = rsp.getValues().toSolrParams(); - reqParams.clear(); - int schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); + int schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); // load the contents of a file - Collection files = (Collection) rsp.getValues().get("files"); + Collection files = (Collection) response.unknownProperties().get("files"); assertTrue(files != null && !files.isEmpty()); - reqParams.set(CONFIG_SET_PARAM, configSet); String file = null; for (String f : files) { if ("solrconfig.xml".equals(f)) { @@ -396,63 +332,34 @@ public void testBasicUserWorkflow() throws Exception { } } assertNotNull("solrconfig.xml not found in files!", file); - reqParams.set("file", file); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - rsp = new SolrQueryResponse(); - schemaDesignerAPI.getFileContents(req, rsp); - String solrconfigXml = (String) rsp.getValues().get(file); + response = schemaDesignerAPI.getFileContents(configSet, file); + String solrconfigXml = (String) response.unknownProperties().get(file); assertNotNull(solrconfigXml); - reqParams.clear(); // Update solrconfig.xml - rsp = new SolrQueryResponse(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set("file", file); - - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - when(req.getContentStreams()) + when(mockReq.getContentStreams()) .thenReturn( Collections.singletonList( new ContentStreamBase.StringStream(solrconfigXml, "application/xml"))); - - schemaDesignerAPI.updateFileContents(req, rsp); - rspData = rsp.getValues().toSolrParams(); - reqParams.clear(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); + response = schemaDesignerAPI.updateFileContents(configSet, file); + schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); // update solrconfig.xml with some invalid XML mess - rsp = new SolrQueryResponse(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set("file", file); - - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - when(req.getContentStreams()) + when(mockReq.getContentStreams()) .thenReturn( Collections.singletonList( new ContentStreamBase.StringStream("", "application/xml"))); // this should fail b/c the updated solrconfig.xml is invalid - schemaDesignerAPI.updateFileContents(req, rsp); - rspData = rsp.getValues().toSolrParams(); - reqParams.clear(); - assertNotNull(rspData.get("updateFileError")); + response = schemaDesignerAPI.updateFileContents(configSet, file); + assertNotNull(response.unknownProperties().get("updateFileError")); // remove dynamic fields and change the language to "en" only - rsp = new SolrQueryResponse(); + when(mockReq.getContentStreams()).thenReturn(null); // POST /schema-designer/analyze - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set(LANGUAGES_PARAM, "en"); - reqParams.set(ENABLE_DYNAMIC_FIELDS_PARAM, false); - reqParams.set(ENABLE_FIELD_GUESSING_PARAM, false); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - schemaDesignerAPI.analyze(req, rsp); + response = + schemaDesignerAPI.analyze( + configSet, schemaVersion, null, null, List.of("en"), false, false, null); expSettings = Map.of( @@ -461,28 +368,18 @@ public void testBasicUserWorkflow() throws Exception { ENABLE_NESTED_DOCS_PARAM, false, LANGUAGES_PARAM, Collections.singletonList("en"), COPY_FROM_PARAM, "_default"); - assertDesignerSettings(expSettings, rsp.getValues()); + assertDesignerSettings(expSettings, response.unknownProperties()); - List filesInResp = (List) rsp.getValues().get("files"); + List filesInResp = (List) response.unknownProperties().get("files"); assertEquals(5, filesInResp.size()); assertTrue(filesInResp.contains("lang/stopwords_en.txt")); - rspData = rsp.getValues().toSolrParams(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); - - reqParams.clear(); + schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); // add the dynamic fields back and change the languages too - rsp = new SolrQueryResponse(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.add(LANGUAGES_PARAM, "en"); - reqParams.add(LANGUAGES_PARAM, "fr"); - reqParams.set(ENABLE_DYNAMIC_FIELDS_PARAM, true); - reqParams.set(ENABLE_FIELD_GUESSING_PARAM, false); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - schemaDesignerAPI.analyze(req, rsp); + response = + schemaDesignerAPI.analyze( + configSet, schemaVersion, null, null, Arrays.asList("en", "fr"), true, false, null); expSettings = Map.of( @@ -491,25 +388,18 @@ public void testBasicUserWorkflow() throws Exception { ENABLE_NESTED_DOCS_PARAM, false, LANGUAGES_PARAM, Arrays.asList("en", "fr"), COPY_FROM_PARAM, "_default"); - assertDesignerSettings(expSettings, rsp.getValues()); + assertDesignerSettings(expSettings, response.unknownProperties()); - filesInResp = (List) rsp.getValues().get("files"); + filesInResp = (List) response.unknownProperties().get("files"); assertEquals(7, filesInResp.size()); assertTrue(filesInResp.contains("lang/stopwords_fr.txt")); - rspData = rsp.getValues().toSolrParams(); - reqParams.clear(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); + schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); - // add back all the default languages - rsp = new SolrQueryResponse(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.add(LANGUAGES_PARAM, "*"); - reqParams.set(ENABLE_DYNAMIC_FIELDS_PARAM, false); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - schemaDesignerAPI.analyze(req, rsp); + // add back all the default languages (using "*" wildcard → empty list) + response = + schemaDesignerAPI.analyze( + configSet, schemaVersion, null, null, List.of("*"), false, null, null); expSettings = Map.of( @@ -518,168 +408,105 @@ public void testBasicUserWorkflow() throws Exception { ENABLE_NESTED_DOCS_PARAM, false, LANGUAGES_PARAM, Collections.emptyList(), COPY_FROM_PARAM, "_default"); - assertDesignerSettings(expSettings, rsp.getValues()); + assertDesignerSettings(expSettings, response.unknownProperties()); - filesInResp = (List) rsp.getValues().get("files"); + filesInResp = (List) response.unknownProperties().get("files"); assertEquals(43, filesInResp.size()); assertTrue(filesInResp.contains("lang/stopwords_fr.txt")); assertTrue(filesInResp.contains("lang/stopwords_en.txt")); assertTrue(filesInResp.contains("lang/stopwords_it.txt")); - rspData = rsp.getValues().toSolrParams(); - reqParams.clear(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); + schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); // Get the value of a sample document String docId = "978-0641723445"; String fieldName = "series_t"; - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set(DOC_ID_PARAM, docId); - reqParams.set(FIELD_PARAM, fieldName); - reqParams.set(UNIQUE_KEY_FIELD_PARAM, idField); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - rsp = new SolrQueryResponse(); // GET /schema-designer/sample - schemaDesignerAPI.getSampleValue(req, rsp); - rspData = rsp.getValues().toSolrParams(); - assertNotNull(rspData.get(idField)); - assertNotNull(rspData.get(fieldName)); - assertNotNull(rspData.get("analysis")); - - reqParams.clear(); + response = schemaDesignerAPI.getSampleValue(configSet, fieldName, idField, docId); + assertNotNull(response.unknownProperties().get(idField)); + assertNotNull(response.unknownProperties().get(fieldName)); + assertNotNull(response.unknownProperties().get("analysis")); // at this point the user would refine the schema by // editing suggestions for fields and adding/removing fields / field types as needed // add a new field - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); stream = new ContentStreamBase.FileStream(getFile("schema-designer/add-new-field.json")); stream.setContentType(JSON_MIME); - when(req.getContentStreams()).thenReturn(Collections.singletonList(stream)); - rsp = new SolrQueryResponse(); + when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); // POST /schema-designer/add - schemaDesignerAPI.addSchemaObject(req, rsp); - assertNotNull(rsp.getValues().get("add-field")); - rspData = rsp.getValues().toSolrParams(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); - assertNotNull(rsp.getValues().get("fields")); + response = schemaDesignerAPI.addSchemaObject(configSet, schemaVersion); + assertNotNull(response.unknownProperties().get("add-field")); + schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); + assertNotNull(response.unknownProperties().get("fields")); // update an existing field - reqParams.clear(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - // switch a single-valued field to a multi-valued field, which triggers a full rebuild of the - // "temp" collection + // switch a single-valued field to a multi-valued field, which triggers a full rebuild stream = new ContentStreamBase.FileStream(getFile("schema-designer/update-author-field.json")); stream.setContentType(JSON_MIME); - when(req.getContentStreams()).thenReturn(Collections.singletonList(stream)); - - rsp = new SolrQueryResponse(); + when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); // PUT /schema-designer/update - schemaDesignerAPI.updateSchemaObject(req, rsp); - assertNotNull(rsp.getValues().get("field")); - rspData = rsp.getValues().toSolrParams(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); + response = schemaDesignerAPI.updateSchemaObject(configSet, schemaVersion); + assertNotNull(response.unknownProperties().get("field")); + schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); // add a new type - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); stream = new ContentStreamBase.FileStream(getFile("schema-designer/add-new-type.json")); stream.setContentType(JSON_MIME); - when(req.getContentStreams()).thenReturn(Collections.singletonList(stream)); - rsp = new SolrQueryResponse(); + when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); // POST /schema-designer/add - schemaDesignerAPI.addSchemaObject(req, rsp); + response = schemaDesignerAPI.addSchemaObject(configSet, schemaVersion); final String expectedTypeName = "test_txt"; - assertEquals(expectedTypeName, rsp.getValues().get("add-field-type")); - rspData = rsp.getValues().toSolrParams(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); - assertNotNull(rsp.getValues().get("fieldTypes")); + assertEquals(expectedTypeName, response.unknownProperties().get("add-field-type")); + schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); + assertNotNull(response.unknownProperties().get("fieldTypes")); List> fieldTypes = - (List>) rsp.getValues().get("fieldTypes"); + (List>) response.unknownProperties().get("fieldTypes"); Optional> expected = fieldTypes.stream().filter(m -> expectedTypeName.equals(m.get("name"))).findFirst(); assertTrue( "New field type '" + expectedTypeName + "' not found in add type response!", expected.isPresent()); - reqParams.clear(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); stream = new ContentStreamBase.FileStream(getFile("schema-designer/update-type.json")); stream.setContentType(JSON_MIME); - when(req.getContentStreams()).thenReturn(Collections.singletonList(stream)); - rsp = new SolrQueryResponse(); + when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); // POST /schema-designer/update - schemaDesignerAPI.updateSchemaObject(req, rsp); - rspData = rsp.getValues().toSolrParams(); - schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); + response = schemaDesignerAPI.updateSchemaObject(configSet, schemaVersion); + schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); // query to see how the schema decisions impact retrieval / ranking - reqParams.clear(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set(CommonParams.Q, "*:*"); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - rsp = new SolrQueryResponse(); + ModifiableSolrParams queryParams = new ModifiableSolrParams(); + queryParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); + queryParams.set(CONFIG_SET_PARAM, configSet); + queryParams.set(CommonParams.Q, "*:*"); + when(mockReq.getParams()).thenReturn(queryParams); + when(mockReq.getContentStreams()).thenReturn(null); // GET /schema-designer/query - schemaDesignerAPI.query(req, rsp); - assertNotNull(rsp.getResponseHeader()); - SolrDocumentList results = (SolrDocumentList) rsp.getResponse(); + response = schemaDesignerAPI.query(configSet); + assertNotNull(response.unknownProperties().get("responseHeader")); + SolrDocumentList results = (SolrDocumentList) response.unknownProperties().get("response"); assertEquals(4, results.size()); // Download ZIP - reqParams.clear(); - reqParams.set(CONFIG_SET_PARAM, configSet); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - rsp = new SolrQueryResponse(); - schemaDesignerAPI.downloadConfig(req, rsp); - assertNotNull(rsp.getValues().get(CONTENT)); + when(mockReq.getContentStreams()).thenReturn(null); + assertNotNull(schemaDesignerAPI.downloadConfig(configSet)); // publish schema to a config set that can be used by real collections - reqParams.clear(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - String collection = "test123"; - reqParams.set(NEW_COLLECTION_PARAM, collection); - reqParams.set(INDEX_TO_COLLECTION_PARAM, true); - reqParams.set(RELOAD_COLLECTIONS_PARAM, true); - reqParams.set(CLEANUP_TEMP_PARAM, true); - - rsp = new SolrQueryResponse(); - schemaDesignerAPI.publish(req, rsp); + schemaDesignerAPI.publish(configSet, schemaVersion, collection, true, 1, 1, true, true, false); assertNotNull(cc.getZkController().zkStateReader.getCollection(collection)); // listCollectionsForConfig - reqParams.clear(); - reqParams.set(CONFIG_SET_PARAM, configSet); - rsp = new SolrQueryResponse(); - schemaDesignerAPI.listCollectionsForConfig(req, rsp); - List collections = (List) rsp.getValues().get("collections"); + response = schemaDesignerAPI.listCollectionsForConfig(configSet); + List collections = (List) response.unknownProperties().get("collections"); assertNotNull(collections); assertTrue(collections.contains(collection)); @@ -700,39 +527,26 @@ public void testBasicUserWorkflow() throws Exception { public void testFieldUpdates() throws Exception { String configSet = "fieldUpdates"; - ModifiableSolrParams reqParams = new ModifiableSolrParams(); - // Use the prep endpoint to prepare the new schema - reqParams.set(CONFIG_SET_PARAM, configSet); - SolrQueryResponse rsp = new SolrQueryResponse(); - SolrQueryRequest req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - schemaDesignerAPI.prepNewSchema(req, rsp); - assertNotNull(rsp.getValues().get(CONFIG_SET_PARAM)); - assertNotNull(rsp.getValues().get(SCHEMA_VERSION_PARAM)); - SolrParams rspData = rsp.getValues().toSolrParams(); - int schemaVersion = rspData.getInt(SCHEMA_VERSION_PARAM); + FlexibleSolrJerseyResponse response = schemaDesignerAPI.prepNewSchema(configSet, null); + assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); + assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); + int schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); // add our test field that we'll test various updates to - reqParams.clear(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(schemaVersion)); - reqParams.set(CONFIG_SET_PARAM, configSet); - req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); ContentStreamBase.FileStream stream = new ContentStreamBase.FileStream(getFile("schema-designer/add-new-field.json")); stream.setContentType(JSON_MIME); - when(req.getContentStreams()).thenReturn(Collections.singletonList(stream)); - rsp = new SolrQueryResponse(); + when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); // POST /schema-designer/add - schemaDesignerAPI.addSchemaObject(req, rsp); - assertNotNull(rsp.getValues().get("add-field")); + response = schemaDesignerAPI.addSchemaObject(configSet, schemaVersion); + assertNotNull(response.unknownProperties().get("add-field")); final String fieldName = "keywords"; Optional> maybeField = - ((List>) rsp.getValues().get("fields")) + ((List>) response.unknownProperties().get("fields")) .stream().filter(m -> fieldName.equals(m.get("name"))).findFirst(); assertTrue(maybeField.isPresent()); SimpleOrderedMap field = maybeField.get(); @@ -801,44 +615,28 @@ public void testFieldUpdates() throws Exception { public void testSchemaDiffEndpoint() throws Exception { String configSet = "testDiff"; - ModifiableSolrParams reqParams = new ModifiableSolrParams(); - // Use the prep endpoint to prepare the new schema - reqParams.set(CONFIG_SET_PARAM, configSet); - SolrQueryResponse rsp = new SolrQueryResponse(); - SolrQueryRequest req = mock(SolrQueryRequest.class); - when(req.getParams()).thenReturn(reqParams); - schemaDesignerAPI.prepNewSchema(req, rsp); - assertNotNull(rsp.getValues().get(CONFIG_SET_PARAM)); - assertNotNull(rsp.getValues().get(SCHEMA_VERSION_PARAM)); + FlexibleSolrJerseyResponse response = schemaDesignerAPI.prepNewSchema(configSet, null); + assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); + assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); + int schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); // publish schema to a config set that can be used by real collections - reqParams.clear(); - reqParams.set(SCHEMA_VERSION_PARAM, String.valueOf(rsp.getValues().get(SCHEMA_VERSION_PARAM))); - reqParams.set(CONFIG_SET_PARAM, configSet); - String collection = "diff456"; - reqParams.set(NEW_COLLECTION_PARAM, collection); - reqParams.set(INDEX_TO_COLLECTION_PARAM, true); - reqParams.set(RELOAD_COLLECTIONS_PARAM, true); - reqParams.set(CLEANUP_TEMP_PARAM, true); - - rsp = new SolrQueryResponse(); - schemaDesignerAPI.publish(req, rsp); + schemaDesignerAPI.publish(configSet, schemaVersion, collection, true, 1, 1, true, true, false); assertNotNull(cc.getZkController().zkStateReader.getCollection(collection)); // Load the schema designer for the existing config set and make some changes to it - reqParams.clear(); + ModifiableSolrParams reqParams = new ModifiableSolrParams(); reqParams.set(CONFIG_SET_PARAM, configSet); - reqParams.set(ENABLE_DYNAMIC_FIELDS_PARAM, "true"); - reqParams.set(ENABLE_FIELD_GUESSING_PARAM, "false"); - rsp = new SolrQueryResponse(); - schemaDesignerAPI.analyze(req, rsp); + when(mockReq.getParams()).thenReturn(reqParams); + when(mockReq.getContentStreams()).thenReturn(null); + response = schemaDesignerAPI.analyze(configSet, null, null, null, null, true, false, null); // Update id field to not use docValues List> fields = - (List>) rsp.getValues().get("fields"); + (List>) response.unknownProperties().get("fields"); SimpleOrderedMap idFieldMap = fields.stream().filter(field -> field.get("name").equals("id")).findFirst().get(); idFieldMap.remove("copyDest"); // Don't include copyDest as it is not a property of SchemaField @@ -849,47 +647,39 @@ public void testSchemaDiffEndpoint() throws Exception { idFieldMapUpdated.setVal( idFieldMapUpdated.indexOf("omitTermFreqAndPositions", 0), Boolean.FALSE); - SolrParams solrParams = idFieldMapUpdated.toSolrParams(); - Map mapParams = solrParams.toMap(new HashMap<>()); + Map mapParams = idFieldMapUpdated.toSolrParams().toMap(new HashMap<>()); mapParams.put("termVectors", Boolean.FALSE); - reqParams.set( - SCHEMA_VERSION_PARAM, rsp.getValues().toSolrParams().getInt(SCHEMA_VERSION_PARAM)); + schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); ContentStreamBase.StringStream stringStream = new ContentStreamBase.StringStream(JSONUtil.toJSON(mapParams), JSON_MIME); - when(req.getContentStreams()).thenReturn(Collections.singletonList(stringStream)); + when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stringStream)); - rsp = new SolrQueryResponse(); - schemaDesignerAPI.updateSchemaObject(req, rsp); + response = schemaDesignerAPI.updateSchemaObject(configSet, schemaVersion); // Add a new field - Integer schemaVersion = rsp.getValues().toSolrParams().getInt(SCHEMA_VERSION_PARAM); - reqParams.set(SCHEMA_VERSION_PARAM, schemaVersion); + schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); ContentStreamBase.FileStream fileStream = new ContentStreamBase.FileStream(getFile("schema-designer/add-new-field.json")); fileStream.setContentType(JSON_MIME); - when(req.getContentStreams()).thenReturn(Collections.singletonList(fileStream)); - rsp = new SolrQueryResponse(); + when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(fileStream)); // POST /schema-designer/add - schemaDesignerAPI.addSchemaObject(req, rsp); - assertNotNull(rsp.getValues().get("add-field")); + response = schemaDesignerAPI.addSchemaObject(configSet, schemaVersion); + assertNotNull(response.unknownProperties().get("add-field")); // Add a new field type - schemaVersion = rsp.getValues().toSolrParams().getInt(SCHEMA_VERSION_PARAM); - reqParams.set(SCHEMA_VERSION_PARAM, schemaVersion); + schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); fileStream = new ContentStreamBase.FileStream(getFile("schema-designer/add-new-type.json")); fileStream.setContentType(JSON_MIME); - when(req.getContentStreams()).thenReturn(Collections.singletonList(fileStream)); - rsp = new SolrQueryResponse(); + when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(fileStream)); // POST /schema-designer/add - schemaDesignerAPI.addSchemaObject(req, rsp); - assertNotNull(rsp.getValues().get("add-field-type")); + response = schemaDesignerAPI.addSchemaObject(configSet, schemaVersion); + assertNotNull(response.unknownProperties().get("add-field-type")); // Let's do a diff now - rsp = new SolrQueryResponse(); - schemaDesignerAPI.getSchemaDiff(req, rsp); + response = schemaDesignerAPI.getSchemaDiff(configSet); - Map diff = (Map) rsp.getValues().get("diff"); + Map diff = (Map) response.unknownProperties().get("diff"); // field asserts assertNotNull(diff.get("fields")); @@ -929,7 +719,7 @@ public void testSchemaDiffEndpoint() throws Exception { assertNotNull(fieldTypesAdded.get("test_txt")); } - protected void assertDesignerSettings(Map expected, NamedList actual) { + protected void assertDesignerSettings(Map expected, Map actual) { for (String expKey : expected.keySet()) { Object expValue = expected.get(expKey); assertEquals( From 20f4431b95c42e26920646deaeb378e34fcfb5fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 15:31:31 +0000 Subject: [PATCH 03/69] Address code review feedback: fix copyFrom parameter shadowing and comments Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../apache/solr/handler/designer/SchemaDesignerAPI.java | 7 +++---- .../solr/handler/designer/TestSchemaDesignerAPI.java | 5 +++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java index 7a12f4d1f9ad..7ae28e0fb79d 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java @@ -195,11 +195,10 @@ public FlexibleSolrJerseyResponse prepNewSchema(String configSet, String copyFro requireNotEmpty(CONFIG_SET_PARAM, configSet); validateNewConfigSetName(configSet); - if (copyFrom == null) { - copyFrom = DEFAULT_CONFIGSET_NAME; - } + final String effectiveCopyFrom = copyFrom != null ? copyFrom : DEFAULT_CONFIGSET_NAME; - SchemaDesignerSettings settings = getMutableSchemaForConfigSet(configSet, -1, copyFrom); + SchemaDesignerSettings settings = + getMutableSchemaForConfigSet(configSet, -1, effectiveCopyFrom); ManagedIndexSchema schema = settings.getSchema(); String mutableId = getMutableId(configSet); diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java index 50ebe9b9abc2..8fff2fec0652 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java @@ -396,7 +396,7 @@ public void testBasicUserWorkflow() throws Exception { schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); - // add back all the default languages (using "*" wildcard → empty list) + // add back all the default languages (using "*" wildcard -> empty list) response = schemaDesignerAPI.analyze( configSet, schemaVersion, null, null, List.of("*"), false, null, null); @@ -443,7 +443,8 @@ public void testBasicUserWorkflow() throws Exception { assertNotNull(response.unknownProperties().get("fields")); // update an existing field - // switch a single-valued field to a multi-valued field, which triggers a full rebuild + // switch a single-valued field to a multi-valued field, which triggers a full rebuild of the + // "temp" collection stream = new ContentStreamBase.FileStream(getFile("schema-designer/update-author-field.json")); stream.setContentType(JSON_MIME); when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); From de905296eb14e89a21cd696ab37b89a51b726e1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:23:45 +0000 Subject: [PATCH 04/69] Fix query() returning no results when sample docs not in memory (empty docs guard) Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../solr/handler/designer/SchemaDesignerAPI.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java index 7ae28e0fb79d..ffcdea1c6459 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java @@ -800,12 +800,14 @@ public FlexibleSolrJerseyResponse query(String configSet) throws Exception { version, currentVersion); List docs = configSetHelper.retrieveSampleDocs(configSet); - ManagedIndexSchema schema = loadLatestSchema(mutableId); - errorsDuringIndexing = - indexSampleDocsWithRebuildOnAnalysisError( - schema.getUniqueKeyField().getName(), docs, mutableId, true, null); - // the version changes when you index (due to field guessing URP) - currentVersion = configSetHelper.getCurrentSchemaVersion(mutableId); + if (!docs.isEmpty()) { + ManagedIndexSchema schema = loadLatestSchema(mutableId); + errorsDuringIndexing = + indexSampleDocsWithRebuildOnAnalysisError( + schema.getUniqueKeyField().getName(), docs, mutableId, true, null); + // the version changes when you index (due to field guessing URP) + currentVersion = configSetHelper.getCurrentSchemaVersion(mutableId); + } indexedVersion.put(mutableId, currentVersion); } From 912c76260ed2806d7b2ffdcab2f7ce8754734348 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:15:10 +0000 Subject: [PATCH 05/69] Fix schema designer download: correct URL, add Content-Disposition header, sanitize filename Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../solr/client/api/endpoint/SchemaDesignerApi.java | 4 ++-- .../solr/handler/designer/SchemaDesignerAPI.java | 11 +++++++++-- .../solr/handler/designer/TestSchemaDesignerAPI.java | 7 ++++++- .../web/js/angular/controllers/schema-designer.js | 4 ++-- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index 295a77f2d70a..d2705876e420 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -28,7 +28,7 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.core.StreamingOutput; +import jakarta.ws.rs.core.Response; import java.util.List; import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; import org.apache.solr.client.api.model.SolrJerseyResponse; @@ -112,7 +112,7 @@ FlexibleSolrJerseyResponse listCollectionsForConfig(@QueryParam("configSet") Str @Extension(properties = {@ExtensionProperty(name = RAW_OUTPUT_PROPERTY, value = "true")}) }) @Produces("application/zip") - StreamingOutput downloadConfig(@QueryParam("configSet") String configSet) throws Exception; + Response downloadConfig(@QueryParam("configSet") String configSet) throws Exception; @POST @Path("/add") diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java index ffcdea1c6459..c8b1917b99b9 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java @@ -23,6 +23,7 @@ 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.ByteArrayInputStream; import java.io.IOException; @@ -403,7 +404,7 @@ protected Map listEnabledConfigs() throws IOException { @Override @PermissionName(CONFIG_READ_PERM) - public StreamingOutput downloadConfig(String configSet) throws Exception { + public Response downloadConfig(String configSet) throws Exception { requireNotEmpty(CONFIG_SET_PARAM, configSet); String mutableId = getMutableId(configSet); @@ -424,7 +425,13 @@ public StreamingOutput downloadConfig(String configSet) throws Exception { } final byte[] zipBytes = configSetHelper.downloadAndZipConfigSet(configId); - return outputStream -> outputStream.write(zipBytes); + // Sanitize configSet to safe filename characters to prevent header injection + final String safeConfigSet = configSet.replaceAll("[^a-zA-Z0-9_\\-.]", "_"); + final String fileName = safeConfigSet + "_configset.zip"; + return Response.ok((StreamingOutput) outputStream -> outputStream.write(zipBytes)) + .type("application/zip") + .header("Content-Disposition", "attachment; filename=\"" + fileName + "\"") + .build(); } @Override diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java index 8fff2fec0652..21bd2eef32a8 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java @@ -497,7 +497,12 @@ public void testBasicUserWorkflow() throws Exception { // Download ZIP when(mockReq.getContentStreams()).thenReturn(null); - assertNotNull(schemaDesignerAPI.downloadConfig(configSet)); + jakarta.ws.rs.core.Response downloadResponse = schemaDesignerAPI.downloadConfig(configSet); + assertNotNull(downloadResponse); + assertEquals(200, downloadResponse.getStatus()); + assertTrue( + String.valueOf(downloadResponse.getHeaderString("Content-Disposition")) + .contains("_configset.zip")); // publish schema to a config set that can be used by real collections String collection = "test123"; diff --git a/solr/webapp/web/js/angular/controllers/schema-designer.js b/solr/webapp/web/js/angular/controllers/schema-designer.js index 07013446a279..f4572ec6d629 100644 --- a/solr/webapp/web/js/angular/controllers/schema-designer.js +++ b/solr/webapp/web/js/angular/controllers/schema-designer.js @@ -1526,7 +1526,7 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, if (sessionStorage.getItem("auth.header")) { var fileName = $scope.currentSchema+"_configset.zip"; var xhr = new XMLHttpRequest(); - xhr.open("GET", "/api/schema-designer/download/"+fileName+"?wt=raw&configSet="+$scope.currentSchema, true); + xhr.open("GET", "/api/schema-designer/download?configSet="+$scope.currentSchema, true); xhr.setRequestHeader('Authorization', sessionStorage.getItem("auth.header")); xhr.responseType = 'blob'; xhr.addEventListener('load',function() { @@ -1543,7 +1543,7 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, }) xhr.send(); } else { - location.href = "/api/schema-designer/download/"+$scope.currentSchema+"_configset.zip?wt=raw&configSet=" + $scope.currentSchema; + location.href = "/api/schema-designer/download?configSet=" + $scope.currentSchema; } }; From 7c16593a979bd43b6de5d1f03dd414d73ecdf2c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:49:54 +0000 Subject: [PATCH 06/69] Fix query response structure: wrap SolrDocumentList in numFound/start/docs map for JS rendering Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../handler/designer/SchemaDesignerAPI.java | 22 ++++++++++++++++--- .../designer/TestSchemaDesignerAPI.java | 21 +++++++++++++----- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java index c8b1917b99b9..daa78c208a93 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java @@ -60,6 +60,7 @@ import org.apache.solr.client.solrj.response.QueryResponse; import org.apache.solr.cloud.ZkConfigSetService; import org.apache.solr.cloud.ZkSolrResourceLoader; +import org.apache.solr.common.SolrDocumentList; import org.apache.solr.common.SolrException; import org.apache.solr.common.SolrInputDocument; import org.apache.solr.common.SolrInputField; @@ -826,9 +827,24 @@ public FlexibleSolrJerseyResponse query(String configSet) throws Exception { // execute the user's query against the temp collection QueryResponse qr = cloudClient().query(mutableId, solrQueryRequest.getParams()); - Map response = new HashMap<>(); - qr.getResponse().forEach((name, val) -> response.put(name, val)); - return buildFlexibleResponse(response); + Map responseMap = new HashMap<>(); + qr.getResponse() + .forEach( + (name, val) -> { + if ("response".equals(name) && val instanceof SolrDocumentList) { + // SolrDocumentList extends ArrayList, so Jackson would serialize it as a plain + // array, losing numFound/start metadata that the UI expects at data.response.docs + SolrDocumentList docList = (SolrDocumentList) val; + Map responseObj = new HashMap<>(); + responseObj.put("numFound", docList.getNumFound()); + responseObj.put("start", docList.getStart()); + responseObj.put("docs", new ArrayList<>(docList)); + responseMap.put(name, responseObj); + } else { + responseMap.put(name, val); + } + }); + return buildFlexibleResponse(responseMap); } /** diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java index 21bd2eef32a8..58bc0e34b06c 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java @@ -37,7 +37,6 @@ import org.apache.solr.client.solrj.request.SolrQuery; import org.apache.solr.client.solrj.response.QueryResponse; import org.apache.solr.cloud.SolrCloudTestCase; -import org.apache.solr.common.SolrDocumentList; import org.apache.solr.common.SolrException; import org.apache.solr.common.cloud.SolrZkClient; import org.apache.solr.common.params.CommonParams; @@ -219,8 +218,15 @@ public void testAddTechproductsProgressively() throws Exception { // GET /schema-designer/query response = schemaDesignerAPI.query(configSet); assertNotNull(response.unknownProperties().get("responseHeader")); - SolrDocumentList results = (SolrDocumentList) response.unknownProperties().get("response"); - assertEquals(47, results.getNumFound()); + @SuppressWarnings("unchecked") + Map queryResponse = + (Map) response.unknownProperties().get("response"); + assertNotNull("response object must be a map with numFound/docs", queryResponse); + assertEquals(47L, queryResponse.get("numFound")); + @SuppressWarnings("unchecked") + List queryDocs = (List) queryResponse.get("docs"); + assertNotNull("response.docs must be a list", queryDocs); + assertTrue("response.docs must be non-empty", queryDocs.size() > 0); // publish schema to a config set that can be used by real collections String collection = "techproducts"; @@ -492,8 +498,13 @@ public void testBasicUserWorkflow() throws Exception { // GET /schema-designer/query response = schemaDesignerAPI.query(configSet); assertNotNull(response.unknownProperties().get("responseHeader")); - SolrDocumentList results = (SolrDocumentList) response.unknownProperties().get("response"); - assertEquals(4, results.size()); + @SuppressWarnings("unchecked") + Map queryResponse2 = + (Map) response.unknownProperties().get("response"); + assertNotNull("response object must be a map with numFound/docs", queryResponse2); + @SuppressWarnings("unchecked") + List queryDocs2 = (List) queryResponse2.get("docs"); + assertEquals(4, queryDocs2.size()); // Download ZIP when(mockReq.getContentStreams()).thenReturn(null); From b382a4cea484f364b21e30076b17430be7a234f4 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 6 Mar 2026 12:30:50 -0500 Subject: [PATCH 07/69] Lint clean ups. "multivalued" is how we spell it ;-) Not "multi-valued" ;-) --- .../designer/DefaultSampleDocumentsLoader.java | 8 ++++---- .../handler/designer/DefaultSchemaSuggester.java | 15 +++++---------- .../solr/handler/designer/SampleDocuments.java | 2 +- .../solr/handler/designer/SchemaDesignerAPI.java | 2 +- .../designer/SchemaDesignerConfigSetHelper.java | 10 +++++----- .../handler/designer/SchemaDesignerConstants.java | 5 ----- .../handler/designer/SchemaDesignerSettings.java | 2 +- .../handler/designer/TestSchemaDesignerAPI.java | 4 ++-- 8 files changed, 19 insertions(+), 29 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/handler/designer/DefaultSampleDocumentsLoader.java b/solr/core/src/java/org/apache/solr/handler/designer/DefaultSampleDocumentsLoader.java index 921c72bfcdc8..dc60df984b1d 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/DefaultSampleDocumentsLoader.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/DefaultSampleDocumentsLoader.java @@ -101,7 +101,7 @@ public SampleDocuments parseDocsFromStream( + MAX_STREAM_SIZE + " bytes is the max upload size for sample documents."); } - // use a byte stream for the parsers in case they need to re-parse using a different strategy + // use a byte stream for the parsers in case they need to reparse using a different strategy // e.g. JSON vs. JSON lines or different CSV strategies ... ContentStreamBase.ByteArrayStream byteStream = new ContentStreamBase.ByteArrayStream(uploadedBytes, fileSource, contentType); @@ -161,7 +161,7 @@ protected List loadJsonLines( String line; while ((line = br.readLine()) != null) { line = line.trim(); - if (!line.isEmpty() && line.startsWith("{") && line.endsWith("}")) { + if (line.startsWith("{") && line.endsWith("}")) { Object jsonLine = ObjectBuilder.getVal(new JSONParser(line)); if (jsonLine instanceof Map) { docs.add((Map) jsonLine); @@ -203,7 +203,7 @@ protected List loadJsonDocs( if (lines.length > 1) { for (String line : lines) { line = line.trim(); - if (!line.isEmpty() && line.startsWith("{") && line.endsWith("}")) { + if (line.startsWith("{") && line.endsWith("}")) { isJsonLines = true; break; } @@ -298,7 +298,7 @@ protected List> loadJsonLines(String[] lines) throws IOExcep List> docs = new ArrayList<>(lines.length); for (String line : lines) { line = line.trim(); - if (!line.isEmpty() && line.startsWith("{") && line.endsWith("}")) { + if (line.startsWith("{") && line.endsWith("}")) { Object jsonLine = ObjectBuilder.getVal(new JSONParser(line)); if (jsonLine instanceof Map) { docs.add((Map) jsonLine); diff --git a/solr/core/src/java/org/apache/solr/handler/designer/DefaultSchemaSuggester.java b/solr/core/src/java/org/apache/solr/handler/designer/DefaultSchemaSuggester.java index 543b7a77af16..963cae662830 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/DefaultSchemaSuggester.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/DefaultSchemaSuggester.java @@ -170,8 +170,7 @@ public Optional suggestField( throw new IllegalStateException("FieldType '" + fieldTypeName + "' not found in the schema!"); } - Map fieldProps = - guessFieldProps(fieldName, fieldType, sampleValues, isMV, schema); + Map fieldProps = guessFieldProps(fieldName, fieldType, isMV, schema); SchemaField schemaField = schema.newField(fieldName, fieldTypeName, fieldProps); return Optional.of(schemaField); } @@ -179,9 +178,9 @@ public Optional suggestField( @Override public ManagedIndexSchema adaptExistingFieldToData( SchemaField schemaField, List sampleValues, ManagedIndexSchema schema) { - // Promote a single-valued to multi-valued if needed + // Promote a single-valued to multivalued if needed if (!schemaField.multiValued() && isMultiValued(sampleValues)) { - // this existing field needs to be promoted to multi-valued + // this existing field needs to be promoted to multivalued SimpleOrderedMap fieldProps = schemaField.getNamedPropertyValues(false); fieldProps.add("multiValued", true); fieldProps.remove("name"); @@ -210,7 +209,7 @@ public Map> transposeDocs(List docs) { Collection fieldValues = doc.getFieldValues(f); if (fieldValues != null && !fieldValues.isEmpty()) { if (fieldValues.size() == 1) { - // flatten so every field doesn't end up multi-valued + // flatten so every field doesn't end up multivalued values.add(fieldValues.iterator().next()); } else { // truly multi-valued @@ -395,11 +394,7 @@ protected boolean isMultiValued(final List sampleValues) { } protected Map guessFieldProps( - String fieldName, - FieldType fieldType, - List sampleValues, - boolean isMV, - IndexSchema schema) { + String fieldName, FieldType fieldType, boolean isMV, IndexSchema schema) { Map props = new HashMap<>(); props.put("indexed", "true"); diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SampleDocuments.java b/solr/core/src/java/org/apache/solr/handler/designer/SampleDocuments.java index b98c5995db28..6037db515241 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SampleDocuments.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SampleDocuments.java @@ -56,7 +56,7 @@ public List appendDocs( return id != null && !ids.contains(id); // doc has ID, and it's not already in the set }) - .collect(Collectors.toList()); + .toList(); parsed.addAll(toAdd); if (maxDocsToLoad > 0 && parsed.size() > maxDocsToLoad) { parsed = parsed.subList(0, maxDocsToLoad); diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java index daa78c208a93..8be794493af6 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java @@ -372,7 +372,7 @@ public FlexibleSolrJerseyResponse getSampleValue( @Override @PermissionName(CONFIG_READ_PERM) - public FlexibleSolrJerseyResponse listCollectionsForConfig(String configSet) throws Exception { + public FlexibleSolrJerseyResponse listCollectionsForConfig(String configSet) { requireNotEmpty(CONFIG_SET_PARAM, configSet); return buildFlexibleResponse( Collections.singletonMap( diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java index 90a7763a16e7..b90a11ab8026 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java @@ -383,7 +383,7 @@ boolean updateField( } } - // detect if they're trying to copy multi-valued fields into a single-valued field + // detect if they're trying to copy multivalued fields into a single-valued field Object multiValued = diff.get(MULTIVALUED); if (multiValued == null) { // mv not overridden explicitly, but we need the actual value, which will come from the new @@ -404,7 +404,7 @@ boolean updateField( name, src); multiValued = Boolean.TRUE; - diff.put(MULTIVALUED, multiValued); + diff.put(MULTIVALUED, true); break; } } @@ -415,8 +415,8 @@ boolean updateField( validateMultiValuedChange(configSet, schemaField, Boolean.FALSE); } - // switch from single-valued to multi-valued requires a full rebuild - // See SOLR-12185 ... if we're switching from single to multi-valued, then it's a big operation + // switch from single-valued to multivalued requires a full rebuild + // See SOLR-12185 ... if we're switching from single to multivalued, then it's a big operation if (fieldHasMultiValuedChange(multiValued, schemaField)) { needsRebuild = true; log.warn( @@ -709,7 +709,7 @@ boolean applyCopyFieldUpdates( continue; // cannot copy to self } - // make sure the field exists and is multi-valued if this field is + // make sure the field exists and is multivalued if this field is SchemaField toAddField = schema.getFieldOrNull(toAdd); if (toAddField != null) { if (!field.multiValued() || toAddField.multiValued()) { diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConstants.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConstants.java index 0ad93d90d27c..5cb31ec954a6 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConstants.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConstants.java @@ -21,18 +21,13 @@ public interface SchemaDesignerConstants { String CONFIG_SET_PARAM = "configSet"; String COPY_FROM_PARAM = "copyFrom"; String SCHEMA_VERSION_PARAM = "schemaVersion"; - String RELOAD_COLLECTIONS_PARAM = "reloadCollections"; - String INDEX_TO_COLLECTION_PARAM = "indexToCollection"; String NEW_COLLECTION_PARAM = "newCollection"; - String CLEANUP_TEMP_PARAM = "cleanupTemp"; String ENABLE_DYNAMIC_FIELDS_PARAM = "enableDynamicFields"; String ENABLE_FIELD_GUESSING_PARAM = "enableFieldGuessing"; String ENABLE_NESTED_DOCS_PARAM = "enableNestedDocs"; String TEMP_COLLECTION_PARAM = "tempCollection"; String PUBLISHED_VERSION = "publishedVersion"; - String DISABLE_DESIGNER_PARAM = "disableDesigner"; String DISABLED = "disabled"; - String DOC_ID_PARAM = "docId"; String FIELD_PARAM = "field"; String UNIQUE_KEY_FIELD_PARAM = "uniqueKeyField"; String AUTO_CREATE_FIELDS = "update.autoCreateFields"; diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerSettings.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerSettings.java index 0216d433ee56..53955e9a0395 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerSettings.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerSettings.java @@ -25,7 +25,7 @@ import java.util.Optional; import org.apache.solr.schema.ManagedIndexSchema; -class SchemaDesignerSettings implements SchemaDesignerConstants { +public class SchemaDesignerSettings implements SchemaDesignerConstants { private String copyFrom; private boolean isDisabled; diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java index 58bc0e34b06c..a64f8cfbce35 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java @@ -226,7 +226,7 @@ public void testAddTechproductsProgressively() throws Exception { @SuppressWarnings("unchecked") List queryDocs = (List) queryResponse.get("docs"); assertNotNull("response.docs must be a list", queryDocs); - assertTrue("response.docs must be non-empty", queryDocs.size() > 0); + assertFalse("response.docs must be non-empty", queryDocs.isEmpty()); // publish schema to a config set that can be used by real collections String collection = "techproducts"; @@ -449,7 +449,7 @@ public void testBasicUserWorkflow() throws Exception { assertNotNull(response.unknownProperties().get("fields")); // update an existing field - // switch a single-valued field to a multi-valued field, which triggers a full rebuild of the + // switch a single-valued field to a multivalued field, which triggers a full rebuild of the // "temp" collection stream = new ContentStreamBase.FileStream(getFile("schema-designer/update-author-field.json")); stream.setContentType(JSON_MIME); From 233162e28d4dae97fdc2ddb343ca272a29e1dc11 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Tue, 10 Mar 2026 05:48:41 -0400 Subject: [PATCH 08/69] code review and manual testing --- .../DefaultSampleDocumentsLoader.java | 31 +++++++++-------- .../SchemaDesignerConfigSetHelper.java | 33 +++---------------- 2 files changed, 19 insertions(+), 45 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/handler/designer/DefaultSampleDocumentsLoader.java b/solr/core/src/java/org/apache/solr/handler/designer/DefaultSampleDocumentsLoader.java index dc60df984b1d..4b5e3d61d3ac 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/DefaultSampleDocumentsLoader.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/DefaultSampleDocumentsLoader.java @@ -152,7 +152,6 @@ protected List loadCsvDocs( .loadDocs(stream); } - @SuppressWarnings("unchecked") protected List loadJsonLines( ContentStreamBase.ByteArrayStream stream, final int maxDocsToLoad) throws IOException { List> docs = new ArrayList<>(); @@ -160,13 +159,7 @@ protected List loadJsonLines( BufferedReader br = new BufferedReader(r); String line; while ((line = br.readLine()) != null) { - line = line.trim(); - if (line.startsWith("{") && line.endsWith("}")) { - Object jsonLine = ObjectBuilder.getVal(new JSONParser(line)); - if (jsonLine instanceof Map) { - docs.add((Map) jsonLine); - } - } + parseStringToJson(docs, line); if (maxDocsToLoad > 0 && docs.size() == maxDocsToLoad) { break; } @@ -176,6 +169,19 @@ protected List loadJsonLines( return docs.stream().map(JsonLoader::buildDoc).collect(Collectors.toList()); } + private void parseStringToJson(List> docs, String line) throws IOException { + line = line.trim(); + if (line.startsWith("{") && line.endsWith("}")) { + Object jsonLine = ObjectBuilder.getVal(new JSONParser(line)); + if (jsonLine instanceof Map rawMap) { + // JSON object keys are always Strings; the cast is safe + @SuppressWarnings("unchecked") + Map typedMap = (Map) rawMap; + docs.add(typedMap); + } + } + } + @SuppressWarnings("unchecked") protected List loadJsonDocs( ContentStreamBase.ByteArrayStream stream, final int maxDocsToLoad) throws IOException { @@ -293,17 +299,10 @@ protected List parseXmlDocs(XMLStreamReader parser, final int } } - @SuppressWarnings("unchecked") protected List> loadJsonLines(String[] lines) throws IOException { List> docs = new ArrayList<>(lines.length); for (String line : lines) { - line = line.trim(); - if (line.startsWith("{") && line.endsWith("}")) { - Object jsonLine = ObjectBuilder.getVal(new JSONParser(line)); - if (jsonLine instanceof Map) { - docs.add((Map) jsonLine); - } - } + parseStringToJson(docs, line); } return docs; } diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java index b90a11ab8026..40708f73e995 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java @@ -47,7 +47,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -74,7 +73,6 @@ import org.apache.solr.common.SolrException.ErrorCode; import org.apache.solr.common.SolrInputDocument; import org.apache.solr.common.cloud.DocCollection; -import org.apache.solr.common.cloud.Replica; import org.apache.solr.common.cloud.SolrZkClient; import org.apache.solr.common.cloud.ZkMaintenanceUtils; import org.apache.solr.common.cloud.ZkStateReader; @@ -536,29 +534,6 @@ static byte[] readAllBytes(IOSupplier hasStream) throws IOException } } - private String getBaseUrl(final String collection) { - String baseUrl = null; - try { - Set liveNodes = zkStateReader().getClusterState().getLiveNodes(); - DocCollection docColl = zkStateReader().getCollection(collection); - if (docColl != null && !liveNodes.isEmpty()) { - Optional maybeActive = - docColl.getReplicas().stream().filter(r -> r.isActive(liveNodes)).findAny(); - if (maybeActive.isPresent()) { - baseUrl = maybeActive.get().getBaseUrl(); - } - } - } catch (Exception exc) { - log.warn("Failed to lookup base URL for collection {}", collection, exc); - } - - if (baseUrl == null) { - baseUrl = zkStateReader().getBaseUrlForNodeName(cc.getZkController().getNodeName()); - } - - return baseUrl; - } - protected String getManagedSchemaZkPath(final String configSet) { return getConfigSetZkPath(configSet, DEFAULT_MANAGED_SCHEMA_RESOURCE_NAME); } @@ -819,8 +794,8 @@ protected ManagedIndexSchema removeLanguageSpecificObjectsAndFiles( final Set toRemove = types.values().stream() .filter(this::isTextType) - .filter(t -> !languages.contains(t.getTypeName().substring(TEXT_PREFIX_LEN))) .map(FieldType::getTypeName) + .filter(typeName -> !languages.contains(typeName.substring(TEXT_PREFIX_LEN))) .filter(t -> !usedTypes.contains(t)) // not explicitly used by a field .collect(Collectors.toSet()); @@ -961,9 +936,9 @@ protected ManagedIndexSchema restoreLanguageSpecificObjectsAndFiles( List addDynFields = Arrays.stream(copyFromSchema.getDynamicFields()) - .filter(df -> langFieldTypeNames.contains(df.getPrototype().getType().getTypeName())) - .filter(df -> !existingDynFields.contains(df.getPrototype().getName())) .map(IndexSchema.DynamicField::getPrototype) + .filter(prototype -> langFieldTypeNames.contains(prototype.getType().getTypeName())) + .filter(prototype -> !existingDynFields.contains(prototype.getName())) .collect(Collectors.toList()); if (!addDynFields.isEmpty()) { schema = schema.addDynamicFields(addDynFields, null, false); @@ -1035,8 +1010,8 @@ protected ManagedIndexSchema restoreDynamicFields( .collect(Collectors.toSet()); List toAdd = Arrays.stream(dynamicFields) - .filter(df -> !existingDFNames.contains(df.getPrototype().getName())) .map(IndexSchema.DynamicField::getPrototype) + .filter(prototype -> !existingDFNames.contains(prototype.getName())) .collect(Collectors.toList()); // only restore language specific dynamic fields that match our langSet From 11f5209ddcb4a780dc56942f6ac904400d17a674 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Tue, 10 Mar 2026 05:59:20 -0400 Subject: [PATCH 09/69] track change --- ...-18152-migrate-schemadesignerapi-to-v2-annotations.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog/unreleased/SOLR-18152-migrate-schemadesignerapi-to-v2-annotations.yml diff --git a/changelog/unreleased/SOLR-18152-migrate-schemadesignerapi-to-v2-annotations.yml b/changelog/unreleased/SOLR-18152-migrate-schemadesignerapi-to-v2-annotations.yml new file mode 100644 index 000000000000..f91837f7d7ce --- /dev/null +++ b/changelog/unreleased/SOLR-18152-migrate-schemadesignerapi-to-v2-annotations.yml @@ -0,0 +1,8 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc +title: Migrate Schema Designer API to JAX-RS. Fix bug preventing analysis of sample documents from running. +type: fixed # added, changed, fixed, deprecated, removed, dependency_update, security, other +authors: + - name: Eric Pugh +links: + - name: SOLR-18152 + url: https://issues.apache.org/jira/browse/SOLR-18152 From 6a46d4097f50c4aad2ec3663d4163853daf4e7f1 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Tue, 10 Mar 2026 06:06:10 -0400 Subject: [PATCH 10/69] Finally fix the visibility warning! --- .../apache/solr/handler/designer/SchemaDesignerAPI.java | 7 +++---- .../solr/handler/designer/SchemaDesignerSettings.java | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java index 8be794493af6..336b683701d2 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java @@ -935,7 +935,7 @@ protected ManagedIndexSchema analyzeInputDocs( return schema; } - protected SchemaDesignerSettings getMutableSchemaForConfigSet( + SchemaDesignerSettings getMutableSchemaForConfigSet( final String configSet, final int schemaVersion, String copyFrom) throws IOException { // The designer works with mutable config sets stored in a "temp" znode in ZK instead of the // "live" configSet @@ -1156,7 +1156,7 @@ protected long waitToSeeSampleDocs(String collectionName, long numAdded) return numFound; } - protected Map buildResponse( + Map buildResponse( String configSet, final ManagedIndexSchema schema, SchemaDesignerSettings settings, @@ -1310,8 +1310,7 @@ protected Map readJsonFromRequest() throws IOException { return (Map) json; } - protected void addSettingsToResponse( - SchemaDesignerSettings settings, final Map response) { + void addSettingsToResponse(SchemaDesignerSettings settings, final Map response) { response.put(LANGUAGES_PARAM, settings.getLanguages()); response.put(ENABLE_FIELD_GUESSING_PARAM, settings.fieldGuessingEnabled()); response.put(ENABLE_DYNAMIC_FIELDS_PARAM, settings.dynamicFieldsEnabled()); diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerSettings.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerSettings.java index 53955e9a0395..0216d433ee56 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerSettings.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerSettings.java @@ -25,7 +25,7 @@ import java.util.Optional; import org.apache.solr.schema.ManagedIndexSchema; -public class SchemaDesignerSettings implements SchemaDesignerConstants { +class SchemaDesignerSettings implements SchemaDesignerConstants { private String copyFrom; private boolean isDisabled; From bcb6a86b750a85354a8e416c47e3f09264fc1003 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Tue, 10 Mar 2026 06:36:35 -0400 Subject: [PATCH 11/69] Restore surfacing indexing errors. --- .../handler/designer/SchemaDesignerAPI.java | 11 +++- .../designer/TestSchemaDesignerAPI.java | 52 +++++++++++++++++++ .../js/angular/controllers/schema-designer.js | 5 ++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java index 336b683701d2..587109ffe8f3 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java @@ -820,9 +820,16 @@ public FlexibleSolrJerseyResponse query(String configSet) throws Exception { } if (errorsDuringIndexing != null) { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, + Map errorResponse = new HashMap<>(); + addErrorToResponse( + mutableId, + new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Failed to re-index sample documents after schema updated."), + errorsDuringIndexing, + errorResponse, "Failed to re-index sample documents after schema updated."); + return buildFlexibleResponse(errorResponse); } // execute the user's query against the temp collection diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java index a64f8cfbce35..5c40e71dc372 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java @@ -23,6 +23,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; @@ -34,10 +35,12 @@ import java.util.Optional; import java.util.stream.Stream; import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; +import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.request.SolrQuery; import org.apache.solr.client.solrj.response.QueryResponse; import org.apache.solr.cloud.SolrCloudTestCase; import org.apache.solr.common.SolrException; +import org.apache.solr.common.SolrInputDocument; import org.apache.solr.common.cloud.SolrZkClient; import org.apache.solr.common.params.CommonParams; import org.apache.solr.common.params.ModifiableSolrParams; @@ -736,6 +739,55 @@ public void testSchemaDiffEndpoint() throws Exception { assertNotNull(fieldTypesAdded.get("test_txt")); } + @Test + @SuppressWarnings("unchecked") + public void testQueryReturnsErrorDetailsOnIndexingFailure() throws Exception { + String configSet = "queryIndexErrTest"; + + // Prep the schema and analyze sample docs so the temp collection and stored docs exist + schemaDesignerAPI.prepNewSchema(configSet, null); + ContentStreamBase.StringStream stream = + new ContentStreamBase.StringStream( + "[{\"id\":\"doc1\",\"title\":\"test doc\"}]", JSON_MIME); + ModifiableSolrParams reqParams = new ModifiableSolrParams(); + reqParams.set(CONFIG_SET_PARAM, configSet); + when(mockReq.getParams()).thenReturn(reqParams); + when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); + schemaDesignerAPI.analyze(configSet, null, null, null, null, null, null, null); + + // Build a fresh API instance whose indexedVersion cache is empty (so it always + // attempts to re-index before running the query), and which simulates indexing errors. + Map fakeErrors = new HashMap<>(); + fakeErrors.put("doc1", new RuntimeException("simulated indexing failure")); + SchemaDesignerAPI apiWithErrors = + new SchemaDesignerAPI( + cc, + SchemaDesignerAPI.newSchemaSuggester(), + SchemaDesignerAPI.newSampleDocumentsLoader(), + mockReq) { + @Override + protected Map indexSampleDocsWithRebuildOnAnalysisError( + String idField, + List docs, + String collectionName, + boolean asBatch, + String[] analysisErrorHolder) + throws IOException, SolrServerException { + return fakeErrors; + } + }; + + when(mockReq.getContentStreams()).thenReturn(null); + FlexibleSolrJerseyResponse response = apiWithErrors.query(configSet); + + Map props = response.unknownProperties(); + assertNotNull("updateError must be present in error response", props.get(UPDATE_ERROR)); + assertEquals(400, props.get("updateErrorCode")); + Map details = (Map) props.get(ERROR_DETAILS); + assertNotNull("errorDetails must be present in error response", details); + assertTrue("errorDetails must contain the failing doc id", details.containsKey("doc1")); + } + protected void assertDesignerSettings(Map expected, Map actual) { for (String expKey : expected.keySet()) { Object expValue = expected.get(expKey); diff --git a/solr/webapp/web/js/angular/controllers/schema-designer.js b/solr/webapp/web/js/angular/controllers/schema-designer.js index f4572ec6d629..6b7176fb8c4d 100644 --- a/solr/webapp/web/js/angular/controllers/schema-designer.js +++ b/solr/webapp/web/js/angular/controllers/schema-designer.js @@ -1834,6 +1834,11 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, } SchemaDesigner.get(params, function (data) { + if (data.updateError != null) { + $scope.onError(data.updateError, data.updateErrorCode, data.errorDetails); + return; + } + $("#sort").trigger("chosen:updated"); $("#ff").trigger("chosen:updated"); $("#hl").trigger("chosen:updated"); From 115fccbd64c8b9ad66d93c2a1550692bf6aabf6f Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Tue, 10 Mar 2026 06:42:23 -0400 Subject: [PATCH 12/69] Fix error prone. --- .../apache/solr/handler/designer/TestSchemaDesignerAPI.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java index 5c40e71dc372..dbc922470407 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java @@ -23,6 +23,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import jakarta.ws.rs.core.Response; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -511,7 +512,7 @@ public void testBasicUserWorkflow() throws Exception { // Download ZIP when(mockReq.getContentStreams()).thenReturn(null); - jakarta.ws.rs.core.Response downloadResponse = schemaDesignerAPI.downloadConfig(configSet); + Response downloadResponse = schemaDesignerAPI.downloadConfig(configSet); assertNotNull(downloadResponse); assertEquals(200, downloadResponse.getStatus()); assertTrue( @@ -747,8 +748,7 @@ public void testQueryReturnsErrorDetailsOnIndexingFailure() throws Exception { // Prep the schema and analyze sample docs so the temp collection and stored docs exist schemaDesignerAPI.prepNewSchema(configSet, null); ContentStreamBase.StringStream stream = - new ContentStreamBase.StringStream( - "[{\"id\":\"doc1\",\"title\":\"test doc\"}]", JSON_MIME); + new ContentStreamBase.StringStream("[{\"id\":\"doc1\",\"title\":\"test doc\"}]", JSON_MIME); ModifiableSolrParams reqParams = new ModifiableSolrParams(); reqParams.set(CONFIG_SET_PARAM, configSet); when(mockReq.getParams()).thenReturn(reqParams); From 6756865160885ed1b1b0026282cfb03f3dc361cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:57:17 +0000 Subject: [PATCH 13/69] Fix requireSchemaVersion to also reject negative values (restores -1 sentinel contract) Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../handler/designer/SchemaDesignerAPI.java | 2 +- .../designer/TestSchemaDesignerAPI.java | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java index 587109ffe8f3..a94027fdcc60 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java @@ -1353,7 +1353,7 @@ protected String checkMutable(String configSet, int clientSchemaVersion) throws } protected void requireSchemaVersion(Integer schemaVersion) { - if (schemaVersion == null) { + if (schemaVersion == null || schemaVersion < 0) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, SCHEMA_VERSION_PARAM + " is a required parameter!"); } diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java index dbc922470407..9659373be09f 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java @@ -788,6 +788,28 @@ protected Map indexSampleDocsWithRebuildOnAnalysisError( assertTrue("errorDetails must contain the failing doc id", details.containsKey("doc1")); } + @Test + public void testRequireSchemaVersionRejectsNegativeValues() throws Exception { + String configSet = "schemaVersionValidation"; + schemaDesignerAPI.prepNewSchema(configSet, null); + + // null schemaVersion must be rejected + SolrException nullEx = + expectThrows(SolrException.class, () -> schemaDesignerAPI.addSchemaObject(configSet, null)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, nullEx.code()); + + // negative schemaVersion must be rejected (was previously bypassing validation) + SolrException negEx = + expectThrows(SolrException.class, () -> schemaDesignerAPI.addSchemaObject(configSet, -1)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, negEx.code()); + + // same contract must hold for updateSchemaObject + SolrException updateNegEx = + expectThrows( + SolrException.class, () -> schemaDesignerAPI.updateSchemaObject(configSet, -1)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, updateNegEx.code()); + } + protected void assertDesignerSettings(Map expected, Map actual) { for (String expKey : expected.keySet()) { Object expValue = expected.get(expKey); From c04147f0cf0e3236600c37e896ce43c3ef7bfbff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:20:50 +0000 Subject: [PATCH 14/69] Move configSet from query parameter to path parameter in Schema Designer API Agent-Logs-Url: https://github.com/epugh/solr/sessions/3e11a7a9-caca-4333-8a6b-bbcd5a2cd862 Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../api/endpoint/SchemaDesignerApi.java | 57 ++++++++++--------- .../js/angular/controllers/schema-designer.js | 4 +- solr/webapp/web/js/angular/services.js | 2 +- 3 files changed, 32 insertions(+), 31 deletions(-) diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index d2705876e420..4df7c100316e 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -26,6 +26,7 @@ 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.Response; @@ -38,62 +39,62 @@ public interface SchemaDesignerApi { @GET - @Path("/info") + @Path("/{configSet}/info") @Operation( summary = "Get info about a configSet being designed.", tags = {"schema-designer"}) - FlexibleSolrJerseyResponse getInfo(@QueryParam("configSet") String configSet) throws Exception; + FlexibleSolrJerseyResponse getInfo(@PathParam("configSet") String configSet) throws Exception; @POST - @Path("/prep") + @Path("/{configSet}/prep") @Operation( summary = "Prepare a mutable configSet copy for schema design.", tags = {"schema-designer"}) FlexibleSolrJerseyResponse prepNewSchema( - @QueryParam("configSet") String configSet, @QueryParam("copyFrom") String copyFrom) + @PathParam("configSet") String configSet, @QueryParam("copyFrom") String copyFrom) throws Exception; @PUT - @Path("/cleanup") + @Path("/{configSet}/cleanup") @Operation( summary = "Clean up temporary resources for a schema being designed.", tags = {"schema-designer"}) - SolrJerseyResponse cleanupTempSchema(@QueryParam("configSet") String configSet) throws Exception; + SolrJerseyResponse cleanupTempSchema(@PathParam("configSet") String configSet) throws Exception; @GET - @Path("/file") + @Path("/{configSet}/file") @Operation( summary = "Get the contents of a file in a configSet being designed.", tags = {"schema-designer"}) FlexibleSolrJerseyResponse getFileContents( - @QueryParam("configSet") String configSet, @QueryParam("file") String file) throws Exception; + @PathParam("configSet") String configSet, @QueryParam("file") String file) throws Exception; @POST - @Path("/file") + @Path("/{configSet}/file") @Operation( summary = "Update the contents of a file in a configSet being designed.", tags = {"schema-designer"}) FlexibleSolrJerseyResponse updateFileContents( - @QueryParam("configSet") String configSet, @QueryParam("file") String file) throws Exception; + @PathParam("configSet") String configSet, @QueryParam("file") String file) throws Exception; @GET - @Path("/sample") + @Path("/{configSet}/sample") @Operation( summary = "Get a sample value and analysis for a field.", tags = {"schema-designer"}) FlexibleSolrJerseyResponse getSampleValue( - @QueryParam("configSet") String configSet, + @PathParam("configSet") String configSet, @QueryParam("field") String fieldName, @QueryParam("uniqueKeyField") String idField, @QueryParam("docId") String docId) throws Exception; @GET - @Path("/collectionsForConfig") + @Path("/{configSet}/collectionsForConfig") @Operation( summary = "List collections that use a given configSet.", tags = {"schema-designer"}) - FlexibleSolrJerseyResponse listCollectionsForConfig(@QueryParam("configSet") String configSet) + FlexibleSolrJerseyResponse listCollectionsForConfig(@PathParam("configSet") String configSet) throws Exception; @GET @@ -104,7 +105,7 @@ FlexibleSolrJerseyResponse listCollectionsForConfig(@QueryParam("configSet") Str FlexibleSolrJerseyResponse listConfigs() throws Exception; @GET - @Path("/download") + @Path("/{configSet}/download") @Operation( summary = "Download a configSet as a ZIP archive.", tags = {"schema-designer"}, @@ -112,33 +113,33 @@ FlexibleSolrJerseyResponse listCollectionsForConfig(@QueryParam("configSet") Str @Extension(properties = {@ExtensionProperty(name = RAW_OUTPUT_PROPERTY, value = "true")}) }) @Produces("application/zip") - Response downloadConfig(@QueryParam("configSet") String configSet) throws Exception; + Response downloadConfig(@PathParam("configSet") String configSet) throws Exception; @POST - @Path("/add") + @Path("/{configSet}/add") @Operation( summary = "Add a new field, field type, or dynamic field to the schema being designed.", tags = {"schema-designer"}) FlexibleSolrJerseyResponse addSchemaObject( - @QueryParam("configSet") String configSet, @QueryParam("schemaVersion") Integer schemaVersion) + @PathParam("configSet") String configSet, @QueryParam("schemaVersion") Integer schemaVersion) throws Exception; @PUT - @Path("/update") + @Path("/{configSet}/update") @Operation( summary = "Update an existing field or field type in the schema being designed.", tags = {"schema-designer"}) FlexibleSolrJerseyResponse updateSchemaObject( - @QueryParam("configSet") String configSet, @QueryParam("schemaVersion") Integer schemaVersion) + @PathParam("configSet") String configSet, @QueryParam("schemaVersion") Integer schemaVersion) throws Exception; @PUT - @Path("/publish") + @Path("/{configSet}/publish") @Operation( summary = "Publish the designed schema to a live configSet.", tags = {"schema-designer"}) FlexibleSolrJerseyResponse publish( - @QueryParam("configSet") String configSet, + @PathParam("configSet") String configSet, @QueryParam("schemaVersion") Integer schemaVersion, @QueryParam("newCollection") String newCollection, @QueryParam("reloadCollections") @DefaultValue("false") Boolean reloadCollections, @@ -150,12 +151,12 @@ FlexibleSolrJerseyResponse publish( throws Exception; @POST - @Path("/analyze") + @Path("/{configSet}/analyze") @Operation( summary = "Analyze sample documents and suggest a schema.", tags = {"schema-designer"}) FlexibleSolrJerseyResponse analyze( - @QueryParam("configSet") String configSet, + @PathParam("configSet") String configSet, @QueryParam("schemaVersion") Integer schemaVersion, @QueryParam("copyFrom") String copyFrom, @QueryParam("uniqueKeyField") String uniqueKeyField, @@ -166,17 +167,17 @@ FlexibleSolrJerseyResponse analyze( throws Exception; @GET - @Path("/query") + @Path("/{configSet}/query") @Operation( summary = "Query the temporary collection used during schema design.", tags = {"schema-designer"}) - FlexibleSolrJerseyResponse query(@QueryParam("configSet") String configSet) throws Exception; + FlexibleSolrJerseyResponse query(@PathParam("configSet") String configSet) throws Exception; @GET - @Path("/diff") + @Path("/{configSet}/diff") @Operation( summary = "Get the diff between the designed schema and the published schema.", tags = {"schema-designer"}) - FlexibleSolrJerseyResponse getSchemaDiff(@QueryParam("configSet") String configSet) + FlexibleSolrJerseyResponse getSchemaDiff(@PathParam("configSet") String configSet) throws Exception; } diff --git a/solr/webapp/web/js/angular/controllers/schema-designer.js b/solr/webapp/web/js/angular/controllers/schema-designer.js index 6b7176fb8c4d..0ab1c52a6db3 100644 --- a/solr/webapp/web/js/angular/controllers/schema-designer.js +++ b/solr/webapp/web/js/angular/controllers/schema-designer.js @@ -1526,7 +1526,7 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, if (sessionStorage.getItem("auth.header")) { var fileName = $scope.currentSchema+"_configset.zip"; var xhr = new XMLHttpRequest(); - xhr.open("GET", "/api/schema-designer/download?configSet="+$scope.currentSchema, true); + xhr.open("GET", "/api/schema-designer/"+$scope.currentSchema+"/download", true); xhr.setRequestHeader('Authorization', sessionStorage.getItem("auth.header")); xhr.responseType = 'blob'; xhr.addEventListener('load',function() { @@ -1543,7 +1543,7 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, }) xhr.send(); } else { - location.href = "/api/schema-designer/download?configSet=" + $scope.currentSchema; + location.href = "/api/schema-designer/"+$scope.currentSchema+"/download"; } }; diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index 98e7e37d9baa..6dc453a2e515 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -271,7 +271,7 @@ solrAdminServices.factory('System', }]) .factory('SchemaDesigner', ['$resource', function($resource) { - return $resource('/api/schema-designer/:path', {wt: 'json', path: '@path', _:Date.now()}, { + return $resource('/api/schema-designer/:configSet/:path', {wt: 'json', path: '@path', configSet: '@configSet', _:Date.now()}, { get: {method: "GET"}, post: {method: "POST", timeout: 90000}, put: {method: "PUT"}, From 7f6274c0aab506ffc394e3f55ebf6bcf9a330fcd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 30 Mar 2026 01:33:08 +0000 Subject: [PATCH 15/69] Use correct HTTP verbs in Schema Designer API (DELETE and PUT) Agent-Logs-Url: https://github.com/epugh/solr/sessions/8f08e90a-243e-43eb-9aaf-070c2e9ed092 Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../apache/solr/client/api/endpoint/SchemaDesignerApi.java | 7 ++++--- solr/webapp/web/js/angular/controllers/schema-designer.js | 2 +- solr/webapp/web/js/angular/services.js | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index 4df7c100316e..f67f68cbdb24 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -21,6 +21,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.extensions.Extension; import io.swagger.v3.oas.annotations.extensions.ExtensionProperty; +import jakarta.ws.rs.DELETE; import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; @@ -54,8 +55,8 @@ FlexibleSolrJerseyResponse prepNewSchema( @PathParam("configSet") String configSet, @QueryParam("copyFrom") String copyFrom) throws Exception; - @PUT - @Path("/{configSet}/cleanup") + @DELETE + @Path("/{configSet}") @Operation( summary = "Clean up temporary resources for a schema being designed.", tags = {"schema-designer"}) @@ -69,7 +70,7 @@ FlexibleSolrJerseyResponse prepNewSchema( FlexibleSolrJerseyResponse getFileContents( @PathParam("configSet") String configSet, @QueryParam("file") String file) throws Exception; - @POST + @PUT @Path("/{configSet}/file") @Operation( summary = "Update the contents of a file in a configSet being designed.", diff --git a/solr/webapp/web/js/angular/controllers/schema-designer.js b/solr/webapp/web/js/angular/controllers/schema-designer.js index 0ab1c52a6db3..287668f783cd 100644 --- a/solr/webapp/web/js/angular/controllers/schema-designer.js +++ b/solr/webapp/web/js/angular/controllers/schema-designer.js @@ -887,7 +887,7 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $scope.updateWorking = true; $scope.updateStatusMessage = "Updating file ..."; - SchemaDesigner.post(params, $scope.fileNodeText, function (data) { + SchemaDesigner.put(params, $scope.fileNodeText, function (data) { if (data.updateFileError) { if (data[$scope.selectedFile]) { $scope.fileNodeText = data[$scope.selectedFile]; diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index 6dc453a2e515..7882630c67ee 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -275,6 +275,7 @@ solrAdminServices.factory('System', get: {method: "GET"}, post: {method: "POST", timeout: 90000}, put: {method: "PUT"}, + delete: {method: "DELETE"}, postXml: {headers: {'Content-type': 'text/xml'}, method: "POST", timeout: 90000}, postCsv: {headers: {'Content-type': 'application/csv'}, method: "POST", timeout: 90000}, upload: {method: "POST", transformRequest: angular.identity, headers: {'Content-Type': undefined}, timeout: 90000} From d05be2f90c004fb02659200228e556e4f430db9a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:08:58 +0000 Subject: [PATCH 16/69] Move downloadConfig to reusable ConfigsetsApi.Download / DownloadConfigSet Agent-Logs-Url: https://github.com/epugh/solr/sessions/b4bd2d0e-f388-4fa4-8e0a-6df9ef796863 Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../client/api/endpoint/ConfigsetsApi.java | 25 +++ .../solr/handler/admin/ConfigSetsHandler.java | 7 +- .../handler/configsets/DownloadConfigSet.java | 139 +++++++++++++++++ .../handler/designer/SchemaDesignerAPI.java | 14 +- .../SchemaDesignerConfigSetHelper.java | 56 ------- .../configsets/DownloadConfigSetAPITest.java | 146 ++++++++++++++++++ .../TestSchemaDesignerConfigSetHelper.java | 6 +- 7 files changed, 324 insertions(+), 69 deletions(-) create mode 100644 solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java create mode 100644 solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.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..3e6f21e9d58e 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,7 +28,9 @@ 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.Response; import java.io.IOException; import java.io.InputStream; import org.apache.solr.client.api.model.CloneConfigsetRequestBody; @@ -71,6 +77,25 @@ 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) throws Exception; + } + /** * V2 API definitions for uploading a configset, in whole or part. * 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..afd37d653ab8 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,7 @@ 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.ListConfigSets; import org.apache.solr.handler.configsets.UploadConfigSet; import org.apache.solr.request.SolrQueryRequest; @@ -187,7 +188,11 @@ 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); } @Override 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..50ee839597fe --- /dev/null +++ b/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java @@ -0,0 +1,139 @@ +/* + * 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 {@link ConfigsetsApi.Download}. */ +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) 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!"); + } + return buildZipResponse(configSetService, configSetName, configSetName); + } + + /** + * 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/designer/SchemaDesignerAPI.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java index f56e742f53d0..32e4d61b4008 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java @@ -24,7 +24,6 @@ import jakarta.inject.Inject; import jakarta.ws.rs.core.Response; -import jakarta.ws.rs.core.StreamingOutput; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -76,6 +75,7 @@ import org.apache.solr.core.CoreContainer; import org.apache.solr.core.SolrConfig; import org.apache.solr.core.SolrResourceLoader; +import org.apache.solr.handler.configsets.DownloadConfigSet; import org.apache.solr.jersey.PermissionName; import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.schema.ManagedIndexSchema; @@ -408,7 +408,7 @@ public Response downloadConfig(String configSet) throws Exception { requireNotEmpty(CONFIG_SET_PARAM, configSet); String mutableId = getMutableId(configSet); - // find the configSet to download + // find the configSet to download: prefer the mutable designer copy, fall back to production SolrZkClient zkClient = zkStateReader().getZkClient(); String configId = mutableId; try { @@ -424,14 +424,8 @@ public Response downloadConfig(String configSet) throws Exception { throw new IOException("Error reading config from ZK", SolrZkClient.checkInterrupted(e)); } - final byte[] zipBytes = configSetHelper.downloadAndZipConfigSet(configId); - // Sanitize configSet to safe filename characters to prevent header injection - final String safeConfigSet = configSet.replaceAll("[^a-zA-Z0-9_\\-.]", "_"); - final String fileName = safeConfigSet + "_configset.zip"; - return Response.ok((StreamingOutput) outputStream -> outputStream.write(zipBytes)) - .type("application/zip") - .header("Content-Disposition", "attachment; filename=\"" + fileName + "\"") - .build(); + return DownloadConfigSet.buildZipResponse( + coreContainer.getConfigSetService(), configId, configSet); } @Override diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java index 4cc3f7e14637..0d75e9f759ee 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java @@ -26,18 +26,12 @@ import static org.apache.solr.schema.IndexSchema.ROOT_FIELD_NAME; import static org.apache.solr.schema.ManagedIndexSchemaFactory.DEFAULT_MANAGED_SCHEMA_RESOURCE_NAME; -import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.lang.invoke.MethodHandles; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; -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.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -52,10 +46,6 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; -import org.apache.commons.io.FilenameUtils; -import org.apache.commons.io.file.PathUtils; import org.apache.lucene.util.IOSupplier; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.SolrResponse; @@ -1073,52 +1063,6 @@ List listConfigsInZk() throws IOException { return cc.getConfigSetService().listConfigs(); } - byte[] downloadAndZipConfigSet(String configId) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - Path tmpDirectory = - Files.createTempDirectory("schema-designer-" + FilenameUtils.getName(configId)); - try { - cc.getConfigSetService().downloadConfig(configId, 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.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(); - } - protected ZkSolrResourceLoader zkLoaderForConfigSet(final String configSet) { SolrResourceLoader loader = cc.getResourceLoader(); return new ZkSolrResourceLoader( 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..ffc7cce19d79 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java @@ -0,0 +1,146 @@ +/* + * 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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import jakarta.ws.rs.core.Response; +import java.io.IOException; +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.ConfigSetService; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.response.SolrQueryResponse; +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 ConfigSetService mockConfigSetService; + private SolrQueryRequest mockRequest; + private SolrQueryResponse mockResponse; + + @BeforeClass + public static void ensureWorkingMockito() { + assumeWorkingMockito(); + } + + @Before + public void setUpMocks() { + mockCoreContainer = mock(CoreContainer.class); + mockConfigSetService = mock(ConfigSetService.class); + mockRequest = mock(SolrQueryRequest.class); + mockResponse = mock(SolrQueryResponse.class); + when(mockCoreContainer.getConfigSetService()).thenReturn(mockConfigSetService); + } + + @Test + public void testMissingConfigSetNameThrowsBadRequest() { + final var api = new DownloadConfigSet(mockCoreContainer, mockRequest, mockResponse); + final var ex = assertThrows(SolrException.class, () -> api.downloadConfigSet(null)); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + + final var ex2 = assertThrows(SolrException.class, () -> api.downloadConfigSet("")); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex2.code()); + } + + @Test + public void testNonExistentConfigSetThrowsNotFound() throws Exception { + when(mockConfigSetService.checkConfigExists("missing")).thenReturn(false); + + final var api = new DownloadConfigSet(mockCoreContainer, mockRequest, mockResponse); + final var ex = assertThrows(SolrException.class, () -> api.downloadConfigSet("missing")); + assertEquals(SolrException.ErrorCode.NOT_FOUND.code, ex.code()); + } + + /** Stubs {@code configSetService.downloadConfig(configSetId, dir)} to write one file. */ + private void stubDownloadConfig(String configSetId, String fileName, String content) + throws IOException { + doAnswer( + inv -> { + Path dir = inv.getArgument(1); + Files.writeString(dir.resolve(fileName), content, StandardCharsets.UTF_8); + return null; + }) + .when(mockConfigSetService) + .downloadConfig(eq(configSetId), any(Path.class)); + } + + @Test + public void testSuccessfulDownloadReturnsZipResponse() throws Exception { + when(mockConfigSetService.checkConfigExists("myconfig")).thenReturn(true); + stubDownloadConfig("myconfig", "solrconfig.xml", ""); + + final var api = new DownloadConfigSet(mockCoreContainer, mockRequest, mockResponse); + final Response response = api.downloadConfigSet("myconfig"); + + 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 { + final String unsafeName = "my/config"; + when(mockConfigSetService.checkConfigExists(unsafeName)).thenReturn(true); + stubDownloadConfig(unsafeName, "schema.xml", ""); + + final var api = new DownloadConfigSet(mockCoreContainer, mockRequest, mockResponse); + final Response response = api.downloadConfigSet(unsafeName); + + assertNotNull(response); + final String disposition = response.getHeaderString("Content-Disposition"); + assertFalse( + "filename must not contain unsafe characters", + disposition.contains("/") || disposition.contains("<") || disposition.contains(">")); + assertTrue(disposition.contains("_configset.zip")); + } + + @Test + public void testBuildZipResponseUsesDisplayName() throws IOException { + stubDownloadConfig("_designer_films", "schema.xml", ""); + + final Response response = + DownloadConfigSet.buildZipResponse(mockConfigSetService, "_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/designer/TestSchemaDesignerConfigSetHelper.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java index 4552a8a65bc8..4347b19de822 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java @@ -38,6 +38,7 @@ import org.apache.solr.core.CoreContainer; import org.apache.solr.core.SolrConfig; import org.apache.solr.filestore.FileStore; +import org.apache.solr.handler.configsets.DownloadConfigSet; import org.apache.solr.schema.FieldType; import org.apache.solr.schema.ManagedIndexSchema; import org.apache.solr.schema.SchemaField; @@ -113,13 +114,14 @@ public void testSetupMutable() throws Exception { configSet, schema, List.of(), true, DEFAULT_CONFIGSET_NAME); assertEquals(2, schema.getSchemaZkVersion()); - byte[] zipped = helper.downloadAndZipConfigSet(mutableId); + byte[] zipped = DownloadConfigSet.zipConfigSet(cc.getConfigSetService(), mutableId); assertTrue(zipped != null && zipped.length > 0); } @Test public void testDownloadAndZip() throws IOException { - byte[] zipped = helper.downloadAndZipConfigSet(DEFAULT_CONFIGSET_NAME); + byte[] zipped = + DownloadConfigSet.zipConfigSet(cc.getConfigSetService(), DEFAULT_CONFIGSET_NAME); ZipInputStream stream = new ZipInputStream(new ByteArrayInputStream(zipped)); boolean foundSolrConfig = false; From 0881dba825029a7875e5cd9bd0e13dbb6476fd78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:47:27 +0000 Subject: [PATCH 17/69] Move schema-designer download to generic configsets endpoint; add displayName query param Agent-Logs-Url: https://github.com/epugh/solr/sessions/eff3e08e-97a3-4e83-832c-74cc41e9058d Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../client/api/endpoint/ConfigsetsApi.java | 5 +++- .../api/endpoint/SchemaDesignerApi.java | 17 ----------- .../handler/configsets/DownloadConfigSet.java | 6 ++-- .../handler/designer/SchemaDesignerAPI.java | 28 ----------------- .../configsets/DownloadConfigSetAPITest.java | 30 +++++++++++++++---- .../designer/TestSchemaDesignerAPI.java | 10 ------- .../js/angular/controllers/schema-designer.js | 6 ++-- 7 files changed, 37 insertions(+), 65 deletions(-) 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 3e6f21e9d58e..a1152e2b86a4 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 @@ -93,7 +93,10 @@ interface Download { @Extension(properties = {@ExtensionProperty(name = RAW_OUTPUT_PROPERTY, value = "true")}) }) @Produces("application/zip") - Response downloadConfigSet(@PathParam("configSetName") String configSetName) throws Exception; + Response downloadConfigSet( + @PathParam("configSetName") String configSetName, + @QueryParam("displayName") String displayName) + throws Exception; } /** diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index f67f68cbdb24..ba28278c6669 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -16,11 +16,7 @@ */ 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 jakarta.ws.rs.DELETE; import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; @@ -28,9 +24,7 @@ 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.Response; import java.util.List; import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; import org.apache.solr.client.api.model.SolrJerseyResponse; @@ -105,17 +99,6 @@ FlexibleSolrJerseyResponse listCollectionsForConfig(@PathParam("configSet") Stri tags = {"schema-designer"}) FlexibleSolrJerseyResponse listConfigs() throws Exception; - @GET - @Path("/{configSet}/download") - @Operation( - summary = "Download a configSet as a ZIP archive.", - tags = {"schema-designer"}, - extensions = { - @Extension(properties = {@ExtensionProperty(name = RAW_OUTPUT_PROPERTY, value = "true")}) - }) - @Produces("application/zip") - Response downloadConfig(@PathParam("configSet") String configSet) throws Exception; - @POST @Path("/{configSet}/add") @Operation( 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 index 50ee839597fe..872e7ebd4613 100644 --- a/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java +++ b/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java @@ -54,7 +54,7 @@ public DownloadConfigSet( @Override @PermissionName(CONFIG_READ_PERM) - public Response downloadConfigSet(String configSetName) throws Exception { + 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"); @@ -63,7 +63,9 @@ public Response downloadConfigSet(String configSetName) throws Exception { throw new SolrException( SolrException.ErrorCode.NOT_FOUND, "ConfigSet " + configSetName + " not found!"); } - return buildZipResponse(configSetService, configSetName, configSetName); + final String resolvedDisplayName = + StrUtils.isNullOrEmpty(displayName) ? configSetName : displayName; + return buildZipResponse(configSetService, configSetName, resolvedDisplayName); } /** diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java index 32e4d61b4008..60e89dde40a3 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java @@ -23,7 +23,6 @@ import static org.apache.solr.security.PermissionNameProvider.Name.CONFIG_READ_PERM; import jakarta.inject.Inject; -import jakarta.ws.rs.core.Response; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -75,7 +74,6 @@ import org.apache.solr.core.CoreContainer; import org.apache.solr.core.SolrConfig; import org.apache.solr.core.SolrResourceLoader; -import org.apache.solr.handler.configsets.DownloadConfigSet; import org.apache.solr.jersey.PermissionName; import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.schema.ManagedIndexSchema; @@ -402,32 +400,6 @@ protected Map listEnabledConfigs() throws IOException { return configs; } - @Override - @PermissionName(CONFIG_READ_PERM) - public Response downloadConfig(String configSet) throws Exception { - requireNotEmpty(CONFIG_SET_PARAM, configSet); - String mutableId = getMutableId(configSet); - - // find the configSet to download: prefer the mutable designer copy, fall back to production - SolrZkClient zkClient = zkStateReader().getZkClient(); - String configId = mutableId; - try { - if (!zkClient.exists(getConfigSetZkPath(mutableId, null))) { - if (zkClient.exists(getConfigSetZkPath(configSet, null))) { - configId = configSet; - } else { - throw new SolrException( - SolrException.ErrorCode.NOT_FOUND, "ConfigSet " + configSet + " not found!"); - } - } - } catch (KeeperException | InterruptedException e) { - throw new IOException("Error reading config from ZK", SolrZkClient.checkInterrupted(e)); - } - - return DownloadConfigSet.buildZipResponse( - coreContainer.getConfigSetService(), configId, configSet); - } - @Override @PermissionName(CONFIG_EDIT_PERM) public FlexibleSolrJerseyResponse addSchemaObject(String configSet, Integer schemaVersion) 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 index ffc7cce19d79..5f917ed46291 100644 --- a/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java +++ b/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java @@ -64,10 +64,10 @@ public void setUpMocks() { @Test public void testMissingConfigSetNameThrowsBadRequest() { final var api = new DownloadConfigSet(mockCoreContainer, mockRequest, mockResponse); - final var ex = assertThrows(SolrException.class, () -> api.downloadConfigSet(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("")); + final var ex2 = assertThrows(SolrException.class, () -> api.downloadConfigSet("", null)); assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex2.code()); } @@ -76,7 +76,7 @@ public void testNonExistentConfigSetThrowsNotFound() throws Exception { when(mockConfigSetService.checkConfigExists("missing")).thenReturn(false); final var api = new DownloadConfigSet(mockCoreContainer, mockRequest, mockResponse); - final var ex = assertThrows(SolrException.class, () -> api.downloadConfigSet("missing")); + final var ex = assertThrows(SolrException.class, () -> api.downloadConfigSet("missing", null)); assertEquals(SolrException.ErrorCode.NOT_FOUND.code, ex.code()); } @@ -99,7 +99,7 @@ public void testSuccessfulDownloadReturnsZipResponse() throws Exception { stubDownloadConfig("myconfig", "solrconfig.xml", ""); final var api = new DownloadConfigSet(mockCoreContainer, mockRequest, mockResponse); - final Response response = api.downloadConfigSet("myconfig"); + final Response response = api.downloadConfigSet("myconfig", null); assertNotNull(response); assertEquals(200, response.getStatus()); @@ -116,7 +116,7 @@ public void testFilenameIsSanitized() throws Exception { stubDownloadConfig(unsafeName, "schema.xml", ""); final var api = new DownloadConfigSet(mockCoreContainer, mockRequest, mockResponse); - final Response response = api.downloadConfigSet(unsafeName); + final Response response = api.downloadConfigSet(unsafeName, null); assertNotNull(response); final String disposition = response.getHeaderString("Content-Disposition"); @@ -126,6 +126,26 @@ public void testFilenameIsSanitized() throws Exception { assertTrue(disposition.contains("_configset.zip")); } + @Test + public void testDisplayNameOverridesFilename() throws Exception { + final String mutableId = "._designer_films"; + when(mockConfigSetService.checkConfigExists(mutableId)).thenReturn(true); + stubDownloadConfig(mutableId, "schema.xml", ""); + + final var api = new DownloadConfigSet(mockCoreContainer, mockRequest, mockResponse); + 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 IOException { stubDownloadConfig("_designer_films", "schema.xml", ""); diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java index c7bd500eea42..4b49cc29f33d 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java @@ -23,7 +23,6 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import jakarta.ws.rs.core.Response; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -510,15 +509,6 @@ public void testBasicUserWorkflow() throws Exception { List queryDocs2 = (List) queryResponse2.get("docs"); assertEquals(4, queryDocs2.size()); - // Download ZIP - when(mockReq.getContentStreams()).thenReturn(null); - Response downloadResponse = schemaDesignerAPI.downloadConfig(configSet); - assertNotNull(downloadResponse); - assertEquals(200, downloadResponse.getStatus()); - assertTrue( - String.valueOf(downloadResponse.getHeaderString("Content-Disposition")) - .contains("_configset.zip")); - // publish schema to a config set that can be used by real collections String collection = "test123"; schemaDesignerAPI.publish(configSet, schemaVersion, collection, true, 1, 1, true, true, false); diff --git a/solr/webapp/web/js/angular/controllers/schema-designer.js b/solr/webapp/web/js/angular/controllers/schema-designer.js index 287668f783cd..bab782a48093 100644 --- a/solr/webapp/web/js/angular/controllers/schema-designer.js +++ b/solr/webapp/web/js/angular/controllers/schema-designer.js @@ -1523,10 +1523,12 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $scope.downloadConfig = function () { // have to use an AJAX request so we can supply the Authorization header + var mutableId = "._designer_" + $scope.currentSchema; + var downloadUrl = "/api/configsets/" + encodeURIComponent(mutableId) + "/download?displayName=" + encodeURIComponent($scope.currentSchema); if (sessionStorage.getItem("auth.header")) { var fileName = $scope.currentSchema+"_configset.zip"; var xhr = new XMLHttpRequest(); - xhr.open("GET", "/api/schema-designer/"+$scope.currentSchema+"/download", true); + xhr.open("GET", downloadUrl, true); xhr.setRequestHeader('Authorization', sessionStorage.getItem("auth.header")); xhr.responseType = 'blob'; xhr.addEventListener('load',function() { @@ -1543,7 +1545,7 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, }) xhr.send(); } else { - location.href = "/api/schema-designer/"+$scope.currentSchema+"/download"; + location.href = downloadUrl; } }; From b021f153d61c169984bed1040226fc1e44d34a95 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Thu, 2 Apr 2026 10:57:54 -0400 Subject: [PATCH 18/69] Use our standard naming pattern that the Api is in the endpoint package --- .../org/apache/solr/core/CoreContainer.java | 4 +- ...maDesignerAPI.java => SchemaDesigner.java} | 10 +- .../SchemaDesignerConfigSetHelper.java | 4 +- .../designer/SchemaDesignerSettingsDAO.java | 2 +- .../solr/handler/designer/package-info.java | 2 +- ...signerAPI.java => TestSchemaDesigner.java} | 119 +++++++++--------- .../TestSchemaDesignerConfigSetHelper.java | 8 +- 7 files changed, 74 insertions(+), 75 deletions(-) rename solr/core/src/java/org/apache/solr/handler/designer/{SchemaDesignerAPI.java => SchemaDesigner.java} (99%) rename solr/core/src/test/org/apache/solr/handler/designer/{TestSchemaDesignerAPI.java => TestSchemaDesigner.java} (88%) diff --git a/solr/core/src/java/org/apache/solr/core/CoreContainer.java b/solr/core/src/java/org/apache/solr/core/CoreContainer.java index 6de41e1768f5..f6e85b7273c3 100644 --- a/solr/core/src/java/org/apache/solr/core/CoreContainer.java +++ b/solr/core/src/java/org/apache/solr/core/CoreContainer.java @@ -126,7 +126,7 @@ import org.apache.solr.handler.admin.ZookeeperStatusHandler; import org.apache.solr.handler.api.V2ApiUtils; import org.apache.solr.handler.component.ShardHandlerFactory; -import org.apache.solr.handler.designer.SchemaDesignerAPI; +import org.apache.solr.handler.designer.SchemaDesigner; import org.apache.solr.jersey.InjectionFactories; import org.apache.solr.jersey.JerseyAppHandlerCache; import org.apache.solr.logging.LogWatcher; @@ -870,7 +870,7 @@ private void loadInternal() { registerV2ApiIfEnabled(clusterAPI.commands); if (isZooKeeperAware()) { - registerV2ApiIfEnabled(SchemaDesignerAPI.class); + registerV2ApiIfEnabled(SchemaDesigner.class); } // else Schema Designer not available in standalone (non-cloud) mode /* diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java similarity index 99% rename from solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java rename to solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index 60e89dde40a3..1cff6374b2bc 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerAPI.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -86,7 +86,7 @@ import org.slf4j.LoggerFactory; /** All V2 APIs have a prefix of /api/schema-designer/ */ -public class SchemaDesignerAPI extends JerseyResource +public class SchemaDesigner extends JerseyResource implements SchemaDesignerApi, SchemaDesignerConstants { private static final Set excludeConfigSetNames = Set.of(DEFAULT_CONFIGSET_NAME); @@ -102,15 +102,15 @@ public class SchemaDesignerAPI extends JerseyResource private final SolrQueryRequest solrQueryRequest; @Inject - public SchemaDesignerAPI(CoreContainer coreContainer, SolrQueryRequest solrQueryRequest) { + public SchemaDesigner(CoreContainer coreContainer, SolrQueryRequest solrQueryRequest) { this( coreContainer, - SchemaDesignerAPI.newSchemaSuggester(), - SchemaDesignerAPI.newSampleDocumentsLoader(), + SchemaDesigner.newSchemaSuggester(), + SchemaDesigner.newSampleDocumentsLoader(), solrQueryRequest); } - SchemaDesignerAPI( + SchemaDesigner( CoreContainer coreContainer, SchemaSuggester schemaSuggester, SampleDocumentsLoader sampleDocLoader, diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java index 0d75e9f759ee..ba76a8e1e51e 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java @@ -20,8 +20,8 @@ import static org.apache.solr.common.params.CommonParams.VERSION_FIELD; import static org.apache.solr.common.util.Utils.toJavabin; import static org.apache.solr.handler.admin.ConfigSetsHandler.DEFAULT_CONFIGSET_NAME; -import static org.apache.solr.handler.designer.SchemaDesignerAPI.getConfigSetZkPath; -import static org.apache.solr.handler.designer.SchemaDesignerAPI.getMutableId; +import static org.apache.solr.handler.designer.SchemaDesigner.getConfigSetZkPath; +import static org.apache.solr.handler.designer.SchemaDesigner.getMutableId; import static org.apache.solr.schema.IndexSchema.NEST_PATH_FIELD_NAME; import static org.apache.solr.schema.IndexSchema.ROOT_FIELD_NAME; import static org.apache.solr.schema.ManagedIndexSchemaFactory.DEFAULT_MANAGED_SCHEMA_RESOURCE_NAME; diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerSettingsDAO.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerSettingsDAO.java index 9a09e8e5da94..939a89004978 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerSettingsDAO.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerSettingsDAO.java @@ -17,7 +17,7 @@ package org.apache.solr.handler.designer; -import static org.apache.solr.handler.designer.SchemaDesignerAPI.getConfigSetZkPath; +import static org.apache.solr.handler.designer.SchemaDesigner.getConfigSetZkPath; import java.io.IOException; import java.lang.invoke.MethodHandles; diff --git a/solr/core/src/java/org/apache/solr/handler/designer/package-info.java b/solr/core/src/java/org/apache/solr/handler/designer/package-info.java index 17e3b7af2761..c8516c1c3273 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/package-info.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/package-info.java @@ -20,5 +20,5 @@ * limitations under the License. */ -/** The {@link org.apache.solr.handler.designer.SchemaDesignerAPI} and supporting classes. */ +/** The {@link org.apache.solr.handler.designer.SchemaDesigner} and supporting classes. */ package org.apache.solr.handler.designer; diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java similarity index 88% rename from solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java rename to solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java index 4b49cc29f33d..ad23ac488b92 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerAPI.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java @@ -19,7 +19,7 @@ import static org.apache.solr.common.params.CommonParams.JSON_MIME; import static org.apache.solr.handler.admin.ConfigSetsHandler.DEFAULT_CONFIGSET_NAME; -import static org.apache.solr.handler.designer.SchemaDesignerAPI.getMutableId; +import static org.apache.solr.handler.designer.SchemaDesigner.getMutableId; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -59,10 +59,10 @@ import org.junit.Test; import org.noggit.JSONUtil; -public class TestSchemaDesignerAPI extends SolrCloudTestCase implements SchemaDesignerConstants { +public class TestSchemaDesigner extends SolrCloudTestCase implements SchemaDesignerConstants { private CoreContainer cc; - private SchemaDesignerAPI schemaDesignerAPI; + private SchemaDesigner schemaDesigner; private SolrQueryRequest mockReq; @BeforeClass @@ -88,11 +88,11 @@ public void setupTest() { cc = cluster.getJettySolrRunner(0).getCoreContainer(); assertNotNull(cc); mockReq = mock(SolrQueryRequest.class); - schemaDesignerAPI = - new SchemaDesignerAPI( + schemaDesigner = + new SchemaDesigner( cc, - SchemaDesignerAPI.newSchemaSuggester(), - SchemaDesignerAPI.newSampleDocumentsLoader(), + SchemaDesigner.newSchemaSuggester(), + SchemaDesigner.newSampleDocumentsLoader(), mockReq); } @@ -113,7 +113,7 @@ public void testTSV() throws Exception { // POST /schema-designer/analyze FlexibleSolrJerseyResponse response = - schemaDesignerAPI.analyze(configSet, null, null, null, List.of("en"), false, null, null); + schemaDesigner.analyze(configSet, null, null, null, List.of("en"), false, null, null); assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); assertEquals(2, response.unknownProperties().get("numDocs")); @@ -121,7 +121,7 @@ public void testTSV() throws Exception { reqParams.clear(); reqParams.set(CONFIG_SET_PARAM, configSet); when(mockReq.getContentStreams()).thenReturn(null); - schemaDesignerAPI.cleanupTempSchema(configSet); + schemaDesigner.cleanupTempSchema(configSet); String mutableId = getMutableId(configSet); assertFalse(cc.getZkController().getClusterState().hasCollection(mutableId)); @@ -153,7 +153,7 @@ public void testAddTechproductsProgressively() throws Exception { String configSet = "techproducts"; // GET /schema-designer/info - FlexibleSolrJerseyResponse response = schemaDesignerAPI.getInfo(configSet); + FlexibleSolrJerseyResponse response = schemaDesigner.getInfo(configSet); // response should just be the default values Map expSettings = Map.of( @@ -166,7 +166,7 @@ public void testAddTechproductsProgressively() throws Exception { assertEquals(schemaVersion, -1); // shouldn't exist yet // Use the prep endpoint to prepare the new schema - response = schemaDesignerAPI.prepNewSchema(configSet, null); + response = schemaDesigner.prepNewSchema(configSet, null); assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); @@ -185,7 +185,7 @@ public void testAddTechproductsProgressively() throws Exception { // POST /schema-designer/analyze response = - schemaDesignerAPI.analyze( + schemaDesigner.analyze( configSet, schemaVersion, null, null, List.of("en"), false, null, null); assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); @@ -200,7 +200,7 @@ public void testAddTechproductsProgressively() throws Exception { // get info (from the temp) // GET /schema-designer/info - response = schemaDesignerAPI.getInfo(configSet); + response = schemaDesigner.getInfo(configSet); expSettings = Map.of( ENABLE_DYNAMIC_FIELDS_PARAM, false, @@ -219,7 +219,7 @@ public void testAddTechproductsProgressively() throws Exception { when(mockReq.getContentStreams()).thenReturn(null); // GET /schema-designer/query - response = schemaDesignerAPI.query(configSet); + response = schemaDesigner.query(configSet); assertNotNull(response.unknownProperties().get("responseHeader")); @SuppressWarnings("unchecked") Map queryResponse = @@ -233,11 +233,11 @@ public void testAddTechproductsProgressively() throws Exception { // publish schema to a config set that can be used by real collections String collection = "techproducts"; - schemaDesignerAPI.publish(configSet, schemaVersion, collection, true, 1, 1, true, true, true); + schemaDesigner.publish(configSet, schemaVersion, collection, true, 1, 1, true, true, true); assertNotNull(cc.getZkController().zkStateReader.getCollection(collection)); // listCollectionsForConfig - response = schemaDesignerAPI.listCollectionsForConfig(configSet); + response = schemaDesigner.listCollectionsForConfig(configSet); List collections = (List) response.unknownProperties().get("collections"); assertNotNull(collections); assertTrue(collections.contains(collection)); @@ -245,7 +245,7 @@ public void testAddTechproductsProgressively() throws Exception { // now try to create another temp, which should fail since designer is disabled for this // configSet now try { - schemaDesignerAPI.prepNewSchema(configSet, null); + schemaDesigner.prepNewSchema(configSet, null); fail("Prep should fail for locked schema " + configSet); } catch (SolrException solrExc) { assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, solrExc.code()); @@ -272,7 +272,7 @@ public void testSuggestFilmsXml() throws Exception { // POST /schema-designer/analyze FlexibleSolrJerseyResponse response = - schemaDesignerAPI.analyze(configSet, null, null, null, null, true, null, null); + schemaDesigner.analyze(configSet, null, null, null, null, true, null, null); assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); @@ -292,7 +292,7 @@ public void testBasicUserWorkflow() throws Exception { String configSet = "testJson"; // Use the prep endpoint to prepare the new schema - FlexibleSolrJerseyResponse response = schemaDesignerAPI.prepNewSchema(configSet, null); + FlexibleSolrJerseyResponse response = schemaDesigner.prepNewSchema(configSet, null); assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); @@ -315,7 +315,7 @@ public void testBasicUserWorkflow() throws Exception { when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); // POST /schema-designer/analyze - response = schemaDesignerAPI.analyze(configSet, null, null, null, null, null, null, null); + response = schemaDesigner.analyze(configSet, null, null, null, null, null, null, null); assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); @@ -341,7 +341,7 @@ public void testBasicUserWorkflow() throws Exception { } } assertNotNull("solrconfig.xml not found in files!", file); - response = schemaDesignerAPI.getFileContents(configSet, file); + response = schemaDesigner.getFileContents(configSet, file); String solrconfigXml = (String) response.unknownProperties().get(file); assertNotNull(solrconfigXml); @@ -350,7 +350,7 @@ public void testBasicUserWorkflow() throws Exception { .thenReturn( Collections.singletonList( new ContentStreamBase.StringStream(solrconfigXml, "application/xml"))); - response = schemaDesignerAPI.updateFileContents(configSet, file); + response = schemaDesigner.updateFileContents(configSet, file); schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); // update solrconfig.xml with some invalid XML mess @@ -360,14 +360,14 @@ public void testBasicUserWorkflow() throws Exception { new ContentStreamBase.StringStream("", "application/xml"))); // this should fail b/c the updated solrconfig.xml is invalid - response = schemaDesignerAPI.updateFileContents(configSet, file); + response = schemaDesigner.updateFileContents(configSet, file); assertNotNull(response.unknownProperties().get("updateFileError")); // remove dynamic fields and change the language to "en" only when(mockReq.getContentStreams()).thenReturn(null); // POST /schema-designer/analyze response = - schemaDesignerAPI.analyze( + schemaDesigner.analyze( configSet, schemaVersion, null, null, List.of("en"), false, false, null); expSettings = @@ -387,7 +387,7 @@ public void testBasicUserWorkflow() throws Exception { // add the dynamic fields back and change the languages too response = - schemaDesignerAPI.analyze( + schemaDesigner.analyze( configSet, schemaVersion, null, null, Arrays.asList("en", "fr"), true, false, null); expSettings = @@ -407,7 +407,7 @@ public void testBasicUserWorkflow() throws Exception { // add back all the default languages (using "*" wildcard -> empty list) response = - schemaDesignerAPI.analyze( + schemaDesigner.analyze( configSet, schemaVersion, null, null, List.of("*"), false, null, null); expSettings = @@ -432,7 +432,7 @@ public void testBasicUserWorkflow() throws Exception { String fieldName = "series_t"; // GET /schema-designer/sample - response = schemaDesignerAPI.getSampleValue(configSet, fieldName, idField, docId); + response = schemaDesigner.getSampleValue(configSet, fieldName, idField, docId); assertNotNull(response.unknownProperties().get(idField)); assertNotNull(response.unknownProperties().get(fieldName)); assertNotNull(response.unknownProperties().get("analysis")); @@ -446,7 +446,7 @@ public void testBasicUserWorkflow() throws Exception { when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); // POST /schema-designer/add - response = schemaDesignerAPI.addSchemaObject(configSet, schemaVersion); + response = schemaDesigner.addSchemaObject(configSet, schemaVersion); assertNotNull(response.unknownProperties().get("add-field")); schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); assertNotNull(response.unknownProperties().get("fields")); @@ -459,7 +459,7 @@ public void testBasicUserWorkflow() throws Exception { when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); // PUT /schema-designer/update - response = schemaDesignerAPI.updateSchemaObject(configSet, schemaVersion); + response = schemaDesigner.updateSchemaObject(configSet, schemaVersion); assertNotNull(response.unknownProperties().get("field")); schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); @@ -469,7 +469,7 @@ public void testBasicUserWorkflow() throws Exception { when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); // POST /schema-designer/add - response = schemaDesignerAPI.addSchemaObject(configSet, schemaVersion); + response = schemaDesigner.addSchemaObject(configSet, schemaVersion); final String expectedTypeName = "test_txt"; assertEquals(expectedTypeName, response.unknownProperties().get("add-field-type")); schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); @@ -487,7 +487,7 @@ public void testBasicUserWorkflow() throws Exception { when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); // POST /schema-designer/update - response = schemaDesignerAPI.updateSchemaObject(configSet, schemaVersion); + response = schemaDesigner.updateSchemaObject(configSet, schemaVersion); schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); // query to see how the schema decisions impact retrieval / ranking @@ -499,7 +499,7 @@ public void testBasicUserWorkflow() throws Exception { when(mockReq.getContentStreams()).thenReturn(null); // GET /schema-designer/query - response = schemaDesignerAPI.query(configSet); + response = schemaDesigner.query(configSet); assertNotNull(response.unknownProperties().get("responseHeader")); @SuppressWarnings("unchecked") Map queryResponse2 = @@ -511,12 +511,12 @@ public void testBasicUserWorkflow() throws Exception { // publish schema to a config set that can be used by real collections String collection = "test123"; - schemaDesignerAPI.publish(configSet, schemaVersion, collection, true, 1, 1, true, true, false); + schemaDesigner.publish(configSet, schemaVersion, collection, true, 1, 1, true, true, false); assertNotNull(cc.getZkController().zkStateReader.getCollection(collection)); // listCollectionsForConfig - response = schemaDesignerAPI.listCollectionsForConfig(configSet); + response = schemaDesigner.listCollectionsForConfig(configSet); List collections = (List) response.unknownProperties().get("collections"); assertNotNull(collections); assertTrue(collections.contains(collection)); @@ -539,7 +539,7 @@ public void testFieldUpdates() throws Exception { String configSet = "fieldUpdates"; // Use the prep endpoint to prepare the new schema - FlexibleSolrJerseyResponse response = schemaDesignerAPI.prepNewSchema(configSet, null); + FlexibleSolrJerseyResponse response = schemaDesigner.prepNewSchema(configSet, null); assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); int schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); @@ -551,7 +551,7 @@ public void testFieldUpdates() throws Exception { when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); // POST /schema-designer/add - response = schemaDesignerAPI.addSchemaObject(configSet, schemaVersion); + response = schemaDesigner.addSchemaObject(configSet, schemaVersion); assertNotNull(response.unknownProperties().get("add-field")); final String fieldName = "keywords"; @@ -571,22 +571,22 @@ public void testFieldUpdates() throws Exception { String mutableId = getMutableId(configSet); SchemaDesignerConfigSetHelper configSetHelper = - new SchemaDesignerConfigSetHelper(cc, SchemaDesignerAPI.newSchemaSuggester()); - ManagedIndexSchema schema = schemaDesignerAPI.loadLatestSchema(mutableId); + new SchemaDesignerConfigSetHelper(cc, SchemaDesigner.newSchemaSuggester()); + ManagedIndexSchema schema = schemaDesigner.loadLatestSchema(mutableId); // make it required Map updateField = Map.of("name", fieldName, "type", field.get("type"), "required", true); configSetHelper.updateField(configSet, updateField, schema); - schema = schemaDesignerAPI.loadLatestSchema(mutableId); + schema = schemaDesigner.loadLatestSchema(mutableId); SchemaField schemaField = schema.getField(fieldName); assertTrue(schemaField.isRequired()); updateField = Map.of("name", fieldName, "type", field.get("type"), "required", false, "stored", false); configSetHelper.updateField(configSet, updateField, schema); - schema = schemaDesignerAPI.loadLatestSchema(mutableId); + schema = schemaDesigner.loadLatestSchema(mutableId); schemaField = schema.getField(fieldName); assertFalse(schemaField.isRequired()); assertFalse(schemaField.stored()); @@ -604,7 +604,7 @@ public void testFieldUpdates() throws Exception { "multiValued", true); configSetHelper.updateField(configSet, updateField, schema); - schema = schemaDesignerAPI.loadLatestSchema(mutableId); + schema = schemaDesigner.loadLatestSchema(mutableId); schemaField = schema.getField(fieldName); assertFalse(schemaField.isRequired()); assertFalse(schemaField.stored()); @@ -612,7 +612,7 @@ public void testFieldUpdates() throws Exception { updateField = Map.of("name", fieldName, "type", "strings", "copyDest", "_text_"); configSetHelper.updateField(configSet, updateField, schema); - schema = schemaDesignerAPI.loadLatestSchema(mutableId); + schema = schemaDesigner.loadLatestSchema(mutableId); schemaField = schema.getField(fieldName); assertTrue(schemaField.multiValued()); assertEquals("strings", schemaField.getType().getTypeName()); @@ -627,14 +627,14 @@ public void testSchemaDiffEndpoint() throws Exception { String configSet = "testDiff"; // Use the prep endpoint to prepare the new schema - FlexibleSolrJerseyResponse response = schemaDesignerAPI.prepNewSchema(configSet, null); + FlexibleSolrJerseyResponse response = schemaDesigner.prepNewSchema(configSet, null); assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); int schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); // publish schema to a config set that can be used by real collections String collection = "diff456"; - schemaDesignerAPI.publish(configSet, schemaVersion, collection, true, 1, 1, true, true, false); + schemaDesigner.publish(configSet, schemaVersion, collection, true, 1, 1, true, true, false); assertNotNull(cc.getZkController().zkStateReader.getCollection(collection)); @@ -643,7 +643,7 @@ public void testSchemaDiffEndpoint() throws Exception { reqParams.set(CONFIG_SET_PARAM, configSet); when(mockReq.getParams()).thenReturn(reqParams); when(mockReq.getContentStreams()).thenReturn(null); - response = schemaDesignerAPI.analyze(configSet, null, null, null, null, true, false, null); + response = schemaDesigner.analyze(configSet, null, null, null, null, true, false, null); // Update id field to not use docValues List> fields = @@ -666,7 +666,7 @@ public void testSchemaDiffEndpoint() throws Exception { new ContentStreamBase.StringStream(JSONUtil.toJSON(mapParams), JSON_MIME); when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stringStream)); - response = schemaDesignerAPI.updateSchemaObject(configSet, schemaVersion); + response = schemaDesigner.updateSchemaObject(configSet, schemaVersion); // Add a new field schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); @@ -675,7 +675,7 @@ public void testSchemaDiffEndpoint() throws Exception { fileStream.setContentType(JSON_MIME); when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(fileStream)); // POST /schema-designer/add - response = schemaDesignerAPI.addSchemaObject(configSet, schemaVersion); + response = schemaDesigner.addSchemaObject(configSet, schemaVersion); assertNotNull(response.unknownProperties().get("add-field")); // Add a new field type @@ -684,11 +684,11 @@ public void testSchemaDiffEndpoint() throws Exception { fileStream.setContentType(JSON_MIME); when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(fileStream)); // POST /schema-designer/add - response = schemaDesignerAPI.addSchemaObject(configSet, schemaVersion); + response = schemaDesigner.addSchemaObject(configSet, schemaVersion); assertNotNull(response.unknownProperties().get("add-field-type")); // Let's do a diff now - response = schemaDesignerAPI.getSchemaDiff(configSet); + response = schemaDesigner.getSchemaDiff(configSet); Map diff = (Map) response.unknownProperties().get("diff"); @@ -736,24 +736,24 @@ public void testQueryReturnsErrorDetailsOnIndexingFailure() throws Exception { String configSet = "queryIndexErrTest"; // Prep the schema and analyze sample docs so the temp collection and stored docs exist - schemaDesignerAPI.prepNewSchema(configSet, null); + schemaDesigner.prepNewSchema(configSet, null); ContentStreamBase.StringStream stream = new ContentStreamBase.StringStream("[{\"id\":\"doc1\",\"title\":\"test doc\"}]", JSON_MIME); ModifiableSolrParams reqParams = new ModifiableSolrParams(); reqParams.set(CONFIG_SET_PARAM, configSet); when(mockReq.getParams()).thenReturn(reqParams); when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); - schemaDesignerAPI.analyze(configSet, null, null, null, null, null, null, null); + schemaDesigner.analyze(configSet, null, null, null, null, null, null, null); // Build a fresh API instance whose indexedVersion cache is empty (so it always // attempts to re-index before running the query), and which simulates indexing errors. Map fakeErrors = new HashMap<>(); fakeErrors.put("doc1", new RuntimeException("simulated indexing failure")); - SchemaDesignerAPI apiWithErrors = - new SchemaDesignerAPI( + SchemaDesigner apiWithErrors = + new SchemaDesigner( cc, - SchemaDesignerAPI.newSchemaSuggester(), - SchemaDesignerAPI.newSampleDocumentsLoader(), + SchemaDesigner.newSchemaSuggester(), + SchemaDesigner.newSampleDocumentsLoader(), mockReq) { @Override protected Map indexSampleDocsWithRebuildOnAnalysisError( @@ -781,22 +781,21 @@ protected Map indexSampleDocsWithRebuildOnAnalysisError( @Test public void testRequireSchemaVersionRejectsNegativeValues() throws Exception { String configSet = "schemaVersionValidation"; - schemaDesignerAPI.prepNewSchema(configSet, null); + schemaDesigner.prepNewSchema(configSet, null); // null schemaVersion must be rejected SolrException nullEx = - expectThrows(SolrException.class, () -> schemaDesignerAPI.addSchemaObject(configSet, null)); + expectThrows(SolrException.class, () -> schemaDesigner.addSchemaObject(configSet, null)); assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, nullEx.code()); // negative schemaVersion must be rejected (was previously bypassing validation) SolrException negEx = - expectThrows(SolrException.class, () -> schemaDesignerAPI.addSchemaObject(configSet, -1)); + expectThrows(SolrException.class, () -> schemaDesigner.addSchemaObject(configSet, -1)); assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, negEx.code()); // same contract must hold for updateSchemaObject SolrException updateNegEx = - expectThrows( - SolrException.class, () -> schemaDesignerAPI.updateSchemaObject(configSet, -1)); + expectThrows(SolrException.class, () -> schemaDesigner.updateSchemaObject(configSet, -1)); assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, updateNegEx.code()); } diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java index 4347b19de822..1a904a4e0720 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java @@ -19,7 +19,7 @@ import static org.apache.solr.common.util.Utils.toJavabin; import static org.apache.solr.handler.admin.ConfigSetsHandler.DEFAULT_CONFIGSET_NAME; -import static org.apache.solr.handler.designer.SchemaDesignerAPI.getMutableId; +import static org.apache.solr.handler.designer.SchemaDesigner.getMutableId; import static org.apache.solr.schema.IndexSchema.NEST_PATH_FIELD_NAME; import static org.apache.solr.schema.IndexSchema.ROOT_FIELD_NAME; @@ -76,7 +76,7 @@ public void setupTest() { assertNotNull(cluster); cc = cluster.getJettySolrRunner(0).getCoreContainer(); assertNotNull(cc); - helper = new SchemaDesignerConfigSetHelper(cc, SchemaDesignerAPI.newSchemaSuggester()); + helper = new SchemaDesignerConfigSetHelper(cc, SchemaDesigner.newSchemaSuggester()); } @Test @@ -179,7 +179,7 @@ public void testEnableDisableOptions() throws Exception { assertTrue( cluster .getZkClient() - .exists(SchemaDesignerAPI.getConfigSetZkPath(mutableId, "lang/stopwords_en.txt"))); + .exists(SchemaDesigner.getConfigSetZkPath(mutableId, "lang/stopwords_en.txt"))); assertNotNull(schema.getFieldTypeByName("text_fr")); assertNotNull(schema.getFieldOrNull("*_txt_fr")); assertNull(schema.getFieldOrNull("*_txt_ga")); @@ -202,7 +202,7 @@ public void testEnableDisableOptions() throws Exception { assertTrue( cluster .getZkClient() - .exists(SchemaDesignerAPI.getConfigSetZkPath(mutableId, "lang/stopwords_en.txt"))); + .exists(SchemaDesigner.getConfigSetZkPath(mutableId, "lang/stopwords_en.txt"))); assertNotNull(schema.getFieldTypeByName("text_fr")); assertNotNull(schema.getFieldOrNull("*_txt_fr")); assertNull(schema.getFieldOrNull("*_txt_ga")); From 96678968a9a9f1bca0db58bada23487224439447 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Thu, 2 Apr 2026 11:09:13 -0400 Subject: [PATCH 19/69] Rework changelog --- ...configset-download-zip-to-solrj-fix-schema-designer-bug.yml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename changelog/unreleased/{SOLR-18152-migrate-schemadesignerapi-to-v2-annotations.yml => SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml} (68%) diff --git a/changelog/unreleased/SOLR-18152-migrate-schemadesignerapi-to-v2-annotations.yml b/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml similarity index 68% rename from changelog/unreleased/SOLR-18152-migrate-schemadesignerapi-to-v2-annotations.yml rename to changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml index f91837f7d7ce..7bec4bc52644 100644 --- a/changelog/unreleased/SOLR-18152-migrate-schemadesignerapi-to-v2-annotations.yml +++ b/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml @@ -1,5 +1,5 @@ # See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc -title: Migrate Schema Designer API to JAX-RS. Fix bug preventing analysis of sample documents from running. +title: The "analyze" existing documents feature of Schema Designer was fixed. Added a new ConfigSet.Download capablity to SolrJ. type: fixed # added, changed, fixed, deprecated, removed, dependency_update, security, other authors: - name: Eric Pugh From 91be03ceaafce30ed2bd98dee56b561df72f7c4e Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Thu, 2 Apr 2026 11:27:30 -0400 Subject: [PATCH 20/69] Use same style for all class intro javadocs. --- .../org/apache/solr/handler/configsets/CloneConfigSet.java | 6 +++++- .../org/apache/solr/handler/configsets/DeleteConfigSet.java | 6 +++++- .../apache/solr/handler/configsets/DownloadConfigSet.java | 2 +- .../org/apache/solr/handler/configsets/UploadConfigSet.java | 5 +++++ 4 files changed, 16 insertions(+), 3 deletions(-) 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 index 872e7ebd4613..97de78113443 100644 --- a/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java +++ b/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java @@ -41,7 +41,7 @@ import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.response.SolrQueryResponse; -/** V2 API implementation for {@link ConfigsetsApi.Download}. */ +/** V2 API implementation for downloading a configset as a zip file. */ public class DownloadConfigSet extends ConfigSetAPIBase implements ConfigsetsApi.Download { @Inject 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 d87c38154f5a..24a585c6d6fd 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()); From a3befe4869a06bc27c463df2c8b65a8ed4ba2700 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:47:54 +0000 Subject: [PATCH 21/69] Update TestSchemaDesigner to use typed POJO return types Replace all FlexibleSolrJerseyResponse usages with the specific typed response POJOs (SchemaDesignerResponse, SchemaDesignerInfoResponse, SchemaDesignerCollectionsResponse, SchemaDesignerSchemaDiffResponse) returned by the SchemaDesigner API methods. Also fix SchemaDesigner.setSchemaObjectField() to handle the add-field and add-field-type action names used by the Schema API request JSON, so that response.field is populated for add-field requests and response.fieldType for add-field-type requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../solr/handler/designer/SchemaDesigner.java | 282 ++++++++++++------ .../handler/designer/TestSchemaDesigner.java | 226 ++++++++------ 2 files changed, 324 insertions(+), 184 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index 1cff6374b2bc..9aee826cfe04 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -50,6 +50,12 @@ import org.apache.solr.api.JerseyResource; import org.apache.solr.client.api.endpoint.SchemaDesignerApi; import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; +import org.apache.solr.client.api.model.SchemaDesignerCollectionsResponse; +import org.apache.solr.client.api.model.SchemaDesignerConfigsResponse; +import org.apache.solr.client.api.model.SchemaDesignerInfoResponse; +import org.apache.solr.client.api.model.SchemaDesignerPublishResponse; +import org.apache.solr.client.api.model.SchemaDesignerResponse; +import org.apache.solr.client.api.model.SchemaDesignerSchemaDiffResponse; import org.apache.solr.client.api.model.SolrJerseyResponse; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.impl.CloudSolrClient; @@ -154,13 +160,14 @@ static String getMutableId(final String configSet) { @Override @PermissionName(CONFIG_READ_PERM) - public FlexibleSolrJerseyResponse getInfo(String configSet) throws Exception { + public SchemaDesignerInfoResponse getInfo(String configSet) throws Exception { requireNotEmpty(CONFIG_SET_PARAM, configSet); - Map responseMap = new HashMap<>(); - responseMap.put(CONFIG_SET_PARAM, configSet); + SchemaDesignerInfoResponse response = + instantiateJerseyResponse(SchemaDesignerInfoResponse.class); + response.configSet = configSet; boolean exists = configExists(configSet); - responseMap.put("published", exists); + response.published = exists; // mutable config may not exist yet as this is just an info check to gather some basic info the // UI needs @@ -171,26 +178,24 @@ public FlexibleSolrJerseyResponse getInfo(String configSet) throws Exception { SolrConfig srcConfig = exists ? configSetHelper.loadSolrConfig(configSet) : null; SolrConfig solrConfig = configExists(mutableId) ? configSetHelper.loadSolrConfig(mutableId) : srcConfig; - addSettingsToResponse(settingsDAO.getSettings(solrConfig), responseMap); + addSettingsToResponse(settingsDAO.getSettings(solrConfig), response); - responseMap.put(SCHEMA_VERSION_PARAM, configSetHelper.getCurrentSchemaVersion(mutableId)); - responseMap.put( - "collections", exists ? configSetHelper.listCollectionsForConfig(configSet) : List.of()); + response.schemaVersion = configSetHelper.getCurrentSchemaVersion(mutableId); + response.collections = exists ? configSetHelper.listCollectionsForConfig(configSet) : List.of(); // don't fail if loading sample docs fails try { - responseMap.put("numDocs", configSetHelper.retrieveSampleDocs(configSet).size()); + response.numDocs = configSetHelper.retrieveSampleDocs(configSet).size(); } catch (Exception exc) { log.warn("Failed to load sample docs from blob store for {}", configSet, exc); } - return buildFlexibleResponse(responseMap); + return response; } @Override @PermissionName(CONFIG_EDIT_PERM) - public FlexibleSolrJerseyResponse prepNewSchema(String configSet, String copyFrom) - throws Exception { + public SchemaDesignerResponse prepNewSchema(String configSet, String copyFrom) throws Exception { requireNotEmpty(CONFIG_SET_PARAM, configSet); validateNewConfigSetName(configSet); @@ -209,7 +214,7 @@ public FlexibleSolrJerseyResponse prepNewSchema(String configSet, String copyFro settingsDAO.persistIfChanged(mutableId, settings); - return buildFlexibleResponse(buildResponse(configSet, schema, settings, null)); + return buildSchemaDesignerResponse(configSet, schema, settings, null); } @Override @@ -240,8 +245,7 @@ public FlexibleSolrJerseyResponse getFileContents(String configSet, String file) @Override @PermissionName(CONFIG_EDIT_PERM) - public FlexibleSolrJerseyResponse updateFileContents(String configSet, String file) - throws Exception { + public SchemaDesignerResponse updateFileContents(String configSet, String file) throws Exception { requireNotEmpty(CONFIG_SET_PARAM, configSet); requireNotEmpty("file", file); @@ -275,10 +279,11 @@ public FlexibleSolrJerseyResponse updateFileContents(String configSet, String fi // solrconfig.xml update failed, but haven't impacted the configSet yet, so just return the // error directly Throwable causedBy = SolrException.getRootCause(updateFileError); - Map response = new HashMap<>(); - response.put("updateFileError", causedBy.getMessage()); - response.put(file, new String(data, StandardCharsets.UTF_8)); - return buildFlexibleResponse(response); + SchemaDesignerResponse errorResponse = + instantiateJerseyResponse(SchemaDesignerResponse.class); + errorResponse.updateFileError = causedBy.getMessage(); + errorResponse.field = new String(data, StandardCharsets.UTF_8); + return errorResponse; } // apply the update and reload the temp collection / re-index sample docs @@ -308,10 +313,10 @@ public FlexibleSolrJerseyResponse updateFileContents(String configSet, String fi } } - Map response = buildResponse(configSet, schema, null, docs); + SchemaDesignerResponse response = buildSchemaDesignerResponse(configSet, schema, null, docs); if (analysisErrorHolder[0] != null) { - response.put(ANALYSIS_ERROR, analysisErrorHolder[0]); + response.analysisError = analysisErrorHolder[0]; } addErrorToResponse( @@ -321,7 +326,7 @@ public FlexibleSolrJerseyResponse updateFileContents(String configSet, String fi response, "Failed to re-index sample documents after update to the " + file + " file"); - return buildFlexibleResponse(response); + return response; } @Override @@ -369,19 +374,23 @@ public FlexibleSolrJerseyResponse getSampleValue( @Override @PermissionName(CONFIG_READ_PERM) - public FlexibleSolrJerseyResponse listCollectionsForConfig(String configSet) { + public SchemaDesignerCollectionsResponse listCollectionsForConfig(String configSet) { requireNotEmpty(CONFIG_SET_PARAM, configSet); - return buildFlexibleResponse( - Collections.singletonMap( - "collections", configSetHelper.listCollectionsForConfig(configSet))); + SchemaDesignerCollectionsResponse response = + instantiateJerseyResponse(SchemaDesignerCollectionsResponse.class); + response.collections = configSetHelper.listCollectionsForConfig(configSet); + return response; } // CONFIG_EDIT_PERM is required here since this endpoint is used by the UI to determine if the // user has access to the Schema Designer UI @Override @PermissionName(CONFIG_EDIT_PERM) - public FlexibleSolrJerseyResponse listConfigs() throws Exception { - return buildFlexibleResponse(Collections.singletonMap("configSets", listEnabledConfigs())); + public SchemaDesignerConfigsResponse listConfigs() throws Exception { + SchemaDesignerConfigsResponse response = + instantiateJerseyResponse(SchemaDesignerConfigsResponse.class); + response.configSets = listEnabledConfigs(); + return response; } protected Map listEnabledConfigs() throws IOException { @@ -402,7 +411,7 @@ protected Map listEnabledConfigs() throws IOException { @Override @PermissionName(CONFIG_EDIT_PERM) - public FlexibleSolrJerseyResponse addSchemaObject(String configSet, Integer schemaVersion) + public SchemaDesignerResponse addSchemaObject(String configSet, Integer schemaVersion) throws Exception { requireNotEmpty(CONFIG_SET_PARAM, configSet); requireSchemaVersion(schemaVersion); @@ -415,15 +424,16 @@ public FlexibleSolrJerseyResponse addSchemaObject(String configSet, Integer sche String action = addJson.keySet().iterator().next(); ManagedIndexSchema schema = loadLatestSchema(mutableId); - Map response = - buildResponse(configSet, schema, null, configSetHelper.retrieveSampleDocs(configSet)); - response.put(action, objectName); - return buildFlexibleResponse(response); + SchemaDesignerResponse response = + buildSchemaDesignerResponse( + configSet, schema, null, configSetHelper.retrieveSampleDocs(configSet)); + setSchemaObjectField(response, action, objectName); + return response; } @Override @PermissionName(CONFIG_EDIT_PERM) - public FlexibleSolrJerseyResponse updateSchemaObject(String configSet, Integer schemaVersion) + public SchemaDesignerResponse updateSchemaObject(String configSet, Integer schemaVersion) throws Exception { requireNotEmpty(CONFIG_SET_PARAM, configSet); requireSchemaVersion(schemaVersion); @@ -477,27 +487,28 @@ public FlexibleSolrJerseyResponse updateSchemaObject(String configSet, Integer s } } - Map response = buildResponse(configSet, schema, settings, docs); - response.put("updateType", updateType); + SchemaDesignerResponse response = + buildSchemaDesignerResponse(configSet, schema, settings, docs); + response.updateType = updateType; if (FIELD_PARAM.equals(updateType)) { - response.put(updateType, fieldToMap(schema.getField(name), schema)); + response.field = fieldToMap(schema.getField(name), schema); } else if ("type".equals(updateType)) { - response.put(updateType, schema.getFieldTypeByName(name).getNamedPropertyValues(true)); + response.type = schema.getFieldTypeByName(name).getNamedPropertyValues(true); } if (analysisErrorHolder[0] != null) { - response.put(ANALYSIS_ERROR, analysisErrorHolder[0]); + response.analysisError = analysisErrorHolder[0]; } addErrorToResponse(mutableId, solrExc, errorsDuringIndexing, response, updateError); - response.put("rebuild", needsRebuild); - return buildFlexibleResponse(response); + response.rebuild = needsRebuild; + return response; } @Override @PermissionName(CONFIG_EDIT_PERM) - public FlexibleSolrJerseyResponse publish( + public SchemaDesignerPublishResponse publish( String configSet, Integer schemaVersion, String newCollection, @@ -588,21 +599,22 @@ && zkStateReader().getClusterState().hasCollection(newCollection)) { settings.setDisabled(disableDesigner); settingsDAO.persistIfChanged(configSet, settings); - Map response = new HashMap<>(); - response.put(CONFIG_SET_PARAM, configSet); - response.put(SCHEMA_VERSION_PARAM, configSetHelper.getCurrentSchemaVersion(configSet)); + SchemaDesignerPublishResponse response = + instantiateJerseyResponse(SchemaDesignerPublishResponse.class); + response.configSet = configSet; + response.schemaVersion = configSetHelper.getCurrentSchemaVersion(configSet); if (StrUtils.isNotNullOrEmpty(newCollection)) { - response.put(NEW_COLLECTION_PARAM, newCollection); + response.newCollection = newCollection; } - addErrorToResponse(newCollection, null, errorsDuringIndexing, response, null); + addErrorToResponse(newCollection, null, errorsDuringIndexing, response); - return buildFlexibleResponse(response); + return response; } @Override @PermissionName(CONFIG_EDIT_PERM) - public FlexibleSolrJerseyResponse analyze( + public SchemaDesignerResponse analyze( String configSet, Integer schemaVersion, String copyFrom, @@ -733,14 +745,14 @@ public FlexibleSolrJerseyResponse analyze( CollectionAdminRequest.reloadCollection(mutableId).process(cloudClient()); } - Map response = - buildResponse(configSet, loadLatestSchema(mutableId), settings, docs); - response.put("sampleSource", sampleDocuments.getSource()); + SchemaDesignerResponse response = + buildSchemaDesignerResponse(configSet, loadLatestSchema(mutableId), settings, docs); + response.sampleSource = sampleDocuments.getSource(); if (analysisErrorHolder[0] != null) { - response.put(ANALYSIS_ERROR, analysisErrorHolder[0]); + response.analysisError = analysisErrorHolder[0]; } addErrorToResponse(mutableId, null, errorsDuringIndexing, response, null); - return buildFlexibleResponse(response); + return response; } @Override @@ -825,18 +837,18 @@ public FlexibleSolrJerseyResponse query(String configSet) throws Exception { */ @Override @PermissionName(CONFIG_READ_PERM) - public FlexibleSolrJerseyResponse getSchemaDiff(String configSet) throws Exception { + public SchemaDesignerSchemaDiffResponse getSchemaDiff(String configSet) throws Exception { requireNotEmpty(CONFIG_SET_PARAM, configSet); SchemaDesignerSettings settings = getMutableSchemaForConfigSet(configSet, -1, null); // diff the published if found, else use the original source schema String sourceSchema = configExists(configSet) ? configSet : settings.getCopyFrom(); - Map response = new HashMap<>(); - response.put( - "diff", ManagedSchemaDiff.diff(loadLatestSchema(sourceSchema), settings.getSchema())); - response.put("diff-source", sourceSchema); + SchemaDesignerSchemaDiffResponse response = + instantiateJerseyResponse(SchemaDesignerSchemaDiffResponse.class); + response.diff = ManagedSchemaDiff.diff(loadLatestSchema(sourceSchema), settings.getSchema()); + response.diffSource = sourceSchema; addSettingsToResponse(settings, response); - return buildFlexibleResponse(response); + return response; } protected SampleDocuments loadSampleDocuments(String configSet) throws IOException { @@ -1128,7 +1140,7 @@ protected long waitToSeeSampleDocs(String collectionName, long numAdded) return numFound; } - Map buildResponse( + SchemaDesignerResponse buildSchemaDesignerResponse( String configSet, final ManagedIndexSchema schema, SchemaDesignerSettings settings, @@ -1138,50 +1150,44 @@ Map buildResponse( int currentVersion = configSetHelper.getCurrentSchemaVersion(mutableId); indexedVersion.put(mutableId, currentVersion); - // response is a map of data structures to support the schema designer - Map response = new HashMap<>(); + SchemaDesignerResponse response = instantiateJerseyResponse(SchemaDesignerResponse.class); DocCollection coll = zkStateReader().getCollection(mutableId); Collection activeSlices = coll.getActiveSlices(); if (!activeSlices.isEmpty()) { - String coreName = activeSlices.stream().findAny().orElseThrow().getLeader().getCoreName(); - response.put("core", coreName); + response.core = activeSlices.stream().findAny().orElseThrow().getLeader().getCoreName(); } - response.put(UNIQUE_KEY_FIELD_PARAM, schema.getUniqueKeyField().getName()); - - response.put(CONFIG_SET_PARAM, configSet); + response.uniqueKeyField = schema.getUniqueKeyField().getName(); + response.configSet = configSet; // important: pass the designer the current schema zk version for MVCC - response.put(SCHEMA_VERSION_PARAM, currentVersion); - response.put(TEMP_COLLECTION_PARAM, mutableId); - response.put("collectionsForConfig", configSetHelper.listCollectionsForConfig(configSet)); + response.schemaVersion = currentVersion; + response.tempCollection = mutableId; + response.collectionsForConfig = configSetHelper.listCollectionsForConfig(configSet); // Guess at a schema for each field found in the sample docs // Collect all fields across all docs with mapping to values - response.put( - "fields", + response.fields = schema.getFields().values().stream() .map(f -> fieldToMap(f, schema)) .sorted(Comparator.comparing(map -> ((String) map.get("name")))) - .collect(Collectors.toList())); + .collect(Collectors.toList()); if (settings == null) { settings = settingsDAO.getSettings(mutableId); } addSettingsToResponse(settings, response); - response.put( - "dynamicFields", + response.dynamicFields = Arrays.stream(schema.getDynamicFieldPrototypes()) .map(e -> e.getNamedPropertyValues(true)) .sorted(Comparator.comparing(map -> ((String) map.get("name")))) - .collect(Collectors.toList())); + .collect(Collectors.toList()); - response.put( - "fieldTypes", + response.fieldTypes = schema.getFieldTypes().values().stream() .map(fieldType -> fieldType.getNamedPropertyValues(true)) .sorted(Comparator.comparing(map -> ((String) map.get("name")))) - .collect(Collectors.toList())); + .collect(Collectors.toList()); // files SolrZkClient zkClient = zkStateReader().getZkClient(); @@ -1208,25 +1214,38 @@ Map buildResponse( List sortedFiles = new ArrayList<>(stripPrefix); Collections.sort(sortedFiles); - response.put("files", sortedFiles); + response.files = sortedFiles; // info about the sample docs if (docs != null) { final String uniqueKeyField = schema.getUniqueKeyField().getName(); - response.put( - "docIds", + response.docIds = docs.stream() .map(d -> (String) d.getFieldValue(uniqueKeyField)) .filter(Objects::nonNull) .limit(100) - .collect(Collectors.toList())); + .collect(Collectors.toList()); } - response.put("numDocs", docs != null ? docs.size() : -1); + response.numDocs = docs != null ? docs.size() : -1; return response; } + /** Sets the named schema-object field on {@code response} based on the action type. */ + private static void setSchemaObjectField( + SchemaDesignerResponse response, String action, Object value) { + switch (action) { + case "field", "add-field" -> response.field = value; + case "type", "add-type" -> response.type = value; + case "dynamicField", "add-dynamic-field" -> response.dynamicField = value; + case "fieldType", "add-field-type" -> response.fieldType = value; + default -> { + /* unknown action type — silently ignore */ + } + } + } + protected void addErrorToResponse( String collection, SolrException solrExc, @@ -1254,6 +1273,66 @@ protected void addErrorToResponse( } } + protected void addErrorToResponse( + String collection, + SolrException solrExc, + Map errorsDuringIndexing, + SchemaDesignerResponse response, + String updateError) { + + if (solrExc == null && (errorsDuringIndexing == null || errorsDuringIndexing.isEmpty())) { + return; // no errors + } + + if (updateError != null) { + response.updateError = updateError; + } + + if (solrExc != null) { + response.updateErrorCode = solrExc.code(); + if (response.updateError == null) { + response.updateError = solrExc.getMessage(); + } + } + + if (response.updateError == null) { + response.updateError = "Index sample documents into " + collection + " failed!"; + } + if (response.updateErrorCode == null) { + response.updateErrorCode = 400; + } + if (errorsDuringIndexing != null) { + response.errorDetails = errorsDuringIndexing; + } + } + + /** Overload for {@link SchemaDesignerPublishResponse} error fields. */ + protected void addErrorToResponse( + String collection, + SolrException solrExc, + Map errorsDuringIndexing, + SchemaDesignerPublishResponse response) { + + if (solrExc == null && (errorsDuringIndexing == null || errorsDuringIndexing.isEmpty())) { + return; // no errors + } + + if (solrExc != null) { + response.updateErrorCode = solrExc.code(); + response.updateError = solrExc.getMessage(); + } + + if (response.updateError == null) { + response.updateError = "Index sample documents into " + collection + " failed!"; + } + if (response.updateErrorCode == null) { + response.updateErrorCode = 400; + } + if (errorsDuringIndexing != null) { + response.errorDetails = errorsDuringIndexing; + } + } + protected SimpleOrderedMap fieldToMap(SchemaField f, ManagedIndexSchema schema) { SimpleOrderedMap map = f.getNamedPropertyValues(true); @@ -1296,6 +1375,39 @@ void addSettingsToResponse(SchemaDesignerSettings settings, final Map response.publishedVersion = v); + response.copyFrom = settings.getCopyFrom(); + } + + void addSettingsToResponse( + SchemaDesignerSettings settings, final SchemaDesignerSchemaDiffResponse response) { + response.languages = settings.getLanguages(); + response.enableFieldGuessing = settings.fieldGuessingEnabled(); + response.enableDynamicFields = settings.dynamicFieldsEnabled(); + response.enableNestedDocs = settings.nestedDocsEnabled(); + response.disabled = settings.isDisabled(); + settings.getPublishedVersion().ifPresent(v -> response.publishedVersion = v); + response.copyFrom = settings.getCopyFrom(); + } + + void addSettingsToResponse( + SchemaDesignerSettings settings, final SchemaDesignerResponse response) { + response.languages = settings.getLanguages(); + response.enableFieldGuessing = settings.fieldGuessingEnabled(); + response.enableDynamicFields = settings.dynamicFieldsEnabled(); + response.enableNestedDocs = settings.nestedDocsEnabled(); + response.disabled = settings.isDisabled(); + settings.getPublishedVersion().ifPresent(v -> response.publishedVersion = v); + response.copyFrom = settings.getCopyFrom(); + } + protected String checkMutable(String configSet, int clientSchemaVersion) throws IOException { // an apply just copies over the temp config to the "live" location String mutableId = getMutableId(configSet); diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java index ad23ac488b92..cbc32ba2bb7d 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java @@ -35,6 +35,10 @@ import java.util.Optional; import java.util.stream.Stream; import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; +import org.apache.solr.client.api.model.SchemaDesignerCollectionsResponse; +import org.apache.solr.client.api.model.SchemaDesignerInfoResponse; +import org.apache.solr.client.api.model.SchemaDesignerResponse; +import org.apache.solr.client.api.model.SchemaDesignerSchemaDiffResponse; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.request.SolrQuery; import org.apache.solr.client.solrj.response.QueryResponse; @@ -112,11 +116,11 @@ public void testTSV() throws Exception { when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); // POST /schema-designer/analyze - FlexibleSolrJerseyResponse response = + SchemaDesignerResponse response = schemaDesigner.analyze(configSet, null, null, null, List.of("en"), false, null, null); - assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); - assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); - assertEquals(2, response.unknownProperties().get("numDocs")); + assertNotNull(response.configSet); + assertNotNull(response.schemaVersion); + assertEquals(Integer.valueOf(2), response.numDocs); reqParams.clear(); reqParams.set(CONFIG_SET_PARAM, configSet); @@ -153,7 +157,7 @@ public void testAddTechproductsProgressively() throws Exception { String configSet = "techproducts"; // GET /schema-designer/info - FlexibleSolrJerseyResponse response = schemaDesigner.getInfo(configSet); + SchemaDesignerInfoResponse infoResponse = schemaDesigner.getInfo(configSet); // response should just be the default values Map expSettings = Map.of( @@ -161,15 +165,15 @@ public void testAddTechproductsProgressively() throws Exception { ENABLE_FIELD_GUESSING_PARAM, true, ENABLE_NESTED_DOCS_PARAM, false, LANGUAGES_PARAM, List.of()); - assertDesignerSettings(expSettings, response.unknownProperties()); - int schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); + assertDesignerSettings(expSettings, infoResponse); + int schemaVersion = infoResponse.schemaVersion; assertEquals(schemaVersion, -1); // shouldn't exist yet // Use the prep endpoint to prepare the new schema - response = schemaDesigner.prepNewSchema(configSet, null); - assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); - assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); - schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); + SchemaDesignerResponse response = schemaDesigner.prepNewSchema(configSet, null); + assertNotNull(response.configSet); + assertNotNull(response.schemaVersion); + schemaVersion = response.schemaVersion; for (Path next : toAdd) { // Analyze some sample documents to refine the schema @@ -188,19 +192,19 @@ public void testAddTechproductsProgressively() throws Exception { schemaDesigner.analyze( configSet, schemaVersion, null, null, List.of("en"), false, null, null); - assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); - assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); - assertNotNull(response.unknownProperties().get("fields")); - assertNotNull(response.unknownProperties().get("fieldTypes")); - assertNotNull(response.unknownProperties().get("docIds")); + assertNotNull(response.configSet); + assertNotNull(response.schemaVersion); + assertNotNull(response.fields); + assertNotNull(response.fieldTypes); + assertNotNull(response.docIds); // capture the schema version for MVCC - schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); + schemaVersion = response.schemaVersion; } // get info (from the temp) // GET /schema-designer/info - response = schemaDesigner.getInfo(configSet); + infoResponse = schemaDesigner.getInfo(configSet); expSettings = Map.of( ENABLE_DYNAMIC_FIELDS_PARAM, false, @@ -208,7 +212,7 @@ public void testAddTechproductsProgressively() throws Exception { ENABLE_NESTED_DOCS_PARAM, false, LANGUAGES_PARAM, Collections.singletonList("en"), COPY_FROM_PARAM, "_default"); - assertDesignerSettings(expSettings, response.unknownProperties()); + assertDesignerSettings(expSettings, infoResponse); // query to see how the schema decisions impact retrieval / ranking ModifiableSolrParams queryParams = new ModifiableSolrParams(); @@ -219,11 +223,11 @@ public void testAddTechproductsProgressively() throws Exception { when(mockReq.getContentStreams()).thenReturn(null); // GET /schema-designer/query - response = schemaDesigner.query(configSet); - assertNotNull(response.unknownProperties().get("responseHeader")); + FlexibleSolrJerseyResponse queryResp = schemaDesigner.query(configSet); + assertNotNull(queryResp.unknownProperties().get("responseHeader")); @SuppressWarnings("unchecked") Map queryResponse = - (Map) response.unknownProperties().get("response"); + (Map) queryResp.unknownProperties().get("response"); assertNotNull("response object must be a map with numFound/docs", queryResponse); assertEquals(47L, queryResponse.get("numFound")); @SuppressWarnings("unchecked") @@ -237,8 +241,9 @@ public void testAddTechproductsProgressively() throws Exception { assertNotNull(cc.getZkController().zkStateReader.getCollection(collection)); // listCollectionsForConfig - response = schemaDesigner.listCollectionsForConfig(configSet); - List collections = (List) response.unknownProperties().get("collections"); + SchemaDesignerCollectionsResponse collectionsResp = + schemaDesigner.listCollectionsForConfig(configSet); + List collections = collectionsResp.collections; assertNotNull(collections); assertTrue(collections.contains(collection)); @@ -271,18 +276,18 @@ public void testSuggestFilmsXml() throws Exception { when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(stream)); // POST /schema-designer/analyze - FlexibleSolrJerseyResponse response = + SchemaDesignerResponse response = schemaDesigner.analyze(configSet, null, null, null, null, true, null, null); - assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); - assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); - assertNotNull(response.unknownProperties().get("fields")); - assertNotNull(response.unknownProperties().get("fieldTypes")); - List docIds = (List) response.unknownProperties().get("docIds"); + assertNotNull(response.configSet); + assertNotNull(response.schemaVersion); + assertNotNull(response.fields); + assertNotNull(response.fieldTypes); + List docIds = response.docIds; assertNotNull(docIds); assertEquals(100, docIds.size()); // designer limits the doc ids to top 100 - String idField = (String) response.unknownProperties().get(UNIQUE_KEY_FIELD_PARAM); + String idField = response.uniqueKeyField; assertNotNull(idField); } @@ -292,9 +297,9 @@ public void testBasicUserWorkflow() throws Exception { String configSet = "testJson"; // Use the prep endpoint to prepare the new schema - FlexibleSolrJerseyResponse response = schemaDesigner.prepNewSchema(configSet, null); - assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); - assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); + SchemaDesignerResponse response = schemaDesigner.prepNewSchema(configSet, null); + assertNotNull(response.configSet); + assertNotNull(response.schemaVersion); Map expSettings = Map.of( @@ -303,7 +308,7 @@ public void testBasicUserWorkflow() throws Exception { ENABLE_NESTED_DOCS_PARAM, false, LANGUAGES_PARAM, List.of(), COPY_FROM_PARAM, "_default"); - assertDesignerSettings(expSettings, response.unknownProperties()); + assertDesignerSettings(expSettings, response); // Analyze some sample documents to refine the schema Path booksJson = ExternalPaths.SOURCE_HOME.resolve("example/exampledocs/books.json"); @@ -317,20 +322,20 @@ public void testBasicUserWorkflow() throws Exception { // POST /schema-designer/analyze response = schemaDesigner.analyze(configSet, null, null, null, null, null, null, null); - assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); - assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); - assertNotNull(response.unknownProperties().get("fields")); - assertNotNull(response.unknownProperties().get("fieldTypes")); - assertNotNull(response.unknownProperties().get("docIds")); - String idField = (String) response.unknownProperties().get(UNIQUE_KEY_FIELD_PARAM); + assertNotNull(response.configSet); + assertNotNull(response.schemaVersion); + assertNotNull(response.fields); + assertNotNull(response.fieldTypes); + assertNotNull(response.docIds); + String idField = response.uniqueKeyField; assertNotNull(idField); - assertDesignerSettings(expSettings, response.unknownProperties()); + assertDesignerSettings(expSettings, response); // capture the schema version for MVCC - int schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); + int schemaVersion = response.schemaVersion; // load the contents of a file - Collection files = (Collection) response.unknownProperties().get("files"); + Collection files = response.files; assertTrue(files != null && !files.isEmpty()); String file = null; @@ -341,8 +346,8 @@ public void testBasicUserWorkflow() throws Exception { } } assertNotNull("solrconfig.xml not found in files!", file); - response = schemaDesigner.getFileContents(configSet, file); - String solrconfigXml = (String) response.unknownProperties().get(file); + FlexibleSolrJerseyResponse fileContentsResp = schemaDesigner.getFileContents(configSet, file); + String solrconfigXml = (String) fileContentsResp.unknownProperties().get(file); assertNotNull(solrconfigXml); // Update solrconfig.xml @@ -351,7 +356,7 @@ public void testBasicUserWorkflow() throws Exception { Collections.singletonList( new ContentStreamBase.StringStream(solrconfigXml, "application/xml"))); response = schemaDesigner.updateFileContents(configSet, file); - schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); + schemaVersion = response.schemaVersion; // update solrconfig.xml with some invalid XML mess when(mockReq.getContentStreams()) @@ -361,7 +366,7 @@ public void testBasicUserWorkflow() throws Exception { // this should fail b/c the updated solrconfig.xml is invalid response = schemaDesigner.updateFileContents(configSet, file); - assertNotNull(response.unknownProperties().get("updateFileError")); + assertNotNull(response.updateFileError); // remove dynamic fields and change the language to "en" only when(mockReq.getContentStreams()).thenReturn(null); @@ -377,13 +382,13 @@ public void testBasicUserWorkflow() throws Exception { ENABLE_NESTED_DOCS_PARAM, false, LANGUAGES_PARAM, Collections.singletonList("en"), COPY_FROM_PARAM, "_default"); - assertDesignerSettings(expSettings, response.unknownProperties()); + assertDesignerSettings(expSettings, response); - List filesInResp = (List) response.unknownProperties().get("files"); + List filesInResp = response.files; assertEquals(5, filesInResp.size()); assertTrue(filesInResp.contains("lang/stopwords_en.txt")); - schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); + schemaVersion = response.schemaVersion; // add the dynamic fields back and change the languages too response = @@ -397,13 +402,13 @@ public void testBasicUserWorkflow() throws Exception { ENABLE_NESTED_DOCS_PARAM, false, LANGUAGES_PARAM, Arrays.asList("en", "fr"), COPY_FROM_PARAM, "_default"); - assertDesignerSettings(expSettings, response.unknownProperties()); + assertDesignerSettings(expSettings, response); - filesInResp = (List) response.unknownProperties().get("files"); + filesInResp = response.files; assertEquals(7, filesInResp.size()); assertTrue(filesInResp.contains("lang/stopwords_fr.txt")); - schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); + schemaVersion = response.schemaVersion; // add back all the default languages (using "*" wildcard -> empty list) response = @@ -417,25 +422,26 @@ public void testBasicUserWorkflow() throws Exception { ENABLE_NESTED_DOCS_PARAM, false, LANGUAGES_PARAM, List.of(), COPY_FROM_PARAM, "_default"); - assertDesignerSettings(expSettings, response.unknownProperties()); + assertDesignerSettings(expSettings, response); - filesInResp = (List) response.unknownProperties().get("files"); + filesInResp = response.files; assertEquals(43, filesInResp.size()); assertTrue(filesInResp.contains("lang/stopwords_fr.txt")); assertTrue(filesInResp.contains("lang/stopwords_en.txt")); assertTrue(filesInResp.contains("lang/stopwords_it.txt")); - schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); + schemaVersion = response.schemaVersion; // Get the value of a sample document String docId = "978-0641723445"; String fieldName = "series_t"; // GET /schema-designer/sample - response = schemaDesigner.getSampleValue(configSet, fieldName, idField, docId); - assertNotNull(response.unknownProperties().get(idField)); - assertNotNull(response.unknownProperties().get(fieldName)); - assertNotNull(response.unknownProperties().get("analysis")); + FlexibleSolrJerseyResponse sampleResp = + schemaDesigner.getSampleValue(configSet, fieldName, idField, docId); + assertNotNull(sampleResp.unknownProperties().get(idField)); + assertNotNull(sampleResp.unknownProperties().get(fieldName)); + assertNotNull(sampleResp.unknownProperties().get("analysis")); // at this point the user would refine the schema by // editing suggestions for fields and adding/removing fields / field types as needed @@ -447,9 +453,9 @@ public void testBasicUserWorkflow() throws Exception { // POST /schema-designer/add response = schemaDesigner.addSchemaObject(configSet, schemaVersion); - assertNotNull(response.unknownProperties().get("add-field")); - schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); - assertNotNull(response.unknownProperties().get("fields")); + assertNotNull(response.field); + schemaVersion = response.schemaVersion; + assertNotNull(response.fields); // update an existing field // switch a single-valued field to a multivalued field, which triggers a full rebuild of the @@ -460,8 +466,8 @@ public void testBasicUserWorkflow() throws Exception { // PUT /schema-designer/update response = schemaDesigner.updateSchemaObject(configSet, schemaVersion); - assertNotNull(response.unknownProperties().get("field")); - schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); + assertNotNull(response.field); + schemaVersion = response.schemaVersion; // add a new type stream = new ContentStreamBase.FileStream(getFile("schema-designer/add-new-type.json")); @@ -471,12 +477,12 @@ public void testBasicUserWorkflow() throws Exception { // POST /schema-designer/add response = schemaDesigner.addSchemaObject(configSet, schemaVersion); final String expectedTypeName = "test_txt"; - assertEquals(expectedTypeName, response.unknownProperties().get("add-field-type")); - schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); - assertNotNull(response.unknownProperties().get("fieldTypes")); - List> fieldTypes = - (List>) response.unknownProperties().get("fieldTypes"); - Optional> expected = + assertEquals(expectedTypeName, response.fieldType); + schemaVersion = response.schemaVersion; + assertNotNull(response.fieldTypes); + @SuppressWarnings("unchecked") + List> fieldTypes = response.fieldTypes; + Optional> expected = fieldTypes.stream().filter(m -> expectedTypeName.equals(m.get("name"))).findFirst(); assertTrue( "New field type '" + expectedTypeName + "' not found in add type response!", @@ -488,7 +494,7 @@ public void testBasicUserWorkflow() throws Exception { // POST /schema-designer/update response = schemaDesigner.updateSchemaObject(configSet, schemaVersion); - schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); + schemaVersion = response.schemaVersion; // query to see how the schema decisions impact retrieval / ranking ModifiableSolrParams queryParams = new ModifiableSolrParams(); @@ -499,11 +505,11 @@ public void testBasicUserWorkflow() throws Exception { when(mockReq.getContentStreams()).thenReturn(null); // GET /schema-designer/query - response = schemaDesigner.query(configSet); - assertNotNull(response.unknownProperties().get("responseHeader")); + FlexibleSolrJerseyResponse queryResp2 = schemaDesigner.query(configSet); + assertNotNull(queryResp2.unknownProperties().get("responseHeader")); @SuppressWarnings("unchecked") Map queryResponse2 = - (Map) response.unknownProperties().get("response"); + (Map) queryResp2.unknownProperties().get("response"); assertNotNull("response object must be a map with numFound/docs", queryResponse2); @SuppressWarnings("unchecked") List queryDocs2 = (List) queryResponse2.get("docs"); @@ -516,8 +522,9 @@ public void testBasicUserWorkflow() throws Exception { assertNotNull(cc.getZkController().zkStateReader.getCollection(collection)); // listCollectionsForConfig - response = schemaDesigner.listCollectionsForConfig(configSet); - List collections = (List) response.unknownProperties().get("collections"); + SchemaDesignerCollectionsResponse collectionsResp2 = + schemaDesigner.listCollectionsForConfig(configSet); + List collections = collectionsResp2.collections; assertNotNull(collections); assertTrue(collections.contains(collection)); @@ -539,10 +546,10 @@ public void testFieldUpdates() throws Exception { String configSet = "fieldUpdates"; // Use the prep endpoint to prepare the new schema - FlexibleSolrJerseyResponse response = schemaDesigner.prepNewSchema(configSet, null); - assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); - assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); - int schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); + SchemaDesignerResponse response = schemaDesigner.prepNewSchema(configSet, null); + assertNotNull(response.configSet); + assertNotNull(response.schemaVersion); + int schemaVersion = response.schemaVersion; // add our test field that we'll test various updates to ContentStreamBase.FileStream stream = @@ -552,15 +559,14 @@ public void testFieldUpdates() throws Exception { // POST /schema-designer/add response = schemaDesigner.addSchemaObject(configSet, schemaVersion); - assertNotNull(response.unknownProperties().get("add-field")); + assertNotNull(response.field); final String fieldName = "keywords"; - Optional> maybeField = - ((List>) response.unknownProperties().get("fields")) - .stream().filter(m -> fieldName.equals(m.get("name"))).findFirst(); + Optional> maybeField = + response.fields.stream().filter(m -> fieldName.equals(m.get("name"))).findFirst(); assertTrue(maybeField.isPresent()); - SimpleOrderedMap field = maybeField.get(); + Map field = maybeField.get(); assertEquals(Boolean.FALSE, field.get("indexed")); assertEquals(Boolean.FALSE, field.get("required")); assertEquals(Boolean.TRUE, field.get("stored")); @@ -627,10 +633,10 @@ public void testSchemaDiffEndpoint() throws Exception { String configSet = "testDiff"; // Use the prep endpoint to prepare the new schema - FlexibleSolrJerseyResponse response = schemaDesigner.prepNewSchema(configSet, null); - assertNotNull(response.unknownProperties().get(CONFIG_SET_PARAM)); - assertNotNull(response.unknownProperties().get(SCHEMA_VERSION_PARAM)); - int schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); + SchemaDesignerResponse response = schemaDesigner.prepNewSchema(configSet, null); + assertNotNull(response.configSet); + assertNotNull(response.schemaVersion); + int schemaVersion = response.schemaVersion; // publish schema to a config set that can be used by real collections String collection = "diff456"; @@ -647,7 +653,7 @@ public void testSchemaDiffEndpoint() throws Exception { // Update id field to not use docValues List> fields = - (List>) response.unknownProperties().get("fields"); + (List>) (List) response.fields; SimpleOrderedMap idFieldMap = fields.stream().filter(field -> field.get("name").equals("id")).findFirst().get(); idFieldMap.remove("copyDest"); // Don't include copyDest as it is not a property of SchemaField @@ -660,7 +666,7 @@ public void testSchemaDiffEndpoint() throws Exception { Map mapParams = idFieldMapUpdated.toSolrParams().toMap(new HashMap<>()); mapParams.put("termVectors", Boolean.FALSE); - schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); + schemaVersion = response.schemaVersion; ContentStreamBase.StringStream stringStream = new ContentStreamBase.StringStream(JSONUtil.toJSON(mapParams), JSON_MIME); @@ -669,28 +675,28 @@ public void testSchemaDiffEndpoint() throws Exception { response = schemaDesigner.updateSchemaObject(configSet, schemaVersion); // Add a new field - schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); + schemaVersion = response.schemaVersion; ContentStreamBase.FileStream fileStream = new ContentStreamBase.FileStream(getFile("schema-designer/add-new-field.json")); fileStream.setContentType(JSON_MIME); when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(fileStream)); // POST /schema-designer/add response = schemaDesigner.addSchemaObject(configSet, schemaVersion); - assertNotNull(response.unknownProperties().get("add-field")); + assertNotNull(response.field); // Add a new field type - schemaVersion = (Integer) response.unknownProperties().get(SCHEMA_VERSION_PARAM); + schemaVersion = response.schemaVersion; fileStream = new ContentStreamBase.FileStream(getFile("schema-designer/add-new-type.json")); fileStream.setContentType(JSON_MIME); when(mockReq.getContentStreams()).thenReturn(Collections.singletonList(fileStream)); // POST /schema-designer/add response = schemaDesigner.addSchemaObject(configSet, schemaVersion); - assertNotNull(response.unknownProperties().get("add-field-type")); + assertNotNull(response.fieldType); // Let's do a diff now - response = schemaDesigner.getSchemaDiff(configSet); + SchemaDesignerSchemaDiffResponse diffResp = schemaDesigner.getSchemaDiff(configSet); - Map diff = (Map) response.unknownProperties().get("diff"); + Map diff = diffResp.diff; // field asserts assertNotNull(diff.get("fields")); @@ -808,4 +814,26 @@ protected void assertDesignerSettings(Map expected, Map expected, SchemaDesignerResponse response) { + Map actual = new HashMap<>(); + actual.put(LANGUAGES_PARAM, response.languages); + actual.put(ENABLE_FIELD_GUESSING_PARAM, response.enableFieldGuessing); + actual.put(ENABLE_DYNAMIC_FIELDS_PARAM, response.enableDynamicFields); + actual.put(ENABLE_NESTED_DOCS_PARAM, response.enableNestedDocs); + actual.put(COPY_FROM_PARAM, response.copyFrom); + assertDesignerSettings(expected, actual); + } + + protected void assertDesignerSettings( + Map expected, SchemaDesignerInfoResponse response) { + Map actual = new HashMap<>(); + actual.put(LANGUAGES_PARAM, response.languages); + actual.put(ENABLE_FIELD_GUESSING_PARAM, response.enableFieldGuessing); + actual.put(ENABLE_DYNAMIC_FIELDS_PARAM, response.enableDynamicFields); + actual.put(ENABLE_NESTED_DOCS_PARAM, response.enableNestedDocs); + actual.put(COPY_FROM_PARAM, response.copyFrom); + assertDesignerSettings(expected, actual); + } } From 05209527aba09b1952a71581109c9accdd4df678 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:49:47 +0000 Subject: [PATCH 22/69] Add clarifying comment to setSchemaObjectField switch Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../api/endpoint/SchemaDesignerApi.java | 26 +-- .../SchemaDesignerCollectionsResponse.java | 27 +++ .../model/SchemaDesignerConfigsResponse.java | 31 ++++ .../api/model/SchemaDesignerInfoResponse.java | 67 +++++++ .../model/SchemaDesignerPublishResponse.java | 45 +++++ .../api/model/SchemaDesignerResponse.java | 170 ++++++++++++++++++ .../SchemaDesignerSchemaDiffResponse.java | 57 ++++++ .../solr/handler/designer/SchemaDesigner.java | 3 + 8 files changed, 416 insertions(+), 10 deletions(-) create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerCollectionsResponse.java create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerConfigsResponse.java create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerInfoResponse.java create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerPublishResponse.java create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index ba28278c6669..6eeba0315455 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -27,6 +27,12 @@ import jakarta.ws.rs.QueryParam; import java.util.List; import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; +import org.apache.solr.client.api.model.SchemaDesignerCollectionsResponse; +import org.apache.solr.client.api.model.SchemaDesignerConfigsResponse; +import org.apache.solr.client.api.model.SchemaDesignerInfoResponse; +import org.apache.solr.client.api.model.SchemaDesignerPublishResponse; +import org.apache.solr.client.api.model.SchemaDesignerResponse; +import org.apache.solr.client.api.model.SchemaDesignerSchemaDiffResponse; import org.apache.solr.client.api.model.SolrJerseyResponse; /** V2 API definitions for the Solr Schema Designer. */ @@ -38,14 +44,14 @@ public interface SchemaDesignerApi { @Operation( summary = "Get info about a configSet being designed.", tags = {"schema-designer"}) - FlexibleSolrJerseyResponse getInfo(@PathParam("configSet") String configSet) throws Exception; + SchemaDesignerInfoResponse getInfo(@PathParam("configSet") String configSet) throws Exception; @POST @Path("/{configSet}/prep") @Operation( summary = "Prepare a mutable configSet copy for schema design.", tags = {"schema-designer"}) - FlexibleSolrJerseyResponse prepNewSchema( + SchemaDesignerResponse prepNewSchema( @PathParam("configSet") String configSet, @QueryParam("copyFrom") String copyFrom) throws Exception; @@ -69,7 +75,7 @@ FlexibleSolrJerseyResponse getFileContents( @Operation( summary = "Update the contents of a file in a configSet being designed.", tags = {"schema-designer"}) - FlexibleSolrJerseyResponse updateFileContents( + SchemaDesignerResponse updateFileContents( @PathParam("configSet") String configSet, @QueryParam("file") String file) throws Exception; @GET @@ -89,7 +95,7 @@ FlexibleSolrJerseyResponse getSampleValue( @Operation( summary = "List collections that use a given configSet.", tags = {"schema-designer"}) - FlexibleSolrJerseyResponse listCollectionsForConfig(@PathParam("configSet") String configSet) + SchemaDesignerCollectionsResponse listCollectionsForConfig(@PathParam("configSet") String configSet) throws Exception; @GET @@ -97,14 +103,14 @@ FlexibleSolrJerseyResponse listCollectionsForConfig(@PathParam("configSet") Stri @Operation( summary = "List all configSets available for schema design.", tags = {"schema-designer"}) - FlexibleSolrJerseyResponse listConfigs() throws Exception; + SchemaDesignerConfigsResponse listConfigs() throws Exception; @POST @Path("/{configSet}/add") @Operation( summary = "Add a new field, field type, or dynamic field to the schema being designed.", tags = {"schema-designer"}) - FlexibleSolrJerseyResponse addSchemaObject( + SchemaDesignerResponse addSchemaObject( @PathParam("configSet") String configSet, @QueryParam("schemaVersion") Integer schemaVersion) throws Exception; @@ -113,7 +119,7 @@ FlexibleSolrJerseyResponse addSchemaObject( @Operation( summary = "Update an existing field or field type in the schema being designed.", tags = {"schema-designer"}) - FlexibleSolrJerseyResponse updateSchemaObject( + SchemaDesignerResponse updateSchemaObject( @PathParam("configSet") String configSet, @QueryParam("schemaVersion") Integer schemaVersion) throws Exception; @@ -122,7 +128,7 @@ FlexibleSolrJerseyResponse updateSchemaObject( @Operation( summary = "Publish the designed schema to a live configSet.", tags = {"schema-designer"}) - FlexibleSolrJerseyResponse publish( + SchemaDesignerPublishResponse publish( @PathParam("configSet") String configSet, @QueryParam("schemaVersion") Integer schemaVersion, @QueryParam("newCollection") String newCollection, @@ -139,7 +145,7 @@ FlexibleSolrJerseyResponse publish( @Operation( summary = "Analyze sample documents and suggest a schema.", tags = {"schema-designer"}) - FlexibleSolrJerseyResponse analyze( + SchemaDesignerResponse analyze( @PathParam("configSet") String configSet, @QueryParam("schemaVersion") Integer schemaVersion, @QueryParam("copyFrom") String copyFrom, @@ -162,6 +168,6 @@ FlexibleSolrJerseyResponse analyze( @Operation( summary = "Get the diff between the designed schema and the published schema.", tags = {"schema-designer"}) - FlexibleSolrJerseyResponse getSchemaDiff(@PathParam("configSet") String configSet) + SchemaDesignerSchemaDiffResponse getSchemaDiff(@PathParam("configSet") String configSet) throws Exception; } diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerCollectionsResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerCollectionsResponse.java new file mode 100644 index 000000000000..2e0d31a27243 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerCollectionsResponse.java @@ -0,0 +1,27 @@ +/* + * 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; +import java.util.List; + +/** Response body for the Schema Designer list-collections-for-config endpoint. */ +public class SchemaDesignerCollectionsResponse extends SolrJerseyResponse { + + @JsonProperty("collections") + public List collections; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerConfigsResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerConfigsResponse.java new file mode 100644 index 000000000000..8f8025980822 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerConfigsResponse.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; +import java.util.Map; + +/** Response body for the Schema Designer list-configs endpoint. */ +public class SchemaDesignerConfigsResponse extends SolrJerseyResponse { + + /** + * Map of configSet name to status: 0 = in-progress (temp only), 1 = disabled, 2 = enabled and + * published. + */ + @JsonProperty("configSets") + public Map configSets; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerInfoResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerInfoResponse.java new file mode 100644 index 000000000000..104495af07da --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerInfoResponse.java @@ -0,0 +1,67 @@ +/* + * 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.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** Response body for the Schema Designer get-info endpoint. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SchemaDesignerInfoResponse extends SolrJerseyResponse { + + @JsonProperty("configSet") + public String configSet; + + /** Whether the configSet has a published (live) version. */ + @JsonProperty("published") + public Boolean published; + + @JsonProperty("schemaVersion") + public Integer schemaVersion; + + /** Collections currently using this configSet. */ + @JsonProperty("collections") + public List collections; + + /** Number of sample documents stored for this configSet, if available. */ + @JsonProperty("numDocs") + public Integer numDocs; + + // --- designer settings --- + + @JsonProperty("languages") + public List languages; + + @JsonProperty("enableFieldGuessing") + public Boolean enableFieldGuessing; + + @JsonProperty("enableDynamicFields") + public Boolean enableDynamicFields; + + @JsonProperty("enableNestedDocs") + public Boolean enableNestedDocs; + + @JsonProperty("disabled") + public Boolean disabled; + + @JsonProperty("publishedVersion") + public Integer publishedVersion; + + @JsonProperty("copyFrom") + public String copyFrom; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerPublishResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerPublishResponse.java new file mode 100644 index 000000000000..b6d7038ff2fa --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerPublishResponse.java @@ -0,0 +1,45 @@ +/* + * 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.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Response body for the Schema Designer publish endpoint. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SchemaDesignerPublishResponse extends SolrJerseyResponse { + + @JsonProperty("configSet") + public String configSet; + + @JsonProperty("schemaVersion") + public Integer schemaVersion; + + /** The new collection created during publish, if requested. */ + @JsonProperty("newCollection") + public String newCollection; + + /** Error message if indexing sample docs into the new collection failed. */ + @JsonProperty("updateError") + public String updateError; + + @JsonProperty("updateErrorCode") + public Integer updateErrorCode; + + @JsonProperty("errorDetails") + public Object errorDetails; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java new file mode 100644 index 000000000000..1be4a182f849 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java @@ -0,0 +1,170 @@ +/* + * 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.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; + +/** + * Response body for Schema Designer endpoints that operate on a full schema: {@code prepNewSchema}, + * {@code updateFileContents}, {@code addSchemaObject}, {@code updateSchemaObject}, and {@code + * analyze}. + * + *

All nullable fields are omitted from JSON output when null. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SchemaDesignerResponse extends SolrJerseyResponse { + + // --- core schema identification --- + + @JsonProperty("configSet") + public String configSet; + + @JsonProperty("schemaVersion") + public Integer schemaVersion; + + /** The temporary mutable collection used during design (e.g. {@code ._designer_myConfig}). */ + @JsonProperty("tempCollection") + public String tempCollection; + + /** Active replica core name for the temp collection, used for Luke API calls. */ + @JsonProperty("core") + public String core; + + @JsonProperty("uniqueKeyField") + public String uniqueKeyField; + + /** Collections currently using the published version of this configSet. */ + @JsonProperty("collectionsForConfig") + public List collectionsForConfig; + + // --- schema objects --- + + @JsonProperty("fields") + public List> fields; + + @JsonProperty("dynamicFields") + public List> dynamicFields; + + @JsonProperty("fieldTypes") + public List> fieldTypes; + + /** ConfigSet files available in ZooKeeper (excluding managed-schema and internal files). */ + @JsonProperty("files") + public List files; + + /** IDs of the first 100 sample documents (present when docs were loaded/analyzed). */ + @JsonProperty("docIds") + public List docIds; + + /** Total number of sample documents, or -1 when no docs were passed to the endpoint. */ + @JsonProperty("numDocs") + public Integer numDocs; + + // --- designer settings --- + + @JsonProperty("languages") + public List languages; + + @JsonProperty("enableFieldGuessing") + public Boolean enableFieldGuessing; + + @JsonProperty("enableDynamicFields") + public Boolean enableDynamicFields; + + @JsonProperty("enableNestedDocs") + public Boolean enableNestedDocs; + + @JsonProperty("disabled") + public Boolean disabled; + + @JsonProperty("publishedVersion") + public Integer publishedVersion; + + @JsonProperty("copyFrom") + public String copyFrom; + + // --- error fields (set when sample-doc indexing fails) --- + + @JsonProperty("updateError") + public String updateError; + + @JsonProperty("updateErrorCode") + public Integer updateErrorCode; + + @JsonProperty("errorDetails") + public Object errorDetails; + + // --- endpoint-specific fields --- + + /** Source of the sample documents (e.g. "blob", "request"); set by {@code analyze}. */ + @JsonProperty("sampleSource") + public String sampleSource; + + /** Analysis warning when field-type inference produced errors; set by {@code analyze}. */ + @JsonProperty("analysisError") + public String analysisError; + + /** + * The type of schema object that was updated: {@code "field"} or {@code "type"}; set by {@code + * updateSchemaObject}. + */ + @JsonProperty("updateType") + public String updateType; + + /** + * The updated field definition map; populated when {@code updateType} is {@code "field"} in + * {@code updateSchemaObject}, or the field name string when returned by {@code addSchemaObject}. + */ + @JsonProperty("field") + public Object field; + + /** + * The updated field-type definition map; populated when {@code updateType} is {@code "type"} in + * {@code updateSchemaObject}, or the type name string when returned by {@code addSchemaObject}. + */ + @JsonProperty("type") + public Object type; + + /** + * The added dynamic-field name; set by {@code addSchemaObject} when adding a dynamic field. + */ + @JsonProperty("dynamicField") + public Object dynamicField; + + /** + * The added field-type name; set by {@code addSchemaObject} when adding a field type. + */ + @JsonProperty("fieldType") + public Object fieldType; + + /** + * Whether the temp collection needs to be rebuilt after this update; set by {@code + * updateSchemaObject}. + */ + @JsonProperty("rebuild") + public Boolean rebuild; + + /** + * Error message when a file update (e.g. {@code solrconfig.xml}) fails validation; set by + * {@code updateFileContents}. + */ + @JsonProperty("updateFileError") + public String updateFileError; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java new file mode 100644 index 000000000000..cc70b1ebfa93 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java @@ -0,0 +1,57 @@ +/* + * 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.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; +/** Response body for the Schema Designer get-schema-diff endpoint. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SchemaDesignerSchemaDiffResponse extends SolrJerseyResponse { + + /** The list of field-level differences between the designed schema and the source. */ + @JsonProperty("diff") + public Map diff; + + /** The configSet used as the diff source (either the published configSet or copyFrom). */ + @JsonProperty("diff-source") + public String diffSource; + + // --- designer settings (reflected from the mutable configSet) --- + + @JsonProperty("languages") + public List languages; + + @JsonProperty("enableFieldGuessing") + public Boolean enableFieldGuessing; + + @JsonProperty("enableDynamicFields") + public Boolean enableDynamicFields; + + @JsonProperty("enableNestedDocs") + public Boolean enableNestedDocs; + + @JsonProperty("disabled") + public Boolean disabled; + + @JsonProperty("publishedVersion") + public Integer publishedVersion; + + @JsonProperty("copyFrom") + public String copyFrom; +} diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index 9aee826cfe04..6cb8c15e7b34 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -1235,6 +1235,9 @@ SchemaDesignerResponse buildSchemaDesignerResponse( /** Sets the named schema-object field on {@code response} based on the action type. */ private static void setSchemaObjectField( SchemaDesignerResponse response, String action, Object value) { + // Handles both bare camelCase names used internally ('field', 'fieldType') and the + // kebab-case prefixed names that come directly from Schema API request JSON + // ('add-field', 'add-field-type', 'add-dynamic-field'). switch (action) { case "field", "add-field" -> response.field = value; case "type", "add-type" -> response.type = value; From 9e3f38d6ed01dd162335dca2c5df8e689db7af2d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:00:28 +0000 Subject: [PATCH 23/69] Replace FlexibleSolrJerseyResponse with typed POJOs in Schema Designer API Agent-Logs-Url: https://github.com/epugh/solr/sessions/507f5cc3-13ae-4824-a6c1-ef4f98052d35 Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../api/endpoint/SchemaDesignerApi.java | 4 ++-- .../api/model/SchemaDesignerResponse.java | 19 +++++++++++-------- .../SchemaDesignerSchemaDiffResponse.java | 1 + .../handler/configsets/DownloadConfigSet.java | 2 +- .../solr/handler/designer/SchemaDesigner.java | 2 +- .../handler/designer/TestSchemaDesigner.java | 1 + .../js/angular/controllers/schema-designer.js | 4 ++-- 7 files changed, 19 insertions(+), 14 deletions(-) diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index 6eeba0315455..3a7cb5992711 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -95,8 +95,8 @@ FlexibleSolrJerseyResponse getSampleValue( @Operation( summary = "List collections that use a given configSet.", tags = {"schema-designer"}) - SchemaDesignerCollectionsResponse listCollectionsForConfig(@PathParam("configSet") String configSet) - throws Exception; + SchemaDesignerCollectionsResponse listCollectionsForConfig( + @PathParam("configSet") String configSet) throws Exception; @GET @Path("/configs") diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java index 1be4a182f849..d41f79d64bb5 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java @@ -142,15 +142,11 @@ public class SchemaDesignerResponse extends SolrJerseyResponse { @JsonProperty("type") public Object type; - /** - * The added dynamic-field name; set by {@code addSchemaObject} when adding a dynamic field. - */ + /** The added dynamic-field name; set by {@code addSchemaObject} when adding a dynamic field. */ @JsonProperty("dynamicField") public Object dynamicField; - /** - * The added field-type name; set by {@code addSchemaObject} when adding a field type. - */ + /** The added field-type name; set by {@code addSchemaObject} when adding a field type. */ @JsonProperty("fieldType") public Object fieldType; @@ -162,9 +158,16 @@ public class SchemaDesignerResponse extends SolrJerseyResponse { public Boolean rebuild; /** - * Error message when a file update (e.g. {@code solrconfig.xml}) fails validation; set by - * {@code updateFileContents}. + * Error message when a file update (e.g. {@code solrconfig.xml}) fails validation; set by {@code + * updateFileContents}. */ @JsonProperty("updateFileError") public String updateFileError; + + /** + * The raw file content returned when a file update fails validation; set by {@code + * updateFileContents} so the UI can display the attempted content alongside the error. + */ + @JsonProperty("fileContent") + public String fileContent; } diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java index cc70b1ebfa93..f5b9f61d7de0 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; import java.util.Map; + /** Response body for the Schema Designer get-schema-diff endpoint. */ @JsonInclude(JsonInclude.Include.NON_NULL) public class SchemaDesignerSchemaDiffResponse extends SolrJerseyResponse { 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 index 872e7ebd4613..20d72a9e9f00 100644 --- a/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java +++ b/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java @@ -41,7 +41,7 @@ import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.response.SolrQueryResponse; -/** V2 API implementation for {@link ConfigsetsApi.Download}. */ +/** V2 API implementation for the configset download endpoint. */ public class DownloadConfigSet extends ConfigSetAPIBase implements ConfigsetsApi.Download { @Inject diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index 6cb8c15e7b34..651920e48342 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -282,7 +282,7 @@ public SchemaDesignerResponse updateFileContents(String configSet, String file) SchemaDesignerResponse errorResponse = instantiateJerseyResponse(SchemaDesignerResponse.class); errorResponse.updateFileError = causedBy.getMessage(); - errorResponse.field = new String(data, StandardCharsets.UTF_8); + errorResponse.fileContent = new String(data, StandardCharsets.UTF_8); return errorResponse; } diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java index cbc32ba2bb7d..bc69c33750f3 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java @@ -652,6 +652,7 @@ public void testSchemaDiffEndpoint() throws Exception { response = schemaDesigner.analyze(configSet, null, null, null, null, true, false, null); // Update id field to not use docValues + @SuppressWarnings("unchecked") List> fields = (List>) (List) response.fields; SimpleOrderedMap idFieldMap = diff --git a/solr/webapp/web/js/angular/controllers/schema-designer.js b/solr/webapp/web/js/angular/controllers/schema-designer.js index bab782a48093..fd56cd405a6a 100644 --- a/solr/webapp/web/js/angular/controllers/schema-designer.js +++ b/solr/webapp/web/js/angular/controllers/schema-designer.js @@ -889,8 +889,8 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, SchemaDesigner.put(params, $scope.fileNodeText, function (data) { if (data.updateFileError) { - if (data[$scope.selectedFile]) { - $scope.fileNodeText = data[$scope.selectedFile]; + if (data.fileContent) { + $scope.fileNodeText = data.fileContent; } $scope.updateFileError = data.updateFileError; } else { From f53de8fee8351c4a1307faeb9fd7c72cbaa93c6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:25:17 +0000 Subject: [PATCH 24/69] Add BATS integration test for Schema Designer API endpoints Agent-Logs-Url: https://github.com/epugh/solr/sessions/b74e99f0-20cb-45c6-afa8-b2acdd295385 Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- solr/packaging/test/test_schema_designer.bats | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 solr/packaging/test/test_schema_designer.bats diff --git a/solr/packaging/test/test_schema_designer.bats b/solr/packaging/test/test_schema_designer.bats new file mode 100644 index 000000000000..b769973c37b8 --- /dev/null +++ b/solr/packaging/test/test_schema_designer.bats @@ -0,0 +1,184 @@ +#!/usr/bin/env bats + +# 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. + +load bats_helper + +# A configSet name used throughout these tests +DESIGNER_CONFIGSET="bats_books" + +setup_file() { + common_clean_setup + solr start + solr assert --started http://localhost:${SOLR_PORT} --timeout 60000 +} + +teardown_file() { + common_setup + solr stop --all +} + +setup() { + common_setup +} + +teardown() { + save_home_on_failure + + # Best-effort cleanup of the designer draft so tests remain independent + curl -s -X DELETE "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}" > /dev/null || true +} + +# --------------------------------------------------------------------------- +# 1. List configs — the endpoint should return a JSON object with a configSets +# property even when no designer drafts exist yet. +# --------------------------------------------------------------------------- +@test "list schema-designer configs returns JSON with configSets key" { + run curl -s "http://localhost:${SOLR_PORT}/api/schema-designer/configs" + assert_output --partial '"configSets"' + refute_output --partial '"status":400' + refute_output --partial '"status":500' +} + +# --------------------------------------------------------------------------- +# 2. Prepare a new mutable draft configSet +# --------------------------------------------------------------------------- +@test "prep new schema-designer configSet" { + run curl -s -X POST \ + "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" + assert_output --partial '"configSet"' + refute_output --partial '"status":400' + refute_output --partial '"status":500' +} + +# --------------------------------------------------------------------------- +# 3. Get info for the prepared configSet +# --------------------------------------------------------------------------- +@test "get info for schema-designer configSet" { + # Prepare first + curl -s -X POST \ + "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" \ + > /dev/null + + run curl -s "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/info" + assert_output --partial '"configSet"' + assert_output --partial "${DESIGNER_CONFIGSET}" + refute_output --partial '"status":400' + refute_output --partial '"status":500' +} + +# --------------------------------------------------------------------------- +# 4. Analyze sample documents — sends books.json as the request body +# --------------------------------------------------------------------------- +@test "analyze sample documents for schema-designer configSet" { + # Prepare the draft first + curl -s -X POST \ + "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" \ + > /dev/null + + run curl -s -X POST \ + -H "Content-Type: application/json" \ + --data-binary "@${SOLR_TIP}/example/exampledocs/books.json" \ + "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/analyze" + assert_output --partial '"configSet"' + refute_output --partial '"status":400' + refute_output --partial '"status":500' +} + +# --------------------------------------------------------------------------- +# 5. Query the temporary collection — should return documents after analyze +# --------------------------------------------------------------------------- +@test "query schema-designer configSet returns documents" { + # Prepare and analyze to load sample docs + curl -s -X POST \ + "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" \ + > /dev/null + curl -s -X POST \ + -H "Content-Type: application/json" \ + --data-binary "@${SOLR_TIP}/example/exampledocs/books.json" \ + "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/analyze" \ + > /dev/null + + run curl -s \ + "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/query?q=*:*" + assert_output --partial '"numFound"' + refute_output --partial '"status":400' + refute_output --partial '"status":500' +} + +# --------------------------------------------------------------------------- +# 6. Download configSet zip via the generic configsets endpoint. +# This is the primary new endpoint introduced by the migration. +# We verify: +# - HTTP 200 response +# - Content-Disposition header with a .zip filename +# - The response body is a valid zip (starts with the PK magic bytes) +# --------------------------------------------------------------------------- +@test "download schema-designer configSet as zip" { + # Prepare the draft + curl -s -X POST \ + "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" \ + > /dev/null + + local mutable_id="._designer_${DESIGNER_CONFIGSET}" + local zip_file="${BATS_TEST_TMPDIR}/${DESIGNER_CONFIGSET}.zip" + + # Capture HTTP status code separately + local http_code + http_code=$(curl -s -o "${zip_file}" -w "%{http_code}" \ + "http://localhost:${SOLR_PORT}/api/configsets/${mutable_id}/download?displayName=${DESIGNER_CONFIGSET}") + + # Assert HTTP 200 + [ "${http_code}" = "200" ] + + # Assert the file was written and is non-empty + [ -s "${zip_file}" ] + + # Assert the file starts with the ZIP magic bytes (PK = 0x504B) + run bash -c "xxd '${zip_file}' | head -1" + assert_output --partial '504b' +} + +# --------------------------------------------------------------------------- +# 7. Download configSet zip — Content-Disposition header carries the filename +# --------------------------------------------------------------------------- +@test "download schema-designer configSet has correct Content-Disposition header" { + # Prepare the draft + curl -s -X POST \ + "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" \ + > /dev/null + + local mutable_id="._designer_${DESIGNER_CONFIGSET}" + + run curl -s -I \ + "http://localhost:${SOLR_PORT}/api/configsets/${mutable_id}/download?displayName=${DESIGNER_CONFIGSET}" + assert_output --partial 'Content-Disposition' + assert_output --partial '.zip' +} + +# --------------------------------------------------------------------------- +# 8. Cleanup (DELETE) removes the designer draft +# --------------------------------------------------------------------------- +@test "cleanup schema-designer configSet succeeds" { + # Prepare first so there is something to delete + curl -s -X POST \ + "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" \ + > /dev/null + + run curl -s -o /dev/null -w "%{http_code}" \ + -X DELETE "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}" + assert_output "200" +} From 13a2abf2da8a8418f39cf18d85af183025ae63e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 18:38:14 +0000 Subject: [PATCH 25/69] Move getFileContents to ConfigsetsApi/GetConfigSetFile; add Configsets $resource in JS Agent-Logs-Url: https://github.com/epugh/solr/sessions/adec0806-852d-4a34-a0fb-aef713d8bf76 Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../client/api/endpoint/ConfigsetsApi.java | 20 +++ .../api/endpoint/SchemaDesignerApi.java | 8 - .../model/ConfigSetFileContentsResponse.java | 31 ++++ .../solr/handler/admin/ConfigSetsHandler.java | 4 +- .../handler/configsets/GetConfigSetFile.java | 81 +++++++++++ .../solr/handler/designer/SchemaDesigner.java | 18 --- .../configsets/GetConfigSetFileAPITest.java | 137 ++++++++++++++++++ .../handler/designer/TestSchemaDesigner.java | 10 +- .../js/angular/controllers/schema-designer.js | 8 +- solr/webapp/web/js/angular/services.js | 6 + 10 files changed, 290 insertions(+), 33 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/GetConfigSetFile.java create mode 100644 solr/core/src/test/org/apache/solr/handler/configsets/GetConfigSetFileAPITest.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 a1152e2b86a4..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 @@ -30,10 +30,12 @@ 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; @@ -99,6 +101,24 @@ Response downloadConfigSet( 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/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index 3a7cb5992711..713d529703ea 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -62,14 +62,6 @@ SchemaDesignerResponse prepNewSchema( tags = {"schema-designer"}) SolrJerseyResponse cleanupTempSchema(@PathParam("configSet") String configSet) throws Exception; - @GET - @Path("/{configSet}/file") - @Operation( - summary = "Get the contents of a file in a configSet being designed.", - tags = {"schema-designer"}) - FlexibleSolrJerseyResponse getFileContents( - @PathParam("configSet") String configSet, @QueryParam("file") String file) throws Exception; - @PUT @Path("/{configSet}/file") @Operation( 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 afd37d653ab8..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 @@ -40,6 +40,7 @@ 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; @@ -192,7 +193,8 @@ public Collection> getJerseyResources() { CloneConfigSet.class, DeleteConfigSet.class, UploadConfigSet.class, - DownloadConfigSet.class); + DownloadConfigSet.class, + GetConfigSetFile.class); } @Override 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..f250ec6bfa3f --- /dev/null +++ b/solr/core/src/java/org/apache/solr/handler/configsets/GetConfigSetFile.java @@ -0,0 +1,81 @@ +/* + * 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. + * + *

This API (GET /api/configsets/{configSetName}/file?path=...) is a general-purpose endpoint + * that works for any configset, including the temporary schema-designer drafts. + */ +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) throws IOException { + try { + return configSetService.downloadFileFromConfig(configSetName, filePath); + } 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/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index 651920e48342..4c776bfcc25e 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -225,24 +225,6 @@ public SolrJerseyResponse cleanupTempSchema(String configSet) throws Exception { return instantiateJerseyResponse(SolrJerseyResponse.class); } - @Override - @PermissionName(CONFIG_READ_PERM) - public FlexibleSolrJerseyResponse getFileContents(String configSet, String file) - throws Exception { - requireNotEmpty(CONFIG_SET_PARAM, configSet); - requireNotEmpty("file", file); - String filePath = getConfigSetZkPath(getMutableId(configSet), file); - byte[] data; - try { - data = zkStateReader().getZkClient().getData(filePath, null, null); - } catch (KeeperException | InterruptedException e) { - throw new IOException("Error reading file: " + filePath, SolrZkClient.checkInterrupted(e)); - } - String stringData = - data != null && data.length > 0 ? new String(data, StandardCharsets.UTF_8) : ""; - return buildFlexibleResponse(Collections.singletonMap(file, stringData)); - } - @Override @PermissionName(CONFIG_EDIT_PERM) public SchemaDesignerResponse updateFileContents(String configSet, String file) throws Exception { 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..255d74c260ce --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/configsets/GetConfigSetFileAPITest.java @@ -0,0 +1,137 @@ +/* + * 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.IOException; +import java.nio.charset.StandardCharsets; +import org.apache.solr.SolrTestCase; +import org.apache.solr.client.api.model.ConfigSetFileContentsResponse; +import org.apache.solr.common.SolrException; +import org.apache.solr.core.ConfigSetService; +import org.apache.solr.core.CoreContainer; +import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.response.SolrQueryResponse; +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 ConfigSetService mockConfigSetService; + private SolrQueryRequest mockRequest; + private SolrQueryResponse mockResponse; + + @BeforeClass + public static void ensureWorkingMockito() { + assumeWorkingMockito(); + } + + @Before + public void setUpMocks() { + mockCoreContainer = mock(CoreContainer.class); + mockConfigSetService = mock(ConfigSetService.class); + mockRequest = mock(SolrQueryRequest.class); + mockResponse = mock(SolrQueryResponse.class); + when(mockCoreContainer.getConfigSetService()).thenReturn(mockConfigSetService); + } + + @Test + public void testMissingConfigSetNameThrowsBadRequest() { + final var api = new GetConfigSetFile(mockCoreContainer, mockRequest, mockResponse); + 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, mockRequest, mockResponse); + 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() throws Exception { + when(mockConfigSetService.checkConfigExists("missing")).thenReturn(false); + + final var api = new GetConfigSetFile(mockCoreContainer, mockRequest, mockResponse); + 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 = ""; + + when(mockConfigSetService.checkConfigExists(configSetName)).thenReturn(true); + when(mockConfigSetService.downloadFileFromConfig(configSetName, filePath)) + .thenReturn(fileContent.getBytes(StandardCharsets.UTF_8)); + + final var api = new GetConfigSetFile(mockCoreContainer, mockRequest, mockResponse); + 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"; + when(mockConfigSetService.checkConfigExists(configSetName)).thenReturn(true); + when(mockConfigSetService.downloadFileFromConfig(configSetName, "missing.xml")) + .thenThrow(new IOException("not found")); + + final var api = new GetConfigSetFile(mockCoreContainer, mockRequest, mockResponse); + 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"; + + when(mockConfigSetService.checkConfigExists(configSetName)).thenReturn(true); + when(mockConfigSetService.downloadFileFromConfig(configSetName, filePath)) + .thenReturn(new byte[0]); + + final var api = new GetConfigSetFile(mockCoreContainer, mockRequest, mockResponse); + 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/designer/TestSchemaDesigner.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java index bc69c33750f3..eedaf3d976b0 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java @@ -34,6 +34,7 @@ import java.util.Map; import java.util.Optional; import java.util.stream.Stream; +import org.apache.solr.client.api.model.ConfigSetFileContentsResponse; import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; import org.apache.solr.client.api.model.SchemaDesignerCollectionsResponse; import org.apache.solr.client.api.model.SchemaDesignerInfoResponse; @@ -53,7 +54,9 @@ import org.apache.solr.common.util.SimpleOrderedMap; import org.apache.solr.core.CoreContainer; import org.apache.solr.handler.TestSampleDocumentsLoader; +import org.apache.solr.handler.configsets.GetConfigSetFile; import org.apache.solr.request.SolrQueryRequest; +import org.apache.solr.response.SolrQueryResponse; import org.apache.solr.schema.ManagedIndexSchema; import org.apache.solr.schema.SchemaField; import org.apache.solr.util.ExternalPaths; @@ -346,8 +349,11 @@ public void testBasicUserWorkflow() throws Exception { } } assertNotNull("solrconfig.xml not found in files!", file); - FlexibleSolrJerseyResponse fileContentsResp = schemaDesigner.getFileContents(configSet, file); - String solrconfigXml = (String) fileContentsResp.unknownProperties().get(file); + GetConfigSetFile getFileApi = new GetConfigSetFile(cc, mockReq, mock(SolrQueryResponse.class)); + String fileMutableId = getMutableId(configSet); + ConfigSetFileContentsResponse fileContentsResp = + getFileApi.getConfigSetFile(fileMutableId, file); + String solrconfigXml = fileContentsResp.content; assertNotNull(solrconfigXml); // Update solrconfig.xml diff --git a/solr/webapp/web/js/angular/controllers/schema-designer.js b/solr/webapp/web/js/angular/controllers/schema-designer.js index fd56cd405a6a..fdf7c68b7ba0 100644 --- a/solr/webapp/web/js/angular/controllers/schema-designer.js +++ b/solr/webapp/web/js/angular/controllers/schema-designer.js @@ -15,7 +15,7 @@ limitations under the License. */ -solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) { +solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Configsets, Luke) { $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE); $scope.schemas = []; @@ -904,9 +904,9 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $scope.onSelectFileNode = function (id, doSelectOnTree) { $scope.selectedFile = id.startsWith("files/") ? id.substring("files/".length) : id; - var params = {path: "file", file: $scope.selectedFile, configSet: $scope.currentSchema}; - SchemaDesigner.get(params, function (data) { - $scope.fileNodeText = data[$scope.selectedFile]; + var mutableId = "._designer_" + $scope.currentSchema; + Configsets.get({configSetName: mutableId, endpoint: "file", path: $scope.selectedFile}, function (data) { + $scope.fileNodeText = data.content; $scope.isLeafNode = false; if (doSelectOnTree) { delete $scope.selectedNode; diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index 7882630c67ee..c72948546459 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -281,6 +281,12 @@ solrAdminServices.factory('System', upload: {method: "POST", transformRequest: angular.identity, headers: {'Content-Type': undefined}, timeout: 90000} }) }]) +.factory('Configsets', + ['$resource', function($resource) { + return $resource('/api/configsets/:configSetName/:endpoint', {wt: 'json', configSetName: '@configSetName', endpoint: '@endpoint', _:Date.now()}, { + get: {method: "GET"} + }) +}]) .factory('Security', ['$resource', function($resource) { return $resource('/api/cluster/security/:path', {wt: 'json', path: '@path', _:Date.now()}, { From b6a69de970af39dc5ec813f0e5c59023ce0f69c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:14:13 +0000 Subject: [PATCH 26/69] Reduce mocking: use real FileSystemConfigSetService in configsets API tests Agent-Logs-Url: https://github.com/epugh/solr/sessions/10ac2a78-7ae6-44d5-858d-e74dd03593ea Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../handler/configsets/GetConfigSetFile.java | 8 +- .../configsets/DownloadConfigSetAPITest.java | 86 ++++++++----------- .../configsets/GetConfigSetFileAPITest.java | 62 +++++++------ 3 files changed, 73 insertions(+), 83 deletions(-) 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 index f250ec6bfa3f..238ffc55a3fb 100644 --- a/solr/core/src/java/org/apache/solr/handler/configsets/GetConfigSetFile.java +++ b/solr/core/src/java/org/apache/solr/handler/configsets/GetConfigSetFile.java @@ -70,7 +70,13 @@ public ConfigSetFileContentsResponse getConfigSetFile(String configSetName, Stri private byte[] downloadFileFromConfig(String configSetName, String filePath) throws IOException { try { - return configSetService.downloadFileFromConfig(configSetName, filePath); + 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, 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 index 5f917ed46291..3101f97cc8b8 100644 --- a/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java +++ b/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java @@ -18,23 +18,17 @@ package org.apache.solr.handler.configsets; import static org.apache.solr.SolrTestCaseJ4.assumeWorkingMockito; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import jakarta.ws.rs.core.Response; -import java.io.IOException; 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.ConfigSetService; import org.apache.solr.core.CoreContainer; -import org.apache.solr.request.SolrQueryRequest; -import org.apache.solr.response.SolrQueryResponse; +import org.apache.solr.core.FileSystemConfigSetService; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; @@ -43,9 +37,8 @@ public class DownloadConfigSetAPITest extends SolrTestCase { private CoreContainer mockCoreContainer; - private ConfigSetService mockConfigSetService; - private SolrQueryRequest mockRequest; - private SolrQueryResponse mockResponse; + private FileSystemConfigSetService configSetService; + private Path configSetBase; @BeforeClass public static void ensureWorkingMockito() { @@ -53,17 +46,24 @@ public static void ensureWorkingMockito() { } @Before - public void setUpMocks() { + public void initConfigSetService() throws Exception { + configSetBase = createTempDir("configsets"); + // Use an anonymous subclass to access the protected testing constructor + configSetService = new FileSystemConfigSetService(configSetBase) {}; mockCoreContainer = mock(CoreContainer.class); - mockConfigSetService = mock(ConfigSetService.class); - mockRequest = mock(SolrQueryRequest.class); - mockResponse = mock(SolrQueryResponse.class); - when(mockCoreContainer.getConfigSetService()).thenReturn(mockConfigSetService); + 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 public void testMissingConfigSetNameThrowsBadRequest() { - final var api = new DownloadConfigSet(mockCoreContainer, mockRequest, mockResponse); + 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()); @@ -72,33 +72,18 @@ public void testMissingConfigSetNameThrowsBadRequest() { } @Test - public void testNonExistentConfigSetThrowsNotFound() throws Exception { - when(mockConfigSetService.checkConfigExists("missing")).thenReturn(false); - - final var api = new DownloadConfigSet(mockCoreContainer, mockRequest, mockResponse); + 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()); } - /** Stubs {@code configSetService.downloadConfig(configSetId, dir)} to write one file. */ - private void stubDownloadConfig(String configSetId, String fileName, String content) - throws IOException { - doAnswer( - inv -> { - Path dir = inv.getArgument(1); - Files.writeString(dir.resolve(fileName), content, StandardCharsets.UTF_8); - return null; - }) - .when(mockConfigSetService) - .downloadConfig(eq(configSetId), any(Path.class)); - } - @Test public void testSuccessfulDownloadReturnsZipResponse() throws Exception { - when(mockConfigSetService.checkConfigExists("myconfig")).thenReturn(true); - stubDownloadConfig("myconfig", "solrconfig.xml", ""); + createConfigSet("myconfig", "solrconfig.xml", ""); - final var api = new DownloadConfigSet(mockCoreContainer, mockRequest, mockResponse); + final var api = new DownloadConfigSet(mockCoreContainer, null, null); final Response response = api.downloadConfigSet("myconfig", null); assertNotNull(response); @@ -111,28 +96,29 @@ public void testSuccessfulDownloadReturnsZipResponse() throws Exception { @Test public void testFilenameIsSanitized() throws Exception { - final String unsafeName = "my/config"; - when(mockConfigSetService.checkConfigExists(unsafeName)).thenReturn(true); - stubDownloadConfig(unsafeName, "schema.xml", ""); + // 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, mockRequest, mockResponse); - final Response response = api.downloadConfigSet(unsafeName, null); + final var api = new DownloadConfigSet(mockCoreContainer, null, null); + 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 contain unsafe characters", - disposition.contains("/") || disposition.contains("<") || disposition.contains(">")); - assertTrue(disposition.contains("_configset.zip")); + "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"; - when(mockConfigSetService.checkConfigExists(mutableId)).thenReturn(true); - stubDownloadConfig(mutableId, "schema.xml", ""); + createConfigSet(mutableId, "schema.xml", ""); - final var api = new DownloadConfigSet(mockCoreContainer, mockRequest, mockResponse); + final var api = new DownloadConfigSet(mockCoreContainer, null, null); final Response response = api.downloadConfigSet(mutableId, "films"); assertNotNull(response); @@ -147,11 +133,11 @@ public void testDisplayNameOverridesFilename() throws Exception { } @Test - public void testBuildZipResponseUsesDisplayName() throws IOException { - stubDownloadConfig("_designer_films", "schema.xml", ""); + public void testBuildZipResponseUsesDisplayName() throws Exception { + createConfigSet("_designer_films", "schema.xml", ""); final Response response = - DownloadConfigSet.buildZipResponse(mockConfigSetService, "_designer_films", "films"); + DownloadConfigSet.buildZipResponse(configSetService, "_designer_films", "films"); assertNotNull(response); assertEquals(200, response.getStatus()); 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 index 255d74c260ce..fda1e6fdd872 100644 --- a/solr/core/src/test/org/apache/solr/handler/configsets/GetConfigSetFileAPITest.java +++ b/solr/core/src/test/org/apache/solr/handler/configsets/GetConfigSetFileAPITest.java @@ -21,15 +21,14 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import java.io.IOException; 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.ConfigSetService; import org.apache.solr.core.CoreContainer; -import org.apache.solr.request.SolrQueryRequest; -import org.apache.solr.response.SolrQueryResponse; +import org.apache.solr.core.FileSystemConfigSetService; import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; @@ -38,9 +37,8 @@ public class GetConfigSetFileAPITest extends SolrTestCase { private CoreContainer mockCoreContainer; - private ConfigSetService mockConfigSetService; - private SolrQueryRequest mockRequest; - private SolrQueryResponse mockResponse; + private FileSystemConfigSetService configSetService; + private Path configSetBase; @BeforeClass public static void ensureWorkingMockito() { @@ -48,17 +46,25 @@ public static void ensureWorkingMockito() { } @Before - public void setUpMocks() { + public void initConfigSetService() throws Exception { + configSetBase = createTempDir("configsets"); + // Use an anonymous subclass to access the protected testing constructor + configSetService = new FileSystemConfigSetService(configSetBase) {}; mockCoreContainer = mock(CoreContainer.class); - mockConfigSetService = mock(ConfigSetService.class); - mockRequest = mock(SolrQueryRequest.class); - mockResponse = mock(SolrQueryResponse.class); - when(mockCoreContainer.getConfigSetService()).thenReturn(mockConfigSetService); + 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, mockRequest, mockResponse); + 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()); @@ -69,7 +75,7 @@ public void testMissingConfigSetNameThrowsBadRequest() { @Test public void testMissingFilePathThrowsBadRequest() { - final var api = new GetConfigSetFile(mockCoreContainer, mockRequest, mockResponse); + 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()); @@ -78,10 +84,9 @@ public void testMissingFilePathThrowsBadRequest() { } @Test - public void testNonExistentConfigSetThrowsNotFound() throws Exception { - when(mockConfigSetService.checkConfigExists("missing")).thenReturn(false); - - final var api = new GetConfigSetFile(mockCoreContainer, mockRequest, mockResponse); + 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()); @@ -92,12 +97,9 @@ public void testSuccessfulFileRead() throws Exception { final String configSetName = "myconfig"; final String filePath = "schema.xml"; final String fileContent = ""; + createConfigSetWithFile(configSetName, filePath, fileContent); - when(mockConfigSetService.checkConfigExists(configSetName)).thenReturn(true); - when(mockConfigSetService.downloadFileFromConfig(configSetName, filePath)) - .thenReturn(fileContent.getBytes(StandardCharsets.UTF_8)); - - final var api = new GetConfigSetFile(mockCoreContainer, mockRequest, mockResponse); + final var api = new GetConfigSetFile(mockCoreContainer, null, null); final ConfigSetFileContentsResponse response = api.getConfigSetFile(configSetName, filePath); assertNotNull(response); @@ -108,11 +110,10 @@ public void testSuccessfulFileRead() throws Exception { @Test public void testFileNotFoundInConfigSetThrowsNotFound() throws Exception { final String configSetName = "myconfig"; - when(mockConfigSetService.checkConfigExists(configSetName)).thenReturn(true); - when(mockConfigSetService.downloadFileFromConfig(configSetName, "missing.xml")) - .thenThrow(new IOException("not found")); + // Create the configset directory but do NOT add the requested file + Files.createDirectories(configSetBase.resolve(configSetName)); - final var api = new GetConfigSetFile(mockCoreContainer, mockRequest, mockResponse); + 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()); @@ -122,12 +123,9 @@ public void testFileNotFoundInConfigSetThrowsNotFound() throws Exception { public void testEmptyFileReturnsEmptyContent() throws Exception { final String configSetName = "myconfig"; final String filePath = "empty.xml"; + createConfigSetWithFile(configSetName, filePath, ""); - when(mockConfigSetService.checkConfigExists(configSetName)).thenReturn(true); - when(mockConfigSetService.downloadFileFromConfig(configSetName, filePath)) - .thenReturn(new byte[0]); - - final var api = new GetConfigSetFile(mockCoreContainer, mockRequest, mockResponse); + final var api = new GetConfigSetFile(mockCoreContainer, null, null); final ConfigSetFileContentsResponse response = api.getConfigSetFile(configSetName, filePath); assertNotNull(response); From 42db2a3f3914db05d4b6d7390bd1d4ed78013289 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 3 Apr 2026 09:40:58 -0400 Subject: [PATCH 27/69] Mention new capablities in solrj in changelog --- ...-configset-download-zip-to-solrj-fix-schema-designer-bug.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml b/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml index 7bec4bc52644..68a7cec731e6 100644 --- a/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml +++ b/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml @@ -1,5 +1,5 @@ # See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc -title: The "analyze" existing documents feature of Schema Designer was fixed. Added a new ConfigSet.Download capablity to SolrJ. +title: The "analyze" existing documents feature of Schema Designer was fixed. Added a new ConfigSet.Download and ConfigSet.GetFile capablities to SolrJ. type: fixed # added, changed, fixed, deprecated, removed, dependency_update, security, other authors: - name: Eric Pugh From ac0a661fcbdabd08c67e146120c0250154ad660c Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 3 Apr 2026 09:41:17 -0400 Subject: [PATCH 28/69] code review --- .../apache/solr/handler/configsets/GetConfigSetFile.java | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) 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 index 238ffc55a3fb..4c69e60b0a2c 100644 --- a/solr/core/src/java/org/apache/solr/handler/configsets/GetConfigSetFile.java +++ b/solr/core/src/java/org/apache/solr/handler/configsets/GetConfigSetFile.java @@ -30,12 +30,7 @@ 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. - * - *

This API (GET /api/configsets/{configSetName}/file?path=...) is a general-purpose endpoint - * that works for any configset, including the temporary schema-designer drafts. - */ +/** V2 API implementation for reading the contents of a single file from an existing configset. */ public class GetConfigSetFile extends ConfigSetAPIBase implements ConfigsetsApi.GetFile { @Inject @@ -68,7 +63,7 @@ public ConfigSetFileContentsResponse getConfigSetFile(String configSetName, Stri return response; } - private byte[] downloadFileFromConfig(String configSetName, String filePath) throws IOException { + private byte[] downloadFileFromConfig(String configSetName, String filePath) { try { final byte[] data = configSetService.downloadFileFromConfig(configSetName, filePath); if (data == null) { From 96efd9de13bca36b3a493d32a2583fec38fa84e8 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 3 Apr 2026 09:41:37 -0400 Subject: [PATCH 29/69] More test coverage --- .../configsets/DeleteConfigSetAPITest.java | 93 ++++ .../configsets/DownloadConfigSetAPITest.java | 86 ++-- .../configsets/GetConfigSetFileAPITest.java | 2 +- .../configsets/UploadConfigSetAPITest.java | 423 ++++++++++++++++++ 4 files changed, 561 insertions(+), 43 deletions(-) create mode 100644 solr/core/src/test/org/apache/solr/handler/configsets/DeleteConfigSetAPITest.java create mode 100644 solr/core/src/test/org/apache/solr/handler/configsets/UploadConfigSetAPITest.java diff --git a/solr/core/src/test/org/apache/solr/handler/configsets/DeleteConfigSetAPITest.java b/solr/core/src/test/org/apache/solr/handler/configsets/DeleteConfigSetAPITest.java new file mode 100644 index 000000000000..9846f960852e --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/configsets/DeleteConfigSetAPITest.java @@ -0,0 +1,93 @@ +/* + * 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 org.apache.solr.SolrTestCase; +import org.apache.solr.common.SolrException; +import org.apache.solr.core.CoreContainer; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Unit tests for {@link DeleteConfigSet}. + * + *

Note: This test focuses on input validation. Full deletion workflow is tested in integration + * tests like {@code TestConfigSetsAPI} since actual deletion requires ZooKeeper interaction. + */ +public class DeleteConfigSetAPITest extends SolrTestCase { + + private CoreContainer mockCoreContainer; + + @BeforeClass + public static void ensureWorkingMockito() { + assumeWorkingMockito(); + } + + @Before + public void clearMocks() { + mockCoreContainer = mock(CoreContainer.class); + } + + @Test + public void testNullConfigSetNameThrowsBadRequest() { + final var api = new DeleteConfigSet(mockCoreContainer, null, null); + final var ex = assertThrows(SolrException.class, () -> api.deleteConfigSet(null)); + + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + assertTrue( + "Error message should mention missing configset name", + ex.getMessage().contains("No configset name")); + } + + @Test + public void testEmptyConfigSetNameThrowsBadRequest() { + final var api = new DeleteConfigSet(mockCoreContainer, null, null); + final var ex = assertThrows(SolrException.class, () -> api.deleteConfigSet("")); + + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + assertTrue( + "Error message should mention missing configset name", + ex.getMessage().contains("No configset name")); + } + + @Test + public void testWhitespaceOnlyConfigSetNameThrowsBadRequest() { + final var api = new DeleteConfigSet(mockCoreContainer, null, null); + final var ex = assertThrows(SolrException.class, () -> api.deleteConfigSet(" ")); + + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + assertTrue( + "Error message should mention missing configset name", + ex.getMessage().contains("No configset name")); + } + + @Test + public void testTabOnlyConfigSetNameThrowsBadRequest() { + final var api = new DeleteConfigSet(mockCoreContainer, null, null); + final var ex = assertThrows(SolrException.class, () -> api.deleteConfigSet("\t")); + + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + assertTrue( + "Error message should mention missing configset name", + ex.getMessage().contains("No configset name")); + } +} 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 index 3101f97cc8b8..78984198271a 100644 --- a/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java +++ b/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java @@ -46,7 +46,7 @@ public static void ensureWorkingMockito() { } @Before - public void initConfigSetService() throws Exception { + public void initConfigSetService() { configSetBase = createTempDir("configsets"); // Use an anonymous subclass to access the protected testing constructor configSetService = new FileSystemConfigSetService(configSetBase) {}; @@ -62,6 +62,7 @@ private void createConfigSet(String name, String fileName, String content) throw } @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)); @@ -72,6 +73,7 @@ public void testMissingConfigSetNameThrowsBadRequest() { } @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); @@ -84,14 +86,14 @@ public void testSuccessfulDownloadReturnsZipResponse() throws Exception { createConfigSet("myconfig", "solrconfig.xml", ""); final var api = new DownloadConfigSet(mockCoreContainer, null, null); - 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")); + 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 @@ -101,16 +103,16 @@ public void testFilenameIsSanitized() throws Exception { createConfigSet(nameWithSpaces, "schema.xml", ""); final var api = new DownloadConfigSet(mockCoreContainer, null, null); - 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")); + 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 @@ -119,34 +121,34 @@ public void testDisplayNameOverridesFilename() throws Exception { createConfigSet(mutableId, "schema.xml", ""); final var api = new DownloadConfigSet(mockCoreContainer, null, null); - 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_")); + 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", ""); - 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_")); + 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 index fda1e6fdd872..072924605ae7 100644 --- a/solr/core/src/test/org/apache/solr/handler/configsets/GetConfigSetFileAPITest.java +++ b/solr/core/src/test/org/apache/solr/handler/configsets/GetConfigSetFileAPITest.java @@ -46,7 +46,7 @@ public static void ensureWorkingMockito() { } @Before - public void initConfigSetService() throws Exception { + public void initConfigSetService() { configSetBase = createTempDir("configsets"); // Use an anonymous subclass to access the protected testing constructor configSetService = new FileSystemConfigSetService(configSetBase) {}; 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..94ff24901d17 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/configsets/UploadConfigSetAPITest.java @@ -0,0 +1,423 @@ +/* + * 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 { + 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", ""); + + 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 46d30425addafe770eeff4d95c9640f3bede4875 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 3 Apr 2026 10:47:36 -0400 Subject: [PATCH 30/69] Prevent warning --- .../configsets/UploadConfigSetAPITest.java | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) 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 index 94ff24901d17..474b14b193ff 100644 --- a/solr/core/src/test/org/apache/solr/handler/configsets/UploadConfigSetAPITest.java +++ b/solr/core/src/test/org/apache/solr/handler/configsets/UploadConfigSetAPITest.java @@ -143,17 +143,18 @@ public void testSuccessfulZipUploadWithMultipleFiles() throws Exception { @Test public void testEmptyZipThrowsBadRequest() throws Exception { - InputStream emptyZip = createEmptyZipStream(); + try (InputStream emptyZip = createEmptyZipStream()) { - final var api = new UploadConfigSet(mockCoreContainer, null, null); - final var ex = - assertThrows( - SolrException.class, () -> api.uploadConfigSet("newconfig", true, false, emptyZip)); + 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")); + 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 @@ -190,16 +191,17 @@ public void testOverwriteFalseThrowsExceptionWhenExists() throws Exception { final String configSetName = "existing"; createExistingConfigSet(configSetName, "solrconfig.xml", ""); - InputStream zipStream = createZipStream("solrconfig.xml", ""); - final var api = new UploadConfigSet(mockCoreContainer, null, null); + 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)); + 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")); + assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); + assertTrue( + "Error message should mention config already exists", ex.getMessage().contains("already")); + } } @Test From fa6fcad90ce4f2d14f1cb673d03031096514d12e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:29:52 +0000 Subject: [PATCH 31/69] Merge origin/main: pick up ConfigSets revamp (validation, isFileForbiddenInConfigSets on ConfigSetService) Agent-Logs-Url: https://github.com/epugh/solr/sessions/8fcb8001-f311-4bcb-bf46-522ebd7a9d05 Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../solr/handler/configsets/UploadConfigSetAPITest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 474b14b193ff..92030c0db099 100644 --- a/solr/core/src/test/org/apache/solr/handler/configsets/UploadConfigSetAPITest.java +++ b/solr/core/src/test/org/apache/solr/handler/configsets/UploadConfigSetAPITest.java @@ -196,11 +196,13 @@ public void testOverwriteFalseThrowsExceptionWhenExists() throws Exception { final var ex = assertThrows( - SolrException.class, () -> api.uploadConfigSet(configSetName, false, false, zipStream)); + 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")); + "Error message should mention config already exists", + ex.getMessage().contains("already")); } } From c725d972c91da4c7a4428cce30222755860e15f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:06:59 +0000 Subject: [PATCH 32/69] Keep only schema designer changes; remove configset additions; restore download+getFile to SchemaDesignerApi Agent-Logs-Url: https://github.com/epugh/solr/sessions/07fed0d8-5175-46b5-bff4-7111da9bb8c3 Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- ...d-zip-to-solrj-fix-schema-designer-bug.yml | 2 +- .../client/api/endpoint/ConfigsetsApi.java | 48 ------ .../api/endpoint/SchemaDesignerApi.java | 26 +++ .../model/ConfigSetFileContentsResponse.java | 31 ---- .../org/apache/solr/core/CoreContainer.java | 2 +- .../solr/handler/admin/ConfigSetsHandler.java | 9 +- .../handler/configsets/DownloadConfigSet.java | 141 ---------------- .../handler/configsets/GetConfigSetFile.java | 82 ---------- .../solr/handler/designer/SchemaDesigner.java | 39 +++++ .../SchemaDesignerConfigSetHelper.java | 59 +++++++ .../configsets/DownloadConfigSetAPITest.java | 154 ------------------ .../configsets/GetConfigSetFileAPITest.java | 135 --------------- .../handler/designer/TestSchemaDesigner.java | 10 +- .../TestSchemaDesignerConfigSetHelper.java | 6 +- solr/packaging/test/test_schema_designer.bats | 11 +- .../js/angular/controllers/schema-designer.js | 7 +- solr/webapp/web/js/angular/services.js | 8 +- 17 files changed, 139 insertions(+), 631 deletions(-) delete mode 100644 solr/api/src/java/org/apache/solr/client/api/model/ConfigSetFileContentsResponse.java delete mode 100644 solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java delete mode 100644 solr/core/src/java/org/apache/solr/handler/configsets/GetConfigSetFile.java delete mode 100644 solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java delete mode 100644 solr/core/src/test/org/apache/solr/handler/configsets/GetConfigSetFileAPITest.java diff --git a/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml b/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml index 68a7cec731e6..37c153b38600 100644 --- a/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml +++ b/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml @@ -1,5 +1,5 @@ # See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc -title: The "analyze" existing documents feature of Schema Designer was fixed. Added a new ConfigSet.Download and ConfigSet.GetFile capablities to SolrJ. +title: Migrated SchemaDesignerAPI to JAX-RS V2 annotations. Fixed a bug in the "analyze" existing documents feature of Schema Designer. Added download and getFileContents endpoints to the Schema Designer API. type: fixed # added, changed, fixed, deprecated, removed, dependency_update, security, other authors: - name: Eric Pugh 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 ac05da1225e5..4bc812043e9d 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,11 +16,7 @@ */ 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; @@ -28,14 +24,10 @@ 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; @@ -79,46 +71,6 @@ 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/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index 713d529703ea..6962cefe3852 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.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 jakarta.ws.rs.DELETE; import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; @@ -24,7 +28,9 @@ 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.Response; import java.util.List; import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; import org.apache.solr.client.api.model.SchemaDesignerCollectionsResponse; @@ -162,4 +168,24 @@ SchemaDesignerResponse analyze( tags = {"schema-designer"}) SchemaDesignerSchemaDiffResponse getSchemaDiff(@PathParam("configSet") String configSet) throws Exception; + + @GET + @Path("/{configSet}/file") + @Operation( + summary = "Get the contents of a file in a configSet being designed.", + tags = {"schema-designer"}) + FlexibleSolrJerseyResponse getFileContents( + @PathParam("configSet") String configSet, @QueryParam("filePath") String filePath) + throws Exception; + + @GET + @Path("/{configSet}/download") + @Operation( + summary = "Download the configSet being designed as a ZIP archive.", + tags = {"schema-designer"}, + extensions = { + @Extension(properties = {@ExtensionProperty(name = RAW_OUTPUT_PROPERTY, value = "true")}) + }) + @Produces("application/zip") + Response downloadConfig(@PathParam("configSet") String configSet) throws Exception; } 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 deleted file mode 100644 index 3ef99a13bbe1..000000000000 --- a/solr/api/src/java/org/apache/solr/client/api/model/ConfigSetFileContentsResponse.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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/core/CoreContainer.java b/solr/core/src/java/org/apache/solr/core/CoreContainer.java index 3865e07d1129..2e2cb6007f96 100644 --- a/solr/core/src/java/org/apache/solr/core/CoreContainer.java +++ b/solr/core/src/java/org/apache/solr/core/CoreContainer.java @@ -875,7 +875,7 @@ private void loadInternal() { registerV2Api(clusterAPI.commands); if (isZooKeeperAware()) { - registerV2ApiIfEnabled(SchemaDesigner.class); + registerV2Api(SchemaDesigner.class); } // else Schema Designer not available in standalone (non-cloud) mode /* 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 6180a5398309..edcdc0b1088b 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,8 +39,6 @@ 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; @@ -189,12 +187,7 @@ public Collection getApis() { @Override public Collection> getJerseyResources() { return List.of( - ListConfigSets.class, - CloneConfigSet.class, - DeleteConfigSet.class, - UploadConfigSet.class, - DownloadConfigSet.class, - GetConfigSetFile.class); + ListConfigSets.class, CloneConfigSet.class, DeleteConfigSet.class, UploadConfigSet.class); } @Override 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 deleted file mode 100644 index 97de78113443..000000000000 --- a/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java +++ /dev/null @@ -1,141 +0,0 @@ -/* - * 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 deleted file mode 100644 index 4c69e60b0a2c..000000000000 --- a/solr/core/src/java/org/apache/solr/handler/configsets/GetConfigSetFile.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * 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/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index 4c776bfcc25e..58abe9c97f67 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -23,6 +23,8 @@ 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.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -1477,6 +1479,43 @@ private boolean pathExistsInZk(final String zkPath) throws IOException { } } + @Override + @PermissionName(CONFIG_READ_PERM) + public FlexibleSolrJerseyResponse getFileContents(String configSet, String filePath) + throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); + requireNotEmpty("filePath", filePath); + String mutableId = getMutableId(configSet); + String resolvedId = configExists(mutableId) ? mutableId : configSet; + byte[] data = coreContainer.getConfigSetService().downloadFileFromConfig(resolvedId, filePath); + if (data == null) { + throw new SolrException( + SolrException.ErrorCode.NOT_FOUND, + "File '" + filePath + "' not found in configSet: " + configSet); + } + FlexibleSolrJerseyResponse response = + instantiateJerseyResponse(FlexibleSolrJerseyResponse.class); + response.setUnknownProperty("path", filePath); + response.setUnknownProperty("content", new String(data, StandardCharsets.UTF_8)); + return response; + } + + @Override + @PermissionName(CONFIG_READ_PERM) + public Response downloadConfig(String configSet) throws Exception { + requireNotEmpty(CONFIG_SET_PARAM, configSet); + String mutableId = getMutableId(configSet); + String resolvedId = configExists(mutableId) ? mutableId : configSet; + final byte[] zipBytes = + SchemaDesignerConfigSetHelper.zipConfigSet(coreContainer.getConfigSetService(), resolvedId); + final String safeName = configSet.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(); + } + private static class InMemoryResourceLoader extends SolrResourceLoader { String resource; byte[] data; diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java index ba76a8e1e51e..78a7259e63e7 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java @@ -26,12 +26,18 @@ import static org.apache.solr.schema.IndexSchema.ROOT_FIELD_NAME; import static org.apache.solr.schema.ManagedIndexSchemaFactory.DEFAULT_MANAGED_SCHEMA_RESOURCE_NAME; +import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.lang.invoke.MethodHandles; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; +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.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -46,6 +52,9 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.apache.commons.io.file.PathUtils; import org.apache.lucene.util.IOSupplier; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.SolrResponse; @@ -70,6 +79,7 @@ import org.apache.solr.common.util.NamedList; import org.apache.solr.common.util.SimpleOrderedMap; import org.apache.solr.common.util.Utils; +import org.apache.solr.core.ConfigSetService; import org.apache.solr.core.CoreContainer; import org.apache.solr.core.SolrConfig; import org.apache.solr.core.SolrResourceLoader; @@ -1063,6 +1073,55 @@ List listConfigsInZk() throws IOException { return cc.getConfigSetService().listConfigs(); } + /** + * Download the named configSet and return its contents as a ZIP archive byte array. + * + * @param configSetService the service to use for downloading the configSet files + * @param configSetId the internal configSet name to zip + * @return the bytes of a ZIP archive containing all configSet files + */ + 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)) { + zipOut.putNextEntry(new ZipEntry(tmpDirectory.relativize(file).toString())); + fis.transferTo(zipOut); + } + } + return FileVisitResult.CONTINUE; + } + }); + } + } finally { + PathUtils.deleteDirectory(tmpDirectory); + } + return baos.toByteArray(); + } + protected ZkSolrResourceLoader zkLoaderForConfigSet(final String configSet) { SolrResourceLoader loader = cc.getResourceLoader(); return new ZkSolrResourceLoader( 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 deleted file mode 100644 index 78984198271a..000000000000 --- a/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java +++ /dev/null @@ -1,154 +0,0 @@ -/* - * 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 deleted file mode 100644 index 072924605ae7..000000000000 --- a/solr/core/src/test/org/apache/solr/handler/configsets/GetConfigSetFileAPITest.java +++ /dev/null @@ -1,135 +0,0 @@ -/* - * 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/designer/TestSchemaDesigner.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java index eedaf3d976b0..8252d9337688 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java @@ -34,7 +34,6 @@ import java.util.Map; import java.util.Optional; import java.util.stream.Stream; -import org.apache.solr.client.api.model.ConfigSetFileContentsResponse; import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; import org.apache.solr.client.api.model.SchemaDesignerCollectionsResponse; import org.apache.solr.client.api.model.SchemaDesignerInfoResponse; @@ -54,9 +53,7 @@ import org.apache.solr.common.util.SimpleOrderedMap; import org.apache.solr.core.CoreContainer; import org.apache.solr.handler.TestSampleDocumentsLoader; -import org.apache.solr.handler.configsets.GetConfigSetFile; import org.apache.solr.request.SolrQueryRequest; -import org.apache.solr.response.SolrQueryResponse; import org.apache.solr.schema.ManagedIndexSchema; import org.apache.solr.schema.SchemaField; import org.apache.solr.util.ExternalPaths; @@ -349,11 +346,8 @@ public void testBasicUserWorkflow() throws Exception { } } assertNotNull("solrconfig.xml not found in files!", file); - GetConfigSetFile getFileApi = new GetConfigSetFile(cc, mockReq, mock(SolrQueryResponse.class)); - String fileMutableId = getMutableId(configSet); - ConfigSetFileContentsResponse fileContentsResp = - getFileApi.getConfigSetFile(fileMutableId, file); - String solrconfigXml = fileContentsResp.content; + FlexibleSolrJerseyResponse fileContentsResp = schemaDesigner.getFileContents(configSet, file); + String solrconfigXml = (String) fileContentsResp.unknownProperties().get("content"); assertNotNull(solrconfigXml); // Update solrconfig.xml diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java index 1a904a4e0720..4222b7c417a0 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java @@ -38,7 +38,6 @@ import org.apache.solr.core.CoreContainer; import org.apache.solr.core.SolrConfig; import org.apache.solr.filestore.FileStore; -import org.apache.solr.handler.configsets.DownloadConfigSet; import org.apache.solr.schema.FieldType; import org.apache.solr.schema.ManagedIndexSchema; import org.apache.solr.schema.SchemaField; @@ -114,14 +113,15 @@ public void testSetupMutable() throws Exception { configSet, schema, List.of(), true, DEFAULT_CONFIGSET_NAME); assertEquals(2, schema.getSchemaZkVersion()); - byte[] zipped = DownloadConfigSet.zipConfigSet(cc.getConfigSetService(), mutableId); + byte[] zipped = SchemaDesignerConfigSetHelper.zipConfigSet(cc.getConfigSetService(), mutableId); assertTrue(zipped != null && zipped.length > 0); } @Test public void testDownloadAndZip() throws IOException { byte[] zipped = - DownloadConfigSet.zipConfigSet(cc.getConfigSetService(), DEFAULT_CONFIGSET_NAME); + SchemaDesignerConfigSetHelper.zipConfigSet( + cc.getConfigSetService(), DEFAULT_CONFIGSET_NAME); ZipInputStream stream = new ZipInputStream(new ByteArrayInputStream(zipped)); boolean foundSolrConfig = false; diff --git a/solr/packaging/test/test_schema_designer.bats b/solr/packaging/test/test_schema_designer.bats index b769973c37b8..c1378bb4fac2 100644 --- a/solr/packaging/test/test_schema_designer.bats +++ b/solr/packaging/test/test_schema_designer.bats @@ -120,11 +120,9 @@ teardown() { } # --------------------------------------------------------------------------- -# 6. Download configSet zip via the generic configsets endpoint. -# This is the primary new endpoint introduced by the migration. +# 6. Download configSet zip via the schema-designer endpoint. # We verify: # - HTTP 200 response -# - Content-Disposition header with a .zip filename # - The response body is a valid zip (starts with the PK magic bytes) # --------------------------------------------------------------------------- @test "download schema-designer configSet as zip" { @@ -133,13 +131,12 @@ teardown() { "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" \ > /dev/null - local mutable_id="._designer_${DESIGNER_CONFIGSET}" local zip_file="${BATS_TEST_TMPDIR}/${DESIGNER_CONFIGSET}.zip" # Capture HTTP status code separately local http_code http_code=$(curl -s -o "${zip_file}" -w "%{http_code}" \ - "http://localhost:${SOLR_PORT}/api/configsets/${mutable_id}/download?displayName=${DESIGNER_CONFIGSET}") + "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/download") # Assert HTTP 200 [ "${http_code}" = "200" ] @@ -161,10 +158,8 @@ teardown() { "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" \ > /dev/null - local mutable_id="._designer_${DESIGNER_CONFIGSET}" - run curl -s -I \ - "http://localhost:${SOLR_PORT}/api/configsets/${mutable_id}/download?displayName=${DESIGNER_CONFIGSET}" + "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/download" assert_output --partial 'Content-Disposition' assert_output --partial '.zip' } diff --git a/solr/webapp/web/js/angular/controllers/schema-designer.js b/solr/webapp/web/js/angular/controllers/schema-designer.js index fdf7c68b7ba0..a050d7d475e3 100644 --- a/solr/webapp/web/js/angular/controllers/schema-designer.js +++ b/solr/webapp/web/js/angular/controllers/schema-designer.js @@ -15,7 +15,7 @@ limitations under the License. */ -solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Configsets, Luke) { +solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) { $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE); $scope.schemas = []; @@ -905,7 +905,7 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $scope.selectedFile = id.startsWith("files/") ? id.substring("files/".length) : id; var mutableId = "._designer_" + $scope.currentSchema; - Configsets.get({configSetName: mutableId, endpoint: "file", path: $scope.selectedFile}, function (data) { + SchemaDesigner.get({configSet: mutableId, path: "file", filePath: $scope.selectedFile}, function (data) { $scope.fileNodeText = data.content; $scope.isLeafNode = false; if (doSelectOnTree) { @@ -1523,8 +1523,7 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $scope.downloadConfig = function () { // have to use an AJAX request so we can supply the Authorization header - var mutableId = "._designer_" + $scope.currentSchema; - var downloadUrl = "/api/configsets/" + encodeURIComponent(mutableId) + "/download?displayName=" + encodeURIComponent($scope.currentSchema); + var downloadUrl = "/api/schema-designer/" + encodeURIComponent($scope.currentSchema) + "/download"; if (sessionStorage.getItem("auth.header")) { var fileName = $scope.currentSchema+"_configset.zip"; var xhr = new XMLHttpRequest(); diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index c72948546459..cb7af05151e9 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -271,7 +271,7 @@ solrAdminServices.factory('System', }]) .factory('SchemaDesigner', ['$resource', function($resource) { - return $resource('/api/schema-designer/:configSet/:path', {wt: 'json', path: '@path', configSet: '@configSet', _:Date.now()}, { + return $resource('/api/schema-designer/:configSet/:path', {wt: 'json', path: '@path', configSet: '@configSet', filePath: '@filePath', _:Date.now()}, { get: {method: "GET"}, post: {method: "POST", timeout: 90000}, put: {method: "PUT"}, @@ -281,12 +281,6 @@ solrAdminServices.factory('System', upload: {method: "POST", transformRequest: angular.identity, headers: {'Content-Type': undefined}, timeout: 90000} }) }]) -.factory('Configsets', - ['$resource', function($resource) { - return $resource('/api/configsets/:configSetName/:endpoint', {wt: 'json', configSetName: '@configSetName', endpoint: '@endpoint', _:Date.now()}, { - get: {method: "GET"} - }) -}]) .factory('Security', ['$resource', function($resource) { return $resource('/api/cluster/security/:path', {wt: 'json', path: '@path', _:Date.now()}, { From c4c1e0e420ee51b8bf9ecf427cb27bc06a4f6e29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:56:36 +0000 Subject: [PATCH 33/69] Remove duplicate zipConfigSet from SchemaDesignerConfigSetHelper; use DownloadConfigSet.zipConfigSet() Agent-Logs-Url: https://github.com/epugh/solr/sessions/fd2b245f-2e34-45e1-96fe-a5047e48206b Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../solr/handler/designer/SchemaDesigner.java | 3 +- .../SchemaDesignerConfigSetHelper.java | 59 ------------------- .../handler/designer/TestSchemaDesigner.java | 6 +- .../TestSchemaDesignerConfigSetHelper.java | 6 +- 4 files changed, 7 insertions(+), 67 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index 58abe9c97f67..ed00c4f33d29 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -82,6 +82,7 @@ import org.apache.solr.core.CoreContainer; import org.apache.solr.core.SolrConfig; import org.apache.solr.core.SolrResourceLoader; +import org.apache.solr.handler.configsets.DownloadConfigSet; import org.apache.solr.jersey.PermissionName; import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.schema.ManagedIndexSchema; @@ -1507,7 +1508,7 @@ public Response downloadConfig(String configSet) throws Exception { String mutableId = getMutableId(configSet); String resolvedId = configExists(mutableId) ? mutableId : configSet; final byte[] zipBytes = - SchemaDesignerConfigSetHelper.zipConfigSet(coreContainer.getConfigSetService(), resolvedId); + DownloadConfigSet.zipConfigSet(coreContainer.getConfigSetService(), resolvedId); final String safeName = configSet.replaceAll("[^a-zA-Z0-9_\\-.]", "_"); final String fileName = safeName + "_configset.zip"; return Response.ok((StreamingOutput) outputStream -> outputStream.write(zipBytes)) diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java index db31bacbefe5..7b5a0d41db51 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesignerConfigSetHelper.java @@ -26,18 +26,12 @@ import static org.apache.solr.schema.IndexSchema.ROOT_FIELD_NAME; import static org.apache.solr.schema.ManagedIndexSchemaFactory.DEFAULT_MANAGED_SCHEMA_RESOURCE_NAME; -import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.lang.invoke.MethodHandles; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; -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.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -51,9 +45,6 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; -import org.apache.commons.io.file.PathUtils; import org.apache.lucene.util.IOSupplier; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.SolrResponse; @@ -78,7 +69,6 @@ import org.apache.solr.common.util.NamedList; import org.apache.solr.common.util.SimpleOrderedMap; import org.apache.solr.common.util.Utils; -import org.apache.solr.core.ConfigSetService; import org.apache.solr.core.CoreContainer; import org.apache.solr.core.SolrConfig; import org.apache.solr.core.SolrResourceLoader; @@ -1072,55 +1062,6 @@ List listConfigsInZk() throws IOException { return cc.getConfigSetService().listConfigs(); } - /** - * Download the named configSet and return its contents as a ZIP archive byte array. - * - * @param configSetService the service to use for downloading the configSet files - * @param configSetId the internal configSet name to zip - * @return the bytes of a ZIP archive containing all configSet files - */ - 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)) { - zipOut.putNextEntry(new ZipEntry(tmpDirectory.relativize(file).toString())); - fis.transferTo(zipOut); - } - } - return FileVisitResult.CONTINUE; - } - }); - } - } finally { - PathUtils.deleteDirectory(tmpDirectory); - } - return baos.toByteArray(); - } - protected ZkSolrResourceLoader zkLoaderForConfigSet(final String configSet) { SolrResourceLoader loader = cc.getResourceLoader(); return new ZkSolrResourceLoader( diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java index 076ba62b074e..788a3d3e39d9 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java @@ -351,15 +351,13 @@ public void testBasicUserWorkflow() throws Exception { // Update solrconfig.xml when(mockReq.getContentStreams()) - .thenReturn( - List.of(new ContentStreamBase.StringStream(solrconfigXml, "application/xml"))); + .thenReturn(List.of(new ContentStreamBase.StringStream(solrconfigXml, "application/xml"))); response = schemaDesigner.updateFileContents(configSet, file); schemaVersion = response.schemaVersion; // update solrconfig.xml with some invalid XML mess when(mockReq.getContentStreams()) - .thenReturn( - List.of(new ContentStreamBase.StringStream("", "application/xml"))); + .thenReturn(List.of(new ContentStreamBase.StringStream("", "application/xml"))); // this should fail b/c the updated solrconfig.xml is invalid response = schemaDesigner.updateFileContents(configSet, file); diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java index ae6b3ac1dfc5..300a78c35812 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerConfigSetHelper.java @@ -37,6 +37,7 @@ import org.apache.solr.core.CoreContainer; import org.apache.solr.core.SolrConfig; import org.apache.solr.filestore.FileStore; +import org.apache.solr.handler.configsets.DownloadConfigSet; import org.apache.solr.schema.FieldType; import org.apache.solr.schema.ManagedIndexSchema; import org.apache.solr.schema.SchemaField; @@ -112,15 +113,14 @@ public void testSetupMutable() throws Exception { configSet, schema, List.of(), true, DEFAULT_CONFIGSET_NAME); assertEquals(2, schema.getSchemaZkVersion()); - byte[] zipped = SchemaDesignerConfigSetHelper.zipConfigSet(cc.getConfigSetService(), mutableId); + byte[] zipped = DownloadConfigSet.zipConfigSet(cc.getConfigSetService(), mutableId); assertTrue(zipped != null && zipped.length > 0); } @Test public void testDownloadAndZip() throws IOException { byte[] zipped = - SchemaDesignerConfigSetHelper.zipConfigSet( - cc.getConfigSetService(), DEFAULT_CONFIGSET_NAME); + DownloadConfigSet.zipConfigSet(cc.getConfigSetService(), DEFAULT_CONFIGSET_NAME); ZipInputStream stream = new ZipInputStream(new ByteArrayInputStream(zipped)); boolean foundSolrConfig = false; From c0169a2f20c396a256fcb88d9459fe3e28b2cd92 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:29:19 +0000 Subject: [PATCH 34/69] Remove downloadConfig from SchemaDesignerApi; JS uses ConfigsetsApi download endpoint directly Agent-Logs-Url: https://github.com/epugh/solr/sessions/4f648089-c8e3-4fcd-9ac5-c55c3e9307df Co-authored-by: epugh <22395+epugh@users.noreply.github.com> --- .../client/api/endpoint/ConfigsetsApi.java | 5 ++++- .../api/endpoint/SchemaDesignerApi.java | 17 ----------------- .../handler/configsets/DownloadConfigSet.java | 14 ++++++++++---- .../solr/handler/designer/SchemaDesigner.java | 19 ------------------- solr/packaging/test/test_schema_designer.bats | 6 ++++-- .../js/angular/controllers/schema-designer.js | 5 +++-- 6 files changed, 21 insertions(+), 45 deletions(-) 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 f7fd006ef440..7b13df1baa3f 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 @@ -92,7 +92,10 @@ interface Download { @Extension(properties = {@ExtensionProperty(name = RAW_OUTPUT_PROPERTY, value = "true")}) }) @Produces("application/zip") - Response downloadConfigSet(@PathParam("configSetName") String configSetName) throws Exception; + Response downloadConfigSet( + @PathParam("configSetName") String configSetName, + @QueryParam("displayName") String displayName) + throws Exception; } /** diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index 6962cefe3852..6e79a81de990 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -16,11 +16,7 @@ */ 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 jakarta.ws.rs.DELETE; import jakarta.ws.rs.DefaultValue; import jakarta.ws.rs.GET; @@ -28,9 +24,7 @@ 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.Response; import java.util.List; import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; import org.apache.solr.client.api.model.SchemaDesignerCollectionsResponse; @@ -177,15 +171,4 @@ SchemaDesignerSchemaDiffResponse getSchemaDiff(@PathParam("configSet") String co FlexibleSolrJerseyResponse getFileContents( @PathParam("configSet") String configSet, @QueryParam("filePath") String filePath) throws Exception; - - @GET - @Path("/{configSet}/download") - @Operation( - summary = "Download the configSet being designed as a ZIP archive.", - tags = {"schema-designer"}, - extensions = { - @Extension(properties = {@ExtensionProperty(name = RAW_OUTPUT_PROPERTY, value = "true")}) - }) - @Produces("application/zip") - Response downloadConfig(@PathParam("configSet") String configSet) throws Exception; } 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 index 729aaf00d914..60790f9461f4 100644 --- a/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java +++ b/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java @@ -54,7 +54,7 @@ public DownloadConfigSet( @Override @PermissionName(CONFIG_READ_PERM) - public Response downloadConfigSet(String configSetName) throws Exception { + 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"); @@ -63,20 +63,26 @@ public Response downloadConfigSet(String configSetName) throws Exception { throw new SolrException( SolrException.ErrorCode.NOT_FOUND, "ConfigSet " + configSetName + " not found!"); } - return buildZipResponse(configSetService, configSetName); + String effectiveDisplayName = StrUtils.isNullOrEmpty(displayName) ? configSetName : displayName; + return buildZipResponse(configSetService, configSetName, effectiveDisplayName); } /** * Build a ZIP download {@link Response} for the given configset. * * @param configSetService the service to use for downloading the configset files - * @param configSetName the name of the configset to download + * @param configSetName the name of the configset to download (internal id) + * @param displayName the name to use in the Content-Disposition filename */ - public static Response buildZipResponse(ConfigSetService configSetService, String configSetName) + public static Response buildZipResponse( + ConfigSetService configSetService, String configSetName, String displayName) throws IOException { final byte[] zipBytes = zipConfigSet(configSetService, configSetName); + 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(); } diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index ed00c4f33d29..021978455146 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -23,8 +23,6 @@ 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.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; @@ -82,7 +80,6 @@ import org.apache.solr.core.CoreContainer; import org.apache.solr.core.SolrConfig; import org.apache.solr.core.SolrResourceLoader; -import org.apache.solr.handler.configsets.DownloadConfigSet; import org.apache.solr.jersey.PermissionName; import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.schema.ManagedIndexSchema; @@ -1501,22 +1498,6 @@ public FlexibleSolrJerseyResponse getFileContents(String configSet, String fileP return response; } - @Override - @PermissionName(CONFIG_READ_PERM) - public Response downloadConfig(String configSet) throws Exception { - requireNotEmpty(CONFIG_SET_PARAM, configSet); - String mutableId = getMutableId(configSet); - String resolvedId = configExists(mutableId) ? mutableId : configSet; - final byte[] zipBytes = - DownloadConfigSet.zipConfigSet(coreContainer.getConfigSetService(), resolvedId); - final String safeName = configSet.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(); - } - private static class InMemoryResourceLoader extends SolrResourceLoader { String resource; byte[] data; diff --git a/solr/packaging/test/test_schema_designer.bats b/solr/packaging/test/test_schema_designer.bats index c1378bb4fac2..cdb295490ecd 100644 --- a/solr/packaging/test/test_schema_designer.bats +++ b/solr/packaging/test/test_schema_designer.bats @@ -132,11 +132,12 @@ teardown() { > /dev/null local zip_file="${BATS_TEST_TMPDIR}/${DESIGNER_CONFIGSET}.zip" + local mutable_id="._designer_${DESIGNER_CONFIGSET}" # Capture HTTP status code separately local http_code http_code=$(curl -s -o "${zip_file}" -w "%{http_code}" \ - "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/download") + "http://localhost:${SOLR_PORT}/api/configsets/$(python3 -c "import urllib.parse; print(urllib.parse.quote('${mutable_id}', safe=''))")/files?displayName=${DESIGNER_CONFIGSET}") # Assert HTTP 200 [ "${http_code}" = "200" ] @@ -158,8 +159,9 @@ teardown() { "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" \ > /dev/null + local mutable_id="._designer_${DESIGNER_CONFIGSET}" run curl -s -I \ - "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/download" + "http://localhost:${SOLR_PORT}/api/configsets/$(python3 -c "import urllib.parse; print(urllib.parse.quote('${mutable_id}', safe=''))")/files?displayName=${DESIGNER_CONFIGSET}" assert_output --partial 'Content-Disposition' assert_output --partial '.zip' } diff --git a/solr/webapp/web/js/angular/controllers/schema-designer.js b/solr/webapp/web/js/angular/controllers/schema-designer.js index a050d7d475e3..bb24bd5f41f9 100644 --- a/solr/webapp/web/js/angular/controllers/schema-designer.js +++ b/solr/webapp/web/js/angular/controllers/schema-designer.js @@ -1522,8 +1522,9 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, }; $scope.downloadConfig = function () { - // have to use an AJAX request so we can supply the Authorization header - var downloadUrl = "/api/schema-designer/" + encodeURIComponent($scope.currentSchema) + "/download"; + // Use the generic configsets download endpoint on the mutable draft + var mutableId = "._designer_" + $scope.currentSchema; + var downloadUrl = "/api/configsets/" + encodeURIComponent(mutableId) + "/files?displayName=" + encodeURIComponent($scope.currentSchema); if (sessionStorage.getItem("auth.header")) { var fileName = $scope.currentSchema+"_configset.zip"; var xhr = new XMLHttpRequest(); From 8832e91522aac50b9164a686f38c2bea0d4437e6 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 24 Apr 2026 13:42:34 -0400 Subject: [PATCH 35/69] Be able to reuse download configset without magic property --- .../client/api/endpoint/ConfigsetsApi.java | 5 +--- .../handler/configsets/DownloadConfigSet.java | 16 +++++++++---- .../configsets/DownloadConfigSetAPITest.java | 23 +++++++++++++++++++ .../js/angular/controllers/schema-designer.js | 2 +- 4 files changed, 37 insertions(+), 9 deletions(-) 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 7b13df1baa3f..f7fd006ef440 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 @@ -92,10 +92,7 @@ interface Download { @Extension(properties = {@ExtensionProperty(name = RAW_OUTPUT_PROPERTY, value = "true")}) }) @Produces("application/zip") - Response downloadConfigSet( - @PathParam("configSetName") String configSetName, - @QueryParam("displayName") String displayName) - throws Exception; + Response downloadConfigSet(@PathParam("configSetName") String configSetName) throws Exception; } /** 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 index 60790f9461f4..5ea6d6be3208 100644 --- a/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java +++ b/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java @@ -54,7 +54,7 @@ public DownloadConfigSet( @Override @PermissionName(CONFIG_READ_PERM) - public Response downloadConfigSet(String configSetName, String displayName) throws Exception { + public Response downloadConfigSet(String configSetName) throws Exception { if (StrUtils.isNullOrEmpty(configSetName)) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, "No configset name provided to download"); @@ -63,8 +63,16 @@ public Response downloadConfigSet(String configSetName, String displayName) thro throw new SolrException( SolrException.ErrorCode.NOT_FOUND, "ConfigSet " + configSetName + " not found!"); } - String effectiveDisplayName = StrUtils.isNullOrEmpty(displayName) ? configSetName : displayName; - return buildZipResponse(configSetService, configSetName, effectiveDisplayName); + return buildZipResponse(configSetService, configSetName, deriveDisplayName(configSetName)); + } + + // This is to support the schema designer's internal name and + // lets us not duplicate the download endpoint. + static String deriveDisplayName(String configSetName) { + if (configSetName.startsWith("._designer_")) { + return configSetName.substring("._designer_".length()); + } + return configSetName; } /** @@ -72,7 +80,7 @@ public Response downloadConfigSet(String configSetName, String displayName) thro * * @param configSetService the service to use for downloading the configset files * @param configSetName the name of the configset to download (internal id) - * @param displayName the name to use in the Content-Disposition filename + * @param displayName the sanitized name to use in the Content-Disposition filename */ public static Response buildZipResponse( ConfigSetService configSetService, String configSetName, String displayName) 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 index 10325b278ee8..7ae288e7fdaa 100644 --- a/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java +++ b/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java @@ -89,6 +89,29 @@ public void testSuccessfulDownloadReturnsZipResponse() throws Exception { try (final Response response = api.downloadConfigSet("myconfig")) { assertEquals(200, response.getStatus()); assertEquals("application/zip", response.getMediaType().toString()); + assertEquals( + "attachment; filename=\"myconfig_configset.zip\"", + response.getHeaderString("Content-Disposition")); } } + + @Test + public void testDesignerPrefixStrippedFromFilename() throws Exception { + createConfigSet("._designer_myschema", "solrconfig.xml", ""); + + final var api = new DownloadConfigSet(mockCoreContainer, null, null); + try (final Response response = api.downloadConfigSet("._designer_myschema")) { + assertEquals(200, response.getStatus()); + assertEquals( + "attachment; filename=\"myschema_configset.zip\"", + response.getHeaderString("Content-Disposition")); + } + } + + @Test + public void testDeriveDisplayName() { + assertEquals("myschema", DownloadConfigSet.deriveDisplayName("._designer_myschema")); + assertEquals("plain", DownloadConfigSet.deriveDisplayName("plain")); + assertEquals("", DownloadConfigSet.deriveDisplayName("._designer_")); + } } diff --git a/solr/webapp/web/js/angular/controllers/schema-designer.js b/solr/webapp/web/js/angular/controllers/schema-designer.js index bb24bd5f41f9..8a10e549a084 100644 --- a/solr/webapp/web/js/angular/controllers/schema-designer.js +++ b/solr/webapp/web/js/angular/controllers/schema-designer.js @@ -1524,7 +1524,7 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $scope.downloadConfig = function () { // Use the generic configsets download endpoint on the mutable draft var mutableId = "._designer_" + $scope.currentSchema; - var downloadUrl = "/api/configsets/" + encodeURIComponent(mutableId) + "/files?displayName=" + encodeURIComponent($scope.currentSchema); + var downloadUrl = "/api/configsets/" + encodeURIComponent(mutableId) + "/files"; if (sessionStorage.getItem("auth.header")) { var fileName = $scope.currentSchema+"_configset.zip"; var xhr = new XMLHttpRequest(); From fa0f2c04be6330e51a43aad6a17911911feb5ac5 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 24 Apr 2026 14:49:11 -0400 Subject: [PATCH 36/69] Typo fix! Lets be explicit --- solr/webapp/web/partials/schema-designer.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solr/webapp/web/partials/schema-designer.html b/solr/webapp/web/partials/schema-designer.html index 63936d434375..4d7fbd5b4b38 100644 --- a/solr/webapp/web/partials/schema-designer.html +++ b/solr/webapp/web/partials/schema-designer.html @@ -476,7 +476,7 @@

Sample Documents

-

Upload a JSON, CSS, or XML file containing sample documents or simply paste some sample documents into the text area below; the Schema Designer supports a maximum of 5MB and 1,000 documents. +

Upload a JSON, JSONL, CSV, or XML file containing sample documents or simply paste some sample documents into the text area below; the Schema Designer supports a maximum of 5MB and 1,000 documents.

Click on the Analyze Documents button to have Solr determine the schema by looking at the sample values for each field. Sample documents are stored on the server so you can make changes to the schema and Schema Designer will automatically re-index the sample documents to apply the changes.

From ddba94e95da9ad0f07f8a2b416d2bfb403f28461 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 24 Apr 2026 14:49:37 -0400 Subject: [PATCH 37/69] Fix "inprog" error, mostly seen on Schema Designer. --- solr/webapp/web/js/angular/app.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/solr/webapp/web/js/angular/app.js b/solr/webapp/web/js/angular/app.js index abdd53f0c59c..3f64bc0478d0 100644 --- a/solr/webapp/web/js/angular/app.js +++ b/solr/webapp/web/js/angular/app.js @@ -330,6 +330,15 @@ solrAdminApp.config([ onSelect: '&' }, link: function(scope, element, attrs) { + // Bind once; previously this was inside the $watch which stacked listeners + // on every data change and could fire synchronously during a digest, + // triggering $rootScope:inprog. + element.on("select_node.jstree", function (event, data) { + scope.$applyAsync(function() { + scope.onSelect({url: data.node.a_attr.href, data: data}); + }); + }); + scope.$watch("data", function(newValue, oldValue) { if (newValue && !jQuery.isEmptyObject(newValue)) { var treeConfig = { @@ -339,7 +348,7 @@ solrAdminApp.config([ } }; - var tree = $(element).jstree(treeConfig); + $(element).jstree(treeConfig); // This is done to ensure that the data can be refreshed if it is updated behind the scenes. // Putting the data in the treeConfig makes it stack and doesn't update. @@ -347,13 +356,6 @@ solrAdminApp.config([ $(element).jstree(true).refresh(); $(element).jstree('open_node','li:first'); - if (tree) { - element.bind("select_node.jstree", function (event, data) { - scope.$apply(function() { - scope.onSelect({url: data.node.a_attr.href, data: data}); - }); - }); - } } }, true); } From 22161564ed321f8573bc1d8a4893b5514e341561 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 24 Apr 2026 15:07:22 -0400 Subject: [PATCH 38/69] Be user facing! --- ...-configset-download-zip-to-solrj-fix-schema-designer-bug.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml b/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml index 37c153b38600..a4274e0c88f3 100644 --- a/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml +++ b/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml @@ -1,5 +1,5 @@ # See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc -title: Migrated SchemaDesignerAPI to JAX-RS V2 annotations. Fixed a bug in the "analyze" existing documents feature of Schema Designer. Added download and getFileContents endpoints to the Schema Designer API. +title: Fixed a bug in the "analyze" existing documents feature of Schema Designer that prevented the designer from working. type: fixed # added, changed, fixed, deprecated, removed, dependency_update, security, other authors: - name: Eric Pugh From 7b99a0dee40df352bf59d156bfc8d87d35922f5d Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 24 Apr 2026 15:59:49 -0400 Subject: [PATCH 39/69] Lets reuse a configset api for getting a single file --- .../api/endpoint/SchemaDesignerApi.java | 8 ------- .../solr/handler/designer/SchemaDesigner.java | 21 ------------------- .../handler/designer/TestSchemaDesigner.java | 8 ++++--- .../js/angular/controllers/schema-designer.js | 4 ++-- solr/webapp/web/js/angular/services.js | 17 +++++++++++++++ 5 files changed, 24 insertions(+), 34 deletions(-) diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index 6e79a81de990..7e286596cda9 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -163,12 +163,4 @@ SchemaDesignerResponse analyze( SchemaDesignerSchemaDiffResponse getSchemaDiff(@PathParam("configSet") String configSet) throws Exception; - @GET - @Path("/{configSet}/file") - @Operation( - summary = "Get the contents of a file in a configSet being designed.", - tags = {"schema-designer"}) - FlexibleSolrJerseyResponse getFileContents( - @PathParam("configSet") String configSet, @QueryParam("filePath") String filePath) - throws Exception; } diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index 021978455146..4c776bfcc25e 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -1477,27 +1477,6 @@ private boolean pathExistsInZk(final String zkPath) throws IOException { } } - @Override - @PermissionName(CONFIG_READ_PERM) - public FlexibleSolrJerseyResponse getFileContents(String configSet, String filePath) - throws Exception { - requireNotEmpty(CONFIG_SET_PARAM, configSet); - requireNotEmpty("filePath", filePath); - String mutableId = getMutableId(configSet); - String resolvedId = configExists(mutableId) ? mutableId : configSet; - byte[] data = coreContainer.getConfigSetService().downloadFileFromConfig(resolvedId, filePath); - if (data == null) { - throw new SolrException( - SolrException.ErrorCode.NOT_FOUND, - "File '" + filePath + "' not found in configSet: " + configSet); - } - FlexibleSolrJerseyResponse response = - instantiateJerseyResponse(FlexibleSolrJerseyResponse.class); - response.setUnknownProperty("path", filePath); - response.setUnknownProperty("content", new String(data, StandardCharsets.UTF_8)); - return response; - } - private static class InMemoryResourceLoader extends SolrResourceLoader { String resource; byte[] data; diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java index 788a3d3e39d9..23af56d31f31 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java @@ -24,6 +24,7 @@ import static org.mockito.Mockito.when; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; @@ -345,9 +346,10 @@ public void testBasicUserWorkflow() throws Exception { } } assertNotNull("solrconfig.xml not found in files!", file); - FlexibleSolrJerseyResponse fileContentsResp = schemaDesigner.getFileContents(configSet, file); - String solrconfigXml = (String) fileContentsResp.unknownProperties().get("content"); - assertNotNull(solrconfigXml); + byte[] solrconfigBytes = + cc.getConfigSetService().downloadFileFromConfig(getMutableId(configSet), file); + assertNotNull(solrconfigBytes); + String solrconfigXml = new String(solrconfigBytes, StandardCharsets.UTF_8); // Update solrconfig.xml when(mockReq.getContentStreams()) diff --git a/solr/webapp/web/js/angular/controllers/schema-designer.js b/solr/webapp/web/js/angular/controllers/schema-designer.js index 8a10e549a084..070ceb844f4f 100644 --- a/solr/webapp/web/js/angular/controllers/schema-designer.js +++ b/solr/webapp/web/js/angular/controllers/schema-designer.js @@ -15,7 +15,7 @@ limitations under the License. */ -solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, Luke) { +solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $cookies, $window, Constants, SchemaDesigner, ConfigSetFiles, Luke) { $scope.resetMenu("schema-designer", Constants.IS_ROOT_PAGE); $scope.schemas = []; @@ -905,7 +905,7 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $scope.selectedFile = id.startsWith("files/") ? id.substring("files/".length) : id; var mutableId = "._designer_" + $scope.currentSchema; - SchemaDesigner.get({configSet: mutableId, path: "file", filePath: $scope.selectedFile}, function (data) { + ConfigSetFiles.get({configSet: mutableId, filePath: $scope.selectedFile}, function (data) { $scope.fileNodeText = data.content; $scope.isLeafNode = false; if (doSelectOnTree) { diff --git a/solr/webapp/web/js/angular/services.js b/solr/webapp/web/js/angular/services.js index cb7af05151e9..47c5ad2fa6ee 100644 --- a/solr/webapp/web/js/angular/services.js +++ b/solr/webapp/web/js/angular/services.js @@ -73,6 +73,23 @@ solrAdminServices.factory('System', return $resource('admin/configs', {'wt': 'json', '_': Date.now()}, {"configs": {params: {action: "LIST"}} }); }]) +.factory('ConfigSetFiles', + ['$http', function ($http) { + // Fetches a single file from a configset via V2 /api/configsets/{name}/files/{path}. + // Each path segment is encoded separately so subdirectory paths like "lang/stopwords.txt" + // preserve their slashes (encoding them as %2F gets rejected by Jetty). + // transformResponse is overridden to skip JSON parsing since files are raw text. + return { + get: function (params, successFn, errorFn) { + var url = "/api/configsets/" + encodeURIComponent(params.configSet) + + "/files/" + params.filePath.split("/").map(encodeURIComponent).join("/"); + $http.get(url, {transformResponse: [function (data) { return data; }]}).then( + function (response) { if (successFn) successFn({content: response.data}); }, + function (response) { if (errorFn) errorFn(response); } + ); + } + }; + }]) .factory('Cores', ['$resource', function($resource) { return $resource('admin/cores', From 0574d0f4ef5ff174d10c5e6d915157c459fcb6e8 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 24 Apr 2026 18:18:47 -0400 Subject: [PATCH 40/69] tidy --- .../org/apache/solr/client/api/endpoint/SchemaDesignerApi.java | 1 - 1 file changed, 1 deletion(-) diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index 7e286596cda9..713d529703ea 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -162,5 +162,4 @@ SchemaDesignerResponse analyze( tags = {"schema-designer"}) SchemaDesignerSchemaDiffResponse getSchemaDiff(@PathParam("configSet") String configSet) throws Exception; - } From 41d35ed714a35c448168adcfdf2a50d321f25be7 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 24 Apr 2026 18:20:45 -0400 Subject: [PATCH 41/69] wordsmeith --- ...-configset-download-zip-to-solrj-fix-schema-designer-bug.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml b/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml index a4274e0c88f3..28d618f5f2da 100644 --- a/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml +++ b/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml @@ -1,5 +1,5 @@ # See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc -title: Fixed a bug in the "analyze" existing documents feature of Schema Designer that prevented the designer from working. +title: Fixed a bug in the analyze sample documents feature of Schema Designer that prevented the designer from working. type: fixed # added, changed, fixed, deprecated, removed, dependency_update, security, other authors: - name: Eric Pugh From ebb31ea68b344301137f1357a27dab8f8b93a1f1 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Wed, 6 May 2026 14:59:56 -0400 Subject: [PATCH 42/69] I hate that I added that super detailed unit test on inputs because then I had to fix this. If we used Rails we wouldn't need this! --- .../org/apache/solr/handler/configsets/DeleteConfigSet.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 3b26c5e2fc2b..9b6337528374 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 @@ -51,7 +51,7 @@ public DeleteConfigSet( @PermissionName(CONFIG_EDIT_PERM) public SolrJerseyResponse deleteConfigSet(String configSetName) throws Exception { final var response = instantiateJerseyResponse(SolrJerseyResponse.class); - if (StrUtils.isNullOrEmpty(configSetName)) { + if (StrUtils.isNullOrEmpty(configSetName) || configSetName.isBlank()) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, "No configset name provided to delete"); } From a89880f7cae760c1ee4dac6fb4a85e1778e71b7f Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 8 May 2026 07:47:47 -0400 Subject: [PATCH 43/69] remove duplication of tests from bats and junit, follow our bats style --- solr/packaging/test/test_schema_designer.bats | 95 +++---------------- 1 file changed, 11 insertions(+), 84 deletions(-) diff --git a/solr/packaging/test/test_schema_designer.bats b/solr/packaging/test/test_schema_designer.bats index cdb295490ecd..a08b35be813d 100644 --- a/solr/packaging/test/test_schema_designer.bats +++ b/solr/packaging/test/test_schema_designer.bats @@ -15,9 +15,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +# System-level coverage of the schema-designer HTTP surface only. +# Per-endpoint behavior is tested in TestSchemaDesigner.java. + load bats_helper -# A configSet name used throughout these tests DESIGNER_CONFIGSET="bats_books" setup_file() { @@ -37,15 +39,9 @@ setup() { teardown() { save_home_on_failure - - # Best-effort cleanup of the designer draft so tests remain independent curl -s -X DELETE "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}" > /dev/null || true } -# --------------------------------------------------------------------------- -# 1. List configs — the endpoint should return a JSON object with a configSets -# property even when no designer drafts exist yet. -# --------------------------------------------------------------------------- @test "list schema-designer configs returns JSON with configSets key" { run curl -s "http://localhost:${SOLR_PORT}/api/schema-designer/configs" assert_output --partial '"configSets"' @@ -53,41 +49,12 @@ teardown() { refute_output --partial '"status":500' } -# --------------------------------------------------------------------------- -# 2. Prepare a new mutable draft configSet -# --------------------------------------------------------------------------- -@test "prep new schema-designer configSet" { +@test "schema-designer end-to-end flow over HTTP" { run curl -s -X POST \ "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" assert_output --partial '"configSet"' refute_output --partial '"status":400' refute_output --partial '"status":500' -} - -# --------------------------------------------------------------------------- -# 3. Get info for the prepared configSet -# --------------------------------------------------------------------------- -@test "get info for schema-designer configSet" { - # Prepare first - curl -s -X POST \ - "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" \ - > /dev/null - - run curl -s "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/info" - assert_output --partial '"configSet"' - assert_output --partial "${DESIGNER_CONFIGSET}" - refute_output --partial '"status":400' - refute_output --partial '"status":500' -} - -# --------------------------------------------------------------------------- -# 4. Analyze sample documents — sends books.json as the request body -# --------------------------------------------------------------------------- -@test "analyze sample documents for schema-designer configSet" { - # Prepare the draft first - curl -s -X POST \ - "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" \ - > /dev/null run curl -s -X POST \ -H "Content-Type: application/json" \ @@ -96,37 +63,19 @@ teardown() { assert_output --partial '"configSet"' refute_output --partial '"status":400' refute_output --partial '"status":500' -} - -# --------------------------------------------------------------------------- -# 5. Query the temporary collection — should return documents after analyze -# --------------------------------------------------------------------------- -@test "query schema-designer configSet returns documents" { - # Prepare and analyze to load sample docs - curl -s -X POST \ - "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" \ - > /dev/null - curl -s -X POST \ - -H "Content-Type: application/json" \ - --data-binary "@${SOLR_TIP}/example/exampledocs/books.json" \ - "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/analyze" \ - > /dev/null run curl -s \ "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/query?q=*:*" assert_output --partial '"numFound"' refute_output --partial '"status":400' refute_output --partial '"status":500' + + run curl -s -o /dev/null -w "%{http_code}" \ + -X DELETE "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}" + assert_output "200" } -# --------------------------------------------------------------------------- -# 6. Download configSet zip via the schema-designer endpoint. -# We verify: -# - HTTP 200 response -# - The response body is a valid zip (starts with the PK magic bytes) -# --------------------------------------------------------------------------- @test "download schema-designer configSet as zip" { - # Prepare the draft curl -s -X POST \ "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" \ > /dev/null @@ -134,48 +83,26 @@ teardown() { local zip_file="${BATS_TEST_TMPDIR}/${DESIGNER_CONFIGSET}.zip" local mutable_id="._designer_${DESIGNER_CONFIGSET}" - # Capture HTTP status code separately local http_code http_code=$(curl -s -o "${zip_file}" -w "%{http_code}" \ - "http://localhost:${SOLR_PORT}/api/configsets/$(python3 -c "import urllib.parse; print(urllib.parse.quote('${mutable_id}', safe=''))")/files?displayName=${DESIGNER_CONFIGSET}") + "http://localhost:${SOLR_PORT}/api/configsets/${mutable_id}/files?displayName=${DESIGNER_CONFIGSET}") - # Assert HTTP 200 [ "${http_code}" = "200" ] - - # Assert the file was written and is non-empty [ -s "${zip_file}" ] - # Assert the file starts with the ZIP magic bytes (PK = 0x504B) + # ZIP magic bytes (PK = 0x504B) run bash -c "xxd '${zip_file}' | head -1" assert_output --partial '504b' } -# --------------------------------------------------------------------------- -# 7. Download configSet zip — Content-Disposition header carries the filename -# --------------------------------------------------------------------------- @test "download schema-designer configSet has correct Content-Disposition header" { - # Prepare the draft curl -s -X POST \ "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" \ > /dev/null local mutable_id="._designer_${DESIGNER_CONFIGSET}" run curl -s -I \ - "http://localhost:${SOLR_PORT}/api/configsets/$(python3 -c "import urllib.parse; print(urllib.parse.quote('${mutable_id}', safe=''))")/files?displayName=${DESIGNER_CONFIGSET}" + "http://localhost:${SOLR_PORT}/api/configsets/${mutable_id}/files?displayName=${DESIGNER_CONFIGSET}" assert_output --partial 'Content-Disposition' assert_output --partial '.zip' } - -# --------------------------------------------------------------------------- -# 8. Cleanup (DELETE) removes the designer draft -# --------------------------------------------------------------------------- -@test "cleanup schema-designer configSet succeeds" { - # Prepare first so there is something to delete - curl -s -X POST \ - "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" \ - > /dev/null - - run curl -s -o /dev/null -w "%{http_code}" \ - -X DELETE "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}" - assert_output "200" -} From 9f1ca910064d6957eb5f339edea31632efb1e539 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 8 May 2026 18:24:58 -0400 Subject: [PATCH 44/69] More User oriented title --- ...-configset-download-zip-to-solrj-fix-schema-designer-bug.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml b/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml index 28d618f5f2da..b850dfe995a9 100644 --- a/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml +++ b/changelog/unreleased/SOLR-18152-add-configset-download-zip-to-solrj-fix-schema-designer-bug.yml @@ -1,5 +1,5 @@ # See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc -title: Fixed a bug in the analyze sample documents feature of Schema Designer that prevented the designer from working. +title: Schema Designer sample-doc analysis now works correctly when analyze sample documents. type: fixed # added, changed, fixed, deprecated, removed, dependency_update, security, other authors: - name: Eric Pugh From 414d6a3e98834f32aea93bf9d331e50af78056ed Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 8 May 2026 18:25:05 -0400 Subject: [PATCH 45/69] Move to seperate PR --- .../configsets/DeleteConfigSetAPITest.java | 93 ------------------- 1 file changed, 93 deletions(-) delete mode 100644 solr/core/src/test/org/apache/solr/handler/configsets/DeleteConfigSetAPITest.java diff --git a/solr/core/src/test/org/apache/solr/handler/configsets/DeleteConfigSetAPITest.java b/solr/core/src/test/org/apache/solr/handler/configsets/DeleteConfigSetAPITest.java deleted file mode 100644 index 9846f960852e..000000000000 --- a/solr/core/src/test/org/apache/solr/handler/configsets/DeleteConfigSetAPITest.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * 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 org.apache.solr.SolrTestCase; -import org.apache.solr.common.SolrException; -import org.apache.solr.core.CoreContainer; -import org.junit.Before; -import org.junit.BeforeClass; -import org.junit.Test; - -/** - * Unit tests for {@link DeleteConfigSet}. - * - *

Note: This test focuses on input validation. Full deletion workflow is tested in integration - * tests like {@code TestConfigSetsAPI} since actual deletion requires ZooKeeper interaction. - */ -public class DeleteConfigSetAPITest extends SolrTestCase { - - private CoreContainer mockCoreContainer; - - @BeforeClass - public static void ensureWorkingMockito() { - assumeWorkingMockito(); - } - - @Before - public void clearMocks() { - mockCoreContainer = mock(CoreContainer.class); - } - - @Test - public void testNullConfigSetNameThrowsBadRequest() { - final var api = new DeleteConfigSet(mockCoreContainer, null, null); - final var ex = assertThrows(SolrException.class, () -> api.deleteConfigSet(null)); - - assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); - assertTrue( - "Error message should mention missing configset name", - ex.getMessage().contains("No configset name")); - } - - @Test - public void testEmptyConfigSetNameThrowsBadRequest() { - final var api = new DeleteConfigSet(mockCoreContainer, null, null); - final var ex = assertThrows(SolrException.class, () -> api.deleteConfigSet("")); - - assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); - assertTrue( - "Error message should mention missing configset name", - ex.getMessage().contains("No configset name")); - } - - @Test - public void testWhitespaceOnlyConfigSetNameThrowsBadRequest() { - final var api = new DeleteConfigSet(mockCoreContainer, null, null); - final var ex = assertThrows(SolrException.class, () -> api.deleteConfigSet(" ")); - - assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); - assertTrue( - "Error message should mention missing configset name", - ex.getMessage().contains("No configset name")); - } - - @Test - public void testTabOnlyConfigSetNameThrowsBadRequest() { - final var api = new DeleteConfigSet(mockCoreContainer, null, null); - final var ex = assertThrows(SolrException.class, () -> api.deleteConfigSet("\t")); - - assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, ex.code()); - assertTrue( - "Error message should mention missing configset name", - ex.getMessage().contains("No configset name")); - } -} From fdcfc5c771278b82ef88349ee078f7c847544898 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 8 May 2026 18:25:15 -0400 Subject: [PATCH 46/69] unclear the true value! --- solr/packaging/test/test_schema_designer.bats | 108 ------------------ 1 file changed, 108 deletions(-) delete mode 100644 solr/packaging/test/test_schema_designer.bats diff --git a/solr/packaging/test/test_schema_designer.bats b/solr/packaging/test/test_schema_designer.bats deleted file mode 100644 index a08b35be813d..000000000000 --- a/solr/packaging/test/test_schema_designer.bats +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env bats - -# 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. - -# System-level coverage of the schema-designer HTTP surface only. -# Per-endpoint behavior is tested in TestSchemaDesigner.java. - -load bats_helper - -DESIGNER_CONFIGSET="bats_books" - -setup_file() { - common_clean_setup - solr start - solr assert --started http://localhost:${SOLR_PORT} --timeout 60000 -} - -teardown_file() { - common_setup - solr stop --all -} - -setup() { - common_setup -} - -teardown() { - save_home_on_failure - curl -s -X DELETE "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}" > /dev/null || true -} - -@test "list schema-designer configs returns JSON with configSets key" { - run curl -s "http://localhost:${SOLR_PORT}/api/schema-designer/configs" - assert_output --partial '"configSets"' - refute_output --partial '"status":400' - refute_output --partial '"status":500' -} - -@test "schema-designer end-to-end flow over HTTP" { - run curl -s -X POST \ - "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" - assert_output --partial '"configSet"' - refute_output --partial '"status":400' - refute_output --partial '"status":500' - - run curl -s -X POST \ - -H "Content-Type: application/json" \ - --data-binary "@${SOLR_TIP}/example/exampledocs/books.json" \ - "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/analyze" - assert_output --partial '"configSet"' - refute_output --partial '"status":400' - refute_output --partial '"status":500' - - run curl -s \ - "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/query?q=*:*" - assert_output --partial '"numFound"' - refute_output --partial '"status":400' - refute_output --partial '"status":500' - - run curl -s -o /dev/null -w "%{http_code}" \ - -X DELETE "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}" - assert_output "200" -} - -@test "download schema-designer configSet as zip" { - curl -s -X POST \ - "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" \ - > /dev/null - - local zip_file="${BATS_TEST_TMPDIR}/${DESIGNER_CONFIGSET}.zip" - local mutable_id="._designer_${DESIGNER_CONFIGSET}" - - local http_code - http_code=$(curl -s -o "${zip_file}" -w "%{http_code}" \ - "http://localhost:${SOLR_PORT}/api/configsets/${mutable_id}/files?displayName=${DESIGNER_CONFIGSET}") - - [ "${http_code}" = "200" ] - [ -s "${zip_file}" ] - - # ZIP magic bytes (PK = 0x504B) - run bash -c "xxd '${zip_file}' | head -1" - assert_output --partial '504b' -} - -@test "download schema-designer configSet has correct Content-Disposition header" { - curl -s -X POST \ - "http://localhost:${SOLR_PORT}/api/schema-designer/${DESIGNER_CONFIGSET}/prep?copyFrom=_default" \ - > /dev/null - - local mutable_id="._designer_${DESIGNER_CONFIGSET}" - run curl -s -I \ - "http://localhost:${SOLR_PORT}/api/configsets/${mutable_id}/files?displayName=${DESIGNER_CONFIGSET}" - assert_output --partial 'Content-Disposition' - assert_output --partial '.zip' -} From b51d7c82e7e6b080504b50f1e56ddf1146849f62 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 8 May 2026 19:50:12 -0400 Subject: [PATCH 47/69] Refactor duplicates, found a fourth method that wasn't actually used, left over from v1 --- .../api/model/SchemaDesignerInfoResponse.java | 25 +--------- .../api/model/SchemaDesignerResponse.java | 25 +--------- .../SchemaDesignerSchemaDiffResponse.java | 26 +--------- .../model/SchemaDesignerSettingsResponse.java | 47 +++++++++++++++++++ .../solr/handler/designer/SchemaDesigner.java | 39 +-------------- 5 files changed, 52 insertions(+), 110 deletions(-) create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSettingsResponse.java diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerInfoResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerInfoResponse.java index 104495af07da..528f426c0abe 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerInfoResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerInfoResponse.java @@ -22,7 +22,7 @@ /** Response body for the Schema Designer get-info endpoint. */ @JsonInclude(JsonInclude.Include.NON_NULL) -public class SchemaDesignerInfoResponse extends SolrJerseyResponse { +public class SchemaDesignerInfoResponse extends SchemaDesignerSettingsResponse { @JsonProperty("configSet") public String configSet; @@ -41,27 +41,4 @@ public class SchemaDesignerInfoResponse extends SolrJerseyResponse { /** Number of sample documents stored for this configSet, if available. */ @JsonProperty("numDocs") public Integer numDocs; - - // --- designer settings --- - - @JsonProperty("languages") - public List languages; - - @JsonProperty("enableFieldGuessing") - public Boolean enableFieldGuessing; - - @JsonProperty("enableDynamicFields") - public Boolean enableDynamicFields; - - @JsonProperty("enableNestedDocs") - public Boolean enableNestedDocs; - - @JsonProperty("disabled") - public Boolean disabled; - - @JsonProperty("publishedVersion") - public Integer publishedVersion; - - @JsonProperty("copyFrom") - public String copyFrom; } diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java index d41f79d64bb5..f7e3325d9f83 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java @@ -29,7 +29,7 @@ *

All nullable fields are omitted from JSON output when null. */ @JsonInclude(JsonInclude.Include.NON_NULL) -public class SchemaDesignerResponse extends SolrJerseyResponse { +public class SchemaDesignerResponse extends SchemaDesignerSettingsResponse { // --- core schema identification --- @@ -77,29 +77,6 @@ public class SchemaDesignerResponse extends SolrJerseyResponse { @JsonProperty("numDocs") public Integer numDocs; - // --- designer settings --- - - @JsonProperty("languages") - public List languages; - - @JsonProperty("enableFieldGuessing") - public Boolean enableFieldGuessing; - - @JsonProperty("enableDynamicFields") - public Boolean enableDynamicFields; - - @JsonProperty("enableNestedDocs") - public Boolean enableNestedDocs; - - @JsonProperty("disabled") - public Boolean disabled; - - @JsonProperty("publishedVersion") - public Integer publishedVersion; - - @JsonProperty("copyFrom") - public String copyFrom; - // --- error fields (set when sample-doc indexing fails) --- @JsonProperty("updateError") diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java index f5b9f61d7de0..dfe60441117b 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java @@ -18,12 +18,11 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; import java.util.Map; /** Response body for the Schema Designer get-schema-diff endpoint. */ @JsonInclude(JsonInclude.Include.NON_NULL) -public class SchemaDesignerSchemaDiffResponse extends SolrJerseyResponse { +public class SchemaDesignerSchemaDiffResponse extends SchemaDesignerSettingsResponse { /** The list of field-level differences between the designed schema and the source. */ @JsonProperty("diff") @@ -32,27 +31,4 @@ public class SchemaDesignerSchemaDiffResponse extends SolrJerseyResponse { /** The configSet used as the diff source (either the published configSet or copyFrom). */ @JsonProperty("diff-source") public String diffSource; - - // --- designer settings (reflected from the mutable configSet) --- - - @JsonProperty("languages") - public List languages; - - @JsonProperty("enableFieldGuessing") - public Boolean enableFieldGuessing; - - @JsonProperty("enableDynamicFields") - public Boolean enableDynamicFields; - - @JsonProperty("enableNestedDocs") - public Boolean enableNestedDocs; - - @JsonProperty("disabled") - public Boolean disabled; - - @JsonProperty("publishedVersion") - public Integer publishedVersion; - - @JsonProperty("copyFrom") - public String copyFrom; } diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSettingsResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSettingsResponse.java new file mode 100644 index 000000000000..7f695ef48a42 --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSettingsResponse.java @@ -0,0 +1,47 @@ +/* + * 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.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** Base response for Schema Designer endpoints that surface the designer settings. */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public abstract class SchemaDesignerSettingsResponse extends SolrJerseyResponse { + + @JsonProperty("languages") + public List languages; + + @JsonProperty("enableFieldGuessing") + public Boolean enableFieldGuessing; + + @JsonProperty("enableDynamicFields") + public Boolean enableDynamicFields; + + @JsonProperty("enableNestedDocs") + public Boolean enableNestedDocs; + + @JsonProperty("disabled") + public Boolean disabled; + + @JsonProperty("publishedVersion") + public Integer publishedVersion; + + @JsonProperty("copyFrom") + public String copyFrom; +} diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index 4c776bfcc25e..a2bec2d7ed78 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -56,6 +56,7 @@ import org.apache.solr.client.api.model.SchemaDesignerPublishResponse; import org.apache.solr.client.api.model.SchemaDesignerResponse; import org.apache.solr.client.api.model.SchemaDesignerSchemaDiffResponse; +import org.apache.solr.client.api.model.SchemaDesignerSettingsResponse; import org.apache.solr.client.api.model.SolrJerseyResponse; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.impl.CloudSolrClient; @@ -1346,44 +1347,8 @@ protected Map readJsonFromRequest() throws IOException { return (Map) json; } - void addSettingsToResponse(SchemaDesignerSettings settings, final Map response) { - response.put(LANGUAGES_PARAM, settings.getLanguages()); - response.put(ENABLE_FIELD_GUESSING_PARAM, settings.fieldGuessingEnabled()); - response.put(ENABLE_DYNAMIC_FIELDS_PARAM, settings.dynamicFieldsEnabled()); - response.put(ENABLE_NESTED_DOCS_PARAM, settings.nestedDocsEnabled()); - response.put(DISABLED, settings.isDisabled()); - Optional publishedVersion = settings.getPublishedVersion(); - publishedVersion.ifPresent(version -> response.put(PUBLISHED_VERSION, version)); - String copyFrom = settings.getCopyFrom(); - if (copyFrom != null) { - response.put(COPY_FROM_PARAM, copyFrom); - } - } - - void addSettingsToResponse( - SchemaDesignerSettings settings, final SchemaDesignerInfoResponse response) { - response.languages = settings.getLanguages(); - response.enableFieldGuessing = settings.fieldGuessingEnabled(); - response.enableDynamicFields = settings.dynamicFieldsEnabled(); - response.enableNestedDocs = settings.nestedDocsEnabled(); - response.disabled = settings.isDisabled(); - settings.getPublishedVersion().ifPresent(v -> response.publishedVersion = v); - response.copyFrom = settings.getCopyFrom(); - } - - void addSettingsToResponse( - SchemaDesignerSettings settings, final SchemaDesignerSchemaDiffResponse response) { - response.languages = settings.getLanguages(); - response.enableFieldGuessing = settings.fieldGuessingEnabled(); - response.enableDynamicFields = settings.dynamicFieldsEnabled(); - response.enableNestedDocs = settings.nestedDocsEnabled(); - response.disabled = settings.isDisabled(); - settings.getPublishedVersion().ifPresent(v -> response.publishedVersion = v); - response.copyFrom = settings.getCopyFrom(); - } - void addSettingsToResponse( - SchemaDesignerSettings settings, final SchemaDesignerResponse response) { + SchemaDesignerSettings settings, final SchemaDesignerSettingsResponse response) { response.languages = settings.getLanguages(); response.enableFieldGuessing = settings.fieldGuessingEnabled(); response.enableDynamicFields = settings.dynamicFieldsEnabled(); From ee50f7df354aefa0e6131206a7bcfae99576585b Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 8 May 2026 19:55:41 -0400 Subject: [PATCH 48/69] Some deeper docs on addErrorToResponse. This is definitly a unique thing about this api that others don't have. --- .../solr/handler/designer/SchemaDesigner.java | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index a2bec2d7ed78..424ceb9db1b2 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -1232,6 +1232,28 @@ private static void setSchemaObjectField( } } + /** + * Merges sample-document indexing errors into a response so the endpoint can return them to the + * UI instead of throwing. Sample docs are indexed into a temp collection to drive field-type + * inference; when that indexing fails, callers continue to build a normal response and use this + * method to attach the error details. + * + *

Two error sources are accepted (either or both may be present): + * + *

    + *
  • {@code solrExc} — a top-level exception (e.g. the whole indexing call failed). May be + * {@code null}. + *
  • {@code errorsDuringIndexing} — per-document failures. Keys are the failing document's + * unique-id field VALUE (typed as {@code Object} because the schema's id field type is + * unknown — typically {@code String}, but could be e.g. {@code Long}); values are the root + * cause for that doc. May be {@code null} or empty. + *
+ * + *

If both are absent, the response is left untouched. Otherwise, populates {@code updateError} + * (message), {@code updateErrorCode} (HTTP-style code; defaults to 400), and {@code errorDetails} + * (the per-doc map). The {@code updateError} parameter, when non-null, overrides {@code + * solrExc.getMessage()} as the user-facing message. + */ protected void addErrorToResponse( String collection, SolrException solrExc, @@ -1259,6 +1281,10 @@ protected void addErrorToResponse( } } + /** + * Overload that writes into the typed {@link SchemaDesignerResponse}. See {@link + * #addErrorToResponse(String, SolrException, Map, Map, String)} for full semantics. + */ protected void addErrorToResponse( String collection, SolrException solrExc, @@ -1292,7 +1318,12 @@ protected void addErrorToResponse( } } - /** Overload for {@link SchemaDesignerPublishResponse} error fields. */ + /** + * Overload that writes into the typed {@link SchemaDesignerPublishResponse}. See {@link + * #addErrorToResponse(String, SolrException, Map, Map, String)} for full semantics. Note this + * variant has no {@code updateError} override parameter — publish callers always derive the + * message from {@code solrExc} or the default. + */ protected void addErrorToResponse( String collection, SolrException solrExc, From 9c86ee841a2876ce2b7b6f108a0fceb4058b3f2c Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 8 May 2026 20:04:16 -0400 Subject: [PATCH 49/69] Cleaning up old code, and comparign schema-designer.js to our java code. --- .../solr/handler/designer/SchemaDesigner.java | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index 424ceb9db1b2..e9f81bc6e21b 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -1215,20 +1215,26 @@ SchemaDesignerResponse buildSchemaDesignerResponse( return response; } - /** Sets the named schema-object field on {@code response} based on the action type. */ + /** + * Sets the response's name field for the schema object that was just added, based on the action + * key from the Schema API request body. The four valid actions are pre-validated by {@link + * SchemaDesignerConfigSetHelper#addSchemaObject}, so reaching {@code default} indicates a + * programmer error (e.g. a new action added upstream without a corresponding case here). + */ private static void setSchemaObjectField( SchemaDesignerResponse response, String action, Object value) { - // Handles both bare camelCase names used internally ('field', 'fieldType') and the - // kebab-case prefixed names that come directly from Schema API request JSON - // ('add-field', 'add-field-type', 'add-dynamic-field'). switch (action) { - case "field", "add-field" -> response.field = value; - case "type", "add-type" -> response.type = value; - case "dynamicField", "add-dynamic-field" -> response.dynamicField = value; - case "fieldType", "add-field-type" -> response.fieldType = value; - default -> { - /* unknown action type — silently ignore */ + case "add-field" -> response.field = value; + case "add-dynamic-field" -> response.dynamicField = value; + case "add-field-type" -> response.fieldType = value; + case "add-copy-field" -> { + // Copy fields have no single "name" to surface on the response — the JS UI only checks + // for an error and refreshes; nothing to set. } + default -> throw new IllegalStateException( + "Unhandled schema-designer action '" + + action + + "'; addSchemaObject should have rejected this upstream."); } } From 690dd9b52ab62d5c5c729ca7e3b4b3000dca873b Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 8 May 2026 20:26:04 -0400 Subject: [PATCH 50/69] Actually support this in solrj (not jsut when js calsl the endpoint) --- .../api/endpoint/SchemaDesignerApi.java | 19 ++++++++++++++++++- .../solr/handler/designer/SchemaDesigner.java | 9 +++++++-- .../handler/designer/TestSchemaDesigner.java | 19 +++++++++++-------- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index 713d529703ea..979d0176dbfe 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -16,7 +16,12 @@ */ package org.apache.solr.client.api.endpoint; +import static org.apache.solr.client.api.util.Constants.GENERIC_ENTITY_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.DefaultValue; import jakarta.ws.rs.GET; @@ -25,6 +30,7 @@ import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.QueryParam; +import java.io.InputStream; import java.util.List; import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; import org.apache.solr.client.api.model.SchemaDesignerCollectionsResponse; @@ -68,7 +74,18 @@ SchemaDesignerResponse prepNewSchema( summary = "Update the contents of a file in a configSet being designed.", tags = {"schema-designer"}) SchemaDesignerResponse updateFileContents( - @PathParam("configSet") String configSet, @QueryParam("file") String file) throws Exception; + @PathParam("configSet") String configSet, + @QueryParam("file") String file, + @RequestBody( + required = true, + extensions = { + @Extension( + properties = { + @ExtensionProperty(name = GENERIC_ENTITY_PROPERTY, value = "true") + }) + }) + InputStream fileContents) + throws Exception; @GET @Path("/{configSet}/sample") diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index e9f81bc6e21b..98e297de1ff5 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -228,9 +228,14 @@ public SolrJerseyResponse cleanupTempSchema(String configSet) throws Exception { @Override @PermissionName(CONFIG_EDIT_PERM) - public SchemaDesignerResponse updateFileContents(String configSet, String file) throws Exception { + public SchemaDesignerResponse updateFileContents( + String configSet, String file, InputStream fileContents) throws Exception { requireNotEmpty(CONFIG_SET_PARAM, configSet); requireNotEmpty("file", file); + if (fileContents == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Request body with file contents is required!"); + } String mutableId = getMutableId(configSet); String zkPath = getConfigSetZkPath(mutableId, file); @@ -241,7 +246,7 @@ public SchemaDesignerResponse updateFileContents(String configSet, String file) } byte[] data; - try (InputStream in = extractSingleContentStream(true).getStream()) { + try (InputStream in = fileContents) { data = in.readAllBytes(); } Exception updateFileError = null; diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java index 23af56d31f31..62ae9f7f790c 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java @@ -23,6 +23,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -352,17 +353,19 @@ public void testBasicUserWorkflow() throws Exception { String solrconfigXml = new String(solrconfigBytes, StandardCharsets.UTF_8); // Update solrconfig.xml - when(mockReq.getContentStreams()) - .thenReturn(List.of(new ContentStreamBase.StringStream(solrconfigXml, "application/xml"))); - response = schemaDesigner.updateFileContents(configSet, file); + response = + schemaDesigner.updateFileContents( + configSet, + file, + new ByteArrayInputStream(solrconfigXml.getBytes(StandardCharsets.UTF_8))); schemaVersion = response.schemaVersion; - // update solrconfig.xml with some invalid XML mess - when(mockReq.getContentStreams()) - .thenReturn(List.of(new ContentStreamBase.StringStream("", "application/xml"))); - // this should fail b/c the updated solrconfig.xml is invalid - response = schemaDesigner.updateFileContents(configSet, file); + response = + schemaDesigner.updateFileContents( + configSet, + file, + new ByteArrayInputStream("".getBytes(StandardCharsets.UTF_8))); assertNotNull(response.updateFileError); // remove dynamic fields and change the language to "en" only From bd303147a2b2dc26da91ba3a1d4a24aa0fca5b5e Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Sat, 9 May 2026 10:07:35 -0400 Subject: [PATCH 51/69] Revamp API with an eye to proper OpenAPI support. --- .../api/endpoint/SchemaDesignerApi.java | 22 ++- .../model/SchemaDesignerAddRequestBody.java | 45 +++++ .../SchemaDesignerUpdateRequestBody.java | 48 ++++++ .../solr/handler/designer/SchemaDesigner.java | 48 +++++- .../handler/designer/TestSchemaDesigner.java | 88 +++++----- .../designer/TestSchemaDesignerSolrJ.java | 154 ++++++++++++++++++ 6 files changed, 350 insertions(+), 55 deletions(-) create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerAddRequestBody.java create mode 100644 solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerUpdateRequestBody.java create mode 100644 solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index 979d0176dbfe..09c66395e068 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -33,12 +33,14 @@ import java.io.InputStream; import java.util.List; import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; +import org.apache.solr.client.api.model.SchemaDesignerAddRequestBody; import org.apache.solr.client.api.model.SchemaDesignerCollectionsResponse; import org.apache.solr.client.api.model.SchemaDesignerConfigsResponse; import org.apache.solr.client.api.model.SchemaDesignerInfoResponse; import org.apache.solr.client.api.model.SchemaDesignerPublishResponse; import org.apache.solr.client.api.model.SchemaDesignerResponse; import org.apache.solr.client.api.model.SchemaDesignerSchemaDiffResponse; +import org.apache.solr.client.api.model.SchemaDesignerUpdateRequestBody; import org.apache.solr.client.api.model.SolrJerseyResponse; /** V2 API definitions for the Solr Schema Designer. */ @@ -120,7 +122,9 @@ SchemaDesignerCollectionsResponse listCollectionsForConfig( summary = "Add a new field, field type, or dynamic field to the schema being designed.", tags = {"schema-designer"}) SchemaDesignerResponse addSchemaObject( - @PathParam("configSet") String configSet, @QueryParam("schemaVersion") Integer schemaVersion) + @PathParam("configSet") String configSet, + @QueryParam("schemaVersion") Integer schemaVersion, + SchemaDesignerAddRequestBody requestBody) throws Exception; @PUT @@ -129,7 +133,9 @@ SchemaDesignerResponse addSchemaObject( summary = "Update an existing field or field type in the schema being designed.", tags = {"schema-designer"}) SchemaDesignerResponse updateSchemaObject( - @PathParam("configSet") String configSet, @QueryParam("schemaVersion") Integer schemaVersion) + @PathParam("configSet") String configSet, + @QueryParam("schemaVersion") Integer schemaVersion, + SchemaDesignerUpdateRequestBody requestBody) throws Exception; @PUT @@ -149,10 +155,22 @@ SchemaDesignerPublishResponse publish( @QueryParam("disableDesigner") @DefaultValue("false") Boolean disableDesigner) throws Exception; + /** + * Analyzes sample documents to suggest a schema. + * + *

Sample documents are read from the HTTP request body (not declared as a parameter on this + * interface — see {@code SchemaDesigner#loadSampleDocuments}) and dispatched to a parser based on + * the {@code Content-Type} header. + */ @POST @Path("/{configSet}/analyze") @Operation( summary = "Analyze sample documents and suggest a schema.", + description = + "Sample documents are supplied in the request body. The Content-Type header selects the" + + " parser: application/json, text/xml or application/xml, text/csv or" + + " application/csv, or text/plain or application/octet-stream (treated as JSON" + + " lines). Capped at 5MB and 1000 documents.", tags = {"schema-designer"}) SchemaDesignerResponse analyze( @PathParam("configSet") String configSet, diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerAddRequestBody.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerAddRequestBody.java new file mode 100644 index 000000000000..821cabc4246a --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerAddRequestBody.java @@ -0,0 +1,45 @@ +/* + * 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; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Map; + +/** + * Request body for the Schema Designer add endpoint. Exactly one of the four fields should be + * populated; the populated field's name is the action and its value carries the schema-object + * attributes (e.g. for {@code addField}: {@code name}, {@code type}, {@code stored}, etc.). + */ +public class SchemaDesignerAddRequestBody { + + @Schema(name = "addField") + @JsonProperty("add-field") + public Map addField; + + @Schema(name = "addDynamicField") + @JsonProperty("add-dynamic-field") + public Map addDynamicField; + + @Schema(name = "addCopyField") + @JsonProperty("add-copy-field") + public Map addCopyField; + + @Schema(name = "addFieldType") + @JsonProperty("add-field-type") + public Map addFieldType; +} diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerUpdateRequestBody.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerUpdateRequestBody.java new file mode 100644 index 000000000000..54b9bb9e56cb --- /dev/null +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerUpdateRequestBody.java @@ -0,0 +1,48 @@ +/* + * 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.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.HashMap; +import java.util.Map; + +/** + * Request body for the Schema Designer update endpoint: a flat field or field-type definition. The + * {@code name} property is required; remaining schema attributes (e.g. {@code type}, {@code + * indexed}, {@code stored}, {@code analyzer}, {@code copyDest}) are captured via the dynamic {@code + * additionalProperties} map and forwarded to the Schema API. + */ +public class SchemaDesignerUpdateRequestBody { + + @JsonProperty public String name; + + // Non-final + public so the OpenAPI-generated SolrJ client can assign to it directly. + // Accessed via @JsonAnyGetter / @JsonAnySetter for JSON (de)serialization. + public Map additionalProperties = new HashMap<>(); + + @JsonAnyGetter + public Map getAdditionalProperties() { + return additionalProperties; + } + + @JsonAnySetter + public void setAdditionalProperty(String key, Object value) { + additionalProperties.put(key, value); + } +} diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index 98e297de1ff5..068959645de3 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -50,6 +50,7 @@ import org.apache.solr.api.JerseyResource; import org.apache.solr.client.api.endpoint.SchemaDesignerApi; import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; +import org.apache.solr.client.api.model.SchemaDesignerAddRequestBody; import org.apache.solr.client.api.model.SchemaDesignerCollectionsResponse; import org.apache.solr.client.api.model.SchemaDesignerConfigsResponse; import org.apache.solr.client.api.model.SchemaDesignerInfoResponse; @@ -57,6 +58,7 @@ import org.apache.solr.client.api.model.SchemaDesignerResponse; import org.apache.solr.client.api.model.SchemaDesignerSchemaDiffResponse; import org.apache.solr.client.api.model.SchemaDesignerSettingsResponse; +import org.apache.solr.client.api.model.SchemaDesignerUpdateRequestBody; import org.apache.solr.client.api.model.SolrJerseyResponse; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.impl.CloudSolrClient; @@ -399,17 +401,44 @@ protected Map listEnabledConfigs() throws IOException { @Override @PermissionName(CONFIG_EDIT_PERM) - public SchemaDesignerResponse addSchemaObject(String configSet, Integer schemaVersion) + public SchemaDesignerResponse addSchemaObject( + String configSet, Integer schemaVersion, SchemaDesignerAddRequestBody requestBody) throws Exception { requireNotEmpty(CONFIG_SET_PARAM, configSet); requireSchemaVersion(schemaVersion); final String mutableId = checkMutable(configSet, schemaVersion); - Map addJson = readJsonFromRequest(); + String action; + Map attrs; + if (requestBody == null) { + action = null; + attrs = null; + } else if (requestBody.addField != null) { + action = "add-field"; + attrs = requestBody.addField; + } else if (requestBody.addDynamicField != null) { + action = "add-dynamic-field"; + attrs = requestBody.addDynamicField; + } else if (requestBody.addCopyField != null) { + action = "add-copy-field"; + attrs = requestBody.addCopyField; + } else if (requestBody.addFieldType != null) { + action = "add-field-type"; + attrs = requestBody.addFieldType; + } else { + action = null; + attrs = null; + } + if (action == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Request body must contain exactly one of: add-field, add-dynamic-field, add-copy-field, add-field-type"); + } + + Map addJson = Map.of(action, attrs); log.info("Adding new schema object from JSON: {}", addJson); String objectName = configSetHelper.addSchemaObject(configSet, addJson); - String action = addJson.keySet().iterator().next(); ManagedIndexSchema schema = loadLatestSchema(mutableId); SchemaDesignerResponse response = @@ -421,21 +450,22 @@ public SchemaDesignerResponse addSchemaObject(String configSet, Integer schemaVe @Override @PermissionName(CONFIG_EDIT_PERM) - public SchemaDesignerResponse updateSchemaObject(String configSet, Integer schemaVersion) + public SchemaDesignerResponse updateSchemaObject( + String configSet, Integer schemaVersion, SchemaDesignerUpdateRequestBody requestBody) throws Exception { requireNotEmpty(CONFIG_SET_PARAM, configSet); requireSchemaVersion(schemaVersion); final String mutableId = checkMutable(configSet, schemaVersion); - // Updated field definition is in the request body as JSON - Map updateField = readJsonFromRequest(); - String name = (String) updateField.get("name"); - if (StrUtils.isNullOrEmpty(name)) { + if (requestBody == null || StrUtils.isNullOrEmpty(requestBody.name)) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, "Invalid update request! JSON payload is missing the required name property: " - + updateField); + + requestBody); } + String name = requestBody.name; + Map updateField = new HashMap<>(requestBody.getAdditionalProperties()); + updateField.put("name", name); log.info( "Updating schema object: configSet={}, mutableId={}, name={}, JSON={}", configSet, diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java index 62ae9f7f790c..2845fe0707fa 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java @@ -36,10 +36,12 @@ import java.util.Optional; import java.util.stream.Stream; import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; +import org.apache.solr.client.api.model.SchemaDesignerAddRequestBody; import org.apache.solr.client.api.model.SchemaDesignerCollectionsResponse; import org.apache.solr.client.api.model.SchemaDesignerInfoResponse; import org.apache.solr.client.api.model.SchemaDesignerResponse; import org.apache.solr.client.api.model.SchemaDesignerSchemaDiffResponse; +import org.apache.solr.client.api.model.SchemaDesignerUpdateRequestBody; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.request.SolrQuery; import org.apache.solr.client.solrj.response.QueryResponse; @@ -54,6 +56,7 @@ import org.apache.solr.common.util.SimpleOrderedMap; import org.apache.solr.core.CoreContainer; import org.apache.solr.handler.TestSampleDocumentsLoader; +import org.apache.solr.jersey.SolrJacksonMapper; import org.apache.solr.request.SolrQueryRequest; import org.apache.solr.schema.ManagedIndexSchema; import org.apache.solr.schema.SchemaField; @@ -62,7 +65,6 @@ import org.junit.Before; import org.junit.BeforeClass; import org.junit.Test; -import org.noggit.JSONUtil; public class TestSchemaDesigner extends SolrCloudTestCase implements SchemaDesignerConstants { @@ -447,12 +449,10 @@ public void testBasicUserWorkflow() throws Exception { // editing suggestions for fields and adding/removing fields / field types as needed // add a new field - stream = new ContentStreamBase.FileStream(getFile("schema-designer/add-new-field.json")); - stream.setContentType(JSON_MIME); - when(mockReq.getContentStreams()).thenReturn(List.of(stream)); - // POST /schema-designer/add - response = schemaDesigner.addSchemaObject(configSet, schemaVersion); + response = + schemaDesigner.addSchemaObject( + configSet, schemaVersion, loadAddBody("schema-designer/add-new-field.json")); assertNotNull(response.field); schemaVersion = response.schemaVersion; assertNotNull(response.fields); @@ -460,22 +460,18 @@ public void testBasicUserWorkflow() throws Exception { // update an existing field // switch a single-valued field to a multivalued field, which triggers a full rebuild of the // "temp" collection - stream = new ContentStreamBase.FileStream(getFile("schema-designer/update-author-field.json")); - stream.setContentType(JSON_MIME); - when(mockReq.getContentStreams()).thenReturn(List.of(stream)); - // PUT /schema-designer/update - response = schemaDesigner.updateSchemaObject(configSet, schemaVersion); + response = + schemaDesigner.updateSchemaObject( + configSet, schemaVersion, loadUpdateBody("schema-designer/update-author-field.json")); assertNotNull(response.field); schemaVersion = response.schemaVersion; // add a new type - stream = new ContentStreamBase.FileStream(getFile("schema-designer/add-new-type.json")); - stream.setContentType(JSON_MIME); - when(mockReq.getContentStreams()).thenReturn(List.of(stream)); - // POST /schema-designer/add - response = schemaDesigner.addSchemaObject(configSet, schemaVersion); + response = + schemaDesigner.addSchemaObject( + configSet, schemaVersion, loadAddBody("schema-designer/add-new-type.json")); final String expectedTypeName = "test_txt"; assertEquals(expectedTypeName, response.fieldType); schemaVersion = response.schemaVersion; @@ -488,12 +484,10 @@ public void testBasicUserWorkflow() throws Exception { "New field type '" + expectedTypeName + "' not found in add type response!", expected.isPresent()); - stream = new ContentStreamBase.FileStream(getFile("schema-designer/update-type.json")); - stream.setContentType(JSON_MIME); - when(mockReq.getContentStreams()).thenReturn(List.of(stream)); - // POST /schema-designer/update - response = schemaDesigner.updateSchemaObject(configSet, schemaVersion); + response = + schemaDesigner.updateSchemaObject( + configSet, schemaVersion, loadUpdateBody("schema-designer/update-type.json")); schemaVersion = response.schemaVersion; // query to see how the schema decisions impact retrieval / ranking @@ -552,13 +546,10 @@ public void testFieldUpdates() throws Exception { int schemaVersion = response.schemaVersion; // add our test field that we'll test various updates to - ContentStreamBase.FileStream stream = - new ContentStreamBase.FileStream(getFile("schema-designer/add-new-field.json")); - stream.setContentType(JSON_MIME); - when(mockReq.getContentStreams()).thenReturn(List.of(stream)); - // POST /schema-designer/add - response = schemaDesigner.addSchemaObject(configSet, schemaVersion); + response = + schemaDesigner.addSchemaObject( + configSet, schemaVersion, loadAddBody("schema-designer/add-new-field.json")); assertNotNull(response.field); final String fieldName = "keywords"; @@ -669,29 +660,25 @@ public void testSchemaDiffEndpoint() throws Exception { mapParams.put("termVectors", Boolean.FALSE); schemaVersion = response.schemaVersion; - ContentStreamBase.StringStream stringStream = - new ContentStreamBase.StringStream(JSONUtil.toJSON(mapParams), JSON_MIME); - when(mockReq.getContentStreams()).thenReturn(List.of(stringStream)); - - response = schemaDesigner.updateSchemaObject(configSet, schemaVersion); + SchemaDesignerUpdateRequestBody idFieldUpdate = + SolrJacksonMapper.getObjectMapper() + .convertValue(mapParams, SchemaDesignerUpdateRequestBody.class); + response = schemaDesigner.updateSchemaObject(configSet, schemaVersion, idFieldUpdate); // Add a new field schemaVersion = response.schemaVersion; - ContentStreamBase.FileStream fileStream = - new ContentStreamBase.FileStream(getFile("schema-designer/add-new-field.json")); - fileStream.setContentType(JSON_MIME); - when(mockReq.getContentStreams()).thenReturn(List.of(fileStream)); // POST /schema-designer/add - response = schemaDesigner.addSchemaObject(configSet, schemaVersion); + response = + schemaDesigner.addSchemaObject( + configSet, schemaVersion, loadAddBody("schema-designer/add-new-field.json")); assertNotNull(response.field); // Add a new field type schemaVersion = response.schemaVersion; - fileStream = new ContentStreamBase.FileStream(getFile("schema-designer/add-new-type.json")); - fileStream.setContentType(JSON_MIME); - when(mockReq.getContentStreams()).thenReturn(List.of(fileStream)); // POST /schema-designer/add - response = schemaDesigner.addSchemaObject(configSet, schemaVersion); + response = + schemaDesigner.addSchemaObject( + configSet, schemaVersion, loadAddBody("schema-designer/add-new-type.json")); assertNotNull(response.fieldType); // Let's do a diff now @@ -792,17 +779,20 @@ public void testRequireSchemaVersionRejectsNegativeValues() throws Exception { // null schemaVersion must be rejected SolrException nullEx = - expectThrows(SolrException.class, () -> schemaDesigner.addSchemaObject(configSet, null)); + expectThrows( + SolrException.class, () -> schemaDesigner.addSchemaObject(configSet, null, null)); assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, nullEx.code()); // negative schemaVersion must be rejected (was previously bypassing validation) SolrException negEx = - expectThrows(SolrException.class, () -> schemaDesigner.addSchemaObject(configSet, -1)); + expectThrows( + SolrException.class, () -> schemaDesigner.addSchemaObject(configSet, -1, null)); assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, negEx.code()); // same contract must hold for updateSchemaObject SolrException updateNegEx = - expectThrows(SolrException.class, () -> schemaDesigner.updateSchemaObject(configSet, -1)); + expectThrows( + SolrException.class, () -> schemaDesigner.updateSchemaObject(configSet, -1, null)); assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, updateNegEx.code()); } @@ -837,4 +827,14 @@ protected void assertDesignerSettings( actual.put(COPY_FROM_PARAM, response.copyFrom); assertDesignerSettings(expected, actual); } + + private SchemaDesignerAddRequestBody loadAddBody(String fixturePath) throws IOException { + return SolrJacksonMapper.getObjectMapper() + .readValue(getFile(fixturePath).toFile(), SchemaDesignerAddRequestBody.class); + } + + private SchemaDesignerUpdateRequestBody loadUpdateBody(String fixturePath) throws IOException { + return SolrJacksonMapper.getObjectMapper() + .readValue(getFile(fixturePath).toFile(), SchemaDesignerUpdateRequestBody.class); + } } diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java new file mode 100644 index 000000000000..781b06c9a3aa --- /dev/null +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.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.designer; + +import static org.apache.solr.handler.admin.ConfigSetsHandler.DEFAULT_CONFIGSET_NAME; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.apache.solr.client.api.model.SchemaDesignerInfoResponse; +import org.apache.solr.client.api.model.SchemaDesignerResponse; +import org.apache.solr.client.solrj.request.SchemaDesignerApi; +import org.apache.solr.cloud.SolrCloudTestCase; +import org.apache.solr.util.ExternalPaths; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +/** + * Smoke tests that exercise the Schema Designer V2 API through the generated SolrJ client. Unlike + * {@link TestSchemaDesigner}, which calls the handler in-process and bypasses both the JAX-RS layer + * and JSON (de)serialization, these tests round-trip every typed request body and response over + * HTTP. They guard against regressions in the {@code @JsonProperty} / {@code @Schema} / + * {@code @JsonAnySetter} annotations and the OpenAPI-generated SolrJ wiring. This is required as + * the primary interaction mechanism to Schema Designer APIs is via the schema-designer.js JSON + * calls, not SolrJ. + */ +public class TestSchemaDesignerSolrJ extends SolrCloudTestCase { + + @BeforeClass + public static void createCluster() throws Exception { + System.setProperty("managed.schema.mutable", "true"); + configureCluster(1) + .addConfig(DEFAULT_CONFIGSET_NAME, ExternalPaths.DEFAULT_CONFIGSET) + .configure(); + } + + @AfterClass + public static void cleanup() throws Exception { + if (cluster != null && cluster.getSolrClient() != null) { + cluster.deleteAllCollections(); + cluster.deleteAllConfigSets(); + } + } + + /** + * Walks through prep → add (each of the four wrapper keys) → update → info, verifying both the + * SolrJ request wiring and the typed-response deserialization. + */ + @Test + public void testTypedBodyRoundTrip() throws Exception { + final String configSet = "solrjSmoke"; + + // POST /schema-designer/{configSet}/prep — no body, baseline that SolrJ wiring works + SchemaDesignerResponse prep = + new SchemaDesignerApi.PrepNewSchema(configSet).process(cluster.getSolrClient()); + assertEquals(configSet, prep.configSet); + int schemaVersion = prep.schemaVersion; + + // POST /add — addField — exercises kebab-case @JsonProperty("add-field") / @Schema(name=…) + var addField = new SchemaDesignerApi.AddSchemaObject(configSet); + addField.setSchemaVersion(schemaVersion); + addField.setAddField(Map.of("name", "keywords", "type", "string", "stored", true)); + SchemaDesignerResponse addFieldResp = addField.process(cluster.getSolrClient()); + assertEquals("keywords", addFieldResp.field); + schemaVersion = addFieldResp.schemaVersion; + + // POST /add — addFieldType — covers a different wrapper key + var addType = new SchemaDesignerApi.AddSchemaObject(configSet); + addType.setSchemaVersion(schemaVersion); + addType.setAddFieldType( + Map.of( + "name", + "smoke_txt", + "class", + "solr.TextField", + "analyzer", + Map.of("tokenizer", Map.of("class", "solr.StandardTokenizerFactory")))); + SchemaDesignerResponse addTypeResp = addType.process(cluster.getSolrClient()); + assertEquals("smoke_txt", addTypeResp.fieldType); + schemaVersion = addTypeResp.schemaVersion; + + // POST /add — addDynamicField + var addDyn = new SchemaDesignerApi.AddSchemaObject(configSet); + addDyn.setSchemaVersion(schemaVersion); + addDyn.setAddDynamicField(Map.of("name", "*_smoke", "type", "string")); + SchemaDesignerResponse addDynResp = addDyn.process(cluster.getSolrClient()); + assertEquals("*_smoke", addDynResp.dynamicField); + schemaVersion = addDynResp.schemaVersion; + + // POST /add — addCopyField — verifies the explicit no-op response branch in + // setSchemaObjectField (no field/type/dynamicField/fieldType is populated) + var addCopy = new SchemaDesignerApi.AddSchemaObject(configSet); + addCopy.setSchemaVersion(schemaVersion); + addCopy.setAddCopyField(Map.of("source", "keywords", "dest", "_text_")); + SchemaDesignerResponse addCopyResp = addCopy.process(cluster.getSolrClient()); + assertNull(addCopyResp.field); + assertNull(addCopyResp.fieldType); + assertNull(addCopyResp.dynamicField); + schemaVersion = addCopyResp.schemaVersion; + + // PUT /update — exercises the @JsonAnyGetter/@JsonAnySetter capture for arbitrary attrs + var update = new SchemaDesignerApi.UpdateSchemaObject(configSet); + update.setSchemaVersion(schemaVersion); + update.setName("keywords"); + update.setAdditionalProperties(Map.of("type", "string", "stored", true, "multiValued", true)); + SchemaDesignerResponse updateResp = update.process(cluster.getSolrClient()); + assertNotNull(updateResp.field); + assertEquals("field", updateResp.updateType); + + // GET /info — round-trips a typed response that extends SchemaDesignerSettingsResponse + SchemaDesignerInfoResponse info = + new SchemaDesignerApi.GetInfo(configSet).process(cluster.getSolrClient()); + assertEquals(configSet, info.configSet); + } + + /** + * Exercises the {@code InputStream} body binding on {@code updateFileContents} by sending an + * invalid {@code solrconfig.xml} and verifying the server returns the typed error fields rather + * than throwing — this also confirms the bytes actually reached the server. + */ + @Test + public void testUpdateFileContentsBodyBinding() throws Exception { + final String configSet = "solrjFileSmoke"; + + new SchemaDesignerApi.PrepNewSchema(configSet).process(cluster.getSolrClient()); + + byte[] invalidXml = "".getBytes(StandardCharsets.UTF_8); + var req = + new SchemaDesignerApi.UpdateFileContents(configSet, new ByteArrayInputStream(invalidXml)); + req.setFile("solrconfig.xml"); + SchemaDesignerResponse resp = req.process(cluster.getSolrClient()); + + assertNotNull( + "server should report a validation error for the invalid solrconfig.xml", + resp.updateFileError); + assertEquals("", resp.fileContent); + } +} From 2bad42532ebba75d1d43969aa7cba1381befc012 Mon Sep 17 00:00:00 2001 From: Utsav <39943143+utsav00@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:25:51 +0530 Subject: [PATCH 52/69] SOLR-18110: Remove deprecated attributes for telemetry (#4524) --- ...SOLR-18110-remove-deprecated-tracing-attributes.yml | 8 ++++++++ .../java/org/apache/solr/util/tracing/TraceUtils.java | 10 ---------- .../upgrade-notes/pages/major-changes-in-solr-10.adoc | 2 +- 3 files changed, 9 insertions(+), 11 deletions(-) create mode 100644 changelog/unreleased/SOLR-18110-remove-deprecated-tracing-attributes.yml diff --git a/changelog/unreleased/SOLR-18110-remove-deprecated-tracing-attributes.yml b/changelog/unreleased/SOLR-18110-remove-deprecated-tracing-attributes.yml new file mode 100644 index 000000000000..04b040d7efad --- /dev/null +++ b/changelog/unreleased/SOLR-18110-remove-deprecated-tracing-attributes.yml @@ -0,0 +1,8 @@ +# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc +title: Remove deprecated http.status_code and http.method telemetry tags from TraceUtils; use http.response.status_code and http.request.method instead. +type: removed +authors: + - name: Utsav Parmar +links: + - name: SOLR-18110 + url: https://issues.apache.org/jira/browse/SOLR-18110 diff --git a/solr/core/src/java/org/apache/solr/util/tracing/TraceUtils.java b/solr/core/src/java/org/apache/solr/util/tracing/TraceUtils.java index 9936dfbb2320..b907e35fb4a1 100644 --- a/solr/core/src/java/org/apache/solr/util/tracing/TraceUtils.java +++ b/solr/core/src/java/org/apache/solr/util/tracing/TraceUtils.java @@ -59,14 +59,6 @@ public class TraceUtils { public static final AttributeKey> TAG_OPS = AttributeKey.stringArrayKey("ops"); public static final AttributeKey TAG_CLASS = AttributeKey.stringKey("class"); - @Deprecated - private static final AttributeKey TAG_HTTP_METHOD_DEP = - AttributeKey.stringKey("http.method"); - - @Deprecated - private static final AttributeKey TAG_HTTP_STATUS_DEP = - AttributeKey.longKey("http.status_code"); - public static final String TAG_DB_TYPE_SOLR = "solr"; public static final Predicate DEFAULT_IS_RECORDING = Span::isRecording; @@ -103,7 +95,6 @@ public static void setUser(Span span, String user) { public static void setHttpStatus(Span span, int httpStatus) { span.setAttribute(TAG_HTTP_STATUS, httpStatus); - span.setAttribute(TAG_HTTP_STATUS_DEP, httpStatus); } public static void ifNotNoop(Span span, Consumer consumer) { @@ -165,7 +156,6 @@ public static Span startHttpRequestSpan(HttpServletRequest request, Context cont .setParent(context) .setSpanKind(SpanKind.SERVER) .setAttribute(TAG_HTTP_METHOD, request.getMethod()) - .setAttribute(TAG_HTTP_METHOD_DEP, request.getMethod()) .setAttribute(TAG_HTTP_URL, request.getRequestURL().toString()); if (request.getQueryString() != null) { spanBuilder.setAttribute(TAG_HTTP_PARAMS, request.getQueryString()); diff --git a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc index 627816636c50..d05081d297f4 100644 --- a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc +++ b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc @@ -224,7 +224,7 @@ Attention: ** `analytics` has been removed ** `hadoop-auth` (including the `solr.KerberosPlugin` class) has been removed -* `OpenTracing` libraries were removed and replaced with `OpenTelemetry` libraries. Any Java agents providing `OpenTracing` tracers will no longer work. Telemetry tags `http.status_code` and `http.method` have been deprecated, newer versions of the tags have been added to the span data: `http.response.status_code`, `http.request.method`. +* `OpenTracing` libraries were removed and replaced with `OpenTelemetry` libraries. Any Java agents providing `OpenTracing` tracers will no longer work. Telemetry tags `http.status_code` and `http.method` have been removed from the span data; use `http.response.status_code` and `http.request.method` instead. (SOLR-18110) * The sysProp `-Dsolr.redaction.system.pattern`, which allows users to provide a pattern to match sysProps that should be redacted for sensitive information, has been removed. Please use `-Dsolr.hiddenSysProps` or the envVar `SOLR_HIDDEN_SYS_PROPS` instead. From 86bc6f292245e0566511a9d9c3fb8aaba933e564 Mon Sep 17 00:00:00 2001 From: David Smiley Date: Thu, 18 Jun 2026 10:44:17 -0400 Subject: [PATCH 53/69] SOLR-14070: Deprecate CloudSolrClient ZkHost constructor (#4533) -- again; was accidentally un-deprecated for 10.0. Oops. --- .../client/solrj/impl/CloudSolrClient.java | 2935 ++++++++--------- 1 file changed, 1466 insertions(+), 1469 deletions(-) diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudSolrClient.java b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudSolrClient.java index 69e5c8d58095..b9b524422975 100644 --- a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudSolrClient.java +++ b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/CloudSolrClient.java @@ -92,25 +92,12 @@ */ public abstract class CloudSolrClient extends SolrClient { + public static final String STATE_VERSION = "_stateVer_"; + static final int DEFAULT_STATE_REFRESH_PARALLELISM = 5; private static final Logger log = LoggerFactory.getLogger(MethodHandles.lookup().lookupClass()); - // no of times collection state to be reloaded if stale state error is received private static final int MAX_STALE_RETRIES = Integer.parseInt(System.getProperty("solr.solrj.cloud.max.stale.retries", "5")); - static final int DEFAULT_STATE_REFRESH_PARALLELISM = 5; - private final Random rand = new Random(); - - private final boolean updatesToLeaders; - private final boolean directUpdatesToLeadersOnly; - private final RequestReplicaListTransformerGenerator requestRLTGenerator; - private final boolean parallelUpdates; - private final ExecutorService threadPool = - ExecutorUtil.newMDCAwareCachedThreadPool( - new SolrNamedThreadFactory("CloudSolrClient ThreadPool")); - - public static final String STATE_VERSION = "_stateVer_"; - protected long retryExpiryTimeNano = - TimeUnit.NANOSECONDS.convert(3, TimeUnit.SECONDS); // 3 seconds or 3 million nanos private static final Set NON_ROUTABLE_PARAMS = Set.of( UpdateParams.EXPUNGE_DELETES, @@ -125,1713 +112,1672 @@ public abstract class CloudSolrClient extends SolrClient { // Not supported via SolrCloud // UpdateParams.ROLLBACK ); - + protected final StateCache collectionStateCache = new StateCache(); + private final Random rand = new Random(); + private final boolean updatesToLeaders; + private final boolean directUpdatesToLeadersOnly; + private final RequestReplicaListTransformerGenerator requestRLTGenerator; + private final boolean parallelUpdates; + private final ExecutorService threadPool = + ExecutorUtil.newMDCAwareCachedThreadPool( + new SolrNamedThreadFactory("CloudSolrClient ThreadPool")); private final ConcurrentHashMap> collectionRefreshes = new ConcurrentHashMap<>(); private final Semaphore stateRefreshSemaphore; private final int stateRefreshParallelism; + protected long retryExpiryTimeNano = + TimeUnit.NANOSECONDS.convert(3, TimeUnit.SECONDS); // 3 seconds or 3 million nanos private volatile boolean closed; - /** - * Constructs {@link CloudSolrClient} instances from provided configuration. It will use a Jetty - * based {@code HttpClient} if available, or will otherwise use the JDK. - */ - public static class Builder { + protected CloudSolrClient( + boolean updatesToLeaders, boolean parallelUpdates, boolean directUpdatesToLeadersOnly) { + this( + updatesToLeaders, + parallelUpdates, + directUpdatesToLeadersOnly, + DEFAULT_STATE_REFRESH_PARALLELISM); + } - protected Collection zkHosts = new ArrayList<>(); - protected List solrUrls = new ArrayList<>(); - protected String zkChroot; - protected HttpSolrClient httpClient; - protected boolean shardLeadersOnly = true; - protected boolean directUpdatesToLeadersOnly = false; - protected boolean parallelUpdates = true; - protected ClusterStateProvider stateProvider; - protected HttpSolrClient.BuilderBase internalClientBuilder; - protected RequestWriter requestWriter; - protected ResponseParser responseParser; - protected long retryExpiryTimeNano = - TimeUnit.NANOSECONDS.convert(3, TimeUnit.SECONDS); // 3 seconds or 3 million nanos + protected CloudSolrClient( + boolean updatesToLeaders, + boolean parallelUpdates, + boolean directUpdatesToLeadersOnly, + int stateRefreshThreads) { + this.updatesToLeaders = updatesToLeaders; + this.parallelUpdates = parallelUpdates; + this.directUpdatesToLeadersOnly = directUpdatesToLeadersOnly; + this.requestRLTGenerator = new RequestReplicaListTransformerGenerator(); + this.stateRefreshParallelism = Math.max(1, stateRefreshThreads); + this.stateRefreshSemaphore = new Semaphore(this.stateRefreshParallelism); + } - protected String defaultCollection; - protected long timeToLiveSeconds = 60; - protected int parallelCacheRefreshesLocks = DEFAULT_STATE_REFRESH_PARALLELISM; - protected int zkConnectTimeout = SolrZkClientTimeout.DEFAULT_ZK_CONNECT_TIMEOUT; - protected int zkClientTimeout = SolrZkClientTimeout.DEFAULT_ZK_CLIENT_TIMEOUT; - protected boolean canUseZkACLs = true; + /** + * Determines whether an UpdateRequest contains sufficient routing information to identify shard + * leaders for direct updates when directUpdatesToLeadersOnly is enabled. + */ + private static boolean hasInfoToFindLeaders(UpdateRequest updateRequest, String idField) { + final Map> documents = updateRequest.getDocumentsMap(); + final Map> deleteById = updateRequest.getDeleteByIdMap(); - /** - * Provide a series of Solr URLs to be used when configuring {@link CloudSolrClient} instances. - * The solr client will use these urls to understand the cluster topology, which solr nodes are - * active etc. - * - *

Provided Solr URLs are expected to point to the root Solr path - * ("http://hostname:8983/solr"); they should not include any collections, cores, or other path - * components. - * - *

Usage example: - * - *

-     *   final List<String> solrBaseUrls = new ArrayList<String>();
-     *   solrBaseUrls.add("http://solr1:8983/solr"); solrBaseUrls.add("http://solr2:8983/solr"); solrBaseUrls.add("http://solr3:8983/solr");
-     *   final SolrClient client = new CloudSolrClient.Builder(solrBaseUrls).build();
-     * 
- */ - public Builder(List solrUrls) { - this.solrUrls = solrUrls; + final boolean hasNoDocuments = (documents == null || documents.isEmpty()); + final boolean hasNoDeleteById = (deleteById == null || deleteById.isEmpty()); + if (hasNoDocuments && hasNoDeleteById) { + // no documents and no delete-by-id, so no info to find leader(s) + return false; } - /** - * Provide a series of ZK hosts which will be used when configuring {@link CloudSolrClient} - * instances. - * - *

Usage example when Solr stores data at the ZooKeeper root ('/'): - * - *

-     *   final List<String> zkServers = new ArrayList<String>();
-     *   zkServers.add("zookeeper1:2181"); zkServers.add("zookeeper2:2181"); zkServers.add("zookeeper3:2181");
-     *   final SolrClient client = new CloudSolrClient.Builder(zkServers, Optional.empty()).build();
-     * 
- * - * Usage example when Solr data is stored in a ZooKeeper chroot: - * - *
-     *    final List<String> zkServers = new ArrayList<String>();
-     *    zkServers.add("zookeeper1:2181"); zkServers.add("zookeeper2:2181"); zkServers.add("zookeeper3:2181");
-     *    final SolrClient client = new CloudSolrClient.Builder(zkServers, Optional.of("/solr")).build();
-     *  
- * - * @param zkHosts a List of at least one ZooKeeper host and port (e.g. "zookeeper1:2181") - * @param zkChroot the path to the root ZooKeeper node containing Solr data. Provide {@code - * java.util.Optional.empty()} if no ZK chroot is used. - */ - public Builder(List zkHosts, Optional zkChroot) { - this.zkHosts = zkHosts; - if (zkChroot.isPresent()) this.zkChroot = zkChroot.get(); + if (documents != null) { + for (final Map.Entry> entry : documents.entrySet()) { + final SolrInputDocument doc = entry.getKey(); + final Object fieldValue = doc.getFieldValue(idField); + if (fieldValue == null) { + // a document with no id field value, so can't find leader for it + return false; + } + } } - /** for an expert use-case */ - public Builder(ClusterStateProvider stateProvider) { - this.stateProvider = stateProvider; + if (deleteById != null) { + for (final Map.Entry> entry : deleteById.entrySet()) { + final Map params = entry.getValue(); + if (params == null || params.get(ShardParams._ROUTE_) == null) { + // deleteById entry lacks explicit route parameter, can't find leader for it + return false; + } + } } - /** - * Creates a client builder based on a connection string of 2 possible formats: - * - *
    - *
  • ZooKeeper connection string (optionally with chroot), e.g. {@code - * zk1:2181,zk2:2181,zk3:2181/solr} - *
  • Comma-separated list of Solr node base URLs (HTTP or HTTPS), e.g. {@code - * http://solr1:8983/solr,http://solr2:8983/solr} - *
- * - * @param connectionString a string specifying either ZooKeeper connection string or HTTP(S) - * Solr URLs - * @throws IllegalArgumentException if string is null, empty, or malformed - */ - public Builder(String connectionString) { - this(CloudSolrClientConnection.parse(connectionString)); - } + return true; + } - /** - * Creates a client builder from a {@link CloudSolrClientConnection}. - * - * @param connection instance of {@link CloudSolrClientConnection}, which can be obtained from - * the solr connection string or created via the constructor - */ - public Builder(CloudSolrClientConnection connection) { - if (connection.isZookeeper()) { - this.zkHosts = connection.quorumItems(); - this.zkChroot = connection.zkChroot(); - } else { - this.solrUrls = connection.quorumItems(); - } - } + protected abstract LBSolrClient getLbClient(); - /** Whether to use the default ZK ACLs when building a ZK Client. */ - public Builder canUseZkACLs(boolean canUseZkACLs) { - this.canUseZkACLs = canUseZkACLs; - return this; - } + public abstract ClusterStateProvider getClusterStateProvider(); - /** - * Tells {@link Builder} that created clients should be configured such that {@link - * CloudSolrClient#isUpdatesToLeaders} returns true. - * - * @see #sendUpdatesToAnyReplica - * @see CloudSolrClient#isUpdatesToLeaders - */ - public Builder sendUpdatesOnlyToShardLeaders() { - shardLeadersOnly = true; - return this; - } + /** + * @deprecated problematic as a 'get' method, since one implementation will do a remote request + * each time this is called, potentially return lots of data that isn't even needed. + */ + @Deprecated + public ClusterState getClusterState() { + // The future of "ClusterState" isn't clear. Could make it more of a cache instead of a + // snapshot, so we un-deprecate. Or we avoid it and maybe make the ClusterStateProvider as that + // cache. SOLR-17604 is related. + return getClusterStateProvider().getClusterState(); + } - /** - * Tells {@link Builder} that created clients should be configured such that {@link - * CloudSolrClient#isUpdatesToLeaders} returns false. - * - * @see #sendUpdatesOnlyToShardLeaders - * @see CloudSolrClient#isUpdatesToLeaders - */ - public Builder sendUpdatesToAnyReplica() { - shardLeadersOnly = false; - return this; - } + /** Is this a communication error? We will retry if so. */ + protected boolean wasCommError(Throwable t) { + return t instanceof SocketException || t instanceof UnknownHostException; + } - /** - * Tells {@link CloudSolrClient.Builder} that created clients should send direct updates to - * shard leaders only. - * - *

UpdateRequests whose leaders cannot be found will "fail fast" on the client side with a - * {@link SolrException} - * - * @see #sendDirectUpdatesToAnyShardReplica - * @see CloudSolrClient#isDirectUpdatesToLeadersOnly - */ - public Builder sendDirectUpdatesToShardLeadersOnly() { - directUpdatesToLeadersOnly = true; - return this; + @Override + public void close() { + closed = true; + collectionRefreshes.clear(); + if (!ExecutorUtil.isShutdown(this.threadPool)) { + ExecutorUtil.shutdownAndAwaitTermination(this.threadPool); } + } - /** - * Tells {@link CloudSolrClient.Builder} that created clients can send updates to any shard - * replica (shard leaders and non-leaders). - * - *

Shard leaders are still preferred, but the created clients will fall back to using other - * replicas if a leader cannot be found. - * - * @see #sendDirectUpdatesToShardLeadersOnly - * @see CloudSolrClient#isDirectUpdatesToLeadersOnly - */ - public Builder sendDirectUpdatesToAnyShardReplica() { - directUpdatesToLeadersOnly = false; - return this; - } - - /** Provides a {@link RequestWriter} for created clients to use when handing requests. */ - public Builder withRequestWriter(RequestWriter requestWriter) { - this.requestWriter = requestWriter; - return this; - } - - /** Provides a {@link ResponseParser} for created clients to use when handling requests. */ - public Builder withResponseParser(ResponseParser responseParser) { - this.responseParser = responseParser; - return this; - } - - /** - * Tells {@link CloudSolrClient.Builder} whether created clients should send shard updates - * serially or in parallel - * - *

When an {@link UpdateRequest} affects multiple shards, {@link CloudSolrClient} splits it - * up and sends a request to each affected shard. This setting chooses whether those - * sub-requests are sent serially or in parallel. - * - *

If not set, this defaults to 'true' and sends sub-requests in parallel. - */ - public Builder withParallelUpdates(boolean parallelUpdates) { - this.parallelUpdates = parallelUpdates; - return this; - } + public ResponseParser getParser() { + return getLbClient().getParser(); + } - /** - * Configures how many collection state refresh operations may run in parallel using a dedicated - * thread pool. This controls the maximum number of concurrent ZooKeeper/cluster state lookups. - * - *

Defaults to 5. - */ - public Builder withParallelCacheRefreshes(int parallelCacheRefreshesLocks) { - this.parallelCacheRefreshesLocks = parallelCacheRefreshesLocks; - return this; - } + public RequestWriter getRequestWriter() { + return getLbClient().getRequestWriter(); + } - /** - * This is the time to wait to re-fetch the state after getting the same state version from ZK - */ - public Builder withRetryExpiryTime(long expiryTime, TimeUnit unit) { - this.retryExpiryTimeNano = TimeUnit.NANOSECONDS.convert(expiryTime, unit); - return this; - } + /** Gets whether direct updates are sent in parallel */ + public boolean isParallelUpdates() { + return parallelUpdates; + } - /** Sets the default collection for request. */ - public Builder withDefaultCollection(String defaultCollection) { - this.defaultCollection = defaultCollection; - return this; - } + /** + * Connect to the zookeeper ensemble. This is an optional method that may be used to force a + * connection before any other requests are sent. + * + * @deprecated Call {@link ClusterStateProvider#getLiveNodes()} instead. + */ + @Deprecated + public void connect() { + getClusterStateProvider().connect(); + } - /** - * Sets the cache ttl for DocCollection Objects cached. - * - * @param timeToLive ttl value - */ - public Builder withCollectionCacheTtl(long timeToLive, TimeUnit unit) { - assert timeToLive > 0; - this.timeToLiveSeconds = TimeUnit.SECONDS.convert(timeToLive, unit); - return this; + /** + * Connect to a cluster. If the cluster is not ready, retry connection up to a given timeout. + * + * @param duration the timeout + * @param timeUnit the units of the timeout + * @throws TimeoutException if the cluster is not ready after the timeout + * @throws InterruptedException if the wait is interrupted + */ + @Deprecated + public void connect(long duration, TimeUnit timeUnit) + throws TimeoutException, InterruptedException { + if (log.isInfoEnabled()) { + log.info( + "Waiting for {} {} for cluster at {} to be ready", + duration, + timeUnit, + getClusterStateProvider()); } - - /** - * Set the internal Solr HTTP client. - * - *

Note: closing the client instance is the responsibility of the caller. - * - * @return this - */ - public Builder withHttpClient(HttpSolrClient httpSolrClient) { - if (this.internalClientBuilder != null) { - throw new IllegalStateException( - "The builder can't accept an httpClient AND an internalClientBuilder, only one of those can be provided"); + long timeout = System.nanoTime() + timeUnit.toNanos(duration); + while (System.nanoTime() < timeout) { + try { + connect(); + if (log.isInfoEnabled()) { + log.info("Cluster at {} ready", getClusterStateProvider()); + } + return; + } catch (RuntimeException e) { + // not ready yet, then... } - this.httpClient = httpSolrClient; - return this; + TimeUnit.MILLISECONDS.sleep(250); } + throw new TimeoutException("Timed out waiting for cluster"); + } - /** - * If provided, the CloudSolrClient will build it's internal client using this builder (instead - * of the empty default one). Providing this builder allows users to configure the internal - * clients (authentication, timeouts, etc.). - * - * @param internalClientBuilder the builder to use for creating the internal http client. - * @return this - */ - public Builder withHttpClientBuilder(HttpSolrClient.BuilderBase internalClientBuilder) { - if (this.httpClient != null) { - throw new IllegalStateException( - "The builder can't accept an httpClient AND an internalClientBuilder, only one of those can be provided"); - } - this.internalClientBuilder = internalClientBuilder; - return this; - } + @SuppressWarnings({"unchecked"}) + private NamedList directUpdate(UpdateRequest request, String collection) + throws SolrServerException { + SolrParams params = request.getParams(); + ModifiableSolrParams routableParams = new ModifiableSolrParams(); + ModifiableSolrParams nonRoutableParams = new ModifiableSolrParams(); - @Deprecated(since = "9.10") - public Builder withInternalClientBuilder( - HttpSolrClient.BuilderBase internalClientBuilder) { - return withHttpClientBuilder(internalClientBuilder); + if (params != null) { + nonRoutableParams.add(params); + routableParams.add(params); + for (String param : NON_ROUTABLE_PARAMS) { + routableParams.remove(param); + } + } else { + params = new ModifiableSolrParams(); } - /** - * Sets the Zk connection timeout - * - * @param zkConnectTimeout timeout value - * @param unit time unit - */ - public Builder withZkConnectTimeout(int zkConnectTimeout, TimeUnit unit) { - this.zkConnectTimeout = Math.toIntExact(unit.toMillis(zkConnectTimeout)); - return this; + if (collection == null) { + throw new SolrServerException( + "No collection param specified on request and no default collection has been set."); } - /** - * Sets the Zk client session timeout - * - * @param zkClientTimeout timeout value - * @param unit time unit - */ - public Builder withZkClientTimeout(int zkClientTimeout, TimeUnit unit) { - this.zkClientTimeout = Math.toIntExact(unit.toMillis(zkClientTimeout)); - return this; + // Check to see if the collection is an alias. Updates to multi-collection aliases are ok as + // long as they are routed aliases + List aliasedCollections = new ArrayList<>(resolveAliases(List.of(collection))); + if (aliasedCollections.size() == 1 || getClusterStateProvider().isRoutedAlias(collection)) { + collection = aliasedCollections.get(0); // pick 1st (consistent with HttpSolrCall behavior) + } else { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Update request to non-routed multi-collection alias not supported: " + + collection + + " -> " + + aliasedCollections); } - /** Create a {@link CloudSolrClient} based on the provided configuration. */ - public CloudHttp2SolrClient build() { - int providedOptions = 0; - if (!zkHosts.isEmpty()) providedOptions++; - if (!solrUrls.isEmpty()) providedOptions++; - if (stateProvider != null) providedOptions++; + DocCollection col = getDocCollection(collection, null); - if (providedOptions > 1) { - throw new IllegalArgumentException( - "Only one of zkHost(s), solrUrl(s), or stateProvider should be specified."); - } else if (providedOptions == 0) { - throw new IllegalArgumentException( - "One of zkHosts, solrUrls, or stateProvider must be specified."); - } + DocRouter router = col.getRouter(); - return new CloudHttp2SolrClient(this); + if (router instanceof ImplicitDocRouter) { + // short circuit as optimization + return null; } - protected HttpSolrClient createOrGetHttpClient() { - if (httpClient != null) { - return httpClient; - } else if (internalClientBuilder != null) { - return internalClientBuilder.build(); + ReplicaListTransformer replicaListTransformer = + requestRLTGenerator.getReplicaListTransformer(params); + + // Create the URL map, which is keyed on slice name. + // The value is a list of URLs for each replica in the slice. + // The first value in the list is the leader for the slice. + final Map> urlMap = buildUrlMap(col, replicaListTransformer); + String routeField = + (col.getRouter().getRouteField(col) == null) ? ID : col.getRouter().getRouteField(col); + final Map routes = + createRoutes(request, routableParams, col, router, urlMap, routeField); + if (routes == null) { + if (directUpdatesToLeadersOnly && hasInfoToFindLeaders(request, routeField)) { + // we have info (documents with ids and/or ids to delete) with + // which to find the leaders, but we could not find (all of) them + throw new SolrException( + SolrException.ErrorCode.SERVICE_UNAVAILABLE, + "directUpdatesToLeadersOnly==true but could not find leader(s)"); } else { - return HttpSolrClient.builder(null).build(); + // we could not find a leader or routes yet - use unoptimized general path + log.warn( + "No routing info found for update to collection '{}', broadcasting to all shards.", + collection); + return null; } } - protected LBSolrClient createOrGetLbClient(HttpSolrClient myClient) { - return myClient.createLBSolrClient(); - } + final NamedList exceptions = new NamedList<>(); + final NamedList> shardResponses = + new NamedList<>(routes.size() + 1); // +1 for deleteQuery - protected ClusterStateProvider createZkClusterStateProvider() { - ClusterStateProvider stateProvider = - ClusterStateProvider.newZkClusterStateProvider(zkHosts, zkChroot, canUseZkACLs); - if (stateProvider instanceof SolrZkClientTimeout.SolrZkClientTimeoutAware timeoutAware) { - timeoutAware.setZkClientTimeout(zkClientTimeout); - timeoutAware.setZkConnectTimeout(zkConnectTimeout); - } - return stateProvider; - } + long start = System.nanoTime(); - protected ClusterStateProvider createHttpClusterStateProvider(HttpSolrClient httpClient) { - try { - return new HttpClusterStateProvider<>(solrUrls, httpClient); - } catch (Exception e) { - throw new RuntimeException( - "Couldn't initialize a HttpClusterStateProvider (is/are the " - + "Solr server(s), " - + solrUrls - + ", down?)", - e); + if (parallelUpdates) { + final Map>> responseFutures = + CollectionUtil.newHashMap(routes.size()); + for (final Map.Entry entry : routes.entrySet()) { + final String url = entry.getKey(); + final LBSolrClient.Req lbRequest = entry.getValue(); + try { + MDC.put("CloudSolrClient.url", url); + responseFutures.put( + url, + threadPool.submit( + () -> { + return getLbClient().request(lbRequest).getResponse(); + })); + } finally { + MDC.remove("CloudSolrClient.url"); + } } - } - } - protected static class StateCache extends ConcurrentHashMap { - final AtomicLong puts = new AtomicLong(); - final AtomicLong hits = new AtomicLong(); - final Lock evictLock = new ReentrantLock(true); - public volatile long timeToLiveMs = 60 * 1000L; + for (final Map.Entry>> entry : responseFutures.entrySet()) { + final String url = entry.getKey(); + final Future> responseFuture = entry.getValue(); + try { + shardResponses.add(url, responseFuture.get()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } catch (ExecutionException e) { + exceptions.add(url, e.getCause()); + } + } - @Override - public ExpiringCachedDocCollection get(Object key) { - ExpiringCachedDocCollection val = super.get(key); - if (val == null) { - // a new collection is likely to be added now. - // check if there are stale items and remove them - evictStale(); - return null; + if (exceptions.size() > 0) { + Throwable firstException = exceptions.getVal(0); + if (firstException instanceof SolrException e) { + throw getRouteException( + SolrException.ErrorCode.getErrorCode(e.code()), exceptions, routes); + } else { + throw getRouteException(SolrException.ErrorCode.SERVER_ERROR, exceptions, routes); + } } - if (val.isExpired(timeToLiveMs)) { - super.remove(key); - return null; + } else { + for (Map.Entry entry : routes.entrySet()) { + String url = entry.getKey(); + LBSolrClient.Req lbRequest = entry.getValue(); + try { + NamedList rsp = getLbClient().request(lbRequest).getResponse(); + shardResponses.add(url, rsp); + } catch (Exception e) { + if (e instanceof SolrException) { + throw (SolrException) e; + } else { + throw new SolrServerException(e); + } + } } - hits.incrementAndGet(); - return val; } - ExpiringCachedDocCollection peek(Object key) { - return super.get(key); + UpdateRequest nonRoutableRequest = null; + List deleteQuery = request.getDeleteQuery(); + if (deleteQuery != null && deleteQuery.size() > 0) { + UpdateRequest deleteQueryRequest = new UpdateRequest(); + deleteQueryRequest.setDeleteQuery(deleteQuery); + nonRoutableRequest = deleteQueryRequest; } - @Override - public ExpiringCachedDocCollection put(String key, ExpiringCachedDocCollection value) { - puts.incrementAndGet(); - return super.put(key, value); - } + Set paramNames = nonRoutableParams.getParameterNames(); - void evictStale() { - if (!evictLock.tryLock()) return; + Set intersection = new HashSet<>(paramNames); + intersection.retainAll(NON_ROUTABLE_PARAMS); + + if (nonRoutableRequest != null || intersection.size() > 0) { + if (nonRoutableRequest == null) { + nonRoutableRequest = new UpdateRequest(); + } + nonRoutableRequest.setParams(nonRoutableParams); + nonRoutableRequest.setBasicAuthCredentials( + request.getBasicAuthUser(), request.getBasicAuthPassword()); + final var endpoints = + routes.keySet().stream() + .map(url -> LBSolrClient.Endpoint.from(url)) + .collect(Collectors.toList()); + Collections.shuffle(endpoints, rand); + LBSolrClient.Req req = new LBSolrClient.Req(nonRoutableRequest, endpoints); try { - for (Entry e : entrySet()) { - if (e.getValue().isExpired(timeToLiveMs)) { - super.remove(e.getKey()); - } - } - } finally { - evictLock.unlock(); + LBSolrClient.Rsp rsp = getLbClient().request(req); + shardResponses.add(endpoints.get(0).toString(), rsp.getResponse()); + } catch (Exception e) { + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, endpoints.get(0).toString(), e); } } - } - protected final StateCache collectionStateCache = new StateCache(); + long end = System.nanoTime(); - class ExpiringCachedDocCollection { - final DocCollection cached; - final long cachedAtNano; - // This is the time at which the collection is retried and got the same old version - volatile long retriedAtNano = -1; - // flag that suggests that this is potentially to be rechecked - volatile boolean maybeStale = false; + @SuppressWarnings({"rawtypes"}) + RouteResponse rr = + condenseResponse( + shardResponses, (int) TimeUnit.MILLISECONDS.convert(end - start, TimeUnit.NANOSECONDS)); + rr.setRouteResponses(shardResponses); + rr.setRoutes(routes); + return rr; + } - ExpiringCachedDocCollection(DocCollection cached) { - this.cached = cached; - this.cachedAtNano = System.nanoTime(); - } + protected RouteException getRouteException( + SolrException.ErrorCode serverError, + NamedList exceptions, + Map routes) { + return new RouteException(serverError, exceptions, routes); + } - boolean isExpired(long timeToLiveMs) { - return (System.nanoTime() - cachedAtNano) - > TimeUnit.NANOSECONDS.convert(timeToLiveMs, TimeUnit.MILLISECONDS); - } + protected Map createRoutes( + UpdateRequest updateRequest, + ModifiableSolrParams routableParams, + DocCollection col, + DocRouter router, + Map> urlMap, + String routeField) { + return urlMap == null + ? null + : updateRequest.getRoutesToCollection(router, col, urlMap, routableParams, routeField); + } - boolean shouldRetry() { - if (maybeStale) { // we are not sure if it is stale so check with retry time - if ((retriedAtNano == -1 || (System.nanoTime() - retriedAtNano) > retryExpiryTimeNano)) { - return true; // we retried a while back. and we could not get anything new. - // it's likely that it is not going to be available now also. + private Map> buildUrlMap( + DocCollection col, ReplicaListTransformer replicaListTransformer) { + Map> urlMap = new HashMap<>(); + Collection slices = col.getActiveSlices(); + Set liveNodes = getClusterStateProvider().getLiveNodes(); + for (Slice slice : slices) { + String name = slice.getName(); + List sortedReplicas = new ArrayList<>(); + Replica leader = slice.getLeader(); + if (directUpdatesToLeadersOnly && leader == null) { + for (Replica replica : + slice.getReplicas( + replica -> replica.isActive(liveNodes) && replica.getType() == Replica.Type.NRT)) { + leader = replica; + break; } } - return false; - } - - void setRetriedAt() { - retriedAtNano = System.nanoTime(); - } - - /** - * Marks this entry as {@code maybeStale} if the provided backoff window has elapsed since the - * last retry. - * - * @return {@code true} if the entry was flagged as maybe stale - */ - boolean markMaybeStaleIfOutsideBackoff(long retryBackoffNano) { - if (maybeStale) { - return true; + if (leader == null) { + if (directUpdatesToLeadersOnly) { + continue; + } + // take unoptimized general path - we cannot find a leader yet + return null; } - long lastRetry = retriedAtNano; - if (lastRetry != -1 && (System.nanoTime() - lastRetry) <= retryBackoffNano) { - return false; + + if (!directUpdatesToLeadersOnly) { + for (Replica replica : slice.getReplicas()) { + if (!replica.equals(leader)) { + sortedReplicas.add(replica); + } + } } - maybeStale = true; - return true; - } - } - protected CloudSolrClient( - boolean updatesToLeaders, boolean parallelUpdates, boolean directUpdatesToLeadersOnly) { - this( - updatesToLeaders, - parallelUpdates, - directUpdatesToLeadersOnly, - DEFAULT_STATE_REFRESH_PARALLELISM); - } + // Sort the non-leader replicas according to the request parameters + replicaListTransformer.transform(sortedReplicas); - protected CloudSolrClient( - boolean updatesToLeaders, - boolean parallelUpdates, - boolean directUpdatesToLeadersOnly, - int stateRefreshThreads) { - this.updatesToLeaders = updatesToLeaders; - this.parallelUpdates = parallelUpdates; - this.directUpdatesToLeadersOnly = directUpdatesToLeadersOnly; - this.requestRLTGenerator = new RequestReplicaListTransformerGenerator(); - this.stateRefreshParallelism = Math.max(1, stateRefreshThreads); - this.stateRefreshSemaphore = new Semaphore(this.stateRefreshParallelism); + // put the leaderUrl first. + sortedReplicas.add(0, leader); + + urlMap.put( + name, sortedReplicas.stream().map(Replica::getCoreUrl).collect(Collectors.toList())); + } + return urlMap; } - protected abstract LBSolrClient getLbClient(); + protected > T condenseResponse( + NamedList response, int timeMillis, Supplier supplier) { + T condensed = supplier.get(); + int status = 0; + Integer rf = null; - public abstract ClusterStateProvider getClusterStateProvider(); + // TolerantUpdateProcessor + List> toleratedErrors = null; + int maxToleratedErrors = Integer.MAX_VALUE; - /** - * @deprecated problematic as a 'get' method, since one implementation will do a remote request - * each time this is called, potentially return lots of data that isn't even needed. - */ - @Deprecated - public ClusterState getClusterState() { - // The future of "ClusterState" isn't clear. Could make it more of a cache instead of a - // snapshot, so we un-deprecate. Or we avoid it and maybe make the ClusterStateProvider as that - // cache. SOLR-17604 is related. - return getClusterStateProvider().getClusterState(); - } + // For "adds", "deletes", "deleteByQuery" etc. + Map> versions = new HashMap<>(); - /** Is this a communication error? We will retry if so. */ - protected boolean wasCommError(Throwable t) { - return t instanceof SocketException || t instanceof UnknownHostException; - } + for (int i = 0; i < response.size(); i++) { + NamedList shardResponse = (NamedList) response.getVal(i); + NamedList header = (NamedList) shardResponse.get("responseHeader"); + Integer shardStatus = (Integer) header.get("status"); + int s = shardStatus.intValue(); + if (s > 0) { + status = s; + } + Object rfObj = header.get(UpdateRequest.REPFACT); + if (rfObj != null && rfObj instanceof Integer routeRf) { + if (rf == null || routeRf < rf) rf = routeRf; + } - @Override - public void close() { - closed = true; - collectionRefreshes.clear(); - if (!ExecutorUtil.isShutdown(this.threadPool)) { - ExecutorUtil.shutdownAndAwaitTermination(this.threadPool); + @SuppressWarnings("unchecked") + List> shardTolerantErrors = + (List>) header.get("errors"); + if (null != shardTolerantErrors) { + Integer shardMaxToleratedErrors = (Integer) header.get("maxErrors"); + assert null != shardMaxToleratedErrors + : "TolerantUpdateProcessor reported errors but not maxErrors"; + // if we get into some weird state where the nodes disagree about the effective maxErrors, + // assume the min value seen to decide if we should fail. + maxToleratedErrors = + Math.min( + maxToleratedErrors, + ToleratedUpdateError.getEffectiveMaxErrors(shardMaxToleratedErrors.intValue())); + + if (null == toleratedErrors) { + toleratedErrors = new ArrayList>(shardTolerantErrors.size()); + } + for (SimpleOrderedMap err : shardTolerantErrors) { + toleratedErrors.add(err); + } + } + for (String updateType : Arrays.asList("adds", "deletes", "deleteByQuery")) { + Object obj = shardResponse.get(updateType); + if (obj instanceof NamedList nl) { + NamedList versionsList = + versions.containsKey(updateType) ? versions.get(updateType) : new NamedList<>(); + versionsList.addAll(nl); + versions.put(updateType, versionsList); + } + } } - } - public ResponseParser getParser() { - return getLbClient().getParser(); - } + NamedList cheader = new NamedList<>(); + cheader.add("status", status); + cheader.add("QTime", timeMillis); + if (rf != null) cheader.add(UpdateRequest.REPFACT, rf); + if (null != toleratedErrors) { + cheader.add("maxErrors", ToleratedUpdateError.getUserFriendlyMaxErrors(maxToleratedErrors)); + cheader.add("errors", toleratedErrors); + if (maxToleratedErrors < toleratedErrors.size()) { + // cumulative errors are too high, we need to throw a client exception w/correct metadata - public RequestWriter getRequestWriter() { - return getLbClient().getRequestWriter(); + // NOTE: it shouldn't be possible for 1 == toleratedErrors.size(), because if that were the + // case then at least one shard should have thrown a real error before this, so we don't + // worry about having a more "singular" exception msg for that situation + StringBuilder msgBuf = + new StringBuilder() + .append(toleratedErrors.size()) + .append(" Async failures during distributed update: "); + + NamedList metadata = new NamedList<>(); + for (SimpleOrderedMap err : toleratedErrors) { + ToleratedUpdateError te = ToleratedUpdateError.parseMap(err); + metadata.add(te.getMetadataKey(), te.getMetadataValue()); + + msgBuf.append("\n").append(te.getMessage()); + } + + SolrException toThrow = + new SolrException(SolrException.ErrorCode.BAD_REQUEST, msgBuf.toString()); + toThrow.setMetadata(metadata); + throw toThrow; + } + } + for (Map.Entry> entry : versions.entrySet()) { + condensed.add(entry.getKey(), entry.getValue()); + } + condensed.add("responseHeader", cheader); + return condensed; } - /** Gets whether direct updates are sent in parallel */ - public boolean isParallelUpdates() { - return parallelUpdates; + @SuppressWarnings({"rawtypes"}) + public RouteResponse condenseResponse(NamedList response, int timeMillis) { + return condenseResponse(response, timeMillis, RouteResponse::new); } - /** - * Connect to the zookeeper ensemble. This is an optional method that may be used to force a - * connection before any other requests are sent. - * - * @deprecated Call {@link ClusterStateProvider#getLiveNodes()} instead. - */ - @Deprecated - public void connect() { - getClusterStateProvider().connect(); + @Override + public NamedList request(SolrRequest request, String collection) + throws SolrServerException, IOException { + // the collection parameter of the request overrides that of the parameter to this method + String requestCollection = request.getCollection(); + if (requestCollection != null) { + collection = requestCollection; + } else if (collection == null) { + collection = defaultCollection; + } + + List inputCollections = + collection == null ? List.of() : StrUtils.splitSmart(collection, ",", true); + return requestWithRetryOnStaleState( + request, + 0, + inputCollections, + /*skipStateVersion*/ false, + Map.of(), + /*waitedForRefresh*/ false); } /** - * Connect to a cluster. If the cluster is not ready, retry connection up to a given timeout. - * - * @param duration the timeout - * @param timeUnit the units of the timeout - * @throws TimeoutException if the cluster is not ready after the timeout - * @throws InterruptedException if the wait is interrupted + * As this class doesn't watch external collections on the client side, there's a chance that the + * request will fail due to cached stale state, which means the state must be refreshed from ZK + * and retried. */ - @Deprecated - public void connect(long duration, TimeUnit timeUnit) - throws TimeoutException, InterruptedException { - if (log.isInfoEnabled()) { - log.info( - "Waiting for {} {} for cluster at {} to be ready", - duration, - timeUnit, - getClusterStateProvider()); + protected NamedList requestWithRetryOnStaleState( + SolrRequest request, + int retryCount, + List inputCollections, + boolean skipStateVersion, + Map> pendingRefreshes, + boolean waitedForRefresh) + throws SolrServerException, IOException { + // build up a _stateVer_ param to pass to the server containing all the + // external collection state versions involved in this request, which allows + // the server to notify us that our cached state for one or more of the external + // collections is stale and needs to be refreshed ... this code has no impact on internal + // collections + String stateVerParam = null; + List requestedCollections = null; + boolean isCollectionRequestOfV2 = false; + if (request instanceof V2Request) { + isCollectionRequestOfV2 = ((V2Request) request).isPerCollectionRequest(); } - long timeout = System.nanoTime() + timeUnit.toNanos(duration); - while (System.nanoTime() < timeout) { - try { - connect(); - if (log.isInfoEnabled()) { - log.info("Cluster at {} ready", getClusterStateProvider()); + boolean isAdmin = + request.getRequestType() == SolrRequestType.ADMIN && !request.requiresCollection(); + if (!inputCollections.isEmpty() + && !isAdmin + && !isCollectionRequestOfV2) { // don't do _stateVer_ checking for admin, v2 api requests + Set requestedCollectionNames = resolveAliases(inputCollections); + + StringBuilder stateVerParamBuilder = null; + for (String requestedCollection : requestedCollectionNames) { + // track the version of state we're using on the client side using the _stateVer_ param + DocCollection coll = getDocCollection(requestedCollection, null); + if (coll == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Collection not found: " + requestedCollection); } - return; - } catch (RuntimeException e) { - // not ready yet, then... - } - TimeUnit.MILLISECONDS.sleep(250); - } - throw new TimeoutException("Timed out waiting for cluster"); - } + int collVer = coll.getZNodeVersion(); + if (requestedCollections == null) + requestedCollections = new ArrayList<>(requestedCollectionNames.size()); + requestedCollections.add(coll); - @SuppressWarnings({"unchecked"}) - private NamedList directUpdate(UpdateRequest request, String collection) - throws SolrServerException { - SolrParams params = request.getParams(); - ModifiableSolrParams routableParams = new ModifiableSolrParams(); - ModifiableSolrParams nonRoutableParams = new ModifiableSolrParams(); + if (stateVerParamBuilder == null) { + stateVerParamBuilder = new StringBuilder(); + } else { + stateVerParamBuilder.append( + "|"); // hopefully pipe is not an allowed char in a collection name + } - if (params != null) { - nonRoutableParams.add(params); - routableParams.add(params); - for (String param : NON_ROUTABLE_PARAMS) { - routableParams.remove(param); + stateVerParamBuilder.append(coll.getName()).append(":").append(collVer); } - } else { - params = new ModifiableSolrParams(); - } - if (collection == null) { - throw new SolrServerException( - "No collection param specified on request and no default collection has been set."); + if (stateVerParamBuilder != null) { + stateVerParam = stateVerParamBuilder.toString(); + } } - // Check to see if the collection is an alias. Updates to multi-collection aliases are ok as - // long as they are routed aliases - List aliasedCollections = new ArrayList<>(resolveAliases(List.of(collection))); - if (aliasedCollections.size() == 1 || getClusterStateProvider().isRoutedAlias(collection)) { - collection = aliasedCollections.get(0); // pick 1st (consistent with HttpSolrCall behavior) - } else { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, - "Update request to non-routed multi-collection alias not supported: " - + collection - + " -> " - + aliasedCollections); - } - - DocCollection col = getDocCollection(collection, null); - - DocRouter router = col.getRouter(); - - if (router instanceof ImplicitDocRouter) { - // short circuit as optimization - return null; - } - - ReplicaListTransformer replicaListTransformer = - requestRLTGenerator.getReplicaListTransformer(params); - - // Create the URL map, which is keyed on slice name. - // The value is a list of URLs for each replica in the slice. - // The first value in the list is the leader for the slice. - final Map> urlMap = buildUrlMap(col, replicaListTransformer); - String routeField = - (col.getRouter().getRouteField(col) == null) ? ID : col.getRouter().getRouteField(col); - final Map routes = - createRoutes(request, routableParams, col, router, urlMap, routeField); - if (routes == null) { - if (directUpdatesToLeadersOnly && hasInfoToFindLeaders(request, routeField)) { - // we have info (documents with ids and/or ids to delete) with - // which to find the leaders, but we could not find (all of) them - throw new SolrException( - SolrException.ErrorCode.SERVICE_UNAVAILABLE, - "directUpdatesToLeadersOnly==true but could not find leader(s)"); + if (request.getParams() instanceof ModifiableSolrParams params) { + if (!skipStateVersion && stateVerParam != null) { + params.set(STATE_VERSION, stateVerParam); } else { - // we could not find a leader or routes yet - use unoptimized general path - log.warn( - "No routing info found for update to collection '{}', broadcasting to all shards.", - collection); - return null; + params.remove(STATE_VERSION); } - } - - final NamedList exceptions = new NamedList<>(); - final NamedList> shardResponses = - new NamedList<>(routes.size() + 1); // +1 for deleteQuery + } // else: ??? how to set this ??? - long start = System.nanoTime(); + NamedList resp = null; + try { + resp = sendRequest(request, inputCollections); + // to avoid an O(n) operation we always add STATE_VERSION to the last and try to read it from + // there + Object o = resp == null || resp.size() == 0 ? null : resp.get(STATE_VERSION, resp.size() - 1); + if (o != null && o instanceof Map invalidStates) { + // remove this because no one else needs this and tests would fail if they are comparing + // responses + resp.remove(resp.size() - 1); + for (Map.Entry e : invalidStates.entrySet()) { + getDocCollection((String) e.getKey(), (Integer) e.getValue()); + } + } + } catch (Exception exc) { - if (parallelUpdates) { - final Map>> responseFutures = - CollectionUtil.newHashMap(routes.size()); - for (final Map.Entry entry : routes.entrySet()) { - final String url = entry.getKey(); - final LBSolrClient.Req lbRequest = entry.getValue(); - try { - MDC.put("CloudSolrClient.url", url); - responseFutures.put( - url, - threadPool.submit( - () -> { - return getLbClient().request(lbRequest).getResponse(); - })); - } finally { - MDC.remove("CloudSolrClient.url"); + Throwable rootCause = SolrException.getRootCause(exc); + // don't do retry support for admin requests + // or if the request doesn't have a collection specified + // or request is v2 api and its method is not GET + if (inputCollections.isEmpty() + || isAdmin + || (request.getApiVersion() == SolrRequest.ApiVersion.V2 + && request.getMethod() != SolrRequest.METHOD.GET)) { + if (exc instanceof SolrServerException) { + throw (SolrServerException) exc; + } else if (exc instanceof IOException) { + throw (IOException) exc; + } else if (exc instanceof RuntimeException) { + throw (RuntimeException) exc; + } else { + throw new SolrServerException(rootCause); } } - for (final Map.Entry>> entry : responseFutures.entrySet()) { - final String url = entry.getKey(); - final Future> responseFuture = entry.getValue(); - try { - shardResponses.add(url, responseFuture.get()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException(e); - } catch (ExecutionException e) { - exceptions.add(url, e.getCause()); + int errorCode = + (rootCause instanceof SolrException) + ? ((SolrException) rootCause).code() + : SolrException.ErrorCode.UNKNOWN.code; + + final boolean wasCommError = wasCommError(rootCause); + + if (wasCommError + || (exc instanceof RouteException + && (errorCode == 503)) // 404 because the core does not exist 503 service unavailable + // TODO there are other reasons for 404. We need to change the solr response format from HTML + // to structured data to know that + ) { + // it was a communication error. it is likely that + // the node to which the request to be sent is down . So , expire the state + // so that the next attempt would fetch the fresh state + // just re-read state for all of them, if it has not been retried + // in retryExpiryTime time + if (requestedCollections != null) { + for (DocCollection ext : requestedCollections) { + String name = ext.getName(); + ExpiringCachedDocCollection cacheEntry = collectionStateCache.peek(name); + if (cacheEntry != null) { + if (wasCommError) { + cacheEntry.maybeStale = true; + } else { + boolean markedStale = + cacheEntry.markMaybeStaleIfOutsideBackoff(retryExpiryTimeNano); + if (markedStale && cacheEntry.shouldRetry()) { + triggerCollectionRefresh(name); + } + } + } else { + triggerCollectionRefresh(name); + } + } + } + if (retryCount < MAX_STALE_RETRIES) { // if it is a communication error , we must try again + // may be, we have a stale version of the collection state, + // and we could not get any information from the server + // it is probably not worth trying again and again because + // the state would not have been updated + log.info( + "Request to collection {} failed due to ({}) {}, retry={} maxRetries={} commError={} errorCode={} - retrying", + inputCollections, + errorCode, + rootCause, + retryCount, + MAX_STALE_RETRIES, + wasCommError, + errorCode); + return requestWithRetryOnStaleState( + request, + retryCount + 1, + inputCollections, + skipStateVersion, + pendingRefreshes, + waitedForRefresh); } + } else { + log.info("request was not communication error it seems"); } + log.info( + "Request to collection {} failed due to ({}) {}, retry={} maxRetries={} commError={} errorCode={} ", + inputCollections, + errorCode, + rootCause, + retryCount, + MAX_STALE_RETRIES, + wasCommError, + errorCode); - if (exceptions.size() > 0) { - Throwable firstException = exceptions.getVal(0); - if (firstException instanceof SolrException e) { - throw getRouteException( - SolrException.ErrorCode.getErrorCode(e.code()), exceptions, routes); - } else { - throw getRouteException(SolrException.ErrorCode.SERVER_ERROR, exceptions, routes); + boolean stateWasStale = false; + if (retryCount < MAX_STALE_RETRIES + && requestedCollections != null + && !requestedCollections.isEmpty() + && (SolrException.ErrorCode.getErrorCode(errorCode) + == SolrException.ErrorCode.INVALID_STATE + || errorCode == 404)) { + // cached state for one or more external collections was stale + // re-issue request using updated state + stateWasStale = true; + + // just re-read state for all of them, which is a little heavy-handed but hopefully a rare + // occurrence + for (DocCollection ext : requestedCollections) { + collectionStateCache.remove(ext.getName()); } } - } else { - for (Map.Entry entry : routes.entrySet()) { - String url = entry.getKey(); - LBSolrClient.Req lbRequest = entry.getValue(); - try { - NamedList rsp = getLbClient().request(lbRequest).getResponse(); - shardResponses.add(url, rsp); - } catch (Exception e) { - if (e instanceof SolrException) { - throw (SolrException) e; - } else { - throw new SolrServerException(e); + + // if we experienced a communication error, it's worth checking the state + // with ZK just to make sure the node we're trying to hit is still part of the collection + if (retryCount < MAX_STALE_RETRIES + && !stateWasStale + && requestedCollections != null + && !requestedCollections.isEmpty() + && wasCommError) { + for (DocCollection ext : requestedCollections) { + DocCollection latestStateFromZk = getDocCollection(ext.getName(), null); + if (latestStateFromZk.getZNodeVersion() != ext.getZNodeVersion()) { + // looks like we couldn't reach the server because the state was stale == retry + stateWasStale = true; + // we just pulled state from ZK, so update the cache so that the retry uses it + collectionStateCache.put( + ext.getName(), new ExpiringCachedDocCollection(latestStateFromZk)); } } } - } - UpdateRequest nonRoutableRequest = null; - List deleteQuery = request.getDeleteQuery(); - if (deleteQuery != null && deleteQuery.size() > 0) { - UpdateRequest deleteQueryRequest = new UpdateRequest(); - deleteQueryRequest.setDeleteQuery(deleteQuery); - nonRoutableRequest = deleteQueryRequest; - } - - Set paramNames = nonRoutableParams.getParameterNames(); + // if the state was stale, then we retry the request once with new state pulled from Zk + if (stateWasStale) { + log.warn( + "Re-trying request to collection(s) {} after stale state error from server.", + inputCollections); - Set intersection = new HashSet<>(paramNames); - intersection.retainAll(NON_ROUTABLE_PARAMS); + Map> refreshesToWaitFor = pendingRefreshes; + if (!waitedForRefresh && (pendingRefreshes == null || pendingRefreshes.isEmpty())) { + refreshesToWaitFor = new HashMap<>(); + for (DocCollection ext : requestedCollections) { + refreshesToWaitFor.put(ext.getName(), triggerCollectionRefresh(ext.getName())); + } + } - if (nonRoutableRequest != null || intersection.size() > 0) { - if (nonRoutableRequest == null) { - nonRoutableRequest = new UpdateRequest(); - } - nonRoutableRequest.setParams(nonRoutableParams); - nonRoutableRequest.setBasicAuthCredentials( - request.getBasicAuthUser(), request.getBasicAuthPassword()); - final var endpoints = - routes.keySet().stream() - .map(url -> LBSolrClient.Endpoint.from(url)) - .collect(Collectors.toList()); - Collections.shuffle(endpoints, rand); - LBSolrClient.Req req = new LBSolrClient.Req(nonRoutableRequest, endpoints); - try { - LBSolrClient.Rsp rsp = getLbClient().request(req); - shardResponses.add(endpoints.get(0).toString(), rsp.getResponse()); - } catch (Exception e) { - throw new SolrException( - SolrException.ErrorCode.SERVER_ERROR, endpoints.get(0).toString(), e); + // First retry without sending state versions so the server does not immediately reject the + // request while we intentionally rely on stale routing (e.g., to allow forwarding to a new + // leader) as the background refresh completes. + if (!skipStateVersion && !waitedForRefresh) { + resp = + requestWithRetryOnStaleState( + request, + retryCount + 1, + inputCollections, + /*skipStateVersion*/ true, + refreshesToWaitFor, + waitedForRefresh); + } else if (!waitedForRefresh + && refreshesToWaitFor != null + && !refreshesToWaitFor.isEmpty()) { + for (Map.Entry> entry : + refreshesToWaitFor.entrySet()) { + waitForCollectionRefresh(entry.getKey(), entry.getValue()); + } + resp = + requestWithRetryOnStaleState( + request, + retryCount + 1, + inputCollections, + /*skipStateVersion*/ false, + Map.of(), + /*waitedForRefresh*/ true); + } else { + resp = + requestWithRetryOnStaleState( + request, + retryCount + 1, + inputCollections, + /*skipStateVersion*/ false, + Map.of(), + /*waitedForRefresh*/ waitedForRefresh); + } + } else { + if (exc instanceof SolrException + || exc instanceof SolrServerException + || exc instanceof IOException) { + throw exc; + } else { + throw new SolrServerException(rootCause); + } } - } - long end = System.nanoTime(); + if (requestedCollections != null) { + requestedCollections.clear(); // done with this + } + } - @SuppressWarnings({"rawtypes"}) - RouteResponse rr = - condenseResponse( - shardResponses, (int) TimeUnit.MILLISECONDS.convert(end - start, TimeUnit.NANOSECONDS)); - rr.setRouteResponses(shardResponses); - rr.setRoutes(routes); - return rr; + return resp; } - protected RouteException getRouteException( - SolrException.ErrorCode serverError, - NamedList exceptions, - Map routes) { - return new RouteException(serverError, exceptions, routes); - } + protected NamedList sendRequest(SolrRequest request, List inputCollections) + throws SolrServerException, IOException { + boolean sendToLeaders = false; - protected Map createRoutes( - UpdateRequest updateRequest, - ModifiableSolrParams routableParams, - DocCollection col, - DocRouter router, - Map> urlMap, - String routeField) { - return urlMap == null - ? null - : updateRequest.getRoutesToCollection(router, col, urlMap, routableParams, routeField); - } + if (request.getRequestType() == SolrRequestType.UPDATE) { + sendToLeaders = this.isUpdatesToLeaders(); - private Map> buildUrlMap( - DocCollection col, ReplicaListTransformer replicaListTransformer) { - Map> urlMap = new HashMap<>(); - Collection slices = col.getActiveSlices(); - Set liveNodes = getClusterStateProvider().getLiveNodes(); - for (Slice slice : slices) { - String name = slice.getName(); - List sortedReplicas = new ArrayList<>(); - Replica leader = slice.getLeader(); - if (directUpdatesToLeadersOnly && leader == null) { - for (Replica replica : - slice.getReplicas( - replica -> replica.isActive(liveNodes) && replica.getType() == Replica.Type.NRT)) { - leader = replica; - break; - } - } - if (leader == null) { - if (directUpdatesToLeadersOnly) { - continue; - } - // take unoptimized general path - we cannot find a leader yet - return null; - } + if (sendToLeaders && request instanceof UpdateRequest updateRequest) { + sendToLeaders = sendToLeaders && updateRequest.isSendToLeaders(); - if (!directUpdatesToLeadersOnly) { - for (Replica replica : slice.getReplicas()) { - if (!replica.equals(leader)) { - sortedReplicas.add(replica); + // Check if we can do a "directUpdate" ... + if (sendToLeaders) { + if (inputCollections.size() > 1) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Update request must be sent to a single collection " + + "or an alias: " + + inputCollections); + } + String collection = + inputCollections.isEmpty() + ? null + : inputCollections.get(0); // getting first mimics HttpSolrCall + NamedList response = directUpdate(updateRequest, collection); + if (response != null) { + return response; } } } + } - // Sort the non-leader replicas according to the request parameters - replicaListTransformer.transform(sortedReplicas); - - // put the leaderUrl first. - sortedReplicas.add(0, leader); + SolrParams reqParams = request.getParams(); + assert reqParams != null; - urlMap.put( - name, sortedReplicas.stream().map(Replica::getCoreUrl).collect(Collectors.toList())); - } - return urlMap; - } + ReplicaListTransformer replicaListTransformer = + requestRLTGenerator.getReplicaListTransformer(reqParams); - protected > T condenseResponse( - NamedList response, int timeMillis, Supplier supplier) { - T condensed = supplier.get(); - int status = 0; - Integer rf = null; + final ClusterStateProvider provider = getClusterStateProvider(); + final String urlScheme = provider.getUrlScheme(); + final Set liveNodes = provider.getLiveNodes(); - // TolerantUpdateProcessor - List> toleratedErrors = null; - int maxToleratedErrors = Integer.MAX_VALUE; + final List requestEndpoints = + new ArrayList<>(); // we populate this as follows... - // For "adds", "deletes", "deleteByQuery" etc. - Map> versions = new HashMap<>(); + if (request.getApiVersion() == SolrRequest.ApiVersion.V2) { + if (!liveNodes.isEmpty()) { + List liveNodesList = new ArrayList<>(liveNodes); + Collections.shuffle(liveNodesList, rand); + final var chosenNodeUrl = Utils.getBaseUrlForNodeName(liveNodesList.get(0), urlScheme); + requestEndpoints.add(new LBSolrClient.Endpoint(chosenNodeUrl)); + } - for (int i = 0; i < response.size(); i++) { - NamedList shardResponse = (NamedList) response.getVal(i); - NamedList header = (NamedList) shardResponse.get("responseHeader"); - Integer shardStatus = (Integer) header.get("status"); - int s = shardStatus.intValue(); - if (s > 0) { - status = s; + } else if (!request.requiresCollection()) { + for (String liveNode : liveNodes) { + final var nodeBaseUrl = Utils.getBaseUrlForNodeName(liveNode, urlScheme); + requestEndpoints.add(new LBSolrClient.Endpoint(nodeBaseUrl)); } - Object rfObj = header.get(UpdateRequest.REPFACT); - if (rfObj != null && rfObj instanceof Integer routeRf) { - if (rf == null || routeRf < rf) rf = routeRf; + } else { // API call to a particular collection / core / alias (i.e. + // request.requiresCollection() == true) + Set collectionNames = resolveAliases(inputCollections); + if (collectionNames.isEmpty()) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "No collection param specified on request and no default collection has been set: " + + inputCollections); } - @SuppressWarnings("unchecked") - List> shardTolerantErrors = - (List>) header.get("errors"); - if (null != shardTolerantErrors) { - Integer shardMaxToleratedErrors = (Integer) header.get("maxErrors"); - assert null != shardMaxToleratedErrors - : "TolerantUpdateProcessor reported errors but not maxErrors"; - // if we get into some weird state where the nodes disagree about the effective maxErrors, - // assume the min value seen to decide if we should fail. - maxToleratedErrors = - Math.min( - maxToleratedErrors, - ToleratedUpdateError.getEffectiveMaxErrors(shardMaxToleratedErrors.intValue())); - - if (null == toleratedErrors) { - toleratedErrors = new ArrayList>(shardTolerantErrors.size()); - } - for (SimpleOrderedMap err : shardTolerantErrors) { - toleratedErrors.add(err); + List preferredNodes = request.getPreferredNodes(); + if (preferredNodes != null && !preferredNodes.isEmpty()) { + String joinedInputCollections = StrUtils.join(inputCollections, ','); + final var endpoints = + preferredNodes.stream() + .map(nodeName -> Utils.getBaseUrlForNodeName(nodeName, urlScheme)) + .map(nodeUrl -> new LBSolrClient.Endpoint(nodeUrl, joinedInputCollections)) + .collect(Collectors.toList()); + if (!endpoints.isEmpty()) { + LBSolrClient.Req req = new LBSolrClient.Req(request, endpoints); + LBSolrClient.Rsp rsp = getLbClient().request(req); + return rsp.getResponse(); } } - for (String updateType : Arrays.asList("adds", "deletes", "deleteByQuery")) { - Object obj = shardResponse.get(updateType); - if (obj instanceof NamedList nl) { - NamedList versionsList = - versions.containsKey(updateType) ? versions.get(updateType) : new NamedList<>(); - versionsList.addAll(nl); - versions.put(updateType, versionsList); + + // TODO: not a big deal because of the caching, but we could avoid looking + // at every shard when getting leaders if we tweaked some things + + // Retrieve slices from the cloud state and, for each collection specified, add it to the Map + // of slices. + Map slices = new HashMap<>(); + String shardKeys = reqParams.get(ShardParams._ROUTE_); + for (String collectionName : collectionNames) { + DocCollection col = getDocCollection(collectionName, null); + if (col == null) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, "Collection not found: " + collectionName); } + Collection routeSlices = col.getRouter().getSearchSlices(shardKeys, reqParams, col); + ClientUtils.addSlices(slices, collectionName, routeSlices, true); } - } - NamedList cheader = new NamedList<>(); - cheader.add("status", status); - cheader.add("QTime", timeMillis); - if (rf != null) cheader.add(UpdateRequest.REPFACT, rf); - if (null != toleratedErrors) { - cheader.add("maxErrors", ToleratedUpdateError.getUserFriendlyMaxErrors(maxToleratedErrors)); - cheader.add("errors", toleratedErrors); - if (maxToleratedErrors < toleratedErrors.size()) { - // cumulative errors are too high, we need to throw a client exception w/correct metadata + // Gather URLs, grouped by leader or replica + List sortedReplicas = new ArrayList<>(); + List replicas = new ArrayList<>(); + for (Slice slice : slices.values()) { + Replica leader = slice.getLeader(); + for (Replica replica : slice.getReplicas()) { + String node = replica.getNodeName(); + if (!liveNodes.contains(node) // Must be a live node to continue + || replica.getState() + != Replica.State.ACTIVE) { // Must be an ACTIVE replica to continue + continue; + } + if (sendToLeaders && replica.equals(leader)) { + sortedReplicas.add(replica); // put leaders here eagerly (if sendToLeader mode) + } else { + replicas.add(replica); // replicas here + } + } + } - // NOTE: it shouldn't be possible for 1 == toleratedErrors.size(), because if that were the - // case then at least one shard should have thrown a real error before this, so we don't - // worry about having a more "singular" exception msg for that situation - StringBuilder msgBuf = - new StringBuilder() - .append(toleratedErrors.size()) - .append(" Async failures during distributed update: "); + // Sort the leader replicas, if any, according to the request preferences (none if + // !sendToLeaders) + replicaListTransformer.transform(sortedReplicas); - NamedList metadata = new NamedList<>(); - for (SimpleOrderedMap err : toleratedErrors) { - ToleratedUpdateError te = ToleratedUpdateError.parseMap(err); - metadata.add(te.getMetadataKey(), te.getMetadataValue()); + // Sort the replicas, if any, according to the request preferences and append to our list + replicaListTransformer.transform(replicas); - msgBuf.append("\n").append(te.getMessage()); - } + sortedReplicas.addAll(replicas); - SolrException toThrow = - new SolrException(SolrException.ErrorCode.BAD_REQUEST, msgBuf.toString()); - toThrow.setMetadata(metadata); - throw toThrow; + String joinedInputCollections = StrUtils.join(inputCollections, ','); + Set seenNodes = new HashSet<>(); + sortedReplicas.forEach( + replica -> { + if (seenNodes.add(replica.getNodeName())) { + if (inputCollections.size() == 1 && collectionNames.size() == 1) { + // If we have a single collection name (and not an alias to multiple collection), + // send the query directly to a replica of this collection. + requestEndpoints.add( + new LBSolrClient.Endpoint(replica.getBaseUrl(), replica.getCoreName())); + } else { + requestEndpoints.add( + new LBSolrClient.Endpoint(replica.getBaseUrl(), joinedInputCollections)); + } + } + }); + + if (requestEndpoints.isEmpty()) { + collectionStateCache.keySet().removeAll(collectionNames); + throw new SolrException( + SolrException.ErrorCode.INVALID_STATE, + "Could not find a healthy node to handle the request."); } } - for (Map.Entry> entry : versions.entrySet()) { - condensed.add(entry.getKey(), entry.getValue()); + + LBSolrClient.Req req = new LBSolrClient.Req(request, requestEndpoints); + LBSolrClient.Rsp rsp = getLbClient().request(req); + return rsp.getResponse(); + } + + /** + * Resolves the input collections to their possible aliased collections. Doesn't validate + * collection existence. + */ + private Set resolveAliases(List inputCollections) { + if (inputCollections.isEmpty()) { + return Set.of(); } - condensed.add("responseHeader", cheader); - return condensed; + LinkedHashSet uniqueNames = new LinkedHashSet<>(); // consistent ordering + for (String collectionName : inputCollections) { + if (getDocCollection(collectionName, -1) == null) { + // perhaps it's an alias + uniqueNames.addAll(getClusterStateProvider().resolveAlias(collectionName)); + } else { + uniqueNames.add(collectionName); // it's a collection + } + } + return uniqueNames; } - @SuppressWarnings({"rawtypes"}) - public RouteResponse condenseResponse(NamedList response, int timeMillis) { - return condenseResponse(response, timeMillis, RouteResponse::new); + /** + * If true, this client has been configured such that it will generally prefer to send {@link + * SolrRequestType#UPDATE} requests to a shard leader, if and only if {@link + * UpdateRequest#isSendToLeaders} is also true. If false, then this client has been configured to + * obey normal routing preferences when dealing with {@link SolrRequestType#UPDATE} requests. + * + * @see #isDirectUpdatesToLeadersOnly + */ + public boolean isUpdatesToLeaders() { + return updatesToLeaders; } - @SuppressWarnings({"rawtypes"}) - public static class RouteResponse extends NamedList { - private NamedList> routeResponses; - private Map routes; + /** + * If true, this client has been configured such that "direct updates" will only be sent + * to the current leader of the corresponding shard, and will not be retried with other replicas. + * This method has no effect if {@link #isUpdatesToLeaders()} or {@link + * UpdateRequest#isSendToLeaders} returns false. + * + *

A "direct update" is any update that can be sent directly to a single shard, and does not + * need to be broadcast to every shard. (Example: document updates or "delete by id" when using + * the default router; non-direct updates are things like commits and "delete by query"). + * + *

NOTE: If a single {@link UpdateRequest} contains multiple "direct updates" for different + * shards, this client may break the request up and merge the responses. + * + * @return true if direct updates are sent to shard leaders only + */ + public boolean isDirectUpdatesToLeadersOnly() { + return directUpdatesToLeadersOnly; + } - public void setRouteResponses(NamedList> routeResponses) { - this.routeResponses = routeResponses; - } + /** Visible for tests so they can assert the configured refresh parallelism. */ + protected int getStateRefreshParallelism() { + return stateRefreshParallelism; + } - public NamedList> getRouteResponses() { - return routeResponses; + protected DocCollection getDocCollection(String collection, Integer expectedVersion) + throws SolrException { + if (expectedVersion == null) { + expectedVersion = -1; + } + if (collection == null) { + return null; } - public void setRoutes(Map routes) { - this.routes = routes; + ExpiringCachedDocCollection cacheEntry = collectionStateCache.peek(collection); + if (cacheEntry != null && cacheEntry.isExpired(collectionStateCache.timeToLiveMs)) { + collectionStateCache.remove(collection, cacheEntry); + cacheEntry = null; } - public Map getRoutes() { - return routes; + DocCollection cached = cacheEntry == null ? null : cacheEntry.cached; + + if (cacheEntry != null && cacheEntry.shouldRetry()) { + triggerCollectionRefresh(collection); } - } - public static class RouteException extends SolrException { + if (cached != null && expectedVersion <= cached.getZNodeVersion()) { + return cached; + } - private NamedList throwables; - private Map routes; + CompletableFuture refreshFuture = triggerCollectionRefresh(collection); + return waitForCollectionRefresh(collection, refreshFuture); + } - public RouteException( - ErrorCode errorCode, - NamedList throwables, - Map routes) { - super(errorCode, throwables.getVal(0).getMessage(), throwables.getVal(0)); - this.throwables = throwables; - this.routes = routes; + private CompletableFuture triggerCollectionRefresh(String collection) { + return collectionRefreshes.compute( + collection, + (key, existingFuture) -> { + // A refresh is still in progress; return it. + if (existingFuture != null && !existingFuture.isDone()) { + return existingFuture; + } + // No refresh is in-progress, so trigger it. - // create a merged copy of the metadata from all wrapped exceptions - NamedList metadata = new NamedList(); - for (int i = 0; i < throwables.size(); i++) { - Throwable t = throwables.getVal(i); - if (t instanceof SolrException e) { - NamedList eMeta = e.getMetadata(); - if (null != eMeta) { - metadata.addAll(eMeta); + if (ExecutorUtil.isShutdown(threadPool)) { + assert closed; // see close() for the sequence + ExpiringCachedDocCollection cacheEntry = collectionStateCache.peek(key); + DocCollection cached = cacheEntry == null ? null : cacheEntry.cached; + return CompletableFuture.completedFuture(cached); + } else { + return CompletableFuture.supplyAsync( + () -> { + stateRefreshSemaphore.acquireUninterruptibly(); + try { + return loadDocCollection(key); + } finally { + stateRefreshSemaphore.release(); + // Remove the entry in case of many collections + collectionRefreshes.remove(key); + } + }, + threadPool); } - } - } - if (0 < metadata.size()) { - this.setMetadata(metadata); + }); + } + + private DocCollection waitForCollectionRefresh( + String collection, CompletableFuture refreshFuture) { + try { + return refreshFuture.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, + "Interrupted while refreshing state for collection " + collection, + e); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof SolrException) { + throw (SolrException) cause; } + throw new SolrException( + SolrException.ErrorCode.SERVER_ERROR, + "Error refreshing state for collection " + collection, + cause); } + } - public NamedList getThrowables() { - return throwables; + private DocCollection loadDocCollection(String collection) { + ClusterState.CollectionRef ref = getCollectionRef(collection); + if (ref == null) { + collectionStateCache.remove(collection); + return null; } - public Map getRoutes() { - return this.routes; + DocCollection fetchedCol = ref.get(); + if (fetchedCol == null) { + collectionStateCache.remove(collection); + return null; } - } - @Override - public NamedList request(SolrRequest request, String collection) - throws SolrServerException, IOException { - // the collection parameter of the request overrides that of the parameter to this method - String requestCollection = request.getCollection(); - if (requestCollection != null) { - collection = requestCollection; - } else if (collection == null) { - collection = defaultCollection; + ExpiringCachedDocCollection existing = collectionStateCache.peek(collection); + if (existing != null && existing.cached.getZNodeVersion() == fetchedCol.getZNodeVersion()) { + existing.setRetriedAt(); + existing.maybeStale = false; + return existing.cached; } - List inputCollections = - collection == null ? List.of() : StrUtils.splitSmart(collection, ",", true); - return requestWithRetryOnStaleState( - request, - 0, - inputCollections, - /*skipStateVersion*/ false, - Map.of(), - /*waitedForRefresh*/ false); + collectionStateCache.put(collection, new ExpiringCachedDocCollection(fetchedCol)); + return fetchedCol; + } + + ClusterState.CollectionRef getCollectionRef(String collection) { + return getClusterStateProvider().getState(collection); } /** - * As this class doesn't watch external collections on the client side, there's a chance that the - * request will fail due to cached stale state, which means the state must be refreshed from ZK - * and retried. + * Useful for determining the minimum achieved replication factor across all shards involved in + * processing an update request, typically useful for gauging the replication factor of a batch. */ - protected NamedList requestWithRetryOnStaleState( - SolrRequest request, - int retryCount, - List inputCollections, - boolean skipStateVersion, - Map> pendingRefreshes, - boolean waitedForRefresh) - throws SolrServerException, IOException { - // build up a _stateVer_ param to pass to the server containing all the - // external collection state versions involved in this request, which allows - // the server to notify us that our cached state for one or more of the external - // collections is stale and needs to be refreshed ... this code has no impact on internal - // collections - String stateVerParam = null; - List requestedCollections = null; - boolean isCollectionRequestOfV2 = false; - if (request instanceof V2Request) { - isCollectionRequestOfV2 = ((V2Request) request).isPerCollectionRequest(); - } - boolean isAdmin = - request.getRequestType() == SolrRequestType.ADMIN && !request.requiresCollection(); - if (!inputCollections.isEmpty() - && !isAdmin - && !isCollectionRequestOfV2) { // don't do _stateVer_ checking for admin, v2 api requests - Set requestedCollectionNames = resolveAliases(inputCollections); - - StringBuilder stateVerParamBuilder = null; - for (String requestedCollection : requestedCollectionNames) { - // track the version of state we're using on the client side using the _stateVer_ param - DocCollection coll = getDocCollection(requestedCollection, null); - if (coll == null) { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, "Collection not found: " + requestedCollection); - } - int collVer = coll.getZNodeVersion(); - if (requestedCollections == null) - requestedCollections = new ArrayList<>(requestedCollectionNames.size()); - requestedCollections.add(coll); - - if (stateVerParamBuilder == null) { - stateVerParamBuilder = new StringBuilder(); - } else { - stateVerParamBuilder.append( - "|"); // hopefully pipe is not an allowed char in a collection name - } - - stateVerParamBuilder.append(coll.getName()).append(":").append(collVer); - } + public int getMinAchievedReplicationFactor(String collection, NamedList resp) { + // it's probably already on the top-level header set by condense + NamedList header = (NamedList) resp.get("responseHeader"); + Integer achRf = (Integer) header.get(UpdateRequest.REPFACT); + if (achRf != null) return achRf.intValue(); - if (stateVerParamBuilder != null) { - stateVerParam = stateVerParamBuilder.toString(); + // not on the top-level header, walk the shard route tree + Map shardRf = getShardReplicationFactor(collection, resp); + for (Integer rf : shardRf.values()) { + if (achRf == null || rf < achRf) { + achRf = rf; } } + return (achRf != null) ? achRf.intValue() : -1; + } - if (request.getParams() instanceof ModifiableSolrParams params) { - if (!skipStateVersion && stateVerParam != null) { - params.set(STATE_VERSION, stateVerParam); - } else { - params.remove(STATE_VERSION); - } - } // else: ??? how to set this ??? - - NamedList resp = null; - try { - resp = sendRequest(request, inputCollections); - // to avoid an O(n) operation we always add STATE_VERSION to the last and try to read it from - // there - Object o = resp == null || resp.size() == 0 ? null : resp.get(STATE_VERSION, resp.size() - 1); - if (o != null && o instanceof Map invalidStates) { - // remove this because no one else needs this and tests would fail if they are comparing - // responses - resp.remove(resp.size() - 1); - for (Map.Entry e : invalidStates.entrySet()) { - getDocCollection((String) e.getKey(), (Integer) e.getValue()); - } - } - } catch (Exception exc) { - - Throwable rootCause = SolrException.getRootCause(exc); - // don't do retry support for admin requests - // or if the request doesn't have a collection specified - // or request is v2 api and its method is not GET - if (inputCollections.isEmpty() - || isAdmin - || (request.getApiVersion() == SolrRequest.ApiVersion.V2 - && request.getMethod() != SolrRequest.METHOD.GET)) { - if (exc instanceof SolrServerException) { - throw (SolrServerException) exc; - } else if (exc instanceof IOException) { - throw (IOException) exc; - } else if (exc instanceof RuntimeException) { - throw (RuntimeException) exc; - } else { - throw new SolrServerException(rootCause); + /** + * Walks the NamedList response after performing an update request looking for the replication + * factor that was achieved in each shard involved in the request. For single doc updates, there + * will be only one shard in the return value. + */ + public Map getShardReplicationFactor(String collection, NamedList resp) { + Map results = new HashMap<>(); + if (resp instanceof RouteResponse) { + NamedList> routes = ((RouteResponse) resp).getRouteResponses(); + DocCollection coll = getDocCollection(collection, null); + Map leaders = new HashMap<>(); + for (Slice slice : coll.getActiveSlices()) { + Replica leader = slice.getLeader(); + if (leader != null) { + String leaderUrl = leader.getBaseUrl() + "/" + leader.getCoreName(); + leaders.put(leaderUrl, slice.getName()); + String altLeaderUrl = leader.getBaseUrl() + "/" + collection; + leaders.put(altLeaderUrl, slice.getName()); } } - int errorCode = - (rootCause instanceof SolrException) - ? ((SolrException) rootCause).code() - : SolrException.ErrorCode.UNKNOWN.code; - - final boolean wasCommError = wasCommError(rootCause); - - if (wasCommError - || (exc instanceof RouteException - && (errorCode == 503)) // 404 because the core does not exist 503 service unavailable - // TODO there are other reasons for 404. We need to change the solr response format from HTML - // to structured data to know that - ) { - // it was a communication error. it is likely that - // the node to which the request to be sent is down . So , expire the state - // so that the next attempt would fetch the fresh state - // just re-read state for all of them, if it has not been retried - // in retryExpiryTime time - if (requestedCollections != null) { - for (DocCollection ext : requestedCollections) { - String name = ext.getName(); - ExpiringCachedDocCollection cacheEntry = collectionStateCache.peek(name); - if (cacheEntry != null) { - if (wasCommError) { - cacheEntry.maybeStale = true; - } else { - boolean markedStale = - cacheEntry.markMaybeStaleIfOutsideBackoff(retryExpiryTimeNano); - if (markedStale && cacheEntry.shouldRetry()) { - triggerCollectionRefresh(name); - } - } - } else { - triggerCollectionRefresh(name); + Iterator>> routeIter = routes.iterator(); + while (routeIter.hasNext()) { + Map.Entry> next = routeIter.next(); + String host = next.getKey(); + NamedList hostResp = next.getValue(); + Integer rf = + (Integer) ((NamedList) hostResp.get("responseHeader")).get(UpdateRequest.REPFACT); + if (rf != null) { + String shard = leaders.get(host); + if (shard == null) { + if (host.endsWith("/")) shard = leaders.get(host.substring(0, host.length() - 1)); + if (shard == null) { + shard = host; } } + results.put(shard, rf); } - if (retryCount < MAX_STALE_RETRIES) { // if it is a communication error , we must try again - // may be, we have a stale version of the collection state, - // and we could not get any information from the server - // it is probably not worth trying again and again because - // the state would not have been updated - log.info( - "Request to collection {} failed due to ({}) {}, retry={} maxRetries={} commError={} errorCode={} - retrying", - inputCollections, - errorCode, - rootCause, - retryCount, - MAX_STALE_RETRIES, - wasCommError, - errorCode); - return requestWithRetryOnStaleState( - request, - retryCount + 1, - inputCollections, - skipStateVersion, - pendingRefreshes, - waitedForRefresh); - } - } else { - log.info("request was not communication error it seems"); } - log.info( - "Request to collection {} failed due to ({}) {}, retry={} maxRetries={} commError={} errorCode={} ", - inputCollections, - errorCode, - rootCause, - retryCount, - MAX_STALE_RETRIES, - wasCommError, - errorCode); - - boolean stateWasStale = false; - if (retryCount < MAX_STALE_RETRIES - && requestedCollections != null - && !requestedCollections.isEmpty() - && (SolrException.ErrorCode.getErrorCode(errorCode) - == SolrException.ErrorCode.INVALID_STATE - || errorCode == 404)) { - // cached state for one or more external collections was stale - // re-issue request using updated state - stateWasStale = true; + } + return results; + } - // just re-read state for all of them, which is a little heavy-handed but hopefully a rare - // occurrence - for (DocCollection ext : requestedCollections) { - collectionStateCache.remove(ext.getName()); - } - } + /** + * Constructs {@link CloudSolrClient} instances from provided configuration. It will use a Jetty + * based {@code HttpClient} if available, or will otherwise use the JDK. + */ + public static class Builder { - // if we experienced a communication error, it's worth checking the state - // with ZK just to make sure the node we're trying to hit is still part of the collection - if (retryCount < MAX_STALE_RETRIES - && !stateWasStale - && requestedCollections != null - && !requestedCollections.isEmpty() - && wasCommError) { - for (DocCollection ext : requestedCollections) { - DocCollection latestStateFromZk = getDocCollection(ext.getName(), null); - if (latestStateFromZk.getZNodeVersion() != ext.getZNodeVersion()) { - // looks like we couldn't reach the server because the state was stale == retry - stateWasStale = true; - // we just pulled state from ZK, so update the cache so that the retry uses it - collectionStateCache.put( - ext.getName(), new ExpiringCachedDocCollection(latestStateFromZk)); - } - } - } - - // if the state was stale, then we retry the request once with new state pulled from Zk - if (stateWasStale) { - log.warn( - "Re-trying request to collection(s) {} after stale state error from server.", - inputCollections); - - Map> refreshesToWaitFor = pendingRefreshes; - if (!waitedForRefresh && (pendingRefreshes == null || pendingRefreshes.isEmpty())) { - refreshesToWaitFor = new HashMap<>(); - for (DocCollection ext : requestedCollections) { - refreshesToWaitFor.put(ext.getName(), triggerCollectionRefresh(ext.getName())); - } - } + protected Collection zkHosts = new ArrayList<>(); + protected List solrUrls = new ArrayList<>(); + protected String zkChroot; + protected HttpSolrClient httpClient; + protected boolean shardLeadersOnly = true; + protected boolean directUpdatesToLeadersOnly = false; + protected boolean parallelUpdates = true; + protected ClusterStateProvider stateProvider; + protected HttpSolrClient.BuilderBase internalClientBuilder; + protected RequestWriter requestWriter; + protected ResponseParser responseParser; + protected long retryExpiryTimeNano = + TimeUnit.NANOSECONDS.convert(3, TimeUnit.SECONDS); // 3 seconds or 3 million nanos - // First retry without sending state versions so the server does not immediately reject the - // request while we intentionally rely on stale routing (e.g., to allow forwarding to a new - // leader) as the background refresh completes. - if (!skipStateVersion && !waitedForRefresh) { - resp = - requestWithRetryOnStaleState( - request, - retryCount + 1, - inputCollections, - /*skipStateVersion*/ true, - refreshesToWaitFor, - waitedForRefresh); - } else if (!waitedForRefresh - && refreshesToWaitFor != null - && !refreshesToWaitFor.isEmpty()) { - for (Map.Entry> entry : - refreshesToWaitFor.entrySet()) { - waitForCollectionRefresh(entry.getKey(), entry.getValue()); - } - resp = - requestWithRetryOnStaleState( - request, - retryCount + 1, - inputCollections, - /*skipStateVersion*/ false, - Map.of(), - /*waitedForRefresh*/ true); - } else { - resp = - requestWithRetryOnStaleState( - request, - retryCount + 1, - inputCollections, - /*skipStateVersion*/ false, - Map.of(), - /*waitedForRefresh*/ waitedForRefresh); - } - } else { - if (exc instanceof SolrException - || exc instanceof SolrServerException - || exc instanceof IOException) { - throw exc; - } else { - throw new SolrServerException(rootCause); - } - } + protected String defaultCollection; + protected long timeToLiveSeconds = 60; + protected int parallelCacheRefreshesLocks = DEFAULT_STATE_REFRESH_PARALLELISM; + protected int zkConnectTimeout = SolrZkClientTimeout.DEFAULT_ZK_CONNECT_TIMEOUT; + protected int zkClientTimeout = SolrZkClientTimeout.DEFAULT_ZK_CLIENT_TIMEOUT; + protected boolean canUseZkACLs = true; - if (requestedCollections != null) { - requestedCollections.clear(); // done with this - } + /** + * Provide a series of Solr URLs to be used when configuring {@link CloudSolrClient} instances. + * The solr client will use these urls to understand the cluster topology, which solr nodes are + * active etc. + * + *

Provided Solr URLs are expected to point to the root Solr path + * ("http://hostname:8983/solr"); they should not include any collections, cores, or other path + * components. + * + *

Usage example: + * + *

+     *   final List<String> solrBaseUrls = new ArrayList<String>();
+     *   solrBaseUrls.add("http://solr1:8983/solr"); solrBaseUrls.add("http://solr2:8983/solr"); solrBaseUrls.add("http://solr3:8983/solr");
+     *   final SolrClient client = new CloudSolrClient.Builder(solrBaseUrls).build();
+     * 
+ */ + public Builder(List solrUrls) { + this.solrUrls = solrUrls; } - return resp; - } - - protected NamedList sendRequest(SolrRequest request, List inputCollections) - throws SolrServerException, IOException { - boolean sendToLeaders = false; - - if (request.getRequestType() == SolrRequestType.UPDATE) { - sendToLeaders = this.isUpdatesToLeaders(); - - if (sendToLeaders && request instanceof UpdateRequest updateRequest) { - sendToLeaders = sendToLeaders && updateRequest.isSendToLeaders(); - - // Check if we can do a "directUpdate" ... - if (sendToLeaders) { - if (inputCollections.size() > 1) { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, - "Update request must be sent to a single collection " - + "or an alias: " - + inputCollections); - } - String collection = - inputCollections.isEmpty() - ? null - : inputCollections.get(0); // getting first mimics HttpSolrCall - NamedList response = directUpdate(updateRequest, collection); - if (response != null) { - return response; - } - } - } + /** + * Provide a series of ZK hosts which will be used when configuring {@link CloudSolrClient} + * instances. + * + *

Usage example when Solr stores data at the ZooKeeper root ('/'): + * + *

+     *   final List<String> zkServers = new ArrayList<String>();
+     *   zkServers.add("zookeeper1:2181"); zkServers.add("zookeeper2:2181"); zkServers.add("zookeeper3:2181");
+     *   final SolrClient client = new CloudSolrClient.Builder(zkServers, Optional.empty()).build();
+     * 
+ * + * Usage example when Solr data is stored in a ZooKeeper chroot: + * + *
+     *    final List<String> zkServers = new ArrayList<String>();
+     *    zkServers.add("zookeeper1:2181"); zkServers.add("zookeeper2:2181"); zkServers.add("zookeeper3:2181");
+     *    final SolrClient client = new CloudSolrClient.Builder(zkServers, Optional.of("/solr")).build();
+     *  
+ * + * @param zkHosts a List of at least one ZooKeeper host and port (e.g. "zookeeper1:2181") + * @param zkChroot the path to the root ZooKeeper node containing Solr data. Provide {@code + * java.util.Optional.empty()} if no ZK chroot is used. + * @deprecated Use a connectionString constructor and/or prefer HTTP URLs instead. + */ + @Deprecated(since = "10.1") // sort of 10.0 but accidentally removed + public Builder(List zkHosts, Optional zkChroot) { + this.zkHosts = zkHosts; + if (zkChroot.isPresent()) this.zkChroot = zkChroot.get(); } - SolrParams reqParams = request.getParams(); - assert reqParams != null; - - ReplicaListTransformer replicaListTransformer = - requestRLTGenerator.getReplicaListTransformer(reqParams); - - final ClusterStateProvider provider = getClusterStateProvider(); - final String urlScheme = provider.getUrlScheme(); - final Set liveNodes = provider.getLiveNodes(); - - final List requestEndpoints = - new ArrayList<>(); // we populate this as follows... - - if (request.getApiVersion() == SolrRequest.ApiVersion.V2) { - if (!liveNodes.isEmpty()) { - List liveNodesList = new ArrayList<>(liveNodes); - Collections.shuffle(liveNodesList, rand); - final var chosenNodeUrl = Utils.getBaseUrlForNodeName(liveNodesList.get(0), urlScheme); - requestEndpoints.add(new LBSolrClient.Endpoint(chosenNodeUrl)); - } - - } else if (!request.requiresCollection()) { - for (String liveNode : liveNodes) { - final var nodeBaseUrl = Utils.getBaseUrlForNodeName(liveNode, urlScheme); - requestEndpoints.add(new LBSolrClient.Endpoint(nodeBaseUrl)); - } - } else { // API call to a particular collection / core / alias (i.e. - // request.requiresCollection() == true) - Set collectionNames = resolveAliases(inputCollections); - if (collectionNames.isEmpty()) { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, - "No collection param specified on request and no default collection has been set: " - + inputCollections); - } - - List preferredNodes = request.getPreferredNodes(); - if (preferredNodes != null && !preferredNodes.isEmpty()) { - String joinedInputCollections = StrUtils.join(inputCollections, ','); - final var endpoints = - preferredNodes.stream() - .map(nodeName -> Utils.getBaseUrlForNodeName(nodeName, urlScheme)) - .map(nodeUrl -> new LBSolrClient.Endpoint(nodeUrl, joinedInputCollections)) - .collect(Collectors.toList()); - if (!endpoints.isEmpty()) { - LBSolrClient.Req req = new LBSolrClient.Req(request, endpoints); - LBSolrClient.Rsp rsp = getLbClient().request(req); - return rsp.getResponse(); - } - } - - // TODO: not a big deal because of the caching, but we could avoid looking - // at every shard when getting leaders if we tweaked some things + /** for an expert use-case */ + public Builder(ClusterStateProvider stateProvider) { + this.stateProvider = stateProvider; + } - // Retrieve slices from the cloud state and, for each collection specified, add it to the Map - // of slices. - Map slices = new HashMap<>(); - String shardKeys = reqParams.get(ShardParams._ROUTE_); - for (String collectionName : collectionNames) { - DocCollection col = getDocCollection(collectionName, null); - if (col == null) { - throw new SolrException( - SolrException.ErrorCode.BAD_REQUEST, "Collection not found: " + collectionName); - } - Collection routeSlices = col.getRouter().getSearchSlices(shardKeys, reqParams, col); - ClientUtils.addSlices(slices, collectionName, routeSlices, true); - } + /** + * Creates a client builder based on a connection string of 2 possible formats: + * + *
    + *
  • ZooKeeper connection string (optionally with chroot), e.g. {@code + * zk1:2181,zk2:2181,zk3:2181/solr} + *
  • Comma-separated list of Solr node base URLs (HTTP or HTTPS), e.g. {@code + * http://solr1:8983/solr,http://solr2:8983/solr} + *
+ * + * @param connectionString a string specifying either ZooKeeper connection string or HTTP(S) + * Solr URLs + * @throws IllegalArgumentException if string is null, empty, or malformed + */ + public Builder(String connectionString) { + this(CloudSolrClientConnection.parse(connectionString)); + } - // Gather URLs, grouped by leader or replica - List sortedReplicas = new ArrayList<>(); - List replicas = new ArrayList<>(); - for (Slice slice : slices.values()) { - Replica leader = slice.getLeader(); - for (Replica replica : slice.getReplicas()) { - String node = replica.getNodeName(); - if (!liveNodes.contains(node) // Must be a live node to continue - || replica.getState() - != Replica.State.ACTIVE) { // Must be an ACTIVE replica to continue - continue; - } - if (sendToLeaders && replica.equals(leader)) { - sortedReplicas.add(replica); // put leaders here eagerly (if sendToLeader mode) - } else { - replicas.add(replica); // replicas here - } - } + /** + * Creates a client builder from a {@link CloudSolrClientConnection}. + * + * @param connection instance of {@link CloudSolrClientConnection}, which can be obtained from + * the solr connection string or created via the constructor + */ + public Builder(CloudSolrClientConnection connection) { + if (connection.isZookeeper()) { + this.zkHosts = connection.quorumItems(); + this.zkChroot = connection.zkChroot(); + } else { + this.solrUrls = connection.quorumItems(); } + } - // Sort the leader replicas, if any, according to the request preferences (none if - // !sendToLeaders) - replicaListTransformer.transform(sortedReplicas); + /** Whether to use the default ZK ACLs when building a ZK Client. */ + public Builder canUseZkACLs(boolean canUseZkACLs) { + this.canUseZkACLs = canUseZkACLs; + return this; + } - // Sort the replicas, if any, according to the request preferences and append to our list - replicaListTransformer.transform(replicas); + /** + * Tells {@link Builder} that created clients should be configured such that {@link + * CloudSolrClient#isUpdatesToLeaders} returns true. + * + * @see #sendUpdatesToAnyReplica + * @see CloudSolrClient#isUpdatesToLeaders + */ + public Builder sendUpdatesOnlyToShardLeaders() { + shardLeadersOnly = true; + return this; + } - sortedReplicas.addAll(replicas); + /** + * Tells {@link Builder} that created clients should be configured such that {@link + * CloudSolrClient#isUpdatesToLeaders} returns false. + * + * @see #sendUpdatesOnlyToShardLeaders + * @see CloudSolrClient#isUpdatesToLeaders + */ + public Builder sendUpdatesToAnyReplica() { + shardLeadersOnly = false; + return this; + } - String joinedInputCollections = StrUtils.join(inputCollections, ','); - Set seenNodes = new HashSet<>(); - sortedReplicas.forEach( - replica -> { - if (seenNodes.add(replica.getNodeName())) { - if (inputCollections.size() == 1 && collectionNames.size() == 1) { - // If we have a single collection name (and not an alias to multiple collection), - // send the query directly to a replica of this collection. - requestEndpoints.add( - new LBSolrClient.Endpoint(replica.getBaseUrl(), replica.getCoreName())); - } else { - requestEndpoints.add( - new LBSolrClient.Endpoint(replica.getBaseUrl(), joinedInputCollections)); - } - } - }); + /** + * Tells {@link CloudSolrClient.Builder} that created clients should send direct updates to + * shard leaders only. + * + *

UpdateRequests whose leaders cannot be found will "fail fast" on the client side with a + * {@link SolrException} + * + * @see #sendDirectUpdatesToAnyShardReplica + * @see CloudSolrClient#isDirectUpdatesToLeadersOnly + */ + public Builder sendDirectUpdatesToShardLeadersOnly() { + directUpdatesToLeadersOnly = true; + return this; + } - if (requestEndpoints.isEmpty()) { - collectionStateCache.keySet().removeAll(collectionNames); - throw new SolrException( - SolrException.ErrorCode.INVALID_STATE, - "Could not find a healthy node to handle the request."); - } + /** + * Tells {@link CloudSolrClient.Builder} that created clients can send updates to any shard + * replica (shard leaders and non-leaders). + * + *

Shard leaders are still preferred, but the created clients will fall back to using other + * replicas if a leader cannot be found. + * + * @see #sendDirectUpdatesToShardLeadersOnly + * @see CloudSolrClient#isDirectUpdatesToLeadersOnly + */ + public Builder sendDirectUpdatesToAnyShardReplica() { + directUpdatesToLeadersOnly = false; + return this; } - LBSolrClient.Req req = new LBSolrClient.Req(request, requestEndpoints); - LBSolrClient.Rsp rsp = getLbClient().request(req); - return rsp.getResponse(); - } - - /** - * Resolves the input collections to their possible aliased collections. Doesn't validate - * collection existence. - */ - private Set resolveAliases(List inputCollections) { - if (inputCollections.isEmpty()) { - return Set.of(); - } - LinkedHashSet uniqueNames = new LinkedHashSet<>(); // consistent ordering - for (String collectionName : inputCollections) { - if (getDocCollection(collectionName, -1) == null) { - // perhaps it's an alias - uniqueNames.addAll(getClusterStateProvider().resolveAlias(collectionName)); - } else { - uniqueNames.add(collectionName); // it's a collection - } + /** Provides a {@link RequestWriter} for created clients to use when handing requests. */ + public Builder withRequestWriter(RequestWriter requestWriter) { + this.requestWriter = requestWriter; + return this; } - return uniqueNames; - } - /** - * If true, this client has been configured such that it will generally prefer to send {@link - * SolrRequestType#UPDATE} requests to a shard leader, if and only if {@link - * UpdateRequest#isSendToLeaders} is also true. If false, then this client has been configured to - * obey normal routing preferences when dealing with {@link SolrRequestType#UPDATE} requests. - * - * @see #isDirectUpdatesToLeadersOnly - */ - public boolean isUpdatesToLeaders() { - return updatesToLeaders; - } + /** Provides a {@link ResponseParser} for created clients to use when handling requests. */ + public Builder withResponseParser(ResponseParser responseParser) { + this.responseParser = responseParser; + return this; + } - /** - * If true, this client has been configured such that "direct updates" will only be sent - * to the current leader of the corresponding shard, and will not be retried with other replicas. - * This method has no effect if {@link #isUpdatesToLeaders()} or {@link - * UpdateRequest#isSendToLeaders} returns false. - * - *

A "direct update" is any update that can be sent directly to a single shard, and does not - * need to be broadcast to every shard. (Example: document updates or "delete by id" when using - * the default router; non-direct updates are things like commits and "delete by query"). - * - *

NOTE: If a single {@link UpdateRequest} contains multiple "direct updates" for different - * shards, this client may break the request up and merge the responses. - * - * @return true if direct updates are sent to shard leaders only - */ - public boolean isDirectUpdatesToLeadersOnly() { - return directUpdatesToLeadersOnly; - } + /** + * Tells {@link CloudSolrClient.Builder} whether created clients should send shard updates + * serially or in parallel + * + *

When an {@link UpdateRequest} affects multiple shards, {@link CloudSolrClient} splits it + * up and sends a request to each affected shard. This setting chooses whether those + * sub-requests are sent serially or in parallel. + * + *

If not set, this defaults to 'true' and sends sub-requests in parallel. + */ + public Builder withParallelUpdates(boolean parallelUpdates) { + this.parallelUpdates = parallelUpdates; + return this; + } - /** Visible for tests so they can assert the configured refresh parallelism. */ - protected int getStateRefreshParallelism() { - return stateRefreshParallelism; - } + /** + * Configures how many collection state refresh operations may run in parallel using a dedicated + * thread pool. This controls the maximum number of concurrent ZooKeeper/cluster state lookups. + * + *

Defaults to 5. + */ + public Builder withParallelCacheRefreshes(int parallelCacheRefreshesLocks) { + this.parallelCacheRefreshesLocks = parallelCacheRefreshesLocks; + return this; + } - protected DocCollection getDocCollection(String collection, Integer expectedVersion) - throws SolrException { - if (expectedVersion == null) { - expectedVersion = -1; + /** + * This is the time to wait to re-fetch the state after getting the same state version from ZK + */ + public Builder withRetryExpiryTime(long expiryTime, TimeUnit unit) { + this.retryExpiryTimeNano = TimeUnit.NANOSECONDS.convert(expiryTime, unit); + return this; } - if (collection == null) { - return null; + + /** Sets the default collection for request. */ + public Builder withDefaultCollection(String defaultCollection) { + this.defaultCollection = defaultCollection; + return this; } - ExpiringCachedDocCollection cacheEntry = collectionStateCache.peek(collection); - if (cacheEntry != null && cacheEntry.isExpired(collectionStateCache.timeToLiveMs)) { - collectionStateCache.remove(collection, cacheEntry); - cacheEntry = null; + /** + * Sets the cache ttl for DocCollection Objects cached. + * + * @param timeToLive ttl value + */ + public Builder withCollectionCacheTtl(long timeToLive, TimeUnit unit) { + assert timeToLive > 0; + this.timeToLiveSeconds = TimeUnit.SECONDS.convert(timeToLive, unit); + return this; } - DocCollection cached = cacheEntry == null ? null : cacheEntry.cached; + /** + * Set the internal Solr HTTP client. + * + *

Note: closing the client instance is the responsibility of the caller. + * + * @return this + */ + public Builder withHttpClient(HttpSolrClient httpSolrClient) { + if (this.internalClientBuilder != null) { + throw new IllegalStateException( + "The builder can't accept an httpClient AND an internalClientBuilder, only one of those can be provided"); + } + this.httpClient = httpSolrClient; + return this; + } - if (cacheEntry != null && cacheEntry.shouldRetry()) { - triggerCollectionRefresh(collection); + /** + * If provided, the CloudSolrClient will build it's internal client using this builder (instead + * of the empty default one). Providing this builder allows users to configure the internal + * clients (authentication, timeouts, etc.). + * + * @param internalClientBuilder the builder to use for creating the internal http client. + * @return this + */ + public Builder withHttpClientBuilder(HttpSolrClient.BuilderBase internalClientBuilder) { + if (this.httpClient != null) { + throw new IllegalStateException( + "The builder can't accept an httpClient AND an internalClientBuilder, only one of those can be provided"); + } + this.internalClientBuilder = internalClientBuilder; + return this; } - if (cached != null && expectedVersion <= cached.getZNodeVersion()) { - return cached; + @Deprecated(since = "9.10") + public Builder withInternalClientBuilder( + HttpSolrClient.BuilderBase internalClientBuilder) { + return withHttpClientBuilder(internalClientBuilder); } - CompletableFuture refreshFuture = triggerCollectionRefresh(collection); - return waitForCollectionRefresh(collection, refreshFuture); - } + /** + * Sets the Zk connection timeout + * + * @param zkConnectTimeout timeout value + * @param unit time unit + */ + public Builder withZkConnectTimeout(int zkConnectTimeout, TimeUnit unit) { + this.zkConnectTimeout = Math.toIntExact(unit.toMillis(zkConnectTimeout)); + return this; + } - private CompletableFuture triggerCollectionRefresh(String collection) { - return collectionRefreshes.compute( - collection, - (key, existingFuture) -> { - // A refresh is still in progress; return it. - if (existingFuture != null && !existingFuture.isDone()) { - return existingFuture; - } - // No refresh is in-progress, so trigger it. + /** + * Sets the Zk client session timeout + * + * @param zkClientTimeout timeout value + * @param unit time unit + */ + public Builder withZkClientTimeout(int zkClientTimeout, TimeUnit unit) { + this.zkClientTimeout = Math.toIntExact(unit.toMillis(zkClientTimeout)); + return this; + } - if (ExecutorUtil.isShutdown(threadPool)) { - assert closed; // see close() for the sequence - ExpiringCachedDocCollection cacheEntry = collectionStateCache.peek(key); - DocCollection cached = cacheEntry == null ? null : cacheEntry.cached; - return CompletableFuture.completedFuture(cached); - } else { - return CompletableFuture.supplyAsync( - () -> { - stateRefreshSemaphore.acquireUninterruptibly(); - try { - return loadDocCollection(key); - } finally { - stateRefreshSemaphore.release(); - // Remove the entry in case of many collections - collectionRefreshes.remove(key); - } - }, - threadPool); - } - }); - } + /** Create a {@link CloudSolrClient} based on the provided configuration. */ + public CloudHttp2SolrClient build() { + int providedOptions = 0; + if (!zkHosts.isEmpty()) providedOptions++; + if (!solrUrls.isEmpty()) providedOptions++; + if (stateProvider != null) providedOptions++; - private DocCollection waitForCollectionRefresh( - String collection, CompletableFuture refreshFuture) { - try { - return refreshFuture.get(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new SolrException( - SolrException.ErrorCode.SERVER_ERROR, - "Interrupted while refreshing state for collection " + collection, - e); - } catch (ExecutionException e) { - Throwable cause = e.getCause(); - if (cause instanceof SolrException) { - throw (SolrException) cause; + if (providedOptions > 1) { + throw new IllegalArgumentException( + "Only one of zkHost(s), solrUrl(s), or stateProvider should be specified."); + } else if (providedOptions == 0) { + throw new IllegalArgumentException( + "One of zkHosts, solrUrls, or stateProvider must be specified."); } - throw new SolrException( - SolrException.ErrorCode.SERVER_ERROR, - "Error refreshing state for collection " + collection, - cause); + + return new CloudHttp2SolrClient(this); } - } - private DocCollection loadDocCollection(String collection) { - ClusterState.CollectionRef ref = getCollectionRef(collection); - if (ref == null) { - collectionStateCache.remove(collection); - return null; + protected HttpSolrClient createOrGetHttpClient() { + if (httpClient != null) { + return httpClient; + } else if (internalClientBuilder != null) { + return internalClientBuilder.build(); + } else { + return HttpSolrClient.builder(null).build(); + } } - DocCollection fetchedCol = ref.get(); - if (fetchedCol == null) { - collectionStateCache.remove(collection); - return null; + protected LBSolrClient createOrGetLbClient(HttpSolrClient myClient) { + return myClient.createLBSolrClient(); } - ExpiringCachedDocCollection existing = collectionStateCache.peek(collection); - if (existing != null && existing.cached.getZNodeVersion() == fetchedCol.getZNodeVersion()) { - existing.setRetriedAt(); - existing.maybeStale = false; - return existing.cached; + protected ClusterStateProvider createZkClusterStateProvider() { + ClusterStateProvider stateProvider = + ClusterStateProvider.newZkClusterStateProvider(zkHosts, zkChroot, canUseZkACLs); + if (stateProvider instanceof SolrZkClientTimeout.SolrZkClientTimeoutAware timeoutAware) { + timeoutAware.setZkClientTimeout(zkClientTimeout); + timeoutAware.setZkConnectTimeout(zkConnectTimeout); + } + return stateProvider; } - collectionStateCache.put(collection, new ExpiringCachedDocCollection(fetchedCol)); - return fetchedCol; - } - - ClusterState.CollectionRef getCollectionRef(String collection) { - return getClusterStateProvider().getState(collection); + protected ClusterStateProvider createHttpClusterStateProvider(HttpSolrClient httpClient) { + try { + return new HttpClusterStateProvider<>(solrUrls, httpClient); + } catch (Exception e) { + throw new RuntimeException( + "Couldn't initialize a HttpClusterStateProvider (is/are the " + + "Solr server(s), " + + solrUrls + + ", down?)", + e); + } + } } - /** - * Useful for determining the minimum achieved replication factor across all shards involved in - * processing an update request, typically useful for gauging the replication factor of a batch. - */ - public int getMinAchievedReplicationFactor(String collection, NamedList resp) { - // it's probably already on the top-level header set by condense - NamedList header = (NamedList) resp.get("responseHeader"); - Integer achRf = (Integer) header.get(UpdateRequest.REPFACT); - if (achRf != null) return achRf.intValue(); + protected static class StateCache extends ConcurrentHashMap { + final AtomicLong puts = new AtomicLong(); + final AtomicLong hits = new AtomicLong(); + final Lock evictLock = new ReentrantLock(true); + public volatile long timeToLiveMs = 60 * 1000L; - // not on the top-level header, walk the shard route tree - Map shardRf = getShardReplicationFactor(collection, resp); - for (Integer rf : shardRf.values()) { - if (achRf == null || rf < achRf) { - achRf = rf; + @Override + public ExpiringCachedDocCollection get(Object key) { + ExpiringCachedDocCollection val = super.get(key); + if (val == null) { + // a new collection is likely to be added now. + // check if there are stale items and remove them + evictStale(); + return null; + } + if (val.isExpired(timeToLiveMs)) { + super.remove(key); + return null; } + hits.incrementAndGet(); + return val; } - return (achRf != null) ? achRf.intValue() : -1; - } - /** - * Walks the NamedList response after performing an update request looking for the replication - * factor that was achieved in each shard involved in the request. For single doc updates, there - * will be only one shard in the return value. - */ - public Map getShardReplicationFactor(String collection, NamedList resp) { - Map results = new HashMap<>(); - if (resp instanceof RouteResponse) { - NamedList> routes = ((RouteResponse) resp).getRouteResponses(); - DocCollection coll = getDocCollection(collection, null); - Map leaders = new HashMap<>(); - for (Slice slice : coll.getActiveSlices()) { - Replica leader = slice.getLeader(); - if (leader != null) { - String leaderUrl = leader.getBaseUrl() + "/" + leader.getCoreName(); - leaders.put(leaderUrl, slice.getName()); - String altLeaderUrl = leader.getBaseUrl() + "/" + collection; - leaders.put(altLeaderUrl, slice.getName()); - } - } + ExpiringCachedDocCollection peek(Object key) { + return super.get(key); + } - Iterator>> routeIter = routes.iterator(); - while (routeIter.hasNext()) { - Map.Entry> next = routeIter.next(); - String host = next.getKey(); - NamedList hostResp = next.getValue(); - Integer rf = - (Integer) ((NamedList) hostResp.get("responseHeader")).get(UpdateRequest.REPFACT); - if (rf != null) { - String shard = leaders.get(host); - if (shard == null) { - if (host.endsWith("/")) shard = leaders.get(host.substring(0, host.length() - 1)); - if (shard == null) { - shard = host; - } + @Override + public ExpiringCachedDocCollection put(String key, ExpiringCachedDocCollection value) { + puts.incrementAndGet(); + return super.put(key, value); + } + + void evictStale() { + if (!evictLock.tryLock()) return; + try { + for (Entry e : entrySet()) { + if (e.getValue().isExpired(timeToLiveMs)) { + super.remove(e.getKey()); } - results.put(shard, rf); } + } finally { + evictLock.unlock(); } } - return results; } - /** - * Determines whether an UpdateRequest contains sufficient routing information to identify shard - * leaders for direct updates when directUpdatesToLeadersOnly is enabled. - */ - private static boolean hasInfoToFindLeaders(UpdateRequest updateRequest, String idField) { - final Map> documents = updateRequest.getDocumentsMap(); - final Map> deleteById = updateRequest.getDeleteByIdMap(); + @SuppressWarnings({"rawtypes"}) + public static class RouteResponse extends NamedList { + private NamedList> routeResponses; + private Map routes; - final boolean hasNoDocuments = (documents == null || documents.isEmpty()); - final boolean hasNoDeleteById = (deleteById == null || deleteById.isEmpty()); - if (hasNoDocuments && hasNoDeleteById) { - // no documents and no delete-by-id, so no info to find leader(s) - return false; + public void setRouteResponses(NamedList> routeResponses) { + this.routeResponses = routeResponses; } - if (documents != null) { - for (final Map.Entry> entry : documents.entrySet()) { - final SolrInputDocument doc = entry.getKey(); - final Object fieldValue = doc.getFieldValue(idField); - if (fieldValue == null) { - // a document with no id field value, so can't find leader for it - return false; - } - } + public NamedList> getRouteResponses() { + return routeResponses; } - if (deleteById != null) { - for (final Map.Entry> entry : deleteById.entrySet()) { - final Map params = entry.getValue(); - if (params == null || params.get(ShardParams._ROUTE_) == null) { - // deleteById entry lacks explicit route parameter, can't find leader for it - return false; + public void setRoutes(Map routes) { + this.routes = routes; + } + + public Map getRoutes() { + return routes; + } + } + + public static class RouteException extends SolrException { + + private NamedList throwables; + private Map routes; + + public RouteException( + ErrorCode errorCode, + NamedList throwables, + Map routes) { + super(errorCode, throwables.getVal(0).getMessage(), throwables.getVal(0)); + this.throwables = throwables; + this.routes = routes; + + // create a merged copy of the metadata from all wrapped exceptions + NamedList metadata = new NamedList(); + for (int i = 0; i < throwables.size(); i++) { + Throwable t = throwables.getVal(i); + if (t instanceof SolrException e) { + NamedList eMeta = e.getMetadata(); + if (null != eMeta) { + metadata.addAll(eMeta); + } } } + if (0 < metadata.size()) { + this.setMetadata(metadata); + } } - return true; + public NamedList getThrowables() { + return throwables; + } + + public Map getRoutes() { + return this.routes; + } } /** Universal connection string parser logic. */ @@ -1887,4 +1833,55 @@ public String toString() { return String.join(",", quorumItems) + (isZookeeper && zkChroot != null ? zkChroot : ""); } } + + class ExpiringCachedDocCollection { + final DocCollection cached; + final long cachedAtNano; + // This is the time at which the collection is retried and got the same old version + volatile long retriedAtNano = -1; + // flag that suggests that this is potentially to be rechecked + volatile boolean maybeStale = false; + + ExpiringCachedDocCollection(DocCollection cached) { + this.cached = cached; + this.cachedAtNano = System.nanoTime(); + } + + boolean isExpired(long timeToLiveMs) { + return (System.nanoTime() - cachedAtNano) + > TimeUnit.NANOSECONDS.convert(timeToLiveMs, TimeUnit.MILLISECONDS); + } + + boolean shouldRetry() { + if (maybeStale) { // we are not sure if it is stale so check with retry time + if ((retriedAtNano == -1 || (System.nanoTime() - retriedAtNano) > retryExpiryTimeNano)) { + return true; // we retried a while back. and we could not get anything new. + // it's likely that it is not going to be available now also. + } + } + return false; + } + + void setRetriedAt() { + retriedAtNano = System.nanoTime(); + } + + /** + * Marks this entry as {@code maybeStale} if the provided backoff window has elapsed since the + * last retry. + * + * @return {@code true} if the entry was flagged as maybe stale + */ + boolean markMaybeStaleIfOutsideBackoff(long retryBackoffNano) { + if (maybeStale) { + return true; + } + long lastRetry = retriedAtNano; + if (lastRetry != -1 && (System.nanoTime() - lastRetry) <= retryBackoffNano) { + return false; + } + maybeStale = true; + return true; + } + } } From 28ac2a574470bc62f32a7b3594745ba37a6f3b7f Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Fri, 19 Jun 2026 11:33:45 -0400 Subject: [PATCH 54/69] SOLR-18167: Fix the original PR #4507 to cover more use cases in mapping old to new system property names (#4535) Co-authored-by: Utsav Parmar --- solr/packaging/test/test_start_solr.bats | 2 +- .../org/apache/solr/common/util/EnvUtils.java | 22 +++++-------------- .../apache/solr/common/util/EnvUtilsTest.java | 19 ++++++++++++++-- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/solr/packaging/test/test_start_solr.bats b/solr/packaging/test/test_start_solr.bats index f85c0e5918de..be365e7a65b5 100644 --- a/solr/packaging/test/test_start_solr.bats +++ b/solr/packaging/test/test_start_solr.bats @@ -85,7 +85,7 @@ teardown() { @test "deprecated system properties converted to modern properties" { solr start -Ddisable.config.edit=true - assert_file_contains "${SOLR_LOGS_DIR}/solr.log" 'You are passing in deprecated system property disable.config.edit and should upgrade to using solr.api.config.edit.enabled instead.' + assert_file_contains "${SOLR_LOGS_DIR}/solr.log" 'Deprecated system property disable.config.edit has been replaced by solr.api.config.edit.enabled' } @test "start with custom jetty options" { diff --git a/solr/solrj/src/java/org/apache/solr/common/util/EnvUtils.java b/solr/solrj/src/java/org/apache/solr/common/util/EnvUtils.java index d0b24ca0f319..031116ba23ea 100644 --- a/solr/solrj/src/java/org/apache/solr/common/util/EnvUtils.java +++ b/solr/solrj/src/java/org/apache/solr/common/util/EnvUtils.java @@ -75,7 +75,7 @@ public class EnvUtils { CUSTOM_MAPPINGS.put(key, props.getProperty(key)); } for (String key : deprecatedProps.stringPropertyNames()) { - DEPRECATED_MAPPINGS.put(deprecatedProps.getProperty(key), key); + DEPRECATED_MAPPINGS.put(camelCaseToDotSeparated(deprecatedProps.getProperty(key)), key); } init(false, System.getenv(), System.getProperties()); } @@ -217,24 +217,14 @@ static synchronized void init( } for (String deprecatedKey : sysProperties.stringPropertyNames()) { - String lookupKey = findDeprecatedMappingKey(deprecatedKey); - if (lookupKey != null) { - applyDeprecatedPropertyMapping(deprecatedKey, lookupKey, sysProperties); + var dotKey = camelCaseToDotSeparated(deprecatedKey); + if (DEPRECATED_MAPPINGS.containsKey(dotKey) + || DEPRECATED_MAPPINGS.containsKey("!" + dotKey)) { + applyDeprecatedPropertyMapping(deprecatedKey, dotKey, sysProperties); } } } - // "-D" flags land in system properties as typed - often camelCase - but our mapping file uses - // dot-separated keys, so normalise before looking up. - private static String findDeprecatedMappingKey(String sysPropKey) { - var dotKey = camelCaseToDotSeparated(sysPropKey); - return isInDeprecatedMappings(dotKey) ? dotKey : null; - } - - private static boolean isInDeprecatedMappings(String key) { - return DEPRECATED_MAPPINGS.containsKey(key) || DEPRECATED_MAPPINGS.containsKey("!" + key); - } - private static void applyDeprecatedPropertyMapping( String deprecatedKey, String lookupKey, Properties sysProperties) { var newPropName = @@ -242,7 +232,7 @@ private static void applyDeprecatedPropertyMapping( var newValue = DEPRECATED_MAPPINGS.containsKey(lookupKey) ? sysProperties.getProperty(deprecatedKey) - : String.valueOf(!Boolean.getBoolean(deprecatedKey)); + : String.valueOf(!Boolean.parseBoolean(sysProperties.getProperty(deprecatedKey))); log.warn( "Deprecated system property {} has been replaced by {}. Support for the old property will be removed in a future version of Solr.", deprecatedKey, diff --git a/solr/solrj/src/test/org/apache/solr/common/util/EnvUtilsTest.java b/solr/solrj/src/test/org/apache/solr/common/util/EnvUtilsTest.java index cce0a483bcb7..346aee08b424 100644 --- a/solr/solrj/src/test/org/apache/solr/common/util/EnvUtilsTest.java +++ b/solr/solrj/src/test/org/apache/solr/common/util/EnvUtilsTest.java @@ -103,7 +103,6 @@ public void testOverwrite() { public void testDeprecated() { var env = Map.of("SOLR_OVERWRITE", "overwritten"); Properties defaultProps = new Properties(); - // Use the already converted version, not the original camelCase. defaultProps.setProperty("solr.config.set.forbidden.file.types", "xml,json,jar"); EnvUtils.init(false, env, defaultProps); @@ -111,13 +110,29 @@ public void testDeprecated() { } @Test - public void deprecatedCamelCaseDFlagIsTranslatedToCurrentPropertyName() { + public void deprecatedCamelCaseSystemPropertyIsMigratedToCurrentName() { Properties sysprops = new Properties(); sysprops.setProperty("solr.auth.jwt.allowOutboundHttp", "true"); EnvUtils.init(false, Map.of(), sysprops); assertTrue(EnvUtils.getPropertyAsBool("solr.auth.jwt.outbound.http.enabled")); } + @Test + public void deprecatedCamelCaseOldNameInMappingsFileIsTranslated() { + Properties sysprops = new Properties(); + sysprops.setProperty("collection.configName", "techproducts"); + EnvUtils.init(false, Map.of(), sysprops); + assertEquals("techproducts", EnvUtils.getProperty("solr.configset.bootstrap.config.name")); + } + + @Test + public void deprecatedCamelCaseInvertedPropertyIsTranslatedAndValueIsFlipped() { + Properties sysprops = new Properties(); + sysprops.setProperty("solr.disableFingerprint", "true"); + EnvUtils.init(true, Map.of(), sysprops); + assertFalse(EnvUtils.getPropertyAsBool("solr.index.replication.fingerprint.enabled")); + } + @Test public void testFlippingDisabledToEnabledPropertyName() { From d79de34b590ec02ff549cc2d3bbe9bfe50279379 Mon Sep 17 00:00:00 2001 From: Abhishek Umarjikar <35094694+abumarjikar@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:08:29 +0530 Subject: [PATCH 55/69] SOLR-18286: dev-docs/changelog.adoc: document "type" (#4530) Repeat changelog "type" documentation/usage. --- dev-docs/changelog.adoc | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/dev-docs/changelog.adoc b/dev-docs/changelog.adoc index 22b18b196357..bd80fec42929 100644 --- a/dev-docs/changelog.adoc +++ b/dev-docs/changelog.adoc @@ -53,6 +53,41 @@ links: url: https://issues.apache.org/jira/browse/SOLR-3333 ---- +=== Changelog Types Reference + +[cols="1,2,3",options="header"] +|=== +| Type | Description | When to Use (Examples) + +| `added` +| For changes requiring a user to take action to use (opt-in). +| Could be completely new features or simply new configuration values on existing features. Typically documented in the Ref Guide. + +| `changed` +| For improvements; not opt-in. +| Modifying behavior or performance of existing requests/configuration. + +| `fixed` +| For improvements that are deemed to have fixed buggy behavior. +| Fixing a `NullPointerException`, correcting data corruption issues, or resolving UI glitches. + +| `deprecated` +| For marking things deprecated. +| Declaring a configuration option, class, or API endpoint as deprecated ahead of a future removal. + +| `removed` +| For code removed. +| Removing a previously deprecated parameter, class, or legacy module. + +| `dependency_update` +| For updates to dependencies. +| Bumping third-party library versions (e.g., Lucene, Jetty, Jackson). + +| `other` +| For anything else. Most such changes are too small/minor to bother with a changelog entry. +| Large/significant refactorings, build changes, test infrastructure, or documentation. +|=== + === 3.1 Tool to draft a YAML for your change We have a gradle task that bootstraps a YAML file in the `changelog/unreleased/` directory. The task will use your current branch name as a file name and also title, and will From f9f5abf21e972afc67121af36640eec7ac5c63b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=C5=A0ari=C4=87?= Date: Sun, 21 Jun 2026 06:23:27 +0200 Subject: [PATCH 56/69] SOLR-17600: MapSerializable p4: Delete MapSerializable (#4466) MapSerializable is deprecated; stop using it in some places. --- changelog/unreleased/SOLR-17600.yml | 7 ++++ .../apache/solr/common/MapSerializable.java | 33 ------------------- .../apache/solr/common/util/JavaBinCodec.java | 6 ---- .../apache/solr/common/util/TextWriter.java | 5 --- 4 files changed, 7 insertions(+), 44 deletions(-) create mode 100644 changelog/unreleased/SOLR-17600.yml delete mode 100644 solr/solrj/src/java/org/apache/solr/common/MapSerializable.java diff --git a/changelog/unreleased/SOLR-17600.yml b/changelog/unreleased/SOLR-17600.yml new file mode 100644 index 000000000000..e89a70907421 --- /dev/null +++ b/changelog/unreleased/SOLR-17600.yml @@ -0,0 +1,7 @@ +title: Replace MapSerializable with MapWriter +type: other +authors: + - name: Ivan Šarić +links: + - name: SOLR-17600 + url: https://issues.apache.org/jira/browse/SOLR-17600 diff --git a/solr/solrj/src/java/org/apache/solr/common/MapSerializable.java b/solr/solrj/src/java/org/apache/solr/common/MapSerializable.java deleted file mode 100644 index e5d6ebafa92d..000000000000 --- a/solr/solrj/src/java/org/apache/solr/common/MapSerializable.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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.common; - -import java.util.Map; - -/** - * This is to facilitate just in time creation of objects before writing it to the response. - * - * @deprecated Use {@link MapWriter} instead - */ -@Deprecated -public interface MapSerializable { - /** - * Use the passed map to minimize object creation. Do not keep a reference to the passed map and - * reuse it. it may be reused by the framework - */ - Map toMap(Map map); -} diff --git a/solr/solrj/src/java/org/apache/solr/common/util/JavaBinCodec.java b/solr/solrj/src/java/org/apache/solr/common/util/JavaBinCodec.java index dc47f5aa67d4..63e6613ae255 100644 --- a/solr/solrj/src/java/org/apache/solr/common/util/JavaBinCodec.java +++ b/solr/solrj/src/java/org/apache/solr/common/util/JavaBinCodec.java @@ -47,7 +47,6 @@ import org.apache.solr.common.EnumFieldValue; import org.apache.solr.common.IteratorWriter; import org.apache.solr.common.IteratorWriter.ItemWriter; -import org.apache.solr.common.MapSerializable; import org.apache.solr.common.MapWriter; import org.apache.solr.common.PushWriter; import org.apache.solr.common.SolrDocument; @@ -428,11 +427,6 @@ public boolean writeKnownType(Object val) throws IOException { writeMapEntry((Map.Entry) val); return true; } - if (val instanceof MapSerializable) { - // todo find a better way to reuse the map more efficiently - writeMap(((MapSerializable) val).toMap(new NamedList().asShallowMap())); - return true; - } if (val instanceof AtomicInteger) { writeInt(((AtomicInteger) val).get()); return true; diff --git a/solr/solrj/src/java/org/apache/solr/common/util/TextWriter.java b/solr/solrj/src/java/org/apache/solr/common/util/TextWriter.java index 6901b2db5ed8..ea50ab8bbbc7 100644 --- a/solr/solrj/src/java/org/apache/solr/common/util/TextWriter.java +++ b/solr/solrj/src/java/org/apache/solr/common/util/TextWriter.java @@ -27,7 +27,6 @@ import java.util.Collection; import java.util.Date; import java.util.Iterator; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; @@ -38,7 +37,6 @@ import org.apache.solr.client.api.util.ReflectWritable; import org.apache.solr.common.EnumFieldValue; import org.apache.solr.common.IteratorWriter; -import org.apache.solr.common.MapSerializable; import org.apache.solr.common.MapWriter; import org.apache.solr.common.PushWriter; @@ -89,9 +87,6 @@ default void writeVal(String name, Object val, boolean raw) throws IOException { writeMap(name, (MapWriter) val); } else if (val instanceof ReflectWritable) { writeVal(name, Utils.getReflectWriter(val)); - } else if (val instanceof MapSerializable) { - // todo find a better way to reuse the map more efficiently - writeMap(name, ((MapSerializable) val).toMap(new LinkedHashMap<>()), false, true); } else if (val instanceof Map) { writeMap(name, (Map) val, false, true); } else if (val instanceof Collection cval) { // very generic; keep towards the end From 33af79132a69348ec564f538e933e593b378f09b Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Sun, 21 Jun 2026 07:50:33 -0400 Subject: [PATCH 57/69] SOLR-16341: fix blank file zip handling (#4249) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: epugh <22395+epugh@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ...SOLR-16341-fix-blank-file-zip-handling.yml | 8 ++ .../handler/configsets/UploadConfigSet.java | 51 ++++++-- .../apache/solr/cloud/TestConfigSetsAPI.java | 120 ++++++++++++++++++ 3 files changed, 165 insertions(+), 14 deletions(-) create mode 100644 changelog/unreleased/SOLR-16341-fix-blank-file-zip-handling.yml diff --git a/changelog/unreleased/SOLR-16341-fix-blank-file-zip-handling.yml b/changelog/unreleased/SOLR-16341-fix-blank-file-zip-handling.yml new file mode 100644 index 000000000000..69fd1515ce73 --- /dev/null +++ b/changelog/unreleased/SOLR-16341-fix-blank-file-zip-handling.yml @@ -0,0 +1,8 @@ + +title: Support blank/zero-byte files in configset zip uploads +type: fixed +authors: + - name: Eric Pugh +links: + - name: SOLR-16341 + url: https://issues.apache.org/jira/browse/SOLR-16341 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 6728b17ef103..bb9ca94c761a 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 @@ -22,11 +22,15 @@ import java.io.IOException; import java.io.InputStream; import java.lang.invoke.MethodHandles; -import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; +import java.util.Enumeration; import java.util.List; import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; +import java.util.zip.ZipException; +import java.util.zip.ZipFile; import org.apache.solr.client.api.endpoint.ConfigsetsApi; import org.apache.solr.client.api.model.SolrJerseyResponse; import org.apache.solr.client.solrj.util.SolrIdentifierValidator; @@ -85,22 +89,41 @@ public SolrJerseyResponse uploadConfigSet( filesToDelete = new ArrayList<>(); } - try (ZipInputStream zis = new ZipInputStream(requestBody, StandardCharsets.UTF_8)) { - boolean hasEntry = false; - ZipEntry zipEntry; - while ((zipEntry = zis.getNextEntry()) != null) { - hasEntry = true; - String filePath = zipEntry.getName(); - filesToDelete.remove(filePath); - if (!zipEntry.isDirectory()) { - configSetService.uploadFileToConfig(configSetName, filePath, zis.readAllBytes(), true); + // Write the request body to a temp file so we can use ZipFile, which reads the central + // directory and correctly handles entries that use the STORED method with an EXT (data + // descriptor) flag — a combination that ZipInputStream cannot process. This allows + // zero-byte files (e.g. created with `touch`) to be included in the uploaded configset. + final Path tempZip = Files.createTempFile("solr-configset-upload-", ".zip"); + try { + Files.copy(requestBody, tempZip, StandardCopyOption.REPLACE_EXISTING); + try (ZipFile zipFile = new ZipFile(tempZip.toFile())) { + boolean hasEntry = false; + Enumeration entries = zipFile.entries(); + while (entries.hasMoreElements()) { + ZipEntry zipEntry = entries.nextElement(); + hasEntry = true; + String filePath = zipEntry.getName(); + filesToDelete.remove(filePath); + if (!zipEntry.isDirectory()) { + try (InputStream entryStream = zipFile.getInputStream(zipEntry)) { + configSetService.uploadFileToConfig( + configSetName, filePath, entryStream.readAllBytes(), true); + } + } } - } - if (!hasEntry) { + if (!hasEntry) { + throw new SolrException( + SolrException.ErrorCode.BAD_REQUEST, + "Either empty zipped data, or non-zipped data was uploaded. In order to upload a configSet, you must zip a non-empty directory to upload."); + } + } catch (ZipException e) { throw new SolrException( SolrException.ErrorCode.BAD_REQUEST, - "Either empty zipped data, or non-zipped data was uploaded. In order to upload a configSet, you must zip a non-empty directory to upload."); + "Failed to read the uploaded zip file: " + e.getMessage(), + e); } + } finally { + Files.deleteIfExists(tempZip); } deleteUnusedFiles(configSetService, configSetName, filesToDelete); diff --git a/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java b/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java index 6736af93b686..29c17139d2a4 100644 --- a/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java +++ b/solr/core/src/test/org/apache/solr/cloud/TestConfigSetsAPI.java @@ -25,6 +25,7 @@ import jakarta.servlet.http.HttpServletRequestWrapper; import jakarta.servlet.http.HttpServletResponse; import java.io.ByteArrayInputStream; +import java.io.DataOutputStream; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; @@ -1002,6 +1003,32 @@ public void testUploadWithForbiddenContent() throws Exception { assertEquals(400, res); } + @Test + public void testUploadWithBlankFile() throws Exception { + // Uploads a zip containing a blank (0-byte) file using STORED method with an EXT descriptor. + // Java's ZipInputStream cannot read this format, but ZipFile can. + // Verifies the upload succeeds and the empty file is stored in the configset. + final String configSetName = "blank-file-configset"; + final String suffix = "-suffix"; + final Path zipFile = createTempZipWithStoredEntryAndExtDescriptor(); + try (SolrZkClient zkClient = + new SolrZkClient.Builder() + .withUrl(cluster.getZkServer().getZkAddress()) + .withTimeout(AbstractZkTestCase.TIMEOUT, TimeUnit.MILLISECONDS) + .withConnTimeOut(45000, TimeUnit.MILLISECONDS) + .build()) { + long res = uploadGivenConfigSet(zipFile, configSetName, suffix, null, true, false, true); + assertEquals("Upload of configset with blank file should succeed", 0L, res); + assertTrue( + "blank.txt should have been uploaded to the configset", + zkClient.exists("/configs/" + configSetName + suffix + "/blank.txt")); + assertArrayEquals( + "blank.txt in configset should be empty", + new byte[0], + zkClient.getData("/configs/" + configSetName + suffix + "/blank.txt", null, null)); + } + } + @Test public void testGetFile() throws Exception { String configSetName = "regular"; @@ -1331,6 +1358,99 @@ private Path createTempZipFileWithForbiddenContent(String resourcePath) { } } + /** + * Creates a zip file (in the temp directory) containing an empty file entry that uses the STORED + * compression method with the EXT descriptor flag set. Some zip tools produce this format for + * empty (0-byte) files, e.g., when using {@code touch conf/blank.txt} followed by {@code zip -r + * ...}. Java's {@link java.util.zip.ZipInputStream} cannot read this combination, but {@link + * java.util.zip.ZipFile} handles it correctly by reading from the central directory. + */ + private Path createTempZipWithStoredEntryAndExtDescriptor() throws IOException { + final Path zipFile = createTempFile("configset-blank", "zip"); + // Build a valid ZIP file manually with one STORED entry that has the EXT (data descriptor) + // flag set (flag bit 3 = 0x08). Java's ZipInputStream rejects this combination. + // All multi-byte fields are little-endian. + byte[] fileName = "blank.txt".getBytes(UTF_8); + int fileNameLen = fileName.length; // 9 + + // Offsets for computing central directory offset + // Local file header size: 30 + fileNameLen + int localHeaderSize = 30 + fileNameLen; + // Data descriptor size: 16 (with signature) + int dataDescriptorSize = 16; + // Central directory header size: 46 + fileNameLen + int centralDirHeaderSize = 46 + fileNameLen; + int centralDirOffset = localHeaderSize + dataDescriptorSize; // = 55 + + try (DataOutputStream dos = new DataOutputStream(Files.newOutputStream(zipFile))) { + // --- Local file header --- + dos.write(new byte[] {0x50, 0x4b, 0x03, 0x04}); // signature PK\x03\x04 + dos.write(new byte[] {0x14, 0x00}); // version needed = 20 + dos.write(new byte[] {0x08, 0x00}); // flag: bit 3 (data descriptor / EXT) + dos.write(new byte[] {0x00, 0x00}); // compression method: STORED + dos.write(new byte[] {0x00, 0x00}); // last mod time + dos.write(new byte[] {0x00, 0x00}); // last mod date + dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // CRC-32 (0, deferred to data descriptor) + dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // compressed size (deferred) + dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // uncompressed size (deferred) + dos.write(new byte[] {(byte) fileNameLen, 0x00}); // file name length + dos.write(new byte[] {0x00, 0x00}); // extra field length + dos.write(fileName); // file name "blank.txt" + // (no file data — the file is empty) + + // --- Data descriptor (EXT record) --- + dos.write(new byte[] {0x50, 0x4b, 0x07, 0x08}); // signature PK\x07\x08 + dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // CRC-32 (0 for empty file) + dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // compressed size + dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // uncompressed size + + // --- Central directory header --- + dos.write(new byte[] {0x50, 0x4b, 0x01, 0x02}); // signature PK\x01\x02 + dos.write(new byte[] {0x14, 0x00}); // version made by + dos.write(new byte[] {0x14, 0x00}); // version needed + dos.write(new byte[] {0x08, 0x00}); // flag (same as local header) + dos.write(new byte[] {0x00, 0x00}); // compression method: STORED + dos.write(new byte[] {0x00, 0x00}); // last mod time + dos.write(new byte[] {0x00, 0x00}); // last mod date + dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // CRC-32 + dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // compressed size + dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // uncompressed size + dos.write(new byte[] {(byte) fileNameLen, 0x00}); // file name length + dos.write(new byte[] {0x00, 0x00}); // extra field length + dos.write(new byte[] {0x00, 0x00}); // file comment length + dos.write(new byte[] {0x00, 0x00}); // disk number start + dos.write(new byte[] {0x00, 0x00}); // internal file attributes + dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // external file attributes + dos.write(new byte[] {0x00, 0x00, 0x00, 0x00}); // local header relative offset (= 0) + dos.write(fileName); // file name "blank.txt" + + // --- End of central directory record --- + dos.write(new byte[] {0x50, 0x4b, 0x05, 0x06}); // signature PK\x05\x06 + dos.write(new byte[] {0x00, 0x00}); // disk number + dos.write(new byte[] {0x00, 0x00}); // disk with start of central directory + dos.write(new byte[] {0x01, 0x00}); // entries on this disk + dos.write(new byte[] {0x01, 0x00}); // total entries + // size of central directory + dos.write( + new byte[] { + (byte) (centralDirHeaderSize & 0xFF), + (byte) ((centralDirHeaderSize >> 8) & 0xFF), + (byte) ((centralDirHeaderSize >> 16) & 0xFF), + (byte) ((centralDirHeaderSize >> 24) & 0xFF) + }); + // offset of central directory + dos.write( + new byte[] { + (byte) (centralDirOffset & 0xFF), + (byte) ((centralDirOffset >> 8) & 0xFF), + (byte) ((centralDirOffset >> 16) & 0xFF), + (byte) ((centralDirOffset >> 24) & 0xFF) + }); + dos.write(new byte[] {0x00, 0x00}); // comment length + } + return zipFile; + } + private static void zipWithForbiddenContent(Path directory, Path zipfile) throws IOException { OutputStream out = Files.newOutputStream(zipfile); assertTrue(Files.isDirectory(directory)); From 95f7c1682b9ba2f1571518d557e12970fc404b24 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Sun, 21 Jun 2026 09:45:50 -0400 Subject: [PATCH 58/69] Use the http verbs we have to be more restful and simplify urls --- .../solr/client/api/endpoint/SchemaDesignerApi.java | 4 ++-- .../solr/handler/designer/TestSchemaDesigner.java | 12 ++++++------ .../handler/designer/TestSchemaDesignerSolrJ.java | 10 +++++----- .../web/js/angular/controllers/schema-designer.js | 3 --- 4 files changed, 13 insertions(+), 16 deletions(-) diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index 09c66395e068..b08f59ead939 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -117,7 +117,7 @@ SchemaDesignerCollectionsResponse listCollectionsForConfig( SchemaDesignerConfigsResponse listConfigs() throws Exception; @POST - @Path("/{configSet}/add") + @Path("/{configSet}") @Operation( summary = "Add a new field, field type, or dynamic field to the schema being designed.", tags = {"schema-designer"}) @@ -128,7 +128,7 @@ SchemaDesignerResponse addSchemaObject( throws Exception; @PUT - @Path("/{configSet}/update") + @Path("/{configSet}") @Operation( summary = "Update an existing field or field type in the schema being designed.", tags = {"schema-designer"}) diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java index 7ca23b5392d4..6d1ec5c6b73e 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java @@ -449,7 +449,7 @@ public void testBasicUserWorkflow() throws Exception { // editing suggestions for fields and adding/removing fields / field types as needed // add a new field - // POST /schema-designer/add + // POST /schema-designer/{configSet} response = schemaDesigner.addSchemaObject( configSet, schemaVersion, loadAddBody("schema-designer/add-new-field.json")); @@ -460,7 +460,7 @@ public void testBasicUserWorkflow() throws Exception { // update an existing field // switch a single-valued field to a multivalued field, which triggers a full rebuild of the // "temp" collection - // PUT /schema-designer/update + // PUT /schema-designer/{configSet} response = schemaDesigner.updateSchemaObject( configSet, schemaVersion, loadUpdateBody("schema-designer/update-author-field.json")); @@ -468,7 +468,7 @@ public void testBasicUserWorkflow() throws Exception { schemaVersion = response.schemaVersion; // add a new type - // POST /schema-designer/add + // POST /schema-designer/{configSet} response = schemaDesigner.addSchemaObject( configSet, schemaVersion, loadAddBody("schema-designer/add-new-type.json")); @@ -546,7 +546,7 @@ public void testFieldUpdates() throws Exception { int schemaVersion = response.schemaVersion; // add our test field that we'll test various updates to - // POST /schema-designer/add + // POST /schema-designer/{configSet} response = schemaDesigner.addSchemaObject( configSet, schemaVersion, loadAddBody("schema-designer/add-new-field.json")); @@ -667,7 +667,7 @@ public void testSchemaDiffEndpoint() throws Exception { // Add a new field schemaVersion = response.schemaVersion; - // POST /schema-designer/add + // POST /schema-designer/{configSet} response = schemaDesigner.addSchemaObject( configSet, schemaVersion, loadAddBody("schema-designer/add-new-field.json")); @@ -675,7 +675,7 @@ public void testSchemaDiffEndpoint() throws Exception { // Add a new field type schemaVersion = response.schemaVersion; - // POST /schema-designer/add + // POST /schema-designer/{configSet} response = schemaDesigner.addSchemaObject( configSet, schemaVersion, loadAddBody("schema-designer/add-new-type.json")); diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java index 781b06c9a3aa..a067f194e81e 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java @@ -72,7 +72,7 @@ public void testTypedBodyRoundTrip() throws Exception { assertEquals(configSet, prep.configSet); int schemaVersion = prep.schemaVersion; - // POST /add — addField — exercises kebab-case @JsonProperty("add-field") / @Schema(name=…) + // POST /{configSet} — addField — exercises kebab-case @JsonProperty("add-field") / @Schema(name=…) var addField = new SchemaDesignerApi.AddSchemaObject(configSet); addField.setSchemaVersion(schemaVersion); addField.setAddField(Map.of("name", "keywords", "type", "string", "stored", true)); @@ -80,7 +80,7 @@ public void testTypedBodyRoundTrip() throws Exception { assertEquals("keywords", addFieldResp.field); schemaVersion = addFieldResp.schemaVersion; - // POST /add — addFieldType — covers a different wrapper key + // POST /{configSet} — addFieldType — covers a different wrapper key var addType = new SchemaDesignerApi.AddSchemaObject(configSet); addType.setSchemaVersion(schemaVersion); addType.setAddFieldType( @@ -95,7 +95,7 @@ public void testTypedBodyRoundTrip() throws Exception { assertEquals("smoke_txt", addTypeResp.fieldType); schemaVersion = addTypeResp.schemaVersion; - // POST /add — addDynamicField + // POST /{configSet} — addDynamicField var addDyn = new SchemaDesignerApi.AddSchemaObject(configSet); addDyn.setSchemaVersion(schemaVersion); addDyn.setAddDynamicField(Map.of("name", "*_smoke", "type", "string")); @@ -103,7 +103,7 @@ public void testTypedBodyRoundTrip() throws Exception { assertEquals("*_smoke", addDynResp.dynamicField); schemaVersion = addDynResp.schemaVersion; - // POST /add — addCopyField — verifies the explicit no-op response branch in + // POST /{configSet} — addCopyField — verifies the explicit no-op response branch in // setSchemaObjectField (no field/type/dynamicField/fieldType is populated) var addCopy = new SchemaDesignerApi.AddSchemaObject(configSet); addCopy.setSchemaVersion(schemaVersion); @@ -114,7 +114,7 @@ public void testTypedBodyRoundTrip() throws Exception { assertNull(addCopyResp.dynamicField); schemaVersion = addCopyResp.schemaVersion; - // PUT /update — exercises the @JsonAnyGetter/@JsonAnySetter capture for arbitrary attrs + // PUT /{configSet} — exercises the @JsonAnyGetter/@JsonAnySetter capture for arbitrary attrs var update = new SchemaDesignerApi.UpdateSchemaObject(configSet); update.setSchemaVersion(schemaVersion); update.setName("keywords"); diff --git a/solr/webapp/web/js/angular/controllers/schema-designer.js b/solr/webapp/web/js/angular/controllers/schema-designer.js index 070ceb844f4f..3d9e756cb932 100644 --- a/solr/webapp/web/js/angular/controllers/schema-designer.js +++ b/solr/webapp/web/js/angular/controllers/schema-designer.js @@ -656,7 +656,6 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, delete $scope.addErrors; // no errors! SchemaDesigner.post({ - path: "add", configSet: $scope.currentSchema, schemaVersion: $scope.schemaVersion }, addData, function (data) { @@ -791,7 +790,6 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, delete $scope.addCopyFieldErrors; var data = {"add-copy-field": $scope.copyField}; SchemaDesigner.post({ - path: "add", configSet: $scope.currentSchema, schemaVersion: $scope.schemaVersion }, data, function (data) { @@ -1389,7 +1387,6 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, $scope.updateStatusMessage = "Updating " + $scope.selectedType + " ..."; SchemaDesigner.put({ - path: "update", configSet: $scope.currentSchema, schemaVersion: $scope.schemaVersion }, putData, function (data) { From 1b79855780558829efdf09e6d5adfc8c78593684 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Sun, 21 Jun 2026 09:47:28 -0400 Subject: [PATCH 59/69] Use the http verbs we have to be more restful and simplify urls --- .../apache/solr/client/api/endpoint/SchemaDesignerApi.java | 2 +- .../org/apache/solr/handler/designer/TestSchemaDesigner.java | 4 ++-- .../apache/solr/handler/designer/TestSchemaDesignerSolrJ.java | 2 +- solr/webapp/web/js/angular/controllers/schema-designer.js | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index b08f59ead939..a62165a64ea9 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -48,7 +48,7 @@ public interface SchemaDesignerApi { @GET - @Path("/{configSet}/info") + @Path("/{configSet}") @Operation( summary = "Get info about a configSet being designed.", tags = {"schema-designer"}) diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java index 6d1ec5c6b73e..799d86f30880 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java @@ -159,7 +159,7 @@ public void testAddTechproductsProgressively() throws Exception { String configSet = "techproducts"; - // GET /schema-designer/info + // GET /schema-designer/{configSet} SchemaDesignerInfoResponse infoResponse = schemaDesigner.getInfo(configSet); // response should just be the default values Map expSettings = @@ -206,7 +206,7 @@ public void testAddTechproductsProgressively() throws Exception { } // get info (from the temp) - // GET /schema-designer/info + // GET /schema-designer/{configSet} infoResponse = schemaDesigner.getInfo(configSet); expSettings = Map.of( diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java index a067f194e81e..e152da7f3836 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java @@ -123,7 +123,7 @@ public void testTypedBodyRoundTrip() throws Exception { assertNotNull(updateResp.field); assertEquals("field", updateResp.updateType); - // GET /info — round-trips a typed response that extends SchemaDesignerSettingsResponse + // GET /{configSet} — round-trips a typed response that extends SchemaDesignerSettingsResponse SchemaDesignerInfoResponse info = new SchemaDesignerApi.GetInfo(configSet).process(cluster.getSolrClient()); assertEquals(configSet, info.configSet); diff --git a/solr/webapp/web/js/angular/controllers/schema-designer.js b/solr/webapp/web/js/angular/controllers/schema-designer.js index 3d9e756cb932..4d26f4cff883 100644 --- a/solr/webapp/web/js/angular/controllers/schema-designer.js +++ b/solr/webapp/web/js/angular/controllers/schema-designer.js @@ -239,7 +239,7 @@ solrAdminApp.controller('SchemaDesignerController', function ($scope, $timeout, } $scope.resetSchema(); - var params = {path: "info", configSet: $scope.currentSchema}; + var params = {configSet: $scope.currentSchema}; SchemaDesigner.get(params, function (data) { $scope.currentSchema = data.configSet; $("#select-schema").trigger("chosen:updated"); From 334d1fe9fe609634583d4511072abd1679ffe596 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Sun, 21 Jun 2026 10:09:43 -0400 Subject: [PATCH 60/69] better name. This api is not currently used by any callers. --- .../apache/solr/client/api/endpoint/SchemaDesignerApi.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index a62165a64ea9..50a2111199a5 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -101,8 +101,10 @@ FlexibleSolrJerseyResponse getSampleValue( @QueryParam("docId") String docId) throws Exception; + // TODO: this sub-resource belongs in ConfigsetsApi as GET /configsets/{configSetName}/collections; + // move it there in a follow-up so it is reusable outside the schema designer. @GET - @Path("/{configSet}/collectionsForConfig") + @Path("/{configSet}/collections") @Operation( summary = "List collections that use a given configSet.", tags = {"schema-designer"}) From b38c55199674b9e400fa4e34f01ae0cf6788840d Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Sun, 21 Jun 2026 10:22:12 -0400 Subject: [PATCH 61/69] Description makes clear that this passes lots of solr query params through. --- .../apache/solr/client/api/endpoint/SchemaDesignerApi.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index 50a2111199a5..1e27d232eab3 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -101,7 +101,8 @@ FlexibleSolrJerseyResponse getSampleValue( @QueryParam("docId") String docId) throws Exception; - // TODO: this sub-resource belongs in ConfigsetsApi as GET /configsets/{configSetName}/collections; + // TODO: this sub-resource belongs in ConfigsetsApi as GET + // /configsets/{configSetName}/collections; // move it there in a follow-up so it is reusable outside the schema designer. @GET @Path("/{configSet}/collections") @@ -189,6 +190,10 @@ SchemaDesignerResponse analyze( @Path("/{configSet}/query") @Operation( summary = "Query the temporary collection used during schema design.", + description = + "All standard Solr query parameters (q, fq, fl, sort, facet.*, hl.*, etc.) are" + + " forwarded directly to the temporary collection. The configSet path parameter" + + " identifies which designer session to query; it is not a query parameter itself.", tags = {"schema-designer"}) FlexibleSolrJerseyResponse query(@PathParam("configSet") String configSet) throws Exception; From 37a4c2343893c359f467efbe19edce4f86bbe289 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Sun, 21 Jun 2026 10:24:21 -0400 Subject: [PATCH 62/69] try to make clear what we are doing. --- .../apache/solr/handler/designer/TestSchemaDesignerSolrJ.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java index e152da7f3836..e3a95a8debd0 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesignerSolrJ.java @@ -72,7 +72,9 @@ public void testTypedBodyRoundTrip() throws Exception { assertEquals(configSet, prep.configSet); int schemaVersion = prep.schemaVersion; - // POST /{configSet} — addField — exercises kebab-case @JsonProperty("add-field") / @Schema(name=…) + // POST /{configSet} — addField — verifies the dual-annotation pair: @Schema(name="addField") + // generates a camelCase SolrJ setter, while @JsonProperty("add-field") serializes it to + // kebab-case on the wire. Both must be correct for the round-trip to succeed. var addField = new SchemaDesignerApi.AddSchemaObject(configSet); addField.setSchemaVersion(schemaVersion); addField.setAddField(Map.of("name", "keywords", "type", "string", "stored", true)); From c5a9423c4294e51f50503b21904ed65a1997c33c Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Sun, 21 Jun 2026 10:32:08 -0400 Subject: [PATCH 63/69] Reuse a standard response class! --- .../api/endpoint/SchemaDesignerApi.java | 6 ++--- .../SchemaDesignerCollectionsResponse.java | 27 ------------------- .../solr/handler/designer/SchemaDesigner.java | 7 +++-- .../handler/designer/TestSchemaDesigner.java | 8 +++--- 4 files changed, 9 insertions(+), 39 deletions(-) delete mode 100644 solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerCollectionsResponse.java diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index 1e27d232eab3..0626f2eaf3ae 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -33,8 +33,8 @@ import java.io.InputStream; import java.util.List; import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; +import org.apache.solr.client.api.model.ListCollectionsResponse; import org.apache.solr.client.api.model.SchemaDesignerAddRequestBody; -import org.apache.solr.client.api.model.SchemaDesignerCollectionsResponse; import org.apache.solr.client.api.model.SchemaDesignerConfigsResponse; import org.apache.solr.client.api.model.SchemaDesignerInfoResponse; import org.apache.solr.client.api.model.SchemaDesignerPublishResponse; @@ -109,8 +109,8 @@ FlexibleSolrJerseyResponse getSampleValue( @Operation( summary = "List collections that use a given configSet.", tags = {"schema-designer"}) - SchemaDesignerCollectionsResponse listCollectionsForConfig( - @PathParam("configSet") String configSet) throws Exception; + ListCollectionsResponse listCollectionsForConfig(@PathParam("configSet") String configSet) + throws Exception; @GET @Path("/configs") diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerCollectionsResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerCollectionsResponse.java deleted file mode 100644 index 2e0d31a27243..000000000000 --- a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerCollectionsResponse.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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; -import java.util.List; - -/** Response body for the Schema Designer list-collections-for-config endpoint. */ -public class SchemaDesignerCollectionsResponse extends SolrJerseyResponse { - - @JsonProperty("collections") - public List collections; -} diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index 068959645de3..ad29e49ef845 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -50,8 +50,8 @@ import org.apache.solr.api.JerseyResource; import org.apache.solr.client.api.endpoint.SchemaDesignerApi; import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; +import org.apache.solr.client.api.model.ListCollectionsResponse; import org.apache.solr.client.api.model.SchemaDesignerAddRequestBody; -import org.apache.solr.client.api.model.SchemaDesignerCollectionsResponse; import org.apache.solr.client.api.model.SchemaDesignerConfigsResponse; import org.apache.solr.client.api.model.SchemaDesignerInfoResponse; import org.apache.solr.client.api.model.SchemaDesignerPublishResponse; @@ -364,10 +364,9 @@ public FlexibleSolrJerseyResponse getSampleValue( @Override @PermissionName(CONFIG_READ_PERM) - public SchemaDesignerCollectionsResponse listCollectionsForConfig(String configSet) { + public ListCollectionsResponse listCollectionsForConfig(String configSet) { requireNotEmpty(CONFIG_SET_PARAM, configSet); - SchemaDesignerCollectionsResponse response = - instantiateJerseyResponse(SchemaDesignerCollectionsResponse.class); + ListCollectionsResponse response = instantiateJerseyResponse(ListCollectionsResponse.class); response.collections = configSetHelper.listCollectionsForConfig(configSet); return response; } diff --git a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java index 799d86f30880..742908ec638f 100644 --- a/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java +++ b/solr/core/src/test/org/apache/solr/handler/designer/TestSchemaDesigner.java @@ -36,8 +36,8 @@ import java.util.Optional; import java.util.stream.Stream; import org.apache.solr.client.api.model.FlexibleSolrJerseyResponse; +import org.apache.solr.client.api.model.ListCollectionsResponse; import org.apache.solr.client.api.model.SchemaDesignerAddRequestBody; -import org.apache.solr.client.api.model.SchemaDesignerCollectionsResponse; import org.apache.solr.client.api.model.SchemaDesignerInfoResponse; import org.apache.solr.client.api.model.SchemaDesignerResponse; import org.apache.solr.client.api.model.SchemaDesignerSchemaDiffResponse; @@ -244,8 +244,7 @@ public void testAddTechproductsProgressively() throws Exception { assertNotNull(cc.getZkController().zkStateReader.getCollection(collection)); // listCollectionsForConfig - SchemaDesignerCollectionsResponse collectionsResp = - schemaDesigner.listCollectionsForConfig(configSet); + ListCollectionsResponse collectionsResp = schemaDesigner.listCollectionsForConfig(configSet); List collections = collectionsResp.collections; assertNotNull(collections); assertTrue(collections.contains(collection)); @@ -516,8 +515,7 @@ public void testBasicUserWorkflow() throws Exception { assertNotNull(cc.getZkController().zkStateReader.getCollection(collection)); // listCollectionsForConfig - SchemaDesignerCollectionsResponse collectionsResp2 = - schemaDesigner.listCollectionsForConfig(configSet); + ListCollectionsResponse collectionsResp2 = schemaDesigner.listCollectionsForConfig(configSet); List collections = collectionsResp2.collections; assertNotNull(collections); assertTrue(collections.contains(collection)); From 2536c8cac1fff27ebaa0e682b0b8df4133254007 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Sun, 21 Jun 2026 10:34:44 -0400 Subject: [PATCH 64/69] This is set globally, not needed on this. --- .../solr/client/api/model/SchemaDesignerInfoResponse.java | 2 -- .../solr/client/api/model/SchemaDesignerPublishResponse.java | 2 -- .../apache/solr/client/api/model/SchemaDesignerResponse.java | 4 ---- .../client/api/model/SchemaDesignerSchemaDiffResponse.java | 2 -- .../solr/client/api/model/SchemaDesignerSettingsResponse.java | 2 -- 5 files changed, 12 deletions(-) diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerInfoResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerInfoResponse.java index 528f426c0abe..2e77b7066aed 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerInfoResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerInfoResponse.java @@ -16,12 +16,10 @@ */ package org.apache.solr.client.api.model; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; /** Response body for the Schema Designer get-info endpoint. */ -@JsonInclude(JsonInclude.Include.NON_NULL) public class SchemaDesignerInfoResponse extends SchemaDesignerSettingsResponse { @JsonProperty("configSet") diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerPublishResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerPublishResponse.java index b6d7038ff2fa..c04ab9e76f23 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerPublishResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerPublishResponse.java @@ -16,11 +16,9 @@ */ package org.apache.solr.client.api.model; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; /** Response body for the Schema Designer publish endpoint. */ -@JsonInclude(JsonInclude.Include.NON_NULL) public class SchemaDesignerPublishResponse extends SolrJerseyResponse { @JsonProperty("configSet") diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java index f7e3325d9f83..ac6e585dbfc0 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerResponse.java @@ -16,7 +16,6 @@ */ package org.apache.solr.client.api.model; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; import java.util.Map; @@ -25,10 +24,7 @@ * Response body for Schema Designer endpoints that operate on a full schema: {@code prepNewSchema}, * {@code updateFileContents}, {@code addSchemaObject}, {@code updateSchemaObject}, and {@code * analyze}. - * - *

All nullable fields are omitted from JSON output when null. */ -@JsonInclude(JsonInclude.Include.NON_NULL) public class SchemaDesignerResponse extends SchemaDesignerSettingsResponse { // --- core schema identification --- diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java index dfe60441117b..3b6b2015c30f 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSchemaDiffResponse.java @@ -16,12 +16,10 @@ */ package org.apache.solr.client.api.model; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Map; /** Response body for the Schema Designer get-schema-diff endpoint. */ -@JsonInclude(JsonInclude.Include.NON_NULL) public class SchemaDesignerSchemaDiffResponse extends SchemaDesignerSettingsResponse { /** The list of field-level differences between the designed schema and the source. */ diff --git a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSettingsResponse.java b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSettingsResponse.java index 7f695ef48a42..bc3d4a3a4020 100644 --- a/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSettingsResponse.java +++ b/solr/api/src/java/org/apache/solr/client/api/model/SchemaDesignerSettingsResponse.java @@ -16,12 +16,10 @@ */ package org.apache.solr.client.api.model; -import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; /** Base response for Schema Designer endpoints that surface the designer settings. */ -@JsonInclude(JsonInclude.Include.NON_NULL) public abstract class SchemaDesignerSettingsResponse extends SolrJerseyResponse { @JsonProperty("languages") From d20488ac7047b9a3c047b79651ec6e36d91a60bf Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Sun, 21 Jun 2026 10:39:58 -0400 Subject: [PATCH 65/69] Update this code to match what improvements had happened in seperate PR and landed in main. --- .../handler/configsets/DownloadConfigSet.java | 20 +++------------- .../configsets/DownloadConfigSetAPITest.java | 24 +------------------ 2 files changed, 4 insertions(+), 40 deletions(-) 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 index 5ea6d6be3208..729aaf00d914 100644 --- a/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java +++ b/solr/core/src/java/org/apache/solr/handler/configsets/DownloadConfigSet.java @@ -63,34 +63,20 @@ public Response downloadConfigSet(String configSetName) throws Exception { throw new SolrException( SolrException.ErrorCode.NOT_FOUND, "ConfigSet " + configSetName + " not found!"); } - return buildZipResponse(configSetService, configSetName, deriveDisplayName(configSetName)); - } - - // This is to support the schema designer's internal name and - // lets us not duplicate the download endpoint. - static String deriveDisplayName(String configSetName) { - if (configSetName.startsWith("._designer_")) { - return configSetName.substring("._designer_".length()); - } - return configSetName; + return buildZipResponse(configSetService, configSetName); } /** * Build a ZIP download {@link Response} for the given configset. * * @param configSetService the service to use for downloading the configset files - * @param configSetName the name of the configset to download (internal id) - * @param displayName the sanitized name to use in the Content-Disposition filename + * @param configSetName the name of the configset to download */ - public static Response buildZipResponse( - ConfigSetService configSetService, String configSetName, String displayName) + public static Response buildZipResponse(ConfigSetService configSetService, String configSetName) throws IOException { final byte[] zipBytes = zipConfigSet(configSetService, configSetName); - 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(); } 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 index 7ae288e7fdaa..31bfd1e97d83 100644 --- a/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java +++ b/solr/core/src/test/org/apache/solr/handler/configsets/DownloadConfigSetAPITest.java @@ -89,29 +89,7 @@ public void testSuccessfulDownloadReturnsZipResponse() throws Exception { try (final Response response = api.downloadConfigSet("myconfig")) { assertEquals(200, response.getStatus()); assertEquals("application/zip", response.getMediaType().toString()); - assertEquals( - "attachment; filename=\"myconfig_configset.zip\"", - response.getHeaderString("Content-Disposition")); + assertNull(response.getHeaderString("Content-Disposition")); } } - - @Test - public void testDesignerPrefixStrippedFromFilename() throws Exception { - createConfigSet("._designer_myschema", "solrconfig.xml", ""); - - final var api = new DownloadConfigSet(mockCoreContainer, null, null); - try (final Response response = api.downloadConfigSet("._designer_myschema")) { - assertEquals(200, response.getStatus()); - assertEquals( - "attachment; filename=\"myschema_configset.zip\"", - response.getHeaderString("Content-Disposition")); - } - } - - @Test - public void testDeriveDisplayName() { - assertEquals("myschema", DownloadConfigSet.deriveDisplayName("._designer_myschema")); - assertEquals("plain", DownloadConfigSet.deriveDisplayName("plain")); - assertEquals("", DownloadConfigSet.deriveDisplayName("._designer_")); - } } From 38ef16869717767dd440c588a40c45f447083335 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Sun, 21 Jun 2026 10:45:49 -0400 Subject: [PATCH 66/69] make clearer how this listing of /configs is different then the ConfigSetsAPI /configs. --- .../apache/solr/client/api/endpoint/SchemaDesignerApi.java | 7 ++++++- .../org/apache/solr/handler/designer/SchemaDesigner.java | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java index 0626f2eaf3ae..5a4d4af6fdc0 100644 --- a/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java +++ b/solr/api/src/java/org/apache/solr/client/api/endpoint/SchemaDesignerApi.java @@ -116,8 +116,13 @@ ListCollectionsResponse listCollectionsForConfig(@PathParam("configSet") String @Path("/configs") @Operation( summary = "List all configSets available for schema design.", + description = + "Returns a filtered, de-duplicated view of configSets enriched with a per-configSet" + + " status: 0 = draft only (no published version yet), 1 = published but schema" + + " designer is disabled for it, 2 = published and designer is enabled. Internal" + + " mutable copies (._designer_ prefix) and the default configSet are excluded.", tags = {"schema-designer"}) - SchemaDesignerConfigsResponse listConfigs() throws Exception; + SchemaDesignerConfigsResponse listDesignerConfigs() throws Exception; @POST @Path("/{configSet}") diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index ad29e49ef845..ab65ef6a0cc6 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -375,7 +375,7 @@ public ListCollectionsResponse listCollectionsForConfig(String configSet) { // user has access to the Schema Designer UI @Override @PermissionName(CONFIG_EDIT_PERM) - public SchemaDesignerConfigsResponse listConfigs() throws Exception { + public SchemaDesignerConfigsResponse listDesignerConfigs() throws Exception { SchemaDesignerConfigsResponse response = instantiateJerseyResponse(SchemaDesignerConfigsResponse.class); response.configSets = listEnabledConfigs(); From c788e9540648847a170c123f962274868af43fba Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Sun, 21 Jun 2026 10:50:38 -0400 Subject: [PATCH 67/69] Make code more robust sure, but is it needed? --- .../solr/handler/designer/SchemaDesigner.java | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index ab65ef6a0cc6..567d7e80c702 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -540,6 +540,15 @@ public SchemaDesignerPublishResponse publish( requireSchemaVersion(schemaVersion); final String mutableId = checkMutable(configSet, schemaVersion); + // @DefaultValue on the interface only fires through JAX-RS injection; direct Java callers + // bypass it, so we resolve here to guard against NPE on unboxing below. + final boolean doReloadCollections = Objects.requireNonNullElse(reloadCollections, false); + final int shards = Objects.requireNonNullElse(numShards, 1); + final int replicas = Objects.requireNonNullElse(replicationFactor, 1); + final boolean doIndexToCollection = Objects.requireNonNullElse(indexToCollection, false); + final boolean doCleanupTemp = Objects.requireNonNullElse(cleanupTempParam, true); + final boolean doDisableDesigner = Objects.requireNonNullElse(disableDesigner, false); + // verify the configSet we're going to apply changes to hasn't been changed since being loaded // for // editing by the schema designer @@ -581,7 +590,7 @@ && zkStateReader().getClusterState().hasCollection(newCollection)) { copyConfig(mutableId, configSet); } - if (reloadCollections) { + if (doReloadCollections) { log.debug("Reloading collections after update to configSet: {}", configSet); List collectionsForConfig = configSetHelper.listCollectionsForConfig(configSet); CloudSolrClient csc = cloudClient(); @@ -593,8 +602,8 @@ && zkStateReader().getClusterState().hasCollection(newCollection)) { // create new collection Map errorsDuringIndexing = null; if (StrUtils.isNotNullOrEmpty(newCollection)) { - configSetHelper.createCollection(newCollection, configSet, numShards, replicationFactor); - if (indexToCollection) { + configSetHelper.createCollection(newCollection, configSet, shards, replicas); + if (doIndexToCollection) { List docs = configSetHelper.retrieveSampleDocs(configSet); if (!docs.isEmpty()) { ManagedIndexSchema schema = loadLatestSchema(mutableId); @@ -604,7 +613,7 @@ && zkStateReader().getClusterState().hasCollection(newCollection)) { } } - if (cleanupTempParam) { + if (doCleanupTemp) { try { doCleanupTemp(configSet); } catch (IOException | SolrServerException | SolrException exc) { @@ -613,7 +622,7 @@ && zkStateReader().getClusterState().hasCollection(newCollection)) { } } - settings.setDisabled(disableDesigner); + settings.setDisabled(doDisableDesigner); settingsDAO.persistIfChanged(configSet, settings); SchemaDesignerPublishResponse response = From c4256e654aabb0bd92ad08a333e9d769ce3b6df1 Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Sun, 21 Jun 2026 10:54:52 -0400 Subject: [PATCH 68/69] Explain what the heck is going on in this method! --- .../java/org/apache/solr/handler/designer/SchemaDesigner.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index 567d7e80c702..32e28f5eb2b6 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -823,6 +823,10 @@ public FlexibleSolrJerseyResponse query(String configSet) throws Exception { } if (errorsDuringIndexing != null) { + // Re-indexing failed, so the temp collection may have partial or no data — running the query + // would return misleading results. Return the indexing errors inline instead of proceeding. + // FlexibleSolrJerseyResponse is used (rather than throwing) because the UI reads error fields + // from the response body rather than relying on HTTP status codes. Map errorResponse = new HashMap<>(); addErrorToResponse( mutableId, From 483292273d05f3b841809d2c74e819b078ab1adf Mon Sep 17 00:00:00 2001 From: Eric Pugh Date: Sun, 21 Jun 2026 10:57:29 -0400 Subject: [PATCH 69/69] Suggestion from Jason on use of var.. I am not a var guy, but okay ;-). --- .../solr/handler/designer/SchemaDesigner.java | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java index 32e28f5eb2b6..34f85b4269c2 100644 --- a/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java +++ b/solr/core/src/java/org/apache/solr/handler/designer/SchemaDesigner.java @@ -166,8 +166,7 @@ static String getMutableId(final String configSet) { public SchemaDesignerInfoResponse getInfo(String configSet) throws Exception { requireNotEmpty(CONFIG_SET_PARAM, configSet); - SchemaDesignerInfoResponse response = - instantiateJerseyResponse(SchemaDesignerInfoResponse.class); + final var response = instantiateJerseyResponse(SchemaDesignerInfoResponse.class); response.configSet = configSet; boolean exists = configExists(configSet); response.published = exists; @@ -269,8 +268,7 @@ public SchemaDesignerResponse updateFileContents( // solrconfig.xml update failed, but haven't impacted the configSet yet, so just return the // error directly Throwable causedBy = SolrException.getRootCause(updateFileError); - SchemaDesignerResponse errorResponse = - instantiateJerseyResponse(SchemaDesignerResponse.class); + final var errorResponse = instantiateJerseyResponse(SchemaDesignerResponse.class); errorResponse.updateFileError = causedBy.getMessage(); errorResponse.fileContent = new String(data, StandardCharsets.UTF_8); return errorResponse; @@ -366,7 +364,7 @@ public FlexibleSolrJerseyResponse getSampleValue( @PermissionName(CONFIG_READ_PERM) public ListCollectionsResponse listCollectionsForConfig(String configSet) { requireNotEmpty(CONFIG_SET_PARAM, configSet); - ListCollectionsResponse response = instantiateJerseyResponse(ListCollectionsResponse.class); + final var response = instantiateJerseyResponse(ListCollectionsResponse.class); response.collections = configSetHelper.listCollectionsForConfig(configSet); return response; } @@ -376,8 +374,7 @@ public ListCollectionsResponse listCollectionsForConfig(String configSet) { @Override @PermissionName(CONFIG_EDIT_PERM) public SchemaDesignerConfigsResponse listDesignerConfigs() throws Exception { - SchemaDesignerConfigsResponse response = - instantiateJerseyResponse(SchemaDesignerConfigsResponse.class); + final var response = instantiateJerseyResponse(SchemaDesignerConfigsResponse.class); response.configSets = listEnabledConfigs(); return response; } @@ -625,8 +622,7 @@ && zkStateReader().getClusterState().hasCollection(newCollection)) { settings.setDisabled(doDisableDesigner); settingsDAO.persistIfChanged(configSet, settings); - SchemaDesignerPublishResponse response = - instantiateJerseyResponse(SchemaDesignerPublishResponse.class); + final var response = instantiateJerseyResponse(SchemaDesignerPublishResponse.class); response.configSet = configSet; response.schemaVersion = configSetHelper.getCurrentSchemaVersion(configSet); if (StrUtils.isNotNullOrEmpty(newCollection)) { @@ -873,8 +869,7 @@ public SchemaDesignerSchemaDiffResponse getSchemaDiff(String configSet) throws E SchemaDesignerSettings settings = getMutableSchemaForConfigSet(configSet, -1, null); // diff the published if found, else use the original source schema String sourceSchema = configExists(configSet) ? configSet : settings.getCopyFrom(); - SchemaDesignerSchemaDiffResponse response = - instantiateJerseyResponse(SchemaDesignerSchemaDiffResponse.class); + final var response = instantiateJerseyResponse(SchemaDesignerSchemaDiffResponse.class); response.diff = ManagedSchemaDiff.diff(loadLatestSchema(sourceSchema), settings.getSchema()); response.diffSource = sourceSchema; addSettingsToResponse(settings, response); @@ -1180,7 +1175,7 @@ SchemaDesignerResponse buildSchemaDesignerResponse( int currentVersion = configSetHelper.getCurrentSchemaVersion(mutableId); indexedVersion.put(mutableId, currentVersion); - SchemaDesignerResponse response = instantiateJerseyResponse(SchemaDesignerResponse.class); + final var response = instantiateJerseyResponse(SchemaDesignerResponse.class); DocCollection coll = zkStateReader().getCollection(mutableId); Collection activeSlices = coll.getActiveSlices(); @@ -1486,8 +1481,7 @@ protected void doCleanupTemp(String configSet) throws IOException, SolrServerExc } protected FlexibleSolrJerseyResponse buildFlexibleResponse(Map responseMap) { - FlexibleSolrJerseyResponse response = - instantiateJerseyResponse(FlexibleSolrJerseyResponse.class); + final var response = instantiateJerseyResponse(FlexibleSolrJerseyResponse.class); responseMap.forEach(response::setUnknownProperty); return response; }