Skip to content

Commit

Permalink
fix: add support for endpoint prefix (#1035)
Browse files Browse the repository at this point in the history
Adds a code generator that creates the connect-client.ts file if a custom
endpoint prefix has been configured in application.properties.

Fixes #114
---------

Co-authored-by: Dario Götze <[email protected]>
  • Loading branch information
mcollovati and Dudeplayz authored Nov 7, 2024
1 parent fedd3c9 commit 4ef799f
Show file tree
Hide file tree
Showing 37 changed files with 1,549 additions and 16 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ error-screenshots/
webpack.generated.js
integration-tests/smoke-tests/build/
commons/deployment/src/main/frontend/
integration-tests/custom-prefix-test/src/main/frontend/connect-client.ts
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,16 @@ integration eliminates the need for code duplication and ensures tighter alignme
more streamlined updates and improved stability. By leveraging the Vaadin Quarkus extension, users of `quarkus-hilla`
will benefit from enhanced compatibility with future Vaadin features.

### Custom Endpoint Prefix

A custom endpoint prefix can be configured by setting the `vaadin.endpoint.prefix` entry in `application.properties`. The extension will create a custom `connect-client.ts` file in the `frontend` folder and construct the `ConnectClient` object with the configured prefix.
If `connect-client.ts` exists and does not match the default Hilla template, it is not overwritten.


## Limitations

The current Hilla support has some known limitations:

* The endpoint prefix is not configurable
* [Stateless Authentication](https://hilla.dev/docs/lit/guides/security/spring-stateless)
is not supported
* Native image compilation does not work
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,12 +284,26 @@ void registerEndpoints(
}

@BuildStep
void registerHillaPushServlet(BuildProducer<ServletBuildItem> servletProducer, NativeConfig nativeConfig) {
void registerHillaPushServlet(
BuildProducer<ServletBuildItem> servletProducer,
QuarkusEndpointConfiguration endpointConfiguration,
NativeConfig nativeConfig) {
ServletBuildItem.Builder builder = ServletBuildItem.builder(
QuarkusAtmosphereServlet.class.getName(), QuarkusAtmosphereServlet.class.getName());
builder.addMapping("/HILLA/push")
String prefix = endpointConfiguration.getEndpointPrefix();
if (prefix.matches("^/?connect/?$")) {
prefix = "/";
} else if (!prefix.startsWith("/")) {
prefix = "/" + prefix;
}
if (prefix.endsWith("/")) {
prefix = prefix.substring(0, prefix.length() - 1);
}
String hillaPushMapping = prefix + "/HILLA/push";

builder.addMapping(hillaPushMapping)
.setAsyncSupported(true)
.addInitParam(ApplicationConfig.JSR356_MAPPING_PATH, "/HILLA/push")
.addInitParam(ApplicationConfig.JSR356_MAPPING_PATH, hillaPushMapping)
.addInitParam(ApplicationConfig.ATMOSPHERE_HANDLER, PushEndpoint.class.getName())
.addInitParam(ApplicationConfig.OBJECT_FACTORY, HillaAtmosphereObjectFactory.class.getName())
.addInitParam(ApplicationConfig.ANALYTICS, "false")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/*
* Copyright 2024 Marco Collovati, Dario Götze
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.github.mcollovati.quarkus.hilla.deployment;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Scanner;

import com.vaadin.hilla.Endpoint;
import io.quarkus.bootstrap.model.ApplicationModel;
import io.quarkus.deployment.CodeGenContext;
import io.quarkus.deployment.CodeGenProvider;
import org.eclipse.microprofile.config.Config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.github.mcollovati.quarkus.hilla.QuarkusEndpointConfiguration;

public class TypescriptClientCodeGenProvider implements CodeGenProvider {

private static final Logger LOGGER = LoggerFactory.getLogger(TypescriptClientCodeGenProvider.class);

static final String FRONTEND_FOLDER_NAME = "frontend";
private Path frontendFolder;

@Override
public String providerId() {
return "quarkus-hilla-connect-client";
}

@Override
public String inputDirectory() {
return FRONTEND_FOLDER_NAME;
}

@Override
public void init(ApplicationModel model, Map<String, String> properties) {
if (model.getApplicationModule() == null) {
LOGGER.info("ApplicationModule is null");
return;
}
File moduleDir = model.getApplicationModule().getModuleDir();
Path legacyFrontendFolder = moduleDir.toPath().resolve(FRONTEND_FOLDER_NAME);
Path frontendDir = moduleDir.toPath().resolve(Path.of("src", "main", FRONTEND_FOLDER_NAME));

if (Files.isDirectory(frontendDir)) {
this.frontendFolder = frontendDir;
if (Files.isDirectory(legacyFrontendFolder)) {
LOGGER.warn(
"Using frontend folder {}, but also found legacy frontend folder {}. "
+ "Consider removing the legacy folder.",
frontendDir,
legacyFrontendFolder);
} else {
LOGGER.debug("Using frontend folder {}", frontendDir);
}
} else if (Files.isDirectory(legacyFrontendFolder)) {
this.frontendFolder = legacyFrontendFolder;
LOGGER.debug("Using legacy frontend folder {}", legacyFrontendFolder);
} else {
LOGGER.debug("Frontend folder not found");
}
}

@Override
public Path getInputDirectory() {
return frontendFolder;
}

@Override
public boolean shouldRun(Path sourceDir, Config config) {
if (!Files.isDirectory(sourceDir)) {
return false;
}
String prefix = computeConnectClientPrefix(config);
boolean defaultPrefix = "connect".equals(prefix);
Path customClient = sourceDir.resolve("connect-client.ts");
if (Files.exists(customClient)) {
try {
String content = Files.readString(customClient);
if (!content.contains("const client = new ConnectClient({prefix: '" + prefix + "'});")) {
LOGGER.debug(
"Custom connect-client.ts detected ({}), but prefix does not match configuration {}.",
customClient,
prefix);
return true;
}
} catch (IOException e) {
LOGGER.debug("Custom connect-client.ts detected ({}), but cannot read content.", customClient);
return false;
}
LOGGER.debug("Custom connect-client.ts detected ({}) with expected prefix {}.", customClient, prefix);
return false;
} else if (!defaultPrefix) {
LOGGER.debug("Custom prefix {} detected, connect-client.ts to be created in {}.", prefix, customClient);
}
return !defaultPrefix;
}

@Override
public boolean trigger(CodeGenContext context) {
String prefix = computeConnectClientPrefix(context.config());
boolean defaultPrefix = "connect".equals(prefix);
Path customClient = context.inputDir().resolve("connect-client.ts");
if (Files.exists(customClient)) {
String content = null;
try {
content = Files.readString(customClient);
} catch (IOException e) {
LOGGER.warn(
"Cannot read content of custom connect-client.ts ({}). File will not be overwritten.",
customClient,
e);
}
if (content != null) {
content = content.replaceFirst(
"(const client = new ConnectClient\\(\\{prefix:\\s*')[^']+('}\\);)", "$1" + prefix + "$2");
try {
Files.writeString(customClient, content);
LOGGER.debug(
"Prefix in custom connect-client.ts ({}) replaced with new value {}", customClient, prefix);
return true;
} catch (IOException e) {
LOGGER.warn("Cannot write content of custom connect-client.ts ({}).", customClient, e);
}
}
} else if (!defaultPrefix) {
return writeConnectClient(prefix, customClient);
}
return false;
}

private static String computeConnectClientPrefix(Config config) {
String prefix = config.getValue(QuarkusEndpointConfiguration.VAADIN_ENDPOINT_PREFIX, String.class);
if (prefix.startsWith("/")) {
prefix = prefix.substring(1);
}
if (prefix.endsWith("/")) {
prefix = prefix.substring(0, prefix.length() - 1);
}
return prefix;
}

static boolean writeConnectClient(String prefix, Path customClient) {
InputStream template = Endpoint.class.getResourceAsStream("/connect-client.default.template.ts");
if (template == null) {
LOGGER.debug("Cannot find template file connect-client.default.template.ts.");
return false;
}
try (InputStream is = template;
Scanner scanner = new Scanner(is).useDelimiter("\\A")) {
if (!scanner.hasNext()) {
LOGGER.debug("Template file connect-client.default.template.ts is empty.");
return false;
}
String out = scanner.next().replace("{{PREFIX}}", prefix);
Files.writeString(customClient, out, StandardCharsets.UTF_8);
return true;
} catch (IOException ex) {
LOGGER.warn("Cannot read template file connect-client.default.template.ts.", ex);
return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#
# Copyright 2024 Marco Collovati, Dario Götze
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
#

com.github.mcollovati.quarkus.hilla.deployment.TypescriptClientCodeGenProvider
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ abstract class AbstractEndpointControllerTest {
@Test
void invokeEndpoint_singleSimpleParameter() {
String msg = "A text message";
givenEndpointRequest(getEndpointName(), "echo", TestUtils.Parameters.param("message", msg))
givenEndpointRequest(getEndpointPrefix(), getEndpointName(), "echo", TestUtils.Parameters.param("message", msg))
.then()
.assertThat()
.statusCode(200)
Expand All @@ -46,7 +46,7 @@ void invokeEndpoint_singleSimpleParameter() {
void invokeEndpoint_singleComplexParameter() {
String msg = "A text message";
Pojo pojo = new Pojo(10, msg);
givenEndpointRequest(getEndpointName(), "pojo", TestUtils.Parameters.param("pojo", pojo))
givenEndpointRequest(getEndpointPrefix(), getEndpointName(), "pojo", TestUtils.Parameters.param("pojo", pojo))
.then()
.assertThat()
.statusCode(200)
Expand All @@ -59,6 +59,7 @@ void invokeEndpoint_singleComplexParameter() {
@Test
void invokeEndpoint_multipleParameters() {
givenEndpointRequest(
getEndpointPrefix(),
getEndpointName(),
"calculate",
TestUtils.Parameters.param("operator", "+").add("a", 10).add("b", 20))
Expand All @@ -72,6 +73,7 @@ void invokeEndpoint_multipleParameters() {
@Test
void invokeEndpoint_wrongParametersOrder_badRequest() {
givenEndpointRequest(
getEndpointPrefix(),
getEndpointName(),
"calculate",
TestUtils.Parameters.param("a", 10).add("operator", "+").add("b", 20))
Expand All @@ -92,7 +94,11 @@ void invokeEndpoint_wrongParametersOrder_badRequest() {

@Test
void invokeEndpoint_wrongNumberOfParameters_badRequest() {
givenEndpointRequest(getEndpointName(), "calculate", TestUtils.Parameters.param("operator", "+"))
givenEndpointRequest(
getEndpointPrefix(),
getEndpointName(),
"calculate",
TestUtils.Parameters.param("operator", "+"))
.then()
.assertThat()
.statusCode(400)
Expand All @@ -108,23 +114,31 @@ void invokeEndpoint_wrongNumberOfParameters_badRequest() {

@Test
void invokeEndpoint_wrongEndpointName_notFound() {
givenEndpointRequest("NotExistingTestEndpoint", "calculate", TestUtils.Parameters.param("operator", "+"))
givenEndpointRequest(
getEndpointPrefix(),
"NotExistingTestEndpoint",
"calculate",
TestUtils.Parameters.param("operator", "+"))
.then()
.assertThat()
.statusCode(404);
}

@Test
void invokeEndpoint_wrongMethodName_notFound() {
givenEndpointRequest(getEndpointName(), "notExistingMethod", TestUtils.Parameters.param("operator", "+"))
givenEndpointRequest(
getEndpointPrefix(),
getEndpointName(),
"notExistingMethod",
TestUtils.Parameters.param("operator", "+"))
.then()
.assertThat()
.statusCode(404);
}

@Test
void invokeEndpoint_emptyMethodName_notFound() {
givenEndpointRequest(getEndpointName(), "", TestUtils.Parameters.param("operator", "+"))
givenEndpointRequest(getEndpointPrefix(), getEndpointName(), "", TestUtils.Parameters.param("operator", "+"))
.then()
.assertThat()
.statusCode(404);
Expand All @@ -136,11 +150,15 @@ void invokeEndpoint_missingMethodName_notFound() {
.contentType(ContentType.JSON)
.cookie("csrfToken", "CSRF_TOKEN")
.header("X-CSRF-Token", "CSRF_TOKEN")
.basePath("/connect")
.basePath(getEndpointPrefix())
.when()
.post(getEndpointName())
.then()
.assertThat()
.statusCode(404);
}

protected String getEndpointPrefix() {
return TestUtils.DEFAULT_PREFIX;
}
}
Loading

0 comments on commit 4ef799f

Please sign in to comment.