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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.file.CopyOption;
import java.nio.file.FileSystem; import java.nio.file.FileSystem;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Arrays; import java.util.Arrays;
import java.util.Objects; import java.util.Objects;
import java.util.function.Function; import java.util.function.Function;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.InputStreamSource; import org.springframework.core.io.InputStreamSource;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.function.ThrowingConsumer;
/** /**
* {@link GeneratedFiles} implementation that stores generated files using a * {@link GeneratedFiles} implementation that stores generated files using a
* {@link FileSystem}. * {@link FileSystem}.
* *
* @author Phillip Webb * @author Phillip Webb
* @author Stephane Nicoll
* @since 6.0 * @since 6.0
*/ */
public class FileSystemGeneratedFiles implements GeneratedFiles { public class FileSystemGeneratedFiles extends GeneratedFiles {
private final Function<Kind, Path> roots; private final Function<Kind, Path> roots;
@@ -80,21 +85,54 @@ public class FileSystemGeneratedFiles implements GeneratedFiles {
} }
@Override @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.notNull(kind, "'kind' must not be null");
Assert.hasLength(path, "'path' must not be empty"); 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 root = this.roots.apply(kind).toAbsolutePath().normalize();
Path relativePath = root.resolve(path).toAbsolutePath().normalize(); Path relativePath = root.resolve(path).toAbsolutePath().normalize();
Assert.isTrue(relativePath.startsWith(root), "'path' must be relative"); Assert.isTrue(relativePath.startsWith(root), "'path' must be relative");
try { return relativePath;
try (InputStream inputStream = content.getInputStream()) { }
Files.createDirectories(relativePath.getParent());
Files.copy(inputStream, 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -16,8 +16,11 @@
package org.springframework.aot.generate; package org.springframework.aot.generate;
import java.util.function.Supplier;
import org.springframework.core.io.InputStreamSource; import org.springframework.core.io.InputStreamSource;
import org.springframework.javapoet.JavaFile; import org.springframework.javapoet.JavaFile;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.ClassUtils; import org.springframework.util.ClassUtils;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@@ -36,14 +39,14 @@ import org.springframework.util.function.ThrowingConsumer;
* @see InMemoryGeneratedFiles * @see InMemoryGeneratedFiles
* @see FileSystemGeneratedFiles * @see FileSystemGeneratedFiles
*/ */
public interface GeneratedFiles { public abstract class GeneratedFiles {
/** /**
* Add a generated {@link Kind#SOURCE source file} with content from the * Add a generated {@link Kind#SOURCE source file} with content from the
* given {@link JavaFile}. * given {@link JavaFile}.
* @param javaFile the java file to add * @param javaFile the java file to add
*/ */
default void addSourceFile(JavaFile javaFile) { public void addSourceFile(JavaFile javaFile) {
validatePackage(javaFile.packageName, javaFile.typeSpec.name); validatePackage(javaFile.packageName, javaFile.typeSpec.name);
String className = javaFile.packageName + "." + javaFile.typeSpec.name; String className = javaFile.packageName + "." + javaFile.typeSpec.name;
addSourceFile(className, javaFile::writeTo); addSourceFile(className, javaFile::writeTo);
@@ -56,7 +59,7 @@ public interface GeneratedFiles {
* of the file * of the file
* @param content the contents 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)); addSourceFile(className, appendable -> appendable.append(content));
} }
@@ -68,7 +71,7 @@ public interface GeneratedFiles {
* @param content a {@link ThrowingConsumer} that accepts an * @param content a {@link ThrowingConsumer} that accepts an
* {@link Appendable} which will receive the file contents * {@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); addFile(Kind.SOURCE, getClassNamePath(className), content);
} }
@@ -80,7 +83,7 @@ public interface GeneratedFiles {
* @param content an {@link InputStreamSource} that will provide an input * @param content an {@link InputStreamSource} that will provide an input
* stream containing the file contents * 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); addFile(Kind.SOURCE, getClassNamePath(className), content);
} }
@@ -90,7 +93,7 @@ public interface GeneratedFiles {
* @param path the relative path of the file * @param path the relative path of the file
* @param content the contents 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)); addResourceFile(path, appendable -> appendable.append(content));
} }
@@ -101,7 +104,7 @@ public interface GeneratedFiles {
* @param content a {@link ThrowingConsumer} that accepts an * @param content a {@link ThrowingConsumer} that accepts an
* {@link Appendable} which will receive the file contents * {@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); addFile(Kind.RESOURCE, path, content);
} }
@@ -112,7 +115,7 @@ public interface GeneratedFiles {
* @param content an {@link InputStreamSource} that will provide an input * @param content an {@link InputStreamSource} that will provide an input
* stream containing the file contents * stream containing the file contents
*/ */
default void addResourceFile(String path, InputStreamSource content) { public void addResourceFile(String path, InputStreamSource content) {
addFile(Kind.RESOURCE, path, content); addFile(Kind.RESOURCE, path, content);
} }
@@ -123,7 +126,7 @@ public interface GeneratedFiles {
* @param content an {@link InputStreamSource} that will provide an input * @param content an {@link InputStreamSource} that will provide an input
* stream containing the file contents * stream containing the file contents
*/ */
default void addClassFile(String path, InputStreamSource content) { public void addClassFile(String path, InputStreamSource content) {
addFile(Kind.CLASS, path, content); addFile(Kind.CLASS, path, content);
} }
@@ -134,7 +137,7 @@ public interface GeneratedFiles {
* @param path the relative path of the file * @param path the relative path of the file
* @param content the contents 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)); addFile(kind, path, appendable -> appendable.append(content));
} }
@@ -146,7 +149,7 @@ public interface GeneratedFiles {
* @param content a {@link ThrowingConsumer} that accepts an * @param content a {@link ThrowingConsumer} that accepts an
* {@link Appendable} which will receive the file contents * {@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"); Assert.notNull(content, "'content' must not be null");
addFile(kind, path, new AppendableConsumerInputStreamSource(content)); addFile(kind, path, new AppendableConsumerInputStreamSource(content));
} }
@@ -159,7 +162,21 @@ public interface GeneratedFiles {
* @param content an {@link InputStreamSource} that will provide an input * @param content an {@link InputStreamSource} that will provide an input
* stream containing the file contents * 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) { private static String getClassNamePath(String className) {
Assert.hasLength(className, "'className' must not be empty"); Assert.hasLength(className, "'className' must not be empty");
@@ -194,7 +211,7 @@ public interface GeneratedFiles {
/** /**
* The various kinds of generated files that are supported. * The various kinds of generated files that are supported.
*/ */
enum Kind { public enum Kind {
/** /**
* A source file containing Java code that should be compiled. * 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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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.core.io.InputStreamSource;
import org.springframework.lang.Nullable; import org.springframework.lang.Nullable;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.function.ThrowingConsumer;
/** /**
* {@link GeneratedFiles} implementation that keeps generated files in-memory. * {@link GeneratedFiles} implementation that keeps generated files in-memory.
* *
* @author Phillip Webb * @author Phillip Webb
* @author Stephane Nicoll
* @since 6.0 * @since 6.0
*/ */
public class InMemoryGeneratedFiles implements GeneratedFiles { public class InMemoryGeneratedFiles extends GeneratedFiles {
private final Map<Kind, Map<String, InputStreamSource>> files = new HashMap<>(); private final Map<Kind, Map<String, InputStreamSource>> files = new HashMap<>();
@Override @Override
public void addFile(Kind kind, String path, InputStreamSource content) { public void handleFile(Kind kind, String path, ThrowingConsumer<FileHandler> handler) {
Assert.notNull(kind, "'kind' must not be null");
Assert.hasLength(path, "'path' must not be empty");
Assert.notNull(content, "'content' must not be null");
Map<String, InputStreamSource> paths = this.files.computeIfAbsent(kind, Map<String, InputStreamSource> paths = this.files.computeIfAbsent(kind,
key -> new LinkedHashMap<>()); key -> new LinkedHashMap<>());
Assert.state(!paths.containsKey(path), () -> "Path '" + path + "' already in use"); handler.accept(new InMemoryFileHandler(paths, path));
paths.put(path, content);
} }
/** /**
@@ -89,4 +87,27 @@ public class InMemoryGeneratedFiles implements GeneratedFiles {
return (paths != null ? paths.get(path) : null); 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.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import org.springframework.aot.generate.GeneratedFiles.FileHandler;
import org.springframework.aot.generate.GeneratedFiles.Kind; import org.springframework.aot.generate.GeneratedFiles.Kind;
import org.springframework.core.io.ByteArrayResource; 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
/** /**
* Tests for {@link FileSystemGeneratedFiles}. * Tests for {@link FileSystemGeneratedFiles}.
* *
* @author Phillip Webb * @author Phillip Webb
* @author Stephane Nicoll
*/ */
class FileSystemGeneratedFilesTests { class FileSystemGeneratedFilesTests {
@@ -82,7 +86,7 @@ class FileSystemGeneratedFilesTests {
void createWhenRootsResultsInNullThrowsException() { void createWhenRootsResultsInNullThrowsException() {
assertThatIllegalArgumentException() assertThatIllegalArgumentException()
.isThrownBy(() -> new FileSystemGeneratedFiles(kind -> (kind != Kind.CLASS) ? .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"); .withMessage("'roots' must return a value for all file kinds");
} }
@@ -94,6 +98,46 @@ class FileSystemGeneratedFilesTests {
assertPathMustBeRelative(generatedFiles, "test/../../test"); 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) { private void assertPathMustBeRelative(FileSystemGeneratedFiles generatedFiles, String path) {
assertThatIllegalArgumentException() assertThatIllegalArgumentException()
.isThrownBy(() -> generatedFiles.addResourceFile(path, "test")) .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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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.JavaFile;
import org.springframework.javapoet.MethodSpec; import org.springframework.javapoet.MethodSpec;
import org.springframework.javapoet.TypeSpec; 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
@@ -40,6 +42,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
* Tests for {@link GeneratedFiles}. * Tests for {@link GeneratedFiles}.
* *
* @author Phillip Webb * @author Phillip Webb
* @author Stephane Nicoll
*/ */
class GeneratedFilesTests { class GeneratedFilesTests {
@@ -159,30 +162,45 @@ class GeneratedFilesTests {
return this.generatedFiles.assertThatFileAdded(kind, path); return this.generatedFiles.assertThatFileAdded(kind, path);
} }
static class TestGeneratedFiles implements GeneratedFiles { static class TestGeneratedFiles extends GeneratedFiles {
private Kind kind; private Kind kind;
private String path; private String path;
private InputStreamSource content; private final TestFileHandler fileHandler = new TestFileHandler();
@Override @Override
public void addFile(Kind kind, String path, InputStreamSource content) { public void handleFile(Kind kind, String path, ThrowingConsumer<FileHandler> handler) {
this.kind = kind; this.kind = kind;
this.path = path; this.path = path;
this.content = content; handler.accept(this.fileHandler);
} }
AbstractStringAssert<?> assertThatFileAdded(Kind kind, String path) AbstractStringAssert<?> assertThatFileAdded(Kind kind, String path)
throws IOException { throws IOException {
assertThat(this.kind).as("kind").isEqualTo(kind); assertThat(this.kind).as("kind").isEqualTo(kind);
assertThat(this.path).as("path").isEqualTo(path); assertThat(this.path).as("path").isEqualTo(path);
assertThat(this.fileHandler.content).as("content").isNotNull();
ByteArrayOutputStream out = new ByteArrayOutputStream(); ByteArrayOutputStream out = new ByteArrayOutputStream();
this.content.getInputStream().transferTo(out); this.fileHandler.content.getInputStream().transferTo(out);
return assertThat(out.toString(StandardCharsets.UTF_8)); 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"); this.generatedFiles.addResourceFile("META-INF/test", "test");
assertThatIllegalStateException().isThrownBy( assertThatIllegalStateException().isThrownBy(
() -> this.generatedFiles.addResourceFile("META-INF/test", "test")) () -> this.generatedFiles.addResourceFile("META-INF/test", "test"))
.withMessage("Path 'META-INF/test' already in use"); .withMessage("META-INF/test already exists");
} }
@Test @Test