From e601439421f1179fd339a2f7d103b65c2c70cac6 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Wed, 7 Aug 2024 11:31:54 +0200 Subject: [PATCH 01/16] ReactJS: don't crash if a props object returns null from a property. --- .../views/react/IntrospectableToPolyglotObject.java | 2 +- .../micronaut/views/react/ReactViewRenderSpec.groovy | 4 +++- .../views/react/SandboxReactRenderSpec.groovy | 2 +- .../test/java/io/micronaut/views/react/SomeBean.java | 10 +++++++++- views-react/src/test/js/components/App.js | 4 +++- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/IntrospectableToPolyglotObject.java b/views-react/src/main/java/io/micronaut/views/react/IntrospectableToPolyglotObject.java index fb0da8369..4945da088 100644 --- a/views-react/src/main/java/io/micronaut/views/react/IntrospectableToPolyglotObject.java +++ b/views-react/src/main/java/io/micronaut/views/react/IntrospectableToPolyglotObject.java @@ -42,7 +42,7 @@ class ProxyObjectWithIntrospectableSupport implements ProxyObject { @Override public Object getMember(String key) { Object result = map.get(key); - if (BeanIntrospector.SHARED.findIntrospection(result.getClass()).isPresent()) { + if (result != null && BeanIntrospector.SHARED.findIntrospection(result.getClass()).isPresent()) { return new ProxyObjectWithIntrospectableSupport(context, BeanMap.of(result)); } else { return context.asValue(result); diff --git a/views-react/src/test/groovy/io/micronaut/views/react/ReactViewRenderSpec.groovy b/views-react/src/test/groovy/io/micronaut/views/react/ReactViewRenderSpec.groovy index bdc52ea3e..090a0c193 100644 --- a/views-react/src/test/groovy/io/micronaut/views/react/ReactViewRenderSpec.groovy +++ b/views-react/src/test/groovy/io/micronaut/views/react/ReactViewRenderSpec.groovy @@ -15,7 +15,7 @@ class ReactViewRenderSpec extends Specification { void "views can be rendered with basic props"() { when: - Writable writable = renderer.render("App", ["name": "Mike"], null) + Writable writable = renderer.render("App", ["name": "Mike", "someNull": null, "obj": new SomeBean("foo", null)], null) String result = new StringWriter().with { writable.writeTo(it) it.toString() @@ -23,6 +23,8 @@ class ReactViewRenderSpec extends Specification { then: result.contains("Hello there") + result.contains("Reading a property works: foo") + result.contains("Reading a null works:

