Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add endpoint detection #2938

Merged
merged 18 commits into from
Feb 3, 2025
Merged
Show file tree
Hide file tree
Changes from 7 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.vaadin.hilla;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Marker interface to be used for detecting the framework's internal
* {@link BrowserCallable} and {@link Endpoint} endpoints that shouldn't start
* the endpoint generator per se.
* <p>
* For internal use only. May be renamed or removed in a future release.
*
* @author Vaadin Ltd
* @since 24.7
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface InternalBrowserCallable {
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.hilla.BrowserCallable;
import com.vaadin.hilla.EndpointInvocationException;
import com.vaadin.hilla.InternalBrowserCallable;
import com.vaadin.hilla.signals.core.event.ListStateEvent;
import com.vaadin.hilla.signals.core.registry.SecureSignalsRegistry;
import jakarta.annotation.Nullable;
Expand All @@ -30,6 +31,7 @@
*/
@AnonymousAllowed
@BrowserCallable
@InternalBrowserCallable
public class SignalsHandler {

private static final String FEATURE_FLAG_ERROR_MESSAGE = """
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
com.vaadin.hilla.startup.EndpointUsageDetectorImpl
tepi marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import java.util.List;
import java.util.Objects;

import com.vaadin.hilla.parser.core.OpenAPIFileType;
import io.swagger.v3.oas.models.OpenAPI;
import org.jspecify.annotations.NonNull;

import com.vaadin.hilla.engine.commandrunner.CommandNotFoundException;
Expand All @@ -16,6 +18,8 @@
import org.slf4j.LoggerFactory;

public final class GeneratorProcessor {
public static String GENERATED_FILE_LIST_NAME = "generated-file-list.txt";

private static final Logger logger = LoggerFactory
.getLogger(GeneratorProcessor.class);

Expand All @@ -36,6 +40,11 @@ public GeneratorProcessor(EngineConfiguration conf) {
}

public void process() throws GeneratorException {
if (isOpenAPIEmpty()) {
cleanup();
return;
}

var arguments = new ArrayList<Object>();
arguments.add(TSGEN_PATH);
prepareOutputDir(arguments);
Expand All @@ -62,6 +71,25 @@ public void process() throws GeneratorException {
}
}

private void cleanup() throws GeneratorException {
var generatedFilesListFile = outputDirectory.resolve(GENERATED_FILE_LIST_NAME);
try {
var generatedFilesList = Files.readAllLines(generatedFilesListFile);
for (var line : generatedFilesList) {
var path = outputDirectory.resolve(line);
Files.deleteIfExists(path);
// Also remove any empty parent directories
var dir = path.getParent();
while (dir.startsWith(outputDirectory) && !dir.equals(outputDirectory) && Files.isDirectory(dir) && Objects.requireNonNull(dir.toFile().list()).length == 0) {
Files.deleteIfExists(dir);
}
}
Files.deleteIfExists(generatedFilesListFile);
} catch (IOException e) {
throw new GeneratorException("Unable to cleanup generated files", e);
}
}

// Used to catch a checked exception in a lambda and handle it after
private static class LambdaException extends RuntimeException {
public LambdaException(Throwable cause) {
Expand Down Expand Up @@ -104,4 +132,21 @@ private void prepareVerbose(List<Object> arguments) {
arguments.add("-v");
}
}

private OpenAPI getOpenAPI() throws IOException {
String source = Files.readString(openAPIFile);
var mapper = OpenAPIFileType.JSON.getMapper();
var reader = mapper.reader();
return reader.readValue(source, OpenAPI.class);
}

private boolean isOpenAPIEmpty() {
try {
var openApi = getOpenAPI();
return (openApi.getPaths() == null || openApi.getPaths().isEmpty())
&& (openApi.getComponents() == null || openApi.getComponents().getSchemas() == null || openApi.getComponents().getSchemas().isEmpty());
} catch (IOException e) {
throw new GeneratorException("Unable to read OpenAPI json file", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
package com.vaadin.hilla.internal;

import com.vaadin.flow.server.ExecutionFailedException;
import com.vaadin.hilla.ApplicationContextProvider;
import com.vaadin.hilla.engine.EngineConfiguration;
import com.vaadin.hilla.internal.fixtures.CustomEndpoint;
import com.vaadin.hilla.internal.fixtures.EndpointNoValue;
import com.vaadin.hilla.internal.fixtures.MyEndpoint;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ContextConfiguration;

import static org.junit.jupiter.api.Assertions.assertThrowsExactly;

class AbstractTaskEndpointGeneratorTest extends TaskTest {
class AbstractTaskEndpointGeneratorTest extends EndpointsTaskTest {
@Test
void shouldThrowIfEngineConfigurationIsNull() {
assertThrowsExactly(NullPointerException.class, () -> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.vaadin.hilla.internal;

import com.vaadin.flow.server.frontend.FrontendUtils;
import com.vaadin.hilla.ApplicationContextProvider;
import com.vaadin.hilla.internal.fixtures.CustomEndpoint;
import com.vaadin.hilla.internal.fixtures.EndpointNoValue;
import com.vaadin.hilla.internal.fixtures.MyEndpoint;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.util.FileSystemUtils;

import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Stream;

@SpringBootTest(classes = {
CustomEndpoint.class,
EndpointNoValue.class,
MyEndpoint.class,
ApplicationContextProvider.class
})
public class EndpointsTaskTest extends TaskTest {

static private Path npmDependenciesTempDirectory;

@BeforeAll
public static void setupNpmDependencies() throws IOException, FrontendUtils.CommandExecutionException, InterruptedException, URISyntaxException {
npmDependenciesTempDirectory = Files.createTempDirectory(EndpointsTaskTest.class.getName());

Path packagesPath = Path
.of(Objects.requireNonNull(EndpointsTaskTest.class.getClassLoader().getResource("")).toURI())
.getParent() // target
.getParent() // engine-runtime
.getParent() // java
.getParent(); // packages

Path projectRoot = packagesPath.getParent();
Files.copy(projectRoot.resolve(".npmrc"),
npmDependenciesTempDirectory.resolve(".npmrc"));
var tsPackagesDirectory = packagesPath.resolve("ts");

var shellCmd = FrontendUtils.isWindows() ? Stream.of("cmd.exe", "/c")
: Stream.<String> empty();

var npmCmd = Stream.of("npm", "--no-update-notifier", "--no-audit",
"install", "--no-save", "--install-links");

var generatorFiles = Files.list(tsPackagesDirectory)
.map(Path::toString);

var command = Stream.of(shellCmd, npmCmd, generatorFiles)
.flatMap(Function.identity()).toList();

var processBuilder = FrontendUtils.createProcessBuilder(command)
.directory(npmDependenciesTempDirectory.toFile())
.redirectOutput(ProcessBuilder.Redirect.INHERIT)
.redirectError(ProcessBuilder.Redirect.INHERIT);
var exitCode = processBuilder.start().waitFor();
if (exitCode != 0) {
throw new FrontendUtils.CommandExecutionException(exitCode);
}
}

@BeforeEach
public void copyNpmDependencies() throws IOException {
FileSystemUtils.copyRecursively(npmDependenciesTempDirectory, getTemporaryDirectory());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.vaadin.hilla.internal;

import com.vaadin.flow.server.ExecutionFailedException;
import com.vaadin.flow.server.frontend.TaskGenerateEndpoint;
import com.vaadin.flow.server.frontend.TaskGenerateOpenAPI;
import com.vaadin.hilla.ApplicationContextProvider;
import com.vaadin.hilla.engine.GeneratorProcessor;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
import java.util.function.Consumer;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest(classes = {NoEndpointsTaskTest.NoopApplicationContextProvider.class})
public class NoEndpointsTaskTest extends TaskTest {
private TaskGenerateOpenAPI taskGenerateOpenApi;
private TaskGenerateEndpoint taskGenerateEndpoint;

@Autowired
ApplicationContext applicationContext;

@Test
public void should_GenerateEmptySchema_when_NoEndpointsFound() throws ExecutionFailedException, IOException, URISyntaxException {
// Mock ApplicationContextProvider static API to prevent interference
// with other tests.
try (var mockApplicationContextProvider = Mockito.mockStatic(ApplicationContextProvider.class)) {
mockApplicationContextProvider.when(ApplicationContextProvider::getApplicationContext)
.thenReturn(applicationContext);
mockApplicationContextProvider.when(() -> ApplicationContextProvider.runOnContext(Mockito.any()))
.thenAnswer(invocationOnMock -> {
invocationOnMock.<Consumer<ApplicationContext>>getArgument(0).accept(applicationContext);
return null;
});

// Create files resembling output for previously existing endpoints
var outputDirectory = Files.createDirectory(
getTemporaryDirectory().resolve(getOutputDirectory()));
var generatedFileListPath = outputDirectory.resolve(GeneratorProcessor.GENERATED_FILE_LIST_NAME);
var referenceFileListPath = Path.of(Objects.requireNonNull(getClass().getResource(GeneratorProcessor.GENERATED_FILE_LIST_NAME)).toURI());
Files.copy(referenceFileListPath, generatedFileListPath);
var referenceFileList = Files.readAllLines(referenceFileListPath);
for (String line : referenceFileList) {
var path = outputDirectory.resolve(line);
Files.createDirectories(path.getParent());
Files.createFile(path);
}
var arbitraryGeneratedFile = outputDirectory.resolve("vaadin.ts");
Files.createFile(arbitraryGeneratedFile);

taskGenerateOpenApi = new TaskGenerateOpenAPIImpl(getEngineConfiguration());
taskGenerateEndpoint = new TaskGenerateEndpointImpl(getEngineConfiguration());

taskGenerateOpenApi.execute();

var generatedOpenAPI = getGeneratedOpenAPI();

assertNull(generatedOpenAPI.getTags(), "Expected OpenAPI tags to be null");
assertTrue(generatedOpenAPI.getPaths().isEmpty(), "Expected OpenAPI paths to be empty");
assertNull(generatedOpenAPI.getComponents(), "Expected OpenAPI schemas to be null");

assertDoesNotThrow(taskGenerateEndpoint::execute, "Expected to not fail without npm dependencies");

assertFalse(generatedFileListPath.toFile().exists(), "Expected file list to be deleted");
for (String line : referenceFileList) {
var path = outputDirectory.resolve(line);
assertFalse(path.toFile().exists(), String.format("Expected file %s to be deleted", path));
}
assertTrue(arbitraryGeneratedFile.toFile().exists(), "Expected non-Hilla generated file to not be deleted");
}
}

static class NoopApplicationContextProvider extends ApplicationContextProvider {
@Override
public void setApplicationContext(@Nonnull ApplicationContext applicationContext) throws BeansException {
// do nothing
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
import java.util.List;
import java.util.Set;

import com.vaadin.hilla.ApplicationContextProvider;
import com.vaadin.hilla.internal.fixtures.CustomEndpoint;
import com.vaadin.hilla.internal.fixtures.EndpointNoValue;
import com.vaadin.hilla.internal.fixtures.MyEndpoint;
import org.apache.commons.io.FileUtils;
import org.junit.jupiter.api.BeforeEach;
Expand All @@ -23,8 +26,15 @@
import com.vaadin.flow.server.frontend.scanner.ClassFinder.DefaultClassFinder;
import com.vaadin.hilla.EndpointController;
import com.vaadin.hilla.engine.EngineConfiguration;

public class NodeTasksEndpointTest extends TaskTest {
import org.springframework.test.context.ContextConfiguration;

@ContextConfiguration(classes = {
CustomEndpoint.class,
EndpointNoValue.class,
MyEndpoint.class,
ApplicationContextProvider.class
})
public class NodeTasksEndpointTest extends EndpointsTaskTest {
private Options options;

public static class ConnectEndpointsForTesting {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import com.vaadin.flow.server.frontend.TaskGenerateEndpoint;

public class TaskGenerateEndpointTest extends TaskTest {
public class TaskGenerateEndpointTest extends EndpointsTaskTest {

private Path outputDirectory;
private TaskGenerateEndpoint taskGenerateEndpoint;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,12 @@

import com.vaadin.flow.server.frontend.TaskGenerateOpenAPI;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.parser.OpenAPIV3Parser;

/**
* This test suite is only for triggering the OpenAPI generator. For the actual
* content of the generator, they are tested in other package
* {@link com.vaadin.hilla.generator}
*/
public class TaskGenerateOpenAPITest extends TaskTest {
public class TaskGenerateOpenAPITest extends EndpointsTaskTest {

private TaskGenerateOpenAPI taskGenerateOpenApi;

Expand Down Expand Up @@ -100,9 +97,4 @@ public void should_UseDefaultProperties_when_applicationPropertiesIsEmpty()
assertEquals("Hilla Backend", servers.get(0).getDescription(),
"Generated OpenAPI should have default server description");
}

private OpenAPI getGeneratedOpenAPI() {
return new OpenAPIV3Parser()
.read(getOpenAPIFile().toFile().getAbsolutePath());
}
}
Loading
Loading