From 2650da2b531fefc402476928cbfa7ea0acccfeeb Mon Sep 17 00:00:00 2001 From: Stephane Nicoll Date: Sat, 22 Jun 2024 17:55:36 +0200 Subject: [PATCH] Provide more control over registration of GeneratedFiles This commit provides an advanced handling of generated files that provides more control over files registration. The callback provides a FileHandler that can determine if the file already exists and its content. The caller can then chose to override the content or leave it as it is. Closes gh-31331 --- .../generate/FileSystemGeneratedFiles.java | 58 ++++++++-- .../aot/generate/GeneratedFiles.java | 103 +++++++++++++++--- .../aot/generate/InMemoryGeneratedFiles.java | 37 +++++-- .../FileSystemGeneratedFilesTests.java | 46 +++++++- .../aot/generate/GeneratedFilesTests.java | 30 ++++- .../generate/InMemoryGeneratedFilesTests.java | 2 +- 6 files changed, 236 insertions(+), 40 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/aot/generate/FileSystemGeneratedFiles.java b/spring-core/src/main/java/org/springframework/aot/generate/FileSystemGeneratedFiles.java index 7a6322d30015..e0b3d8049325 100644 --- a/spring-core/src/main/java/org/springframework/aot/generate/FileSystemGeneratedFiles.java +++ b/spring-core/src/main/java/org/springframework/aot/generate/FileSystemGeneratedFiles.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,24 +18,29 @@ import java.io.IOException; import java.io.InputStream; +import java.nio.file.CopyOption; import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.Arrays; import java.util.Objects; import java.util.function.Function; +import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.InputStreamSource; import org.springframework.util.Assert; +import org.springframework.util.function.ThrowingConsumer; /** * {@link GeneratedFiles} implementation that stores generated files using a * {@link FileSystem}. * * @author Phillip Webb + * @author Stephane Nicoll * @since 6.0 */ -public class FileSystemGeneratedFiles implements GeneratedFiles { +public class FileSystemGeneratedFiles extends GeneratedFiles { private final Function roots; @@ -80,21 +85,54 @@ private static Function conventionRoots(Path root) { } @Override - public void addFile(Kind kind, String path, InputStreamSource content) { + public void handleFile(Kind kind, String path, ThrowingConsumer handler) { + FileSystemFileHandler fileHandler = new FileSystemFileHandler(toPath(kind, path)); + handler.accept(fileHandler); + } + + private Path toPath(Kind kind, String path) { Assert.notNull(kind, "'kind' must not be null"); Assert.hasLength(path, "'path' must not be empty"); - Assert.notNull(content, "'content' must not be null"); Path root = this.roots.apply(kind).toAbsolutePath().normalize(); Path relativePath = root.resolve(path).toAbsolutePath().normalize(); Assert.isTrue(relativePath.startsWith(root), "'path' must be relative"); - try { - try (InputStream inputStream = content.getInputStream()) { - Files.createDirectories(relativePath.getParent()); - Files.copy(inputStream, relativePath); + return relativePath; + } + + static final class FileSystemFileHandler extends FileHandler { + + private final Path path; + + FileSystemFileHandler(Path path) { + super(Files.exists(path), () -> new FileSystemResource(path)); + this.path = path; + } + + @Override + protected void copy(InputStreamSource content, boolean override) { + if (override) { + copy(content, StandardCopyOption.REPLACE_EXISTING); + } + else { + copy(content); } } - catch (IOException ex) { - throw new IllegalStateException(ex); + + private void copy(InputStreamSource content, CopyOption... copyOptions) { + try { + try (InputStream inputStream = content.getInputStream()) { + Files.createDirectories(this.path.getParent()); + Files.copy(inputStream, this.path, copyOptions); + } + } + catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + @Override + public String toString() { + return this.path.toString(); } } diff --git a/spring-core/src/main/java/org/springframework/aot/generate/GeneratedFiles.java b/spring-core/src/main/java/org/springframework/aot/generate/GeneratedFiles.java index 1c326e4fd96d..147c122bea0a 100644 --- a/spring-core/src/main/java/org/springframework/aot/generate/GeneratedFiles.java +++ b/spring-core/src/main/java/org/springframework/aot/generate/GeneratedFiles.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,8 +16,11 @@ package org.springframework.aot.generate; +import java.util.function.Supplier; + import org.springframework.core.io.InputStreamSource; import org.springframework.javapoet.JavaFile; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; @@ -36,14 +39,14 @@ * @see InMemoryGeneratedFiles * @see FileSystemGeneratedFiles */ -public interface GeneratedFiles { +public abstract class GeneratedFiles { /** * Add a generated {@link Kind#SOURCE source file} with content from the * given {@link JavaFile}. * @param javaFile the java file to add */ - default void addSourceFile(JavaFile javaFile) { + public void addSourceFile(JavaFile javaFile) { validatePackage(javaFile.packageName, javaFile.typeSpec.name); String className = javaFile.packageName + "." + javaFile.typeSpec.name; addSourceFile(className, javaFile::writeTo); @@ -56,7 +59,7 @@ default void addSourceFile(JavaFile javaFile) { * of the file * @param content the contents of the file */ - default void addSourceFile(String className, CharSequence content) { + public void addSourceFile(String className, CharSequence content) { addSourceFile(className, appendable -> appendable.append(content)); } @@ -68,7 +71,7 @@ default void addSourceFile(String className, CharSequence content) { * @param content a {@link ThrowingConsumer} that accepts an * {@link Appendable} which will receive the file contents */ - default void addSourceFile(String className, ThrowingConsumer content) { + public void addSourceFile(String className, ThrowingConsumer content) { addFile(Kind.SOURCE, getClassNamePath(className), content); } @@ -80,7 +83,7 @@ default void addSourceFile(String className, ThrowingConsumer conten * @param content an {@link InputStreamSource} that will provide an input * stream containing the file contents */ - default void addSourceFile(String className, InputStreamSource content) { + public void addSourceFile(String className, InputStreamSource content) { addFile(Kind.SOURCE, getClassNamePath(className), content); } @@ -90,7 +93,7 @@ default void addSourceFile(String className, InputStreamSource content) { * @param path the relative path of the file * @param content the contents of the file */ - default void addResourceFile(String path, CharSequence content) { + public void addResourceFile(String path, CharSequence content) { addResourceFile(path, appendable -> appendable.append(content)); } @@ -101,7 +104,7 @@ default void addResourceFile(String path, CharSequence content) { * @param content a {@link ThrowingConsumer} that accepts an * {@link Appendable} which will receive the file contents */ - default void addResourceFile(String path, ThrowingConsumer content) { + public void addResourceFile(String path, ThrowingConsumer content) { addFile(Kind.RESOURCE, path, content); } @@ -112,7 +115,7 @@ default void addResourceFile(String path, ThrowingConsumer content) * @param content an {@link InputStreamSource} that will provide an input * stream containing the file contents */ - default void addResourceFile(String path, InputStreamSource content) { + public void addResourceFile(String path, InputStreamSource content) { addFile(Kind.RESOURCE, path, content); } @@ -123,7 +126,7 @@ default void addResourceFile(String path, InputStreamSource content) { * @param content an {@link InputStreamSource} that will provide an input * stream containing the file contents */ - default void addClassFile(String path, InputStreamSource content) { + public void addClassFile(String path, InputStreamSource content) { addFile(Kind.CLASS, path, content); } @@ -134,7 +137,7 @@ default void addClassFile(String path, InputStreamSource content) { * @param path the relative path of the file * @param content the contents of the file */ - default void addFile(Kind kind, String path, CharSequence content) { + public void addFile(Kind kind, String path, CharSequence content) { addFile(kind, path, appendable -> appendable.append(content)); } @@ -146,7 +149,7 @@ default void addFile(Kind kind, String path, CharSequence content) { * @param content a {@link ThrowingConsumer} that accepts an * {@link Appendable} which will receive the file contents */ - default void addFile(Kind kind, String path, ThrowingConsumer content) { + public void addFile(Kind kind, String path, ThrowingConsumer content) { Assert.notNull(content, "'content' must not be null"); addFile(kind, path, new AppendableConsumerInputStreamSource(content)); } @@ -159,7 +162,21 @@ default void addFile(Kind kind, String path, ThrowingConsumer conten * @param content an {@link InputStreamSource} that will provide an input * stream containing the file contents */ - void addFile(Kind kind, String path, InputStreamSource content); + public void addFile(Kind kind, String path, InputStreamSource content) { + Assert.notNull(kind, "'kind' must not be null"); + Assert.hasLength(path, "'path' must not be empty"); + Assert.notNull(content, "'content' must not be null"); + handleFile(kind, path, handler -> handler.create(content)); + } + + /** + * Add a generated file of the specified {@link Kind} with the given + * {@linkplain FileHandler handler}. + * @param kind the kind of file being written + * @param path the relative path of the file + * @param handler a consumer of a {@link FileHandler} for the file + */ + public abstract void handleFile(Kind kind, String path, ThrowingConsumer handler); private static String getClassNamePath(String className) { Assert.hasLength(className, "'className' must not be empty"); @@ -194,7 +211,7 @@ private static boolean isJavaIdentifier(String className) { /** * The various kinds of generated files that are supported. */ - enum Kind { + public enum Kind { /** * A source file containing Java code that should be compiled. @@ -215,4 +232,62 @@ enum Kind { } + /** + * Provide access to a particular file and offer convenient method to save + * or override its content. + */ + public abstract static class FileHandler { + + private final boolean exists; + + private final Supplier existingContent; + + protected FileHandler(boolean exists, Supplier existingContent) { + this.exists = exists; + this.existingContent = existingContent; + } + + /** + * Specify whether the file already exists. + * @return {@code true} if the file already exists + */ + public boolean exists() { + return this.exists; + } + + /** + * Return an {@link InputStreamSource} for the content of the file or + * {@code null} if the file does not exist. + */ + @Nullable + public InputStreamSource getContent() { + return (exists() ? this.existingContent.get() : null); + } + + /** + * Create a file with the given {@linkplain InputStreamSource content}. + * @throws IllegalStateException if the file already exists + */ + public void create(InputStreamSource content) { + Assert.notNull(content, "'content' must not be null"); + if (exists()) { + throw new IllegalStateException("%s already exists".formatted(this)); + } + copy(content, false); + } + + /** + * Override the content of the file handled by this instance using the + * given {@linkplain InputStreamSource content}. If the file does not + * exist, it is created. + */ + public void override(InputStreamSource content) { + Assert.notNull(content, "'content' must not be null"); + copy(content, true); + } + + protected abstract void copy(InputStreamSource content, boolean override); + + } + } diff --git a/spring-core/src/main/java/org/springframework/aot/generate/InMemoryGeneratedFiles.java b/spring-core/src/main/java/org/springframework/aot/generate/InMemoryGeneratedFiles.java index f3936c21be7f..885b5f30f216 100644 --- a/spring-core/src/main/java/org/springframework/aot/generate/InMemoryGeneratedFiles.java +++ b/spring-core/src/main/java/org/springframework/aot/generate/InMemoryGeneratedFiles.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -26,27 +26,25 @@ import org.springframework.core.io.InputStreamSource; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.function.ThrowingConsumer; /** * {@link GeneratedFiles} implementation that keeps generated files in-memory. * * @author Phillip Webb + * @author Stephane Nicoll * @since 6.0 */ -public class InMemoryGeneratedFiles implements GeneratedFiles { +public class InMemoryGeneratedFiles extends GeneratedFiles { private final Map> files = new HashMap<>(); @Override - public void addFile(Kind kind, String path, InputStreamSource content) { - Assert.notNull(kind, "'kind' must not be null"); - Assert.hasLength(path, "'path' must not be empty"); - Assert.notNull(content, "'content' must not be null"); + public void handleFile(Kind kind, String path, ThrowingConsumer handler) { Map paths = this.files.computeIfAbsent(kind, key -> new LinkedHashMap<>()); - Assert.state(!paths.containsKey(path), () -> "Path '" + path + "' already in use"); - paths.put(path, content); + handler.accept(new InMemoryFileHandler(paths, path)); } /** @@ -89,4 +87,27 @@ public InputStreamSource getGeneratedFile(Kind kind, String path) { return (paths != null ? paths.get(path) : null); } + private static class InMemoryFileHandler extends FileHandler { + + private final Map paths; + + private final String key; + + InMemoryFileHandler(Map paths, String key) { + super(paths.containsKey(key), () -> paths.get(key)); + this.paths = paths; + this.key = key; + } + + @Override + protected void copy(InputStreamSource content, boolean override) { + this.paths.put(this.key, content); + } + + @Override + public String toString() { + return this.key; + } + } + } diff --git a/spring-core/src/test/java/org/springframework/aot/generate/FileSystemGeneratedFilesTests.java b/spring-core/src/test/java/org/springframework/aot/generate/FileSystemGeneratedFilesTests.java index d2698c0e53ae..b9050eee4574 100644 --- a/spring-core/src/test/java/org/springframework/aot/generate/FileSystemGeneratedFilesTests.java +++ b/spring-core/src/test/java/org/springframework/aot/generate/FileSystemGeneratedFilesTests.java @@ -23,16 +23,20 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.springframework.aot.generate.GeneratedFiles.FileHandler; import org.springframework.aot.generate.GeneratedFiles.Kind; import org.springframework.core.io.ByteArrayResource; +import org.springframework.util.function.ThrowingConsumer; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** * Tests for {@link FileSystemGeneratedFiles}. * * @author Phillip Webb + * @author Stephane Nicoll */ class FileSystemGeneratedFilesTests { @@ -82,7 +86,7 @@ void createWhenRootsIsNullThrowsException() { void createWhenRootsResultsInNullThrowsException() { assertThatIllegalArgumentException() .isThrownBy(() -> new FileSystemGeneratedFiles(kind -> (kind != Kind.CLASS) ? - this.root.resolve(kind.toString()) : null)) + this.root.resolve(kind.toString()) : null)) .withMessage("'roots' must return a value for all file kinds"); } @@ -94,6 +98,46 @@ void addFileWhenPathIsOutsideOfRootThrowsException() { assertPathMustBeRelative(generatedFiles, "test/../../test"); } + @Test + void addFileWhenFileAlreadyAddedThrowsException() { + FileSystemGeneratedFiles generatedFiles = new FileSystemGeneratedFiles(this.root); + generatedFiles.addResourceFile("META-INF/test", "test"); + assertThatIllegalStateException().isThrownBy( + () -> generatedFiles.addResourceFile("META-INF/test", "test")) + .withMessageContaining("META-INF/test", "already exists"); + } + + @Test + void handleFileWhenFileExistsProvidesFileHandler() { + FileSystemGeneratedFiles generatedFiles = new FileSystemGeneratedFiles(this.root); + generatedFiles.addResourceFile("META-INF/test", "test"); + generatedFiles.handleFile(Kind.RESOURCE, "META-INF/test", handler -> { + assertThat(handler.exists()).isTrue(); + assertThat(handler.getContent()).isNotNull(); + assertThat(handler.getContent().getInputStream()).hasContent("test"); + }); + assertThat(this.root.resolve("resources/META-INF/test")).content().isEqualTo("test"); + } + + @Test + void handleFileWhenFileExistsFailsToCreate() { + FileSystemGeneratedFiles generatedFiles = new FileSystemGeneratedFiles(this.root); + generatedFiles.addResourceFile("META-INF/test", "test"); + ThrowingConsumer consumer = handler -> handler.create(new ByteArrayResource("should fail".getBytes(StandardCharsets.UTF_8))); + assertThatIllegalStateException() + .isThrownBy(() -> generatedFiles.handleFile(Kind.RESOURCE, "META-INF/test", consumer)) + .withMessageContaining("META-INF/test", "already exists"); + } + + @Test + void handleFileWhenFileExistsCanOverrideContent() { + FileSystemGeneratedFiles generatedFiles = new FileSystemGeneratedFiles(this.root); + generatedFiles.addResourceFile("META-INF/test", "test"); + generatedFiles.handleFile(Kind.RESOURCE, "META-INF/test", handler -> + handler.override(new ByteArrayResource("overridden".getBytes(StandardCharsets.UTF_8)))); + assertThat(this.root.resolve("resources/META-INF/test")).content().isEqualTo("overridden"); + } + private void assertPathMustBeRelative(FileSystemGeneratedFiles generatedFiles, String path) { assertThatIllegalArgumentException() .isThrownBy(() -> generatedFiles.addResourceFile(path, "test")) diff --git a/spring-core/src/test/java/org/springframework/aot/generate/GeneratedFilesTests.java b/spring-core/src/test/java/org/springframework/aot/generate/GeneratedFilesTests.java index 3372fe0c4dae..1c189cd00b27 100644 --- a/spring-core/src/test/java/org/springframework/aot/generate/GeneratedFilesTests.java +++ b/spring-core/src/test/java/org/springframework/aot/generate/GeneratedFilesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,6 +32,8 @@ import org.springframework.javapoet.JavaFile; import org.springframework.javapoet.MethodSpec; import org.springframework.javapoet.TypeSpec; +import org.springframework.lang.Nullable; +import org.springframework.util.function.ThrowingConsumer; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; @@ -40,6 +42,7 @@ * Tests for {@link GeneratedFiles}. * * @author Phillip Webb + * @author Stephane Nicoll */ class GeneratedFilesTests { @@ -159,30 +162,45 @@ private AbstractStringAssert assertThatFileAdded(Kind kind, String path) return this.generatedFiles.assertThatFileAdded(kind, path); } - static class TestGeneratedFiles implements GeneratedFiles { + static class TestGeneratedFiles extends GeneratedFiles { private Kind kind; private String path; - private InputStreamSource content; + private final TestFileHandler fileHandler = new TestFileHandler(); @Override - public void addFile(Kind kind, String path, InputStreamSource content) { + public void handleFile(Kind kind, String path, ThrowingConsumer handler) { this.kind = kind; this.path = path; - this.content = content; + handler.accept(this.fileHandler); } AbstractStringAssert assertThatFileAdded(Kind kind, String path) throws IOException { assertThat(this.kind).as("kind").isEqualTo(kind); assertThat(this.path).as("path").isEqualTo(path); + assertThat(this.fileHandler.content).as("content").isNotNull(); ByteArrayOutputStream out = new ByteArrayOutputStream(); - this.content.getInputStream().transferTo(out); + this.fileHandler.content.getInputStream().transferTo(out); return assertThat(out.toString(StandardCharsets.UTF_8)); } + private static class TestFileHandler extends FileHandler { + + @Nullable + private InputStreamSource content; + + TestFileHandler() { + super(false, () -> null); + } + + @Override + protected void copy(InputStreamSource content, boolean override) { + this.content = content; + } + } } } diff --git a/spring-core/src/test/java/org/springframework/aot/generate/InMemoryGeneratedFilesTests.java b/spring-core/src/test/java/org/springframework/aot/generate/InMemoryGeneratedFilesTests.java index c9150fa052cd..22bf11bf27f9 100644 --- a/spring-core/src/test/java/org/springframework/aot/generate/InMemoryGeneratedFilesTests.java +++ b/spring-core/src/test/java/org/springframework/aot/generate/InMemoryGeneratedFilesTests.java @@ -44,7 +44,7 @@ void addFileWhenFileAlreadyAddedThrowsException() { this.generatedFiles.addResourceFile("META-INF/test", "test"); assertThatIllegalStateException().isThrownBy( () -> this.generatedFiles.addResourceFile("META-INF/test", "test")) - .withMessage("Path 'META-INF/test' already in use"); + .withMessage("META-INF/test already exists"); } @Test