Skip to content

Commit 95d9b39

Browse files
committed
Merge pull request #46753 from nosan
* pr/46753: Add ArchRule that checks that the type of exposed bean is not private Closes gh-46753
2 parents a5d8eec + b88ad99 commit 95d9b39

File tree

6 files changed

+153
-7
lines changed

6 files changed

+153
-7
lines changed

buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureCheck.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import org.gradle.api.file.FileTree;
4444
import org.gradle.api.provider.ListProperty;
4545
import org.gradle.api.provider.Property;
46+
import org.gradle.api.provider.Provider;
4647
import org.gradle.api.tasks.Classpath;
4748
import org.gradle.api.tasks.IgnoreEmptyDirectories;
4849
import org.gradle.api.tasks.Input;
@@ -53,6 +54,7 @@
5354
import org.gradle.api.tasks.PathSensitive;
5455
import org.gradle.api.tasks.PathSensitivity;
5556
import org.gradle.api.tasks.SkipWhenEmpty;
57+
import org.gradle.api.tasks.SourceSet;
5658
import org.gradle.api.tasks.TaskAction;
5759
import org.gradle.api.tasks.VerificationException;
5860

@@ -75,9 +77,17 @@ public ArchitectureCheck() {
7577
getRules().addAll(getProhibitObjectsRequireNonNull().convention(true)
7678
.map(whenTrue(ArchitectureRules::noClassesShouldCallObjectsRequireNonNull)));
7779
getRules().addAll(ArchitectureRules.standard());
80+
getRules().addAll(whenMainSources(
81+
() -> Collections.singletonList(ArchitectureRules.allBeanMethodsShouldReturnNonPrivateType())));
7882
getRuleDescriptions().set(getRules().map(this::asDescriptions));
7983
}
8084