") result.contains("\"name\":\"Mike\"") } diff --git a/views-react/src/test/groovy/io/micronaut/views/react/SandboxReactRenderSpec.groovy b/views-react/src/test/groovy/io/micronaut/views/react/SandboxReactRenderSpec.groovy index 07f5868c1..fcd683204 100644 --- a/views-react/src/test/groovy/io/micronaut/views/react/SandboxReactRenderSpec.groovy +++ b/views-react/src/test/groovy/io/micronaut/views/react/SandboxReactRenderSpec.groovy @@ -20,7 +20,7 @@ class SandboxReactRenderSpec extends Specification { @FailsWith(BeanInstantiationException) void "views can be rendered with sandboxing enabled"() { given: - def props = ["name": "Mike", "obj": new SomeBean("bar")] + def props = ["name": "Mike", "obj": new SomeBean("bar", null)] when: Writable writable = renderer.render("App", props, null) diff --git a/views-react/src/test/java/io/micronaut/views/react/SomeBean.java b/views-react/src/test/java/io/micronaut/views/react/SomeBean.java index ab3c01635..9434e6268 100644 --- a/views-react/src/test/java/io/micronaut/views/react/SomeBean.java +++ b/views-react/src/test/java/io/micronaut/views/react/SomeBean.java @@ -1,6 +1,7 @@ package io.micronaut.views.react; import io.micronaut.core.annotation.Introspected; +import io.micronaut.core.annotation.Nullable; import org.graalvm.polyglot.HostAccess; /** @@ -9,13 +10,20 @@ @Introspected public class SomeBean { private final String foo; + private final @Nullable String bar; - SomeBean(String foo) { + SomeBean(String foo, String bar) { this.foo = foo; + this.bar = bar; } @HostAccess.Export public String getFoo() { return foo; } + + @HostAccess.Export + public String getBar() { + return bar; + } } diff --git a/views-react/src/test/js/components/App.js b/views-react/src/test/js/components/App.js index 60e3ba70b..d6bb9803a 100644 --- a/views-react/src/test/js/components/App.js +++ b/views-react/src/test/js/components/App.js @@ -1,7 +1,7 @@ // App.js import React from 'react'; -function App({name, url, triggerSandbox}) { +function App({name, obj, url, triggerSandbox}) { if (triggerSandbox) { // Verify that we aren't able to access host types due to the sandbox. Java.type("java.lang.System"); @@ -16,6 +16,8 @@ function App({name, url, triggerSandbox}) {

Hello there {name}, I'm saying hi from SSR React!

URL is {url}

+

Reading a property works: {obj.foo}

+

Reading a null works: {obj.bar}

); From 49940face7209bf1f5e86fd993ef8078a6a85e96 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Wed, 7 Aug 2024 12:30:55 +0200 Subject: [PATCH 02/16] ReactJS: Allow methods to be invoked on props objects too. --- .../docs/guide/views/templates/react.adoc | 2 + .../views/templates/react/reacttodo.adoc | 1 - .../react/IntrospectableToPolyglotObject.java | 115 ++++++++++++++++-- .../views/react/ReactViewsRenderer.java | 21 +--- .../views/react/PreactViewRenderSpec.groovy | 4 +- .../views/react/ReactViewRenderSpec.groovy | 3 +- .../io/micronaut/views/react/SomeBean.java | 7 ++ views-react/src/test/js/components/App.js | 1 + 8 files changed, 124 insertions(+), 30 deletions(-) diff --git a/src/main/docs/guide/views/templates/react.adoc b/src/main/docs/guide/views/templates/react.adoc index 4c67bb484..324d7e78b 100644 --- a/src/main/docs/guide/views/templates/react.adoc +++ b/src/main/docs/guide/views/templates/react.adoc @@ -41,6 +41,8 @@ include::{includedir}configurationProperties/io.micronaut.views.react.ReactViews Props can be supplied in the form of an introspectable bean or a `Map`. Both forms will be serialized to JSON and sent to the client for hydration, as well as used to render the root component. The URL of the current page will be taken from the request and added to the props under the `url` key, which is useful when working with libraries like https://github.com/preactjs/preact-router[`preact-router`]. If you use `Map` as your model type and use Micronaut Security, authenticated usernames and other security info will be added to your props automatically. +On the server side prop objects are exposed to Javascript directly, without being serialized to JSON first. Micronaut's compile time reflection is used and this avoids some overhead, but more importantly means that methods marked `@Executable` can be invoked by your server side JS. Note that this is true even if the sandbox is enabled: being marked `@Executable` is enough. Arguments and return values are mapped to/from Java in a https://www.graalvm.org/sdk/javadoc/org/graalvm/polyglot/Value.html#target-type-mapping-heading[natural manner]. If your props bean returns a non-introspectable object from a property, it will be mapped in the normal way for GraalJS (meaning it will use runtime reflection and may require `@HostAccess.Export` on methods you wish to call). + By default you will need React components that return the entire page, including the `` tag. You'll also need to prepare your Javascript (see below). Then just name your required page component in the `@View` annotation on a controller, for example `@View("App")` will render the `` component with your page props. If your page components don't render the whole page or you need better control over how the framework is invoked you can use _render scripts_ (see below). diff --git a/src/main/docs/guide/views/templates/react/reacttodo.adoc b/src/main/docs/guide/views/templates/react/reacttodo.adoc index 944b6318d..c56399a92 100644 --- a/src/main/docs/guide/views/templates/react/reacttodo.adoc +++ b/src/main/docs/guide/views/templates/react/reacttodo.adoc @@ -1,6 +1,5 @@ Micronaut React SSR has the following known issues and limitations: -- There is currently no way to extend the Javascript execution environment with custom Java-side objects. - There is no built-in support for server side fetching. - The rendering isn't streamed to the user. - `` is not supported. diff --git a/views-react/src/main/java/io/micronaut/views/react/IntrospectableToPolyglotObject.java b/views-react/src/main/java/io/micronaut/views/react/IntrospectableToPolyglotObject.java index 4945da088..1f6e8d253 100644 --- a/views-react/src/main/java/io/micronaut/views/react/IntrospectableToPolyglotObject.java +++ b/views-react/src/main/java/io/micronaut/views/react/IntrospectableToPolyglotObject.java @@ -15,13 +15,19 @@ */ package io.micronaut.views.react; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.beans.BeanIntrospection; import io.micronaut.core.beans.BeanIntrospector; import io.micronaut.core.beans.BeanMap; +import io.micronaut.core.beans.BeanMethod; import org.graalvm.polyglot.Context; import org.graalvm.polyglot.Value; import org.graalvm.polyglot.proxy.ProxyArray; +import org.graalvm.polyglot.proxy.ProxyExecutable; import org.graalvm.polyglot.proxy.ProxyObject; +import java.util.ArrayList; +import java.util.Collection; import java.util.Map; /** @@ -32,31 +38,122 @@ */ class ProxyObjectWithIntrospectableSupport implements ProxyObject { private final Context context; - private final Map map; + private final Object target; + private final boolean isStringMap; - ProxyObjectWithIntrospectableSupport(Context context, Map map) { + @Nullable + private final BeanIntrospection introspection; + + ProxyObjectWithIntrospectableSupport(Context context, Object targetObject) { + this.context = context; + + if (targetObject == null) { + throw new NullPointerException("Cannot proxy a null"); + } + + this.target = targetObject; + this.isStringMap = isStringMap(targetObject); + this.introspection = isStringMap ? null : BeanIntrospector.SHARED.findIntrospection(targetObject.getClass()).orElseThrow(); + } + + private ProxyObjectWithIntrospectableSupport(Context context, Object target, boolean isStringMap, BeanIntrospection introspection) { this.context = context; - this.map = map; + this.target = target; + this.isStringMap = isStringMap; + this.introspection = introspection; + } + + private static boolean isStringMap(Object obj) { + if (obj instanceof Map map) { + return map.keySet().stream().allMatch(it -> it instanceof String); + } else { + return false; + } } @Override public Object getMember(String key) { + Map map = asMap(); + + // Is it a property? Object result = map.get(key); - if (result != null && BeanIntrospector.SHARED.findIntrospection(result.getClass()).isPresent()) { - return new ProxyObjectWithIntrospectableSupport(context, BeanMap.of(result)); - } else { - return context.asValue(result); + if (result != null) { + boolean resultIsMap = isStringMap(result); + var resultIntrospection = BeanIntrospector.SHARED.findIntrospection(result.getClass()); + if (resultIsMap || resultIntrospection.isPresent()) { + return new ProxyObjectWithIntrospectableSupport(context, result, resultIsMap, resultIntrospection.orElseThrow()); + } else { + return context.asValue(result); + } + } + + // Can it be an @Executable method? + if (introspection != null) { + Collection> beanMethods = introspection.getBeanMethods(); + for (BeanMethod method : beanMethods) { + if (method.getName().equals(key)) { + return new PolyglotBeanMethod(beanMethods); + } + } + } + + return context.asValue(null); + } + + @SuppressWarnings("unchecked") + private Map asMap() { + return isStringMap ? (Map) target : BeanMap.of(target); + } + + @SuppressWarnings("rawtypes") + private class PolyglotBeanMethod implements ProxyExecutable { + private final Collection> candidates; + + private PolyglotBeanMethod(Collection> candidates) { + assert !candidates.isEmpty(); + this.candidates = candidates; + } + + @SuppressWarnings("unchecked") + @Override + public Object execute(Value... arguments) { + BeanMethod candidate = findCandidateByNumberOfArguments(arguments); + Object[] convertedArgs = new Object[arguments.length]; + for (int i = 0; i < arguments.length; i++) { + convertedArgs[i] = arguments[i].as(Object.class); + } + return context.asValue(candidate.invoke(target, convertedArgs)); + } + + private BeanMethod findCandidateByNumberOfArguments(Value[] arguments) { + int minNeeded = Integer.MAX_VALUE; + for (BeanMethod candidate : candidates) { + int numArgs = candidate.getArguments().length; + minNeeded = Math.min(minNeeded, numArgs); + if (numArgs == arguments.length) { + return candidate; + } + } + throw new UnsupportedOperationException(String.format("No candidates found with the right number of arguments for method %s, needed at least %d but got %d", candidates.iterator().next().getName(), minNeeded, arguments.length)); } } @Override public Object getMemberKeys() { - return ProxyArray.fromArray(map.keySet().toArray()); + return ProxyArray.fromList(getInvokableNames()); } @Override public boolean hasMember(String key) { - return map.containsKey(key); + return getInvokableNames().contains(key); + } + + private ArrayList getInvokableNames() { + ArrayList propNames = new ArrayList<>(asMap().keySet()); + if (introspection != null) { + introspection.getBeanMethods().forEach(it -> propNames.add(it.getName())); + } + return propNames; } @Override diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java index 6de5d335b..6b2660fc0 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java +++ b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java @@ -17,7 +17,6 @@ import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; -import io.micronaut.core.beans.BeanMap; import io.micronaut.core.io.Writable; import io.micronaut.http.HttpRequest; import io.micronaut.http.exceptions.MessageBodyException; @@ -31,7 +30,6 @@ import java.io.IOException; import java.io.Writer; import java.nio.charset.StandardCharsets; -import java.util.Map; /** *

Instantiates GraalJS and uses it to render React components server side. See the user guide @@ -96,24 +94,13 @@ private void render(String componentName, PROPS props, Writer writer, JSContext var renderCallback = new RenderCallback(writer, request); - // Get props into canonical String->Object form and from there wrap to ProxyObject for the - // JS engine. This is needed because props can come in several forms, and we need to - // wrap them recursively. - @SuppressWarnings("unchecked") - Map strObjMap = isStringMap(props) ? (Map) props : BeanMap.of(props); - ProxyObject propsObj = new ProxyObjectWithIntrospectableSupport(context.polyglotContext, strObjMap); - + // We wrap the props object so we can use Micronaut's compile-time reflection implementation. + // This should be more native-image friendly (no need to write reflection config files), and + // might also be faster. + ProxyObject propsObj = new ProxyObjectWithIntrospectableSupport(context.polyglotContext, props); context.render.execute(component, propsObj, renderCallback, reactConfiguration.getClientBundleURL(), request); } - private boolean isStringMap(PROPS props) { - if (props instanceof Map propsMap) { - return propsMap.keySet().stream().allMatch(it -> it instanceof String); - } else { - return false; - } - } - /** * Methods exposed to the ReactJS components and render scripts. Needs to be public to be * callable from the JS side. diff --git a/views-react/src/test/groovy/io/micronaut/views/react/PreactViewRenderSpec.groovy b/views-react/src/test/groovy/io/micronaut/views/react/PreactViewRenderSpec.groovy index 47356b549..5748f898b 100644 --- a/views-react/src/test/groovy/io/micronaut/views/react/PreactViewRenderSpec.groovy +++ b/views-react/src/test/groovy/io/micronaut/views/react/PreactViewRenderSpec.groovy @@ -21,7 +21,7 @@ class PreactViewRenderSpec extends Specification { void "views can be rendered with basic props and no request"() { when: - Writable writable = renderer.render("App", ["name": "Mike"], null) + Writable writable = renderer.render("App", ["name": "Mike", "obj": new SomeBean("foo", null)], null) String result = new StringWriter().with { writable.writeTo(it) it.toString() @@ -39,7 +39,7 @@ class PreactViewRenderSpec extends Specification { req.getUri() >> URI.create("https://localhost/demopage") when: - Writable writable = renderer.render("App", ["name": "Mike"], req) + Writable writable = renderer.render("App", ["name": "Mike", "obj": new SomeBean("foo", null)], req) String result = new StringWriter().with { writable.writeTo(it) it.toString() diff --git a/views-react/src/test/groovy/io/micronaut/views/react/ReactViewRenderSpec.groovy b/views-react/src/test/groovy/io/micronaut/views/react/ReactViewRenderSpec.groovy index 090a0c193..d52e60452 100644 --- a/views-react/src/test/groovy/io/micronaut/views/react/ReactViewRenderSpec.groovy +++ b/views-react/src/test/groovy/io/micronaut/views/react/ReactViewRenderSpec.groovy @@ -26,6 +26,7 @@ class ReactViewRenderSpec extends Specification { result.contains("Reading a property works: foo") result.contains("Reading a null works:

") result.contains("\"name\":\"Mike\"") + result.contains("Calling a method works: Goodbye Bob!") } void "views can be rendered with basic props with a request"() { @@ -34,7 +35,7 @@ class ReactViewRenderSpec extends Specification { req.getUri() >> URI.create("https://localhost/demopage") when: - Writable writable = renderer.render("App", ["name": "Mike"], req) + Writable writable = renderer.render("App", ["name": "Mike", "obj": new SomeBean("foo", null)], req) String result = new StringWriter().with { writable.writeTo(it) it.toString() diff --git a/views-react/src/test/java/io/micronaut/views/react/SomeBean.java b/views-react/src/test/java/io/micronaut/views/react/SomeBean.java index 9434e6268..439416051 100644 --- a/views-react/src/test/java/io/micronaut/views/react/SomeBean.java +++ b/views-react/src/test/java/io/micronaut/views/react/SomeBean.java @@ -1,5 +1,6 @@ package io.micronaut.views.react; +import io.micronaut.context.annotation.Executable; import io.micronaut.core.annotation.Introspected; import io.micronaut.core.annotation.Nullable; import org.graalvm.polyglot.HostAccess; @@ -26,4 +27,10 @@ public String getFoo() { public String getBar() { return bar; } + + @HostAccess.Export + @Executable + public String sayGoodbye(String name) { + return "Goodbye " + name + "!"; + } } diff --git a/views-react/src/test/js/components/App.js b/views-react/src/test/js/components/App.js index d6bb9803a..966dd0b17 100644 --- a/views-react/src/test/js/components/App.js +++ b/views-react/src/test/js/components/App.js @@ -18,6 +18,7 @@ function App({name, obj, url, triggerSandbox}) {

URL is {url}

Reading a property works: {obj.foo}

Reading a null works: {obj.bar}

+

Calling a method works: {obj.sayGoodbye("Bob")}

); From ba7170c69db42324098bdca02f61fc15ff7d8f4d Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Wed, 7 Aug 2024 14:19:09 +0200 Subject: [PATCH 03/16] ReactJS: Fix up the sandbox test. It's currently disabled whilst waiting for a new GraalVM release. --- .../io/micronaut/views/react/SandboxReactRenderSpec.groovy | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/views-react/src/test/groovy/io/micronaut/views/react/SandboxReactRenderSpec.groovy b/views-react/src/test/groovy/io/micronaut/views/react/SandboxReactRenderSpec.groovy index fcd683204..b2073bdb9 100644 --- a/views-react/src/test/groovy/io/micronaut/views/react/SandboxReactRenderSpec.groovy +++ b/views-react/src/test/groovy/io/micronaut/views/react/SandboxReactRenderSpec.groovy @@ -31,7 +31,7 @@ class SandboxReactRenderSpec extends Specification { then: result.contains("Hello there") - result.contains("\"name\":\"Mike\",\"obj\":{\"foo\":\"bar\"}") + result.contains("{\"name\":\"Mike\",\"obj\":{\"bar\":null,\"foo\":\"bar\"}}") } void "host types are inaccessible with the sandbox enabled"() { @@ -46,6 +46,6 @@ class SandboxReactRenderSpec extends Specification { // The version of GraalJS currently depended on is not compatible with the sandbox. When GraalJS is upgraded, // this unit test can be enabled. thrown(BeanInstantiationException) -// thrown(PolyglotException) +// thrown(MessageBodyException) } } From f8832f9f32ecee64fc691decc77345d0eb81e5bb Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Wed, 7 Aug 2024 14:22:01 +0200 Subject: [PATCH 04/16] ReactJS: Rename a file. The old name was incorrect and confusing. --- ...yglotObject.java => ProxyObjectWithIntrospectableSupport.java} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename views-react/src/main/java/io/micronaut/views/react/{IntrospectableToPolyglotObject.java => ProxyObjectWithIntrospectableSupport.java} (100%) diff --git a/views-react/src/main/java/io/micronaut/views/react/IntrospectableToPolyglotObject.java b/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java similarity index 100% rename from views-react/src/main/java/io/micronaut/views/react/IntrospectableToPolyglotObject.java rename to views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java From 20c9e3d0dde7731a2e3641c7aa464f03b615a7b7 Mon Sep 17 00:00:00 2001 From: Mike Hearn Date: Wed, 7 Aug 2024 15:29:33 +0200 Subject: [PATCH 05/16] ReactJS: Make HostAccess customizable. --- .../docs/guide/views/templates/react.adoc | 24 ++++++++++++--- .../micronaut/views/react/JSBeanFactory.java | 30 +++++++++++++++++++ .../micronaut/views/react/JSSandboxing.java | 18 +++++------ .../views/react/ReactViewRenderSpec.groovy | 9 ++++++ .../views/react/SandboxReactRenderSpec.groovy | 6 ++-- .../io/micronaut/views/react/SomeBean.java | 4 --- views-react/src/test/js/components/App.js | 14 +++++---- 7 files changed, 80 insertions(+), 25 deletions(-) create mode 100644 views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java diff --git a/src/main/docs/guide/views/templates/react.adoc b/src/main/docs/guide/views/templates/react.adoc index 324d7e78b..430648285 100644 --- a/src/main/docs/guide/views/templates/react.adoc +++ b/src/main/docs/guide/views/templates/react.adoc @@ -41,14 +41,30 @@ include::{includedir}configurationProperties/io.micronaut.views.react.ReactViews Props can be supplied in the form of an introspectable bean or a `Map`. Both forms will be serialized to JSON and sent to the client for hydration, as well as used to render the root component. The URL of the current page will be taken from the request and added to the props under the `url` key, which is useful when working with libraries like https://github.com/preactjs/preact-router[`preact-router`]. If you use `Map` as your model type and use Micronaut Security, authenticated usernames and other security info will be added to your props automatically. -On the server side prop objects are exposed to Javascript directly, without being serialized to JSON first. Micronaut's compile time reflection is used and this avoids some overhead, but more importantly means that methods marked `@Executable` can be invoked by your server side JS. Note that this is true even if the sandbox is enabled: being marked `@Executable` is enough. Arguments and return values are mapped to/from Java in a https://www.graalvm.org/sdk/javadoc/org/graalvm/polyglot/Value.html#target-type-mapping-heading[natural manner]. If your props bean returns a non-introspectable object from a property, it will be mapped in the normal way for GraalJS (meaning it will use runtime reflection and may require `@HostAccess.Export` on methods you wish to call). +On the server side prop objects are exposed to Javascript directly, without being serialized to JSON first. Micronaut's compile time reflection is used and this avoids some overhead as well as simplifying access to your props (see below). If your props bean returns a non-introspectable object from a property, it will be mapped in the normal way for GraalJS (meaning it will use runtime reflection and may require `@HostAccess.Export` on methods you wish to call). -By default you will need React components that return the entire page, including the `` tag. You'll also need to prepare your Javascript (see below). Then just name your required page component in the `@View` annotation on a controller, for example `@View("App")` will render the `` component with your page props. +By default, you will need React components that return the entire page, including the `` tag. You'll also need to prepare your Javascript (see below). Then just name your required page component in the `@View` annotation on a controller, for example `@View("App")` will render the `` component with your page props. If your page components don't render the whole page or you need better control over how the framework is invoked you can use _render scripts_ (see below). +== Accessing Java from Javascript + +The https://www.graalvm.org/latest/reference-manual/embed-languages/#access-java-from-guest-languages[usual GraalJS rules for accessing Java apply] with a few differences: + +1. Your root prop object and any introspectable object reachable from it can be accessed using normal Javascript property syntax, for instance if you have an `@Introspectable` bean with a `String getFoo()` method then you can just access that property by writing `props.foo` instead of `props.getFoo()`, as would normally be required when accessing Java objects. +2. Methods annotated with `@Executable` can be invoked from Javascript. Arguments and return values are mapped to/from Java in a https://www.graalvm.org/sdk/javadoc/org/graalvm/polyglot/Value.html#target-type-mapping-heading[natural manner]. +3. Your code can use `Java.type("com.foo.bar.BazClass")` style calls to get access to Java classes and then instantiate them or call static methods on them. + +Note that props are read only. Attempting to set the value of a Java property on a props object will fail. + == Sandbox -By default Javascript executing server side runs with the same privilege level as the server itself. This is similar to the Node security model, but exposes you to supply chain attacks. If a third party React component you depend on turns out to be malicious or simply buggy, it could allow an attacker to run code server side instead of only inside the browser sandbox. +By default, Javascript executing server side runs with the same privilege level as the server itself. This is similar to the Node security model but exposes you to supply chain attacks. If a third party React component you depend on turns out to be malicious or simply buggy, it could allow an attacker to run code server side instead of only inside the browser sandbox. + +Normally with React SSR you can't do much about this, but with Micronaut Views React you can enable a server-side sandbox if you use GraalVM 24.1 or higher. This prevents Javascript from accessing any Java host objects that haven't been specifically exposed into the sandbox. To use this set `micronaut.views.react.sandbox` to true in your `application.properties`. + +In this mode: -Normally with React SSR you can't do much about this, but with Micronaut Views React you can enable a server-side sandbox if you use GraalVM 24.1 or higher. This prevents Javascript from accessing any Java host objects that haven't been specifically marked as accessible to the sandbox. To use this set `micronaut.views.react.sandbox` to true in your `application.properties`, and then ensure that any objects you use as props have their property getters annotated with `@org.graalvm.polyglot.HostAccess.Export`. If there are properties that happen to be on your beans that should _not_ be exposed to Javascript, just don't annotate them. Any properties not annotated will simply be invisible from inside the sandbox. +- The `Java` top level object that lets code access any class will be gone. +- Methods in `@Introspectable` objects reachable from your prop objects that are marked as `@Executable` will be exposed into the sandbox _regardless_ of sandbox settings. So be careful what methods you add to your props. +- Any objects exposed via your root props that are *not* marked `@Introspectable` will be exposed via runtime reflection instead. In that case what's available inside the sandbox will depend on the https://www.graalvm.org/sdk/javadoc/org/graalvm/polyglot/HostAccess.html[`HostAccess`] policy, which can be customized by using the factory replacement mechanism (see the docs for Micronaut Core for details). By default anything not annotated with `@HostAccess.Exposed` will be invisible and uninvokable. Normally this is sufficient, but customizing the `HostAccess` can be useful if you want to expose third party code you don't control into the sandbox. diff --git a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java new file mode 100644 index 000000000..c959d7fd7 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java @@ -0,0 +1,30 @@ +package io.micronaut.views.react; + +import io.micronaut.context.annotation.Bean; +import io.micronaut.context.annotation.Factory; +import io.micronaut.core.annotation.Internal; +import org.graalvm.polyglot.HostAccess; + +/** + * Allows the default Javascript context and host access policy to be controlled. + */ +@Factory +@Internal +class JSBeanFactory { + /** + * This defaults to + * {@link HostAccess#ALL} if the sandbox is disabled, or {@link HostAccess#CONSTRAINED} if it's on. + * By replacing the {@link HostAccess} bean you can whitelist methods/properties by name or + * annotation, which can be useful for exposing third party libraries where you can't add the + * normal {@link HostAccess.Export} annotation, or allowing sandboxed JS to extend or implement + * Java types. + */ + @Bean + HostAccess hostAccess(ReactViewsRendererConfiguration configuration) { + if (configuration.getSandbox()) { + return HostAccess.CONSTRAINED; + } else { + return HostAccess.ALL; + } + } +} diff --git a/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java b/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java index 5857a50ef..44e220f66 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java @@ -33,31 +33,31 @@ class JSSandboxing { private static final Logger LOG = LoggerFactory.getLogger(JSSandboxing.class); private final boolean sandbox; + private final HostAccess hostAccess; @Inject - JSSandboxing(ReactViewsRendererConfiguration configuration) { + JSSandboxing(ReactViewsRendererConfiguration configuration, HostAccess hostAccess) { sandbox = configuration.getSandbox(); if (sandbox) { LOG.debug("ReactJS sandboxing enabled"); } else { LOG.debug("ReactJS sandboxing disabled"); } + this.hostAccess = hostAccess; } Engine.Builder configure(Engine.Builder engineBuilder) { return engineBuilder.sandbox(sandbox ? SandboxPolicy.CONSTRAINED : SandboxPolicy.TRUSTED); } - Context.Builder configure(Context.Builder contextBuilder) { + Context.Builder configure(Context.Builder builder) { if (sandbox) { - return contextBuilder - .sandbox(SandboxPolicy.CONSTRAINED) - .allowHostAccess(HostAccess.CONSTRAINED); + return builder.sandbox(SandboxPolicy.CONSTRAINED).allowHostAccess(hostAccess); } else { - return contextBuilder - .sandbox(SandboxPolicy.TRUSTED) - .allowAllAccess(true) - .allowExperimentalOptions(true); + // allowExperimentalOptions is here because as of the time of writing (August 2024) + // the esm-eval-returns-exports option is experimental. That got fixed and this + // can be removed once the base version of GraalJS is bumped to 24.1 or higher. + return builder.sandbox(SandboxPolicy.TRUSTED).allowAllAccess(true).allowExperimentalOptions(true); } } } diff --git a/views-react/src/test/groovy/io/micronaut/views/react/ReactViewRenderSpec.groovy b/views-react/src/test/groovy/io/micronaut/views/react/ReactViewRenderSpec.groovy index d52e60452..a37b82b45 100644 --- a/views-react/src/test/groovy/io/micronaut/views/react/ReactViewRenderSpec.groovy +++ b/views-react/src/test/groovy/io/micronaut/views/react/ReactViewRenderSpec.groovy @@ -3,6 +3,7 @@ package io.micronaut.views.react import io.micronaut.context.annotation.Property import io.micronaut.core.io.Writable import io.micronaut.http.HttpRequest +import io.micronaut.http.exceptions.MessageBodyException import io.micronaut.test.extensions.spock.annotation.MicronautTest import jakarta.inject.Inject import spock.lang.Specification @@ -46,4 +47,12 @@ class ReactViewRenderSpec extends Specification { result.contains("\"name\":\"Mike\"") // props result.contains("URL is https://localhost/demopage") } + + void "host access is OK if sandbox is disabled"() { + when: + renderer.render("App", ["name": "Mike", "triggerSandbox": true, "obj": new SomeBean("foo", null)], null).writeTo(OutputStream.nullOutputStream()) + + then: + notThrown(MessageBodyException) + } } diff --git a/views-react/src/test/groovy/io/micronaut/views/react/SandboxReactRenderSpec.groovy b/views-react/src/test/groovy/io/micronaut/views/react/SandboxReactRenderSpec.groovy index b2073bdb9..c647ef948 100644 --- a/views-react/src/test/groovy/io/micronaut/views/react/SandboxReactRenderSpec.groovy +++ b/views-react/src/test/groovy/io/micronaut/views/react/SandboxReactRenderSpec.groovy @@ -36,7 +36,7 @@ class SandboxReactRenderSpec extends Specification { void "host types are inaccessible with the sandbox enabled"() { when: - Writable writable = renderer.render("App", ["name": "Mike", "triggerSandbox": true], null) + Writable writable = renderer.render("App", ["name": "Mike", "triggerSandbox": true, "obj": new SomeBean("foo", null)], null) new StringWriter().with { writable.writeTo(it) it.toString() @@ -46,6 +46,8 @@ class SandboxReactRenderSpec extends Specification { // The version of GraalJS currently depended on is not compatible with the sandbox. When GraalJS is upgraded, // this unit test can be enabled. thrown(BeanInstantiationException) -// thrown(MessageBodyException) +// def t = thrown(MessageBodyException) +// t.cause instanceof PolyglotException +// t.cause.message.contains("Java is not defined") } } diff --git a/views-react/src/test/java/io/micronaut/views/react/SomeBean.java b/views-react/src/test/java/io/micronaut/views/react/SomeBean.java index 439416051..0cfdf1a8d 100644 --- a/views-react/src/test/java/io/micronaut/views/react/SomeBean.java +++ b/views-react/src/test/java/io/micronaut/views/react/SomeBean.java @@ -3,7 +3,6 @@ import io.micronaut.context.annotation.Executable; import io.micronaut.core.annotation.Introspected; import io.micronaut.core.annotation.Nullable; -import org.graalvm.polyglot.HostAccess; /** * Test bean accessed from inside the sandbox. @@ -18,17 +17,14 @@ public class SomeBean { this.bar = bar; } - @HostAccess.Export public String getFoo() { return foo; } - @HostAccess.Export public String getBar() { return bar; } - @HostAccess.Export @Executable public String sayGoodbye(String name) { return "Goodbye " + name + "!"; diff --git a/views-react/src/test/js/components/App.js b/views-react/src/test/js/components/App.js index 966dd0b17..cb0e83f8e 100644 --- a/views-react/src/test/js/components/App.js +++ b/views-react/src/test/js/components/App.js @@ -2,12 +2,7 @@ import React from 'react'; function App({name, obj, url, triggerSandbox}) { - if (triggerSandbox) { - // Verify that we aren't able to access host types due to the sandbox. - Java.type("java.lang.System"); - } - - return ( + const markup = ( Hello World! @@ -22,6 +17,13 @@ function App({name, obj, url, triggerSandbox}) { ); + + if (triggerSandbox) { + // Verify that we aren't able to access host types due to the sandbox. + Java.type("java.lang.System"); + } + + return markup; } export default App; From a9e92553d09627f584a16a3d4b8d679c516237f3 Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Wed, 7 Aug 2024 16:14:34 +0200 Subject: [PATCH 06/16] Update views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java --- .../io/micronaut/views/react/JSBeanFactory.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java index c959d7fd7..169343230 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java @@ -1,3 +1,18 @@ +/* + * Copyright 2017-2024 original authors + * + * 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 + * + * https://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 io.micronaut.views.react; import io.micronaut.context.annotation.Bean; From 88ce993a1c5d2f8d8dac19c6672c7820a09598d0 Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Wed, 7 Aug 2024 16:33:46 +0200 Subject: [PATCH 07/16] Update views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java --- .../src/main/java/io/micronaut/views/react/JSSandboxing.java | 1 - 1 file changed, 1 deletion(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java b/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java index 44e220f66..d1cc3fc89 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java @@ -35,7 +35,6 @@ class JSSandboxing { private final boolean sandbox; private final HostAccess hostAccess; - @Inject JSSandboxing(ReactViewsRendererConfiguration configuration, HostAccess hostAccess) { sandbox = configuration.getSandbox(); if (sandbox) { From 96f19fffdd1fe48ef1a0a9e1d92b2978070759e9 Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Wed, 7 Aug 2024 16:33:54 +0200 Subject: [PATCH 08/16] Update views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java --- .../main/java/io/micronaut/views/react/JSSandboxing.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java b/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java index d1cc3fc89..3a7a6c905 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java @@ -37,11 +37,9 @@ class JSSandboxing { JSSandboxing(ReactViewsRendererConfiguration configuration, HostAccess hostAccess) { sandbox = configuration.getSandbox(); - if (sandbox) { - LOG.debug("ReactJS sandboxing enabled"); - } else { - LOG.debug("ReactJS sandboxing disabled"); - } + if (LOG.isDebugEnabled()) { + LOG.debug("ReactJS sandboxing {}", sandbox ? "enabled" : "disabled"); + } this.hostAccess = hostAccess; } From ead2442076987b02cbba0da406eb07e2e4ef7d5a Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Wed, 7 Aug 2024 16:35:20 +0200 Subject: [PATCH 09/16] remove unused import --- .../src/main/java/io/micronaut/views/react/JSSandboxing.java | 1 - 1 file changed, 1 deletion(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java b/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java index 3a7a6c905..3610735e8 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java @@ -16,7 +16,6 @@ package io.micronaut.views.react; import io.micronaut.core.annotation.Internal; -import jakarta.inject.Inject; import jakarta.inject.Singleton; import org.graalvm.polyglot.Context; import org.graalvm.polyglot.Engine; From 19ea1d2ef5b9d6b0f5e25933cdfa0b74a50045e8 Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Wed, 7 Aug 2024 16:35:29 +0200 Subject: [PATCH 10/16] annotate with @Internal --- .../views/react/ProxyObjectWithIntrospectableSupport.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java b/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java index 1f6e8d253..7dbb67eb9 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java +++ b/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java @@ -15,6 +15,7 @@ */ package io.micronaut.views.react; +import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.Nullable; import io.micronaut.core.beans.BeanIntrospection; import io.micronaut.core.beans.BeanIntrospector; @@ -36,6 +37,7 @@ * Reading a key whose value is an introspectable bean will use the {@link BeanMap} instead of * the regular polyglot mapping. */ +@Internal class ProxyObjectWithIntrospectableSupport implements ProxyObject { private final Context context; private final Object target; From 4cb85560b7f8d71d750fa3d202603549e0a2c93a Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Wed, 7 Aug 2024 16:39:36 +0200 Subject: [PATCH 11/16] Update views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java --- .../src/main/java/io/micronaut/views/react/JSBeanFactory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java index 169343230..62c88cca0 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java @@ -34,7 +34,7 @@ class JSBeanFactory { * normal {@link HostAccess.Export} annotation, or allowing sandboxed JS to extend or implement * Java types. */ - @Bean + @Singleton HostAccess hostAccess(ReactViewsRendererConfiguration configuration) { if (configuration.getSandbox()) { return HostAccess.CONSTRAINED; From 0323677392f44a75e52fe7a4bc55436a02fcf64c Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Wed, 7 Aug 2024 16:39:44 +0200 Subject: [PATCH 12/16] Update views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java --- .../src/main/java/io/micronaut/views/react/JSBeanFactory.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java index 62c88cca0..9c432f16d 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java @@ -15,7 +15,7 @@ */ package io.micronaut.views.react; -import io.micronaut.context.annotation.Bean; +import jakarta.inject.Singleton;; import io.micronaut.context.annotation.Factory; import io.micronaut.core.annotation.Internal; import org.graalvm.polyglot.HostAccess; From e4cf1423fe3ec71d1640c89c81693b487215eba4 Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Wed, 7 Aug 2024 16:39:50 +0200 Subject: [PATCH 13/16] Update views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java --- .../main/java/io/micronaut/views/react/JSBeanFactory.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java index 9c432f16d..972930924 100644 --- a/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java +++ b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java @@ -36,10 +36,8 @@ class JSBeanFactory { */ @Singleton HostAccess hostAccess(ReactViewsRendererConfiguration configuration) { - if (configuration.getSandbox()) { - return HostAccess.CONSTRAINED; - } else { - return HostAccess.ALL; - } + return configuration.getSandbox() + ? HostAccess.CONSTRAINED + : HostAccess.ALL; } } From 80bff54e273c044b2f4d02ac54bfb01191d71a78 Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Wed, 7 Aug 2024 16:41:40 +0200 Subject: [PATCH 14/16] checkstyle: Init blocks, constructors, fields and methods should be before inner types. --- .../ProxyObjectWithIntrospectableSupport.java | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java b/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java index 7dbb67eb9..ac1424180 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java +++ b/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java @@ -102,6 +102,30 @@ public Object getMember(String key) { return context.asValue(null); } + @Override + public Object getMemberKeys() { + return ProxyArray.fromList(getInvokableNames()); + } + + @Override + public boolean hasMember(String key) { + return getInvokableNames().contains(key); + } + + private ArrayList getInvokableNames() { + ArrayList propNames = new ArrayList<>(asMap().keySet()); + if (introspection != null) { + introspection.getBeanMethods().forEach(it -> propNames.add(it.getName())); + } + return propNames; + } + + @Override + public void putMember(String key, Value value) { + throw new UnsupportedOperationException(); + } + + @SuppressWarnings("unchecked") private Map asMap() { return isStringMap ? (Map) target : BeanMap.of(target); @@ -139,27 +163,4 @@ private BeanMethod findCandidateByNumberOfArguments(Value[] arguments) { throw new UnsupportedOperationException(String.format("No candidates found with the right number of arguments for method %s, needed at least %d but got %d", candidates.iterator().next().getName(), minNeeded, arguments.length)); } } - - @Override - public Object getMemberKeys() { - return ProxyArray.fromList(getInvokableNames()); - } - - @Override - public boolean hasMember(String key) { - return getInvokableNames().contains(key); - } - - private ArrayList getInvokableNames() { - ArrayList propNames = new ArrayList<>(asMap().keySet()); - if (introspection != null) { - introspection.getBeanMethods().forEach(it -> propNames.add(it.getName())); - } - return propNames; - } - - @Override - public void putMember(String key, Value value) { - throw new UnsupportedOperationException(); - } } From 4877495667cd05cf51d7ae7b3f51eb2908bd9887 Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Wed, 7 Aug 2024 16:42:11 +0200 Subject: [PATCH 15/16] checksytle: Class PolyglotBeanMethod should be declared as final. --- .../views/react/ProxyObjectWithIntrospectableSupport.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java b/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java index ac1424180..d6fd15586 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java +++ b/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java @@ -124,15 +124,14 @@ private ArrayList getInvokableNames() { public void putMember(String key, Value value) { throw new UnsupportedOperationException(); } - - + @SuppressWarnings("unchecked") private Map asMap() { return isStringMap ? (Map) target : BeanMap.of(target); } @SuppressWarnings("rawtypes") - private class PolyglotBeanMethod implements ProxyExecutable { + private final class PolyglotBeanMethod implements ProxyExecutable { private final Collection> candidates; private PolyglotBeanMethod(Collection> candidates) { From dadcc36a469924c11273f3c1e5678d1ace8ce289 Mon Sep 17 00:00:00 2001 From: Sergio del Amo Date: Wed, 7 Aug 2024 16:42:33 +0200 Subject: [PATCH 16/16] =?UTF-8?q?checkstyle:=20=E2=80=98METHOD=5FDEF'=20ha?= =?UTF-8?q?s=20more=20than=201=20empty=20lines=20before.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/react/ProxyObjectWithIntrospectableSupport.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java b/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java index d6fd15586..73a480d2e 100644 --- a/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java +++ b/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java @@ -124,7 +124,7 @@ private ArrayList getInvokableNames() { public void putMember(String key, Value value) { throw new UnsupportedOperationException(); } - + @SuppressWarnings("unchecked") private Map asMap() { return isStringMap ? (Map) target : BeanMap.of(target);