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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ dependencies {
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.actuator)
implementation(libs.spring.boot.starter.aop)
implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.ai.starter.mcp.server.webmvc)
implementation(libs.solr.solrj)
implementation(libs.commons.csv)
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-start
spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" }
spring-boot-starter-aop = { module = "org.springframework.boot:spring-boot-starter-aop" }
spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" }
spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation" }
spring-boot-starter-oauth2-resource-server = { module = "org.springframework.boot:spring-boot-starter-oauth2-resource-server" }
spring-boot-docker-compose = { module = "org.springframework.boot:spring-boot-docker-compose" }
spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
*/
package org.apache.solr.mcp.server.config;

import jakarta.validation.constraints.NotBlank;
import org.jspecify.annotations.Nullable;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

/**
* Spring Boot Configuration Properties record for Apache Solr connection
Expand Down Expand Up @@ -131,6 +133,8 @@
* @see org.springframework.boot.context.properties.ConfigurationProperties
* @see org.springframework.boot.context.properties.EnableConfigurationProperties
*/
@Validated
@ConfigurationProperties(prefix = "solr")
public record SolrConfigurationProperties(String url, @Nullable String username, @Nullable String password) {
public record SolrConfigurationProperties(@NotBlank @SolrUrl String url, @Nullable String username,
@Nullable String password) {
}
90 changes: 90 additions & 0 deletions src/main/java/org/apache/solr/mcp/server/config/SolrUrl.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* 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.mcp.server.config;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Bean Validation constraint asserting that a value is a URL that SolrJ can
* actually connect to.
*
* <p>
* A value satisfies this constraint when it is an <em>absolute</em> URL whose
* scheme is {@code http} or {@code https} and which carries a non-empty host.
* That is precisely the set of URLs
* {@link org.apache.solr.client.solrj.impl.HttpJdkSolrClient} can talk to, so
* anything rejected here would have failed later anyway — at first request
* rather than at startup, and with a far less actionable message.
*
* <p>
* <strong>Why this is needed alongside {@code @NotBlank}:</strong>
*
* <p>
* {@code @NotBlank} rejects only {@code null}, {@code ""} and all-whitespace
* values. It happily accepts {@code solr.url=localhost:8983} — a common
* misconfiguration, since omitting the scheme looks harmless.
* {@link java.net.URI} parses that string as <em>scheme</em> {@code localhost}
* with a {@code null} host, and {@link SolrConfig} normalizes it by pure string
* concatenation into {@code localhost:8983/solr/} without ever noticing.
*
* <p>
* <strong>Accepted:</strong>
*
* <ul>
* <li>{@code http://localhost:8983}
* <li>{@code http://localhost:8983/solr/}
* <li>{@code https://solr.internal:8983/custom/solr/}
* </ul>
*
* <p>
* <strong>Rejected:</strong>
*
* <ul>
* <li>{@code localhost:8983} — parses as scheme {@code localhost}, no host
* <li>{@code ftp://solr.example.com/solr} — SolrJ cannot speak {@code ftp}
* <li>{@code /solr} — not absolute
* <li>{@code not a url} — unparseable
* </ul>
*
* @see SolrUrlValidator
* @see SolrConfigurationProperties
*/
@Documented
@Constraint(validatedBy = SolrUrlValidator.class)
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR,
ElementType.PARAMETER, ElementType.TYPE_USE})
@Retention(RetentionPolicy.RUNTIME)
public @interface SolrUrl {

/**
* {@return the validation message rendered when the value is not a usable Solr
* URL}
*/
String message() default "must be an absolute http or https URL including a host, for example http://localhost:8983/solr/";

/** {@return the validation groups this constraint belongs to} */
Class<?>[] groups() default {};

/** {@return the payload associated with this constraint} */
Class<? extends Payload>[] payload() default {};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* 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.mcp.server.config;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.jspecify.annotations.Nullable;
import org.springframework.util.StringUtils;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;

/**
* Validates {@link SolrUrl} by parsing the candidate value with Spring's
* {@link UriComponentsBuilder} and inspecting the resulting scheme and host.
*
* <p>
* {@code UriComponentsBuilder} is used rather than {@link java.net.URL} because
* it parses without performing any network or protocol-handler lookup, and it
* exposes {@link UriComponents#getScheme()} and {@link UriComponents#getHost()}
* as independent components — which is exactly the distinction that separates
* {@code http://localhost:8983} from {@code localhost:8983}.
*
* @see SolrUrl
*/
public class SolrUrlValidator implements ConstraintValidator<SolrUrl, String> {

private static final String HTTP_SCHEME = "http";
private static final String HTTPS_SCHEME = "https";

/** Default constructor used by the Bean Validation provider. */
public SolrUrlValidator() {
}

/**
* Determines whether {@code value} is a URL SolrJ can connect to.
*
* <p>
* Null and blank values are reported as valid so that emptiness stays the
* concern of {@code @NotBlank}. Without this, an unset {@code solr.url} would
* surface two overlapping messages instead of one.
*
* @param value
* the configured URL, or {@code null} when the property is unset
* @param context
* the constraint validator context
* @return {@code true} if {@code value} satisfies the constraint
*/
@Override
public boolean isValid(@Nullable String value, ConstraintValidatorContext context) {
// Emptiness is @NotBlank's responsibility; reporting it here too would
// yield two messages for a single mistake.
if (!StringUtils.hasText(value)) {
return true;
}

UriComponents uri;
try {
uri = UriComponentsBuilder.fromUriString(value).build();
} catch (IllegalArgumentException ex) {
// Not parseable as a URI at all (for example "not a url").
return false;
}

// A missing scheme means the value is relative ("/solr"); a scheme that
// is neither http nor https is one SolrJ's HTTP client cannot speak.
String scheme = uri.getScheme();
if (!HTTP_SCHEME.equals(scheme) && !HTTPS_SCHEME.equals(scheme)) {
return false;
}

// "localhost:8983" parses as scheme "localhost" with no host and is
// already excluded above, but "http://" reaches here with a null host.
return StringUtils.hasText(uri.getHost());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* 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.mcp.server.config;

import static org.assertj.core.api.Assertions.assertThat;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import java.util.Set;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.DisabledInNativeImage;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;

/**
* Verifies the {@link SolrUrl} constraint both in isolation and through Spring
* Boot's configuration-property binding, which is the path that actually
* decides whether a misconfigured deployment fails at startup.
*/
class SolrUrlValidatorTest {

private static ValidatorFactory validatorFactory;
private static Validator validator;

@BeforeAll
static void openValidatorFactory() {
validatorFactory = Validation.buildDefaultValidatorFactory();
validator = validatorFactory.getValidator();
}

@AfterAll
static void closeValidatorFactory() {
validatorFactory.close();
}

@ParameterizedTest
@ValueSource(
strings = {"http://localhost:8983", "http://localhost:8983/", "http://localhost:8983/solr",
"http://localhost:8983/solr/", "https://solr.internal:8983/custom/solr/",
"https://solr.example.com"})
void acceptsAbsoluteHttpUrlsWithAHost(String url) {
assertThat(violations(url)).isEmpty();
}

@ParameterizedTest
@ValueSource(
strings = {"localhost:8983", "solr.example.com", "/solr", "ftp://solr.example.com/solr", "file:///var/solr",
"not a url", "http://"})
void rejectsUrlsSolrJCannotConnectTo(String url) {
assertThat(violations(url)).extracting(ConstraintViolation::getMessage).containsExactly(
"must be an absolute http or https URL including a host, " + "for example http://localhost:8983/solr/");
}

/**
* A blank URL must report only {@code @NotBlank}'s message. {@link SolrUrl}
* deliberately passes blank values through so a single mistake does not produce
* two overlapping messages.
*/
@ParameterizedTest
@ValueSource(strings = {"", " "})
void reportsBlankUrlOnlyThroughNotBlank(String url) {
assertThat(violations(url)).extracting(ConstraintViolation::getMessage).containsExactly("must not be blank");
}

/**
* Disabled in native image because {@link ApplicationContextRunner} builds its
* {@code AssertableApplicationContext} with {@link java.lang.reflect.Proxy},
* and GraalVM cannot materialize a JDK dynamic proxy that was not registered at
* build time. This is a limitation of the test harness, not of the constraint —
* the JVM build covers this path, and the constraint itself is exercised
* natively by the parameterized tests above.
*/
@Test
@DisabledInNativeImage
void applicationContextFailsToStartWhenSolrUrlOmitsTheScheme() {
contextRunner().withPropertyValues("solr.url=localhost:8983").run(context -> assertThat(context).hasFailed());
}

/** @see #applicationContextFailsToStartWhenSolrUrlOmitsTheScheme() */
@Test
@DisabledInNativeImage
void applicationContextStartsWhenSolrUrlIsAbsolute() {
contextRunner().withPropertyValues("solr.url=http://localhost:8983/solr/")
.run(context -> assertThat(context).hasNotFailed());
}

private static Set<ConstraintViolation<SolrConfigurationProperties>> violations(String url) {
return validator.validate(new SolrConfigurationProperties(url, null, null));
}

private static ApplicationContextRunner contextRunner() {
return new ApplicationContextRunner().withUserConfiguration(SolrPropertiesConfiguration.class);
}

@EnableConfigurationProperties(SolrConfigurationProperties.class)
static class SolrPropertiesConfiguration {
}
}