85+
private Provider<List<ArchRule>> whenMainSources(Supplier<List<ArchRule>> rules) {
86+
return getSourceSet().convention(SourceSet.MAIN_SOURCE_SET_NAME)
87+
.map(SourceSet.MAIN_SOURCE_SET_NAME::equals)
88+
.map(whenTrue(rules));
89+
}
90+
8191
private Transformer<List<ArchRule>, Boolean> whenTrue(Supplier<List<ArchRule>> rules) {
8292
return (in) -> (!in) ? Collections.emptyList() : rules.get();
8393
}
@@ -170,6 +180,9 @@ final FileTree getInputClasses() {
170180
@Internal
171181
public abstract Property<Boolean> getProhibitObjectsRequireNonNull();
172182

183+
@Internal
184+
abstract Property<String> getSourceSet();
185+
173186
@Input // Use descriptions as input since rules aren't serializable
174187
abstract ListProperty<String> getRuleDescriptions();
175188

buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitecturePlugin.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ private void registerTasks(Project project) {
4949
TaskProvider<ArchitectureCheck> checkPackageTangles = project.getTasks()
5050
.register("checkArchitecture" + StringUtils.capitalize(sourceSet.getName()), ArchitectureCheck.class,
5151
(task) -> {
52+
task.getSourceSet().set(sourceSet.getName());
5253
task.getCompileClasspath().from(sourceSet.getCompileClasspath());
5354
task.setClasses(sourceSet.getOutput().getClassesDirs());
5455
task.getResourcesDirectory().set(sourceSet.getOutput().getResourcesDir());

buildSrc/src/main/java/org/springframework/boot/build/architecture/ArchitectureRules.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import com.tngtech.archunit.core.domain.JavaClass;
3434
import com.tngtech.archunit.core.domain.JavaClass.Predicates;
3535
import com.tngtech.archunit.core.domain.JavaMethod;
36+
import com.tngtech.archunit.core.domain.JavaModifier;
3637
import com.tngtech.archunit.core.domain.JavaParameter;
3738
import com.tngtech.archunit.core.domain.JavaType;
3839
import com.tngtech.archunit.core.domain.properties.CanBeAnnotated;
@@ -93,6 +94,20 @@ static List<ArchRule> standard() {
9394
return List.copyOf(rules);
9495
}
9596

97+
static ArchRule allBeanMethodsShouldReturnNonPrivateType() {
98+
return methodsThatAreAnnotatedWith("org.springframework.context.annotation.Bean").should(check(
99+
"not return types declared with the %s modifier, as such types are incompatible with Spring AOT processing"
100+
.formatted(JavaModifier.PRIVATE),
101+
(method, events) -> {
102+
JavaClass returnType = method.getRawReturnType();
103+
if (returnType.getModifiers().contains(JavaModifier.PRIVATE)) {
104+
addViolation(events, method, "%s returns %s which is declared as %s".formatted(
105+
method.getDescription(), returnType.getDescription(), returnType.getModifiers()));
106+
}
107+
}))
108+
.allowEmptyShould(true);
109+
}
110+
96111
private static ArchRule allPackagesShouldBeFreeOfTangles() {
97112
return SlicesRuleDefinition.slices().matching("(**)").should().beFreeOfCycles();
98113
}

buildSrc/src/test/java/org/springframework/boot/build/architecture/ArchitectureCheckTests.java

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616

1717
package org.springframework.boot.build.architecture;
1818

19+
import java.io.FileNotFoundException;
1920
import java.io.IOException;
21+
import java.nio.charset.StandardCharsets;
2022
import java.nio.file.Files;
2123
import java.nio.file.Path;
2224
import java.util.function.Consumer;
@@ -160,6 +162,21 @@ void whenClassCallsStringToUpperCaseWithLocaleShouldNotFail() throws IOException
160162
runGradleWithCompiledClasses("string/toUpperCaseWithLocale", shouldHaveEmptyFailureReport());
161163
}
162164

165+
@Test
166+
void whenBeanMethodExposePrivateTypeShouldFailAndWriteReport() throws IOException {
167+
runGradleWithCompiledClasses("beans/privatebean", shouldHaveFailureReportWithMessage(
168+
"methods that are annotated with @Bean should not return types declared with the PRIVATE modifier,"
169+
+ " as such types are incompatible with Spring AOT processing",
170+
"Method <org.springframework.boot.build.architecture.beans.privatebean.PrivateBean.myBean()> "
171+
+ "returns Class <org.springframework.boot.build.architecture.beans.privatebean.PrivateBean$MyBean>"
172+
+ " which is declared as [PRIVATE, STATIC, FINAL]"));
173+
}
174+
175+
@Test
176+
void whenBeanMethodExposeNonPrivateTypeeShouldNotFail() throws IOException {
177+
runGradleWithCompiledClasses("beans/regular", shouldHaveEmptyFailureReport());
178+
}
179+
163180
@Test
164181
void whenBeanPostProcessorBeanMethodIsNotStaticWithExternalClass() throws IOException {
165182
Files.writeString(this.buildFile, """
@@ -196,17 +213,22 @@ IntegrationMBeanExporter integrationMBeanExporter() {
196213

197214
private Consumer<GradleRunner> shouldHaveEmptyFailureReport() {
198215
return (gradleRunner) -> {
199-
assertThat(gradleRunner.build().getOutput()).contains("BUILD SUCCESSFUL")
200-
.contains("Task :checkArchitectureMain");
201-
assertThat(failureReport()).isEmptyFile();
216+
try {
217+
assertThat(gradleRunner.build().getOutput()).contains("BUILD SUCCESSFUL")
218+
.contains("Task :checkArchitectureMain");
219+
assertThat(failureReport()).isEmpty();
220+
}
221+
catch (Exception ex) {
222+
throw new AssertionError("Expected build to succeed but it failed\n" + failureReport(), ex);
223+
}
202224
};
203225
}
204226

205-
private Consumer<GradleRunner> shouldHaveFailureReportWithMessage(String message) {
227+
private Consumer<GradleRunner> shouldHaveFailureReportWithMessage(String... messages) {
206228
return (gradleRunner) -> {
207229
assertThat(gradleRunner.buildAndFail().getOutput()).contains("BUILD FAILED")
208230
.contains("Task :checkArchitectureMain FAILED");
209-
assertThat(failureReport()).content().contains(message);
231+
assertThat(failureReport()).contains(messages);
210232
};
211233
}
212234

@@ -235,8 +257,17 @@ private void runGradle(Consumer<GradleRunner> callback) {
235257
.withPluginClasspath());
236258
}
237259

238-
private Path failureReport() {
239-
return this.projectDir.resolve("build/checkArchitectureMain/failure-report.txt");
260+
private String failureReport() {
261+
try {
262+
Path failureReport = this.projectDir.resolve("build/checkArchitectureMain/failure-report.txt");
263+
return Files.readString(failureReport, StandardCharsets.UTF_8);
264+
}
265+
catch (FileNotFoundException ex) {
266+
return "Failure report does not exist";
267+
}
268+
catch (IOException ex) {
269+
return "Failure report could not be read: " + ex.getMessage();
270+
}
240271
}
241272

242273
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright 2012-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.build.architecture.beans.privatebean;
18+
19+
import org.springframework.context.annotation.Bean;
20+
import org.springframework.context.annotation.Configuration;
21+
22+
@Configuration(proxyBeanMethods = false)
23+
class PrivateBean {
24+
25+
@Bean
26+
static MyBean myBean() {
27+
return new MyBean();
28+
}
29+
30+
private static final class MyBean {
31+
32+
}
33+
34+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright 2012-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.build.architecture.beans.regular;
18+
19+
import org.springframework.context.annotation.Bean;
20+
import org.springframework.context.annotation.Configuration;
21+
22+
@Configuration(proxyBeanMethods = false)
23+
class RegularBean {
24+
25+
@Bean
26+
static PackagePrivate packagePrivateBean() {
27+
return new PackagePrivate();
28+
}
29+
30+
@Bean
31+
static Protected protectedBean() {
32+
return new Protected();
33+
}
34+
35+
@Bean
36+
static Public publicBean() {
37+
return new Public();
38+
}
39+
40+
static final class PackagePrivate {
41+
42+
}
43+
44+
protected static final class Protected {
45+
46+
}
47+
48+
public static final class Public {
49+
50+
}
51+
52+
}

0 commit comments

Comments
 (0)