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
This commit is contained in:
Stephane Nicoll 2024-06-22 17:55:36 +02:00 committed by Stéphane Nicoll
parent e6b77d301d
commit 2650da2b53
6 changed files with 236 additions and 40 deletions

View File

@ -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 @@ package org.springframework.aot.generate;
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<Kind, Path> roots;
@ -80,21 +85,54 @@ public class FileSystemGeneratedFiles implements GeneratedFiles {
}
@Override
public void addFile(Kind kind, String path, InputStreamSource content) {
public void handleFile(Kind kind, String path, ThrowingConsumer<FileHandler> 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();
}
}

View File

@ -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 @@ import org.springframework.util.function.ThrowingConsumer;
* @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 @@ public interface GeneratedFiles {
* 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 @@ public interface GeneratedFiles {
* @param content a {@link ThrowingConsumer} that accepts an
* {@link Appendable} which will receive the file contents
*/
default void addSourceFile(String className, ThrowingConsumer<Appendable> content) {
public void addSourceFile(String className, ThrowingConsumer<Appendable> content) {
addFile(Kind.SOURCE, getClassNamePath(className), content);
}
@ -80,7 +83,7 @@ public interface GeneratedFiles {
* @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 @@ public interface GeneratedFiles {
* @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 @@ public interface GeneratedFiles {
* @param content a {@link ThrowingConsumer} that accepts an
* {@link Appendable} which will receive the file contents
*/
default void addResourceFile(String path, ThrowingConsumer<Appendable> content) {
public void addResourceFile(String path, ThrowingConsumer<Appendable> content) {
addFile(Kind.RESOURCE, path, content);
}
@ -112,7 +115,7 @@ public interface GeneratedFiles {
* @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 @@ public interface GeneratedFiles {
* @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 @@ public interface GeneratedFiles {
* @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 @@ public interface GeneratedFiles {
* @param content a {@link ThrowingConsumer} that accepts an
* {@link Appendable} which will receive the file contents
*/
default void addFile(Kind kind, String path, ThrowingConsumer<Appendable> content) {
public void addFile(Kind kind, String path, ThrowingConsumer<Appendable> content) {
Assert.notNull(content, "'content' must not be null");
addFile(kind, path, new AppendableConsumerInputStreamSource(content));
}
@ -159,7 +162,21 @@ public interface GeneratedFiles {
* @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<FileHandler> handler);
private static String getClassNamePath(String className) {
Assert.hasLength(className, "'className' must not be empty");
@ -194,7 +211,7 @@ public interface GeneratedFiles {
/**
* 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 @@ public interface GeneratedFiles {
}
/**
* 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<InputStreamSource> existingContent;
protected FileHandler(boolean exists, Supplier<InputStreamSource> 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);
}
}

View File

@ -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 java.util.Map;
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<Kind, Map<String, InputStreamSource>> 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<FileHandler> handler) {
Map<String, InputStreamSource> 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 class InMemoryGeneratedFiles implements GeneratedFiles {
return (paths != null ? paths.get(path) : null);
}
private static class InMemoryFileHandler extends FileHandler {
private final Map<String, InputStreamSource> paths;
private final String key;
InMemoryFileHandler(Map<String, InputStreamSource> 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;
}
}
}

View File

@ -23,16 +23,20 @@ import java.util.function.Function;
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 @@ class FileSystemGeneratedFilesTests {
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 @@ class FileSystemGeneratedFilesTests {
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<FileHandler> 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"))

View File

@ -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.core.io.Resource;
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 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
* Tests for {@link GeneratedFiles}.
*
* @author Phillip Webb
* @author Stephane Nicoll
*/
class GeneratedFilesTests {
@ -159,30 +162,45 @@ class GeneratedFilesTests {
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<FileHandler> 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;
}
}
}
}

View File

@ -44,7 +44,7 @@ class InMemoryGeneratedFilesTests {
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