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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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