diff --git a/.github/renovate.json b/.github/renovate.json index ed525891b..09c2a5983 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -12,7 +12,15 @@ "packageRules": [ { "matchPackagePatterns": ["actions.*"], - "dependencyDashboardApproval": true + "dependencyDashboardApproval": true, + "matchUpdateTypes": ["patch"], + "matchCurrentVersion": "!/^0/", + "automerge": true + }, + { + "matchUpdateTypes": ["patch"], + "matchCurrentVersion": "!/^0/", + "automerge": true } ] } diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index cc034b1e3..26ae97640 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -45,14 +45,14 @@ jobs: fetch-depth: 0 - name: "🔧 Setup GraalVM CE" - uses: graalvm/setup-graalvm@v1.2.1 + uses: graalvm/setup-graalvm@v1.2.5 with: distribution: 'graalvm' java-version: ${{ matrix.java }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: "🔧 Setup Gradle" - uses: gradle/gradle-build-action@v3.3.0 + uses: gradle/gradle-build-action@v3.5.0 - name: "❓ Optional setup step" run: | @@ -70,7 +70,7 @@ jobs: - name: "📊 Publish Test Report" if: always() - uses: mikepenz/action-junit-report@v4 + uses: mikepenz/action-junit-report@v5 with: check_name: Java CI / Test Report (${{ matrix.java }}) report_paths: '**/build/test-results/test/TEST-*.xml' @@ -78,7 +78,7 @@ jobs: - name: "📜 Upload binary compatibility check results" if: matrix.java == '17' - uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: binary-compatibility-reports path: "**/build/reports/binary-compatibility-*.html" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 065ee5620..2dda05848 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,13 +66,13 @@ jobs: # Store the hash in a file, which is uploaded as a workflow artifact. sha256sum $ARTIFACTS | base64 -w0 > artifacts-sha256 - name: Upload build artifacts - uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: gradle-build-outputs path: build/repo/${{ steps.publish.outputs.group }}/*/${{ steps.publish.outputs.version }}/* retention-days: 5 - name: Upload artifacts-sha256 - uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 with: name: artifacts-sha256 path: artifacts-sha256 @@ -115,7 +115,7 @@ jobs: artifacts-sha256: ${{ steps.set-hash.outputs.artifacts-sha256 }} steps: - name: Download artifacts-sha256 - uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: artifacts-sha256 # The SLSA provenance generator expects the hash digest of artifacts to be passed as a job @@ -134,7 +134,7 @@ jobs: actions: read # To read the workflow path. id-token: write # To sign the provenance. contents: write # To add assets to a release. - uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.10.0 + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0 with: base64-subjects: "${{ needs.provenance-subject.outputs.artifacts-sha256 }}" upload-assets: true # Upload to a new release. @@ -146,11 +146,9 @@ jobs: if: startsWith(github.ref, 'refs/tags/') steps: - name: Checkout repository - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Download artifacts - # Important: update actions/download-artifact to v4 only when generator_generic_slsa3.yml is also compatible. - # See https://github.com/slsa-framework/slsa-github-generator/issues/3068 - uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: name: gradle-build-outputs path: build/repo @@ -162,6 +160,6 @@ jobs: - name: Upload assets # Upload the artifacts to the existing release. Note that the SLSA provenance will # attest to each artifact file and not the aggregated ZIP file. - uses: softprops/action-gh-release@de2c0eb89ae2a093876385947365aca7b0e5f844 # v0.1.15 + uses: softprops/action-gh-release@e7a8f85e1c67a31e6ed99a94b41bd0b71bbee6b8 # v2.0.9 with: files: artifacts.zip diff --git a/buildSrc/src/main/groovy/io.micronaut.build.internal.views-fieldset-tck.gradle b/buildSrc/src/main/groovy/io.micronaut.build.internal.views-fieldset-tck.gradle index 1c94d0349..1cd3f3657 100644 --- a/buildSrc/src/main/groovy/io.micronaut.build.internal.views-fieldset-tck.gradle +++ b/buildSrc/src/main/groovy/io.micronaut.build.internal.views-fieldset-tck.gradle @@ -13,9 +13,9 @@ dependencies { testImplementation(projects.micronautViewsFieldset) testImplementation(projects.micronautViewsFieldsetTck) - testImplementation(libs.junit.jupiter.api) + testImplementation(mnTest.junit.jupiter.api) testImplementation(mnTest.micronaut.test.junit5) - testImplementation(libs.junit.jupiter.engine) + testImplementation(mnTest.junit.jupiter.engine) testImplementation(libs.junit.platform.engine) } test { diff --git a/gradle.properties b/gradle.properties index 06595a4b3..f7beb87c4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -projectVersion=5.3.0-SNAPSHOT +projectVersion=5.6.0-SNAPSHOT projectGroup=io.micronaut.views title=Micronaut Views @@ -18,6 +18,8 @@ testsviewsRocker=views-rocker/src/test testsviewsPebble=views-pebble/src/test testsviewsJte=views-jte/src/test testsviewsJstachio=views-jstachio/src/test +srcjsReact=views-react/src/test/js +srcjsReactRender=views-react/src/main/resources/io/micronaut/views/react org.gradle.caching=true org.gradle.jvmargs=-Xmx1g diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index aa4f88f89..9f09738d5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,29 +1,31 @@ [versions] -micronaut = "4.4.4" -micronaut-platform = "4.3.1" +micronaut = "4.7.2" +micronaut-platform = "4.6.3" micronaut-docs = '2.0.0' -micronaut-test = "4.0.1" -micronaut-data = "4.7.0" -micronaut-sql = "5.5.2" -micronaut-security = "4.6.9" -micronaut-serde = "2.8.2" -micronaut-validation = "4.4.4" -micronaut-gradle-plugin = "4.3.6" -managed-freemarker = "2.3.32" +micronaut-test = "4.5.0" +micronaut-data = "4.9.6" +micronaut-sql = "5.8.2" +micronaut-security = "4.11.0" +micronaut-serde = "2.12.0" +micronaut-validation = "4.8.0" +micronaut-gradle-plugin = "4.4.4" +managed-freemarker = "2.3.33" managed-handlebars = "4.3.1" -managed-jstachio = "1.3.5" -managed-jte = "3.1.10" -managed-rocker = "1.4.0" +managed-jstachio = "1.3.6" +managed-jte = "3.1.13" +managed-rocker = "2.0.1" managed-soy = "2023-09-13" +org-json = "20240303" managed-thymeleaf = "3.1.2.RELEASE" -managed-velocity = "2.3" +managed-velocity = "2.4.1" +graal = "24.1.1" pebble = "3.2.2" thymeleaf-extra-java8time = "3.0.4.RELEASE" -kotlin = "1.9.23" -kotlinx-coroutines = "1.8.0" +kotlin = "1.9.25" +kotlinx-coroutines = "1.9.0" -micronaut-logging = "1.2.3" +micronaut-logging = "1.5.0" [libraries] # Core @@ -42,14 +44,14 @@ managed-handlebars = { module = "com.github.jknack:handlebars", version.ref = "m managed-jstachio = { module = "io.jstach:jstachio", version.ref = "managed-jstachio" } managed-jstachio-apt = { module = "io.jstach:jstachio-apt", version.ref = "managed-jstachio" } managed-jte = { module = "gg.jte:jte", version.ref = "managed-jte" } +managed-jte-native-resources = { module = "gg.jte:jte-native-resources", version.ref = "managed-jte" } managed-jte-kotlin = { module = "gg.jte:jte-kotlin", version.ref = "managed-jte" } managed-rocker-runtime = { module = "com.fizzed:rocker-runtime", version.ref = "managed-rocker" } managed-soy = { module = "com.google.template:soy", version.ref = "managed-soy" } +org-json = { module = "org.json:json", version.ref = "org-json" } managed-thymeleaf = { module = "org.thymeleaf:thymeleaf", version.ref = "managed-thymeleaf" } managed-velocity-engine-core = { module = "org.apache.velocity:velocity-engine-core", version.ref = "managed-velocity" } -junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api" } -junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine" } junit-platform-engine = { module = "org.junit.platform:junit-platform-suite-engine" } pebble = { module = "io.pebbletemplates:pebble", version.ref = "pebble" } thymeleaf-extras-java8time = { module = "org.thymeleaf.extras:thymeleaf-extras-java8time", version.ref = "thymeleaf-extra-java8time" } @@ -57,7 +59,13 @@ kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", versi kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } groovy-json = { module = "org.apache.groovy:groovy-json" } +graal-polyglot = { module = "org.graalvm.polyglot:polyglot", version.ref = "graal" } +graal-js = { module = "org.graalvm.polyglot:js", version.ref = "graal" } + [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } jte = { id = "gg.jte.gradle", version.ref = "managed-jte" } +buildtools-native = { id = "org.graalvm.buildtools.native"} + + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e6441136f..a4b76b953 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b82aa23a4..df97d72b8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 1aa94a426..f5feea6d6 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/gradlew.bat b/gradlew.bat index 25da30dbd..9d21a2183 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## diff --git a/settings.gradle b/settings.gradle index c5f6ed3af..781c28015 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,7 +6,7 @@ pluginManagement { } plugins { - id("io.micronaut.build.shared.settings") version "6.7.0" + id("io.micronaut.build.shared.settings") version "7.2.3" } enableFeaturePreview 'TYPESAFE_PROJECT_ACCESSORS' @@ -23,16 +23,21 @@ include 'views-handlebars' include 'views-thymeleaf' include 'views-htmx' include 'views-velocity' +include 'views-react' include 'views-rocker' include 'views-pebble' include 'views-jte' include 'views-jstachio' include "test-suite" +include "test-suite-csrf" include "test-suite-http" include "test-suite-groovy" include "test-suite-kotlin" include "test-suite-thymeleaf-fieldset" +if (JavaVersion.current().isCompatibleWith(JavaVersion.VERSION_21)) { +include "test-suite-jte-fieldset" +} include "test-suite-freemarker-fieldset" include "test-suite-graal:test-suite-graal-common" include "test-suite-graal:test-suite-graal-freemarker" diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml index 278ff5eea..c77a9f644 100644 --- a/src/main/docs/guide/toc.yml +++ b/src/main/docs/guide/toc.yml @@ -22,6 +22,15 @@ views: title: JStachio jstachioInstallation: JStachio Installation jstachioExample: JStachio Example + react: + title: React SSR + reactpreparingjs: Preparing your Javascript + reactsettingproperties: Setting serving properties + preact: Integrating with Preact + reactrenderscripts: + title: Render scripts + reactheadmanagers: Using head managers + reacttodo: Known limitations model: title: Working with Models custom: Dynamically Enriching Models @@ -30,6 +39,7 @@ views: fieldsetExample: Form Generation Example fieldsetAnnotations: Fieldset Annotations fieldsetFetcher: Radio, Checkbox and Option Fetcher + csrfHidden: CSRF Token Hidden Field customFormElement: Custom Form Elements htmx: title: HTMX diff --git a/src/main/docs/guide/views/fieldset/csrfHidden.adoc b/src/main/docs/guide/views/fieldset/csrfHidden.adoc new file mode 100644 index 000000000..e0d8df61a --- /dev/null +++ b/src/main/docs/guide/views/fieldset/csrfHidden.adoc @@ -0,0 +1 @@ +If you use the https://micronaut-projects.github.io/micronaut-security/latest/guide/#csrf[Micronaut Security CSRF module], and a CSRF token is resolved, the generated form automatically contains a hidden input with the CSRF token as the value. \ No newline at end of file diff --git a/src/main/docs/guide/views/htmx/outOfBandSwaps.adoc b/src/main/docs/guide/views/htmx/outOfBandSwaps.adoc index 1e2a12843..30775c3ff 100644 --- a/src/main/docs/guide/views/htmx/outOfBandSwaps.adoc +++ b/src/main/docs/guide/views/htmx/outOfBandSwaps.adoc @@ -1,3 +1,3 @@ -You can return an API:views.htmx.HtmxResponse[] in a controller method to render multiple views in a single HTMX response—for example, to do https://htmx.org/docs/#oob_swaps[Out Of Band Swaps]. +You can return an api:views.htmx.http.HtmxResponse[] in a controller method to render multiple views in a single HTMX response—for example, to do https://htmx.org/docs/#oob_swaps[Out Of Band Swaps]. -snippet::io.micronaut.views.docs.htmx.HtmxTest[tags="outOfBandSwaps",indent=0] \ No newline at end of file +snippet::io.micronaut.views.docs.htmx.HtmxTest[tags="outOfBandSwaps",indent=0] diff --git a/src/main/docs/guide/views/security/csp.adoc b/src/main/docs/guide/views/security/csp.adoc index 6b92ee019..029f0eb4f 100644 --- a/src/main/docs/guide/views/security/csp.adoc +++ b/src/main/docs/guide/views/security/csp.adoc @@ -42,6 +42,14 @@ That's it! After applying the above configuration, HTTP responses might include Content-Security-Policy: default-src https: self:; script-src 'nonce-4ze2IRazk4Yu/j5K6SEzjA'; ``` +The nonce value can be accessed on the server as a request attribute named `cspNonce`. This is the value to use +in the `nonce` attribute on `script` and related tags. For example (adapt as appropriate for your template language): + +[source,html] +---- + + + + `; + + // Send it back. + callback.write(html); +} +---- diff --git a/src/main/docs/guide/views/templates/react/reactpreparingjs.adoc b/src/main/docs/guide/views/templates/react/reactpreparingjs.adoc new file mode 100644 index 000000000..75f948b3e --- /dev/null +++ b/src/main/docs/guide/views/templates/react/reactpreparingjs.adoc @@ -0,0 +1,53 @@ +An app that uses SSR needs the React components to be bundled twice, once for the client and once for the server. For the server you need to make a Javascript module bundle that imports and then re-exports the (page) components you will render, along with `React` and `ReactServerDOM`. The bundle must be compatible with GraalJS, which is not NodeJS and thus doesn't support the same set of APIs. You will also need to create a client-side bundle as per usual, and change how you start up React. + +TIP: This tutorial doesn't take you through how to create a ReactJS project from scratch - please refer to the React documentation for that. + +To start we will need a `server.js` file. It should be a part of your frontend project and can be named and placed wherever you like, as the server will only need the final compiled bundle. Your `server.js` should look like this: + +[source,javascript] +.src/main/js/server.js +---- +include::{srcjsReact}/server.js[] +---- + +Add your page components as imports, and then also to the export line. We will now set up Webpack to turn this file into a bundle. + +1. Run `npm i webpack node-polyfill-webpack-plugin text-encoding` to install some extra packages that are needed. +2. Create a config file called e.g. `webpack.server.js` like the following: + +[source,javascript] +.src/main/js/webpack.server.js +---- +include::{srcjsReact}/webpack.server.js[] +---- + +This Webpack config does several things: + +* It polyfills APIs that lack a native implementation in the GraalJS engine. +* It ensures the output is a native Javascript module. +* It names the result `ssr-components.mjs`. You can use any name, but it's looked for under this name in the `views` resource directory by default. All components must be in one server side bundle. +* It makes the `SERVER` variable be statically true when the Javascript is being bundled for server-side rendering. This allows you to include/exclude code blocks at bundle optimization time. + +You can use such a config by running `npx webpack --mode production --config webpack.server.js`. Add the `--watch` flag if you want the bundle to be recreated whenever an input file changes. Micronaut React SSR will notice if the bundle file has changed on disk and reload it (see <>). + +Now create `client.js`. This will contain the Javascript that runs once the page is loaded, and which will "hydrate" the React app (reconnect the event handlers to the pre-existing DOM). It should look like this: + +[source,javascript] +.src/main/js/client.js +---- +include::{srcjsReact}/client.js[] +---- + +Depending on how you configure minification, you may also need to import your page components here. This small snippet of code reads the `Micronaut` object which is generated by the Micronaut React SSR renderer just before your `client.js` code is loaded. It contains the component named in your `@View("MyPageComponent")` annotation, which is then loaded assuming it is in a Javascript module of the same name. The props that will be passed to that page component as generated from the object you return from your controller method. If you wish you can wrap `` here with any contexts you need. + +And now for the `webpack.client.js` config: + +[source,javascript] +.src/main/js/webpack.client.js +---- +include::{srcjsReact}/webpack.client.js[] +---- + +It tells Webpack to generate a series of JS files that are then placed in the `src/main/resources/static` directory. + +Run Webpack to generate the needed Javascript files for both client and server. diff --git a/src/main/docs/guide/views/templates/react/reactrenderscripts.adoc b/src/main/docs/guide/views/templates/react/reactrenderscripts.adoc new file mode 100644 index 000000000..0fa03f12a --- /dev/null +++ b/src/main/docs/guide/views/templates/react/reactrenderscripts.adoc @@ -0,0 +1,40 @@ +The code that kicks off the SSR process using your React libraries API is called a render script. Micronaut Views React ships with two pre-packaged render scripts, one for ReactJS and one for Preact, but you are also able to supply your own. This lets you take complete control over the server-side Javascript. To use a custom script, place it somewhere on your classpath or file system and then set the `micronaut.views.react.render-script` property to its path, prefixed with either `classpath:` or `file:` depending on where it should be found. + +A render script should be an ESM module that exports a single function called `ssr` that takes four arguments: + +1. A function object for the page component to render. +2. An object containing the root props. +3. A callback object that contains APIs used to communicate with Micronaut. +4. A string that receives the URL of the bundle that the browser should load. This is specified by the `micronaut.views.react.clientBundleURL` application property. + +The default render script looks like this: + +[source,javascript] +.classpath:/io/micronaut/views/react/react.js +---- +include::{srcjsReactRender}/react.js[] +---- + +The default render script for Preact looks like this: + +[source,javascript] +.classpath:/io/micronaut/views/react/preact.js +---- +include::{srcjsReactRender}/preact.js[] +---- + +A more sophisticated render script might support the use of head managers (see below), do multiple renders, expose other APIs and so on. + +A render script is evaluated _after_ your server side bundle, and has access to any symbols your server script exported. If you wish to access a JS module you should therefore include it in your `server.js` that gets fed to Webpack or similar bundler, and then re-export it like this: + +[source,javascript] +---- +import * as mymod from 'mymod'; +export { mymod }; +---- + +The callback object has a few different APIs you can use: + +1. `write(string)`: Writes the given string to the network response. +2. `write(bytes)`: Writes the given array of bytes to the network response. +3. `url()`: Returns either null or a string containing the URL of the page being served. Useful for sending to page routers. diff --git a/src/main/docs/guide/views/templates/react/reactsettingproperties.adoc b/src/main/docs/guide/views/templates/react/reactsettingproperties.adoc new file mode 100644 index 000000000..39676a2b1 --- /dev/null +++ b/src/main/docs/guide/views/templates/react/reactsettingproperties.adoc @@ -0,0 +1,64 @@ +React SSR needs some Micronaut application properties to be set. + +[configuration] +---- +micronaut: + # Point to the server-side JS. This value is the default. + views: + react: + server-bundle-path: "classpath:views/ssr-components.mjs" + + router: + static-resources: + js: + mapping: "/static/**" + paths: "classpath:static" + + # A temporary workaround for a GraalJS limitation. + executors: + blocking: + virtual: false +---- + +This sets up static file serving so your client JS will be served by your Micronaut app. This isn't mandatory: you can serve your client JS from anywhere, but you would need to set `micronaut.views.react.client-bundle-url` in that case to where the client root bundle can be found. + +IMPORTANT: Watch out for the last property that disables virtual threads. If you skip this you will get an error the first time a view is rendered. Future releases of GraalJS will remove the need to disable virtual threads in Micronaut. + +[[react-dev-mode]] +== Development + +During development you want the fastest iteration speed possible. Firstly turn off response caching so hot reload works with `npx webpack --watch`. Micronaut Views React will automatically notice the file changed on disk and reload it. + +[configuration] +---- +micronaut: + # For development purposes only. + server: + netty: + responses: + file: + cache-seconds: 0 +---- + +If using Maven turn off Micronaut's automatic restart features so that changes to the compiled bundle JS don't cause the whole server to reboot: + +[xml] +---- + + io.micronaut.maven + micronaut-maven-plugin + ... + + + + src/main/resources + + **/*.js + **/*.mjs + + + + + +---- + diff --git a/src/main/docs/guide/views/templates/react/reacttodo.adoc b/src/main/docs/guide/views/templates/react/reacttodo.adoc new file mode 100644 index 000000000..c56399a92 --- /dev/null +++ b/src/main/docs/guide/views/templates/react/reacttodo.adoc @@ -0,0 +1,5 @@ +Micronaut React SSR has the following known issues and limitations: + +- There is no built-in support for server side fetching. +- The rendering isn't streamed to the user. +- `` is not supported. diff --git a/test-suite-csrf/build.gradle.kts b/test-suite-csrf/build.gradle.kts new file mode 100644 index 000000000..5e93129d7 --- /dev/null +++ b/test-suite-csrf/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + `java-library` + id("io.micronaut.build.internal.views-tests") +} +dependencies { + testAnnotationProcessor(mn.micronaut.inject.java) + testImplementation(mnTest.micronaut.test.junit5) + testRuntimeOnly(mnTest.junit.jupiter.engine) + testRuntimeOnly(mnLogging.logback.classic) + testImplementation(mn.micronaut.http.client) + testAnnotationProcessor(mnSerde.micronaut.serde.processor) + testImplementation(mnSerde.micronaut.serde.jackson) + testImplementation(mn.micronaut.http.server.netty) + testImplementation(projects.micronautViewsThymeleaf) + testImplementation(projects.micronautViewsFieldset) + testImplementation(mnSecurity.micronaut.security.csrf) + testImplementation(mnSecurity.micronaut.security.session) +} +tasks.withType { + useJUnitPlatform() +} diff --git a/test-suite-csrf/src/test/java/io/micronaut/views/tests/security/csrf/CsrfFormGenerationTest.java b/test-suite-csrf/src/test/java/io/micronaut/views/tests/security/csrf/CsrfFormGenerationTest.java new file mode 100644 index 000000000..2aa4b6f0c --- /dev/null +++ b/test-suite-csrf/src/test/java/io/micronaut/views/tests/security/csrf/CsrfFormGenerationTest.java @@ -0,0 +1,92 @@ +package io.micronaut.views.tests.security.csrf; + +import io.micronaut.context.annotation.Property; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Status; +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.csrf.repository.CsrfTokenRepository; +import io.micronaut.security.rules.SecurityRule; +import io.micronaut.serde.annotation.Serdeable; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.micronaut.views.fields.Form; +import io.micronaut.views.fields.FormGenerator; +import io.micronaut.views.fields.annotations.InputPassword; +import io.micronaut.views.fields.elements.InputHiddenFormElement; +import io.micronaut.views.fields.elements.InputPasswordFormElement; +import io.micronaut.views.fields.elements.InputSubmitFormElement; +import jakarta.inject.Singleton; +import jakarta.validation.constraints.NotBlank; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@Property(name = "spec.name", value = "CsrfFormGeneration") +@MicronautTest +class CsrfFormGeneration { + + @Test + void formIncludesAHiddenFieldForCsrfToken(FormGenerator formGenerator, + @Client("/") HttpClient httpClient, + MockController controller) { + BlockingHttpClient client = httpClient.toBlocking(); + HttpResponse response = assertDoesNotThrow(() -> client.exchange(HttpRequest.GET("/generate/form"))); + assertEquals(HttpStatus.ACCEPTED, response.status()); + Form form = controller.getForm(); + assertNotNull(form); + assertEquals(2, form.fieldset().fields().stream().filter(f -> f instanceof InputPasswordFormElement).count()); + assertEquals(1, form.fieldset().fields().stream().filter(f -> f instanceof InputSubmitFormElement).count()); + assertEquals(1, form.fieldset().fields().stream().filter(f -> f instanceof InputHiddenFormElement).count()); + assertEquals(4, form.fieldset().fields().size()); + InputHiddenFormElement inputHiddenFormElement =(InputHiddenFormElement) form.fieldset().fields().stream().filter(f -> f instanceof InputHiddenFormElement).findFirst().get(); + assertTrue(StringUtils.isNotEmpty(inputHiddenFormElement.value())); + } + + @Serdeable + record ChangePasswordForm(@InputPassword @NotBlank String password, + @InputPassword @NotBlank String repeatPassword) { + } + + + @Requires(property = "spec.name", value = "CsrfFormGeneration") + @Singleton + static class CsrfRepositoryMock implements CsrfTokenRepository> { + + @Override + public @NonNull Optional findCsrfToken(@NonNull HttpRequest request) { + return Optional.of("abcde"); + } + } + + @Requires(property = "spec.name", value = "CsrfFormGeneration") + @Controller("/generate/form") + static class MockController { + private final FormGenerator formGenerator; + private Form form; + MockController(FormGenerator formGenerator) { + this.formGenerator = formGenerator; + } + + @Secured(SecurityRule.IS_ANONYMOUS) + @Get + @Status(HttpStatus.ACCEPTED) + void index() { + this.form = formGenerator.generate("/password/change", ChangePasswordForm.class); + } + + public Form getForm() { + return form; + } + } +} diff --git a/test-suite-csrf/src/test/java/io/micronaut/views/tests/security/csrf/CsrfSessionLogingHandlerTest.java b/test-suite-csrf/src/test/java/io/micronaut/views/tests/security/csrf/CsrfSessionLogingHandlerTest.java new file mode 100644 index 000000000..207c12850 --- /dev/null +++ b/test-suite-csrf/src/test/java/io/micronaut/views/tests/security/csrf/CsrfSessionLogingHandlerTest.java @@ -0,0 +1,88 @@ +package io.micronaut.views.tests.security.csrf; + +import io.micronaut.context.annotation.Property; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.*; +import io.micronaut.http.annotation.*; +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.http.cookie.Cookie; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.authentication.AuthenticationRequest; +import io.micronaut.security.authentication.AuthenticationResponse; +import io.micronaut.security.authentication.provider.HttpRequestAuthenticationProvider; +import io.micronaut.security.csrf.repository.CsrfTokenRepository; +import io.micronaut.security.rules.SecurityRule; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.micronaut.views.View; +import jakarta.inject.Singleton; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@Property(name = "micronaut.security.authentication", value = "session") +@Property(name = "micronaut.security.redirect.enabled", value = StringUtils.FALSE) +@Property(name = "micronaut.security.csrf.filter.regex-pattern", value = "^(?!\\/login).*$") +@Property(name = "micronaut.security.csrf.signature-key", value = "pleaseChangeThisSecretForANewOnekoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow") +@Property(name = "spec.name", value = "CsrfSessionLogingHandlerTest") +@MicronautTest +class CsrfSessionLogingHandlerTest { + + @Test + void loginSavesACsrfTokenInSession(@Client("/") HttpClient httpClient, CsrfTokenRepository> csrfTokenRepository) { + BlockingHttpClient client = httpClient.toBlocking(); + HttpRequest loginRequest = HttpRequest.POST("/login",Map.of("username", "sherlock", "password", "password")) + .contentType(MediaType.APPLICATION_FORM_URLENCODED_TYPE); + + HttpResponse loginRsp = assertDoesNotThrow(() -> client.exchange(loginRequest)); + assertEquals(HttpStatus.OK, loginRsp.getStatus()); + String cookie = loginRsp.getHeaders().get(HttpHeaders.SET_COOKIE); + assertNotNull(cookie); + assertTrue(cookie.contains("SESSION=")); + assertTrue(cookie.contains("; HTTPOnly")); + String sessionId = cookie.split(";")[0].split("=")[1]; + assertNotNull(sessionId); + HttpRequest csrfEchoRequestWithSession = HttpRequest.GET("/csrf/echo") + .contentType(MediaType.TEXT_HTML_TYPE) + .cookie(Cookie.of("SESSION", sessionId)); + String html = assertDoesNotThrow(() -> client.retrieve(csrfEchoRequestWithSession)); + assertFalse(html.contains("")); + + // request the page without session and no csrf token is present + HttpRequest csrfEchoRequestWithoutSession = HttpRequest.GET("/csrf/echo") + .contentType(MediaType.TEXT_HTML_TYPE); + html = assertDoesNotThrow(() -> client.retrieve(csrfEchoRequestWithoutSession)); + assertTrue(html.contains("")); + } + + @Requires(property = "spec.name", value = "CsrfSessionLogingHandlerTest") + @Singleton + static class AuthenticationProviderUserPassword implements HttpRequestAuthenticationProvider { + @Override + public @NonNull AuthenticationResponse authenticate(@Nullable HttpRequest requestContext, @NonNull AuthenticationRequest authRequest) { + return AuthenticationResponse.success("sherlock"); + } + } + + @Requires(property = "spec.name", value = "CsrfSessionLogingHandlerTest") + @Controller("/csrf") + static class CsrfTokenEchoController { + @Secured(SecurityRule.IS_ANONYMOUS) + @Produces(MediaType.TEXT_HTML) + @Get("/echo") + @View("index") + Map index() { + return Collections.emptyMap(); + } + } +} \ No newline at end of file diff --git a/test-suite-csrf/src/test/resources/logback.xml b/test-suite-csrf/src/test/resources/logback.xml new file mode 100644 index 000000000..8eb8c3a81 --- /dev/null +++ b/test-suite-csrf/src/test/resources/logback.xml @@ -0,0 +1,10 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/test-suite-csrf/src/test/resources/views/index.html b/test-suite-csrf/src/test/resources/views/index.html new file mode 100644 index 000000000..b075afbf4 --- /dev/null +++ b/test-suite-csrf/src/test/resources/views/index.html @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/test-suite-http/build.gradle b/test-suite-http/build.gradle index 5abeffefb..224c0591a 100644 --- a/test-suite-http/build.gradle +++ b/test-suite-http/build.gradle @@ -6,9 +6,9 @@ plugins { dependencies { testAnnotationProcessor(mn.micronaut.inject.java) - testImplementation(libs.junit.jupiter.api) + testImplementation(mnTest.junit.jupiter.api) testImplementation(mnTest.micronaut.test.junit5) - testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(mnTest.junit.jupiter.engine) testRuntimeOnly(mnLogging.logback.classic) diff --git a/test-suite-jte-fieldset/build.gradle.kts b/test-suite-jte-fieldset/build.gradle.kts new file mode 100644 index 000000000..e6145819c --- /dev/null +++ b/test-suite-jte-fieldset/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("io.micronaut.build.internal.views-fieldset-tck") + alias(libs.plugins.jte) + alias(libs.plugins.buildtools.native) +} + +dependencies { + annotationProcessor(platform(mn.micronaut.core.bom)) + annotationProcessor(mn.micronaut.inject.java) + implementation(platform(mn.micronaut.core.bom)) + implementation(projects.micronautViewsJte) + implementation(projects.micronautViewsFieldset) + testImplementation(projects.micronautViewsJte) + jteGenerate(libs.managed.jte.native.resources) +} +graalvmNative.toolchainDetection = true +jte { + sourceDirectory = file("src/test/jte").toPath() + generate() + jteExtension("gg.jte.nativeimage.NativeResourcesExtension") +} +java.sourceCompatibility = JavaVersion.VERSION_21 +java.targetCompatibility = JavaVersion.VERSION_21 diff --git a/test-suite-jte-fieldset/src/test/java/io/micronaut/views/fields/jte/JteSuite.java b/test-suite-jte-fieldset/src/test/java/io/micronaut/views/fields/jte/JteSuite.java new file mode 100644 index 000000000..0d269dc22 --- /dev/null +++ b/test-suite-jte-fieldset/src/test/java/io/micronaut/views/fields/jte/JteSuite.java @@ -0,0 +1,11 @@ +package io.micronaut.views.fields.jte; + +import org.junit.platform.suite.api.SelectPackages; +import org.junit.platform.suite.api.Suite; +import org.junit.platform.suite.api.SuiteDisplayName; + +@Suite +@SelectPackages("io.micronaut.views.fields.tck") +@SuiteDisplayName("Fieldset TCK for Jte") +class JteSuite { +} diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/errors.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/errors.jte new file mode 100644 index 000000000..4f1caffce --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/errors.jte @@ -0,0 +1,7 @@ +@import java.util.List +@import io.micronaut.views.fields.messages.Message +@param String name +@param List errors +@for(var error: errors) +
${error.defaultMessage()}
+@endfor diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/fieldset.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/fieldset.jte new file mode 100644 index 000000000..1ac92a4a0 --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/fieldset.jte @@ -0,0 +1,83 @@ +@import io.micronaut.views.fields.Fieldset +@import io.micronaut.views.fields.elements.InputCheckboxFormElement +@import io.micronaut.views.fields.elements.InputFormElement +@import io.micronaut.views.fields.elements.InputHiddenFormElement +@import io.micronaut.views.fields.elements.InputNumberFormElement +@import io.micronaut.views.fields.elements.InputEmailFormElement +@import io.micronaut.views.fields.elements.InputTelFormElement +@import io.micronaut.views.fields.elements.InputTextFormElement +@import io.micronaut.views.fields.elements.InputUrlFormElement +@import io.micronaut.views.fields.elements.SelectFormElement +@import io.micronaut.views.fields.elements.InputSubmitFormElement +@import io.micronaut.views.fields.elements.InputPasswordFormElement +@import io.micronaut.views.fields.elements.InputDateFormElement +@import io.micronaut.views.fields.elements.InputDateTimeLocalFormElement +@import io.micronaut.views.fields.elements.TextareaFormElement +@import io.micronaut.views.fields.elements.TrixEditorFormElement +@import io.micronaut.views.fields.elements.InputTimeFormElement +@import io.micronaut.views.fields.elements.InputStringFormElement +@import io.micronaut.views.fields.elements.InputFileFormElement + +@param Fieldset el +@for(var field : el.fields()) + @if(field instanceof InputHiddenFormElement inputHiddenFormElement) + @template.fieldset.inputhidden(inputHiddenFormElement) + @endif + @if(field instanceof InputCheckboxFormElement || + field instanceof SelectFormElement || + field instanceof TextareaFormElement || + field instanceof TrixEditorFormElement || + field instanceof InputTimeFormElement || + field instanceof InputDateFormElement || + field instanceof InputDateTimeLocalFormElement || + field instanceof InputStringFormElement || + field instanceof InputNumberFormElement) +
+ @if(field instanceof InputCheckboxFormElement inputCheckboxFormElement) + @template.fieldset.inputcheckbox(inputCheckboxFormElement) + @endif + @if(field instanceof SelectFormElement selectFormElement) + @template.fieldset.select(selectFormElement) + @endif + @if(field instanceof TextareaFormElement textareaFormElement) + @template.fieldset.textarea(textareaFormElement) + @endif + @if(field instanceof TrixEditorFormElement trixEditorFormElement) + @template.fieldset.trixeditor(trixEditorFormElement) + @endif + @if(field instanceof InputTimeFormElement inputTimeFormElement) + @template.fieldset.inputtime(inputTimeFormElement) + @endif + @if(field instanceof InputDateTimeLocalFormElement inputDateTimeLocalFormElement) + @template.fieldset.inputdatetimelocal(inputDateTimeLocalFormElement) + @endif + @if(field instanceof InputPasswordFormElement inputPasswordFormElement) + @template.fieldset.inputpassword(inputPasswordFormElement) + @endif + @if(field instanceof InputTextFormElement inputTextFormElement) + @template.fieldset.inputtext(inputTextFormElement) + @endif + @if(field instanceof InputNumberFormElement inputNumberFormElement) + @template.fieldset.inputnumber(inputNumberFormElement) + @endif + @if(field instanceof InputEmailFormElement inputEmailFormElement) + @template.fieldset.inputemail(inputEmailFormElement) + @endif + @if(field instanceof InputTelFormElement inputTelFormElement) + @template.fieldset.inputtel(inputTelFormElement) + @endif + @if(field instanceof InputUrlFormElement inputUrlFormElement) + @template.fieldset.inputurl(inputUrlFormElement) + @endif + @if(field instanceof InputDateFormElement inputDateFormElement) + @template.fieldset.inputdate(inputDateFormElement) + @endif +
+ @endif + @if(field instanceof InputFileFormElement inputFileFormElement) + @template.fieldset.inputfile(inputFileFormElement) + @endif + @if(field instanceof InputSubmitFormElement inputSubmitFormElement) + @template.fieldset.inputsubmit(inputSubmitFormElement) + @endif +@endfor diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/form.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/form.jte new file mode 100644 index 000000000..5855363c2 --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/form.jte @@ -0,0 +1,12 @@ +@import io.micronaut.views.fields.Form +@param Form form +@if(form.dataturbo() != null) +
+ @template.fieldset.fieldset(form.fieldset()) +
+@else +
+ @template.fieldset.fieldset(form.fieldset()) +
+@endif + diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/inputcheckbox.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/inputcheckbox.jte new file mode 100644 index 000000000..f21d1717f --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/inputcheckbox.jte @@ -0,0 +1,29 @@ +@import io.micronaut.views.fields.elements.InputCheckboxFormElement +@param InputCheckboxFormElement el +@if(el.label() != null && el.checkboxes().size() > 1) +@template.fieldset.label(null, el.label()) +@endif +@for(var checkbox : el.checkboxes()) +
+ @if(el.hasErrors()) + + @else + + @endif + @template.fieldset.label(checkbox.id(), checkbox.label()) +
+@endfor diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/inputdate.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/inputdate.jte new file mode 100644 index 000000000..49debdbec --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/inputdate.jte @@ -0,0 +1,29 @@ +@import io.micronaut.views.fields.elements.InputDateFormElement +@param InputDateFormElement el +@template.fieldset.label(el.id(), el.label()) +@if(el.hasErrors()) + + @template.fieldset.errors(el.name(), el.errors()) +@else + +@endif diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/inputdatetimelocal.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/inputdatetimelocal.jte new file mode 100644 index 000000000..6103a6867 --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/inputdatetimelocal.jte @@ -0,0 +1,29 @@ +@import io.micronaut.views.fields.elements.InputDateTimeLocalFormElement +@param InputDateTimeLocalFormElement el +@template.fieldset.label(el.id(), el.label()) +@if(el.hasErrors()) + + @template.fieldset.errors(el.name(), el.errors()) +@else + +@endif diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/inputemail.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/inputemail.jte new file mode 100644 index 000000000..4c733872e --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/inputemail.jte @@ -0,0 +1,7 @@ +@import io.micronaut.views.fields.elements.InputEmailFormElement +@import io.micronaut.views.fields.elements.InputStringFormElement +@param InputEmailFormElement el +@if(el instanceof InputStringFormElement inputStringFormElement) +@template.fieldset.inputstring("email",inputStringFormElement) +@endif + diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/inputfile.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/inputfile.jte new file mode 100644 index 000000000..69a80c89e --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/inputfile.jte @@ -0,0 +1,22 @@ +@import io.micronaut.views.fields.elements.InputFileFormElement +@param InputFileFormElement el +@template.fieldset.label(el.id(), el.label()) +@if(el.hasErrors()) + + @template.fieldset.errors(el.name(), el.errors()) +@else + +@endif diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/inputhidden.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/inputhidden.jte new file mode 100644 index 000000000..5e23593f6 --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/inputhidden.jte @@ -0,0 +1,3 @@ +@import io.micronaut.views.fields.elements.InputHiddenFormElement +@param InputHiddenFormElement el + diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/inputnumber.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/inputnumber.jte new file mode 100644 index 000000000..2d5585ba3 --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/inputnumber.jte @@ -0,0 +1,31 @@ +@import io.micronaut.views.fields.elements.InputNumberFormElement +@param InputNumberFormElement el +@template.fieldset.label(el.id(), el.label()) +@if(el.hasErrors()) + + @template.fieldset.errors(el.name(), el.errors()) +@else + +@endif + diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/inputpassword.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/inputpassword.jte new file mode 100644 index 000000000..cff48bb45 --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/inputpassword.jte @@ -0,0 +1,7 @@ +@import io.micronaut.views.fields.elements.InputPasswordFormElement +@import io.micronaut.views.fields.elements.InputStringFormElement +@param InputPasswordFormElement el +@if(el instanceof InputStringFormElement inputStringFormElement) +@template.fieldset.inputstring("password",inputStringFormElement) +@endif + diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/inputradio.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/inputradio.jte new file mode 100644 index 000000000..556ab1d1a --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/inputradio.jte @@ -0,0 +1,13 @@ +@import io.micronaut.views.fields.elements.InputRadioFormElement +@import io.micronaut.views.fields.elements.Radio +@param InputRadioFormElement el +@param Radio radio + diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/inputradios.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/inputradios.jte new file mode 100644 index 000000000..9f71f3f4e --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/inputradios.jte @@ -0,0 +1,8 @@ +@import io.micronaut.views.fields.elements.InputRadioFormElement +@param InputRadioFormElement el +@for(var radio : el.buttons()) +
+ @template.fieldset.inputradio(el, radio) + @template.fieldset.label(radio.id(), radio.label()) +
+@endfor diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/inputstring.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/inputstring.jte new file mode 100644 index 000000000..1303527c6 --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/inputstring.jte @@ -0,0 +1,35 @@ +@import io.micronaut.views.fields.elements.InputStringFormElement +@param String type +@param InputStringFormElement el +@template.fieldset.label(el.id(), el.label()) +@if(el.hasErrors()) + + @template.fieldset.errors(el.name(), el.errors()) +@else + +@endif diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/inputsubmit.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/inputsubmit.jte new file mode 100644 index 000000000..f5be94fda --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/inputsubmit.jte @@ -0,0 +1,6 @@ +@import io.micronaut.views.fields.elements.InputSubmitFormElement +@param InputSubmitFormElement el + + diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/inputtel.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/inputtel.jte new file mode 100644 index 000000000..c8536f7b7 --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/inputtel.jte @@ -0,0 +1,7 @@ +@import io.micronaut.views.fields.elements.InputTelFormElement +@import io.micronaut.views.fields.elements.InputStringFormElement +@param InputTelFormElement el +@if(el instanceof InputStringFormElement inputStringFormElement) +@template.fieldset.inputstring("tel",inputStringFormElement) +@endif + diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/inputtext.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/inputtext.jte new file mode 100644 index 000000000..d98a67f2e --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/inputtext.jte @@ -0,0 +1,7 @@ +@import io.micronaut.views.fields.elements.InputTextFormElement +@import io.micronaut.views.fields.elements.InputStringFormElement +@param InputTextFormElement el +@if(el instanceof InputStringFormElement inputStringFormElement) +@template.fieldset.inputstring("text",inputStringFormElement) +@endif + diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/inputtime.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/inputtime.jte new file mode 100644 index 000000000..03647ab8d --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/inputtime.jte @@ -0,0 +1,28 @@ +@import io.micronaut.views.fields.elements.InputTimeFormElement +@param InputTimeFormElement el +@template.fieldset.label(el.id(), el.label()) +@if(el.hasErrors()) + + @template.fieldset.errors(el.name(), el.errors()) +@else + +@endif + diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/inputurl.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/inputurl.jte new file mode 100644 index 000000000..4b569f51d --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/inputurl.jte @@ -0,0 +1,7 @@ +@import io.micronaut.views.fields.elements.InputUrlFormElement +@import io.micronaut.views.fields.elements.InputStringFormElement +@param InputUrlFormElement el +@if(el instanceof InputStringFormElement inputStringFormElement) +@template.fieldset.inputstring("url",inputStringFormElement) +@endif + diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/label.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/label.jte new file mode 100644 index 000000000..91a8ea232 --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/label.jte @@ -0,0 +1,4 @@ +@import io.micronaut.views.fields.messages.Message +@param String id +@param Message el + diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/option.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/option.jte new file mode 100644 index 000000000..9496d7854 --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/option.jte @@ -0,0 +1,3 @@ +@import io.micronaut.views.fields.elements.Option +@param Option el + diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/select.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/select.jte new file mode 100644 index 000000000..01dd55489 --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/select.jte @@ -0,0 +1,25 @@ +@import io.micronaut.views.fields.elements.SelectFormElement +@param SelectFormElement el +@template.fieldset.label(el.id(), el.label()) +@if(el.hasErrors()) + + @template.fieldset.errors(el.name(), el.errors()) +@else + +@endif + diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/textarea.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/textarea.jte new file mode 100644 index 000000000..14f4d6af0 --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/textarea.jte @@ -0,0 +1,21 @@ +@import io.micronaut.views.fields.elements.TextareaFormElement +@param TextareaFormElement el +@template.fieldset.label(el.id(), el.label()) +@if(el.hasErrors()) + + @template.fieldset.errors(el.name(), el.errors()) +@else + +@endif + diff --git a/test-suite-jte-fieldset/src/test/jte/fieldset/trixeditor.jte b/test-suite-jte-fieldset/src/test/jte/fieldset/trixeditor.jte new file mode 100644 index 000000000..0c6cedc6a --- /dev/null +++ b/test-suite-jte-fieldset/src/test/jte/fieldset/trixeditor.jte @@ -0,0 +1,5 @@ +@import io.micronaut.views.fields.elements.TrixEditorFormElement +@param TrixEditorFormElement el +@template.fieldset.label(el.id(), el.label()) + + diff --git a/test-suite-jte-fieldset/src/test/resources/logback.xml b/test-suite-jte-fieldset/src/test/resources/logback.xml new file mode 100644 index 000000000..6113ede2f --- /dev/null +++ b/test-suite-jte-fieldset/src/test/resources/logback.xml @@ -0,0 +1,12 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + diff --git a/test-suite-kotlin/build.gradle.kts b/test-suite-kotlin/build.gradle.kts index 2d3068f26..bcca7f876 100644 --- a/test-suite-kotlin/build.gradle.kts +++ b/test-suite-kotlin/build.gradle.kts @@ -8,7 +8,7 @@ dependencies { testAnnotationProcessor(mnValidation.micronaut.validation.processor) testImplementation(mnValidation.micronaut.validation) - testImplementation(libs.junit.jupiter.api) + testImplementation(mnTest.junit.jupiter.api) testImplementation(mnTest.micronaut.test.junit5) testImplementation(libs.kotlin.stdlib.jdk8) @@ -27,7 +27,7 @@ dependencies { testImplementation(projects.micronautViewsVelocity) testImplementation(projects.micronautViewsHandlebars) - testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(mnTest.junit.jupiter.engine) testRuntimeOnly(mnLogging.logback.classic) } diff --git a/test-suite-thymeleaf-fieldset/src/test/resources/views/fieldset/form.html b/test-suite-thymeleaf-fieldset/src/test/resources/views/fieldset/form.html index 7eb84cdf0..58be4785a 100644 --- a/test-suite-thymeleaf-fieldset/src/test/resources/views/fieldset/form.html +++ b/test-suite-thymeleaf-fieldset/src/test/resources/views/fieldset/form.html @@ -1 +1 @@ -
\ No newline at end of file +
\ No newline at end of file diff --git a/test-suite-thymeleaf-fieldset/src/test/resources/views/fieldset/inputcheckbox.html b/test-suite-thymeleaf-fieldset/src/test/resources/views/fieldset/inputcheckbox.html index 644c59651..21bd6a20d 100644 --- a/test-suite-thymeleaf-fieldset/src/test/resources/views/fieldset/inputcheckbox.html +++ b/test-suite-thymeleaf-fieldset/src/test/resources/views/fieldset/inputcheckbox.html @@ -1 +1 @@ -
+
diff --git a/test-suite/build.gradle.kts b/test-suite/build.gradle.kts index ae35d9de2..37466d41f 100644 --- a/test-suite/build.gradle.kts +++ b/test-suite/build.gradle.kts @@ -9,9 +9,9 @@ dependencies { testImplementation(mnValidation.micronaut.validation) - testImplementation(libs.junit.jupiter.api) + testImplementation(mnTest.junit.jupiter.api) testImplementation(mnTest.micronaut.test.junit5) - testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(mnTest.junit.jupiter.engine) testCompileOnly(mn.micronaut.inject.groovy) testImplementation(mn.micronaut.management) diff --git a/test-suite/src/test/java/io/micronaut/views/ModelAndViewTest.java b/test-suite/src/test/java/io/micronaut/views/ModelAndViewTest.java index 2b75c708d..262c912d3 100644 --- a/test-suite/src/test/java/io/micronaut/views/ModelAndViewTest.java +++ b/test-suite/src/test/java/io/micronaut/views/ModelAndViewTest.java @@ -9,6 +9,7 @@ import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; import io.micronaut.http.annotation.Controller; import io.micronaut.http.annotation.Get; import io.micronaut.http.annotation.Produces; @@ -21,6 +22,7 @@ import io.micronaut.views.model.ConfigViewModelProcessor; import io.micronaut.views.model.FruitsController; import io.micronaut.views.model.ViewModelProcessor; +import io.micronaut.views.turbo.TurboFrame; import io.micronaut.views.turbo.TurboStream; import io.micronaut.views.turbo.TurboStreamAction; import io.micronaut.views.turbo.http.TurboMediaType; @@ -41,6 +43,7 @@ @Property(name = "micronaut.security.enabled", value = StringUtils.FALSE) @MicronautTest class ModelAndViewTest { + @Inject @Client("/") HttpClient httpClient; @@ -175,6 +178,13 @@ void viewModelProcessorsWorkWithControllersReturningPOJOs() { request = HttpRequest.GET("/turboStreamBuilderWithProcessor").accept(TurboMediaType.TURBO_STREAM); html = client.retrieve(request, String.class); assertTrue(html.contains("

config: test

")); + + //when: + request = HttpRequest.GET("/turboFrameBuilderWithProcessor").accept(MediaType.TEXT_HTML); + html = client.retrieve(request, String.class); + assertTrue(html.startsWith("")); + assertTrue(html.endsWith("")); + assertTrue(html.contains("

config: test

")); } @Requires(property = "spec.name", value = "ModelAndViewSpec") @@ -193,6 +203,14 @@ public TurboStream.Builder turboStreamBuilder() { .action(TurboStreamAction.REPLACE) .template("fruits-processor", new Fruit("orange", "orange")); } + + @Produces(MediaType.TEXT_HTML) + @Get("/turboFrameBuilderWithProcessor") + public TurboFrame.Builder turboFrameBuilder() { + return (TurboFrame.Builder) TurboFrame.builder() + .templateView("fruits-processor") + .templateModel(new Fruit("orange", "orange")); + } } @Introspected diff --git a/views-core/build.gradle.kts b/views-core/build.gradle.kts index 35c5499ad..93c825faf 100644 --- a/views-core/build.gradle.kts +++ b/views-core/build.gradle.kts @@ -6,6 +6,7 @@ dependencies { annotationProcessor(mnValidation.micronaut.validation.processor) compileOnly(mnSecurity.micronaut.security) + compileOnly(mnSecurity.micronaut.security.csrf) compileOnly(mn.micronaut.management) compileOnly(mnValidation.micronaut.validation) @@ -14,11 +15,18 @@ dependencies { testRuntimeOnly(mnLogging.logback.classic) testAnnotationProcessor(mnValidation.micronaut.validation.processor) + testImplementation(mnValidation.micronaut.validation) + testImplementation(mnSerde.micronaut.serde.jackson) testImplementation(mn.micronaut.http.client) - testImplementation(mn.micronaut.inject.java) testImplementation(mn.micronaut.http.server.netty) testImplementation(mn.micronaut.management) - testImplementation(mnValidation.micronaut.validation) - testImplementation(mn.snakeyaml) + testImplementation(mnSecurity.micronaut.security.csrf) + testAnnotationProcessor(mn.micronaut.inject.java) + testImplementation(mnTest.micronaut.test.junit5) + testRuntimeOnly(mnTest.junit.jupiter.engine) +} + +tasks.withType { + useJUnitPlatform() } diff --git a/views-core/src/main/java/io/micronaut/views/ViewsFilter.java b/views-core/src/main/java/io/micronaut/views/ViewsFilter.java index d82489fb4..2f4539339 100644 --- a/views-core/src/main/java/io/micronaut/views/ViewsFilter.java +++ b/views-core/src/main/java/io/micronaut/views/ViewsFilter.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2021 original authors + * 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. @@ -21,7 +21,11 @@ import io.micronaut.core.annotation.Nullable; import io.micronaut.core.io.Writable; import io.micronaut.core.type.Argument; -import io.micronaut.http.*; +import io.micronaut.http.HttpAttributes; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.MediaType; +import io.micronaut.http.MutableHttpResponse; import io.micronaut.http.annotation.Filter; import io.micronaut.http.annotation.Produces; import io.micronaut.http.filter.HttpServerFilter; @@ -40,6 +44,8 @@ import reactor.core.publisher.Flux; import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.Optional; /** @@ -51,7 +57,9 @@ @Requires(beans = ViewsResolver.class) @Filter(Filter.MATCH_ALL_PATTERN) public class ViewsFilter implements HttpServerFilter { + private static final Logger LOG = LoggerFactory.getLogger(ViewsFilter.class); + private static final MediaType UTF8_HTML = new MediaType(MediaType.TEXT_HTML, Map.of(MediaType.CHARSET_PARAMETER, "UTF-8")); /** * Views Resolver. @@ -152,9 +160,9 @@ public final Publisher> doFilter(HttpRequest request, } try { - Optional optionalViewsRenderer = viewsRendererLocator.resolveViewsRenderer(view, type.toString(), body); + Optional optionalViewsRenderer = viewsRendererLocator.resolveViewsRenderer(view, type.getName(), body); if (!optionalViewsRenderer.isPresent()) { - LOG.debug("no view renderer found for media type: {}, ignoring", type.toString()); + LOG.debug("no view renderer found for media type: {}, ignoring", type); return Flux.just(response); } ModelAndView modelAndView = new ModelAndView<>(view, body instanceof ModelAndView ? ((ModelAndView) body).getModel().orElse(null) : body); @@ -184,10 +192,10 @@ protected MediaType resolveMediaType(@Nullable HttpRequest request, @NonNull return MediaType.APPLICATION_JSON_TYPE; } AnnotationMetadata route = routeMatch.get(); - return (request == null ? route.getValue(Produces.class, MediaType.class) : - route.getValue(Produces.class, Argument.listOf(MediaType.class)).orElseGet(Collections::emptyList).stream().filter(mt -> accept(request, mt)).findFirst() - ).orElseGet(() -> (route.getValue(View.class).isPresent() || responseBody instanceof ModelAndView) - ? MediaType.TEXT_HTML_TYPE : MediaType.APPLICATION_JSON_TYPE); + Optional type = request == null + ? route.getValue(Produces.class, MediaType.class) + : route.getValue(Produces.class, Argument.listOf(MediaType.class)).orElseGet(Collections::emptyList).stream().filter(mt -> accept(request, mt)).findFirst(); + return type.orElseGet(() -> (route.getValue(View.class).isPresent() || responseBody instanceof ModelAndView) ? UTF8_HTML : MediaType.APPLICATION_JSON_TYPE); } /** @@ -203,8 +211,8 @@ protected MediaType resolveMediaType(@NonNull HttpResponse response, @Nullabl } private static boolean accept(HttpRequest request, MediaType mediaType) { - String acceptHeader = request.getHeaders().get(HttpHeaders.ACCEPT); - return acceptHeader != null && acceptHeader.equalsIgnoreCase(mediaType.toString()); + List accept = request.getHeaders().accept(); + return accept.isEmpty() || accept.stream().anyMatch(p -> p.equals(mediaType)); } @NonNull diff --git a/views-core/src/main/java/io/micronaut/views/model/security/CsrfViewModelProcessor.java b/views-core/src/main/java/io/micronaut/views/model/security/CsrfViewModelProcessor.java new file mode 100644 index 000000000..4903440b5 --- /dev/null +++ b/views-core/src/main/java/io/micronaut/views/model/security/CsrfViewModelProcessor.java @@ -0,0 +1,62 @@ +/* + * 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.model.security; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpRequest; +import io.micronaut.security.csrf.repository.CsrfTokenRepository; +import jakarta.inject.Singleton; + +import java.util.Map; + +/** + * Adds a CSRF Token to the model map if a CSRF Token can be retrieved via {@link CsrfTokenRepository}. + * + * @author Sergio del Amo + * @since 5.6.0 + */ +@Requires(property = CsrfViewModelProcessor.ENABLED, notEquals = StringUtils.FALSE) +@Requires(beans = {CsrfTokenRepository.class, CsrfViewModelProcessorConfiguration.class}) +@Requires(classes = HttpRequest.class) +@Singleton +@Internal +final class CsrfViewModelProcessor implements MapViewModelProcessor { + /** + * Property to enable/disable the CsrfViewModelProcessor. + */ + public static final String ENABLED = CsrfViewModelProcessorConfigurationProperties.PREFIX + ".enabled"; + + private final CsrfTokenRepository> csrfTokenRepository; + private final CsrfViewModelProcessorConfiguration csrfViewModelProcessorConfiguration; + + /** + * @param csrfViewModelProcessorConfiguration CSRF Views Model Decorator configuration + * @param csrfTokenRepository The CSRF Token Repository + */ + public CsrfViewModelProcessor(CsrfViewModelProcessorConfiguration csrfViewModelProcessorConfiguration, + CsrfTokenRepository> csrfTokenRepository) { + this.csrfViewModelProcessorConfiguration = csrfViewModelProcessorConfiguration; + this.csrfTokenRepository = csrfTokenRepository; + } + + @Override + public void populateModel(HttpRequest request, Map model) { + csrfTokenRepository.findCsrfToken(request) + .ifPresent(csrfToken -> model.put(csrfViewModelProcessorConfiguration.getCsrfTokenKey(), csrfToken)); + } +} diff --git a/views-core/src/main/java/io/micronaut/views/model/security/CsrfViewModelProcessorConfiguration.java b/views-core/src/main/java/io/micronaut/views/model/security/CsrfViewModelProcessorConfiguration.java new file mode 100644 index 000000000..acd3c75da --- /dev/null +++ b/views-core/src/main/java/io/micronaut/views/model/security/CsrfViewModelProcessorConfiguration.java @@ -0,0 +1,33 @@ +/* + * 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.model.security; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.util.Toggleable; + +/** + * Configuration CSRF View Model Processor. + * @author Sergio del Amo + * @since 5.6.0 + */ +public interface CsrfViewModelProcessorConfiguration extends Toggleable { + /** + * + * @return Model key for CSRF Token. + */ + @NonNull + String getCsrfTokenKey(); +} diff --git a/views-core/src/main/java/io/micronaut/views/model/security/CsrfViewModelProcessorConfigurationProperties.java b/views-core/src/main/java/io/micronaut/views/model/security/CsrfViewModelProcessorConfigurationProperties.java new file mode 100644 index 000000000..58aa4be66 --- /dev/null +++ b/views-core/src/main/java/io/micronaut/views/model/security/CsrfViewModelProcessorConfigurationProperties.java @@ -0,0 +1,79 @@ +/* + * 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.model.security; + +import io.micronaut.context.annotation.ConfigurationProperties; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.security.csrf.CsrfConfiguration; + +/** + * {@link ConfigurationProperties} implementation of {@link CsrfViewModelProcessorConfiguration}. + */ +@ConfigurationProperties(CsrfViewModelProcessorConfigurationProperties.PREFIX) +@Internal +final class CsrfViewModelProcessorConfigurationProperties implements CsrfViewModelProcessorConfiguration { + public static final String PREFIX = CsrfConfiguration.PREFIX + ".views-model-decorator"; + + /** + * The default enable value. + */ + @SuppressWarnings("WeakerAccess") + public static final boolean DEFAULT_ENABLED = true; + + /** + * The default csrfTokenKey value. + */ + @SuppressWarnings("WeakerAccess") + public static final String DEFAULT_CSRF_TOKEN = "csrfToken"; + + private boolean enabled = DEFAULT_ENABLED; + + @NonNull + private String csrfTokenKey = DEFAULT_CSRF_TOKEN; + + /** + * Model key for CSRF Token. Default value ({@value #DEFAULT_CSRF_TOKEN}). + * @return Model key for CSRF Token. + */ + @NonNull + public String getCsrfTokenKey() { + return csrfTokenKey; + } + + /** + * Model key for CSRF Token. Default value ({@value #DEFAULT_CSRF_TOKEN}). + * + * @param csrfTokenKey the key which will be used in the map model. + */ + public void setCsrfTokenKey(@NonNull String csrfTokenKey) { + this.csrfTokenKey = csrfTokenKey; + } + + @Override + public boolean isEnabled() { + return enabled; + } + + /** + * Enable {@link CsrfViewModelProcessor}. Default value ({@value #DEFAULT_ENABLED}). + * @param enabled enabled flag + */ + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + +} diff --git a/views-core/src/main/java/io/micronaut/views/model/security/MapViewModelProcessor.java b/views-core/src/main/java/io/micronaut/views/model/security/MapViewModelProcessor.java new file mode 100644 index 000000000..48bf11399 --- /dev/null +++ b/views-core/src/main/java/io/micronaut/views/model/security/MapViewModelProcessor.java @@ -0,0 +1,57 @@ +/* + * 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.model.security; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.http.HttpRequest; +import io.micronaut.views.ModelAndView; +import io.micronaut.views.model.ViewModelProcessor; + +import java.util.HashMap; +import java.util.Map; + +/** + * Abstract class to ease populating a map model and handle immutable maps. + */ +@Internal +sealed interface MapViewModelProcessor + extends ViewModelProcessor> + permits CsrfViewModelProcessor, SecurityViewModelProcessor { + @Override + default void process(@NonNull HttpRequest request, @NonNull ModelAndView> modelAndView) { + Map viewModel = modelAndView.getModel().orElseGet(() -> { + final Map newModel = new HashMap<>(1); + modelAndView.setModel(newModel); + return newModel; + }); + try { + populateModel(request, viewModel); + } catch (UnsupportedOperationException ex) { + final Map modifiableModel = new HashMap<>(viewModel); + populateModel(request, modifiableModel); + modelAndView.setModel(modifiableModel); + } + } + + /** + * method to populate the supplied model map with extra entries. + * @param req HTTP Request + * @param model Model map being populated + */ + void populateModel( + @NonNull HttpRequest req, + @NonNull Map model); +} diff --git a/views-core/src/main/java/io/micronaut/views/model/security/SecurityViewModelProcessor.java b/views-core/src/main/java/io/micronaut/views/model/security/SecurityViewModelProcessor.java index c49faab2b..50a48c175 100644 --- a/views-core/src/main/java/io/micronaut/views/model/security/SecurityViewModelProcessor.java +++ b/views-core/src/main/java/io/micronaut/views/model/security/SecurityViewModelProcessor.java @@ -21,13 +21,8 @@ import io.micronaut.security.authentication.Authentication; import io.micronaut.security.filters.SecurityFilter; import io.micronaut.security.utils.SecurityService; -import io.micronaut.views.ModelAndView; -import io.micronaut.views.model.ViewModelProcessor; -import io.micronaut.core.annotation.NonNull; import jakarta.inject.Inject; import jakarta.inject.Singleton; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.util.HashMap; import java.util.Map; @@ -42,10 +37,7 @@ @Requires(beans = {SecurityFilter.class, SecurityViewModelProcessorConfiguration.class}) @Requires(classes = HttpRequest.class) @Singleton -public class SecurityViewModelProcessor implements ViewModelProcessor> { - - private static final Logger LOG = LoggerFactory.getLogger(SecurityViewModelProcessor.class); - +public non-sealed class SecurityViewModelProcessor implements MapViewModelProcessor { private final SecurityViewModelProcessorConfiguration securityViewModelProcessorConfiguration; /** @@ -70,24 +62,18 @@ public SecurityViewModelProcessor( } @Override - public void process(@NonNull HttpRequest request, @NonNull ModelAndView> modelAndView) { - request.getAttribute(SecurityFilter.AUTHENTICATION, Authentication.class).ifPresent(authentication -> { - Map securityModel = new HashMap<>(); - securityModel.put(securityViewModelProcessorConfiguration.getPrincipalNameKey(), authentication.getName()); - securityModel.put(securityViewModelProcessorConfiguration.getAttributesKey(), authentication.getAttributes()); - - Map viewModel = modelAndView.getModel().orElseGet(() -> { - final HashMap newModel = new HashMap<>(1); - modelAndView.setModel(newModel); - return newModel; - }); - try { - viewModel.putIfAbsent(securityViewModelProcessorConfiguration.getSecurityKey(), securityModel); - } catch (UnsupportedOperationException ex) { - final HashMap modifiableModel = new HashMap<>(viewModel); - modifiableModel.putIfAbsent(securityViewModelProcessorConfiguration.getSecurityKey(), securityModel); - modelAndView.setModel(modifiableModel); - } + public void populateModel(HttpRequest request, Map model) { + request.getAttribute(SecurityFilter.AUTHENTICATION, Authentication.class) + .ifPresent(authentication -> { + Map securityModel = securityModel(authentication); + model.put(securityViewModelProcessorConfiguration.getSecurityKey(), securityModel); }); } + + private Map securityModel(Authentication authentication) { + Map securityModel = new HashMap<>(); + securityModel.put(securityViewModelProcessorConfiguration.getPrincipalNameKey(), authentication.getName()); + securityModel.put(securityViewModelProcessorConfiguration.getAttributesKey(), authentication.getAttributes()); + return securityModel; + } } diff --git a/views-core/src/main/java/io/micronaut/views/turbo/AbstractTurboRenderer.java b/views-core/src/main/java/io/micronaut/views/turbo/AbstractTurboRenderer.java index 02d81c3d7..99d1eaa0a 100644 --- a/views-core/src/main/java/io/micronaut/views/turbo/AbstractTurboRenderer.java +++ b/views-core/src/main/java/io/micronaut/views/turbo/AbstractTurboRenderer.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2022 original authors + * 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. @@ -15,37 +15,61 @@ */ package io.micronaut.views.turbo; +import io.micronaut.core.annotation.NextMajorVersion; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; import io.micronaut.core.io.Writable; import io.micronaut.http.HttpRequest; +import io.micronaut.views.ModelAndView; import io.micronaut.views.TemplatedBuilder; +import io.micronaut.views.ViewsModelDecorator; import io.micronaut.views.ViewsRendererLocator; +import io.micronaut.views.turbo.http.TurboMediaType; +import jakarta.inject.Inject; import java.util.Optional; /** + * @param The class to be built * @author Sergio del Amo * @since 3.4.0 - * @param The class to be built */ public abstract class AbstractTurboRenderer> { + private final ViewsRendererLocator viewsRendererLocator; private final String mediaType; + @Nullable + @NextMajorVersion("remove the nullability annotation") + private final ViewsModelDecorator viewsModelDecorator; + /** - * * @param viewsRendererLocator Views renderer Locator - * @param mediaType Media Type + * @param viewsModelDecorator Views Model Decorator + * @param mediaType Media Type */ - protected AbstractTurboRenderer(ViewsRendererLocator viewsRendererLocator, - String mediaType) { + @Inject + protected AbstractTurboRenderer( + ViewsRendererLocator viewsRendererLocator, + ViewsModelDecorator viewsModelDecorator, + String mediaType + ) { this.viewsRendererLocator = viewsRendererLocator; + this.viewsModelDecorator = viewsModelDecorator; this.mediaType = mediaType; } /** - * + * @param viewsRendererLocator Views renderer Locator + * @param mediaType Media Type + */ + @Deprecated(since = "5.2.1", forRemoval = true) + protected AbstractTurboRenderer(ViewsRendererLocator viewsRendererLocator, + String mediaType) { + this(viewsRendererLocator, null, mediaType); + } + + /** * @param builder Builder * @param request The Request * @return An Optional Writable with the builder rendered @@ -54,13 +78,17 @@ protected AbstractTurboRenderer(ViewsRendererLocator viewsRendererLocator, public Optional render(@NonNull T builder, @Nullable HttpRequest request) { return builder.getTemplateView() - .map(viewName -> { - Object model = builder.getTemplateModel().orElse(null); - return viewsRendererLocator.resolveViewsRenderer(viewName, mediaType, model) - .flatMap(renderer -> builder.template(renderer.render(viewName, model, request)) - .build() - .render()); - }) - .orElseGet(() -> builder.build().render()); + .map(viewName -> { + Object model = builder.getTemplateModel().orElse(null); + ModelAndView modelAndView = new ModelAndView<>(viewName, model); + if (request != null && viewsModelDecorator != null) { + viewsModelDecorator.decorate(request, modelAndView); + } + Object decoratedModel = modelAndView.getModel().orElse(null); + return viewsRendererLocator.resolveViewsRenderer(viewName, TurboMediaType.TURBO_STREAM, decoratedModel) + .flatMap(renderer -> builder.template(renderer.render(viewName, decoratedModel, request)).build() + .render()); + }) + .orElseGet(() -> builder.build().render()); } } diff --git a/views-core/src/main/java/io/micronaut/views/turbo/DefaultTurboFrameRenderer.java b/views-core/src/main/java/io/micronaut/views/turbo/DefaultTurboFrameRenderer.java index c0e52c978..8577a03ec 100644 --- a/views-core/src/main/java/io/micronaut/views/turbo/DefaultTurboFrameRenderer.java +++ b/views-core/src/main/java/io/micronaut/views/turbo/DefaultTurboFrameRenderer.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2022 original authors + * 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. @@ -15,7 +15,11 @@ */ package io.micronaut.views.turbo; +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpRequest; +import io.micronaut.views.ViewsModelDecorator; import io.micronaut.views.ViewsRendererLocator; +import jakarta.inject.Inject; import jakarta.inject.Singleton; /** @@ -24,12 +28,29 @@ * @since 3.4.0 */ @Singleton +@Requires(classes = HttpRequest.class) public class DefaultTurboFrameRenderer extends AbstractTurboRenderer implements TurboFrameRenderer { + /** * Constructor. * @param viewsRendererLocator Views Renderer Locator. + * @param viewsModelDecorator Views Model Decorator + */ + @Inject + public DefaultTurboFrameRenderer( + ViewsRendererLocator viewsRendererLocator, + ViewsModelDecorator viewsModelDecorator + ) { + super(viewsRendererLocator, viewsModelDecorator, "text/html"); + } + + /** + * + * @param viewsRendererLocator View Renderer Locator + * @deprecated Use {@link #DefaultTurboFrameRenderer(ViewsRendererLocator, ViewsModelDecorator)} instead. */ + @Deprecated(since = "5.2.1", forRemoval = true) public DefaultTurboFrameRenderer(ViewsRendererLocator viewsRendererLocator) { - super(viewsRendererLocator, "text/html"); + this(viewsRendererLocator, null); } } diff --git a/views-core/src/main/java/io/micronaut/views/turbo/TurboStream.java b/views-core/src/main/java/io/micronaut/views/turbo/TurboStream.java index 0aa2663d2..022647fe4 100644 --- a/views-core/src/main/java/io/micronaut/views/turbo/TurboStream.java +++ b/views-core/src/main/java/io/micronaut/views/turbo/TurboStream.java @@ -74,7 +74,7 @@ public final class TurboStream implements Renderable { private final String targetCssQuerySelector; /** - * request-id attribute, only relevant when action=refresh + * request-id attribute, only relevant when action=refresh. */ @Nullable private final String requestId; @@ -486,6 +486,24 @@ public Builder replace() { return action(TurboStreamAction.REPLACE); } + /** + * Sets the Turbo action as {@link TurboStreamAction#MORPH}. + * @return the Builder + */ + @NonNull + public Builder morph() { + return action(TurboStreamAction.MORPH); + } + + /** + * Sets the Turbo action as {@link TurboStreamAction#REFRESH}. + * @return the Builder + */ + @NonNull + public Builder refresh() { + return action(TurboStreamAction.REFRESH); + } + /** * * @return Builds the {@link TurboStream}. diff --git a/views-core/src/main/resources/META-INF/native-image/io.micronaut.views.model.security/native-image.properties b/views-core/src/main/resources/META-INF/native-image/io.micronaut.views.model.security/native-image.properties deleted file mode 100644 index 3a8e1b691..000000000 --- a/views-core/src/main/resources/META-INF/native-image/io.micronaut.views.model.security/native-image.properties +++ /dev/null @@ -1,17 +0,0 @@ -# -# Copyright 2017-2021 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. -# - -Args = --initialize-at-run-time=io.micronaut.views.model.security.$SecurityViewModelProcessor$Definition diff --git a/views-core/src/test/groovy/io/micronaut/views/turbo/TurboFrameSpec.groovy b/views-core/src/test/groovy/io/micronaut/views/turbo/TurboFrameSpec.groovy index 1345d8206..cf3d309f3 100644 --- a/views-core/src/test/groovy/io/micronaut/views/turbo/TurboFrameSpec.groovy +++ b/views-core/src/test/groovy/io/micronaut/views/turbo/TurboFrameSpec.groovy @@ -13,6 +13,8 @@ import io.micronaut.http.annotation.Header import io.micronaut.http.client.BlockingHttpClient import io.micronaut.http.client.HttpClient import io.micronaut.runtime.server.EmbeddedServer +import io.micronaut.security.annotation.Secured +import io.micronaut.security.rules.SecurityRule import io.micronaut.views.View import io.micronaut.views.ViewsRenderer import io.micronaut.views.turbo.http.TurboHttpHeaders @@ -182,6 +184,7 @@ class TurboFrameSpec extends Specification { } @Requires(property = "spec.name", value = "TurboFrameSpec") + @Secured(SecurityRule.IS_ANONYMOUS) @Controller("/frame") static class TurboFrameController { @Get diff --git a/views-core/src/test/groovy/io/micronaut/views/turbo/TurboStreamSpec.groovy b/views-core/src/test/groovy/io/micronaut/views/turbo/TurboStreamSpec.groovy index d80cd188c..4187b7cfa 100644 --- a/views-core/src/test/groovy/io/micronaut/views/turbo/TurboStreamSpec.groovy +++ b/views-core/src/test/groovy/io/micronaut/views/turbo/TurboStreamSpec.groovy @@ -18,6 +18,8 @@ import io.micronaut.http.annotation.Produces import io.micronaut.http.client.BlockingHttpClient import io.micronaut.http.client.HttpClient import io.micronaut.http.client.annotation.Client +import io.micronaut.security.annotation.Secured +import io.micronaut.security.rules.SecurityRule import io.micronaut.test.extensions.spock.annotation.MicronautTest import io.micronaut.views.View import io.micronaut.views.ViewsRenderer @@ -26,7 +28,6 @@ import io.micronaut.views.turbo.http.TurboMediaType import jakarta.inject.Inject import jakarta.inject.Singleton import spock.lang.Specification -import spock.lang.Unroll @Property(name = "micronaut.http.client.follow-redirects", value = StringUtils.FALSE) @Property(name = "spec.name", value = "TurboStreamSpec") @@ -77,6 +78,8 @@ class TurboStreamSpec extends Specification { TurboStreamAction.BEFORE == TurboStream.builder().targetDomId(domId).before().build().getAction() TurboStreamAction.UPDATE == TurboStream.builder().targetDomId(domId).update().build().getAction() TurboStreamAction.REPLACE == TurboStream.builder().targetDomId(domId).replace().build().getAction() + TurboStreamAction.MORPH == TurboStream.builder().targetDomId(domId).morph().build().getAction() + TurboStreamAction.REFRESH == TurboStream.builder().targetDomId(domId).refresh().build().getAction() } void "template is not required"() { @@ -227,11 +230,10 @@ class TurboStreamSpec extends Specification { then: HttpStatus.OK == responseHtml.status() responseHtml.contentType.isPresent() - responseHtml.contentType.get().toString() == MediaType.TEXT_HTML + responseHtml.contentType.get().toString() == "$MediaType.TEXT_HTML; charset=ISO-8859-1" "Page Title

Hello World

" == responseHtml.body() } - @Unroll void "target CSS Query Selector must have letters, digits, hyphens underscores colons, and periods"(String domId) { when: TurboStream.builder() @@ -253,7 +255,6 @@ class TurboStreamSpec extends Specification { ] } - @Unroll void "Illegal argument exception thrown if target CSS Query Selector contains something but letters, digits, hyphens underscores colons, and periods"(String domId) { when: TurboStream.builder() @@ -272,7 +273,6 @@ class TurboStreamSpec extends Specification { ] } - @Unroll void "target CSS Query Selector validation can be disabled"(String domId) { when: TurboStream.builder() @@ -292,7 +292,6 @@ class TurboStreamSpec extends Specification { ] } - @Unroll void "target DOM Id attribute must begin with a letter and may be followed by any number of letters, digits, hyphens underscores colons, and periods"(String domId) { when: TurboStream.builder() @@ -315,7 +314,6 @@ class TurboStreamSpec extends Specification { ] } - @Unroll void "Illegal argument exception thrown if target DOM Id attribute does not begin with a letter and may be followed by any number of letters, digits, hyphens underscores colons, and periods"(String domId) { when: TurboStream.builder() @@ -339,7 +337,6 @@ class TurboStreamSpec extends Specification { ] } - @Unroll void "Target DOM Id attribute validation can be disabled"(String domId) { when: TurboStream.builder() @@ -390,6 +387,7 @@ class TurboStreamSpec extends Specification { } @Requires(property = "spec.name", value = "TurboStreamSpec") + @Secured(SecurityRule.IS_ANONYMOUS) @Controller("/turbo") static class TurboStreamWriteableController { @Produces(TurboMediaType.TURBO_STREAM) @@ -442,14 +440,14 @@ class TurboStreamSpec extends Specification { } @Produces(TurboMediaType.TURBO_STREAM) - @TurboView(value = "fragments/message", action=TurboStreamAction.REFRESH, requestId = "abcd-1234") + @TurboView(value = "fragments/message", action = TurboStreamAction.REFRESH, requestId = "abcd-1234") @Get("/requestId") String requestId() { "Hello World" } @Produces(TurboMediaType.TURBO_STREAM) - @TurboView(value = "fragments/message", action=TurboStreamAction.MORPH, childrenOnly = true) + @TurboView(value = "fragments/message", action = TurboStreamAction.MORPH, childrenOnly = true) @Get("/childrenOnly") String childrenOnly() { "Hello World" @@ -468,7 +466,7 @@ class TurboStreamSpec extends Specification { "Hello World" } - @Produces(value = [MediaType.TEXT_HTML, TurboMediaType.TURBO_STREAM]) + @Produces(value = ['text/html; charset=ISO-8859-1', TurboMediaType.TURBO_STREAM]) @View("home") @TurboView(value = "fragments/message") @Get("/withBothAnnotations") @@ -511,6 +509,7 @@ class TurboStreamSpec extends Specification { @Requires(property = "spec.name", value = "TurboStreamSpec") @Controller("/customers") + @Secured(SecurityRule.IS_ANONYMOUS) static class CustomersController { @Get diff --git a/views-core/src/test/java/io/micronaut/views/model/security/CsrfViewModelProcessorConfigurationTest.java b/views-core/src/test/java/io/micronaut/views/model/security/CsrfViewModelProcessorConfigurationTest.java new file mode 100644 index 000000000..f46db1580 --- /dev/null +++ b/views-core/src/test/java/io/micronaut/views/model/security/CsrfViewModelProcessorConfigurationTest.java @@ -0,0 +1,35 @@ +package io.micronaut.views.model.security; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.core.util.StringUtils; +import org.junit.jupiter.api.Test; +import spock.lang.Specification; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class CsrfViewModelProcessorConfigurationTest extends Specification { + + @Test + void testCsrfViewModelProcessorConfiguration() { + try (ApplicationContext ctx = ApplicationContext.run()) { + CsrfViewModelProcessorConfiguration csrfViewModelProcessorConfiguration = ctx.getBean(CsrfViewModelProcessorConfiguration.class); + assertEquals("csrfToken", csrfViewModelProcessorConfiguration.getCsrfTokenKey()); + assertTrue(csrfViewModelProcessorConfiguration.isEnabled()); + } + } + + @Test + void testCsrfViewModelProcessorConfigurationSetting() { + try (ApplicationContext ctx = ApplicationContext.run( + Map.of( + "micronaut.security.csrf.views-model-decorator.csrf-token-key", "foobar", + "micronaut.security.csrf.views-model-decorator.enabled", StringUtils.FALSE + ))) { + CsrfViewModelProcessorConfiguration csrfViewModelProcessorConfiguration = ctx.getBean(CsrfViewModelProcessorConfiguration.class); + assertEquals("foobar", csrfViewModelProcessorConfiguration.getCsrfTokenKey()); + assertFalse(csrfViewModelProcessorConfiguration.isEnabled()); + } + } +} \ No newline at end of file diff --git a/views-core/src/test/java/io/micronaut/views/model/security/CsrfViewModelProcessorTest.java b/views-core/src/test/java/io/micronaut/views/model/security/CsrfViewModelProcessorTest.java new file mode 100644 index 000000000..576fa6743 --- /dev/null +++ b/views-core/src/test/java/io/micronaut/views/model/security/CsrfViewModelProcessorTest.java @@ -0,0 +1,18 @@ +package io.micronaut.views.model.security; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.core.util.StringUtils; +import org.junit.jupiter.api.Test; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertFalse; + +class CsrfViewModelProcessorTest { + @Test + void beanOfTypeCsrfViewModelProcessorIsNotPresent() { + try (ApplicationContext ctx = ApplicationContext.run( + Map.of( "micronaut.security.csrf.views-model-decorator.enabled", StringUtils.FALSE) + )) { + assertFalse(ctx.containsBean(CsrfViewModelProcessor.class)); + } + } +} \ No newline at end of file diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/AsssertHtmlUtils.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/AsssertHtmlUtils.java new file mode 100644 index 000000000..498263f3b --- /dev/null +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/AsssertHtmlUtils.java @@ -0,0 +1,42 @@ +/* + * 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.fields.tck; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Utils class to cleanup HTML strings to ease comparison. + */ +public final class AsssertHtmlUtils { + private AsssertHtmlUtils() { + } + + public static void assertHtmlEquals(String expected, String html) { + String expectedCleanup = cleanup(expected); + String htmlCleanup = cleanup(html); + assertEquals(expectedCleanup, htmlCleanup); + } + + public static String cleanup(String html) { + String cleanup = html.replaceAll("\\s+",""); + cleanup = cleanup.replaceAll("value=\"\"", ""); + cleanup = cleanup.replaceAll("disabled=\"disabled\"", "disabled"); + cleanup = cleanup.replaceAll("selected=\"selected\"", "selected"); + cleanup = cleanup.replaceAll("required=\"required\"", "required"); + cleanup = cleanup.replaceAll("checked=\"checked\"", "checked"); + return cleanup; + } +} diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FieldsetViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FieldsetViewRenderTest.java index de5303a9f..fd81077d6 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FieldsetViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FieldsetViewRenderTest.java @@ -28,7 +28,7 @@ import java.util.Collections; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @MicronautTest(startApplication = false) @@ -50,12 +50,12 @@ void render(ViewsRenderer, ?> viewsRenderer) throws IOExcept .build(); Fieldset fieldset = new Fieldset(Collections.singletonList(textElement), Collections.emptyList()); - assertEquals(""" + assertHtmlEquals("""
\ \ \
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); InputHiddenFormElement el = InputHiddenFormElement.builder() @@ -64,13 +64,13 @@ void render(ViewsRenderer, ?> viewsRenderer) throws IOExcept .build(); fieldset = new Fieldset(Arrays.asList(el, textElement), Collections.emptyList()); - assertEquals(""" + assertHtmlEquals(""" \
\ \ \
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); } } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormCompletedFileUploadRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormCompletedFileUploadRenderTest.java index e76a332e7..371444969 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormCompletedFileUploadRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormCompletedFileUploadRenderTest.java @@ -39,7 +39,7 @@ import java.util.Map; import java.util.function.BiConsumer; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @SuppressWarnings({"java:S5960"}) // Assertions are fine, these are tests @@ -51,7 +51,7 @@ class FormCompletedFileUploadRenderTest { void render(ViewsRenderer, ?> viewsRenderer, FormGenerator formGenerator, EventImageSaveValidator validator) throws IOException { - String viewName = "fieldset/form.html"; + String viewName = "fieldset/form"; BiConsumer> builderConsumer = (propertyName, builder) -> { if (propertyName.equals("file")) { builder.with("accept", "image/png, image/jpeg"); @@ -68,22 +68,22 @@ void render(ViewsRenderer, ?> viewsRenderer, \ """; Form form = formGenerator.generate("/foo/bar", "post", EventImageSave.class, builderConsumer); - assertEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + assertHtmlEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); form = formGenerator.generate("/foo/bar", "post", EventImageSave.class, FormGenerator.SUBMIT, builderConsumer); - assertEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + assertHtmlEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); form = formGenerator.generate("/foo/bar", "post", EventImageSave.class, new InputSubmitFormElement(FormGenerator.SUBMIT), builderConsumer); - assertEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + assertHtmlEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); form = formGenerator.generate("/foo/bar", EventImageSave.class, builderConsumer); - assertEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + assertHtmlEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); form = formGenerator.generate("/foo/bar", EventImageSave.class, FormGenerator.SUBMIT, builderConsumer); - assertEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + assertHtmlEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); form = formGenerator.generate("/foo/bar", EventImageSave.class, new InputSubmitFormElement(FormGenerator.SUBMIT), builderConsumer); - assertEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + assertHtmlEquals(expectedClass, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); EventImageSave invalid = new EventImageSave("xxx", "", null); ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, () -> validator.validate(invalid)); @@ -100,25 +100,25 @@ void render(ViewsRenderer, ?> viewsRenderer, \ """; form = formGenerator.generate("/foo/bar", "post", invalid, ex, builderConsumer); - assertEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + assertHtmlEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); form = formGenerator.generate("/foo/bar", "post", invalid, ex, FormGenerator.SUBMIT, builderConsumer); - assertEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + assertHtmlEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); form = formGenerator.generate("/foo/bar", "post", invalid, ex, new InputSubmitFormElement(FormGenerator.SUBMIT), builderConsumer); - assertEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + assertHtmlEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); form = formGenerator.generate("/foo/bar", invalid, ex, new InputSubmitFormElement(FormGenerator.SUBMIT), builderConsumer); - assertEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + assertHtmlEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); form = formGenerator.generate("/foo/bar", invalid, ex, builderConsumer); - assertEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + assertHtmlEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); form = formGenerator.generate("/foo/bar", invalid, ex, FormGenerator.SUBMIT, builderConsumer); - assertEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + assertHtmlEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); form = formGenerator.generate("/foo/bar", invalid, ex, new InputSubmitFormElement(FormGenerator.SUBMIT), builderConsumer); - assertEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); + assertHtmlEquals(expectedInvalid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); EventImageSave valid = new EventImageSave("xxx", "Micronaut Logo", null); String expectedValid = """ @@ -129,19 +129,19 @@ void render(ViewsRenderer, ?> viewsRenderer, \ """; form = formGenerator.generate("/foo/bar", "post", valid, builderConsumer); - assertEquals(expectedValid, + assertHtmlEquals(expectedValid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); form = formGenerator.generate("/foo/bar", valid, builderConsumer); - assertEquals(expectedValid, + assertHtmlEquals(expectedValid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); form = formGenerator.generate("/foo/bar", valid, FormGenerator.SUBMIT, builderConsumer); - assertEquals(expectedValid, + assertHtmlEquals(expectedValid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); form = formGenerator.generate("/foo/bar", valid, new InputSubmitFormElement(FormGenerator.SUBMIT), builderConsumer); - assertEquals(expectedValid, + assertHtmlEquals(expectedValid, TestUtils.render(viewName, viewsRenderer, Map.of("form", form))); } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormDataTurboRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormDataTurboRenderTest.java new file mode 100644 index 000000000..9d693725f --- /dev/null +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormDataTurboRenderTest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2017-2023 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.fields.tck; + +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.micronaut.views.ViewsRenderer; +import io.micronaut.views.fields.Fieldset; +import io.micronaut.views.fields.Form; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; + +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@SuppressWarnings({"java:S5960"}) // Assertions are fine, these are tests +@MicronautTest(startApplication = false) +class FormDataTurboRenderTest { + + @Test + void renderDataTurboTrueByDefault(ViewsRenderer, ?> viewsRenderer) throws IOException { + assertNotNull(viewsRenderer); + Form form = new Form("/foo/bar", "post", new Fieldset(Collections.emptyList(), Collections.emptyList()), "application/x-www-form-urlencoded"); + assertHtmlEquals(""" +
\ +
""", + TestUtils.render("fieldset/form", viewsRenderer, Map.of("form", form))); + } + + @Test + void renderDataTurboTrue(ViewsRenderer, ?> viewsRenderer) throws IOException { + assertNotNull(viewsRenderer); + Form form = new Form("/foo/bar", "post", new Fieldset(Collections.emptyList(), Collections.emptyList()), "application/x-www-form-urlencoded", true); + assertHtmlEquals(""" +
\ +
""", + TestUtils.render("fieldset/form", viewsRenderer, Map.of("form", form))); + } + + @Test + void renderDataTurboFalse(ViewsRenderer, ?> viewsRenderer) throws IOException { + assertNotNull(viewsRenderer); + Form form = new Form("/foo/bar", "post", new Fieldset(Collections.emptyList(), Collections.emptyList()), "application/x-www-form-urlencoded", false); + assertHtmlEquals(""" +
\ +
""", + TestUtils.render("fieldset/form", viewsRenderer, Map.of("form", form))); + } +} diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormEncTypeRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormEncTypeRenderTest.java index 99131b41e..b347ddbbf 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormEncTypeRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormEncTypeRenderTest.java @@ -25,7 +25,7 @@ import java.util.Collections; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -37,9 +37,9 @@ class FormEncTypeRenderTest { void render(ViewsRenderer, ?> viewsRenderer) throws IOException { assertNotNull(viewsRenderer); Form form = new Form("/foo/bar", "post", new Fieldset(Collections.emptyList(), Collections.emptyList()), "application/x-www-form-urlencoded"); - assertEquals(""" + assertHtmlEquals("""
\
""", - TestUtils.render("fieldset/form.html", viewsRenderer, Map.of("form", form))); + TestUtils.render("fieldset/form", viewsRenderer, Map.of("form", form))); } } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormViewRenderTest.java index ce847cc50..9b937dd40 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/FormViewRenderTest.java @@ -29,7 +29,7 @@ import java.util.List; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @MicronautTest(startApplication = false) @@ -55,14 +55,14 @@ void render(ViewsRenderer, ?> viewsRenderer) throws IOExcept .build(); Fieldset fieldset = new Fieldset(List.of(textElement, el), Collections.emptyList()); Form form = new Form("/foo/bar", "post", fieldset); - assertEquals(""" + assertHtmlEquals("""
\
\ \ \
\
""", - TestUtils.render("fieldset/form.html", viewsRenderer, Map.of("form", form)) + TestUtils.render("fieldset/form", viewsRenderer, Map.of("form", form)) ); } } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputCheckboxViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputCheckboxViewRenderTest.java index 1f5406c88..112eca0b8 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputCheckboxViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputCheckboxViewRenderTest.java @@ -17,6 +17,8 @@ import io.micronaut.test.extensions.junit5.annotation.MicronautTest; import io.micronaut.views.ViewsRenderer; +import io.micronaut.views.fields.Form; +import io.micronaut.views.fields.FormGenerator; import io.micronaut.views.fields.elements.Checkbox; import io.micronaut.views.fields.elements.InputCheckboxFormElement; import io.micronaut.views.fields.messages.Message; @@ -26,8 +28,8 @@ import java.util.List; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; +import static org.junit.jupiter.api.Assertions.*; @MicronautTest(startApplication = false) @SuppressWarnings({"java:S5960"}) // Assertions are fine, these are tests @@ -44,7 +46,7 @@ void render(ViewsRenderer, ?> viewsRenderer) throws IOExcept Checkbox.builder().id("devils").name("devils").disabled(true).label(Message.of("Devils")).build() )) .build(); - assertEquals(""" + assertHtmlEquals(""" \
\ \ @@ -58,7 +60,15 @@ void render(ViewsRenderer, ?> viewsRenderer) throws IOExcept \ \
""", - TestUtils.render("fieldset/inputcheckbox.html", viewsRenderer, Map.of("el", el)).trim() + TestUtils.render("fieldset/inputcheckbox", viewsRenderer, Map.of("el", el)).trim() ); } + + @Test + void render(ViewsRenderer, ?> viewsRenderer, FormGenerator formGenerator) throws IOException { + assertNotNull(viewsRenderer); + Form form = formGenerator.generate("/login", SigninForm.class); + String html = TestUtils.render("fieldset/form", viewsRenderer, Map.of("form", form)).trim(); + assertEquals(1, TestUtils.countOccurrences(html, "Remember Me")); + } } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputDateTimeLocalViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputDateTimeLocalViewRenderTest.java index 8de88c510..839df08d8 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputDateTimeLocalViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputDateTimeLocalViewRenderTest.java @@ -28,7 +28,7 @@ import java.time.LocalDateTime; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -43,25 +43,25 @@ void render(ViewsRenderer, ?> viewsRenderer, assertNotNull(viewsRenderer); Fieldset fieldset = fieldsetGenerator.generate(new Event(LocalDateTime.of(2023, 10, 28, 16, 30))); - assertEquals(""" + assertHtmlEquals("""
\ \ \
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); @SuppressWarnings("java:S2637") // We're passing null on purpose Event invalid = new Event(null); ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, () -> formValidator.validate(invalid)); fieldset = fieldsetGenerator.generate(invalid, ex); - assertEquals(""" + assertHtmlEquals("""
\ \ \
must not be null
\
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputDateViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputDateViewRenderTest.java index 6106527b7..96136a3a4 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputDateViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputDateViewRenderTest.java @@ -28,7 +28,7 @@ import java.time.LocalDate; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -43,12 +43,12 @@ void render(ViewsRenderer, ?> viewsRenderer, assertNotNull(viewsRenderer); Fieldset fieldset = fieldsetGenerator.generate(new Event(LocalDate.of(2023, 10, 28))); - assertEquals(""" + assertHtmlEquals("""
\ \ \
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); @@ -56,13 +56,13 @@ void render(ViewsRenderer, ?> viewsRenderer, Event invalid = new Event(null); ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, () -> formValidator.validate(invalid)); fieldset = fieldsetGenerator.generate(invalid, ex); - assertEquals(""" + assertHtmlEquals("""
\ \ \
must not be null
\
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputEmailViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputEmailViewRenderTest.java index 166e85eea..c1f202258 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputEmailViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputEmailViewRenderTest.java @@ -31,7 +31,7 @@ import java.io.IOException; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -47,12 +47,12 @@ void render(ViewsRenderer, ?> viewsRenderer, assertNotNull(viewsRenderer); Fieldset fieldset = fieldsetGenerator.generate(new Login("advocate@micronaut.io")); - assertEquals(""" + assertHtmlEquals("""
\ \ \
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); @@ -60,13 +60,13 @@ void render(ViewsRenderer, ?> viewsRenderer, Login invalid = new Login(null); ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, () -> formValidator.validate(invalid)); fieldset = fieldsetGenerator.generate(invalid, ex); - assertEquals(""" + assertHtmlEquals("""
\ \ \
must not be blank
\
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputHiddenViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputHiddenViewRenderTest.java index 34d23ba69..25687b39b 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputHiddenViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputHiddenViewRenderTest.java @@ -31,7 +31,7 @@ import java.io.IOException; import java.util.Map; -import static io.micronaut.views.fields.tck.TestUtils.assertEqualsIgnoreSpace; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -50,34 +50,34 @@ void render(ViewsRenderer, ?> viewsRenderer, .build(); Form form = new Form("/post/save", "post", fieldsetGenerator.generate(new Post(34657L))); - assertEqualsIgnoreSpace(""" + assertHtmlEquals("""
\ \
""", - TestUtils.render("fieldset/form.html", viewsRenderer, Map.of("form", form)) + TestUtils.render("fieldset/form", viewsRenderer, Map.of("form", form)) ); @SuppressWarnings("java:S2637") // We're passing null on purpose Post invalid = new Post(null); ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, () -> formValidator.validate(invalid)); form = new Form("/post/save", "post", fieldsetGenerator.generate(invalid, ex)); - assertEqualsIgnoreSpace(""" + assertHtmlEquals("""
\ \
""", - TestUtils.render("fieldset/form.html", viewsRenderer, Map.of("form", form)) + TestUtils.render("fieldset/form", viewsRenderer, Map.of("form", form)) ); - assertEqualsIgnoreSpace(""" + assertHtmlEquals(""" """, - TestUtils.render("fieldset/inputhidden.html", viewsRenderer, Map.of("el", el)) + TestUtils.render("fieldset/inputhidden", viewsRenderer, Map.of("el", el)) ); Fieldset fieldset = fieldsetGenerator.generate(new Post(34657L)); - assertEqualsIgnoreSpace(""" + assertHtmlEquals(""" """, - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputNumberViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputNumberViewRenderTest.java index c7792e43d..6d1dcfdd5 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputNumberViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputNumberViewRenderTest.java @@ -28,7 +28,7 @@ import java.io.IOException; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -44,25 +44,25 @@ void render(ViewsRenderer, ?> viewsRenderer, assertNotNull(viewsRenderer); Fieldset fieldset = fieldsetGenerator.generate(new Book(125)); - assertEquals(""" + assertHtmlEquals("""
\ \ \
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); @SuppressWarnings("java:S2637") // We're passing null on purpose Book invalid = new Book(null); ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, () -> formValidator.validate(invalid)); fieldset = fieldsetGenerator.generate(invalid, ex); - assertEquals(""" + assertHtmlEquals("""
\ \ \
must not be null
\
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputPasswordViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputPasswordViewRenderTest.java index 0cc7d5dc6..6653bc0e4 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputPasswordViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputPasswordViewRenderTest.java @@ -28,7 +28,7 @@ import java.io.IOException; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -43,24 +43,24 @@ void render(ViewsRenderer, ?> viewsRenderer, assertNotNull(viewsRenderer); Fieldset fieldset = fieldsetGenerator.generate(new Login("foo")); - assertEquals(""" + assertHtmlEquals("""
\ \ \
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); Login invalid = new Login(null); ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, () -> formValidator.validate(invalid)); fieldset = fieldsetGenerator.generate(invalid, ex); - assertEquals(""" + assertHtmlEquals("""
\ \ \
must not be blank
\
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputRadioViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputRadioViewRenderTest.java index 2851b38ab..35d120f2f 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputRadioViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputRadioViewRenderTest.java @@ -26,9 +26,8 @@ import java.util.List; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; @MicronautTest(startApplication = false) @SuppressWarnings({"java:S5960"}) // Assertions are fine, these are tests @@ -61,15 +60,11 @@ void render(ViewsRenderer, ?> viewsRenderer) throws IOExcept .build() )) .build(); - String html = TestUtils.render("fieldset/inputradio.html", viewsRenderer, Map.of("el", el, "radio", huey)).trim(); + String html = TestUtils.render("fieldset/inputradio", viewsRenderer, Map.of("el", el, "radio", huey)).trim(); - assertTrue( - "".equals(html) - || "".equals(html) - ); - - html = TestUtils.render("fieldset/inputradios.html", viewsRenderer, Map.of("el", el)); - assertEquals(""" + assertHtmlEquals("", html); + html = TestUtils.render("fieldset/inputradios", viewsRenderer, Map.of("el", el)); + assertHtmlEquals("""
\ \ \ diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputStringViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputStringViewRenderTest.java index fa8b57b58..7f4176d43 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputStringViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputStringViewRenderTest.java @@ -24,7 +24,7 @@ import java.io.IOException; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @MicronautTest(startApplication = false) @@ -44,10 +44,10 @@ void render(ViewsRenderer, ?> viewsRenderer) throws IOExcept .required(true) .label(Message.of("Name (4 to 8 characters):")) .build(); - assertEquals(""" + assertHtmlEquals(""" \ """, - TestUtils.render("fieldset/inputstring.html", viewsRenderer, Map.of("type", "text", "el", el)) + TestUtils.render("fieldset/inputstring", viewsRenderer, Map.of("type", "text", "el", el)) ); } } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputSubmitViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputSubmitViewRenderTest.java index dd6ad1871..423c8c851 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputSubmitViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputSubmitViewRenderTest.java @@ -25,7 +25,7 @@ import java.util.Collections; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @MicronautTest(startApplication = false) @@ -42,13 +42,13 @@ void render(ViewsRenderer, ?> viewsRenderer) throws IOExcept String expected = """ """; - assertEquals(expected, TestUtils.render("fieldset/inputsubmit.html", viewsRenderer, Collections.singletonMap("el", el))); + assertHtmlEquals(expected, TestUtils.render("fieldset/inputsubmit", viewsRenderer, Collections.singletonMap("el", el))); value = Message.of("Send Request", "foobar"); el = InputSubmitFormElement.builder() .value(value) .build(); - assertEquals(expected, TestUtils.render("fieldset/inputsubmit.html", viewsRenderer, Collections.singletonMap("el", el))); + assertHtmlEquals(expected, TestUtils.render("fieldset/inputsubmit", viewsRenderer, Collections.singletonMap("el", el))); } } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputTelViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputTelViewRenderTest.java index f9eb694a0..0f95adbd8 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputTelViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputTelViewRenderTest.java @@ -30,7 +30,7 @@ import java.io.IOException; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -46,12 +46,12 @@ void render(ViewsRenderer, ?> viewsRenderer, assertNotNull(viewsRenderer); Fieldset fieldset = fieldsetGenerator.generate(new Contact("123567")); - assertEquals(""" + assertHtmlEquals("""
\ \ \
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); @@ -59,13 +59,13 @@ void render(ViewsRenderer, ?> viewsRenderer, Contact invalid = new Contact(null); ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, () -> formValidator.validate(invalid)); fieldset = fieldsetGenerator.generate(invalid, ex); - assertEquals(""" + assertHtmlEquals("""
\ \ \
must not be blank
\
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputTextViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputTextViewRenderTest.java index 71cef7818..9e7ca36a7 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputTextViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputTextViewRenderTest.java @@ -29,7 +29,7 @@ import java.io.IOException; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -45,25 +45,25 @@ void render(ViewsRenderer, ?> viewsRenderer, assertNotNull(viewsRenderer); Fieldset fieldset = fieldsetGenerator.generate(new Contact("Sergio")); - assertEquals(""" + assertHtmlEquals("""
\ \ \
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); @SuppressWarnings("java:S2637") // We're passing null on purpose Contact invalid = new Contact(null); ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, () -> formValidator.validate(invalid)); fieldset = fieldsetGenerator.generate(invalid, ex); - assertEquals(""" + assertHtmlEquals("""
\ \ \
must not be blank
\
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputTimeViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputTimeViewRenderTest.java index a3e1487b9..7a894754d 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputTimeViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputTimeViewRenderTest.java @@ -31,7 +31,7 @@ import java.util.Collections; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -50,10 +50,10 @@ void render(ViewsRenderer, ?> viewsRenderer) throws IOExcept .max(LocalTime.of(18, 0)) .value(LocalTime.of(10, 0)) .build(); - assertEquals(""" + assertHtmlEquals(""" \ """, - TestUtils.render("fieldset/inputtime.html", viewsRenderer, Collections.singletonMap("el", el)) + TestUtils.render("fieldset/inputtime", viewsRenderer, Collections.singletonMap("el", el)) ); } @@ -64,12 +64,12 @@ void render(ViewsRenderer, ?> viewsRenderer, assertNotNull(viewsRenderer); Fieldset fieldset = fieldsetGenerator.generate(new Event(LocalTime.of(16, 30))); - assertEquals(""" + assertHtmlEquals("""
\ \ \
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); @@ -77,13 +77,13 @@ void render(ViewsRenderer, ?> viewsRenderer, Event invalid = new Event(null); ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, () -> formValidator.validate(invalid)); fieldset = fieldsetGenerator.generate(invalid, ex); - assertEquals(""" + assertHtmlEquals("""
\ \ \
must not be null
\
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputUrlViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputUrlViewRenderTest.java index f2b2897a3..9f438da80 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputUrlViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/InputUrlViewRenderTest.java @@ -29,7 +29,7 @@ import java.io.IOException; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -44,25 +44,25 @@ void render(ViewsRenderer, ?> viewsRenderer, assertNotNull(viewsRenderer); Fieldset fieldset = fieldsetGenerator.generate(new Contact("https://micronaut.io")); - assertEquals(""" + assertHtmlEquals("""
\ \ \
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); @SuppressWarnings("java:S2637") // We're passing null on purpose Contact invalid = new Contact(null); ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, () -> formValidator.validate(invalid)); fieldset = fieldsetGenerator.generate(invalid, ex); - assertEquals(""" + assertHtmlEquals("""
\ \ \
must not be blank
\
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/LabelViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/LabelViewRenderTest.java index 99dccf695..4af57e6bd 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/LabelViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/LabelViewRenderTest.java @@ -23,7 +23,7 @@ import java.io.IOException; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @MicronautTest(startApplication = false) @@ -41,18 +41,18 @@ void render(ViewsRenderer, ?> viewsRenderer) throws IOExcept String expected = """ """.formatted(FOO_BAR); - assertEquals(expected, TestUtils.render("fieldset/label.html", viewsRenderer, Map.of("id", id, "el", message))); + assertHtmlEquals(expected, TestUtils.render("fieldset/label", viewsRenderer, Map.of("id", id, "el", message))); message = Message.of(FOO_BAR); - assertEquals(expected, TestUtils.render("fieldset/label.html", viewsRenderer, Map.of("id", id, "el", message))); + assertHtmlEquals(expected, TestUtils.render("fieldset/label", viewsRenderer, Map.of("id", id, "el", message))); expected = """ """.formatted(FOO_BAR); message = Message.of(FOO_BAR); - assertEquals(expected, TestUtils.render("fieldset/label.html", viewsRenderer, Map.of("el", message))); + assertHtmlEquals(expected, TestUtils.render("fieldset/label", viewsRenderer, Map.of("el", message))); message = Message.of(FOO_BAR, "foo.bar"); - assertEquals(expected, TestUtils.render("fieldset/label.html", viewsRenderer, Map.of("el", message))); + assertHtmlEquals(expected, TestUtils.render("fieldset/label", viewsRenderer, Map.of("el", message))); } } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/OptionViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/OptionViewRenderTest.java index e840c66d9..3e512cd47 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/OptionViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/OptionViewRenderTest.java @@ -25,7 +25,7 @@ import java.util.Collections; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; @MicronautTest(startApplication = false) @SuppressWarnings({"java:S5960"}) // Assertions are fine, these are tests @@ -38,12 +38,12 @@ void render(ViewsRenderer, ?> viewsRenderer) throws IOExcept .label(Message.of("Dog")) .build(); String expected = ""; - assertEquals(expected, TestUtils.render("fieldset/option.html", viewsRenderer, Collections.singletonMap("el", option))); + assertHtmlEquals(expected, TestUtils.render("fieldset/option", viewsRenderer, Collections.singletonMap("el", option))); option = Option.builder() .value("dog") .label(Message.of("Dog", "foobar")) .build(); - assertEquals(expected, TestUtils.render("fieldset/option.html", viewsRenderer, Collections.singletonMap("el", option))); + assertHtmlEquals(expected, TestUtils.render("fieldset/option", viewsRenderer, Collections.singletonMap("el", option))); } } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/SelectViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/SelectViewRenderTest.java index 40635b8b2..36e508427 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/SelectViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/SelectViewRenderTest.java @@ -36,7 +36,7 @@ import java.util.List; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -78,7 +78,7 @@ void render(ViewsRenderer, ?> viewsRenderer) throws IOExcept .build() )) .build(); - assertEquals(""" + assertHtmlEquals(""" """, - TestUtils.render("fieldset/select.html", viewsRenderer, Map.of("el", el)) + TestUtils.render("fieldset/select", viewsRenderer, Map.of("el", el)) ); } @@ -98,7 +98,7 @@ void render(ViewsRenderer, ?> viewsRenderer, assertNotNull(viewsRenderer); Fieldset fieldset = fieldsetGenerator.generate(new Clinic("cat")); - assertEquals(""" + assertHtmlEquals("""
\ \ \ -
""", TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset))); +
""", TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset))); Clinic invalid = new Clinic(null); ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, () -> formValidator.validate(invalid)); fieldset = fieldsetGenerator.generate(invalid, ex); - assertEquals(""" + assertHtmlEquals("""
\ \ \
must not be blank
\ -
""", TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset))); + """, TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset))); } @Requires(property = "spec.name", value = "SelectViewRenderTest") diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/SigninForm.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/SigninForm.java new file mode 100644 index 000000000..58df94afc --- /dev/null +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/SigninForm.java @@ -0,0 +1,22 @@ +/* + * 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.fields.tck; + +import io.micronaut.core.annotation.Introspected; + +@Introspected +public record SigninForm(String username, String password, boolean rememberMe) { +} diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/TestUtils.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/TestUtils.java index 020c1526c..d82a4face 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/TestUtils.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/TestUtils.java @@ -42,4 +42,20 @@ public static String output(Writable writeable) throws IOException { writeable.writeTo(sw); return sw.toString(); } + + public static int countOccurrences(String mainString, String subString) { + if (mainString == null || subString == null || subString.isEmpty()) { + return 0; + } + + int count = 0; + int index = 0; + + while ((index = mainString.indexOf(subString, index)) != -1) { + count++; + index += subString.length(); + } + + return count; + } } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/TextareaViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/TextareaViewRenderTest.java index fd7b14e5e..27d2b243e 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/TextareaViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/TextareaViewRenderTest.java @@ -28,7 +28,7 @@ import java.io.IOException; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -43,25 +43,24 @@ void render(ViewsRenderer, ?> viewsRenderer, assertNotNull(viewsRenderer); Fieldset fieldset = fieldsetGenerator.generate(new Post("bla bla bla")); - assertEquals(""" + assertHtmlEquals("""
\ \ \
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); - Post invalid = new Post(null); ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, () -> formValidator.validate(invalid)); fieldset = fieldsetGenerator.generate(invalid, ex); - assertEquals(""" + assertHtmlEquals("""
\ \ \
must not be blank
\
""", - TestUtils.render("fieldset/fieldset.html", viewsRenderer, Map.of("el", fieldset)) + TestUtils.render("fieldset/fieldset", viewsRenderer, Map.of("el", fieldset)) ); } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/TrixEditorViewRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/TrixEditorViewRenderTest.java index 3d998e105..ff0bc7859 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/TrixEditorViewRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/TrixEditorViewRenderTest.java @@ -25,7 +25,7 @@ import java.util.Collections; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @MicronautTest(startApplication = false) @@ -41,11 +41,11 @@ void render(ViewsRenderer, ?> viewsRenderer) throws IOExcept .value("It was a dark and stormy night...") .label(Message.of("Tell us your story:")) .build(); - assertEquals(""" + assertHtmlEquals(""" \ \ """, - TestUtils.render("fieldset/trixeditor.html", viewsRenderer, Collections.singletonMap("el", el)) + TestUtils.render("fieldset/trixeditor", viewsRenderer, Collections.singletonMap("el", el)) ); } } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputCheckboxFormElementRendererTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputCheckboxFormElementRendererTest.java index 3c4ca0ed4..57fea4c7b 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputCheckboxFormElementRendererTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputCheckboxFormElementRendererTest.java @@ -28,10 +28,10 @@ import java.util.List; import java.util.Locale; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -@Property(name = "micronaut.views.form-element.render.views.input-checkbox", value = "fieldset/inputcheckbox.html") +@Property(name = "micronaut.views.form-element.render.views.input-checkbox", value = "fieldset/inputcheckbox") @MicronautTest(startApplication = false) @SuppressWarnings({ "java:S5960", // Assertions are fine, these are tests @@ -51,7 +51,7 @@ void render() { Checkbox.builder().id("horns").name("horns").label(Message.of("Horns", null)).build() )) .build(); - assertEquals(""" + assertHtmlEquals("""
\
""", renderer.render(el, Locale.ENGLISH).trim() diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputDateFormElementRendererTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputDateFormElementRendererTest.java index 4820ef08f..62a62398b 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputDateFormElementRendererTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputDateFormElementRendererTest.java @@ -27,9 +27,10 @@ import java.time.LocalDate; import java.util.Locale; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.*; -@Property(name = "micronaut.views.form-element.render.views.input-date", value = "fieldset/inputdate.html") +@Property(name = "micronaut.views.form-element.render.views.input-date", value = "fieldset/inputdate") @MicronautTest(startApplication = false) @SuppressWarnings({ "java:S5960", // Assertions are fine, these are tests @@ -51,7 +52,7 @@ void render() { .max(LocalDate.of(2018, 12, 31)) .value(LocalDate.of(2018, 7, 22)) .build(); - assertEquals(""" + assertHtmlEquals(""" \ """, renderer.render(el, Locale.ENGLISH) diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputDateTimeFormElementRendererTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputDateTimeFormElementRendererTest.java index aad5b7922..ba69ea811 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputDateTimeFormElementRendererTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputDateTimeFormElementRendererTest.java @@ -27,10 +27,10 @@ import java.time.LocalDateTime; import java.util.Locale; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -@Property(name = "micronaut.views.form-element.render.views.input-date-time-local", value = "fieldset/inputdatetimelocal.html") +@Property(name = "micronaut.views.form-element.render.views.input-date-time-local", value = "fieldset/inputdatetimelocal") @MicronautTest(startApplication = false) @SuppressWarnings({ "java:S5960", // Assertions are fine, these are tests @@ -52,7 +52,7 @@ void render() { .max(LocalDateTime.of(2018, 6, 14, 0, 0)) .value(LocalDateTime.of(2018, 6, 12, 19, 30)) .build(); - assertEquals(""" + assertHtmlEquals(""" \ """, renderer.render(el, Locale.ENGLISH) diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputEmailFormElementRendererTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputEmailFormElementRendererTest.java index aadb79fb0..a55ffc0c9 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputEmailFormElementRendererTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputEmailFormElementRendererTest.java @@ -26,10 +26,10 @@ import java.util.Locale; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -@Property(name = "micronaut.views.form-element.render.views.input-email", value = "fieldset/inputemail.html") +@Property(name = "micronaut.views.form-element.render.views.input-email", value = "fieldset/inputemail") @MicronautTest(startApplication = false) @SuppressWarnings({ "java:S5960", // Assertions are fine, these are tests @@ -51,7 +51,7 @@ void render() { .size(30) .label(Message.of("Enter your globex.com email:", null)) .build(); - assertEquals(""" + assertHtmlEquals(""" \ """, renderer.render(el, Locale.ENGLISH) diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputHiddenFormElementRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputHiddenFormElementRenderTest.java index 6487060cb..78348ed03 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputHiddenFormElementRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputHiddenFormElementRenderTest.java @@ -25,10 +25,10 @@ import java.util.Locale; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -@Property(name = "micronaut.views.form-element.render.views.input-hidden", value = "fieldset/inputhidden.html") +@Property(name = "micronaut.views.form-element.render.views.input-hidden", value = "fieldset/inputhidden") @MicronautTest(startApplication = false) @SuppressWarnings({ "java:S5960", // Assertions are fine, these are tests @@ -47,7 +47,7 @@ void renderOption() { .name("postId") .value("34657") .build(); - assertEquals(""" + assertHtmlEquals(""" """, renderer.render(el, Locale.ENGLISH) ); diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputNumberFormElementRendererTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputNumberFormElementRendererTest.java index ebbf3a31d..aae44fe4c 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputNumberFormElementRendererTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputNumberFormElementRendererTest.java @@ -26,10 +26,10 @@ import java.util.Locale; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -@Property(name = "micronaut.views.form-element.render.views.input-number", value = "fieldset/inputnumber.html") +@Property(name = "micronaut.views.form-element.render.views.input-number", value = "fieldset/inputnumber") @MicronautTest(startApplication = false) @SuppressWarnings({ "java:S5960", // Assertions are fine, these are tests @@ -50,7 +50,7 @@ void render() { .min(10) .max(100) .build(); - assertEquals(""" + assertHtmlEquals(""" \ """, renderer.render(el, Locale.ENGLISH) diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputPasswordFormElementRendererTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputPasswordFormElementRendererTest.java index 455489cf2..f5b2b7e02 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputPasswordFormElementRendererTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputPasswordFormElementRendererTest.java @@ -26,10 +26,10 @@ import java.util.Locale; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -@Property(name = "micronaut.views.form-element.render.views.input-password", value = "fieldset/inputpassword.html") +@Property(name = "micronaut.views.form-element.render.views.input-password", value = "fieldset/inputpassword") @MicronautTest(startApplication = false) @SuppressWarnings({ "java:S5960", // Assertions are fine, these are tests @@ -50,7 +50,7 @@ void render() { .required(true) .label(Message.of("Password (8 characters minimum):", null)) .build(); - assertEquals(""" + assertHtmlEquals(""" \ """, renderer.render(el, Locale.ENGLISH) diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputRadioFormElementRendererTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputRadioFormElementRendererTest.java index 3de48c7a8..722b08598 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputRadioFormElementRendererTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputRadioFormElementRendererTest.java @@ -28,10 +28,10 @@ import java.util.List; import java.util.Locale; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -@Property(name = "micronaut.views.form-element.render.views.input-radio", value = "fieldset/inputradios.html") +@Property(name = "micronaut.views.form-element.render.views.input-radio", value = "fieldset/inputradios") @MicronautTest(startApplication = false) @SuppressWarnings({ "java:S5960", // Assertions are fine, these are tests @@ -66,7 +66,7 @@ void renderOption() { .build() )) .build(); - assertEquals(""" + assertHtmlEquals("""
\
\
""", diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputSubmitFormElementRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputSubmitFormElementRenderTest.java index b60b46712..5a660be7f 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputSubmitFormElementRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputSubmitFormElementRenderTest.java @@ -26,10 +26,10 @@ import java.util.Locale; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -@Property(name = "micronaut.views.form-element.render.views.input-submit", value = "fieldset/inputsubmit.html") +@Property(name = "micronaut.views.form-element.render.views.input-submit", value = "fieldset/inputsubmit") @MicronautTest(startApplication = false) @SuppressWarnings({ "java:S5960", // Assertions are fine, these are tests @@ -48,7 +48,7 @@ void renderOption() { InputSubmitFormElement el = InputSubmitFormElement.builder() .value(value) .build(); - assertEquals(""" + assertHtmlEquals(""" """, renderer.render(el, Locale.ENGLISH) ); diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputTelFormElementRendererTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputTelFormElementRendererTest.java index 395cf6116..0e88a081f 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputTelFormElementRendererTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputTelFormElementRendererTest.java @@ -26,10 +26,10 @@ import java.util.Locale; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -@Property(name = "micronaut.views.form-element.render.views.input-tel", value = "fieldset/inputtel.html") +@Property(name = "micronaut.views.form-element.render.views.input-tel", value = "fieldset/inputtel") @MicronautTest(startApplication = false) @SuppressWarnings({ "java:S5960", // Assertions are fine, these are tests @@ -50,7 +50,7 @@ void render() { .pattern("[0-9]{3}-[0-9]{3}-[0-9]{4}") .label(Message.of("Enter your phone number:", null)) .build(); - assertEquals(""" + assertHtmlEquals(""" \ """, renderer.render(el, Locale.ENGLISH) diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputTextFormElementRendererTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputTextFormElementRendererTest.java index 0e7f1c285..0ba9a713d 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputTextFormElementRendererTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputTextFormElementRendererTest.java @@ -26,10 +26,10 @@ import java.util.Locale; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -@Property(name = "micronaut.views.form-element.render.views.input-text", value = "fieldset/inputtext.html") +@Property(name = "micronaut.views.form-element.render.views.input-text", value = "fieldset/inputtext") @MicronautTest(startApplication = false) @SuppressWarnings({ "java:S5960", // Assertions are fine, these are tests @@ -52,7 +52,7 @@ void render() { .required(true) .label(Message.of("Name (4 to 8 characters):", null)) .build(); - assertEquals(""" + assertHtmlEquals(""" \ """, renderer.render(el, Locale.ENGLISH) diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputTimeFormElementRendererTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputTimeFormElementRendererTest.java index 976ea0739..7c4311350 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputTimeFormElementRendererTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputTimeFormElementRendererTest.java @@ -27,10 +27,10 @@ import java.time.LocalTime; import java.util.Locale; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -@Property(name = "micronaut.views.form-element.render.views.input-time", value = "fieldset/inputtime.html") +@Property(name = "micronaut.views.form-element.render.views.input-time", value = "fieldset/inputtime") @MicronautTest(startApplication = false) @SuppressWarnings({ "java:S5960", // Assertions are fine, these are tests @@ -52,7 +52,7 @@ void render() { .max(LocalTime.of(18, 0)) .value(LocalTime.of(10, 0)) .build(); - assertEquals(""" + assertHtmlEquals(""" \ """, renderer.render(el, Locale.ENGLISH) diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputUrlFormElementRendererTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputUrlFormElementRendererTest.java index fd9f00360..830bddbd1 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputUrlFormElementRendererTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/InputUrlFormElementRendererTest.java @@ -26,10 +26,10 @@ import java.util.Locale; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -@Property(name = "micronaut.views.form-element.render.views.input-url", value = "fieldset/inputurl.html") +@Property(name = "micronaut.views.form-element.render.views.input-url", value = "fieldset/inputurl") @MicronautTest(startApplication = false) @SuppressWarnings({ "java:S5960", // Assertions are fine, these are tests @@ -52,7 +52,7 @@ void render() { .required(true) .label(Message.of("Enter an https:// URL:", null)) .build(); - assertEquals(""" + assertHtmlEquals(""" \ """, renderer.render(el, Locale.ENGLISH) diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/OptionFormElementRenderTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/OptionFormElementRenderTest.java index 228f149d1..1ab673509 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/OptionFormElementRenderTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/OptionFormElementRenderTest.java @@ -21,14 +21,16 @@ import io.micronaut.views.fields.messages.Message; import io.micronaut.views.fields.render.FormElementRenderer; import io.micronaut.views.fields.render.secondary.OptionFormElementRenderer; +import io.micronaut.views.fields.tck.AsssertHtmlUtils; import jakarta.inject.Inject; import org.junit.jupiter.api.Test; import java.util.Locale; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.*; -@Property(name = "micronaut.views.form-element.render.views.option", value = "fieldset/option.html") +@Property(name = "micronaut.views.form-element.render.views.option", value = "fieldset/option") @MicronautTest(startApplication = false) @SuppressWarnings({ "java:S5960", // Assertions are fine, these are tests @@ -47,7 +49,7 @@ void renderOption() { .value("dog") .label(Message.of("Dog", null)) .build(); - assertEquals(""" + assertHtmlEquals(""" """, renderer.render(option, Locale.ENGLISH) ); @@ -58,10 +60,11 @@ void renderOption() { .disabled(true) .build(); String result = renderer.render(option, Locale.ENGLISH); - assertTrue(""" - """.equals(result) - || """ - """.equals(result) + assertTrue( + AsssertHtmlUtils.cleanup("").equals(AsssertHtmlUtils.cleanup(result)) || + AsssertHtmlUtils.cleanup("").equals(AsssertHtmlUtils.cleanup(result)) || + AsssertHtmlUtils.cleanup("").equals(AsssertHtmlUtils.cleanup(result)) || + AsssertHtmlUtils.cleanup("").equals(AsssertHtmlUtils.cleanup(result)) ); } } diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/SelectFormElementRendererTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/SelectFormElementRendererTest.java index 8984aefa4..c698b8dcd 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/SelectFormElementRendererTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/SelectFormElementRendererTest.java @@ -28,10 +28,10 @@ import java.util.List; import java.util.Locale; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -@Property(name = "micronaut.views.form-element.render.views.select", value = "fieldset/select.html") +@Property(name = "micronaut.views.form-element.render.views.select", value = "fieldset/select") @MicronautTest(startApplication = false) @SuppressWarnings({ "java:S5960", // Assertions are fine, these are tests @@ -76,7 +76,7 @@ void renderOption() { .build() )) .build(); - assertEquals(""" + assertHtmlEquals(""" """, renderer.render(el, Locale.ENGLISH) diff --git a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/TrixEditorFormElementRendererTest.java b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/TrixEditorFormElementRendererTest.java index bee5fd681..7e67a800a 100644 --- a/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/TrixEditorFormElementRendererTest.java +++ b/views-fieldset-tck/src/main/java/io/micronaut/views/fields/tck/render/TrixEditorFormElementRendererTest.java @@ -26,10 +26,10 @@ import java.util.Locale; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.micronaut.views.fields.tck.AsssertHtmlUtils.assertHtmlEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -@Property(name = "micronaut.views.form-element.render.views.trix-editor", value = "fieldset/trixeditor.html") +@Property(name = "micronaut.views.form-element.render.views.trix-editor", value = "fieldset/trixeditor") @MicronautTest(startApplication = false) @SuppressWarnings({ "java:S5960", // Assertions are fine, these are tests @@ -49,7 +49,7 @@ void render() { .value("Editor content goes here") .label(Message.of("Tell us your story:", null)) .build(); - assertEquals(""" + assertHtmlEquals(""" \ """, renderer.render(el, Locale.ENGLISH) diff --git a/views-fieldset/build.gradle.kts b/views-fieldset/build.gradle.kts index 1a5373c37..5ae0bc5fa 100644 --- a/views-fieldset/build.gradle.kts +++ b/views-fieldset/build.gradle.kts @@ -6,6 +6,7 @@ dependencies { annotationProcessor(mnValidation.micronaut.validation.processor) implementation(mnValidation.micronaut.validation) compileOnly(mn.micronaut.http) + compileOnly(mnSecurity.micronaut.security.csrf) testAnnotationProcessor(mnValidation.micronaut.validation.processor) testImplementation(mnValidation.micronaut.validation) @@ -13,10 +14,9 @@ dependencies { testImplementation(mnData.micronaut.data.model) testImplementation(mn.micronaut.http) testAnnotationProcessor(mn.micronaut.inject.java) - testImplementation(libs.junit.jupiter.api) + testImplementation(mnTest.junit.jupiter.api) testImplementation(mnTest.micronaut.test.junit5) - testRuntimeOnly(libs.junit.jupiter.engine) - + testRuntimeOnly(mnTest.junit.jupiter.engine) testRuntimeOnly(mnLogging.logback.classic) } micronautBuild { diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/CsrfFieldsetConsumer.java b/views-fieldset/src/main/java/io/micronaut/views/fields/CsrfFieldsetConsumer.java new file mode 100644 index 000000000..1da6cc5fa --- /dev/null +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/CsrfFieldsetConsumer.java @@ -0,0 +1,60 @@ +/* + * 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.fields; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.context.ServerRequestContext; +import io.micronaut.security.csrf.CsrfConfiguration; +import io.micronaut.security.csrf.repository.CsrfTokenRepository; +import io.micronaut.views.fields.elements.InputHiddenFormElement; +import jakarta.inject.Singleton; +import java.util.List; + +/** + * If a CSRF Token can be resolved, it adds a hidden field to the list of fields with the name {@link CsrfConfiguration#getFieldName()}. and the value the CSRF Token. + * @since 5.6.0 + * @author Sergio del Amo + */ +@Requires(beans = { CsrfTokenRepository.class, CsrfTokenRepository.class }) +@Internal +@Singleton +final class CsrfFieldsetConsumer implements FieldsetConsumer { + private final CsrfTokenRepository> csrfTokenRepository; + private final CsrfConfiguration csrfConfiguration; + + CsrfFieldsetConsumer(CsrfTokenRepository> csrfTokenRepository, CsrfConfiguration csrfConfiguration) { + this.csrfTokenRepository = csrfTokenRepository; + this.csrfConfiguration = csrfConfiguration; + } + + @Override + public void accept(@NonNull List formElements) { + ServerRequestContext.currentRequest() + .flatMap(csrfTokenRepository::findCsrfToken) + .ifPresent(t -> formElements.add(csrfInputHidden(t))); + } + + @NonNull + private InputHiddenFormElement csrfInputHidden(@NonNull String csrfToken) { + return InputHiddenFormElement.builder() + .name(csrfConfiguration.getFieldName()) + .value(csrfToken) + .build(); + } +} diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/DefaultFieldGenerator.java b/views-fieldset/src/main/java/io/micronaut/views/fields/DefaultFieldGenerator.java index 8dfbd3006..b43316685 100644 --- a/views-fieldset/src/main/java/io/micronaut/views/fields/DefaultFieldGenerator.java +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/DefaultFieldGenerator.java @@ -33,6 +33,7 @@ import io.micronaut.views.fields.messages.ConstraintViolationUtils; import io.micronaut.views.fields.messages.Message; import jakarta.annotation.Nonnull; +import jakarta.inject.Inject; import jakarta.inject.Singleton; import jakarta.validation.ConstraintViolationException; import jakarta.validation.constraints.*; @@ -82,6 +83,7 @@ public class DefaultFieldGenerator implements FieldsetGenerator { private final ConcurrentHashMap, RadioFetcher> radioFetcherCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap, CheckboxFetcher> checkboxFetcherCache = new ConcurrentHashMap<>(); + private final List fieldsetConsumers; /** * @param enumOptionFetcher Enum fetcher for {@link Option}. @@ -89,58 +91,87 @@ public class DefaultFieldGenerator implements FieldsetGenerator { * @param enumCheckboxFetcher Enum fetcher for {@link Checkbox}. * @param beanContext Bean Context * @param formElementResolver Primary Form Element Resolver. {@link io.micronaut.views.fields.formelementresolvers.CompositeFormElementResolver}. + * @param fieldsetConsumers Fieldset Consumers */ + @Inject public DefaultFieldGenerator(EnumOptionFetcher enumOptionFetcher, EnumRadioFetcher enumRadioFetcher, EnumCheckboxFetcher enumCheckboxFetcher, BeanContext beanContext, - FormElementResolver formElementResolver) { + FormElementResolver formElementResolver, + List fieldsetConsumers) { this.enumOptionFetcher = enumOptionFetcher; this.enumRadioFetcher = enumRadioFetcher; this.enumCheckboxFetcher = enumCheckboxFetcher; this.beanContext = beanContext; this.formElementResolver = formElementResolver; + this.fieldsetConsumers = fieldsetConsumers; + } + + /** + * @param enumOptionFetcher Enum fetcher for {@link Option}. + * @param enumRadioFetcher Enum fetcher for {@link Radio}. + * @param enumCheckboxFetcher Enum fetcher for {@link Checkbox}. + * @param beanContext Bean Context + * @param formElementResolver Primary Form Element Resolver. {@link io.micronaut.views.fields.formelementresolvers.CompositeFormElementResolver}. + * @deprecated Use {@link DefaultFieldGenerator(EnumOptionFetcher, EnumRadioFetcher, EnumCheckboxFetcher, BeanContext, FormElementResolver, List)} instead. + */ + @Deprecated(forRemoval = true, since = "5.6.0") + public DefaultFieldGenerator(EnumOptionFetcher enumOptionFetcher, + EnumRadioFetcher enumRadioFetcher, + EnumCheckboxFetcher enumCheckboxFetcher, + BeanContext beanContext, + FormElementResolver formElementResolver) { + this(enumOptionFetcher, + enumRadioFetcher, + enumCheckboxFetcher, + beanContext, + formElementResolver, + Collections.emptyList()); } @Override @NonNull public Fieldset generate(@NonNull Class type) { BeanIntrospection introspection = BeanIntrospection.getIntrospection(type); - return new Fieldset(formElements(introspection.getBeanProperties(), null), Collections.emptyList()); + return instantiateFieldset(formElements(introspection.getBeanProperties(), null), null); } @Override public Fieldset generate(@NonNull Class type, @NonNull BiConsumer> builderConsumer) { BeanIntrospection introspection = BeanIntrospection.getIntrospection(type); - return new Fieldset(formElements(introspection.getBeanProperties(), builderConsumer), Collections.emptyList()); + return instantiateFieldset(formElements(introspection.getBeanProperties(), builderConsumer), null); } @Override public Fieldset generate(@NonNull Object instance) { - return new Fieldset(generateOfBeanWrapper(BeanWrapper.getWrapper(instance), null, null), Collections.emptyList()); + return instantiateFieldset(generateOfBeanWrapper(BeanWrapper.getWrapper(instance), null, null), null); } @Override @NonNull public Fieldset generate(@NonNull Object instance, @NonNull BiConsumer> builderConsumer) { - return new Fieldset(generateOfBeanWrapper(BeanWrapper.getWrapper(instance), null, builderConsumer), Collections.emptyList()); + return instantiateFieldset(generateOfBeanWrapper(BeanWrapper.getWrapper(instance), null, builderConsumer), null); } @Override public Fieldset generate(@NonNull Object instance, @NonNull ConstraintViolationException ex) { - return generate(generateOfBeanWrapper(BeanWrapper.getWrapper(instance), ex, null), ex); + return instantiateFieldset(generateOfBeanWrapper(BeanWrapper.getWrapper(instance), ex, null), ex); } @Override public Fieldset generate(Object instance, ConstraintViolationException ex, BiConsumer> builderConsumer) { - return generate(generateOfBeanWrapper(BeanWrapper.getWrapper(instance), ex, builderConsumer), ex); + return instantiateFieldset(generateOfBeanWrapper(BeanWrapper.getWrapper(instance), ex, builderConsumer), ex); } - @NonNull - private Fieldset generate(@NonNull List fields, @NonNull ConstraintViolationException ex) { - return new Fieldset(fields, ex.getConstraintViolations() + private Fieldset instantiateFieldset(@NonNull List fields, @Nullable ConstraintViolationException ex) { + List modifyableList = new ArrayList<>(fields); + for (FieldsetConsumer fieldsetConsumer : fieldsetConsumers) { + fieldsetConsumer.accept(modifyableList); + } + return new Fieldset(modifyableList, ex == null ? Collections.emptyList() : ex.getConstraintViolations() .stream() .filter(constraintViolationEx -> ConstraintViolationUtils.lastNode(constraintViolationEx).isEmpty()) .map(Message::of) diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/FieldsetConsumer.java b/views-fieldset/src/main/java/io/micronaut/views/fields/FieldsetConsumer.java new file mode 100644 index 000000000..a6598b194 --- /dev/null +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/FieldsetConsumer.java @@ -0,0 +1,28 @@ +/* + * 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.fields; + +import java.util.List; +import java.util.function.Consumer; + +/** + * A consumer of a list of {@link FormElement} fields. Allows you to modify the list of fields being generated. + * @author Sergio del Amo + * @since 5.6.0 + */ +@FunctionalInterface +public interface FieldsetConsumer extends Consumer> { +} diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/Form.java b/views-fieldset/src/main/java/io/micronaut/views/fields/Form.java index d2fa90377..099d25c58 100644 --- a/views-fieldset/src/main/java/io/micronaut/views/fields/Form.java +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/Form.java @@ -24,12 +24,19 @@ /** * Representation of an HTML form. + *

+ * When dataturbo is set to false, the form includes the data-turbo="false" attribute, which disables + * Turbo Drive on links and forms including descendants. + * See the Turbo Data Attributes documentation + * for more information. + * * @author Sergio del Amo * @since 4.1.0 * @param action Form Action * @param method Form Method. either `get` or `post` * @param fieldset Form fields * @param enctype how the form-data should be encoded when submitting it to the server + * @param dataturbo enables Turbo Drive on a form, defaults to not disabled */ @Experimental @EnctypePostRequired @@ -37,10 +44,39 @@ public record Form(@NonNull @NotBlank String action, @NonNull @NotBlank @Pattern(regexp = "get|post") String method, @NonNull @NotNull @Valid Fieldset fieldset, - @Nullable @Pattern(regexp = "application/x-www-form-urlencoded|multipart/form-data|text/plain") String enctype) { + @Nullable @Pattern(regexp = "application/x-www-form-urlencoded|multipart/form-data|text/plain") String enctype, + @Nullable Boolean dataturbo) { private static final String POST = "post"; + /** + * + * @param action Form Action + * @param method Form Method. either `get` or `post` + * @param fieldset Form fields + * @param dataturbo Form data-turbo + */ + public Form(@NonNull String action, + @NonNull String method, + @NonNull Fieldset fieldset, + @Nullable Boolean dataturbo) { + this(action, method, fieldset, null, dataturbo); + } + + /** + * + * @param action Form Action + * @param method Form Method. either `get` or `post` + * @param fieldset Form fields + * @param enctype how the form-data should be encoded when submitting it to the server + */ + public Form(@NonNull String action, + @NonNull String method, + @NonNull Fieldset fieldset, + @Nullable String enctype) { + this(action, method, fieldset, enctype, null); + } + /** * * @param action Form Action @@ -50,7 +86,7 @@ public record Form(@NonNull @NotBlank String action, public Form(@NonNull String action, @NonNull String method, @NonNull Fieldset fieldset) { - this(action, method, fieldset, null); + this(action, method, fieldset, null, null); } /** @@ -60,7 +96,7 @@ public Form(@NonNull String action, */ public Form(@NonNull String action, @NonNull Fieldset fieldset) { - this(action, POST, fieldset, null); + this(action, POST, fieldset, null, null); } /** @@ -72,6 +108,6 @@ public Form(@NonNull String action, public Form(@NonNull String action, @NonNull Fieldset fieldset, @Nullable String enctype) { - this(action, POST, fieldset, enctype); + this(action, POST, fieldset, enctype, null); } } diff --git a/views-fieldset/src/main/java/io/micronaut/views/fields/elements/InputStringFormElement.java b/views-fieldset/src/main/java/io/micronaut/views/fields/elements/InputStringFormElement.java index 77b6406c3..bbb3d8025 100644 --- a/views-fieldset/src/main/java/io/micronaut/views/fields/elements/InputStringFormElement.java +++ b/views-fieldset/src/main/java/io/micronaut/views/fields/elements/InputStringFormElement.java @@ -16,11 +16,7 @@ package io.micronaut.views.fields.elements; import io.micronaut.core.annotation.Experimental; -import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; -import io.micronaut.views.fields.messages.Message; - -import java.util.List; /** * API for html input of type string. e.g. input type text, password, url, tel. @@ -28,15 +24,7 @@ * @since 4.1.0 */ @Experimental -public interface InputStringFormElement { - - /** - * - * @return Name of the form control. Submitted with the form as part of a name/value pair - */ - @NonNull - String name(); - +public interface InputStringFormElement extends FormElementAttributes { /** * * @return It defines an identifier (ID) which must be unique in the whole document @@ -97,16 +85,4 @@ public interface InputStringFormElement { */ @Nullable String value(); - - /** - * - * @return message for an HTML Label element. - */ - @Nullable Message label(); - - /** - * - * @return Input Errors - */ - @NonNull List errors(); } diff --git a/views-fieldset/src/test/java/io/micronaut/views/fields/FormTest.java b/views-fieldset/src/test/java/io/micronaut/views/fields/FormTest.java index 75a4ad1ab..d569da1e7 100644 --- a/views-fieldset/src/test/java/io/micronaut/views/fields/FormTest.java +++ b/views-fieldset/src/test/java/io/micronaut/views/fields/FormTest.java @@ -28,11 +28,11 @@ void formValidation(Validator validator) { assertFalse(validator.validate(new Form(null, "post", fieldset, "application/x-www-form-urlencoded")).isEmpty()); // method cannot be an empty string - assertFalse(validator.validate(new Form("/foo/bar", "", fieldset, null)).isEmpty()); + assertFalse(validator.validate(new Form("/foo/bar", "", fieldset, null, null)).isEmpty()); // method cannot be null - assertFalse(validator.validate(new Form("/foo/bar", null, fieldset, null)).isEmpty()); + assertFalse(validator.validate(new Form("/foo/bar", null, fieldset, null, null)).isEmpty()); // method can only be get or post - assertFalse(validator.validate(new Form("/foo/bar", "put", fieldset, null)).isEmpty()); + assertFalse(validator.validate(new Form("/foo/bar", "put", fieldset, null, null)).isEmpty()); //method cannot be get if enctype Set> violations = validator.validate(new Form("/foo/bar", "get", fieldset, "application/x-www-form-urlencoded")); @@ -50,11 +50,16 @@ void formValidation(Validator validator) { assertFalse(validator.validate(new Form("/foo/bar", "post", fieldset, "text/html")).isEmpty()); // enctype can be null - assertTrue(validator.validate(new Form("/foo/bar", "post", fieldset, null)).isEmpty()); + assertTrue(validator.validate(new Form("/foo/bar", "post", fieldset, null, null)).isEmpty()); assertTrue(validator.validate(new Form("/foo/bar", "post", fieldset, "application/x-www-form-urlencoded")).isEmpty()); assertTrue(validator.validate(new Form("/foo/bar", "post", fieldset, "multipart/form-data")).isEmpty()); assertTrue(validator.validate(new Form("/foo/bar", "post", fieldset, "text/plain")).isEmpty()); - assertTrue(validator.validate(new Form("/foo/bar", "get", fieldset, null)).isEmpty()); + assertTrue(validator.validate(new Form("/foo/bar", "get", fieldset, null, null)).isEmpty()); + + // dataturbo can be null, true or false + assertTrue(validator.validate(new Form("/foo/bar", "post", fieldset, null, null)).isEmpty()); + assertTrue(validator.validate(new Form("/foo/bar", "post", fieldset, null, true)).isEmpty()); + assertTrue(validator.validate(new Form("/foo/bar", "post", fieldset, null, false)).isEmpty()); } } diff --git a/views-htmx/build.gradle.kts b/views-htmx/build.gradle.kts index e870936e4..77d3356b4 100644 --- a/views-htmx/build.gradle.kts +++ b/views-htmx/build.gradle.kts @@ -5,9 +5,9 @@ dependencies { api(projects.micronautViewsCore) compileOnly(mn.micronaut.http) testAnnotationProcessor(mn.micronaut.inject.java) - testImplementation(libs.junit.jupiter.api) + testImplementation(mnTest.junit.jupiter.api) testImplementation(mnTest.micronaut.test.junit5) - testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(mnTest.junit.jupiter.engine) testRuntimeOnly(mnLogging.logback.classic) testAnnotationProcessor(mnSerde.micronaut.serde.processor) testImplementation(mnSerde.micronaut.serde.jackson) diff --git a/views-htmx/src/main/java/io/micronaut/views/htmx/http/HtmxResponseRawMessageBodyHandler.java b/views-htmx/src/main/java/io/micronaut/views/htmx/http/HtmxResponseRawMessageBodyHandler.java index d1070c4bb..b5f10a3a5 100644 --- a/views-htmx/src/main/java/io/micronaut/views/htmx/http/HtmxResponseRawMessageBodyHandler.java +++ b/views-htmx/src/main/java/io/micronaut/views/htmx/http/HtmxResponseRawMessageBodyHandler.java @@ -17,40 +17,33 @@ import io.micronaut.core.annotation.Internal; import io.micronaut.core.annotation.NonNull; -import io.micronaut.core.annotation.Nullable; -import io.micronaut.core.io.buffer.ByteBuffer; import io.micronaut.core.type.Argument; -import io.micronaut.core.type.Headers; import io.micronaut.core.type.MutableHeaders; import io.micronaut.http.HttpHeaders; import io.micronaut.http.HttpRequest; import io.micronaut.http.MediaType; -import io.micronaut.http.body.RawMessageBodyHandler; +import io.micronaut.http.body.MessageBodyWriter; import io.micronaut.http.codec.CodecException; import io.micronaut.http.context.ServerRequestContext; import io.micronaut.views.ModelAndView; import io.micronaut.views.ModelAndViewRenderer; import io.micronaut.views.exceptions.ViewRenderingException; import jakarta.inject.Singleton; -import org.reactivestreams.Publisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; -import java.io.InputStream; import java.io.OutputStream; -import java.util.Collection; -import java.util.Collections; /** - * {@link RawMessageBodyHandler} implementation for {@link HtmxResponse}. + * {@link io.micronaut.http.body.MessageBodyHandler} implementation for {@link HtmxResponse}. * @author Sergio del Amo * @since 5.2.0 * @param The model type */ @Internal @Singleton -final class HtmxResponseRawMessageBodyHandler implements RawMessageBodyHandler> { +final class HtmxResponseRawMessageBodyHandler implements MessageBodyWriter> { private static final Logger LOG = LoggerFactory.getLogger(HtmxResponseRawMessageBodyHandler.class); private final ModelAndViewRenderer> modelAndViewRenderer; @@ -59,21 +52,6 @@ public HtmxResponseRawMessageBodyHandler(ModelAndViewRenderer> this.modelAndViewRenderer = modelAndViewRenderer; } - @Override - public @NonNull Collection> getTypes() { - return Collections.singletonList(HtmxResponse.class); - } - - @Override - public @NonNull Publisher> readChunked(@NonNull Argument> type, @Nullable MediaType mediaType, @NonNull Headers httpHeaders, @NonNull Publisher> input) { - throw new UnsupportedOperationException("Not supported"); - } - - @Override - public @Nullable HtmxResponse read(@NonNull Argument> type, @Nullable MediaType mediaType, @NonNull Headers httpHeaders, @NonNull InputStream inputStream) throws CodecException { - throw new UnsupportedOperationException("Not supported"); - } - @Override public void writeTo(@NonNull Argument> type, @NonNull MediaType mediaType, HtmxResponse object, @NonNull MutableHeaders outgoingHeaders, @NonNull OutputStream outputStream) throws CodecException { HttpRequest httpRequest = ServerRequestContext.currentRequest().orElse(null); diff --git a/views-jte/build.gradle b/views-jte/build.gradle index b2c977d51..cf39fcc83 100644 --- a/views-jte/build.gradle +++ b/views-jte/build.gradle @@ -9,7 +9,7 @@ dependencies { api(libs.managed.jte) api projects.micronautViewsCore - implementation(libs.managed.jte.kotlin) + testImplementation(libs.managed.jte.kotlin) testImplementation(mnSerde.micronaut.serde.jackson) testImplementation(mn.reactor) testImplementation(mn.micronaut.http.client) diff --git a/views-jte/src/main/java/io/micronaut/views/jte/HtmlJteViewsRenderer.java b/views-jte/src/main/java/io/micronaut/views/jte/HtmlJteViewsRenderer.java index bbf3d849c..a7057371e 100644 --- a/views-jte/src/main/java/io/micronaut/views/jte/HtmlJteViewsRenderer.java +++ b/views-jte/src/main/java/io/micronaut/views/jte/HtmlJteViewsRenderer.java @@ -16,6 +16,7 @@ package io.micronaut.views.jte; import gg.jte.ContentType; +import io.micronaut.context.annotation.Primary; import io.micronaut.context.annotation.Requires; import io.micronaut.http.HttpRequest; import io.micronaut.http.MediaType; @@ -34,6 +35,7 @@ * @since 3.1.0 */ @Produces(value = {MediaType.TEXT_HTML, TurboMediaType.TURBO_STREAM}) +@Primary @Singleton @Requires(classes = HttpRequest.class) public class HtmlJteViewsRenderer extends JteViewsRenderer> { diff --git a/views-react/.gitignore b/views-react/.gitignore new file mode 100644 index 000000000..f718f05ac --- /dev/null +++ b/views-react/.gitignore @@ -0,0 +1,5 @@ +src/test/js/node_modules +src/test/resources/views/ssr-components.mjs +src/test/resources/views/ssr-components.preact.mjs +src/test/resources/views/static/ +/src/test/js/package-lock.json diff --git a/views-react/README.md b/views-react/README.md new file mode 100644 index 000000000..9620518c1 --- /dev/null +++ b/views-react/README.md @@ -0,0 +1,17 @@ +# React SSR support for Micronaut + +## TODO + +1. Eliminate all TODOs from the docs. +2. Make HTTP prefetches run in parallel. +3. Reduce the need for config: + 1. Work out what `micronaut.views.folder` is supposed to be when run from Maven. Get rid of the need to specify this. + 2. Make it configurable and allow the path to the static assets to be configured so it doesn't have to be served from MN itself. + 3. Get rid of the blocking of the event loop when prefetching. Pending answer from MN team about why IO pool switch isn't implemented. +4. Write unit tests. +5. Document what you can and cannot do in GraalJS. +6. Find a way to use `renderToPipeableStream`? +7. Replace `__micronaut_prefetch` with Sam's implementation of fetch() for Micronaut? +8. Document how to do debugging? +9. Implement / get implemented TextEncoder/TextDecoder +10. Update the micronaut-spa-app sample. diff --git a/views-react/build.gradle b/views-react/build.gradle new file mode 100644 index 000000000..242a227e5 --- /dev/null +++ b/views-react/build.gradle @@ -0,0 +1,97 @@ +plugins { + id "io.micronaut.build.internal.views-module" + id "com.github.node-gradle.node" version "7.1.0" +} + +repositories { + mavenCentral() +} + +dependencies { + annotationProcessor(mnValidation.micronaut.validation.processor) + + api projects.micronautViewsCore + implementation(mn.micronaut.http) + + // The user of this library is expected to supply the JS dependency. That's because they may choose between + // the community edition (open source, slower) and the enterprise edition (liberal usage, not open source, faster). + implementation(libs.graal.polyglot) + + compileOnly(mnValidation.micronaut.validation) { + because("For @NotBlank on config properties") + } + + testCompileOnly(mn.micronaut.inject.groovy) + testAnnotationProcessor(mnValidation.micronaut.validation.processor) + testAnnotationProcessor(mn.micronaut.inject.java) + + testImplementation(mnSerde.micronaut.serde.jackson) + testImplementation(mn.micronaut.http.client) + testImplementation(mn.micronaut.http.server.netty) + testImplementation(mn.micronaut.management) + testImplementation(mnValidation.micronaut.validation) + testImplementation(mn.snakeyaml) + testImplementation(mn.groovy.json) + + // We will use enterprise edition for testing. + testImplementation(libs.graal.js) +} + +// Set up a simple SSR project for testing purposes. We will need nodejs to run the bundlers. +node { + download = true + version = "21.7.1" + nodeProjectDir = file("${project.projectDir}/src/test/js") +} + +task buildTestClientJS(type: NpxTask) { + dependsOn npmInstall + command = 'webpack' + args = ["--mode", "development", "--config", "webpack.client.js"] + inputs.dir(fileTree("src/test/js/").exclude(".cache")) + outputs.file('src/test/resources/views/static/client.js') +} + +task buildTestClientJSPreact(type: NpxTask) { + dependsOn npmInstall + command = 'webpack' + args = ["--mode", "development", "--config", "webpack.client.preact.js"] + inputs.dir(fileTree("src/test/js/").exclude(".cache")) + outputs.file('src/test/resources/views/static/client-preact.js') +} + +task buildTestServerJS(type: NpxTask) { + dependsOn npmInstall + command = 'webpack' + args = ["--mode", "development", "--config", "webpack.server.js"] + inputs.dir(fileTree("src/test/js/").exclude(".cache")) + outputs.file('src/test/resources/views/ssr-components.mjs') +} + +task buildTestServerJSPreact(type: NpxTask) { + dependsOn npmInstall + command = 'webpack' + args = ["--mode", "development", "--config", "webpack.server.preact.js"] + inputs.dir(fileTree("src/test/js/").exclude(".cache")) + outputs.file('src/test/resources/views/ssr-components.preact.mjs') +} + +def buildTestJS = tasks.register("buildTestJS") { + it.dependsOn("buildTestClientJS", "buildTestServerJS", "buildTestClientJSPreact", "buildTestServerJSPreact") +} + +tasks.named("processTestResources") { + it.dependsOn(buildTestJS) +} + +tasks.named("test") { + // This module depends on GraalVM 21 or higher due to the need for the esm-exports GraalJS option and inability + // to upgrade it on older JDKs that bundled languages together. + onlyIf { + JavaVersion.current() >= JavaVersion.VERSION_21 + } +} + +micronautBuild { + binaryCompatibility.enabled.set(false) +} diff --git a/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java b/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java new file mode 100644 index 000000000..54bb299c3 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/CompiledJS.java @@ -0,0 +1,71 @@ +/* + * Copyright 2017-2020 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.core.annotation.Internal; +import jakarta.annotation.PreDestroy; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.graalvm.polyglot.Engine; +import org.graalvm.polyglot.Source; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; + +import java.io.IOException; + +/** + * Holds the thread-safe {@link Engine} and {@link Source} which together pin compiled machine code + * into the JVM code cache. + */ +@Singleton +@Internal +class CompiledJS implements AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger("js"); + + final Engine engine; + private Source source; + private final JSBundlePaths jsBundlePaths; + + @Inject + CompiledJS(JSBundlePaths jsBundlePaths, JSEngineLogHandler engineLogHandler, JSSandboxing sandboxing) { + var engineBuilder = Engine.newBuilder("js") + .out(new OutputStreamToSLF4J(LOG, Level.INFO)) + .err(new OutputStreamToSLF4J(LOG, Level.ERROR)) + .logHandler(engineLogHandler); + engine = sandboxing.configure(engineBuilder).build(); + this.jsBundlePaths = jsBundlePaths; + reload(); + } + + synchronized Source getSource() { + return source; + } + + synchronized void reload() { + try { + source = jsBundlePaths.readServerBundle(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + @PreDestroy + public void close() throws Exception { + engine.close(); + } +} 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..972930924 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/JSBeanFactory.java @@ -0,0 +1,43 @@ +/* + * 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 jakarta.inject.Singleton;; +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. + */ + @Singleton + HostAccess hostAccess(ReactViewsRendererConfiguration configuration) { + return configuration.getSandbox() + ? HostAccess.CONSTRAINED + : HostAccess.ALL; + } +} diff --git a/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java b/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java new file mode 100644 index 000000000..6e4f73f86 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/JSBundlePaths.java @@ -0,0 +1,78 @@ +/* + * Copyright 2017-2020 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.core.annotation.Internal; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.io.ResourceResolver; +import io.micronaut.views.ViewsConfiguration; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.graalvm.polyglot.Source; + +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.file.Path; +import java.util.Optional; + +import static java.lang.String.format; + +/** + * Wraps the computation of where to find the JS for client and server. + */ +@Singleton +@Internal +class JSBundlePaths { + // Source code file name, for JS stack traces. + final String bundleFileName; + + // URL of bundle file, could be a file:// or in a classpath jar. + final URL bundleURL; + + // If a file:// (during development), the path of that file. Used for hot reloads. + @Nullable + final Path bundlePath; + + @Inject + JSBundlePaths( + ViewsConfiguration viewsConfiguration, + ReactViewsRendererConfiguration reactConfiguration, + ResourceResolver resolver + ) throws IOException { + Optional bundlePathOpt = resolver.getResource(reactConfiguration.getServerBundlePath()); + if (bundlePathOpt.isEmpty()) { + throw new FileNotFoundException(format("Server bundle %s could not be found. Check your %s property.", reactConfiguration.getServerBundlePath(), ReactViewsRendererConfiguration.PREFIX + ".server-bundle-path")); + } + bundleURL = bundlePathOpt.get(); + bundleFileName = bundleURL.getFile(); + if (bundleURL.getProtocol().equals("file")) { + bundlePath = Path.of(bundleURL.getPath()); + } else { + bundlePath = null; + } + } + + Source readServerBundle() throws IOException { + try (var reader = new BufferedReader(new InputStreamReader(bundleURL.openStream()))) { + return Source.newBuilder("js", reader, bundleFileName) + .mimeType("application/javascript+module") + .build(); + } + } +} diff --git a/views-react/src/main/java/io/micronaut/views/react/JSContext.java b/views-react/src/main/java/io/micronaut/views/react/JSContext.java new file mode 100644 index 000000000..de87c7510 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/JSContext.java @@ -0,0 +1,159 @@ +/* + * Copyright 2017-2020 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.Parameter; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.inject.Inject; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Source; +import org.graalvm.polyglot.Value; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * A bean that handles the Javascript {@link Context} object representing a loaded execution + * environment usable by one thread at a time. + */ +@Internal +class JSContext implements AutoCloseable { + // Symbols the user's server side bundle might supply us with. + private static final List IMPORT_SYMBOLS = List.of("React", "ReactDOMServer", "renderToString", "h"); + + // Accessed from ReactViewsRenderer + Context polyglotContext; + Value render; + Value ssrModule; + + // What version of the on-disk bundle (considering file change events) we were loaded from. + final int versionCounter; + + private final CompiledJS compiledJS; + private final ReactViewsRendererConfiguration configuration; + private final JSSandboxing sandboxing; + + @Inject + JSContext(CompiledJS compiledJS, ReactViewsRendererConfiguration configuration, JSSandboxing sandboxing, @Parameter int versionCounter) { + this.compiledJS = compiledJS; + this.configuration = configuration; + this.sandboxing = sandboxing; + this.versionCounter = versionCounter; + } + + @PostConstruct + void init() throws IOException { + polyglotContext = createContext(); + + Value global = polyglotContext.getBindings("js"); + ssrModule = polyglotContext.eval(compiledJS.getSource()); + + // Take all the exports from the components bundle, and expose them to the render script. + for (var name : ssrModule.getMemberKeys()) { + global.putMember(name, ssrModule.getMember(name)); + } + + // Evaluate our JS-side framework specific render logic. + Source source = loadRenderSource(); + Value renderModule = polyglotContext.eval(source); + render = renderModule.getMember("ssr"); + if (render == null) { + throw new IllegalArgumentException("Unable to look up ssr function in render script `%s`. Please make sure it is exported.".formatted(configuration.getRenderScript())); + } + } + + private Source loadRenderSource() throws IOException { + String renderScriptName = configuration.getRenderScript(); + String fileName; + String source; + + if (renderScriptName.startsWith("classpath:")) { + var resourcePath = renderScriptName.substring("classpath:".length()); + // Even on Windows, classpath specs use / + fileName = fileNameFromUNIXPath(resourcePath); + try (var stream = getClass().getResourceAsStream(resourcePath)) { + if (stream == null) { + throw new IllegalArgumentException("Render script not found on classpath: " + resourcePath); + } + source = new String(stream.readAllBytes(), UTF_8); + } + } else if (renderScriptName.startsWith("file:")) { + var path = Path.of(renderScriptName.substring("file:".length())); + if (!Files.exists(path)) { + throw new IllegalArgumentException("Render script not found: " + renderScriptName); + } + fileName = path.normalize().toAbsolutePath().getFileName().toString(); + try (var stream = Files.newInputStream(path)) { + source = new String(stream.readAllBytes(), UTF_8); + } + } else { + throw new IllegalArgumentException("The renderScript name '%s' must begin with either `classpath:` or `file:`".formatted(renderScriptName)); + } + + return Source.newBuilder("js", source, fileName) + .mimeType("application/javascript+module") + .build(); + } + + private static @NonNull String fileNameFromUNIXPath(String resourcePath) { + String fileName; + var i = resourcePath.lastIndexOf('/'); + fileName = resourcePath.substring(i + 1); + return fileName; + } + + private Context createContext() { + var contextBuilder = Context.newBuilder() + .engine(compiledJS.engine) + .option("js.esm-eval-returns-exports", "true") + .option("js.unhandled-rejections", "throw"); + try { + return sandboxing.configure(contextBuilder).build(); + } catch (ExceptionInInitializerError e) { + // The catch handler is to work around a bug in Polyglot 24.0.0 + if (e.getCause().getMessage().contains("version compatibility check failed")) { + throw new IllegalStateException("GraalJS version mismatch or it's missing. Please ensure you have added either org.graalvm.polyglot:js or org.graalvm.polyglot:js-community to your dependencies alongside Micronaut Views React, as it's up to you to select the best engine given your licensing constraints. See the user guide for more detail."); + } else { + throw e; + } + } catch (IllegalArgumentException e) { + // We need esm-eval-returns-exports=true but it's not compatible with the sandbox in this version of GraalJS. + if (e.getMessage().contains("Option 'js.esm-eval-returns-exports' is experimental")) { + throw new IllegalStateException("The sandboxing feature requires a newer version of GraalJS. Please upgrade and try again, or disable the sandboxing feature."); + } else { + throw e; + } + } + } + + boolean moduleHasMember(String memberName) { + assert !IMPORT_SYMBOLS.contains(memberName) : "Should not query the server-side bundle for member name " + memberName; + return ssrModule.hasMember(memberName); + } + + @PreDestroy + @Override + public synchronized void close() { + polyglotContext.close(); + } +} diff --git a/views-react/src/main/java/io/micronaut/views/react/JSContextPool.java b/views-react/src/main/java/io/micronaut/views/react/JSContextPool.java new file mode 100644 index 000000000..035a79880 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/JSContextPool.java @@ -0,0 +1,90 @@ +/* + * Copyright 2017-2020 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.ApplicationContext; +import io.micronaut.context.event.ApplicationEventListener; +import io.micronaut.core.annotation.Internal; +import io.micronaut.scheduling.io.watch.event.FileChangedEvent; +import io.micronaut.scheduling.io.watch.event.WatchEventType; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.ref.SoftReference; +import java.util.LinkedList; + +/** + * Vends contexts to threads that need them. We don't use ThreadLocals here because what matters + * is contention. If there are 30 server threads, but only a few requests ever use React, then + * we don't want to have 30 contexts in memory at all times because they are quite chunky objects. + * By only creating more when we are genuinely under load, we avoid bloat. This also fits better + * with virtual threads, where a thread may not live beyond the lifetime of a single request. + */ +@Singleton +@Internal +class JSContextPool implements ApplicationEventListener { + private static final Logger LOG = LoggerFactory.getLogger(JSContextPool.class); + private final ApplicationContext applicationContext; + private final JSBundlePaths paths; + + // Synchronized on 'this'. + private final LinkedList> contexts = new LinkedList<>(); + private int versionCounter = 0; // File reloads. + + @Inject + JSContextPool(ApplicationContext applicationContext, JSBundlePaths paths) { + this.applicationContext = applicationContext; + this.paths = paths; + } + + /** + * Returns a cached context or creates a new one. You must give the JSContext to + * {@link #release(JSContext)} when you're done with it to put it (back) into the pool. + */ + synchronized JSContext acquire() { + while (!contexts.isEmpty()) { + SoftReference ref = contexts.poll(); + assert ref != null; + + var context = ref.get(); + // context may have been garbage collected (== null), or it might be for an old + // version of the on-disk bundle. In both cases we just let it drift away as we + // now hold the only reference. + if (context != null && context.versionCounter == versionCounter) { + return context; + } + } + + // No more pooled contexts available, create one and return it. It'll be added [back] to the + // pool when release() is called. + return applicationContext.createBean(JSContext.class, versionCounter); + } + + synchronized void release(JSContext jsContext) { + // Put it back into the pool for reuse. + contexts.add(new SoftReference<>(jsContext)); + } + + @Override + public synchronized void onApplicationEvent(FileChangedEvent event) { + if (paths.bundlePath != null && event.getPath().equals(paths.bundlePath) && event.getEventType() != WatchEventType.DELETE) { + LOG.info("Reloading Javascript bundle due to file change."); + versionCounter++; + } + } +} diff --git a/views-react/src/main/java/io/micronaut/views/react/JSEngineLogHandler.java b/views-react/src/main/java/io/micronaut/views/react/JSEngineLogHandler.java new file mode 100644 index 000000000..ecba254c0 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/JSEngineLogHandler.java @@ -0,0 +1,57 @@ +/* + * Copyright 2017-2020 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.core.annotation.Internal; +import jakarta.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.logging.Handler; +import java.util.logging.LogRecord; + +/** + * Forwards/redirects log messages from the GraalJS / Truffle engines themselves to SLF4J. + * Note that Javascript's {@code console.log()} is handled differently. + */ +@Singleton +@Internal +class JSEngineLogHandler extends Handler { + private static final Logger LOG = LoggerFactory.getLogger(ReactViewsRenderer.class); + + @Override + public void publish(LogRecord record) { + String message = record.getMessage(); + Throwable thrown = record.getThrown(); + String level = record.getLevel().getName(); + switch (level) { + case "SEVERE" -> LOG.error(message, thrown); + case "WARNING" -> LOG.warn(message, thrown); + case "INFO" -> LOG.info(message, thrown); + case "CONFIG", "FINE" -> LOG.debug(message, thrown); + case "FINER", "FINEST" -> LOG.trace(message, thrown); + default -> throw new IllegalStateException("Unexpected value: " + level); + } + } + + @Override + public void flush() { + } + + @Override + public void close() throws SecurityException { + } +} 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 new file mode 100644 index 000000000..3610735e8 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/JSSandboxing.java @@ -0,0 +1,59 @@ +/* + * Copyright 2017-2020 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.core.annotation.Internal; +import jakarta.inject.Singleton; +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Engine; +import org.graalvm.polyglot.HostAccess; +import org.graalvm.polyglot.SandboxPolicy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Some internal wrappers useful for centralizing sandbox configuration. + */ +@Singleton +@Internal +class JSSandboxing { + private static final Logger LOG = LoggerFactory.getLogger(JSSandboxing.class); + private final boolean sandbox; + private final HostAccess hostAccess; + + JSSandboxing(ReactViewsRendererConfiguration configuration, HostAccess hostAccess) { + sandbox = configuration.getSandbox(); + if (LOG.isDebugEnabled()) { + LOG.debug("ReactJS sandboxing {}", sandbox ? "enabled" : "disabled"); + } + this.hostAccess = hostAccess; + } + + Engine.Builder configure(Engine.Builder engineBuilder) { + return engineBuilder.sandbox(sandbox ? SandboxPolicy.CONSTRAINED : SandboxPolicy.TRUSTED); + } + + Context.Builder configure(Context.Builder builder) { + if (sandbox) { + return builder.sandbox(SandboxPolicy.CONSTRAINED).allowHostAccess(hostAccess); + } else { + // 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/main/java/io/micronaut/views/react/OutputStreamToSLF4J.java b/views-react/src/main/java/io/micronaut/views/react/OutputStreamToSLF4J.java new file mode 100644 index 000000000..077657f75 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/OutputStreamToSLF4J.java @@ -0,0 +1,101 @@ +/* + * Copyright 2017-2020 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.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import org.slf4j.Logger; +import org.slf4j.event.Level; +import org.slf4j.spi.LoggingEventBuilder; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; + +/** + * An output stream that looks for line separators and then writes out the lines of text to the given logger. + */ +@Internal +final class OutputStreamToSLF4J extends OutputStream { + private final Charset charset; + + private ByteBuffer buffer = ByteBuffer.allocate(512); + + private final LoggingEventBuilder loggingEventBuilder; + + /** + * Creates a logging stream with the JVM's default character set. + */ + public OutputStreamToSLF4J(LoggingEventBuilder loggingEventBuilder) { + this(loggingEventBuilder, Charset.defaultCharset()); + } + + /** + * Creates a logging stream with the given character set. + */ + public OutputStreamToSLF4J(LoggingEventBuilder loggingEventBuilder, Charset charset) { + this.loggingEventBuilder = loggingEventBuilder; + this.charset = charset; + } + + /** + * Creates a logging stream for the given logger and logging level. + */ + public OutputStreamToSLF4J(Logger logger, Level level) { + this(logger.makeLoggingEventBuilder(level)); + } + + @Override + public void write(int b) throws IOException { + maybeResizeBuffer(1); + buffer.put((byte) b); + } + + @Override + public void write(@NonNull byte[] b) throws IOException { + maybeResizeBuffer(b.length); + buffer.put(b); + } + + private void maybeResizeBuffer(int forAdditional) { + if (buffer.remaining() >= forAdditional) { + return; + } + + // Otherwise increase the buffer size by 1kb each time until it's big enough. + var targetSize = buffer.position() + forAdditional; + var newSize = buffer.capacity(); + assert newSize < targetSize; + while (newSize < targetSize) { + newSize += 1024; + } + + var old = buffer; + buffer = ByteBuffer.allocate(newSize); + buffer.put(old.flip()); + } + + @Override + public void flush() throws IOException { + buffer.flip(); + var lines = charset.decode(buffer).toString().split("\n"); + for (String line : lines) { + loggingEventBuilder.log(line); + } + buffer.clear(); + } +} 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 new file mode 100644 index 000000000..1d9d3f6c7 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/ProxyObjectWithIntrospectableSupport.java @@ -0,0 +1,170 @@ +/* + * Copyright 2017-2020 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.core.annotation.Internal; +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.HashMap; +import java.util.Map; + +/** + * A proxy object similar to that returned by {@link ProxyObject#fromMap(Map)}, but with support + * for Micronaut's bean introspection system (a form of compile-time reflection code generation). + * Reading a key whose value is an introspectable bean will use the {@link BeanMap} instead of + * the regular polyglot mapping. + */ +@Internal +final class ProxyObjectWithIntrospectableSupport implements ProxyObject { + private final Context context; + private final Object target; + + @Nullable + private final BeanIntrospection introspection; + + private ProxyObjectWithIntrospectableSupport(Context context, Object target, BeanIntrospection introspection) { + this.context = context; + this.target = target; + this.introspection = introspection; + } + + /** + * Returns an object as a Truffle {@link Value} suitable for guest access, wrapping introspectable types with {@link ProxyObjectWithIntrospectableSupport}. + */ + static Value wrap(Context context, Object object) { + if (object == null) { + return context.asValue(null); + } else if (object instanceof Map map) { + // We need to recursively map the values. + var result = new HashMap(); + map.forEach((key, value) -> result.put(key.toString(), wrap(context, value))); + return context.asValue(ProxyObject.fromMap(result)); + } else if (object instanceof Collection collection) { + // We need to recursively map the items. This could be lazy. + return context.asValue(collection.stream().map(it -> wrap(context, it)).toList()); + } else if (object instanceof String) { + // We could ignore this case because we'd fall through the BeanIntrospector check, but that logs some debug spam and it's slower to look up objects we know we won't wrap anyway. + return context.asValue(object); + } else { + var introspection = BeanIntrospector.SHARED.findIntrospection(object.getClass()).orElse(null); + if (introspection != null) { + return context.asValue(new ProxyObjectWithIntrospectableSupport(context, object, introspection)); + } else { + return context.asValue(object); + } + } + } + + @Override + public Object getMember(String key) { + Map map = asMap(); + + // Is it a property? + Object result = map.get(key); + if (result != null) { + return wrap(context, 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); + } + } + } + + // Not found. + 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(); + } + + private Map asMap() { + return BeanMap.of(target); + } + + @Override + public String toString() { + return target.toString(); + } + + @SuppressWarnings("rawtypes") + private final 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)); + } + } +} 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 new file mode 100644 index 000000000..612b36d89 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRenderer.java @@ -0,0 +1,152 @@ +/* + * Copyright 2017-2020 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.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.io.Writable; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.exceptions.MessageBodyException; +import io.micronaut.views.ViewsRenderer; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.graalvm.polyglot.HostAccess; +import org.graalvm.polyglot.Value; + +import java.io.IOException; +import java.io.Writer; +import java.nio.charset.StandardCharsets; + +/** + *

Instantiates GraalJS and uses it to render React components server side. See the user guide + * to learn more about how to render React/Preact apps server side.

+ * + * @param An introspectable bean type that will be fed to the ReactJS root component as props. + */ +@Singleton +public class ReactViewsRenderer implements ViewsRenderer> { + @Inject + ReactViewsRendererConfiguration reactConfiguration; + + @Inject + JSContextPool contextPool; + + /** + * Construct this renderer. Don't call it yourself, as Micronaut Views will set it up for you. + */ + @Inject + public ReactViewsRenderer() { + } + + /** + * Given a <ViewName/> and optionally an object that represents some props (can be a map + * or introspectable object), returns hydratable HTML that can be booted on the client using + * the React libraries. + * + * @param viewName The function or class name of the React component to use as the root. It should return an html root tag. + * @param props If non-null, will be exposed to the given component as React props. + * @param request The HTTP request object. + */ + @Override + public @NonNull Writable render(@NonNull String viewName, @Nullable PROPS props, @Nullable HttpRequest request) { + return writer -> { + JSContext context = contextPool.acquire(); + try { + render(viewName, props, writer, context, request); + } catch (Exception e) { + // If we don't wrap and rethrow, the exception is swallowed and the request hangs. + throw new MessageBodyException("Could not render component " + viewName, e); + } finally { + contextPool.release(context); + } + }; + } + + @Override + public boolean exists(@NonNull String viewName) { + var context = contextPool.acquire(); + try { + return context.moduleHasMember(viewName); + } finally { + contextPool.release(context); + } + } + + private void render(String componentName, PROPS props, Writer writer, JSContext context, @Nullable HttpRequest request) { + Value component = context.ssrModule.getMember(componentName); + if (component == null) { + throw new IllegalArgumentException("Component name %s wasn't exported from the SSR module.".formatted(componentName)); + } + + var renderCallback = new RenderCallback(writer, request); + + // 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. + Value guestProps = ProxyObjectWithIntrospectableSupport.wrap(context.polyglotContext, props); + context.render.executeVoid(component, guestProps, renderCallback, reactConfiguration.getClientBundleURL(), request); + } + + /** + * Methods exposed to the ReactJS components and render scripts. Needs to be public to be + * callable from the JS side. + *

+ * WARNING: These methods may be invoked by sandboxed code. Treat calls adversarially and + * mark methods with @HostAccess.Export to ensure they're visible inside the sandbox. + * + * @hidden + */ + public static final class RenderCallback { + private final Writer responseWriter; + private final @Nullable HttpRequest request; + + RenderCallback(Writer responseWriter, HttpRequest request) { + this.responseWriter = responseWriter; + this.request = request; + } + + @HostAccess.Export + @Nullable + public String url() { + if (request == null) { + return null; + } + return request.getUri().toString(); + } + + @HostAccess.Export + public void write(String html) { + try { + responseWriter.write(html); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @HostAccess.Export + public void write(int[] unsignedBytes) { + try { + byte[] bytes = new byte[unsignedBytes.length]; + for (int i = 0; i < unsignedBytes.length; i++) { + bytes[i] = (byte) unsignedBytes[i]; + } + responseWriter.write(new String(bytes, StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/views-react/src/main/java/io/micronaut/views/react/ReactViewsRendererConfiguration.java b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRendererConfiguration.java new file mode 100644 index 000000000..2eb0fd745 --- /dev/null +++ b/views-react/src/main/java/io/micronaut/views/react/ReactViewsRendererConfiguration.java @@ -0,0 +1,81 @@ +/* + * Copyright 2017-2020 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.ConfigurationProperties; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.bind.annotation.Bindable; +import io.micronaut.views.ViewsConfigurationProperties; +import jakarta.validation.constraints.NotBlank; + +/** + * An interface to the configuration properties for React server-side rendering. + */ +@ConfigurationProperties(ReactViewsRendererConfiguration.PREFIX) +public interface ReactViewsRendererConfiguration { + /** + * The config key prefix used to configure the React SSR view renderer. + */ + String PREFIX = ViewsConfigurationProperties.PREFIX + ".react"; + + /** The default value for {@link #getClientBundleURL()}. */ + String DEFAULT_CLIENT_BUNDLE_URL = "/static/client.js"; + + /** The default value for {@link #getServerBundlePath()}. */ + String DEFAULT_SERVER_BUNDLE_PATH = "classpath:views/ssr-components.mjs"; + + /** The default value for {@link #getRenderScript()}. */ + String DEFAULT_RENDER_SCRIPT = "classpath:/io/micronaut/views/react/react.js"; + + /** + * @return the URL (relative or absolute) where the client Javascript bundle can be found. It will + * be appended to the generated HTML in a <script> tag. Defaults + * to {@value #DEFAULT_CLIENT_BUNDLE_URL} + */ + @NotBlank + @NonNull + @Bindable(defaultValue = DEFAULT_CLIENT_BUNDLE_URL) + @SuppressWarnings("unused") // Accessed from Javascript via reflection. + String getClientBundleURL(); + + /** + * @return the path relative to micronaut.views.folder where the bundle used for + * server-side rendering can be found. Defaults to {@value #DEFAULT_SERVER_BUNDLE_PATH} + */ + @NotBlank + @NonNull + @Bindable(defaultValue = DEFAULT_SERVER_BUNDLE_PATH) + String getServerBundlePath(); + + + /** + * @return Either a file path (starting with "file:" or a resource in the classpath + * (starting with "classpath:") to a render script. Please see the user guide for + * more information on what this Javascript file should contain. + */ + @NotBlank + @NonNull + @Bindable(defaultValue = DEFAULT_RENDER_SCRIPT) + String getRenderScript(); + + /** + * @return If true, GraalJS sandboxing is enabled. This helps protect you against supply + * chain attacks that might inject code into your server via hijacked React components. + * It requires a sufficiently new version of GraalJS. Defaults to OFF. + */ + @Bindable(defaultValue = "false") + boolean getSandbox(); +} diff --git a/views-react/src/main/resources/io/micronaut/views/react/preact.js b/views-react/src/main/resources/io/micronaut/views/react/preact.js new file mode 100644 index 000000000..413657c57 --- /dev/null +++ b/views-react/src/main/resources/io/micronaut/views/react/preact.js @@ -0,0 +1,16 @@ +export function ssr(component, props, callback, clientBundleURL) { + globalThis.Micronaut = {}; + const url = callback.url(); + if (url) + props = {...props, "url": url}; + const html = renderToString(preact.h(component, props, null)) + callback.write(html) + const boot = { + rootProps: props, + rootComponent: component.name, + }; + + // The Micronaut object defined here is not the same as the Micronaut object defined server side. + callback.write(``) + callback.write(`