diff --git a/conf/guides.yml b/conf/guides.yml index 099e0cd665f..b1e98e996b9 100644 --- a/conf/guides.yml +++ b/conf/guides.yml @@ -1918,7 +1918,7 @@ guides: howto: How to Complete the Guide createApp: title: Creating the Application - download: Download a Grails 8 Snapshot Starter + download: Download a Grails 8 Starter dockerOverview: title: Two Paths to a Container Image bootBuildImage: @@ -2318,7 +2318,7 @@ guides: howto: How to Complete the Guide createApp: title: Creating the Application - download: Download a Grails 8 Snapshot Starter + download: Download a Grails 8 Starter fieldsPluginAlreadyIncluded: The Fields Plugin in Grails 8 domainModel: title: The Book and Author Domain Model @@ -2550,7 +2550,7 @@ guides: howto: How to Complete the Guide createApp: title: Creating the Application - download: Download a Grails 8 Snapshot Starter + download: Download a Grails 8 Starter workflowOverview: title: The Multi-Stage Job Graph ciYml: @@ -3396,6 +3396,8 @@ guides: title: The Two Webapps Consume shared-core runningEach: title: Running and Packaging Each Webapp + devReload: + title: Dev-Mode Auto-Reload Across Projects testingPerModule: title: Per-Module Testing extractingPlugin: @@ -4049,7 +4051,7 @@ guides: howto: How to Complete the Guide createApp: title: Creating the Application - download: Download a Grails 8 rest_api Starter + download: Download a Grails 8 Rest API Starter domainModel: title: The Book and Author Domain Model bootstrapData: Bootstrap Sample Data @@ -4257,7 +4259,7 @@ guides: - name: 'grails-spock-test-tour' title: 'A Spock Test Tour for Grails 8' - subtitle: 'Working examples of every Spock test layer Grails 8 supports - DomainUnitTest, ServiceUnitTest + DataTest, ControllerUnitTest, @Integration + @Rollback, plus parameterised where: data tables.' + subtitle: 'Working examples of every Spock test layer Grails 8 supports - DomainUnitTest, ServiceUnitTest + DataTest, ControllerUnitTest, @Integration + @Rollback, ContainerGebSpec functional tests, plus parameterised where: data tables.' authors: - 'James Fredley' category: 'Grails Testing' @@ -4300,7 +4302,7 @@ guides: integrationTest: title: '@Integration With @Rollback' functionalTest: - title: Geb Functional Tests + title: ContainerGebSpec Functional Tests parameterised: title: 'Parameterised where: Data Tables' platform: @@ -4421,7 +4423,7 @@ guides: howto: How to Complete the Guide createApp: title: Creating the Application - download: Download a Grails 8 Snapshot Starter + download: Download a Grails 8 Starter installTailwind: title: Installing Tailwind CSS 4 configureTailwind: diff --git a/guides/grails-docker-bootbuildimage/v8/guide/bootBuildImage.adoc b/guides/grails-docker-bootbuildimage/v8/guide/bootBuildImage.adoc index ae5a3ce9660..94f3c8a006c 100644 --- a/guides/grails-docker-bootbuildimage/v8/guide/bootBuildImage.adoc +++ b/guides/grails-docker-bootbuildimage/v8/guide/bootBuildImage.adoc @@ -16,3 +16,9 @@ Three pieces are doing the work: ==== Paketo also exposes ``BPL_JVM_HEAD_ROOM``, ``BPL_JVM_LOADED_CLASS_COUNT``, and ``BPL_JVM_THREAD_COUNT`` as runtime tuning knobs. The defaults are reasonable for a typical Grails app; reach for them only when JVM memory profiles drive the conversation. ==== + +The Spring Boot Gradle plugin pinned in `grails-core` 8.0.x is `spring-boot-gradle-plugin:4.0.5` (see `git show 8.0.x:dependencies.gradle` for the exact coordinate); `bootBuildImage` is part of that plugin and is enabled automatically by the `org.apache.grails.gradle.web` plugin the `web` profile applies. + +Files touched in this chapter: + +* `build.gradle` diff --git a/guides/grails-docker-bootbuildimage/v8/guide/compose.adoc b/guides/grails-docker-bootbuildimage/v8/guide/compose.adoc index c06ab8373bc..2b5120e089f 100644 --- a/guides/grails-docker-bootbuildimage/v8/guide/compose.adoc +++ b/guides/grails-docker-bootbuildimage/v8/guide/compose.adoc @@ -21,3 +21,7 @@ docker compose up ---- The first run pulls the Postgres image, creates the volume, starts Postgres, waits for healthy, then starts the app. Subsequent runs skip the pull. + +Files touched in this chapter: + +* `compose.yml` diff --git a/guides/grails-docker-bootbuildimage/v8/guide/createApp.adoc b/guides/grails-docker-bootbuildimage/v8/guide/createApp.adoc index bcbceccf0ee..9fb8b64f63f 100644 --- a/guides/grails-docker-bootbuildimage/v8/guide/createApp.adoc +++ b/guides/grails-docker-bootbuildimage/v8/guide/createApp.adoc @@ -1 +1 @@ -Generate a fresh Apache Grails 8 web application from link:https://prev-snapshot.grails.org[prev-snapshot.grails.org] with the three features the rest of the guide expects: `postgres` (the JDBC driver and a Postgres datasource template), `testcontainers` (so we can spin up a real Postgres in functional tests), and `database-migration` (Liquibase, so the production image can run schema changes on first start). +Generate a fresh Apache Grails 8 web application from link:https://start.grails.org[start.grails.org] with the three features the rest of the guide expects: `postgres` (the JDBC driver and a Postgres datasource template), `testcontainers` (so we can spin up a real Postgres in functional tests), and `database-migration` (Liquibase, so the production image can run schema changes on first start). diff --git a/guides/grails-docker-bootbuildimage/v8/guide/dockerOverview.adoc b/guides/grails-docker-bootbuildimage/v8/guide/dockerOverview.adoc index d6c1a89da7c..4d423dde8a7 100644 --- a/guides/grails-docker-bootbuildimage/v8/guide/dockerOverview.adoc +++ b/guides/grails-docker-bootbuildimage/v8/guide/dockerOverview.adoc @@ -6,3 +6,7 @@ Two paths produce a runnable container image from the same `bootJar`: For a typical Grails 8 web app the buildpack path wins on every dimension that matters: smaller images (the buildpack's slim JRE base is ~150 MB), better layering (a code-only change touches only the top layer), and a free non-root user. The Dockerfile path is justified when you have a regulated build that must consume an internal mirror, an air-gapped CI, or a non-buildpack-compatible base image. This guide leads with `bootBuildImage` and treats the Dockerfile as an alternative. + +Files touched in this chapter: + +* None diff --git a/guides/grails-docker-bootbuildimage/v8/guide/dockerfile.adoc b/guides/grails-docker-bootbuildimage/v8/guide/dockerfile.adoc index 26b8fa34ef3..168f11c593a 100644 --- a/guides/grails-docker-bootbuildimage/v8/guide/dockerfile.adoc +++ b/guides/grails-docker-bootbuildimage/v8/guide/dockerfile.adoc @@ -23,3 +23,8 @@ The matching `.dockerignore` file excludes `.gradle/`, `build/`, `out/`, and oth include::../snippets/.dockerignore[] ---- ==== + +Files touched in this chapter: + +* `Dockerfile` +* `.dockerignore` diff --git a/guides/grails-docker-bootbuildimage/v8/guide/download.adoc b/guides/grails-docker-bootbuildimage/v8/guide/download.adoc index 9b3d035dafb..ec55e33822a 100644 --- a/guides/grails-docker-bootbuildimage/v8/guide/download.adoc +++ b/guides/grails-docker-bootbuildimage/v8/guide/download.adoc @@ -1,10 +1,4 @@ -[source,bash] ----- -curl -L -o docker-app.zip \ - "https://prev-snapshot.grails.org/create/web/example.docker?lang=GROOVY&build=GRADLE&test=SPOCK&javaVersion=JDK_21&features=postgres,testcontainers,database-migration" -unzip docker-app.zip -cd docker ----- +Open link:https://start.grails.org[start.grails.org], pick the `web` profile, name the application `docker`, set the package to `example`, choose JDK 21, tick the `postgres`, `testcontainers`, and `database-migration` features, and download the generated zip. Unzip it and `cd` into the `docker` directory. The forge unpacks into a `docker/` directory containing a vanilla Grails 8 layout plus the three feature-driven additions: diff --git a/guides/grails-docker-bootbuildimage/v8/guide/envProfiles.adoc b/guides/grails-docker-bootbuildimage/v8/guide/envProfiles.adoc index adc31e4d7a7..23c3b261ec2 100644 --- a/guides/grails-docker-bootbuildimage/v8/guide/envProfiles.adoc +++ b/guides/grails-docker-bootbuildimage/v8/guide/envProfiles.adoc @@ -15,3 +15,7 @@ Three things to notice: * The username and password resolve from `SPRING_DATASOURCE_USERNAME` and `SPRING_DATASOURCE_PASSWORD` regardless of profile, with the username defaulting to `postgres` for local development. The Compose file we will write later passes these three variables to the container. Kubernetes deployments would pass the same variables via a `Secret` and `envFrom: { secretRef: ... }`. + +Files touched in this chapter: + +* `grails-app/conf/application.yml` diff --git a/guides/grails-docker-bootbuildimage/v8/guide/ghcr.adoc b/guides/grails-docker-bootbuildimage/v8/guide/ghcr.adoc index e9869d85150..e59b58737f2 100644 --- a/guides/grails-docker-bootbuildimage/v8/guide/ghcr.adoc +++ b/guides/grails-docker-bootbuildimage/v8/guide/ghcr.adoc @@ -16,3 +16,7 @@ The first time an image is pushed under a given repo path, GHCR creates the pack ==== The matching CI/CD guide automates this whole flow on every git tag: a `release.yml` workflow runs `bootBuildImage` against the tag's source, pushes to GHCR with the tag name as the image tag, and creates a GitHub Release with the bootJar attached. See the link:../grails-github-actions-cicd/8/guide/index.html[GitHub Actions CI/CD with Grails 8] guide for the workflow file. ==== + +Files touched in this chapter: + +* None (push happens from the developer shell or from CI) diff --git a/guides/grails-docker-bootbuildimage/v8/guide/healthCheck.adoc b/guides/grails-docker-bootbuildimage/v8/guide/healthCheck.adoc index cd2b4592c3e..760b97b54c2 100644 --- a/guides/grails-docker-bootbuildimage/v8/guide/healthCheck.adoc +++ b/guides/grails-docker-bootbuildimage/v8/guide/healthCheck.adoc @@ -14,3 +14,7 @@ The `health.livenessstate` and `health.readinessstate` flags expose the two prob * `/actuator/health/readiness` answers "should the platform send traffic to this pod?". Returns `DOWN` while the application is starting up, or while the database is unreachable. Use as a Kubernetes `readinessProbe` and as the Docker `HEALTHCHECK`. For Docker Compose, the `HEALTHCHECK` lives on the service definition (next chapter). For Kubernetes, you would use `httpGet: { path: /actuator/health/readiness, port: 8080 }` instead. + +Files touched in this chapter: + +* `grails-app/conf/application.yml` diff --git a/guides/grails-docker-bootbuildimage/v8/guide/howto.adoc b/guides/grails-docker-bootbuildimage/v8/guide/howto.adoc index f29561e1e46..a2fa92ad735 100644 --- a/guides/grails-docker-bootbuildimage/v8/guide/howto.adoc +++ b/guides/grails-docker-bootbuildimage/v8/guide/howto.adoc @@ -10,7 +10,7 @@ docker compose up The repository contains two top-level directories: -* `initial/` - the starting Grails 8 snapshot project, generated from `https://prev-snapshot.grails.org` with the `postgres`, `testcontainers`, and `database-migration` features and no further customisations. +* `initial/` - the starting Grails 8 project, generated from link:https://start.grails.org[start.grails.org] with the `postgres`, `testcontainers`, and `database-migration` features and no further customisations. * `complete/` - the same project with all Docker-related changes from this guide already in place. Each chapter ends with the file paths it touches relative to the project root, so you can match what you typed against the `complete/` reference. diff --git a/guides/grails-docker-bootbuildimage/v8/guide/jvmContainer.adoc b/guides/grails-docker-bootbuildimage/v8/guide/jvmContainer.adoc index 1e7e97d5e98..fd0fd0fa65d 100644 --- a/guides/grails-docker-bootbuildimage/v8/guide/jvmContainer.adoc +++ b/guides/grails-docker-bootbuildimage/v8/guide/jvmContainer.adoc @@ -17,3 +17,8 @@ environment = [ The `BPE_APPEND` variant *adds* to whatever the buildpack-supplied `JAVA_TOOL_OPTIONS` already contains. The buildpack ships its own memory calculator output (heap, direct memory, metaspace, reserved code cache, thread stacks); appending preserves all of that and just bolts our two flags on the end. If you switch to the hand-rolled Dockerfile you set the same two flags via `ENV JAVA_TOOL_OPTIONS=...` instead. + +Files touched in this chapter: + +* `build.gradle` (the `bootBuildImage` block) +* `Dockerfile` (only if you take the alternative path) diff --git a/guides/grails-docker-bootbuildimage/v8/guide/nonRoot.adoc b/guides/grails-docker-bootbuildimage/v8/guide/nonRoot.adoc index 57b1d284f72..0c71b538ef5 100644 --- a/guides/grails-docker-bootbuildimage/v8/guide/nonRoot.adoc +++ b/guides/grails-docker-bootbuildimage/v8/guide/nonRoot.adoc @@ -19,3 +19,7 @@ securityContext: ---- `readOnlyRootFilesystem: true` is the strictest setting. The Paketo image already supports it; the hand-rolled Dockerfile may need a `tmpfs` volume mounted at `/tmp` for things like Tomcat's work directory. The trade-off is yours to make. + +Files touched in this chapter: + +* None directly (`Dockerfile` already creates the non-root user) diff --git a/guides/grails-docker-bootbuildimage/v8/guide/postgresWiring.adoc b/guides/grails-docker-bootbuildimage/v8/guide/postgresWiring.adoc index 80991a804ab..c67cc01aebb 100644 --- a/guides/grails-docker-bootbuildimage/v8/guide/postgresWiring.adoc +++ b/guides/grails-docker-bootbuildimage/v8/guide/postgresWiring.adoc @@ -18,3 +18,7 @@ The `application.yml` we wrote in the env-profiles chapter resolves these three ==== For functional tests, the `testcontainers` feature means you do not need a long-running Postgres at all - Spock specs can spin one up per spec via Testcontainers' Postgres module, run their assertions, and tear it down. The CI/CD guide shows how that integrates with GitHub Actions. ==== + +Files touched in this chapter: + +* `compose.yml` diff --git a/guides/grails-docker-bootbuildimage/v8/guide/runImage.adoc b/guides/grails-docker-bootbuildimage/v8/guide/runImage.adoc index c437eacd571..2607c1d1470 100644 --- a/guides/grails-docker-bootbuildimage/v8/guide/runImage.adoc +++ b/guides/grails-docker-bootbuildimage/v8/guide/runImage.adoc @@ -24,3 +24,7 @@ The first request renders the Grails welcome page. The second returns `{"status" ==== ``docker history ghcr.io/grails-guides/grails-docker-bootbuildimage:0.1`` shows you the layer breakdown the buildpack produced. Notice that classes/resources sit on top of dependencies; a code-only change rebuilds only that one layer. ==== + +Files touched in this chapter: + +* None diff --git a/guides/grails-docker-bootbuildimage/v8/guide/whatYouWillBuild.adoc b/guides/grails-docker-bootbuildimage/v8/guide/whatYouWillBuild.adoc index a70a6caec82..7d890443757 100644 --- a/guides/grails-docker-bootbuildimage/v8/guide/whatYouWillBuild.adoc +++ b/guides/grails-docker-bootbuildimage/v8/guide/whatYouWillBuild.adoc @@ -6,3 +6,9 @@ By the end of the guide you will have: * A `compose.yml` stack that runs the image alongside `postgres:16-alpine`, with a Spring Boot Actuator-driven `HEALTHCHECK` and a `depends_on: condition: service_healthy` block so the app waits for the database to be ready before it starts. * An equivalent multi-stage `Dockerfile` (build stage on `bellsoft/liberica-openjdk-debian:21`, runtime stage on the matching JRE image) that runs the app as UID `10001` and shows what the buildpack does for you when you let it. * The commands you need to push the image to GitHub Container Registry, and a one-paragraph note on consuming it from the matching CI/CD guide. + +The official Grails 8 reference manual at `grails-doc/src/en/guide/deployment.adoc` (and the `deployment/` subdirectory) covers WAR file deployment to a servlet container. This guide complements that by walking the modern OCI-image path that Spring Boot 4's `bootBuildImage` task makes possible. + +Files touched in this chapter: + +* None diff --git a/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/fieldsPluginAlreadyIncluded.adoc b/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/fieldsPluginAlreadyIncluded.adoc index b05545c0ae5..2bb22bc3e0b 100644 --- a/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/fieldsPluginAlreadyIncluded.adoc +++ b/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/fieldsPluginAlreadyIncluded.adoc @@ -1,4 +1,4 @@ -As of Apache Grails 7 the Fields plugin is bundled with `grails-core`. You do not need to add a dependency for it. The taglib namespace `f` is registered automatically and the default templates live on the runtime classpath inside `grails-fields.jar`. +As of Apache Grails 7 the Fields plugin is bundled with `grails-core` as the `grails-fields` module. You do not need to add a dependency for it. The taglib namespace `f` is registered automatically by `grails-app/taglib/grails/plugin/formfields/FormFieldsTagLib.groovy` (inside `grails-fields.jar`) and the default templates live on the runtime classpath. You can confirm the plugin is on the classpath by listing the resolved configurations: @@ -7,4 +7,6 @@ You can confirm the plugin is on the classpath by listing the resolved configura ./gradlew dependencies --configuration runtimeClasspath | grep -i fields ---- -You should see `org.apache.grails:grails-fields:8.0.0-SNAPSHOT` (or whichever Grails version your project pins) in the output. There is no separate plugin descriptor to register and no `BuildConfig.groovy` to edit. +You should see `org.apache.grails:grails-fields:` matching the Grails version your project pins. There is no separate plugin descriptor to register and no `BuildConfig.groovy` to edit. + +The official 8.0.x reference for the plugin lives at `grails-doc/src/en/guide/theWebLayer/fields.adoc` (and the `fields/` subdirectory). That chapter covers the high-level `f:` taglib API; this guide complements it with worked examples of customising the per-property and per-class templates that the plugin's `FormFieldsTemplateService` looks up. diff --git a/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/howto.adoc b/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/howto.adoc index 0b22a2a9f68..5338c585c06 100644 --- a/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/howto.adoc +++ b/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/howto.adoc @@ -9,7 +9,7 @@ cd grails-fields-custom-widgets-and-wrappers/complete The repository contains two top-level directories: -* `initial/` - the starting Grails 8 snapshot project, generated from `https://prev-snapshot.grails.org` with no customisations applied. +* `initial/` - the starting Grails 8 project, generated from link:https://start.grails.org[start.grails.org] with no customisations applied. * `complete/` - the same project with all the widgets, wrappers, and table customisations from this guide already in place. Each chapter ends with the file paths it touches relative to the project root, so you can match what you typed against the `complete/` reference. diff --git a/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/templateLookup.adoc b/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/templateLookup.adoc index db4ac4fe951..df771926d26 100644 --- a/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/templateLookup.adoc +++ b/guides/grails-fields-custom-widgets-and-wrappers/v8/guide/templateLookup.adoc @@ -24,4 +24,4 @@ The practical consequence: you customise the _highest-impact_ template at the le The rest of this guide walks each of those layers in turn. -TIP: If you want to see exactly which path the plugin chose, set the log level on `org.grails.plugin.formfields.FormFieldsTemplateService` to `DEBUG`. Each lookup logs every path it tried in order. +TIP: If you want to see exactly which path the plugin chose, set the log level on `org.grails.plugin.formfields.FormFieldsTemplateService` to `DEBUG`. Each lookup logs every path it tried in order. The lookup logic itself lives in `grails-fields/src/main/groovy/grails/plugin/formfields/FormFieldsTemplateService.groovy` if you want to read the source. The plugin also supports a `_themes//` lookup level (controlled by `grails.plugin.fields.theme`) that comes _before_ the per-class layer; see `grails-doc/src/en/guide/theWebLayer/fields.adoc` for the theme story. diff --git a/guides/grails-github-actions-cicd/v8/guide/createApp.adoc b/guides/grails-github-actions-cicd/v8/guide/createApp.adoc index e930e1894e0..da09db3c4d0 100644 --- a/guides/grails-github-actions-cicd/v8/guide/createApp.adoc +++ b/guides/grails-github-actions-cicd/v8/guide/createApp.adoc @@ -1 +1 @@ -Generate a fresh Apache Grails 8 web application from link:https://prev-snapshot.grails.org[prev-snapshot.grails.org] with the three features the rest of the guide expects: `postgres`, `testcontainers`, and `geb-with-webdriver-binaries` (so functional tests can drive a headless browser). +Generate a fresh Apache Grails 8 web application from link:https://start.grails.org[start.grails.org] with the two features the rest of the guide expects: `postgres` and `testcontainers`. The functional tests use `ContainerGebSpec` from `org.apache.grails:grails-geb`, which runs the browser in a Testcontainers Selenium container - no `geb-with-webdriver-binaries` feature is needed. diff --git a/guides/grails-github-actions-cicd/v8/guide/download.adoc b/guides/grails-github-actions-cicd/v8/guide/download.adoc index 354fc31800b..99f7ad07055 100644 --- a/guides/grails-github-actions-cicd/v8/guide/download.adoc +++ b/guides/grails-github-actions-cicd/v8/guide/download.adoc @@ -1,13 +1,8 @@ -[source,bash] ----- -curl -L -o cicd-app.zip \ - "https://prev-snapshot.grails.org/create/web/example.cicd?lang=GROOVY&build=GRADLE&test=SPOCK&javaVersion=JDK_21&features=postgres,testcontainers,geb-with-webdriver-binaries" -unzip cicd-app.zip -cd cicd ----- +Open link:https://start.grails.org[start.grails.org], pick the `web` profile, name the application `cicd`, set the package to `example`, choose JDK 21, tick the `postgres` and `testcontainers` features, and download the generated zip. Unzip it and `cd` into the `cicd` directory. -The forge unpacks into a `cicd/` directory containing a vanilla Grails 8 web layout with three feature-driven additions: +The forge unpacks into a `cicd/` directory containing a vanilla Grails 8 web layout with two feature-driven additions: * `runtimeOnly "org.postgresql:postgresql"` and a Postgres datasource template * `testImplementation "org.testcontainers:postgresql"` (and `org.testcontainers:spock` + `:testcontainers`) -* `integrationTestImplementation testFixtures("org.apache.grails:grails-geb")` and `com.energizedwork.gebish:webdriver-binaries-gradle-plugin` for downloading Chrome/Firefox driver binaries automatically + +Geb container support comes from `testFixtures("org.apache.grails:grails-geb")`, which is already on the test classpath in the standard `web` profile - no extra forge feature is needed. diff --git a/guides/grails-github-actions-cicd/v8/guide/functionalTests.adoc b/guides/grails-github-actions-cicd/v8/guide/functionalTests.adoc index 5937f352e3b..23cb4618a9a 100644 --- a/guides/grails-github-actions-cicd/v8/guide/functionalTests.adoc +++ b/guides/grails-github-actions-cicd/v8/guide/functionalTests.adoc @@ -1,4 +1,4 @@ -The `functional-test` job boots the application against a Testcontainers-managed Postgres and drives it with Geb 8 specs. Because it does not declare a `services.postgres` block, the runner is a plain Ubuntu VM; Testcontainers spins up Postgres on demand from the test JVM via the local Docker socket (which GitHub-hosted runners expose by default). +The `functional-test` job boots the application against a Testcontainers-managed Postgres and drives it with Geb 8 specs that extend `ContainerGebSpec` (from `testFixtures("org.apache.grails:grails-geb")`). The browser itself runs in a Selenium-Chrome container started by Testcontainers, so the runner never needs Chrome/Firefox installed on the host. Because the job does not declare a `services.postgres` block, the runner is a plain Ubuntu VM; Testcontainers spins up both Postgres and Selenium on demand from the test JVM via the local Docker socket (which GitHub-hosted runners expose by default). [source,yaml] ---- @@ -27,4 +27,4 @@ The `functional-test` job boots the application against a Testcontainers-managed `if: always()` on the geb-reports upload is intentional: even on a green run you may want the screenshots from `build/geb-reports/` to confirm the page rendered as expected. On a red run, those screenshots are how you debug "works on my machine" failures - the runner's screenshot of the failed page is usually enough to spot the missing element. -The `geb-with-webdriver-binaries` feature you selected at the forge step downloads matching Chrome/Firefox driver binaries on the fly via `webdriver-binaries-gradle-plugin`, so no `apt-get install` of the browsers themselves is needed - the plugin handles both the browser and the driver. +No `apt-get install` of Chrome/Firefox is needed - the browser process lives inside the Selenium container Testcontainers pulls (`selenium/standalone-chrome` by default). The first cold run pulls the image (~200 MB) and is slower; subsequent runs reuse the cached layer. The legacy `geb-with-webdriver-binaries` forge feature is obsolete in Grails 8 and is not used here. diff --git a/guides/grails-github-actions-cicd/v8/guide/howto.adoc b/guides/grails-github-actions-cicd/v8/guide/howto.adoc index e6089370b8c..b69dfd96484 100644 --- a/guides/grails-github-actions-cicd/v8/guide/howto.adoc +++ b/guides/grails-github-actions-cicd/v8/guide/howto.adoc @@ -9,5 +9,5 @@ cd grails-github-actions-cicd/complete The repository contains two top-level directories: -* `initial/` - the starting Grails 8 snapshot project, generated from `https://prev-snapshot.grails.org` with the `postgres`, `testcontainers`, and `geb-with-webdriver-binaries` features. +* `initial/` - the starting Grails 8 project, generated from link:https://start.grails.org[start.grails.org] with the `postgres` and `testcontainers` features. * `complete/` - the same project with the three workflow YAML files added under `.github/`. diff --git a/guides/grails-htmx/v8/guide/controller.adoc b/guides/grails-htmx/v8/guide/controller.adoc index 57e945df115..77698ff6808 100644 --- a/guides/grails-htmx/v8/guide/controller.adoc +++ b/guides/grails-htmx/v8/guide/controller.adoc @@ -1,4 +1,4 @@ -`TaskController` has seven actions, but only `index` returns a full page. Every other action returns a single GSP partial via `render template: '...'`: +`TaskController` keeps the full-page render and the fragment renders separate. `index` uses `render view:` for the initial page; every HTMX endpoint returns a fragment with `render template:`: [source,groovy] .grails-app/controllers/example/TaskController.groovy @@ -10,5 +10,12 @@ A few patterns to point at: * `static allowedMethods = [...]` rejects mismatched HTTP verbs at the framework level. Sending `GET /tasks` to `create` returns 405, not silently calling the action. * The `@Transactional(readOnly = true)` class annotation flips read-only mode on for the whole controller; individual mutating actions override with their own `@Transactional`. -* On a validation failure (`!t.save()`), the controller returns a 422 with the error fragment. HTMX swaps that fragment into the page, the user sees the error inline, and the form remains in the DOM ready for another attempt. -* `delete` returns an empty body. Combined with `hx-swap="outerHTML"` on the delete button, this removes the row from the DOM. +* `show(Long id)` returns the read-only row fragment. That gives the Cancel button in the edit form a clean endpoint to reload. +* On a validation failure the controller returns `422 Unprocessable Entity` with the same fragment the browser was already showing. HTMX swaps that fragment in place, so the user keeps context and sees the errors inline. +* `create()` renders `_taskCreated.gsp`, which combines a fresh form plus an out-of-band prepend of the new row. This avoids hand-written client-side reset logic. +* `delete()` returns an empty body. Combined with `hx-target="closest li"` and `hx-swap="outerHTML"`, that removes the row from the DOM. + +Files touched: + +* `grails-app/controllers/example/TaskController.groovy` +* `grails-app/views/task/_taskCreated.gsp` diff --git a/guides/grails-htmx/v8/guide/createApp.adoc b/guides/grails-htmx/v8/guide/createApp.adoc index 0ba7cae7c51..a06c636255b 100644 --- a/guides/grails-htmx/v8/guide/createApp.adoc +++ b/guides/grails-htmx/v8/guide/createApp.adoc @@ -1,11 +1,30 @@ -Generate a fresh Apache Grails 8 web application from link:https://prev-snapshot.grails.org[prev-snapshot.grails.org]: +Open link:https://start.grails.org[start.grails.org], pick the `web` profile, name the application `htmxtasks`, set the package to `example`, choose JDK 21, and download the generated zip. Unzip it and `cd` into the `htmxtasks` directory. -[source,bash] +The default `web` starter already gives you the pieces this guide needs from Grails 8 core: + +* GSP support via `org.apache.grails:grails-gsp`, managed by the Grails BOM. +* Asset Pipeline support via the `cloud.wondrify:asset-pipeline-*` modules, with version `5.0.32` coming from the Grails BOM. +* Bootstrap and Bootstrap Icons webjars, which the starter's asset manifests already require. + +HTMX is not part of the Grails 8 BOM or the `web` profile starter, so add the htmx webjar yourself: + +[source,groovy] +.build.gradle ---- -curl -L -o htmx-app.zip \ - "https://prev-snapshot.grails.org/create/web/example.htmxtasks?lang=GROOVY&build=GRADLE&test=SPOCK&javaVersion=JDK_21" -unzip htmx-app.zip -cd htmxtasks +include::../snippets/build.gradle[] ---- -The default `web` starter ships GSP, the asset pipeline, and Bootstrap 5 webjars - all of which we will reuse. +Then require the htmx asset in the JavaScript manifest: + +[source,javascript] +.grails-app/assets/javascripts/application.js +---- +include::../snippets/grails-app/assets/javascripts/application.js[] +---- + +Using `org.webjars.npm:htmx.org:2.0.4` keeps the delivery story consistent with the Grails 8 starter: Asset Pipeline serves the file from the webjar, just as it already does for Bootstrap and jQuery. + +Files touched: + +* `build.gradle` +* `grails-app/assets/javascripts/application.js` diff --git a/guides/grails-htmx/v8/guide/csrf.adoc b/guides/grails-htmx/v8/guide/csrf.adoc index a8b21a72ce0..49c4c5c5f06 100644 --- a/guides/grails-htmx/v8/guide/csrf.adoc +++ b/guides/grails-htmx/v8/guide/csrf.adoc @@ -1,23 +1,42 @@ -Grails' `` tag automatically inserts a `SYNCHRONIZER_TOKEN` parameter when the controller declares `static withForm = ...`. HTMX requests do not go through ``, so the token has to be wired in differently. +There are two different token stories to keep straight in Grails 8. -The HTMX configuration block in `main.gsp` reads the token from a `` tag and forwards it as a custom header on every request: +First, Grails core has the long-standing `useToken` / `withForm` mechanism. It is implemented by `FormTagLib`, `Controller.withForm`, and `SynchronizerTokensHolder`, and it is primarily a duplicate-submit token flow: + +[source,groovy] +---- + + ... + + +withForm { + // valid token +}.invalidToken { + // invalid or replayed token +} +---- + +That mechanism works well for normal Grails forms. It is not, by itself, a built-in HTMX-wide CSRF filter for button-driven `hx-post`, `hx-patch`, or `hx-delete` requests. + +Second, if Spring Security's CSRF filter is on the classpath and active, Grails exposes the `_csrf` request attribute and `FormTagLib` automatically writes the hidden field into ``. For HTMX requests triggered from links and buttons, forward that header globally: [source,html] +.grails-app/views/layouts/main.gsp ---- - - +include::../snippets/grails-app/views/layouts/main.gsp[] ---- -A matching `before` interceptor checks the header on every state-changing request and rejects mismatches with a 403. For a sample app the token check is overkill; for a real back-office the few lines of plumbing buy you the same protection `` provides for traditional forms. +This guide's layout only adds those `` tags and the `htmx:configRequest` listener when `_csrf` exists. That keeps the sample app truthful: + +* without Spring Security, the app is just a plain Grails 8 sample and the listener is inert; +* with Spring Security CSRF enabled, HTMX requests automatically carry the expected header name and token value. [NOTE] ==== -The `Origin` and `Referer` headers also defeat CSRF for fetch-based clients. Modern browsers send them automatically and a same-origin check is one line of interceptor code. Belt-and-braces shops do both; pick the discipline that matches your threat model. +If you stay entirely inside `` submissions, Grails' form helpers already do the right thing for both token systems. You only need the global HTMX header hook for non-form triggers such as row-level toggle and delete buttons. ==== + +Files touched: + +* `grails-app/views/layouts/main.gsp` +* `grails-app/views/task/_taskForm.gsp` +* `grails-app/views/task/_taskEdit.gsp` diff --git a/guides/grails-htmx/v8/guide/gettingStarted.adoc b/guides/grails-htmx/v8/guide/gettingStarted.adoc index 40b3cdd7f27..d7433949f46 100644 --- a/guides/grails-htmx/v8/guide/gettingStarted.adoc +++ b/guides/grails-htmx/v8/guide/gettingStarted.adoc @@ -3,3 +3,5 @@ In this guide you will build a small task tracker on Apache Grails 8 with link:h The pattern is a natural fit for Grails: GSP partials become HTMX response fragments. Your controllers return small chunks of HTML instead of JSON; HTMX swaps them into the right slot. Forms post to controllers via `hx-post` and validation errors come back as the same partial with the errors rendered inline. This guide targets Apache Grails 8 / HTMX 2.x. + +Files touched: none. diff --git a/guides/grails-htmx/v8/guide/helpWithGrails.adoc b/guides/grails-htmx/v8/guide/helpWithGrails.adoc index e062f614b1a..574275595f0 100644 --- a/guides/grails-htmx/v8/guide/helpWithGrails.adoc +++ b/guides/grails-htmx/v8/guide/helpWithGrails.adoc @@ -1 +1,3 @@ include::{commondir}/common-helpWithGrails.adoc[] + +Files touched: none. diff --git a/guides/grails-htmx/v8/guide/howto.adoc b/guides/grails-htmx/v8/guide/howto.adoc index be41d409ba9..477defd444d 100644 --- a/guides/grails-htmx/v8/guide/howto.adoc +++ b/guides/grails-htmx/v8/guide/howto.adoc @@ -8,4 +8,6 @@ cd grails-htmx/complete # open http://localhost:8080/tasks ---- -`initial/` is a vanilla Grails 8 starter. `complete/` adds the Task domain, the controller, the GSP partials, and the HTMX script tag. +`initial/` is a vanilla Grails 8 web starter. `complete/` adds the Task domain, the controller, the URL mappings, the GSP partials, the `htmx.org` webjar dependency, and the asset-pipeline manifest entry that serves `htmx.min.js`. + +Files touched: none. diff --git a/guides/grails-htmx/v8/guide/indexPage.adoc b/guides/grails-htmx/v8/guide/indexPage.adoc index ccc6259c15e..94b4da5d904 100644 --- a/guides/grails-htmx/v8/guide/indexPage.adoc +++ b/guides/grails-htmx/v8/guide/indexPage.adoc @@ -8,8 +8,13 @@ include::../snippets/grails-app/views/task/index.gsp[] Three HTMX patterns are in play: -* The *add-task form* posts to `/tasks` (`hx-post`) and prepends the response to `#taskList` (`hx-target="#taskList"`, `hx-swap="afterbegin"`). The `hx-on::after-request="this.reset()"` clears the input after a successful submission. +* The *add-task form* comes from `_taskForm.gsp`, not raw inline markup in `index.gsp`. Wrapping it in a partial lets the controller return the same fragment on validation failure and a fresh fragment after success. * The *live-search input* fires a `GET /tasks/search?q=...` whenever the user pauses typing for 300 ms (`hx-trigger="keyup changed delay:300ms"`). The response replaces the contents of `#taskList`. * The *initial render* uses `` to populate the list server-side. HTMX takes over from there. This is the canonical HTMX recipe: render once on the server, then let small fragments shuttle in and out of well-defined slots in the DOM. + +Files touched: + +* `grails-app/views/task/index.gsp` +* `grails-app/views/task/_taskForm.gsp` diff --git a/guides/grails-htmx/v8/guide/inlineEdit.adoc b/guides/grails-htmx/v8/guide/inlineEdit.adoc index 5ffa7a32522..58614979853 100644 --- a/guides/grails-htmx/v8/guide/inlineEdit.adoc +++ b/guides/grails-htmx/v8/guide/inlineEdit.adoc @@ -8,6 +8,12 @@ include::../snippets/grails-app/views/task/_taskEdit.gsp[] Three patterns to highlight: -* The form's `hx-patch` posts to `/tasks/{id}` with the PATCH method. The response is either the read-only `_task.gsp` (success) or the same edit form with the validation errors rendered (failure). -* `` renders the field errors inline. Because Grails populates `task.errors` when `save()` fails, the same partial template handles both success and failure. The HTTP status (200 vs 422) is what tells HTMX which one - both swap into the same slot. -* The Cancel button does a `hx-get` for `/tasks/{id}` (which returns the read-only row partial) so cancelling does not require any new wiring. +* The form's `hx-patch` posts to `/tasks/{id}` with the PATCH method. The response is either the read-only `_task.gsp` on success or the same edit form with validation errors on failure. +* Wrapping the markup in `` keeps the snippet idiomatic Grails. If Spring Security CSRF is active, `FormTagLib` will automatically add its hidden field to this form. +* `` renders only the field errors we care about. Because Grails populates `task.errors` when `save()` fails, the same partial template handles both failure and re-render. +* The Cancel button does a `hx-get` for `/tasks/{id}` so cancelling reloads the canonical read-only row fragment instead of trying to rebuild it in JavaScript. + +Files touched: + +* `grails-app/views/task/_taskEdit.gsp` +* `grails-app/controllers/example/TaskController.groovy` diff --git a/guides/grails-htmx/v8/guide/layoutWithHtmx.adoc b/guides/grails-htmx/v8/guide/layoutWithHtmx.adoc index 9d284cb6acf..8e49f8ae575 100644 --- a/guides/grails-htmx/v8/guide/layoutWithHtmx.adoc +++ b/guides/grails-htmx/v8/guide/layoutWithHtmx.adoc @@ -1,4 +1,12 @@ -HTMX is a single ~14 KB script. The starter's layout already loads stylesheets and the Grails javascript bundle; add the HTMX script tag in ``: +The Grails 8 `web` starter already serves frontend libraries through Asset Pipeline and webjars. Follow that same pattern for HTMX instead of adding a CDN ` + + + + + + diff --git a/guides/grails-htmx/v8/snippets/grails-app/views/task/_task.gsp b/guides/grails-htmx/v8/snippets/grails-app/views/task/_task.gsp index 5dafd595593..5024649748e 100644 --- a/guides/grails-htmx/v8/snippets/grails-app/views/task/_task.gsp +++ b/guides/grails-htmx/v8/snippets/grails-app/views/task/_task.gsp @@ -1,30 +1,30 @@
  • - <%-- Toggle the done flag with a single round trip --%> - + diff --git a/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskCreated.gsp b/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskCreated.gsp new file mode 100644 index 00000000000..376d7a4a1e0 --- /dev/null +++ b/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskCreated.gsp @@ -0,0 +1,5 @@ + + +
      + +
    diff --git a/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskEdit.gsp b/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskEdit.gsp index 8b7846261b9..cdc2a27171b 100644 --- a/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskEdit.gsp +++ b/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskEdit.gsp @@ -1,23 +1,26 @@
  • -
    - + + - +
      -
    • ${message(error: it).encodeAsHTML()}
    • +
    • ${message(error: it).encodeAsHTML()}
  • diff --git a/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskForm.gsp b/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskForm.gsp new file mode 100644 index 00000000000..aec6c746d15 --- /dev/null +++ b/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskForm.gsp @@ -0,0 +1,25 @@ +
    + +
    + + +
      + +
    • ${message(error: it).encodeAsHTML()}
    • +
      +
    +
    +
    + +
    +
    diff --git a/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskRows.gsp b/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskRows.gsp index c4f053ae2f6..23438328277 100644 --- a/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskRows.gsp +++ b/guides/grails-htmx/v8/snippets/grails-app/views/task/_taskRows.gsp @@ -1,6 +1,4 @@ - - - +
  • No tasks yet. Add one above.
  • diff --git a/guides/grails-htmx/v8/snippets/grails-app/views/task/index.gsp b/guides/grails-htmx/v8/snippets/grails-app/views/task/index.gsp index c66b053edd9..27eb71c310d 100644 --- a/guides/grails-htmx/v8/snippets/grails-app/views/task/index.gsp +++ b/guides/grails-htmx/v8/snippets/grails-app/views/task/index.gsp @@ -9,28 +9,19 @@

    Tasks

    - <%-- Add a task --%> -
    - - -
    + <%-- Live search --%> + hx-trigger="keyup changed delay:300ms" + hx-target="#taskList" + hx-swap="innerHTML"/> - <%-- The list. HTMX swaps individual rows in/out of this
      . --%>
      diff --git a/guides/grails-multi-module/v8/guide/devReload.adoc b/guides/grails-multi-module/v8/guide/devReload.adoc new file mode 100644 index 00000000000..d44e67fbfc9 --- /dev/null +++ b/guides/grails-multi-module/v8/guide/devReload.adoc @@ -0,0 +1,66 @@ +Grails 8 already ships a supported cross-project reload path for plugin subprojects. You do not need the manual `bootRun` classpath patch as the primary recipe. + +The supported setup has two moving parts: + +* the plugin project publishes exploded classes and resources by applying `org.apache.grails.gradle.grails-exploded` +* the webapp consumes the plugin through `grails { plugins { ... } }`, which lets `bootRun` request that exploded variant automatically + +In `shared-core/build.gradle`, add the exploded plugin: + +[source,groovy] +---- +apply plugin: 'org.apache.grails.gradle.grails-exploded' +---- + +In each webapp `build.gradle`, keep the plugin dependency inside the `grails` block: + +[source,groovy] +---- +grails { + plugins { + implementation project(':shared-core') + } +} +---- + +Those two pieces line up exactly with: + +* `grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/exploded/GrailsExplodedPlugin.groovy` +* `grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/PluginDefiner.groovy` +* `grails-doc/src/en/guide/plugins/creatingAndInstallingPlugins.adoc` + +Now keep `shared-core` compiled in one shell and run whichever webapps you need in the others: + +[source,bash] +---- +./gradlew :shared-core:classes --continuous # shell 1 +./gradlew :webapp-customer:bootRun # shell 2 +./gradlew :webapp-admin:bootRun --args='--server.port=8081' # shell 3 +---- + +Save a file under `shared-core/grails-app/services/` or `shared-core/src/main/groovy/`, shell 1 recompiles it, and the running webapp restarts from the exploded output directories. No manual classpath surgery is required. + +What reloads cleanly versus what still needs a full restart: + +[cols="3,1,4"] +|=== +| Change | Reloads? | Reason + +| Service or controller method body | yes | DevTools restart picks up the new bean classes +| GSP under `shared-core/grails-app/views/` | yes | The view layer is resolved from plugin resources during development +| Static asset | yes | Served from source/build outputs in dev mode +| Add or change a domain class field | no | Hibernate metadata is built once at startup +| Edit `application.yml` | no | Configuration is read at startup +| Edit `*GrailsPlugin.groovy` | no | Plugin lifecycle hooks run during startup +|=== + +[NOTE] +==== +The older manual `bootRun { doFirst { classpath = ... } }` patch can still work because `bootRun` ultimately runs with a mutable classpath. But on Grails 8 it is now a fallback for unusual cases, not the guide's primary recipe, because the framework already includes a first-class exploded-plugin mechanism. +==== + +Files touched: + +- shared-core/build.gradle +- webapp-customer/build.gradle +- webapp-admin/build.gradle diff --git a/guides/grails-multi-module/v8/guide/extractingPlugin.adoc b/guides/grails-multi-module/v8/guide/extractingPlugin.adoc index 3e966c508a9..bb9fef24991 100644 --- a/guides/grails-multi-module/v8/guide/extractingPlugin.adoc +++ b/guides/grails-multi-module/v8/guide/extractingPlugin.adoc @@ -1,10 +1,18 @@ -Most multi-module layouts grow out of a monolith that already exists. The migration from "one Grails app with two areas" to "one shared plugin plus two webapps" is mechanical: +Most multi-module workspaces start from a monolith that already exists. The split is mechanical if you keep the ownership lines clear. -1. *Decide what the plugin owns.* Domain classes, GORM data services, security policy beans, and any tag library that emits content the customer and admin pages both render. Anything UI-shaped (controllers, GSPs, JSON views) stays in the consuming webapp. -2. *Generate a Grails 8 plugin* (the forge `web_plugin` type) into a new `shared-core/` directory next to the existing app. -3. *Move (`git mv`) the chosen files* out of `app/grails-app/...` into `shared-core/grails-app/...`. Keep package names. The history follows the rename. -4. *In `settings.gradle` of the existing app, ``include 'shared-core'``.* In the existing app's `build.gradle`, add `implementation project(':shared-core')`. Run `./gradlew build` - it should still pass. -5. *Generate the second webapp* (the forge `web` type) into `webapp-customer/`. Add `implementation project(':shared-core')` to its `build.gradle`. Move whichever controllers/views logically belong there from the original app. -6. *Rename the original app* to `webapp-admin/` (or whichever role it ended up playing). +1. Generate `shared-core` as a Grails `web_plugin`, not as a plain library project. +2. Move only genuinely shared code into the plugin: domain classes, GORM data services, shared taglibs, and reusable Spring beans. Leave `Application.groovy`, `UrlMappings.groovy`, controllers, GSPs, JSON views, and `BootStrap` classes in the webapps. +3. Keep or recreate the plugin descriptor class (`SharedCoreGrailsPlugin.groovy`). If the extracted code needs plugin ordering or watched resources, express that there using the normal `Plugin` conventions. +4. Wire the original app to the plugin with `grails { plugins { implementation project(':shared-core') } }`. If you want live reload while the split is in flight, also apply `org.apache.grails.gradle.grails-exploded` in `shared-core/build.gradle`. +5. Create the second webapp, wire the same plugin, and move the customer-facing or admin-facing controllers and views into their target module. +6. Choose one webapp to own database migrations and startup data changes. -The third commit on the timeline is a single commit that moves files; the fourth is the wiring change. Reviewers can see exactly what moved without unrelated diffs. +Keep package names stable while you move files. `git mv` plus unchanged packages makes the review readable and keeps history attached to the files that actually moved. + +Files touched: + +- settings.gradle +- shared-core/build.gradle +- shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy +- webapp-customer/build.gradle +- webapp-admin/build.gradle diff --git a/guides/grails-multi-module/v8/guide/failureModes.adoc b/guides/grails-multi-module/v8/guide/failureModes.adoc index fc433f0913d..2dd0c919213 100644 --- a/guides/grails-multi-module/v8/guide/failureModes.adoc +++ b/guides/grails-multi-module/v8/guide/failureModes.adoc @@ -1,12 +1,13 @@ -Three failure modes show up in real multi-module Grails layouts. Recognise them early, address them once. +Three failure modes show up again and again in real Grails multi-module workspaces. -* *Circular plugin dependencies.* If `shared-core` reaches "up" into either webapp - e.g. via a `MessageSource` lookup that depends on a webapp's `i18n/` - the build still passes locally (because the consumer is on the classpath at run time) but breaks the moment you try to publish `shared-core` to a Maven repo. Fix the direction: the plugin should never know about its consumers. +* *Circular module dependencies, disguised as plugin wiring.* The `dependsOn` and `loadAfter` properties on a Grails plugin descriptor are runtime ordering hints from `grails-core/src/main/groovy/grails/plugins/Plugin.groovy`. They do not replace Gradle project dependencies. If a webapp depends on `shared-core`, then `shared-core` must not depend back on that webapp. Keep the dependency arrow one-way: webapps depend on the plugin. -* *GORM mapping conflicts when the same domain class loads twice.* A common mistake: copying a domain class from `shared-core` into `webapp-admin` "just to add an admin-only field". GORM sees two `example.Book` classes on the classpath, registers both, and either silently picks one or fails at startup. The fix is always the same: the field belongs on `shared-core.Book`, with `nullable: true` and an admin-only setter. +* *Mapping collisions in the Hibernate model.* Grails 8 collects every domain artefact into the mapping context and Hibernate bootstrap path. You can see that in `grails-domain-class/src/main/groovy/org/grails/plugins/domain/support/DefaultMappingContextFactoryBean.groovy` and `grails-data-hibernate5/grails-plugin/src/main/groovy/grails/plugin/hibernate/HibernateGrailsPlugin.groovy`. If the plugin and a webapp both define separate entities that map to the same table or incompatible association metadata, startup fails or the schema becomes ambiguous. Keep one authoritative domain model in `shared-core`. -* *Two webapps disagree on the schema migration order.* If both webapps run Liquibase changesets at startup, one of them will be racing the other on first deploy. Pick one - typically `webapp-admin` - to own migrations; the other (`webapp-customer`) reads the migrated schema with `dbCreate: none`. Document the discipline; do not rely on lock acquisition from Liquibase for cross-app coordination. +* *Both webapps try to run startup migrations.* `grails-data-hibernate5/dbmigration/src/main/groovy/org/grails/plugins/databasemigration/DatabaseMigrationGrailsPlugin.groovy` runs Liquibase updates at startup when `grails.plugin.databasemigration.updateOnStart*` flags are enabled. Liquibase locking prevents corruption, but it still leaves startup order and deploy timing to chance. Pick one app to own migrations. Let the other app boot against an already-migrated schema. -[NOTE] -==== -*Cross-repo iteration.* If `shared-core` later moves to its own GitHub repository, `includeBuild('../shared-core')` in the webapps' `settings.gradle` still gives you live cross-iteration during development - the consumer compiles against the live source tree, not a published JAR. -==== +Files touched: + +- shared-core/build.gradle +- webapp-customer/build.gradle +- webapp-admin/build.gradle diff --git a/guides/grails-multi-module/v8/guide/generateModules.adoc b/guides/grails-multi-module/v8/guide/generateModules.adoc index 7d854e30b7e..58d2e34fad2 100644 --- a/guides/grails-multi-module/v8/guide/generateModules.adoc +++ b/guides/grails-multi-module/v8/guide/generateModules.adoc @@ -1,22 +1,41 @@ -Each of the three modules is a fresh forge zip. The plugin is the `web_plugin` application type; the two webapps are the `web` type: +Open link:https://start.grails.org[start.grails.org] in three browser tabs and generate three starters with the same baseline settings: JDK 21, Groovy, Gradle, Spock, and package `example`. -[source,bash] ----- -mkdir grails-multi-module && cd grails-multi-module && mkdir initial complete && cd complete +[cols="1,1,1",options="header"] +|=== +| Forge application type | UI label | Application name + +| `web_plugin` +| Web Plugin +| `shared-core` + +| `web` +| Web Application +| `webapp-customer` + +| `web` +| Web Application +| `webapp-admin` +|=== -# shared-core - the plugin that holds the shared domain model + services -curl -L -o p.zip "https://prev-snapshot.grails.org/create/web_plugin/example.shared?lang=GROOVY&build=GRADLE&test=SPOCK&javaVersion=JDK_21" -unzip p.zip && mv shared shared-core && rm p.zip +The naming here is straight from the Grails 8 forge source: `ApplicationType.WEB_PLUGIN` in `grails-forge/grails-forge-core/src/main/java/org/grails/forge/application/ApplicationType.java` and the `@Named("web_plugin")` registry in `grails-forge/grails-forge-core/src/main/java/org/grails/forge/application/WebPluginAvailableFeatures.java`. -# webapp-customer - read-only catalog -curl -L -o c.zip "https://prev-snapshot.grails.org/create/web/example.customer?lang=GROOVY&build=GRADLE&test=SPOCK&javaVersion=JDK_21" -unzip c.zip && mv customer webapp-customer && rm c.zip +Each downloaded archive uses the application name as the zip filename, so unzip the three archives under one parent directory: -# webapp-admin - back-office CRUD -curl -L -o a.zip "https://prev-snapshot.grails.org/create/web/example.admin?lang=GROOVY&build=GRADLE&test=SPOCK&javaVersion=JDK_21" -unzip a.zip && mv admin webapp-admin && rm a.zip +[source,bash] +---- +mkdir grails-multi-module +cd grails-multi-module + +unzip ~/Downloads/shared-core.zip +unzip ~/Downloads/webapp-customer.zip +unzip ~/Downloads/webapp-admin.zip ---- -Patch `compileJava.options.release = 17` to `21` in each of the three `build.gradle` files (the forge ships JDK 17 by default even when `javaVersion=JDK_21` is requested). +If you selected JDK 21 in the browser, keep the generated `compileJava.options.release = 21` lines exactly as they are. Grails Forge writes that value from the selected target JDK in `grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/build/gradle/templates/buildGradle.rocker.raw`, so there is no Grails 8 era "patch 17 to 21" cleanup step anymore. + +Files touched: -Each subdirectory is currently a stand-alone Grails project. The next chapter wires them into a single multi-project build. +- shared-core/build.gradle +- shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy +- webapp-customer/build.gradle +- webapp-admin/build.gradle diff --git a/guides/grails-multi-module/v8/guide/gettingStarted.adoc b/guides/grails-multi-module/v8/guide/gettingStarted.adoc index 49ea6c91987..2039238614e 100644 --- a/guides/grails-multi-module/v8/guide/gettingStarted.adoc +++ b/guides/grails-multi-module/v8/guide/gettingStarted.adoc @@ -1,5 +1,9 @@ -In this guide you will lay out a real-world Grails 8 multi-project build: one shared Grails Plugin (`shared-core`) holding the domain model, GORM services, and security configuration, plus two separate Grails web apps (`webapp-customer` and `webapp-admin`) that consume the plugin. +In this guide you will assemble a Grails 8 multi-project workspace with three modules: one shared Grails plugin (`shared-core`) and two Grails web applications (`webapp-customer`, `webapp-admin`). -This is the canonical shape for products with a customer-facing UI and an admin/back-office UI: the two apps share their data model exactly but differ in screens, authorization rules, and deployment cadence. Each webapp builds its own `bootJar` with the plugin embedded, so the two apps deploy independently while always staying in sync on the data model. +The shared module owns the domain model, GORM data services, and plugin-level configuration. The two webapps own their own controllers, views, and deployment lifecycle, but both run against the same compiled plugin jar in production and the same exploded plugin classes in development. -This guide targets Apache Grails 8 / JDK 21. +This is the layout Grails 8 is built to support: plugin code lives in a plain jar, webapps build their own `bootJar`, and Grails plus Hibernate discover domain artefacts from the plugin at runtime. + +Files touched: + +- None yet diff --git a/guides/grails-multi-module/v8/guide/helpWithGrails.adoc b/guides/grails-multi-module/v8/guide/helpWithGrails.adoc index e062f614b1a..d61a37c2144 100644 --- a/guides/grails-multi-module/v8/guide/helpWithGrails.adoc +++ b/guides/grails-multi-module/v8/guide/helpWithGrails.adoc @@ -1 +1,5 @@ include::{commondir}/common-helpWithGrails.adoc[] + +Files touched: + +- None diff --git a/guides/grails-multi-module/v8/guide/howto.adoc b/guides/grails-multi-module/v8/guide/howto.adoc index 214f2de8d13..09b9e4c06cb 100644 --- a/guides/grails-multi-module/v8/guide/howto.adoc +++ b/guides/grails-multi-module/v8/guide/howto.adoc @@ -4,12 +4,18 @@ You can either build the workspace from scratch by following the chapters or ski ---- git clone -b grails8 https://github.com/grails-guides/grails-multi-module.git cd grails-multi-module/complete -./gradlew :webapp-customer:bootRun # http://localhost:8080 -# in a second shell, with the customer port freed: -./gradlew :webapp-admin:bootRun # http://localhost:8080 +./gradlew :webapp-customer:bootRun +# In a second shell: +./gradlew :webapp-admin:bootRun --args='--server.port=8081' ---- The repository contains two top-level directories: * `initial/` - intentionally empty; the README points at the three forge URLs that produce each module starter. * `complete/` - the assembled three-module workspace this guide builds. + +Each chapter ends with the paths you should have touched by that point, so you can compare your workspace against the finished sample. + +Files touched: + +- None yet diff --git a/guides/grails-multi-module/v8/guide/requirements.adoc b/guides/grails-multi-module/v8/guide/requirements.adoc index 53a90b001e6..f1dc76059cc 100644 --- a/guides/grails-multi-module/v8/guide/requirements.adoc +++ b/guides/grails-multi-module/v8/guide/requirements.adoc @@ -1,4 +1,9 @@ To complete this guide you will need: * JDK 21 +* Internet access to download three starters from link:https://start.grails.org[start.grails.org] * About 45 minutes + +Files touched: + +- None yet diff --git a/guides/grails-multi-module/v8/guide/rootBuild.adoc b/guides/grails-multi-module/v8/guide/rootBuild.adoc index 48c38528536..96f82bc7d73 100644 --- a/guides/grails-multi-module/v8/guide/rootBuild.adoc +++ b/guides/grails-multi-module/v8/guide/rootBuild.adoc @@ -1,4 +1,4 @@ -A root `settings.gradle` and a root `build.gradle` turn three side-by-side Grails projects into one Gradle build: +A root `settings.gradle` and a root `build.gradle` turn the three generated starters into one Gradle build: [source,groovy] .settings.gradle @@ -12,10 +12,20 @@ include::../snippets/settings.gradle[] include::../snippets/build.gradle[] ---- -Three patterns to point at: +This root pattern intentionally mirrors two Grails 8 authority sources: -* `rootProject.name = 'grails-multi-module'` is what `./gradlew projects` shows at the top of the tree. Without it, Gradle uses the directory name, which is fine for this sample but may not match your repo name in a real layout. -* The `subprojects { repositories { ... } }` block is where every module's Apache Grails snapshot + staging Maven repos live. Avoid the temptation to also put `dependencies { ... }` here - you want each module to declare what it needs explicitly. -* `plugins.withId('groovy') { tasks.withType(Test).configureEach { useJUnitPlatform() } }` ensures the JUnit 5 runner is used in every module that loads the Groovy plugin (which is every Grails module). Without it, only the Grails 8 starter's own build.gradle would have it; the plugin module would default to the Vintage runner. +* `grails-forge/grails-forge-core/src/main/java/org/grails/forge/build/gradle/GradleRepository.java` for the default starter repositories. +* `grails-profiles/base/skeleton/build.gradle` plus `grails-forge/grails-forge-core/src/main/java/org/grails/forge/feature/build/gradle/templates/buildGradle.rocker.raw` for the per-project `tasks.withType(Test).configureEach { useJUnitPlatform() }` block. -The per-module `build.gradle` files keep their dependency lists. Sharing too aggressively at root level (e.g. `dependencies { implementation '...' }` inside `subprojects`) creates surprise transitive dependencies that are hard to debug. +Three details matter: + +* `rootProject.name = 'grails-multi-module'` gives the whole workspace a stable Gradle identity. +* `subprojects { repositories { ... } }` centralises the same Maven repos every generated Grails 8 module would otherwise repeat. On the live `8.0.x` line that includes filtered Apache snapshot and staging repos, because the current forge default still adds them for snapshot Grails builds. +* `tasks.withType(Test).configureEach { useJUnitPlatform() }` is the exact Grails 8 starter pattern. Lifting that block to the root keeps `test` and `integrationTest` aligned across all three modules without changing their dependency lists. + +Do not move ordinary library dependencies to the root. The generated module builds already know which Grails plugins and starters they need. This root file should only carry cross-cutting build policy. + +Files touched: + +- settings.gradle +- build.gradle diff --git a/guides/grails-multi-module/v8/guide/runningEach.adoc b/guides/grails-multi-module/v8/guide/runningEach.adoc index 1b0561c883f..a7ac7ec12c1 100644 --- a/guides/grails-multi-module/v8/guide/runningEach.adoc +++ b/guides/grails-multi-module/v8/guide/runningEach.adoc @@ -4,22 +4,29 @@ Run each webapp independently: ---- ./gradlew :webapp-customer:bootRun # In another shell: -./gradlew :webapp-admin:bootRun +./gradlew :webapp-admin:bootRun --args='--server.port=8081' ---- -Both default to port 8080, so you cannot run them at the same time without overrides. For dev work you have two cheap options: +Both generated webapps default to port 8080. Grails 8 documents two accurate override styles: -* `./gradlew :webapp-admin:bootRun --args='--server.port=8081'` - one-shot port override. -* Set `server.port: 8081` in `webapp-admin/grails-app/conf/application.yml` so the admin app permanently uses 8081 in dev. The customer app keeps 8080. +* `--args='--server.port=8081'` in `grails-doc/src/en/ref/Command Line/bootRun.adoc` +* `-Dgrails.server.port=8081` in `grails-doc/src/en/guide/gettingStarted/runningAndDebuggingAnApplication.adoc` -Production deployment uses `bootJar` instead of `bootRun`: +Use either form, but keep it consistent across your team. + +Production packaging uses `bootJar` for the two webapps and `jar` for the plugin: [source,bash] ---- +./gradlew :shared-core:jar ./gradlew :webapp-customer:bootJar ./gradlew :webapp-admin:bootJar -ls webapp-customer/build/libs/ # webapp-customer-0.1.jar -ls webapp-admin/build/libs/ # webapp-admin-0.1.jar ---- -Each `bootJar` embeds the `shared-core` plugin classes (and only the *transitive* runtime dependencies the plugin actually needs). The two jars deploy independently to different hosts, different containers, different release cadences. The data model they share is the byte-for-byte identical compiled `Book.class` produced once by the `shared-core:compileGroovy` task and packaged into both. +`shared-core` does not produce a deployable `bootJar`. `grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsPluginGradlePlugin.groovy` disables `bootJar` and keeps the plain jar as the plugin artifact. The two webapps are the only deployable applications in this workspace. + +For the day-to-day inner loop where you save a file in `shared-core` and want a running `bootRun` to pick it up, the next chapter covers the Grails 8 supported reload path. + +Files touched: + +- None, unless you choose a fixed port in `webapp-admin/grails-app/conf/application.yml` diff --git a/guides/grails-multi-module/v8/guide/sharedCore.adoc b/guides/grails-multi-module/v8/guide/sharedCore.adoc index 1d694352317..c48b0b9eb76 100644 --- a/guides/grails-multi-module/v8/guide/sharedCore.adoc +++ b/guides/grails-multi-module/v8/guide/sharedCore.adoc @@ -1,4 +1,16 @@ -The plugin module holds the domain model and the GORM data services. A simple Book domain in `shared-core/grails-app/domain/example/Book.groovy`: +`shared-core` is a Grails web plugin, not a plain Groovy library. Keep the generated plugin descriptor class: + +[source,groovy] +.shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy +---- +include::../snippets/shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy[] +---- + +That class name and location are conventions, not decoration. Grails discovers plugins from a class under `src/main/groovy//` whose name ends in `GrailsPlugin` and whose base class is `grails.plugins.Plugin`. The lifecycle hooks available to you - `doWithSpring`, `doWithApplicationContext`, `doWithDynamicMethods`, `onChange`, `onConfigChange`, and `onShutdown` - come from `grails-core/src/main/groovy/grails/plugins/Plugin.groovy`. + +Core Grails plugins use additional descriptor conventions as plain properties on that class: `profiles`, `watchedResources`, `observe`, `loadAfter`, and `pluginExcludes`. See `grails-domain-class/src/main/groovy/org/grails/plugins/domain/DomainClassGrailsPlugin.groovy` and `grails-data-hibernate5/grails-plugin/src/main/groovy/grails/plugin/hibernate/HibernateGrailsPlugin.groovy` for live Grails 8 examples. + +The plugin module then owns the shared domain model and GORM data services. Start with a simple `Book` domain: [source,groovy] .shared-core/grails-app/domain/example/Book.groovy @@ -6,7 +18,7 @@ The plugin module holds the domain model and the GORM data services. A simple Bo include::../snippets/shared-core/grails-app/domain/example/Book.groovy[] ---- -And a GORM data service that both webapps consume: +And a GORM data service that both webapps inject: [source,groovy] .shared-core/grails-app/services/example/BookService.groovy @@ -14,9 +26,19 @@ And a GORM data service that both webapps consume: include::../snippets/shared-core/grails-app/services/example/BookService.groovy[] ---- -Two things to highlight: +The domain auto-discovery story in Grails 8 is now easy to verify in source: + +* `grails-core/src/main/groovy/org/grails/core/artefact/DomainClassArtefactHandler.java` recognises domain artefacts from `grails-app/domain` and `@Entity`. +* `grails-domain-class/src/main/groovy/org/grails/plugins/domain/support/DefaultMappingContextFactoryBean.groovy` adds every `DomainClassArtefactHandler.TYPE` artefact to the mapping context. +* `grails-data-hibernate5/grails-plugin/src/main/groovy/grails/plugin/hibernate/HibernateGrailsPlugin.groovy` collects those same artefacts from `grailsApplication.getArtefacts(...)` and hands them to `HibernateDatastoreSpringInitializer`. + +That is why a sibling plugin's `Book` domain becomes part of the consuming webapp's Hibernate model without any manual registration step. + +Keep `apply plugin: 'org.apache.grails.gradle.grails-plugin'` in `shared-core/build.gradle`. In `grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/GrailsPluginGradlePlugin.groovy` that Gradle plugin applies `java-library`, disables `bootJar` for the plugin project, packages a plain jar, and prepares plugin resources. If you remove it, `shared-core` stops behaving like a Grails plugin build. -* The `@Service(Book)` interface is auto-implemented by GORM at compile time. The webapps inject `BookService bookService` directly; there is no `BookServiceImpl` to write. -* The package is `example`, not `shared` or `core`. This is the *domain* package - it is what the data layer is "about", not what module owns it. The two webapps' UI controllers will live in `customer.*` and `admin.*` so the package separation makes the layering explicit. +Files touched: -The plugin's `build.gradle` should already contain `apply plugin: 'org.apache.grails.gradle.grails-plugin'` (the forge generates this for `web_plugin`). That is what registers the plugin's domain classes and services for GORM auto-discovery in any consumer. +- shared-core/build.gradle +- shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy +- shared-core/grails-app/domain/example/Book.groovy +- shared-core/grails-app/services/example/BookService.groovy diff --git a/guides/grails-multi-module/v8/guide/testingPerModule.adoc b/guides/grails-multi-module/v8/guide/testingPerModule.adoc index c3df0bbb678..8e9fc508085 100644 --- a/guides/grails-multi-module/v8/guide/testingPerModule.adoc +++ b/guides/grails-multi-module/v8/guide/testingPerModule.adoc @@ -1,23 +1,34 @@ Tests live with the module they cover. -* `shared-core` has its own integration suite (`shared-core/src/integration-test/`) that runs against an in-memory H2 datasource and exercises the domain constraints + GORM data service queries directly. No webapp boots. -* Each webapp has its own functional suite (`webapp-customer/src/integration-test/`, `webapp-admin/src/integration-test/`) that boots the corresponding `bootJar` and drives it with Geb specs. -* Unit tests (Spock-based, no Spring context) live in each module's `src/test/`. +* `shared-core` keeps its own unit and integration tests focused on the domain model and GORM data services. +* `webapp-customer` keeps its own web and functional tests for the customer-facing UI. +* `webapp-admin` keeps its own CRUD and admin-side functional tests. Run them granularly during development: [source,bash] ---- -./gradlew :shared-core:integrationTest -./gradlew :webapp-customer:integrationTest -./gradlew :webapp-admin:integrationTest +./gradlew :shared-core:test :shared-core:integrationTest +./gradlew :webapp-customer:test :webapp-customer:integrationTest +./gradlew :webapp-admin:test :webapp-admin:integrationTest ---- -Or fan out the whole test suite from the root: +Or fan out the whole suite from the workspace root: [source,bash] ---- ./gradlew test integrationTest ---- -The matching link:../grails-github-actions-cicd/8/guide/index.html[GitHub Actions CI/CD guide] shows how to model these as separate jobs in a single workflow so a failing functional test in `webapp-admin` does not stop the `shared-core` suite from reporting its result. +Because the root `build.gradle` applies `tasks.withType(Test).configureEach { useJUnitPlatform() }`, every `Test` task in the workspace follows the same Grails 8 starter pattern as `grails-profiles/base/skeleton/build.gradle`. + +The matching link:../grails-github-actions-cicd/8/guide/index.html[GitHub Actions CI/CD guide] shows how to split these module suites into separate jobs so a failing admin functional test does not hide a passing `shared-core` result. + +Files touched: + +- shared-core/src/test/groovy/** +- shared-core/src/integration-test/groovy/** +- webapp-customer/src/test/groovy/** +- webapp-customer/src/integration-test/groovy/** +- webapp-admin/src/test/groovy/** +- webapp-admin/src/integration-test/groovy/** diff --git a/guides/grails-multi-module/v8/guide/webapps.adoc b/guides/grails-multi-module/v8/guide/webapps.adoc index 156bd7c7050..460203b7e5b 100644 --- a/guides/grails-multi-module/v8/guide/webapps.adoc +++ b/guides/grails-multi-module/v8/guide/webapps.adoc @@ -1,13 +1,20 @@ -Both webapps consume `shared-core` via a single line in their `dependencies` block: +Each webapp should consume `shared-core` through the Grails plugin block in its `build.gradle`, not through a plain `dependencies { ... }` entry: [source,groovy] +.webapp-customer/build.gradle and webapp-admin/build.gradle ---- -implementation project(':shared-core') +grails { + plugins { + implementation project(':shared-core') + } +} ---- -That line goes immediately under `profile "org.apache.grails.profiles:web"` in each webapp's `build.gradle`. Once it is in place, the plugin's `Book` domain and `BookService` data service are visible to the consumer's controllers, services, and GSPs at compile time, *and* GORM treats the consumer's persistent context as the place those entities live at runtime. +That is the Grails-aware multi-project path documented in `grails-doc/src/en/guide/plugins/creatingAndInstallingPlugins.adoc`. Under the hood, `grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/core/PluginDefiner.groovy` tags project plugin dependencies so `bootRun` can request the exploded plugin variant, while `grails-gradle/plugins/src/main/groovy/org/grails/gradle/plugin/exploded/GrailsExplodedPlugin.groovy` publishes that exploded classes/resources variant from the plugin project. A plain `dependencies { implementation project(':shared-core') }` declaration still compiles, but it bypasses that built-in reload path. -Each webapp owns its own controller package. The customer webapp uses `customer.*`: +Once wired, the plugin's `example.Book` domain and `example.BookService` data service are available to both webapps at compile time and runtime. + +The customer webapp owns its own controller package: [source,groovy] .webapp-customer/grails-app/controllers/customer/CatalogController.groovy @@ -15,7 +22,7 @@ Each webapp owns its own controller package. The customer webapp uses `customer. include::../snippets/webapp-customer/grails-app/controllers/customer/CatalogController.groovy[] ---- -The admin webapp uses `admin.*`: +The admin webapp owns a different controller package: [source,groovy] .webapp-admin/grails-app/controllers/admin/BookController.groovy @@ -23,10 +30,15 @@ The admin webapp uses `admin.*`: include::../snippets/webapp-admin/grails-app/controllers/admin/BookController.groovy[] ---- -Three patterns to highlight: +Three patterns to keep: + +* Both controllers import `example.Book` and `example.BookService` from the plugin. The shared data model still has exactly one owner. +* The customer-side `CatalogController` is intentionally hand-rolled and read-only. +* The admin-side `BookController` extends `RestfulController` and gets the CRUD-oriented web surface that belongs only in the admin app. -* Both controllers reference `example.Book` and `example.BookService` from the plugin. Neither one duplicates a single line of domain or service code. -* The customer-side `CatalogController` is hand-rolled; it deliberately does not extend `RestfulController`. Customers should not see `/v1/books` (POST/PUT/DELETE). -* The admin-side `BookController` extends `RestfulController`. The full CRUD surface comes for free; the admin webapp picks it up by reference. +Files touched: -The package separation (`customer.*` vs `admin.*` for controllers, `example.*` for domain/service) is not enforced by Grails; it is a convention. But it is the convention that keeps refactors honest: a piece of code that lives under `example.*` belongs to the data layer, and any UI concern that creeps in there (a controller, a tag library, a flash-message string) is by definition misplaced. +- webapp-customer/build.gradle +- webapp-admin/build.gradle +- webapp-customer/grails-app/controllers/customer/CatalogController.groovy +- webapp-admin/grails-app/controllers/admin/BookController.groovy diff --git a/guides/grails-multi-module/v8/guide/whatYouWillBuild.adoc b/guides/grails-multi-module/v8/guide/whatYouWillBuild.adoc index 7c6825fb645..ff24d375b92 100644 --- a/guides/grails-multi-module/v8/guide/whatYouWillBuild.adoc +++ b/guides/grails-multi-module/v8/guide/whatYouWillBuild.adoc @@ -5,19 +5,24 @@ By the end of the guide your workspace will look like: grails-multi-module/ |-- settings.gradle |-- build.gradle -|-- shared-core/ # Grails Plugin +|-- shared-core/ # Grails web plugin, packaged as a plain jar | |-- build.gradle +| |-- src/main/groovy/example/SharedCoreGrailsPlugin.groovy | `-- grails-app/ | |-- domain/example/Book.groovy | `-- services/example/BookService.groovy |-- webapp-customer/ # Grails web app (read-only catalog) -| |-- build.gradle # `implementation project(':shared-core')` +| |-- build.gradle # `grails { plugins { implementation project(':shared-core') } }` | `-- grails-app/ | `-- controllers/customer/CatalogController.groovy `-- webapp-admin/ # Grails web app (CRUD) - |-- build.gradle # `implementation project(':shared-core')` + |-- build.gradle # `grails { plugins { implementation project(':shared-core') } }` `-- grails-app/ `-- controllers/admin/BookController.groovy ---- -Three modules, one root, two webapps that build to two `bootJar`s, one plugin every consumer reuses. Each webapp can be `bootRun` independently; both can be built together with `./gradlew build`. +Three modules, one root, two deployable `bootJar`s, and one shared plugin jar. `shared-core` is not a third application. It is the reusable Grails plugin that both webapps compile against, package, and reload from during development. + +Files touched: + +- None yet diff --git a/guides/grails-multi-module/v8/snippets/build.gradle b/guides/grails-multi-module/v8/snippets/build.gradle index 49533f2b59b..2c72ef8d3a6 100644 --- a/guides/grails-multi-module/v8/snippets/build.gradle +++ b/guides/grails-multi-module/v8/snippets/build.gradle @@ -1,9 +1,7 @@ /* - * Root build script. Per-module build.gradle files keep their own - * dependency lists; this root file only carries cross-cutting concerns: - * the JDK release level, the plain-old `useJUnitPlatform()` for every - * Test task, and the same Apache Grails snapshot repositories every - * module needs. + * Root build script. + * Keep per-module dependencies in the generated Grails projects. + * This file only centralizes repositories and JUnit Platform. */ subprojects { @@ -14,8 +12,6 @@ subprojects { url = 'https://repository.apache.org/content/groups/snapshots' content { includeVersionByRegex('org[.]apache[.]grails.*', '.*', '.*-SNAPSHOT') - } - content { includeVersionByRegex('org[.]apache[.]groovy.*', 'groovy.*', '.*-SNAPSHOT') } mavenContent { snapshotsOnly() } @@ -24,17 +20,13 @@ subprojects { url = 'https://repository.apache.org/content/groups/staging' content { includeVersionByRegex('org[.]apache[.]grails[.]gradle', 'grails-publish', '.*') - } - content { includeVersionByRegex('org[.]apache[.]groovy.*', 'groovy.*', '.*') } mavenContent { releasesOnly() } } } - plugins.withId('groovy') { - tasks.withType(Test).configureEach { - useJUnitPlatform() - } + tasks.withType(Test).configureEach { + useJUnitPlatform() } } diff --git a/guides/grails-multi-module/v8/snippets/settings.gradle b/guides/grails-multi-module/v8/snippets/settings.gradle index a8cfc8f8639..ba025707f0e 100644 --- a/guides/grails-multi-module/v8/snippets/settings.gradle +++ b/guides/grails-multi-module/v8/snippets/settings.gradle @@ -2,13 +2,12 @@ * Multi-project root settings file. * * Three modules participate: - * shared-core : Grails Plugin holding the domain model, GORM services, - * and security-config bits the two webapps share. + * shared-core : Grails plugin holding the domain model and GORM services. * webapp-customer : customer-facing Grails web app (read-only catalog). * webapp-admin : back-office Grails web app with full CRUD. * - * Both webapps depend on shared-core via `implementation project(':shared-core')`. - * GORM picks up the plugin's domain classes automatically. + * Both webapps consume shared-core via their `grails { plugins { ... } }` block + * so bootRun can request the exploded plugin variant in development. */ rootProject.name = 'grails-multi-module' diff --git a/guides/grails-multi-module/v8/snippets/shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy b/guides/grails-multi-module/v8/snippets/shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy new file mode 100644 index 00000000000..24c0b63321b --- /dev/null +++ b/guides/grails-multi-module/v8/snippets/shared-core/src/main/groovy/example/SharedCoreGrailsPlugin.groovy @@ -0,0 +1,29 @@ +package example + +import grails.plugins.Plugin + +class SharedCoreGrailsPlugin extends Plugin { + + def profiles = ['web'] + + Closure doWithSpring() { + { -> + // Optional shared beans go here. + } + } + + void doWithDynamicMethods() { + } + + void doWithApplicationContext() { + } + + void onChange(Map event) { + } + + void onConfigChange(Map event) { + } + + void onShutdown(Map event) { + } +} diff --git a/guides/grails-rest-library/v8/guide/createApp.adoc b/guides/grails-rest-library/v8/guide/createApp.adoc index f939c0a73a1..c82acf5cb69 100644 --- a/guides/grails-rest-library/v8/guide/createApp.adoc +++ b/guides/grails-rest-library/v8/guide/createApp.adoc @@ -1 +1 @@ -Generate a fresh Apache Grails 8 *Rest API* application from link:https://prev-snapshot.grails.org[prev-snapshot.grails.org]. Pick the `rest_api` application type (not `web`); it ships JSON views by default and skips the GSP view layer the regular `web` profile drags along. +Generate a fresh Apache Grails 8 *Rest API* application from link:https://start.grails.org[start.grails.org]. Pick the `rest_api` application type (not `web`); it ships JSON views by default and skips the GSP view layer the regular `web` profile drags along. diff --git a/guides/grails-rest-library/v8/guide/download.adoc b/guides/grails-rest-library/v8/guide/download.adoc index 8e5404730d8..391d17cd9d0 100644 --- a/guides/grails-rest-library/v8/guide/download.adoc +++ b/guides/grails-rest-library/v8/guide/download.adoc @@ -1,9 +1,3 @@ -[source,bash] ----- -curl -L -o library.zip \ - "https://prev-snapshot.grails.org/create/rest_api/example.library?lang=GROOVY&build=GRADLE&test=SPOCK&javaVersion=JDK_21&features=postgres,testcontainers,views-json,database-migration" -unzip library.zip -cd library ----- +Open link:https://start.grails.org[start.grails.org], pick the `rest_api` profile, name the application `library`, set the package to `example`, choose JDK 21, tick the `postgres`, `testcontainers`, `views-json`, and `database-migration` features, and download the generated zip. Unzip it and `cd` into the `library` directory. The `rest_api` starter is leaner than the `web` starter: no asset pipeline, no GSP, no Bootstrap webjars. The `views-json` feature adds the `.gson` view support and the matching `org.apache.grails:grails-views-gson` dependency. diff --git a/guides/grails-rest-library/v8/guide/howto.adoc b/guides/grails-rest-library/v8/guide/howto.adoc index 1fc06895855..ddee0eddf48 100644 --- a/guides/grails-rest-library/v8/guide/howto.adoc +++ b/guides/grails-rest-library/v8/guide/howto.adoc @@ -8,4 +8,4 @@ cd grails-rest-library/complete curl -s http://localhost:8080/v1/books | jq ---- -`initial/` is the vanilla Grails 8 `rest_api` starter from `https://prev-snapshot.grails.org` (with the `postgres`, `testcontainers`, `views-json`, and `database-migration` features). `complete/` adds the Book + Author domain model, the controllers, the JSON views, and the bootstrap data. +`initial/` is the vanilla Grails 8 `rest_api` starter from link:https://start.grails.org[start.grails.org] (with the `postgres`, `testcontainers`, `views-json`, and `database-migration` features). `complete/` adds the Book + Author domain model, the controllers, the JSON views, and the bootstrap data. diff --git a/guides/grails-rest-library/v8/snippets/grails-app/conf/application.yml b/guides/grails-rest-library/v8/snippets/grails-app/conf/application.yml new file mode 100644 index 00000000000..807d13e3c05 --- /dev/null +++ b/guides/grails-rest-library/v8/snippets/grails-app/conf/application.yml @@ -0,0 +1,4 @@ +grails: + plugin: + databasemigration: + updateOnStart: true diff --git a/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/AuthorController.groovy b/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/AuthorController.groovy index 3c51d83e3b8..1688ac26aed 100644 --- a/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/AuthorController.groovy +++ b/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/AuthorController.groovy @@ -6,18 +6,19 @@ class AuthorController extends RestfulController { static responseFormats = ['json'] - AuthorController() { super(Author) } + AuthorController() { + super(Author) + } - /** - * GET /v1/authors?max=20&offset=0&sort=name&order=asc - * - * Bound `max` defaults to 25 and is hard-capped at 100 so a paginating - * client cannot ask for the full table in one request. `offset`, - * `sort`, and `order` flow through to the GORM query unchanged. - */ @Override - protected List listAllResources(Map params) { - params.max = Math.min(params.int('max', 25), 100) - Author.list(params) + def index(Integer max) { + if (max != null && max < 0) { + max = null + } + + params.max = Math.min(max ?: 25, 100) + params.offset = Math.max(params.int('offset', 0), 0) + + respond listAllResources(params), model: [authorCount: countResources()] } } diff --git a/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/BookController.groovy b/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/BookController.groovy index c5957679782..80290b7bb63 100644 --- a/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/BookController.groovy +++ b/guides/grails-rest-library/v8/snippets/grails-app/controllers/example/BookController.groovy @@ -3,53 +3,106 @@ package example import grails.gorm.transactions.Transactional import grails.rest.RestfulController import org.springframework.http.HttpStatus +import org.springframework.validation.FieldError class BookController extends RestfulController { static responseFormats = ['json'] - BookController() { super(Book) } + BookController() { + super(Book) + } + + @Override + def index(Integer max) { + if (max != null && max < 0) { + max = null + } + + params.max = Math.min(max ?: 25, 100) + params.offset = Math.max(params.int('offset', 0), 0) + + respond listAllResources(params), model: [bookCount: countResources()] + } @Override protected List listAllResources(Map params) { - params.max = Math.min(params.int('max', 25), 100) - Book.list(params) + Long authorId = params.long('author') + Map queryParams = [max: params.max, offset: params.offset, sort: params.sort, order: params.order].findAll { it.value != null } + + if (authorId != null) { + return Book.where { + author.id == authorId + }.list(queryParams) + } + + Book.list(queryParams) + } + + @Override + protected Integer countResources() { + Long authorId = params.long('author') + if (authorId != null) { + return Book.where { + author.id == authorId + }.count() as Integer + } + + Book.count() } - /** - * POST /v1/books/bulk - * - * All-or-nothing bulk create. The whole request rolls back on the - * first validation failure; the response is a structured 422 with - * the field errors of every failing book in the batch. - */ @Transactional def bulkCreate() { - if (!request.JSON instanceof List) { + if (!(request.JSON instanceof List)) { response.status = HttpStatus.UNPROCESSABLE_ENTITY.value() - respond errors: [[message: 'request body must be a JSON array of Book objects']] + respond([ + errors: [[code: 'invalid.body', message: 'Request body must be a JSON array of book objects']] + ]) return } - List drafts = request.JSON.collect { json -> new Book(json as Map) } - List failures = [] - drafts.eachWithIndex { Book b, int i -> - if (!b.validate()) { - failures << [index: i, errors: b.errors.fieldErrors.collect { - [field: it.field, code: it.code, message: it.defaultMessage] - }] + List drafts = request.JSON.collect { Object json -> + Book draft = new Book() + bindData(draft, json as Map) + draft + } + + List> failures = [] + drafts.eachWithIndex { Book draft, int index -> + draft.validate() + if (draft.hasErrors()) { + failures << [ + index : index, + errors: draft.errors.allErrors.collect { error -> + Map payload = [ + object : error.objectName, + code : error.code, + message: message(error: error) + ] + if (error instanceof FieldError) { + payload.field = error.field + payload.rejectedValue = error.rejectedValue + payload.bindingFailure = error.bindingFailure + } + payload + } + ] } } if (failures) { transactionStatus.setRollbackOnly() response.status = HttpStatus.UNPROCESSABLE_ENTITY.value() - respond books: failures + respond([books: failures]) return } - drafts*.save(flush: true) - response.status = HttpStatus.CREATED.value() - respond books: drafts + drafts.each { Book book -> + book.save(flush: true, failOnError: true) + } + + params.max = Math.max(drafts.size(), 1) + params.offset = 0 + respond drafts, [status: HttpStatus.CREATED, view: 'index', model: [bookCount: drafts.size()]] } } diff --git a/guides/grails-rest-library/v8/snippets/grails-app/domain/example/Book.groovy b/guides/grails-rest-library/v8/snippets/grails-app/domain/example/Book.groovy index 3547c7ccf00..7c1da607b48 100644 --- a/guides/grails-rest-library/v8/snippets/grails-app/domain/example/Book.groovy +++ b/guides/grails-rest-library/v8/snippets/grails-app/domain/example/Book.groovy @@ -14,7 +14,7 @@ class Book { static constraints = { title blank: false, maxSize: 255 - isbn blank: false, unique: true, matches: /^(97(8|9))?\d{9}(\d|X)$/ + isbn blank: false, unique: true, maxSize: 20, matches: /^(97(8|9))?\d{9}(\d|X)$/ pageCount nullable: true, min: 1 publishedOn nullable: true } diff --git a/guides/grails-rest-library/v8/snippets/grails-app/init/example/BootStrap.groovy b/guides/grails-rest-library/v8/snippets/grails-app/init/example/BootStrap.groovy index 4e231fb0db0..3b5e6c087b7 100644 --- a/guides/grails-rest-library/v8/snippets/grails-app/init/example/BootStrap.groovy +++ b/guides/grails-rest-library/v8/snippets/grails-app/init/example/BootStrap.groovy @@ -1,6 +1,5 @@ package example -import grails.gorm.transactions.Transactional import grails.util.Environment import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -9,7 +8,6 @@ class BootStrap { private static final Logger log = LoggerFactory.getLogger(BootStrap) - @Transactional Closure init = { servletContext -> if (Environment.current == Environment.PRODUCTION) { @@ -21,23 +19,25 @@ class BootStrap { return } - Author tolkien = new Author(name: 'J.R.R. Tolkien', - biography: 'English writer and philologist (1892-1973).', - dateOfBirth: Date.parse('yyyy-MM-dd', '1892-01-03')) - .save(failOnError: true) - Author leguin = new Author(name: 'Ursula K. Le Guin', - biography: 'American author (1929-2018).', - dateOfBirth: Date.parse('yyyy-MM-dd', '1929-10-21')) - .save(failOnError: true) - - new Book(author: tolkien, title: 'The Hobbit', isbn: '9780547928227', pageCount: 310, - publishedOn: Date.parse('yyyy-MM-dd', '1937-09-21')).save(failOnError: true) - new Book(author: tolkien, title: 'The Fellowship of the Ring', isbn: '9780547928210', pageCount: 423, - publishedOn: Date.parse('yyyy-MM-dd', '1954-07-29')).save(failOnError: true) - new Book(author: leguin, title: 'A Wizard of Earthsea', isbn: '9780547851402', pageCount: 205, - publishedOn: Date.parse('yyyy-MM-dd', '1968-11-01')).save(failOnError: true) - new Book(author: leguin, title: 'The Left Hand of Darkness', isbn: '9780441478125', pageCount: 304, - publishedOn: Date.parse('yyyy-MM-dd', '1969-03-01')).save(failOnError: true) + Author.withTransaction { + Author tolkien = new Author(name: 'J.R.R. Tolkien', + biography: 'English writer and philologist (1892-1973).', + dateOfBirth: Date.parse('yyyy-MM-dd', '1892-01-03')) + .save(failOnError: true) + Author leguin = new Author(name: 'Ursula K. Le Guin', + biography: 'American author (1929-2018).', + dateOfBirth: Date.parse('yyyy-MM-dd', '1929-10-21')) + .save(failOnError: true) + + new Book(author: tolkien, title: 'The Hobbit', isbn: '9780547928227', pageCount: 310, + publishedOn: Date.parse('yyyy-MM-dd', '1937-09-21')).save(failOnError: true) + new Book(author: tolkien, title: 'The Fellowship of the Ring', isbn: '9780547928210', pageCount: 423, + publishedOn: Date.parse('yyyy-MM-dd', '1954-07-29')).save(failOnError: true) + new Book(author: leguin, title: 'A Wizard of Earthsea', isbn: '9780547851402', pageCount: 205, + publishedOn: Date.parse('yyyy-MM-dd', '1968-11-01')).save(failOnError: true) + new Book(author: leguin, title: 'The Left Hand of Darkness', isbn: '9780441478125', pageCount: 304, + publishedOn: Date.parse('yyyy-MM-dd', '1969-03-01')).save(failOnError: true) + } } Closure destroy = { } diff --git a/guides/grails-rest-library/v8/snippets/grails-app/migrations/changelog.groovy b/guides/grails-rest-library/v8/snippets/grails-app/migrations/changelog.groovy new file mode 100644 index 00000000000..84de2da4f81 --- /dev/null +++ b/guides/grails-rest-library/v8/snippets/grails-app/migrations/changelog.groovy @@ -0,0 +1,62 @@ +databaseChangeLog = { + + changeSet(author: 'guide', id: 'author-table') { + createTable(tableName: 'author') { + column(autoIncrement: 'true', name: 'id', type: 'BIGINT') { + constraints(nullable: 'false', primaryKey: 'true', primaryKeyName: 'authorPK') + } + + column(name: 'version', type: 'BIGINT') { + constraints(nullable: 'false') + } + + column(name: 'name', type: 'VARCHAR(255)') { + constraints(nullable: 'false') + } + + column(name: 'biography', type: 'VARCHAR(4000)') + column(name: 'date_of_birth', type: 'DATE') + } + } + + changeSet(author: 'guide', id: 'book-table') { + createTable(tableName: 'book') { + column(autoIncrement: 'true', name: 'id', type: 'BIGINT') { + constraints(nullable: 'false', primaryKey: 'true', primaryKeyName: 'bookPK') + } + + column(name: 'version', type: 'BIGINT') { + constraints(nullable: 'false') + } + + column(name: 'author_id', type: 'BIGINT') { + constraints(nullable: 'false') + } + + column(name: 'title', type: 'VARCHAR(255)') { + constraints(nullable: 'false') + } + + column(name: 'isbn', type: 'VARCHAR(20)') { + constraints(nullable: 'false') + } + + column(name: 'page_count', type: 'INTEGER') + column(name: 'published_on', type: 'DATE') + } + } + + changeSet(author: 'guide', id: 'book-isbn-unique') { + addUniqueConstraint(columnNames: 'isbn', tableName: 'book', constraintName: 'uk_book_isbn') + } + + changeSet(author: 'guide', id: 'book-author-fk') { + addForeignKeyConstraint(baseColumnNames: 'author_id', baseTableName: 'book', constraintName: 'fk_book_author', referencedColumnNames: 'id', referencedTableName: 'author') + } + + changeSet(author: 'guide', id: 'book-author-index') { + createIndex(indexName: 'idx_book_author', tableName: 'book') { + column(name: 'author_id') + } + } +} diff --git a/guides/grails-rest-library/v8/snippets/grails-app/views/author/_author.gson b/guides/grails-rest-library/v8/snippets/grails-app/views/author/_author.gson index ac96c7b2d78..01605b8f446 100644 --- a/guides/grails-rest-library/v8/snippets/grails-app/views/author/_author.gson +++ b/guides/grails-rest-library/v8/snippets/grails-app/views/author/_author.gson @@ -9,9 +9,12 @@ json { name author.name biography author.biography dateOfBirth author.dateOfBirth?.format('yyyy-MM-dd') - bookCount author.books?.size() ?: 0 _links { - self url: g.createLink(controller: 'author', action: 'show', id: author.id, absolute: true) - books url: g.createLink(controller: 'book', action: 'index', params: [author: author.id], absolute: true) + self { + href g.link(resource: author, absolute: true) + } + books { + href g.link(controller: 'book', action: 'index', params: [author: author.id], absolute: true) + } } } diff --git a/guides/grails-rest-library/v8/snippets/grails-app/views/author/index.gson b/guides/grails-rest-library/v8/snippets/grails-app/views/author/index.gson index c8b2fbc0f6d..9f1bd246a82 100644 --- a/guides/grails-rest-library/v8/snippets/grails-app/views/author/index.gson +++ b/guides/grails-rest-library/v8/snippets/grails-app/views/author/index.gson @@ -2,12 +2,12 @@ import example.Author model { Iterable authorList - Long authorCount + Integer authorCount } json { - page Math.max(0, (params.int('offset', 0) ?: 0).intdiv(params.int('max', 25) ?: 25)) + page Math.max(0, (params.int('offset', 0) ?: 0).intdiv(params.int('max', 25))) pageSize params.int('max', 25) - total authorCount - items authorList.collect { Author a -> g.render(template: 'author', model: [author: a]) } + total authorCount + items g.render(template: 'author', collection: authorList ?: [], var: 'author') } diff --git a/guides/grails-rest-library/v8/snippets/grails-app/views/book/_book.gson b/guides/grails-rest-library/v8/snippets/grails-app/views/book/_book.gson index 28e7cf9d683..3c88bf00fb7 100644 --- a/guides/grails-rest-library/v8/snippets/grails-app/views/book/_book.gson +++ b/guides/grails-rest-library/v8/snippets/grails-app/views/book/_book.gson @@ -15,7 +15,11 @@ json { name book.author.name } _links { - self url: g.createLink(controller: 'book', action: 'show', id: book.id, absolute: true) - author url: g.createLink(controller: 'author', action: 'show', id: book.author.id, absolute: true) + self { + href g.link(resource: book, absolute: true) + } + author { + href g.link(resource: book.author, absolute: true) + } } } diff --git a/guides/grails-rest-library/v8/snippets/grails-app/views/book/index.gson b/guides/grails-rest-library/v8/snippets/grails-app/views/book/index.gson index 763066abd96..1b478abfc88 100644 --- a/guides/grails-rest-library/v8/snippets/grails-app/views/book/index.gson +++ b/guides/grails-rest-library/v8/snippets/grails-app/views/book/index.gson @@ -2,12 +2,12 @@ import example.Book model { Iterable bookList - Long bookCount + Integer bookCount } json { - page Math.max(0, (params.int('offset', 0) ?: 0).intdiv(params.int('max', 25) ?: 25)) + page Math.max(0, (params.int('offset', 0) ?: 0).intdiv(params.int('max', 25))) pageSize params.int('max', 25) - total bookCount - items bookList.collect { Book b -> g.render(template: 'book', model: [book: b]) } + total bookCount + items g.render(template: 'book', collection: bookList ?: [], var: 'book') } diff --git a/guides/grails-rest-library/v8/snippets/grails-app/views/errors/_errors.gson b/guides/grails-rest-library/v8/snippets/grails-app/views/errors/_errors.gson new file mode 100644 index 00000000000..4ebbc28679c --- /dev/null +++ b/guides/grails-rest-library/v8/snippets/grails-app/views/errors/_errors.gson @@ -0,0 +1,25 @@ +import org.springframework.http.HttpStatus +import org.springframework.validation.Errors +import org.springframework.validation.FieldError + +model { + Errors errors +} + +response.status HttpStatus.UNPROCESSABLE_ENTITY.value() + +json { + errors this.errors.allErrors.collect { error -> + Map payload = [ + object : error.objectName, + code : error.code, + message: messageSource.getMessage(error, locale) + ] + if (error instanceof FieldError) { + payload.field = error.field + payload.rejectedValue = error.rejectedValue + payload.bindingFailure = error.bindingFailure + } + payload + } +} diff --git a/guides/grails-spock-test-tour/v8/guide/functionalTest.adoc b/guides/grails-spock-test-tour/v8/guide/functionalTest.adoc index c9465e882b2..39cf75182da 100644 --- a/guides/grails-spock-test-tour/v8/guide/functionalTest.adoc +++ b/guides/grails-spock-test-tour/v8/guide/functionalTest.adoc @@ -1,24 +1,23 @@ -Functional tests use Geb 8 to drive a real browser against a real booted app. The `geb-with-webdriver-binaries` feature you selected at the forge step downloads matching Chrome/Firefox driver binaries automatically. +Functional tests use Geb 8 to drive a real browser against a real booted app. In Grails 8 the canonical (and only fully supported) base class is `grails.plugin.geb.ContainerGebSpec` from `testFixtures("org.apache.grails:grails-geb")`, already on the test classpath in the standard `web` profile. It starts a Selenium-Chrome browser inside a Testcontainers container instead of relying on local WebDriver binaries, so the host's only requirement is a running Docker daemon. [source,groovy] +.src/integration-test/groovy/example/BookFunctionalSpec.groovy +---- +include::../snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy[] ---- -@Integration -class BookFunctionalSpec extends GebSpec { - void "the book index page lists every book"() { - when: - go '/books' +Three patterns to highlight: - then: - $('table.book-list tr').size() > 0 - } -} ----- +* `@Integration` is *mandatory* for `ContainerGebSpec`. The Spock extension validates the annotation at runtime and throws `IllegalArgumentException: ContainerGebSpec classes must be annotated with @Integration.` if it is missing. +* `@Rollback` is *not* used here. Functional tests exercise the full HTTP stack against committed data, so transactional rollback would hide what the user actually sees. +* The `geb-with-webdriver-binaries` forge feature from earlier Grails majors is obsolete. `ContainerGebSpec` brings its own browser via Testcontainers; adding the legacy feature wires up a second Chrome driver no test ever uses. Three keep-it-fast disciplines: -* *Use Testcontainers' Postgres module per-spec*, not a long-running Postgres. The container start is ~3 s once warm; the trade-off is per-spec database isolation. -* *Reuse a single browser session* across feature methods in the same spec - Geb does this by default. Re-launching Firefox between every method adds 2 s of overhead per method. -* *Run unit and integration tests first*, functional last. Failure is then localised: if every functional test fails, it is because the app would not boot, which is an integration-test problem. +* *Run unit and integration tests first, functional last.* Failure is then localised: if every functional test fails it is because the app would not boot, which is an integration-test problem. +* *Reuse a single browser session across feature methods in the same spec* - Geb does this by default. Re-launching Chrome between every method would add real overhead. +* *Let Testcontainers reuse the Selenium image* across runs. The first cold start pulls the image (~200 MB); subsequent runs reuse it. + +Functional specs are an order of magnitude slower than integration specs (10-60 s per spec) because the full HTTP stack and a real browser are involved. Use them only for what integration tests genuinely cannot answer: end-to-end user flows, JavaScript-driven interactions, multi-page form journeys. -The matching link:../grails-github-actions-cicd/8/guide/index.html[CI/CD guide] models this as a four-stage job graph (validation -> unit -> integration -> functional) so a broken unit test does not waste 60 seconds on functional runs. +The matching link:../grails-github-actions-cicd/8/guide/index.html[CI/CD guide] models this as a four-stage job graph (validation -> unit -> integration -> functional) so a broken unit test does not waste 60 seconds on functional runs. The CI runner needs Docker available too; GitHub-hosted Linux runners ship with Docker out of the box. diff --git a/guides/grails-spock-test-tour/v8/guide/howto.adoc b/guides/grails-spock-test-tour/v8/guide/howto.adoc index 3dec4e46a99..b8c8c6e84e2 100644 --- a/guides/grails-spock-test-tour/v8/guide/howto.adoc +++ b/guides/grails-spock-test-tour/v8/guide/howto.adoc @@ -7,4 +7,4 @@ cd grails-spock-test-tour/complete ./gradlew test integrationTest ---- -`initial/` is a vanilla Grails 8 starter with the `postgres`, `testcontainers`, `geb-with-webdriver-binaries`, and `mockito` features. `complete/` adds the Book domain, the BookService, the BookController, and the four spec files this guide walks through. +`initial/` is a vanilla Grails 8 starter with the `postgres`, `testcontainers`, and `mockito` features. `complete/` adds the Book domain, the BookService, the BookController, and the five spec files this guide walks through. The functional spec uses `ContainerGebSpec` from the standard `org.apache.grails:grails-geb` test fixtures, so no `geb-with-webdriver-binaries` feature is needed - just a running Docker daemon when you `./gradlew integrationTest`. diff --git a/guides/grails-spock-test-tour/v8/guide/whatYouWillBuild.adoc b/guides/grails-spock-test-tour/v8/guide/whatYouWillBuild.adoc index 70b055aaac4..c55261a1f65 100644 --- a/guides/grails-spock-test-tour/v8/guide/whatYouWillBuild.adoc +++ b/guides/grails-spock-test-tour/v8/guide/whatYouWillBuild.adoc @@ -3,10 +3,11 @@ By the end of the guide your sample app will have: * `Book.groovy` - a small domain class with `title`, `isbn` (regex-validated, unique), and `pageCount` (positive integer). * `BookService.groovy` - an `@Service(Book)` GORM data service with `findByIsbn`, `countByPageCountGreaterThanEquals`, and the standard CRUD methods. * `BookController.groovy` - a `RestfulController` so the JSON CRUD surface is testable from `ControllerUnitTest`. -* Four spec files exercising four test layers: +* Five spec files exercising the five test layers: ** `BookSpec` - `DomainUnitTest`, constraint validation including a `@Unroll` parameterised `where:` table. ** `BookServiceSpec` - `ServiceUnitTest` + `DataTest`, query-shape testing against the in-memory datastore. ** `BookControllerSpec` - `ControllerUnitTest` + `DataTest`, action routing and response status testing. ** `BookIntegrationSpec` - `@Integration` + `@Rollback`, full-stack tests with a real Spring context. +** `BookFunctionalSpec` - `@Integration` + `ContainerGebSpec`, end-to-end browser tests driven by a Selenium container started via Testcontainers. -You will also touch the JVM-level concerns most teams get wrong: `useJUnitPlatform()` wiring, JaCoCo coverage report shape, and how to keep functional tests fast. +You will also touch the JVM-level concerns most teams get wrong: `useJUnitPlatform()` wiring, JaCoCo coverage report shape, and how `ContainerGebSpec` keeps functional tests fast and reproducible by removing host WebDriver binaries from the equation entirely. diff --git a/guides/grails-spock-test-tour/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy b/guides/grails-spock-test-tour/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy new file mode 100644 index 00000000000..38abc215fb7 --- /dev/null +++ b/guides/grails-spock-test-tour/v8/snippets/src/integration-test/groovy/example/BookFunctionalSpec.groovy @@ -0,0 +1,30 @@ +package example + +import grails.plugin.geb.ContainerGebSpec +import grails.testing.mixin.integration.Integration + +/** + * Functional test driven by Geb 8 against a real booted application. + * + * ContainerGebSpec (from `testFixtures("org.apache.grails:grails-geb")`) + * starts a Selenium-Chrome container via Testcontainers and points the + * browser at the host-side Grails app. No local WebDriver binaries are + * installed; the only host requirement is a running Docker daemon. + * + * @Integration is mandatory: GrailsContainerGebExtension throws at + * runtime if the annotation is missing. @Rollback is NOT used here - + * functional tests exercise the full HTTP stack and need committed + * data. + */ +@Integration +class BookFunctionalSpec extends ContainerGebSpec { + + void "the book index page renders the seeded books"() { + when: + go '/books' + + then: + title.contains('Book') + $('table tbody tr').size() > 0 + } +} diff --git a/guides/grails-tailwindcss/v8/guide/componentLayer.adoc b/guides/grails-tailwindcss/v8/guide/componentLayer.adoc index 2a944f73730..462f0aee4ff 100644 --- a/guides/grails-tailwindcss/v8/guide/componentLayer.adoc +++ b/guides/grails-tailwindcss/v8/guide/componentLayer.adoc @@ -3,36 +3,33 @@ Two patterns repeat across the welcome page: the primary action button and the w The relevant section of `input.css`: [source,css] +.src/main/css/input.css ---- -@layer components { - .btn-primary { - @apply inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-offset-gray-900; - } - .card { - @apply rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800; - } - .nav-link { - @apply text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white; - } -} +include::../snippets/src/main/css/input.css[tag=components,indent=0][] ---- GSPs then reference them by the short name: [source,html] ---- -New book -
      ...
      -Docs +Read the guide +
      ...
      +Docs ---- Three things to notice: -* `@apply` is still allowed in v4 - the deprecation warnings you may have seen in early v4 betas were about scoping rules, not the directive itself. -* Component classes in v4 still respect the source order of `@layer base`, `@layer components`, `@layer utilities`. A utility class on a single element will override the same property in a `@layer components` rule. -* Component classes do *not* defeat purging: the scanner picks them up via the `@source` directives just like raw utility classes. +* `@apply` is still supported in v4, which keeps the component layer readable even for long utility chains. +* Tailwind 4 also introduces `@utility` for custom utilities. For this guide we stay with `@layer components` because the goal is a tiny named component surface that reads naturally in GSPs. +* Component classes do not defeat purging. The scanner still decides what utilities to emit from the classes and variants it can see in your source files. [TIP] ==== -*Dynamic class names in Groovy code:* if a controller or tag library composes a class string at runtime (e.g. `"alert-${level}"`), Tailwind's static analyser cannot see the result and will purge those classes from the output. Add the variants to a safelist by writing them out as a comment in any scanned file (`/* alert-info alert-warn alert-error */`) - the scanner reads literal class names from any file matched by `@source`. +*Dynamic class names in Groovy code:* if a controller or tag library composes a class string at runtime, Tailwind's static analyser cannot see the result. In v4 the explicit fix is `@source inline(...)` in `input.css`, for example `@source inline("alert-info alert-warn alert-error");`. ==== + +Files touched in this chapter: + +* `src/main/css/input.css` +* `grails-app/views/layouts/main.gsp` +* `grails-app/views/index.gsp` diff --git a/guides/grails-tailwindcss/v8/guide/configureTailwind.adoc b/guides/grails-tailwindcss/v8/guide/configureTailwind.adoc index 97be867ce56..c3459713929 100644 --- a/guides/grails-tailwindcss/v8/guide/configureTailwind.adoc +++ b/guides/grails-tailwindcss/v8/guide/configureTailwind.adoc @@ -1,9 +1,13 @@ -Tailwind 4 introduced a CSS-first configuration model: most projects no longer need a `tailwind.config.js` file at all. Theme tokens, content sources, and custom variants are declared in your CSS entry point with the `@theme`, `@source`, and `@custom-variant` at-rules. +Tailwind 4 introduced a CSS-first configuration model. Most projects no longer need a `tailwind.config.js` file at all. Theme tokens, content sources, and custom variants are declared in your CSS entry point with the `@theme`, `@source`, and `@custom-variant` at-rules. For a Grails app that means three knobs to set: -* *Where to scan for class names.* Tailwind 4's JIT scanner does not know about `.gsp` files out of the box, and it is conservative about reading utility-class string literals from `.groovy` source. Both have to be added with explicit `@source` directives. +* *Where to scan for class names.* Tailwind 4's scanner does not know about `.gsp` files out of the box, and it is conservative about reading utility-class string literals from `.groovy` source. Both have to be added with explicit `@source` directives. * *How to opt into class-based dark mode.* The default dark variant follows `prefers-color-scheme`. To let the user pick a theme and persist it, override the variant so `dark:` utilities apply when an ancestor has the `dark` class. -* *Reusable component classes (optional).* Anything you find yourself spelling more than three or four times across GSPs is a candidate for the `@layer components` block. +* *Reusable component classes (optional).* Tailwind 4 adds `@utility` for custom utilities, but a small `@layer components` block is still a good fit when you want a few named classes that read well in GSPs. All three live in `src/main/css/input.css`, which we will write next. + +Files touched in this chapter: + +* `src/main/css/input.css` diff --git a/guides/grails-tailwindcss/v8/guide/createApp.adoc b/guides/grails-tailwindcss/v8/guide/createApp.adoc index 98b8ae21354..4c0bdfffaff 100644 --- a/guides/grails-tailwindcss/v8/guide/createApp.adoc +++ b/guides/grails-tailwindcss/v8/guide/createApp.adoc @@ -1 +1,9 @@ -Generate a fresh Apache Grails 8 web application from link:https://prev-snapshot.grails.org[prev-snapshot.grails.org]. This is the same forge that produced the `initial/` tree in the upstream repository. +Generate a fresh Apache Grails 8 web application from link:https://start.grails.org[start.grails.org]. This is the same forge that produced the `initial/` tree in the upstream repository. + +Use the `web` profile. That keeps you on the same starter layout, index page, asset-pipeline manifest, and dependency set that Grails 8 ships from `grails-profiles/web`. + +Files touched in this chapter: + +* `build.gradle` +* `grails-app/views/layouts/main.gsp` +* `grails-app/views/index.gsp` diff --git a/guides/grails-tailwindcss/v8/guide/darkMode.adoc b/guides/grails-tailwindcss/v8/guide/darkMode.adoc index f9d176d24fe..f0cc4056ed1 100644 --- a/guides/grails-tailwindcss/v8/guide/darkMode.adoc +++ b/guides/grails-tailwindcss/v8/guide/darkMode.adoc @@ -1,18 +1,11 @@ The `@custom-variant dark (&:where(.dark, .dark *));` line in `input.css` made every `dark:`-prefixed utility opt-in on the presence of the `dark` class on ``. Two small bits of markup in `main.gsp` flip it on and off. -The first is a four-line pre-paint script in `` that runs before any styled content reaches the screen: +The first is a pre-paint script in `` that runs before any styled content reaches the screen: [source,html] +.grails-app/views/layouts/main.gsp ---- - +include::../snippets/grails-app/views/layouts/main.gsp[tag=themePreload,indent=0][] ---- It reads the user's saved choice (or the OS preference if the user has not chosen) and adds the `dark` class to `` synchronously. Because it runs before the body renders there is no light-to-dark flash on reload. @@ -20,20 +13,20 @@ It reads the user's saved choice (or the OS preference if the user has not chose The second is the toggle button in the navbar plus a click handler at the end of the body that flips the class and writes the new value to `localStorage`: [source,html] +.grails-app/views/layouts/main.gsp ---- - - - +include::../snippets/grails-app/views/layouts/main.gsp[tag=themeToggleButton,indent=0][] +---- + +[source,html] +.grails-app/views/layouts/main.gsp +---- +include::../snippets/grails-app/views/layouts/main.gsp[tag=themeToggleScript,indent=0][] ---- Notice the icon swap: the sun glyph is hidden in dark mode (`dark:hidden`), the moon glyph is hidden in light mode (`hidden dark:inline`). The same `dark:`/non-`dark:` pairing pattern works for any element whose appearance depends on theme. + +Files touched in this chapter: + +* `src/main/css/input.css` +* `grails-app/views/layouts/main.gsp` diff --git a/guides/grails-tailwindcss/v8/guide/download.adoc b/guides/grails-tailwindcss/v8/guide/download.adoc index 2c28be25802..5dc6996fdd6 100644 --- a/guides/grails-tailwindcss/v8/guide/download.adoc +++ b/guides/grails-tailwindcss/v8/guide/download.adoc @@ -1,16 +1,22 @@ -The forge exposes a simple HTTP create endpoint that returns a zip: +Open link:https://start.grails.org[start.grails.org], pick the `web` profile, name the application `tailwind`, set the package to `example`, choose JDK 21, and download the generated zip. Unzip it and `cd` into the `tailwind` directory. + +Verify it boots before you go further: [source,bash] ---- -curl -L -o tailwind-app.zip \ - "https://prev-snapshot.grails.org/create/web/example.tailwind?lang=GROOVY&build=GRADLE&test=SPOCK&javaVersion=JDK_21" -unzip tailwind-app.zip -cd tailwind +./gradlew bootRun ---- -The zip unpacks into a `tailwind/` directory containing the standard Grails 8 web layout (`grails-app/`, `src/`, `gradle/`, `build.gradle`, `gradle.properties`). +Open `http://localhost:8080/` in a browser and you should see the default Grails landing page. Stop the application with `Ctrl+C` once you have confirmed it runs. [NOTE] ==== -The forge's default `build.gradle` ships with `compileJava.options.release = 17` even when `javaVersion=JDK_21` is requested. The `complete/` tree in the upstream repo already patches this to `21`; do the same in your local checkout if you want JDK 21 source-level features. +The forge's default `build.gradle` ships with `compileJava.options.release = 17` even when JDK 21 is requested. The `complete/` tree in the upstream repo already patches this to `21`; do the same in your local checkout if you want JDK 21 source-level features. ==== + +Files touched in this chapter: + +* `build.gradle` +* `settings.gradle` +* `grails-app/views/layouts/main.gsp` +* `grails-app/views/index.gsp` diff --git a/guides/grails-tailwindcss/v8/guide/firstPaint.adoc b/guides/grails-tailwindcss/v8/guide/firstPaint.adoc index 5ed6115a732..d57aa9f0fed 100644 --- a/guides/grails-tailwindcss/v8/guide/firstPaint.adoc +++ b/guides/grails-tailwindcss/v8/guide/firstPaint.adoc @@ -3,11 +3,13 @@ With the Tailwind pipeline in place, replace the layout markup. The starter ship [source,html] .grails-app/views/layouts/main.gsp ---- -include::../snippets/grails-app/views/layouts/main.gsp[] +include::../snippets/grails-app/views/layouts/main.gsp[tag=layout][] ---- A few patterns are worth pointing at: +* The `` taglib resolves to the bundled, fingerprinted `application.css` URL at runtime. The manifest you edited in the previous chapter is what tells the pipeline to include the compiled Tailwind `app.css` in that bundle. The layout never references `app.css` directly. +* The layout keeps the starter's `favicon.ico` link and `application.js` include. That means the page still uses the stock asset-pipeline conventions even though the CSS is now Tailwind-driven. * The outer `` carries `bg-gray-50 dark:bg-gray-900` so the colour scheme flips at the top level. * The navbar uses `mx-auto max-w-7xl` plus a flex row, replacing the Bootstrap `navbar` component with a hand-rolled equivalent that is easier to customise. * The footer link is the `text-blue-600 hover:underline dark:text-blue-400` triplet that you will repeat all over the app once you commit to the utility-first approach. @@ -17,7 +19,12 @@ The welcome page itself follows the same recipe: [source,html] .grails-app/views/index.gsp ---- -include::../snippets/grails-app/views/index.gsp[] +include::../snippets/grails-app/views/index.gsp[tag=index][] ---- Reload `http://localhost:8080` after `./gradlew bootRun` and you should see the Tailwind-styled cards and the dark-mode toggle in the top right. + +Files touched in this chapter: + +* `grails-app/views/layouts/main.gsp` +* `grails-app/views/index.gsp` diff --git a/guides/grails-tailwindcss/v8/guide/gettingStarted.adoc b/guides/grails-tailwindcss/v8/guide/gettingStarted.adoc index be8ba3498cc..c3ebaa68444 100644 --- a/guides/grails-tailwindcss/v8/guide/gettingStarted.adoc +++ b/guides/grails-tailwindcss/v8/guide/gettingStarted.adoc @@ -1,5 +1,9 @@ In this guide you will wire link:https://tailwindcss.com[Tailwind CSS 4] into a fresh Apache Grails 8 application so every GSP view can be styled with utility classes, with class-based dark mode and a small `@apply` component layer for the patterns that repeat. -The pattern works for any server-rendered Grails app: you keep GSPs and the asset pipeline, swap Bootstrap for Tailwind utilities, and let a Gradle-driven `npx @tailwindcss/cli` step regenerate the CSS on every build. +The end state stays close to the Grails 8 `web` starter. GSPs still render through the standard layout tags, the asset pipeline still serves `application.css`, and Gradle owns the extra Tailwind compile step. This guide targets Apache Grails 8. + +Files touched in this chapter: + +* None diff --git a/guides/grails-tailwindcss/v8/guide/gradleWiring.adoc b/guides/grails-tailwindcss/v8/guide/gradleWiring.adoc index 0e761f2a3a3..a176f75ea11 100644 --- a/guides/grails-tailwindcss/v8/guide/gradleWiring.adoc +++ b/guides/grails-tailwindcss/v8/guide/gradleWiring.adoc @@ -1,4 +1,4 @@ -A two-task block at the bottom of `build.gradle` is everything the build needs: +A small block at the bottom of `build.gradle` is everything the build needs: [source,groovy] .build.gradle @@ -6,14 +6,24 @@ A two-task block at the bottom of `build.gradle` is everything the build needs: include::../snippets/build.gradle[tag=tailwind,indent=0] ---- -Two new `Exec` tasks are added: +And the asset manifest becomes a one-line include of the generated Tailwind output: -* `npmInstall` runs `npm install` and caches its result. The `outputs.dir('node_modules')` declaration plus the `inputs.file('package.json')` declaration mean Gradle reruns it only when `package.json` changes. -* `tailwindBuild` calls `npx @tailwindcss/cli` with the same `-i` and `-o` paths used by the npm scripts. Its `inputs` cover `package.json`, the entry CSS, and the two directory trees we declared as `@source` paths in `input.css`. Its single output is the compiled `app.css`. +[source,css] +.grails-app/assets/stylesheets/application.css +---- +include::../snippets/grails-app/assets/stylesheets/application.css[] +---- + +Two `Exec` tasks do the work: + +* `npmInstall` runs `npm install` and caches its result. The `node_modules/` output plus the `package.json` and `package-lock.json` inputs mean Gradle reruns it only when the Node dependency graph changes. +* `tailwindBuild` calls `npx @tailwindcss/cli` with the same `-i` and `-o` paths used by the npm scripts. Its `inputs` cover the entry CSS and every directory that the `@source` directives scan. + +The two `tasks.named` lines hook `tailwindBuild` into the standard Grails build graph so plain `./gradlew bootRun`, `assemble`, or `bootJar` regenerates `app.css` before resources and Groovy sources compile. For iterative work without Gradle in the loop, run `npm run watch` in a second shell. + +The compiled `app.css` lands inside `grails-app/assets/stylesheets/`, which is the asset pipeline's source directory. The layout still asks for `application.css` via ``, but the manifest now pulls in the Tailwind output instead of the starter's Bootstrap CSS. `app.css` itself is regenerated on every build, so it is git-ignored. -The two `tasks.named` lines hook `tailwindBuild` into the standard Grails build graph so plain `./gradlew bootRun` (or `assemble`, or `bootJar`) regenerates `app.css` on every cold start. For iterative work without Gradle in the loop, run `npm run watch` in a second shell. +Files touched in this chapter: -[NOTE] -==== -Asset-pipeline manifest: replace the contents of `grails-app/assets/stylesheets/application.css` with `*= require app` so the manifest pulls in the Tailwind output instead of the Bootstrap CSS the starter shipped with. The `app.css` file itself is regenerated on every build, so it is git-ignored. -==== +* `build.gradle` +* `grails-app/assets/stylesheets/application.css` diff --git a/guides/grails-tailwindcss/v8/guide/helpWithGrails.adoc b/guides/grails-tailwindcss/v8/guide/helpWithGrails.adoc index e062f614b1a..ff50c056612 100644 --- a/guides/grails-tailwindcss/v8/guide/helpWithGrails.adoc +++ b/guides/grails-tailwindcss/v8/guide/helpWithGrails.adoc @@ -1 +1,5 @@ include::{commondir}/common-helpWithGrails.adoc[] + +Files touched in this chapter: + +* None diff --git a/guides/grails-tailwindcss/v8/guide/howto.adoc b/guides/grails-tailwindcss/v8/guide/howto.adoc index bd5f010a454..23fc3b6e97b 100644 --- a/guides/grails-tailwindcss/v8/guide/howto.adoc +++ b/guides/grails-tailwindcss/v8/guide/howto.adoc @@ -9,7 +9,11 @@ cd grails-tailwindcss/complete The repository contains two top-level directories: -* `initial/` - the starting Grails 8 snapshot project, generated from `https://prev-snapshot.grails.org` with no customisations applied. +* `initial/` - the starting Grails 8 project, generated from link:https://start.grails.org[start.grails.org] with no customisations applied. * `complete/` - the same project with all Tailwind wiring from this guide already in place. Each chapter ends with the file paths it touches relative to the project root, so you can match what you typed against the `complete/` reference. + +Files touched in this chapter: + +* None diff --git a/guides/grails-tailwindcss/v8/guide/inputCss.adoc b/guides/grails-tailwindcss/v8/guide/inputCss.adoc index dd98cf6d6bd..4efaa242857 100644 --- a/guides/grails-tailwindcss/v8/guide/inputCss.adoc +++ b/guides/grails-tailwindcss/v8/guide/inputCss.adoc @@ -3,13 +3,16 @@ Create the Tailwind entry point at `src/main/css/input.css`: [source,css] .src/main/css/input.css ---- -include::../snippets/src/main/css/input.css[] +include::../snippets/src/main/css/input.css[tag=input][] ---- -Three things in this file are worth highlighting: +Four things in this file are worth highlighting: * `@import "tailwindcss";` pulls in the framework. In v3 you wrote three separate `@tailwind base; @tailwind components; @tailwind utilities;` directives; v4 collapses all three into the single `@import`. -* The `@source` directives tell the scanner exactly where to look. The starter the forge produces puts views under `grails-app/views`, controllers under `grails-app/controllers`, services under `grails-app/services`, and any helper code (tag libs, command objects, traits) under `src/main/groovy`. Adding a new directory tree later means another `@source` line; nothing else. +* The `@source` directives tell the scanner exactly where to look. The starter the forge produces puts views under `grails-app/views`, controllers under `grails-app/controllers`, services under `grails-app/services`, tag libraries under `grails-app/taglib`, and helper code under `src/main/groovy`. * The `@custom-variant dark (&:where(.dark, .dark *));` line redefines `dark:` so it applies whenever an ancestor (typically ``) carries the `dark` class. Without this override the v4 default uses `prefers-color-scheme`, which honours the OS theme but does not let the user override it. +* The `@layer components` block at the bottom defines `.btn-primary`, `.card`, and `.nav-link`. These are referenced from `main.gsp` and `index.gsp` in later chapters. -The `@layer components` block at the bottom defines `.btn-primary`, `.card`, and `.nav-link` - these are referenced from `main.gsp` and `index.gsp` in later chapters. +Files touched in this chapter: + +* `src/main/css/input.css` diff --git a/guides/grails-tailwindcss/v8/guide/installTailwind.adoc b/guides/grails-tailwindcss/v8/guide/installTailwind.adoc index d68edbf6a9d..1e101ad7aa8 100644 --- a/guides/grails-tailwindcss/v8/guide/installTailwind.adoc +++ b/guides/grails-tailwindcss/v8/guide/installTailwind.adoc @@ -6,11 +6,16 @@ Tailwind 4 ships as the npm package `tailwindcss`, with a separate `@tailwindcss include::../snippets/package.json[] ---- -Then run `npm install` once to populate `node_modules/`: +Then run `npm install` once to populate `node_modules/` and create `package-lock.json`: [source,bash] ---- npm install ---- -The `tailwindBuild` Gradle task we will add in a later chapter calls `npm install` automatically the first time it runs (and skips it on subsequent invocations as long as `package.json` has not changed), so committing the lock file is enough; no hand-rolled bootstrap step is needed in CI. +The `build` and `watch` scripts call the `tailwindcss` binary that `@tailwindcss/cli` exposes, while the Gradle task you will add in a later chapter runs the same compiler through `npx @tailwindcss/cli`. That keeps the npm side and Gradle side aligned. + +Files touched in this chapter: + +* `package.json` +* `package-lock.json` diff --git a/guides/grails-tailwindcss/v8/guide/requirements.adoc b/guides/grails-tailwindcss/v8/guide/requirements.adoc index 75784f0caba..130215d7860 100644 --- a/guides/grails-tailwindcss/v8/guide/requirements.adoc +++ b/guides/grails-tailwindcss/v8/guide/requirements.adoc @@ -1,6 +1,11 @@ To complete this guide you will need: * JDK 21 -* link:https://nodejs.org[Node.js] 20+ and a recent npm (the Tailwind 4 CLI is published as `@tailwindcss/cli` on npm; no other build tooling is required) +* link:https://nodejs.org[Node.js] 20+ +* npm 10+ * A Bash, PowerShell, or `cmd` shell capable of running the included Gradle wrapper (`./gradlew` or `gradlew.bat`) * About 30 minutes + +Files touched in this chapter: + +* None diff --git a/guides/grails-tailwindcss/v8/guide/whatYouWillBuild.adoc b/guides/grails-tailwindcss/v8/guide/whatYouWillBuild.adoc index df26b190805..792068a8911 100644 --- a/guides/grails-tailwindcss/v8/guide/whatYouWillBuild.adoc +++ b/guides/grails-tailwindcss/v8/guide/whatYouWillBuild.adoc @@ -1,8 +1,12 @@ By the end of the guide your application will: * Render `grails-app/views/layouts/main.gsp` and `grails-app/views/index.gsp` with Tailwind 4 utility classes instead of the default Bootstrap 5 classes the starter ships with. -* Compile `src/main/css/input.css` (the Tailwind entry point) into `grails-app/assets/stylesheets/app.css` via a `tailwindBuild` Gradle task that runs before `processResources` and `compileGroovy`. +* Compile `src/main/css/input.css` (the Tailwind entry point) into `grails-app/assets/stylesheets/app.css` via a `tailwindBuild` Gradle task that runs before `processResources` and `compileGroovy`. The asset pipeline then bundles, fingerprints, and gzips that file into the `application.css` bundle the layout serves. * Toggle dark mode at runtime by adding the `dark` class on ``, persisting the choice to `localStorage` so reloads do not flash light-to-dark. -* Expose two reusable component classes (`btn-primary`, `card`) defined in the `@layer components` block of `input.css` and used directly from GSP markup. +* Expose three reusable component classes (`btn-primary`, `card`, `nav-link`) defined in the `@layer components` block of `input.css` and used directly from GSP markup. The result is a small but production-shaped baseline you can copy into any Grails 8 app that still wants the GSP view layer but a modern utility-first CSS pipeline. + +Files touched in this chapter: + +* None diff --git a/guides/grails-tailwindcss/v8/snippets/build.gradle b/guides/grails-tailwindcss/v8/snippets/build.gradle index 0955ea3558b..e6077b52f01 100644 --- a/guides/grails-tailwindcss/v8/snippets/build.gradle +++ b/guides/grails-tailwindcss/v8/snippets/build.gradle @@ -110,6 +110,9 @@ dependencies { runtimeOnly "com.h2database:h2" runtimeOnly "com.zaxxer:HikariCP" runtimeOnly "org.fusesource.jansi:jansi" + testAndDevelopmentOnly "org.webjars.npm:bootstrap" + testAndDevelopmentOnly "org.webjars.npm:bootstrap-icons" + testAndDevelopmentOnly "org.webjars.npm:jquery" integrationTestImplementation testFixtures("org.apache.grails:grails-geb") testImplementation "org.apache.grails:grails-testing-support-datamapping" testImplementation "org.apache.grails:grails-testing-support-web" @@ -127,22 +130,24 @@ tasks.withType(Test).configureEach { def npxCmd = org.gradle.internal.os.OperatingSystem.current().isWindows() ? 'npx.cmd' : 'npx' def npmCmd = org.gradle.internal.os.OperatingSystem.current().isWindows() ? 'npm.cmd' : 'npm' -tasks.register('npmInstall', Exec) { - description = 'Install npm dependencies (Tailwind CSS 4 CLI)' +def npmInstall = tasks.register('npmInstall', Exec) { + description = 'Install npm dependencies for Tailwind CSS 4' group = 'tailwind' - inputs.file('package.json') + inputs.files('package.json', 'package-lock.json') outputs.dir('node_modules') commandLine npmCmd, 'install', '--no-fund', '--no-audit' } -tasks.register('tailwindBuild', Exec) { - description = 'Compile src/main/css/input.css into grails-app/assets/stylesheets/app.css via the Tailwind 4 CLI' +def tailwindBuild = tasks.register('tailwindBuild', Exec) { + description = 'Compile src/main/css/input.css into grails-app/assets/stylesheets/app.css' group = 'tailwind' - dependsOn 'npmInstall' - inputs.file('package.json') - inputs.file('src/main/css/input.css') + dependsOn npmInstall + inputs.files('package.json', 'package-lock.json', 'src/main/css/input.css') inputs.dir('grails-app/views') inputs.dir('grails-app/controllers') + inputs.dir('grails-app/services') + inputs.dir('grails-app/taglib') + inputs.dir('src/main/groovy') outputs.file('grails-app/assets/stylesheets/app.css') commandLine npxCmd, '@tailwindcss/cli', '-i', 'src/main/css/input.css', @@ -150,7 +155,7 @@ tasks.register('tailwindBuild', Exec) { '--minify' } -tasks.named('processResources').configure { dependsOn 'tailwindBuild' } -tasks.named('compileGroovy').configure { dependsOn 'tailwindBuild' } +tasks.named('processResources').configure { dependsOn tailwindBuild } +tasks.named('compileGroovy').configure { dependsOn tailwindBuild } // end::tailwind[] diff --git a/guides/grails-tailwindcss/v8/snippets/grails-app/assets/stylesheets/application.css b/guides/grails-tailwindcss/v8/snippets/grails-app/assets/stylesheets/application.css index 62715727608..c0622428f7a 100644 --- a/guides/grails-tailwindcss/v8/snippets/grails-app/assets/stylesheets/application.css +++ b/guides/grails-tailwindcss/v8/snippets/grails-app/assets/stylesheets/application.css @@ -1,9 +1,6 @@ /* - * Asset-pipeline manifest. The `app.css` file is the Tailwind 4 CLI - * output (regenerated by the `tailwindBuild` Gradle task on every build); - * it is git-ignored. `require_self` keeps any rules added below this - * comment block at the bottom of the bundle. + * Asset-pipeline manifest. Pull the Tailwind CLI output into the canonical + * application.css bundle the layout already serves. * *= require app - *= require_self */ diff --git a/guides/grails-tailwindcss/v8/snippets/grails-app/views/index.gsp b/guides/grails-tailwindcss/v8/snippets/grails-app/views/index.gsp index a7bcc27834b..770b4876f12 100644 --- a/guides/grails-tailwindcss/v8/snippets/grails-app/views/index.gsp +++ b/guides/grails-tailwindcss/v8/snippets/grails-app/views/index.gsp @@ -1,6 +1,8 @@ +<%-- tag::index[] --%> <%@ page import="grails.util.Environment"%> <%@ page import="org.springframework.boot.SpringBootVersion"%> <%@ page import="org.springframework.core.SpringVersion"%> + @@ -29,7 +31,7 @@ target="_blank" rel="noopener"> Read the guide - View source @@ -121,3 +123,4 @@
    +<%-- end::index[] --%> diff --git a/guides/grails-tailwindcss/v8/snippets/grails-app/views/layouts/main.gsp b/guides/grails-tailwindcss/v8/snippets/grails-app/views/layouts/main.gsp index cd779aa1c04..075ed9ed693 100644 --- a/guides/grails-tailwindcss/v8/snippets/grails-app/views/layouts/main.gsp +++ b/guides/grails-tailwindcss/v8/snippets/grails-app/views/layouts/main.gsp @@ -1,3 +1,4 @@ +<%-- tag::layout[] --%> @@ -7,6 +8,7 @@
    Docs Community + <%-- tag::themeToggleButton[] --%> + <%-- end::themeToggleButton[] --%>
    -
    @@ -61,6 +65,7 @@ +<%-- tag::themeToggleScript[] --%> +<%-- end::themeToggleScript[] --%> + +<%-- end::layout[] --%> diff --git a/guides/grails-tailwindcss/v8/snippets/package.json b/guides/grails-tailwindcss/v8/snippets/package.json index 63048466d65..1d95852909a 100644 --- a/guides/grails-tailwindcss/v8/snippets/package.json +++ b/guides/grails-tailwindcss/v8/snippets/package.json @@ -3,6 +3,10 @@ "version": "1.0.0", "private": true, "description": "Tailwind CSS 4 build wiring for the grails-tailwindcss/v8 sample app.", + "engines": { + "node": ">=20", + "npm": ">=10" + }, "scripts": { "build": "tailwindcss -i src/main/css/input.css -o grails-app/assets/stylesheets/app.css --minify", "watch": "tailwindcss -i src/main/css/input.css -o grails-app/assets/stylesheets/app.css --watch" diff --git a/guides/grails-tailwindcss/v8/snippets/src/main/css/input.css b/guides/grails-tailwindcss/v8/snippets/src/main/css/input.css index ad4edaa2f72..82609e8fe97 100644 --- a/guides/grails-tailwindcss/v8/snippets/src/main/css/input.css +++ b/guides/grails-tailwindcss/v8/snippets/src/main/css/input.css @@ -1,3 +1,4 @@ +/* tag::input[] */ /* * Tailwind CSS 4 entry point. * @@ -9,6 +10,7 @@ @import "tailwindcss"; +/* tag::sources[] */ /* * Where to scan for class names. Tailwind 4 auto-detects most file types, * but does not know about .gsp out of the box and is conservative about @@ -18,15 +20,20 @@ @source "../../../grails-app/views"; @source "../../../grails-app/controllers"; @source "../../../grails-app/services"; +@source "../../../grails-app/taglib"; @source "../../../src/main/groovy"; +/* end::sources[] */ +/* tag::darkVariant[] */ /* * Dark mode via a class on . Toggled at runtime in main.gsp. * The default Tailwind 4 dark variant follows prefers-color-scheme; this * override lets a user persist their choice across sessions. */ @custom-variant dark (&:where(.dark, .dark *)); +/* end::darkVariant[] */ +/* tag::components[] */ /* * A small component layer: classes you can spell once and reuse across * GSPs without the full utility chain. Good fit for buttons, cards, and @@ -44,3 +51,5 @@ @apply text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white; } } +/* end::components[] */ +/* end::input[] */ diff --git a/guides/grails-vite-spa/v8/guide/backend.adoc b/guides/grails-vite-spa/v8/guide/backend.adoc index 4b1a39e15cf..4d272d4c81a 100644 --- a/guides/grails-vite-spa/v8/guide/backend.adoc +++ b/guides/grails-vite-spa/v8/guide/backend.adoc @@ -26,4 +26,11 @@ include::../snippets/backend/grails-app/views/book/_book.gson[] include::../snippets/backend/grails-app/views/book/index.gson[] ---- -This is exactly the pattern from the link:../grails-rest-library/8/guide/index.html[REST Library] guide, narrowed to a single domain so the Vite-and-React story stays in focus. +This is exactly the pattern from the link:../grails-rest-library/8/guide/index.html[REST Library] guide, narrowed to a single domain so the Vite-and-React story stays in focus. The `RestfulController` and `.gson` view conventions are documented in `grails-doc/src/en/guide/REST/` (subchapters `domainResources.adoc`, `restRendering.adoc`). + +Files touched in this chapter: + +* `backend/grails-app/domain/example/Book.groovy` +* `backend/grails-app/controllers/example/BookController.groovy` +* `backend/grails-app/views/book/_book.gson` +* `backend/grails-app/views/book/index.gson` diff --git a/guides/grails-vite-spa/v8/guide/cors.adoc b/guides/grails-vite-spa/v8/guide/cors.adoc index 527d11b423a..db43c5785af 100644 --- a/guides/grails-vite-spa/v8/guide/cors.adoc +++ b/guides/grails-vite-spa/v8/guide/cors.adoc @@ -3,9 +3,29 @@ CORS does not appear in either dev or prod for this layout - and that is the poi * *In dev*, the browser only ever talks to Vite on `:5173`. Vite forwards `/api/**` to Grails on `:8080` via its server-to-server proxy; the browser never sees a cross-origin request. No CORS preflight, no `Access-Control-Allow-Origin` headers. * *In prod*, the browser only ever talks to the bootJar on `:8080`. The SPA's static bundle is served from `/`; the API is served from `/api/**`. Both are the same origin. Same story: no CORS. -For the case where the SPA and the API genuinely live on different origins (e.g. SPA on `app.example.com`, API on `api.example.com`), you would add a `CorsFilter` to the Grails side and configure allowed origins explicitly. The Spring Boot guide on link:https://docs.spring.io/spring-boot/docs/current/reference/html/web.html#web.servlet.spring-mvc.cors[CORS] is the canonical reference; nothing Grails-specific is required. +For the case where the SPA and the API genuinely live on different origins (e.g. SPA on `app.example.com`, API on `api.example.com`), Grails ships a first-class CORS configuration block. From `grails-doc/src/en/guide/theWebLayer/cors.adoc` (the official 8.0.x reference): + +[source,yaml] +.grails-app/conf/application.yml +---- +grails: + cors: + enabled: true + allowedOrigins: + - 'https://app.example.com' + allowedMethods: + - GET + - POST + allowCredentials: true +---- + +The `grails.cors` block produces a Spring `CorsConfiguration` under the hood, mapped to `/**` by default. Per-mapping overrides are documented in the same `cors.adoc` chapter under the `mappings:` sub-key. The Spring Boot CORS documentation is the underlying reference for the option semantics (max-age, exposed headers, etc.). [NOTE] ==== *Auth*: HTTP-only cookie sessions work transparently with this same-origin layout - the browser sends the session cookie on every request without any client-side wiring. JWT in `Authorization` headers is the alternative when the SPA is hosted independently of the API; it requires the SPA to attach the header explicitly on every fetch. The cookie-session path is recommended for browser-only SPAs unless you have a reason to pick JWT. ==== + +Files touched in this chapter: + +* None (CORS only becomes relevant if you split the SPA and the API across origins) diff --git a/guides/grails-vite-spa/v8/guide/devMode.adoc b/guides/grails-vite-spa/v8/guide/devMode.adoc index 57036f56ef6..41e55ecf1fb 100644 --- a/guides/grails-vite-spa/v8/guide/devMode.adoc +++ b/guides/grails-vite-spa/v8/guide/devMode.adoc @@ -20,4 +20,8 @@ The browser opens `http://localhost:5173`. Every `fetch('/api/books')` call from Two things to keep an eye on: * *Same-origin in dev too.* Because the SPA always calls relative paths and Vite proxies them, the browser never sees a cross-origin request. CORS preflight requests do not happen even in dev. -* *Spring DevTools for the backend, Vite HMR for the frontend.* Each side reloads independently; you can edit `App.jsx` without restarting Grails, and edit a Groovy controller without losing your SPA state. +* *Spring DevTools for the backend, Vite HMR for the frontend.* Each side reloads independently; you can edit `App.jsx` without restarting Grails, and edit a Groovy controller without losing your SPA state. The Spring DevTools story for Grails 8 is covered in `grails-doc/src/en/guide/gettingStarted/developmentReloading.adoc`. + +Files touched in this chapter: + +* None directly (this chapter is a runbook) diff --git a/guides/grails-vite-spa/v8/guide/frontend.adoc b/guides/grails-vite-spa/v8/guide/frontend.adoc index 8534cfafd24..60d7b4e1b59 100644 --- a/guides/grails-vite-spa/v8/guide/frontend.adoc +++ b/guides/grails-vite-spa/v8/guide/frontend.adoc @@ -27,3 +27,10 @@ include::../snippets/frontend/src/App.jsx[] The single `fetch('/api/books')` call is the only contract between the two halves. The relative path means the same code runs in dev (where Vite proxies to `:8080`) and in prod (where the SPA is served from the bootJar at `:8080` already). To use Vue 3 instead of React, swap the dependency to `vue` + `@vitejs/plugin-vue` and replace `App.jsx` with an `App.vue` single-file component. The Gradle wiring, Vite proxy, and backend never change. + +Files touched in this chapter: + +* `frontend/package.json` +* `frontend/index.html` +* `frontend/src/main.jsx` +* `frontend/src/App.jsx` diff --git a/guides/grails-vite-spa/v8/guide/layout.adoc b/guides/grails-vite-spa/v8/guide/layout.adoc index 2f08b7f7d11..99fbe3dacb3 100644 --- a/guides/grails-vite-spa/v8/guide/layout.adoc +++ b/guides/grails-vite-spa/v8/guide/layout.adoc @@ -21,3 +21,8 @@ Three patterns: * `npmInstall` -> `frontendBuild` -> `copyFrontendToBackend` is a chain. Each task declares its `inputs`/`outputs` so Gradle's up-to-date check skips the chain when nothing has changed. * `copyFrontendToBackend` writes into `backend/src/main/resources/public/`. Spring Boot serves anything under that directory at `/` automatically; no further wiring needed. * The `afterEvaluate { tasks.named('processResources').configure { dependsOn ... } }` line is what makes plain `./gradlew :backend:bootJar` (or `bootRun`) trigger the SPA build. Readers never type the frontend tasks directly. + +Files touched in this chapter: + +* `settings.gradle` +* `build.gradle` diff --git a/guides/grails-vite-spa/v8/guide/prodBuild.adoc b/guides/grails-vite-spa/v8/guide/prodBuild.adoc index 7fcdcf5f31f..992c2636dc7 100644 --- a/guides/grails-vite-spa/v8/guide/prodBuild.adoc +++ b/guides/grails-vite-spa/v8/guide/prodBuild.adoc @@ -19,3 +19,7 @@ The resulting jar serves the SPA from `/` and the API from `/api/**`, both from ==== The matching link:../grails-docker-bootbuildimage/8/guide/index.html[Docker bootBuildImage] guide turns this exact bootJar into an OCI image. Combined, the two guides give you `git push` -> tagged GHCR image -> single-container deploy. ==== + +Files touched in this chapter: + +* None directly (this chapter is a runbook for the build chain wired up in `layout.adoc`) diff --git a/guides/grails-vite-spa/v8/guide/spaController.adoc b/guides/grails-vite-spa/v8/guide/spaController.adoc index 86b2bdfdde1..782a8147647 100644 --- a/guides/grails-vite-spa/v8/guide/spaController.adoc +++ b/guides/grails-vite-spa/v8/guide/spaController.adoc @@ -12,3 +12,9 @@ include::../snippets/backend/grails-app/controllers/example/SpaController.groovy ==== *Why not a wildcard URL mapping like `'/$path**'(controller: 'spa', action: 'index')`?* Because URL mappings resolve before static resources. A wildcard mapping would intercept `/favicon.ico` and `/assets/main-abc123.js`, returning the SPA's `index.html` instead of the asset. Letting Spring Boot's static-resource handler claim everything except `/api/**` and `/` is the simplest correct shape. ==== + +The `forward` method available on every Grails controller is the same one the framework uses internally for URL-mapping rewrites; see `grails-controllers/src/main/groovy/grails/web/util/WebUtils.java` for the underlying implementation and `grails-doc/src/en/guide/theWebLayer/controllers/forwarding.adoc` for the user-facing reference. + +Files touched in this chapter: + +* `backend/grails-app/controllers/example/SpaController.groovy` diff --git a/guides/grails-vite-spa/v8/guide/urlMappings.adoc b/guides/grails-vite-spa/v8/guide/urlMappings.adoc index 5960a728685..27a51491b33 100644 --- a/guides/grails-vite-spa/v8/guide/urlMappings.adoc +++ b/guides/grails-vite-spa/v8/guide/urlMappings.adoc @@ -10,3 +10,9 @@ include::../snippets/backend/grails-app/controllers/example/UrlMappings.groovy[] * Everything else - `/`, `/books/42`, `/about` - resolves to `SpaController.index`, which forwards to the bundled `index.html`. The SPA's client-side router takes over from there. The forward (rather than a redirect) is what makes deep links survive a browser refresh: a hard reload on `/books/42` returns the same `index.html` payload, the SPA boots, and React Router parses the URL. + +The URL-mapping DSL itself is covered in `grails-doc/src/en/guide/theWebLayer/urlMappings.adoc` and the per-method-routing story in `grails-doc/src/en/guide/theWebLayer/urlmappings/restfulMappings.adoc`. + +Files touched in this chapter: + +* `backend/grails-app/controllers/example/UrlMappings.groovy` diff --git a/guides/grails-vite-spa/v8/guide/viteConfig.adoc b/guides/grails-vite-spa/v8/guide/viteConfig.adoc index f695d3e0602..b05fc8cd5a2 100644 --- a/guides/grails-vite-spa/v8/guide/viteConfig.adoc +++ b/guides/grails-vite-spa/v8/guide/viteConfig.adoc @@ -12,3 +12,7 @@ Two pieces are doing the work: * `build.outDir = 'dist'` controls where `npm run build` writes the static bundle. Our root `build.gradle`'s `copyFrontendToBackend` task reads from this exact path. `emptyOutDir: true` makes a clean rebuild every time, so a renamed asset does not leave a stale predecessor in `dist/`. + +Files touched in this chapter: + +* `frontend/vite.config.js` diff --git a/guides/grails-vite-spa/v8/guide/whatYouWillBuild.adoc b/guides/grails-vite-spa/v8/guide/whatYouWillBuild.adoc index 8be6550058e..1769c4b5501 100644 --- a/guides/grails-vite-spa/v8/guide/whatYouWillBuild.adoc +++ b/guides/grails-vite-spa/v8/guide/whatYouWillBuild.adoc @@ -23,3 +23,9 @@ Two run-modes: * *Dev*: `./gradlew :backend:bootRun` in one shell, `cd frontend && npm run dev` in another. Vite serves the SPA on `:5173` with HMR; the proxy forwards `/api/**` to the Grails app on `:8080`. * *Prod*: `./gradlew :backend:bootJar`. Gradle runs `npm run build`, copies `frontend/dist/` into `backend/src/main/resources/public/`, and packages everything into one bootJar. The SPA serves from `/` and `/api/**` from the same origin - CORS irrelevant. + +The official Grails 8 reference manual (`grails-doc/src/en/guide/REST/`) covers the JSON-rendering side this guide consumes. The CORS chapter (`grails-doc/src/en/guide/theWebLayer/cors.adoc`) covers the `grails.cors.enabled` block this guide does NOT use - because the same-origin layout this guide builds removes the need for CORS entirely. Both chapters are worth knowing about. + +Files touched in this chapter: + +* None