diff --git a/README.md b/README.md index 05384fae755..e907ab78a58 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ To check `conf/guides.yml` against the schema: ./gradlew validateGuides -PvalidationMode=both ``` -To run the full guide verification harness (warning gate, link crawl, structural diff, CSP scan, acceptance report): +To run the full guide verification harness (warning gate, link crawl, CSP scan, acceptance report): ```bash ./gradlew verifyAllGuides diff --git a/buildSrc/src/main/groovy/website/gradle/GrailsWebsitePlugin.groovy b/buildSrc/src/main/groovy/website/gradle/GrailsWebsitePlugin.groovy index a6f0d6aa672..9a12314d977 100644 --- a/buildSrc/src/main/groovy/website/gradle/GrailsWebsitePlugin.groovy +++ b/buildSrc/src/main/groovy/website/gradle/GrailsWebsitePlugin.groovy @@ -46,7 +46,6 @@ import website.gradle.tasks.RecordCompanionReleaseTask import website.gradle.tasks.RecordReleaseTask import website.gradle.tasks.RenderSiteTask import website.gradle.tasks.SitemapTask -import website.gradle.tasks.StructuralDiffGuidesTask import website.gradle.tasks.ValidateGuidesTask @CompileStatic @@ -154,7 +153,6 @@ class GrailsWebsitePlugin implements Plugin { AsciidoctorWarningGateTask.register(project) CrawlBuiltGuidesTask.register(project) - StructuralDiffGuidesTask.register(project) AcceptanceReportTask.register(project) GenerateRedirectStubsTask.register(project) GenerateRedirectsManifestTask.register(project, siteExt) @@ -166,7 +164,6 @@ class GrailsWebsitePlugin implements Plugin { it.dependsOn('buildAllGuides') it.dependsOn(AsciidoctorWarningGateTask.NAME) it.dependsOn(CrawlBuiltGuidesTask.NAME) - it.dependsOn(StructuralDiffGuidesTask.NAME) it.dependsOn(CspScanTask.NAME) it.dependsOn(AcceptanceReportTask.NAME) } diff --git a/buildSrc/src/main/groovy/website/gradle/RenderGuidesPlugin.groovy b/buildSrc/src/main/groovy/website/gradle/RenderGuidesPlugin.groovy index cec6dbeae19..b25fc6ca117 100644 --- a/buildSrc/src/main/groovy/website/gradle/RenderGuidesPlugin.groovy +++ b/buildSrc/src/main/groovy/website/gradle/RenderGuidesPlugin.groovy @@ -36,7 +36,6 @@ import website.gradle.tasks.DownloadTask import website.gradle.tasks.GuidesTask import website.gradle.tasks.HtaccessTask import website.gradle.tasks.MinutesTask -import website.gradle.tasks.ParityCheckGuideTask import website.gradle.tasks.PluginsTask import website.gradle.tasks.ProfilesTask import website.gradle.tasks.QuestionsTask @@ -81,10 +80,8 @@ class RenderGuidesPlugin { static final String GROUP = 'documentation' static final String AGGREGATE_TASK = 'buildAllGuides' - static final String PARITY_AGGREGATE_TASK = 'parityCheckAllGuides' static final String GUIDES_YML_PATH = 'conf/guides.yml' static final String GUIDE_TEMPLATE_PATH = 'guides/resources' - static final String PARITY_BASELINE_ROOT = 'buildSrc/src/test/resources/parity-baseline' static void apply(Project project) { File guidesYml = project.rootProject.layout.projectDirectory @@ -103,14 +100,10 @@ class RenderGuidesPlugin { registerAggregateTask(project, GROUP, AGGREGATE_TASK, 'Renders every wired-up guide-version pair under build/dist/guides/', wiring.renderTaskNames) - registerAggregateTask(project, GROUP, PARITY_AGGREGATE_TASK, - 'Runs renderer parity checks for every guide-version that has a baseline snapshot under buildSrc/src/test/resources/parity-baseline/', - wiring.parityTaskNames) } private static class Wiring { List renderTaskNames = [] - List parityTaskNames = [] } @CompileDynamic @@ -144,7 +137,7 @@ class RenderGuidesPlugin { String safeName = sanitize(guideName) String safeVersion = sanitize(versionKey) - if (!adocDir.isDirectory()) continue // skip-if-missing for render/parity/stage + if (!adocDir.isDirectory()) continue // skip-if-missing for render/stage Map attributes = buildAttributes( guide, version, versionKey) @@ -240,28 +233,6 @@ class RenderGuidesPlugin { } } wiring.renderTaskNames << renderTaskName - - // Parity check vs the legacy snapshot, when one exists on disk. - File baselineFile = project.rootProject.layout.projectDirectory - .file("${PARITY_BASELINE_ROOT}/${guideName}-v${versionKey}/index.html").asFile - if (baselineFile.isFile()) { - String parityTaskName = "parityCheckGuide_${safeName}_${safeVersion}" - String renderedSinglePage = "dist/guides/${guideName}/${versionKey}/guide/single.html" - String reportRelPath = "reports/parity/${guideName}/${versionKey}.md" - project.tasks.register(parityTaskName, ParityCheckGuideTask) { ParityCheckGuideTask task -> - task.group = GROUP - task.description = "Compares rendered ${guideName} v${versionKey} against the legacy snapshot at ${baselineFile.name}" - task.dependsOn(renderTaskName) - task.localFile.set(project.layout.buildDirectory.file(renderedSinglePage)) - task.baselineFile.set(baselineFile) - task.reportFile.set(project.layout.buildDirectory.file(reportRelPath)) - task.guideLabel.set("${guideName}@v${versionKey}") - if (project.hasProperty('parityFailOnDiff')) { - task.failOnDiff.set(Boolean.parseBoolean(project.property('parityFailOnDiff') as String)) - } - } - wiring.parityTaskNames << parityTaskName - } } } diff --git a/buildSrc/src/main/groovy/website/gradle/tasks/AcceptanceReportTask.groovy b/buildSrc/src/main/groovy/website/gradle/tasks/AcceptanceReportTask.groovy index fd7fccec7c1..6cb768ebabf 100644 --- a/buildSrc/src/main/groovy/website/gradle/tasks/AcceptanceReportTask.groovy +++ b/buildSrc/src/main/groovy/website/gradle/tasks/AcceptanceReportTask.groovy @@ -60,11 +60,6 @@ class AcceptanceReportTask extends DefaultTask { @PathSensitive(PathSensitivity.RELATIVE) final RegularFileProperty crawlReportFile = project.objects.fileProperty() - @Optional - @InputFile - @PathSensitive(PathSensitivity.RELATIVE) - final RegularFileProperty structuralReportFile = project.objects.fileProperty() - @Optional @InputFile @PathSensitive(PathSensitivity.RELATIVE) @@ -81,14 +76,12 @@ class AcceptanceReportTask extends DefaultTask { File guidesRoot = guidesDir.get().asFile Map asciidoctorResults = parseCsvReport(optionalFile(asciidoctorReportFile)) Map crawlResults = parseCsvReport(optionalFile(crawlReportFile)) - Map structuralResults = parseCsvReport(optionalFile(structuralReportFile)) CspAggregation cspAggregation = parseCspReport(optionalFile(cspReportFile)) Set guideKeys = [] as LinkedHashSet guideKeys.addAll(discoverGuideKeys(guidesRoot)) guideKeys.addAll(asciidoctorResults.keySet()) guideKeys.addAll(crawlResults.keySet()) - guideKeys.addAll(structuralResults.keySet()) guideKeys.addAll(cspAggregation.issuesByGuide.keySet()) if (guideKeys.isEmpty()) { @@ -101,16 +94,14 @@ class AcceptanceReportTask extends DefaultTask { Pair pair = splitKey(key) GateResult asciidoctor = asciidoctorResults[key] ?: GateResult.review('asciidoctorWarningGate report row missing.') GateResult crawl = crawlResults[key] ?: GateResult.review('crawlBuiltGuides report row missing.') - GateResult structural = structuralResults[key] ?: GateResult.review('structuralDiffGuides report row missing.') GateResult csp = cspResultFor(key, cspAggregation) - String verdict = mergeVerdict([asciidoctor.status, crawl.status, structural.status, csp.status]) - String details = summarizeDetails(asciidoctor, crawl, structural, csp) + String verdict = mergeVerdict([asciidoctor.status, crawl.status, csp.status]) + String details = summarizeDetails(asciidoctor, crawl, csp) rows << new AcceptanceRow( guide: pair.guide, version: pair.version, asciidoctorWarningGate: asciidoctor.status, crawlBuiltGuides: crawl.status, - structuralDiffGuides: structural.status, cspScan: csp.status, verdict: verdict, details: details, @@ -133,13 +124,11 @@ class AcceptanceReportTask extends DefaultTask { task.guidesDir.convention(project.layout.buildDirectory.dir('dist/guides')) task.asciidoctorReportFile.convention(project.layout.buildDirectory.file('reports/asciidoctor-warning-gate.csv')) task.crawlReportFile.convention(project.layout.buildDirectory.file('reports/crawl-built-guides.csv')) - task.structuralReportFile.convention(project.layout.buildDirectory.file('reports/structural-diff-guides.csv')) task.cspReportFile.convention(project.layout.buildDirectory.file('reports/csp-scan.md')) task.reportFile.convention(project.layout.buildDirectory.file('reports/acceptance.csv')) task.failOnViolation.convention(isHardFailMode(project)) task.dependsOn(AsciidoctorWarningGateTask.NAME) task.dependsOn(CrawlBuiltGuidesTask.NAME) - task.dependsOn(StructuralDiffGuidesTask.NAME) task.dependsOn(CspScanTask.NAME) } } @@ -277,12 +266,10 @@ class AcceptanceReportTask extends DefaultTask { 'VERIFIED' } - private static String summarizeDetails(GateResult asciidoctor, GateResult crawl, - GateResult structural, GateResult csp) { + private static String summarizeDetails(GateResult asciidoctor, GateResult crawl, GateResult csp) { List details = [] addDetail(details, 'asciidoctorWarningGate', asciidoctor) addDetail(details, 'crawlBuiltGuides', crawl) - addDetail(details, 'structuralDiffGuides', structural) addDetail(details, 'cspScan', csp) if (details.isEmpty()) { return 'All guide verification gates passed.' @@ -298,13 +285,12 @@ class AcceptanceReportTask extends DefaultTask { private static void writeCsv(File outputFile, List rows) { outputFile.parentFile.mkdirs() - StringBuilder sb = new StringBuilder('guide,version,asciidoctorWarningGate,crawlBuiltGuides,structuralDiffGuides,cspScan,verdict,details\n') + StringBuilder sb = new StringBuilder('guide,version,asciidoctorWarningGate,crawlBuiltGuides,cspScan,verdict,details\n') for (AcceptanceRow row : rows) { sb << sanitize(row.guide) << ',' sb << sanitize(row.version) << ',' sb << sanitize(row.asciidoctorWarningGate) << ',' sb << sanitize(row.crawlBuiltGuides) << ',' - sb << sanitize(row.structuralDiffGuides) << ',' sb << sanitize(row.cspScan) << ',' sb << sanitize(row.verdict) << ',' sb << sanitize(row.details) << '\n' @@ -381,7 +367,6 @@ class AcceptanceReportTask extends DefaultTask { String version String asciidoctorWarningGate String crawlBuiltGuides - String structuralDiffGuides String cspScan String verdict String details diff --git a/buildSrc/src/main/groovy/website/gradle/tasks/ParityCheckGuideTask.groovy b/buildSrc/src/main/groovy/website/gradle/tasks/ParityCheckGuideTask.groovy deleted file mode 100644 index fa7c40ff24b..00000000000 --- a/buildSrc/src/main/groovy/website/gradle/tasks/ParityCheckGuideTask.groovy +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package website.gradle.tasks - -import groovy.transform.CompileStatic - -import org.gradle.api.DefaultTask -import org.gradle.api.GradleException -import org.gradle.api.Project -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction - -import website.qa.AdHocFixtureDiff - -/** - * Compares a locally rendered guide HTML against a baseline snapshot taken - * from the legacy {@code https://guides.grails.org/...} site, emitting a - * structural-diff report to {@code build/reports/parity//.md} - * and (optionally) failing the build when divergence is severe. - * - *

Wired by {@link website.gradle.RenderGuidesPlugin} as - * {@code parityCheckGuide__}; the aggregate is - * {@code parityCheckAllGuides}. Defaults assume the renderer's - * single-page output ({@code single.html}) is the parity target since the - * legacy site renders all chapters into one page.

- * - *

Exit policy: by default this task fails the build if the diff - * report contains hard-fail conditions (see - * {@link AdHocFixtureDiff.Report#isStructurallyEquivalent()}). Set - * {@code -PparityFailOnDiff=false} to demote failures to warnings while - * the renderer config is being tuned.

- */ -@CompileStatic -class ParityCheckGuideTask extends DefaultTask { - - static final String NAME = 'parityCheckGuide' - static final String GROUP = 'documentation' - - @InputFile - @PathSensitive(PathSensitivity.RELATIVE) - final RegularFileProperty localFile = project.objects.fileProperty() - - @InputFile - @PathSensitive(PathSensitivity.RELATIVE) - final RegularFileProperty baselineFile = project.objects.fileProperty() - - @OutputFile - final RegularFileProperty reportFile = project.objects.fileProperty() - - @Input - final Property failOnDiff = project.objects.property(Boolean).convention(true) - - @Input - final Property guideLabel = project.objects.property(String).convention('guide') - - @TaskAction - void check() { - File local = localFile.get().asFile - File baseline = baselineFile.get().asFile - File report = reportFile.get().asFile - - if (!local.isFile()) { - throw new GradleException("Local file does not exist: ${local} -- run the corresponding renderGuide task first.") - } - if (!baseline.isFile()) { - throw new GradleException("Baseline file does not exist: ${baseline} -- vendor a snapshot from https://guides.grails.org/ into buildSrc/src/test/resources/parity-baseline/.") - } - - report.parentFile.mkdirs() - AdHocFixtureDiff.Report result = AdHocFixtureDiff.compare(local, baseline) - report.text = result.toHumanReport() - - logger.lifecycle("Renderer parity report for ${guideLabel.get()}: ${report.absolutePath}") - if (!result.differences.isEmpty()) { - logger.warn("${result.differences.size()} parity differences found:") - result.differences.each { logger.warn(" - ${it}") } - } else { - logger.lifecycle('Renderer parity OK -- structurally equivalent within thresholds.') - } - - if (!result.isStructurallyEquivalent() && failOnDiff.get()) { - throw new GradleException("Renderer parity check FAILED for ${guideLabel.get()}. " + - "See ${report.absolutePath}. " + - 'Re-run with -PparityFailOnDiff=false to demote to warnings while iterating.') - } - } -} diff --git a/buildSrc/src/main/groovy/website/gradle/tasks/StructuralDiffGuidesTask.groovy b/buildSrc/src/main/groovy/website/gradle/tasks/StructuralDiffGuidesTask.groovy deleted file mode 100644 index d24b6e2cde5..00000000000 --- a/buildSrc/src/main/groovy/website/gradle/tasks/StructuralDiffGuidesTask.groovy +++ /dev/null @@ -1,339 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package website.gradle.tasks - -import groovy.json.JsonOutput -import groovy.transform.CompileStatic - -import org.gradle.api.DefaultTask -import org.gradle.api.GradleException -import org.gradle.api.Project -import org.gradle.api.file.DirectoryProperty -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputDirectory -import org.gradle.api.tasks.Optional -import org.gradle.api.tasks.OutputDirectory -import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction -import org.gradle.api.tasks.TaskProvider - -import org.jsoup.Jsoup -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element - -import website.qa.AdHocFixtureDiff - -/** - * Extracts structural fingerprints from rendered guides and compares them to a - * baseline when one is available. - */ -@CompileStatic -class StructuralDiffGuidesTask extends DefaultTask { - - static final String NAME = 'structuralDiffGuides' - static final String GROUP = 'migration' - - @Optional - @InputDirectory - @PathSensitive(PathSensitivity.RELATIVE) - final DirectoryProperty guidesDir = project.objects.directoryProperty() - - @Optional - @InputDirectory - @PathSensitive(PathSensitivity.RELATIVE) - final DirectoryProperty baselineDir = project.objects.directoryProperty() - - @OutputDirectory - final DirectoryProperty fingerprintDir = project.objects.directoryProperty() - - @OutputFile - final RegularFileProperty reportFile = project.objects.fileProperty() - - @Input - final Property failOnViolation = project.objects.property(Boolean) - - @TaskAction - void diff() { - File guidesRoot = guidesDir.get().asFile - File baselinesRoot = baselineDir.isPresent() ? baselineDir.get().asFile : null - File fingerprintsRoot = fingerprintDir.get().asFile - File report = reportFile.get().asFile - - if (!guidesRoot.isDirectory()) { - writeCsv(report, []) - return - } - - List results = [] - for (File guideDir : sortedDirectories(guidesRoot)) { - for (File versionDir : sortedDirectories(guideDir)) { - GuideSummary summary = inspectGuideVersion(guideDir.name, versionDir, baselinesRoot, fingerprintsRoot) - summary.status = determineStatus(summary) - results << summary - } - } - - writeCsv(report, results) - - List failed = results.findAll { GuideSummary summary -> summary.status == 'FAILED' } as List - if (!failed.isEmpty()) { - throw new GradleException("Structural guide differences detected. See ${report.absolutePath}.") - } - } - - static TaskProvider register(Project project) { - project.tasks.register(NAME, StructuralDiffGuidesTask) { StructuralDiffGuidesTask task -> - task.group = GROUP - task.description = 'Extracts structural fingerprints from rendered guides and compares them with available baselines.' - task.guidesDir.convention(project.layout.buildDirectory.dir('dist/guides')) - // baselineDir is optional. If the directory exists at config time we point at it; - // otherwise we leave the property unset so structuralDiff still produces fingerprints - // without attempting baseline comparison (every row lands as REVIEW pending a snapshot). - File defaultBaseline = project.rootProject.layout.projectDirectory.dir('buildSrc/src/test/resources/structural-baseline').asFile - if (defaultBaseline.isDirectory()) { - task.baselineDir.convention(project.rootProject.layout.projectDirectory.dir('buildSrc/src/test/resources/structural-baseline')) - } - task.fingerprintDir.convention(project.layout.buildDirectory.dir('reports/structural-fingerprints')) - task.reportFile.convention(project.layout.buildDirectory.file('reports/structural-diff-guides.csv')) - task.failOnViolation.convention(isHardFailMode(project)) - task.dependsOn('buildAllGuides') - } - } - - private GuideSummary inspectGuideVersion(String guideName, File versionDir, File baselinesRoot, File fingerprintsRoot) { - GuideSummary summary = new GuideSummary( - guide: guideName, - version: versionDir.name, - ) - - File renderedFile = locateRenderedFile(versionDir) - if (renderedFile == null) { - summary.differences << 'No guide/single.html, guide/index.html, or index.html was found for this rendered guide version.' - return summary - } - - Fingerprint localFingerprint = fingerprint(renderedFile, versionDir) - writeFingerprint(new File(fingerprintsRoot, "${guideName}/${versionDir.name}.json"), localFingerprint) - - File baselineFile = resolveBaselineFile(baselinesRoot, guideName, versionDir.name) - Fingerprint baselineFingerprint = baselineFile == null - ? localFingerprint - : fingerprint(baselineFile, baselineFile.parentFile) - boolean selfBaseline = baselineFile == null - summary.selfBaseline = selfBaseline - - if (localFingerprint.headingTree.isEmpty()) { - summary.differences.add("${localFingerprint.sourcePath} contains no headings.".toString()) - } - if (localFingerprint.totalAnchors == 0) { - summary.differences.add("${localFingerprint.sourcePath} contains no anchor IDs.".toString()) - } - - if (!selfBaseline) { - summary.differences.addAll(compare(localFingerprint, baselineFingerprint)) - } - - if (selfBaseline && summary.differences.isEmpty()) { - summary.differencesInfo = 'Fingerprint extracted successfully. Baseline comparison is currently self-referential until production snapshots are checked in.' - } else if (selfBaseline) { - summary.differencesInfo = 'Fingerprint extracted successfully, but the rendered file itself contains structural issues.' - } else { - summary.differencesInfo = "Compared ${localFingerprint.sourcePath} to ${baselineFingerprint.sourcePath}." - } - summary - } - - private String determineStatus(GuideSummary summary) { - if (!summary.differences.isEmpty()) { - return failOnViolation.get() ? 'FAILED' : 'REVIEW' - } - summary.selfBaseline ? 'REVIEW' : 'VERIFIED' - } - - private static List compare(Fingerprint localFingerprint, Fingerprint baselineFingerprint) { - List differences = [] - if (localFingerprint.headingTree != baselineFingerprint.headingTree) { - differences.add("Heading tree mismatch: local=${localFingerprint.headingTree.size()} baseline=${baselineFingerprint.headingTree.size()}".toString()) - } - Set missingAnchors = baselineFingerprint.anchorIds - localFingerprint.anchorIds - if (!missingAnchors.isEmpty()) { - differences.add("${missingAnchors.size()} anchor IDs are missing from the rendered output.".toString()) - } - if (localFingerprint.totalHeadings != baselineFingerprint.totalHeadings) { - differences.add("Heading count drift: local=${localFingerprint.totalHeadings} baseline=${baselineFingerprint.totalHeadings}".toString()) - } - if (localFingerprint.sectionCount != baselineFingerprint.sectionCount) { - differences.add("Section count drift: local=${localFingerprint.sectionCount} baseline=${baselineFingerprint.sectionCount}".toString()) - } - if (localFingerprint.codeBlockCount != baselineFingerprint.codeBlockCount) { - differences.add("Code block count drift: local=${localFingerprint.codeBlockCount} baseline=${baselineFingerprint.codeBlockCount}".toString()) - } - if (localFingerprint.imageCount != baselineFingerprint.imageCount) { - differences.add("Image count drift: local=${localFingerprint.imageCount} baseline=${baselineFingerprint.imageCount}".toString()) - } - differences - } - - private static Fingerprint fingerprint(File htmlFile, File versionRoot) { - Document document = Jsoup.parse(htmlFile, 'UTF-8') - AdHocFixtureDiff.Metrics metrics = AdHocFixtureDiff.measure(document) - List headingTree = [] - for (Element element : document.select('h1, h2, h3, h4, h5, h6')) { - String text = normalizeText(element.text()) - if (!text.isEmpty()) { - headingTree.add("${element.tagName()}:${text}".toString()) - } - } - - new Fingerprint( - sourcePath: versionRoot.toPath().relativize(htmlFile.toPath()).toString().replace('\\', '/'), - totalHeadings: metrics.totalHeadings, - sectionCount: metrics.sections, - totalAnchors: metrics.anchors, - codeBlockCount: metrics.codeBlocks, - imageCount: metrics.images, - headingTree: headingTree, - anchorIds: new LinkedHashSet(metrics.anchorIds), - ) - } - - private static void writeFingerprint(File outputFile, Fingerprint fingerprint) { - outputFile.parentFile.mkdirs() - Map payload = [ - sourcePath : fingerprint.sourcePath, - totalHeadings : fingerprint.totalHeadings, - sectionCount : fingerprint.sectionCount, - totalAnchors : fingerprint.totalAnchors, - codeBlockCount : fingerprint.codeBlockCount, - imageCount : fingerprint.imageCount, - headingTree : fingerprint.headingTree, - anchorIds : fingerprint.anchorIds as List, - ] - outputFile.text = JsonOutput.prettyPrint(JsonOutput.toJson(payload)) + '\n' - } - - private static File locateRenderedFile(File versionDir) { - List candidates = ['guide/single.html', 'guide/index.html', 'index.html'] - for (String candidate : candidates) { - File renderedFile = new File(versionDir, candidate) - if (renderedFile.isFile()) { - return renderedFile - } - } - null - } - - private static File resolveBaselineFile(File baselinesRoot, String guideName, String version) { - // TODO: Integrate captured production baseline snapshots from https://guides.grails.org/// once they are committed. - if (baselinesRoot == null || !baselinesRoot.isDirectory()) { - return null - } - List candidates = [ - "${guideName}/${version}/guide/single.html".toString(), - "${guideName}/${version}/guide/index.html".toString(), - "${guideName}/${version}/index.html".toString(), - ] - for (String candidate : candidates) { - File baselineFile = new File(baselinesRoot, candidate) - if (baselineFile.isFile()) { - return baselineFile - } - } - null - } - - private static List sortedDirectories(File root) { - File[] children = root.listFiles() - List directories = [] - if (children == null) { - return directories - } - for (File child : children) { - if (child.isDirectory()) { - directories << child - } - } - directories.sort { File left, File right -> left.name <=> right.name } - directories - } - - private static void writeCsv(File outputFile, List results) { - outputFile.parentFile.mkdirs() - StringBuilder sb = new StringBuilder('guide,version,status,issueCount,details\n') - for (GuideSummary summary : results) { - List details = summary.differences.isEmpty() - ? [summary.differencesInfo] - : summary.differences + [summary.differencesInfo] - sb << sanitize(summary.guide) << ',' - sb << sanitize(summary.version) << ',' - sb << sanitize(summary.status ?: 'VERIFIED') << ',' - sb << summary.differences.size() << ',' - sb << sanitize(abbreviate(details.findAll { String value -> value != null && !value.isEmpty() }.join(' | '))) << '\n' - } - outputFile.text = sb.toString() - } - - private static String normalizeText(String value) { - (value ?: '').replaceAll(/\s+/, ' ').trim() - } - - private static String abbreviate(String value) { - if (value.size() <= 1200) { - return value - } - value.substring(0, 1197) + '...' - } - - private static String sanitize(String value) { - (value ?: '') - .replace('\r', ' ') - .replace('\n', ' ') - .replace(',', ';') - .trim() - } - - private static boolean isHardFailMode(Project project) { - String mode = (project.findProperty('verificationMode') ?: '') as String - mode.equalsIgnoreCase('hard-fail') - } - - private static final class Fingerprint { - String sourcePath - int totalHeadings - int sectionCount - int totalAnchors - int codeBlockCount - int imageCount - List headingTree = [] - Set anchorIds = [] as LinkedHashSet - } - - private static final class GuideSummary { - String guide - String version - String status - boolean selfBaseline - String differencesInfo = '' - List differences = [] - } -} diff --git a/buildSrc/src/main/groovy/website/qa/AdHocFixtureDiff.groovy b/buildSrc/src/main/groovy/website/qa/AdHocFixtureDiff.groovy deleted file mode 100644 index 4de29240a65..00000000000 --- a/buildSrc/src/main/groovy/website/qa/AdHocFixtureDiff.groovy +++ /dev/null @@ -1,224 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package website.qa - -import groovy.transform.CompileStatic -import groovy.transform.ToString - -import org.jsoup.Jsoup -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element - -/** - * Structural comparison harness for guide rendering. Compares a locally - * rendered HTML page against a reference snapshot (typically a saved copy - * of the legacy {@code https://guides.grails.org/...} output) and returns - * a {@link Report} of structural deltas. - * - *

Compares (as listed in {@code phase-8-vendor-plan.md}):

- *
    - *
  1. Heading tree (h1-h6 text + hierarchy)
  2. - *
  3. Section count
  4. - *
  5. Anchor IDs (deep-link parity)
  6. - *
  7. Code-block count
  8. - *
  9. Image count
  10. - *
- * - *

This is a diagnostic harness, not a strict pass/fail gate. - * It surfaces gaps so the renderer config (templates, attributes, includes) - * can be iterated until the rendered output matches legacy structure.

- */ -@CompileStatic -class AdHocFixtureDiff { - - @ToString(includeNames = true) - static class Metrics { - int totalHeadings - int h1 - int h2 - int h3 - int h4 - int h5 - int h6 - int sections - int anchors - int codeBlocks - int images - Set headingTexts = [] as LinkedHashSet - Set anchorIds = [] as LinkedHashSet - } - - @ToString(includeNames = true) - static class Report { - File localFile - File baselineFile - Metrics local - Metrics baseline - List differences = [] - List commonAnchors = [] - List localOnlyAnchors = [] - List baselineOnlyAnchors = [] - - boolean isStructurallyEquivalent() { - differences.isEmpty() - } - - String toHumanReport() { - StringBuilder sb = new StringBuilder() - sb << '# Renderer Parity Report\n\n' - sb << "Local: `${localFile?.absolutePath}`\n" - sb << "Baseline: `${baselineFile?.absolutePath}`\n\n" - sb << '## Structural metrics\n\n' - sb << '| Metric | Local | Baseline | Delta |\n' - sb << '|---|---:|---:|---:|\n' - sb << "| Total headings | ${local.totalHeadings} | ${baseline.totalHeadings} | ${local.totalHeadings - baseline.totalHeadings} |\n" - sb << "| h1 | ${local.h1} | ${baseline.h1} | ${local.h1 - baseline.h1} |\n" - sb << "| h2 | ${local.h2} | ${baseline.h2} | ${local.h2 - baseline.h2} |\n" - sb << "| h3 | ${local.h3} | ${baseline.h3} | ${local.h3 - baseline.h3} |\n" - sb << "| h4 | ${local.h4} | ${baseline.h4} | ${local.h4 - baseline.h4} |\n" - sb << "| Sections | ${local.sections} | ${baseline.sections} | ${local.sections - baseline.sections} |\n" - sb << "| Anchor IDs | ${local.anchors} | ${baseline.anchors} | ${local.anchors - baseline.anchors} |\n" - sb << "| Code blocks | ${local.codeBlocks} | ${baseline.codeBlocks} | ${local.codeBlocks - baseline.codeBlocks} |\n" - sb << "| Images | ${local.images} | ${baseline.images} | ${local.images - baseline.images} |\n\n" - - sb << '## Anchor-ID overlap\n\n' - sb << "- Common: ${commonAnchors.size()}\n" - sb << "- Local-only: ${localOnlyAnchors.size()}\n" - sb << "- Baseline-only: ${baselineOnlyAnchors.size()}\n" - - if (!localOnlyAnchors.isEmpty()) { - sb << '\n### Local-only anchors (sample)\n\n' - localOnlyAnchors.take(20).each { sb << "- `${it}`\n" } - if (localOnlyAnchors.size() > 20) { - sb << "- ... ${localOnlyAnchors.size() - 20} more\n" - } - } - if (!baselineOnlyAnchors.isEmpty()) { - sb << '\n### Baseline-only anchors (broken deep-links if we cut over)\n\n' - baselineOnlyAnchors.take(20).each { sb << "- `${it}`\n" } - if (baselineOnlyAnchors.size() > 20) { - sb << "- ... ${baselineOnlyAnchors.size() - 20} more\n" - } - } - - sb << '\n## Differences\n\n' - if (differences.isEmpty()) { - sb << 'NONE -- structurally equivalent (within thresholds).\n' - } else { - differences.each { sb << "- ${it}\n" } - } - sb.toString() - } - } - - /** - * Builds a {@link Metrics} from an HTML document. - */ - static Metrics measure(Document doc) { - Metrics m = new Metrics() - m.h1 = doc.select('h1').size() - m.h2 = doc.select('h2').size() - m.h3 = doc.select('h3').size() - m.h4 = doc.select('h4').size() - m.h5 = doc.select('h5').size() - m.h6 = doc.select('h6').size() - m.totalHeadings = m.h1 + m.h2 + m.h3 + m.h4 + m.h5 + m.h6 - m.sections = doc.select('div.sect1, div.sect2, div.sect3, section').size() - m.codeBlocks = doc.select('pre').size() - m.images = doc.select('img').size() - - for (Element el : doc.select('h1, h2, h3, h4, h5, h6')) { - String text = el.text()?.trim() - if (text) m.headingTexts.add(text) - } - for (Element el : doc.select('[id]')) { - String id = el.attr('id')?.trim() - if (id) m.anchorIds.add(id) - } - m.anchors = m.anchorIds.size() - m - } - - /** - * Compares two HTML files and returns a structural-difference report. - * Threshold defaults are documented in {@code phase-8-vendor-plan.md}. - * - * @param localFile rendered locally (e.g. {@code build/dist/.../guide/single.html}) - * @param baselineFile reference snapshot (e.g. {@code buildSrc/src/test/resources/parity-baseline/.../index.html}) - */ - static Report compare(File localFile, File baselineFile) { - Document localDoc = Jsoup.parse(localFile, 'UTF-8') - Document baselineDoc = Jsoup.parse(baselineFile, 'UTF-8') - - Metrics local = measure(localDoc) - Metrics baseline = measure(baselineDoc) - - Report report = new Report( - localFile: localFile, - baselineFile: baselineFile, - local: local, - baseline: baseline, - ) - - report.commonAnchors = sortedStringList(local.anchorIds.intersect(baseline.anchorIds)) - report.localOnlyAnchors = sortedStringList(local.anchorIds - baseline.anchorIds) - report.baselineOnlyAnchors = sortedStringList(baseline.anchorIds - local.anchorIds) - - // Hard-fail conditions - if (local.totalHeadings == 0) { - report.differences.add('Local has zero headings -- renderer likely produced wrong file or empty output.') - } - if (baseline.totalHeadings > 0 && local.totalHeadings * 5 < baseline.totalHeadings) { - report.differences.add("Heading-count gap is severe: local=${local.totalHeadings} baseline=${baseline.totalHeadings}. Likely rendering only a TOC vs full single-page.".toString()) - } - if (!report.baselineOnlyAnchors.isEmpty()) { - report.differences.add("${report.baselineOnlyAnchors.size()} anchor IDs from the legacy site are missing locally -- deep-links to those would break post-cutover.".toString()) - } - - // Soft-warn conditions (still recorded as differences) - int sectionDelta = Math.abs(local.sections - baseline.sections) - int sectionMax = Math.max(local.sections, baseline.sections) - if (sectionMax > 0 && (sectionDelta * 100 / sectionMax) > 5) { - report.differences.add("Section count drift > 5%: local=${local.sections} baseline=${baseline.sections} (delta=${sectionDelta})".toString()) - } - int codeDelta = Math.abs(local.codeBlocks - baseline.codeBlocks) - int codeMax = Math.max(local.codeBlocks, baseline.codeBlocks) - if (codeMax > 0 && (codeDelta * 100 / codeMax) > 5) { - report.differences.add("Code-block count drift > 5%: local=${local.codeBlocks} baseline=${baseline.codeBlocks} (delta=${codeDelta})".toString()) - } - int imgDelta = Math.abs(local.images - baseline.images) - int imgMax = Math.max(local.images, baseline.images) - if (imgMax > 0 && (imgDelta * 100 / imgMax) > 5) { - report.differences.add("Image count drift > 5%: local=${local.images} baseline=${baseline.images} (delta=${imgDelta})".toString()) - } - - report - } - - private static List sortedStringList(Object collection) { - List result = [] - for (Object item : (collection as Iterable)) { - if (item instanceof String) { - result << (item as String) - } - } - result.sort() - result - } -} diff --git a/buildSrc/src/test/groovy/website/gradle/tasks/AcceptanceReportTaskSpec.groovy b/buildSrc/src/test/groovy/website/gradle/tasks/AcceptanceReportTaskSpec.groovy index e9a51c7dc51..0a6abddf6ea 100644 --- a/buildSrc/src/test/groovy/website/gradle/tasks/AcceptanceReportTaskSpec.groovy +++ b/buildSrc/src/test/groovy/website/gradle/tasks/AcceptanceReportTaskSpec.groovy @@ -35,7 +35,6 @@ class AcceptanceReportTaskSpec extends Specification { createGuideVersion(project) writeCsv(new File(project.buildDir, 'reports/asciidoctor-warning-gate.csv'), 'demo-guide,1,VERIFIED,0,clean') writeCsv(new File(project.buildDir, 'reports/crawl-built-guides.csv'), 'demo-guide,1,VERIFIED,0,clean') - writeCsv(new File(project.buildDir, 'reports/structural-diff-guides.csv'), 'demo-guide,1,VERIFIED,0,clean') new File(project.buildDir, 'reports/csp-scan.md').with { parentFile.mkdirs() text = '# CSP Scan Report\n\n## Result: CLEAN\n' @@ -47,7 +46,7 @@ class AcceptanceReportTaskSpec extends Specification { task.report() then: - task.reportFile.get().asFile.text.contains('demo-guide,1,VERIFIED,VERIFIED,VERIFIED,VERIFIED,VERIFIED') + task.reportFile.get().asFile.text.contains('demo-guide,1,VERIFIED,VERIFIED,VERIFIED,VERIFIED') } def 'writes only the header when there are no guide versions or reports'() { @@ -60,7 +59,7 @@ class AcceptanceReportTaskSpec extends Specification { task.report() then: - task.reportFile.get().asFile.readLines() == ['guide,version,asciidoctorWarningGate,crawlBuiltGuides,structuralDiffGuides,cspScan,verdict,details'] + task.reportFile.get().asFile.readLines() == ['guide,version,asciidoctorWarningGate,crawlBuiltGuides,cspScan,verdict,details'] } def 'throws in hard-fail mode when any gate reports FAILED'() { @@ -69,7 +68,6 @@ class AcceptanceReportTaskSpec extends Specification { createGuideVersion(project) writeCsv(new File(project.buildDir, 'reports/asciidoctor-warning-gate.csv'), 'demo-guide,1,FAILED,1,warning found') writeCsv(new File(project.buildDir, 'reports/crawl-built-guides.csv'), 'demo-guide,1,VERIFIED,0,clean') - writeCsv(new File(project.buildDir, 'reports/structural-diff-guides.csv'), 'demo-guide,1,VERIFIED,0,clean') new File(project.buildDir, 'reports/csp-scan.md').with { parentFile.mkdirs() text = '# CSP Scan Report\n\n## Result: CLEAN\n' @@ -84,7 +82,7 @@ class AcceptanceReportTaskSpec extends Specification { then: def error = thrown(GradleException) error.message.contains('Acceptance report contains FAILED rows') - task.reportFile.get().asFile.text.contains('demo-guide,1,FAILED,VERIFIED,VERIFIED,VERIFIED,FAILED') + task.reportFile.get().asFile.text.contains('demo-guide,1,FAILED,VERIFIED,VERIFIED,FAILED') } def 'maps guide-specific CSP report entries with windows separators to REVIEW'() { @@ -93,7 +91,6 @@ class AcceptanceReportTaskSpec extends Specification { createGuideVersion(project) writeCsv(new File(project.buildDir, 'reports/asciidoctor-warning-gate.csv'), 'demo-guide,1,VERIFIED,0,clean') writeCsv(new File(project.buildDir, 'reports/crawl-built-guides.csv'), 'demo-guide,1,VERIFIED,0,clean') - writeCsv(new File(project.buildDir, 'reports/structural-diff-guides.csv'), 'demo-guide,1,VERIFIED,0,clean') new File(project.buildDir, 'reports/csp-scan.md').with { parentFile.mkdirs() text = '# CSP Scan Report\n\nNon-allowlisted hosts found: 1\n\n## Violations\n\n- `guides\\demo-guide\\1\\guide\\single.html`\n' @@ -105,7 +102,7 @@ class AcceptanceReportTaskSpec extends Specification { task.report() then: - task.reportFile.get().asFile.text.contains('demo-guide,1,VERIFIED,VERIFIED,VERIFIED,REVIEW,REVIEW') + task.reportFile.get().asFile.text.contains('demo-guide,1,VERIFIED,VERIFIED,REVIEW,REVIEW') } private void createGuideVersion(def project) { diff --git a/buildSrc/src/test/groovy/website/gradle/tasks/StructuralDiffGuidesTaskSpec.groovy b/buildSrc/src/test/groovy/website/gradle/tasks/StructuralDiffGuidesTaskSpec.groovy deleted file mode 100644 index 0ead64cfba6..00000000000 --- a/buildSrc/src/test/groovy/website/gradle/tasks/StructuralDiffGuidesTaskSpec.groovy +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -package website.gradle.tasks - -import org.gradle.api.GradleException -import org.gradle.testfixtures.ProjectBuilder - -import spock.lang.Specification -import spock.lang.TempDir - -class StructuralDiffGuidesTaskSpec extends Specification { - - @TempDir - File tempDir - - def 'extracts a structural fingerprint and marks the guide REVIEW until a production baseline exists'() { - given: - def project = ProjectBuilder.builder().withProjectDir(tempDir).build() - createStructuralFile(project, ''' - - -

Intro

-
-

Details

-
code
- -
- - -''') - StructuralDiffGuidesTask.register(project) - - when: - def task = project.tasks.getByName(StructuralDiffGuidesTask.NAME) as StructuralDiffGuidesTask - task.diff() - - then: - task.reportFile.get().asFile.text.contains('demo-guide,1,REVIEW,0') - new File(project.buildDir, 'reports/structural-fingerprints/demo-guide/1.json').text.contains('Intro') - } - - def 'writes only the header when no rendered guides exist'() { - given: - def project = ProjectBuilder.builder().withProjectDir(tempDir).build() - StructuralDiffGuidesTask.register(project) - - when: - def task = project.tasks.getByName(StructuralDiffGuidesTask.NAME) as StructuralDiffGuidesTask - task.diff() - - then: - task.reportFile.get().asFile.readLines() == ['guide,version,status,issueCount,details'] - } - - def 'throws in hard-fail mode when the rendered file has no headings'() { - given: - def project = ProjectBuilder.builder().withProjectDir(tempDir).build() - createStructuralFile(project, '
') - StructuralDiffGuidesTask.register(project) - def task = project.tasks.getByName(StructuralDiffGuidesTask.NAME) as StructuralDiffGuidesTask - task.failOnViolation.set(true) - - when: - task.diff() - - then: - def error = thrown(GradleException) - error.message.contains('Structural guide differences detected') - task.reportFile.get().asFile.text.contains('demo-guide,1,FAILED,1') - } - - private File createStructuralFile(def project, String html) { - File file = new File(project.buildDir, 'dist/guides/demo-guide/1/guide/single.html') - file.parentFile.mkdirs() - file.text = html.trim() - file - } -} diff --git a/buildSrc/src/test/resources/parity-baseline/creating-your-first-grails-app-v6/index.html b/buildSrc/src/test/resources/parity-baseline/creating-your-first-grails-app-v6/index.html deleted file mode 100644 index 74d408939a5..00000000000 --- a/buildSrc/src/test/resources/parity-baseline/creating-your-first-grails-app-v6/index.html +++ /dev/null @@ -1,2937 +0,0 @@ - - - - - - - - - Creating your first Grails Application | Grails Guides | Grails Framework - - - - - -
-
- - Show Navigation -
- - -
-
-
- -
-

Creating your first Grails Application

-

Learn how to create your first Grails app

-

Authors: Zachary Klein

-

Grails Version: 5.0.1

-
- - -

1 Grails Training

- -
- -
- - -
-

Grails Training - Developed and delivered by the folks who created and actively maintain the Grails framework!.

-
-
- - - -

2 Getting Started

- -
- -
- - -
-

In this guide you are going to create your first Grails application. You will learn about domain classes, controllers, services, GSPs, and unit & integration tests. This guide is aimed at developers who are new to Grails or would like a refresher course on the framework.

-
- - -

2.1 What you will need

- -
- -
- - -
-

To complete this guide, you will need the following:

-
-
-
    -
  • -

    Some time on your hands

    -
  • -
  • -

    A decent text editor or IDE

    -
  • -
  • -

    JDK 1.8 or greater installed with JAVA_HOME configured appropriately

    -
  • -
-
- - -

2.2 How to complete the guide

- -
- -
- - -
-

To get started do the following:

-
-
- -
-
-

or

-
- -
-

The Grails guides repositories contain two folders:

-
-
-
    -
  • -

    initial Initial project. Often a simple Grails app with some additional code to give you a head-start.

    -
  • -
  • -

    complete A completed example. It is the result of working through the steps presented by the guide and applying those changes to the initial folder.

    -
  • -
-
-
-

To complete the guide, go to the initial folder

-
-
-
    -
  • -

    cd into grails-guides/creating-your-first-grails-app/initial

    -
  • -
-
-
-

and follow the instructions in the next sections.

-
-
- - - - - -
- - -You can go right to the completed example if you cd into grails-guides/creating-your-first-grails-app/complete -
-
- - -

3 Creating your Grails App

- -
- -
- - -
-

For this guide, you can either create the starting project yourself, or use the initial project included in the guide’s repo to get started. If you choose to use the initial project, you can safely skip this section and continue to [runningTheApp] If you would like to install Grails on your computer, follow one of the options below.

-
- - -

3.1 Installing Grails

- -
- -
- - -
-

Installing Grails

-
-
-

SDKMan

-
-

'sdkman' is a popular command line utility for installing and managing Grails installations (as well as other JVM frameworks and languages). Install sdkman by running the following command in your Unix terminal:

-
-
-
-
$ curl -s "https://get.sdkman.io" | bash
-
-
-
-

Once the installation is complete, install the latest version of Grails (this guide uses 4.0.1):

-
-
-
-
$ sdk install grails 4.0.1
-
-
-
-

sdkman will prompt you to choose whether to set this version as the default (choose 'yes').

-
-
-
-
$ grails --version
-
-| Grails Version: 4.0.1
-| JVM Version: 1.8.0_77
-
-
-
- - - - - -
- - -If you are running Windows, there is a clone project of sdkman available that follows the same conventions. You can download it from https://github.com/flofreud/posh-gvm -
-
-
-
-

Download from Grails.org

-
-

This is not the recommend way to install Grails, but here are the manual installation steps, if the above methods fails.

-
-
-

Download the Grails binary package from https://grails.org. Unpack the package in a convenient directory.

-
-
-
-
$ unzip ~/Downloads/grails-4.0.1.zip
-
-
-
-

Edit your .bashrc (most Linux flavors) or .bash_profile file with the following environment variables (add these to the end of the file)

-
-
-

Set the GRAILS_HOME environment variable to the location where you extracted the zip

-
-
-
-
export GRAILS_HOME=/path/to/grails-4.0.1
-
-
-
-

On Windows you can create an environment variable under My Computer/Advanced/Environment Variables

-
-
-

Now add the Grails bin directory to your PATH variable:

-
-
-
-
export GRAILS_HOME=/path/to/grails-4.0.1
-export PATH="$PATH:$GRAILS_HOME/bin
-
-
-
-

Again, on Windows you will need to modify the Path environment variable under My Computer/Advanced/Environment Variables

-
-
-

You should now be able to type grails -version in the terminal window and verify that Grails has been installed successfully:

-
-
-
-
$ grails --version
-
-| Grails Version: 4.0.1
-| JVM Version: 1.8.0_77
-
-
-
-
-
- - -

3.2 Grails Application Forge

- -
- -
- - -
- - - - - -
- - -
-

Did you know you can download a complete Grails project without installing any additional tools? Go to start.grails.org and use the Grails Application Forge to generate your Grails project. You can choose your project type (Application or Plugin), pick a version of Grails, and choose a Profile - then click "Generate Project" to download a ZIP file. No Grails installation necessary!

-
-
-

You can even download your project from the command line using a HTTP tool like curl (see start.grails.org for API documentation):

-
-
-
-
curl -O start.grails.org/myapp.zip -d version=4.0.1
-
-
-
-
- - -

3.3 Create the App

- -
- -
- - -
-

Creating a Grails app is about as simple as could be - once you have installed Grails, simply run the create-app command:

-
-
-
-
$ grails create-app myApp
-
-
-
-

If you don’t specify a package, the app name will be used as the default package for the application (e.g., myapp). -You can edit the default package at grails-app/conf/application.yml -You can optionally specify a default package for the application:

-
-
-
-
$ grails create-app org.grails.guides.myApp
-
-
- - -

3.4 Application Profile

- -
- -
- - -
-

You can optionally specify a Profile for your Grails app. Profiles are available for many common application types, including rest-api, angular, react, and others, and you can even create your own.

-
-
-

To view of a list of what profiles are available, use the list-profiles command.

-
-
-
-
$ grails list-profiles
-
-| Available Profiles
---------------------
-* angular - A profile for creating applications using AngularJS
-* rest-api - Profile for REST API applications
-* base - The base profile extended by other profiles
-* angular2 - A profile for creating Grails applications with Angular 2
-* plugin - Profile for plugins designed to work across all profiles
-* profile - A profile for creating new Grails profiles
-* react - A profile for creating Grails applications with a React frontend
-* rest-api-plugin - Profile for REST API plugins
-* web - Profile for Web applications
-* web-plugin - Profile for Plugins designed for Web applications
-* webpack - A profile for creating applications with node-based frontends using webpack
-
-
-
-

To use a profile, specify its name preceded by the -profile flag:

-
-
-
-
grails create-app myApp -profile rest-api
-
-
-
-

You may optionally specify a package and version (defaults to org.grails.profiles and the current version of the profile)

-
-
-
-
grails create-app myApp -profile org.grails.profiles:react:1.0.2
-
-
-
-

To get detailed information about a profile use the profile-info command.

-
-
-
-
$ grails profile-info plugin
-
-Profile: plugin
---------------------
-Profile for plugins designed to work across all profiles
-
-Provided Commands:
---------------------
-| Error Error occurred loading commands: grails.dev.commands.ApplicationContextCommandRegistry (Use --stacktrace to see the full trace)
-| Error Error occurred loading commands: grails.dev.commands.ApplicationContextCommandRegistry (Use --stacktrace to see the full trace)
-* package-plugin - Packages the plugin into a JAR file
-* publish-plugin - Publishes the plugin to the Grails central repository
-* help - Prints help information for a specific command
-* open - Opens a file in the project
-* gradle - Allows running of Gradle tasks
-* clean - Cleans a Grails application's compiled sources
-* compile - Compiles a Grails application
-* create-command - Creates an Application Command
-* create-domain-class - Creates a Domain Class
-* create-service - Creates a Service
-* create-unit-test - Creates a unit test
-* install - Installs a Grails application or plugin into the local Maven cache
-* assemble - Creates a JAR or WAR archive for production deployment
-* bug-report - Creates a zip file that can be attached to issue reports for the current project
-* console - Runs the Grails interactive console
-* create-script - Creates a Grails script
-* dependency-report - Prints out the Grails application's dependencies
-* list-plugins - Lists available plugins from the Plugin Repository
-* plugin-info - Prints information about the given plugin
-* run-app - Runs a Grails application
-* run-command - Executes Grails commands
-* run-script - Executes Groovy scripts in a Grails context
-* shell - Runs the Grails interactive shell
-* stats - Prints statistics about the project
-* stop-app - Stops the running Grails application
-* test-app - Runs the applications tests
-
-Provided Features:
---------------------
-* asset-pipeline - Adds Asset Pipeline to a Grails project
-* hibernate4 - Adds GORM for Hibernate 4 to the project
-* hibernate5 - Adds GORM for Hibernate 5 to the project
-* json-views - Adds support for JSON Views to the project
-* less-asset-pipeline - Adds LESS Transpiler Asset Pipeline to a Grails project
-* markup-views - Adds support for Markup Views to the project
-* mongodb - Adds GORM for MongoDB to the project
-* neo4j - Adds GORM for Neo4j to the project
-* rx-mongodb - Adds RxGORM for MongoDB to the project
-* asset-pipeline-plugin - Adds Asset Pipeline to a Grails Plugin for packaging
-
-
-
- - - - - -
- - -When you create an app without -profile the default profile used is the web profile. -
-
- - -

4 Running the App

- -
- -
- - -
-

Now that you’ve created (or downloaded) your Grails project, it’s time to run it and see what Grails has already given you.

-
-
-

If you have a local installation of Grails, you can run the app using the run-app command from within your project:

-
-
-
-
$ cd myApp/
-
-
-
-

Run the App without Grails Installed

-
-
-

Thanks to the Grails Wrapper, as of Grails version 3.2.3 or later, you can run any Grails command without having -Grails installed. If you download it from the Grails Application Forge, the Grails Wrapper is included too.

-
-
-
-
$ ./grailsw run-app
-
-
-
-

Run the App with Grails

-
-
-

If you have Grails installed in your machine, simply type:

-
-
-
-
$ grails run-app
-
-
-
-

Run the App with Grails Interactive Mode

-
-
-

You can also use Grails' Interactive Mode to run the Grails runtime, from which you can issue any Grails command without waiting for the runtime to spin up for each task.

-
-
-

In this guide we will be preferring the Grails wrapper for most commands.

-
-
-
-
$ ./grailsw
-
-| Enter a command name to run. Use TAB for completion:
-grails>run-app      //you can shutdown the app with the stop-app command
-
-
-
-

Run the App with Gradle

-
-
-

Finally, because Grails is built upon Spring Boot and Gradle, you can also use Spring Boot commands like bootRun to interact with your Grails application. These commands are available as Gradle tasks. Just as with Grails itself, there’s no need to install Gradle on your machine. It will be downloaded automatically when using the the Gradle Wrapper (gradlew)

-
-
-
-
$ ./gradlew bootRun
-
-
-
-

After running any of the above commands, Grails will launch your application using an embedded Tomcat server, and make it available (by default) at http://localhost:8080.

-
-
-
-
$ ./grailsw run-app
-
-| Running application...
-Grails application running at http://localhost:8080 in environment: development
-
-
- - -

4.1 Change Default Port

- -
- -
- - -
-

If you’d like to change the port number, simply edit the application.yml file under grails-app/conf/, and add the following line:

-
-
-
-
server:
-    port: 8090
-
-
-
-

You can supply the port number directly in the command invocation:

-
-
-
-
$ ./grailsw run-app --port=8090
-
-| Running application...
-Grails application running at http://localhost:8090 in environment: development
-
-
- - -

4.2 Auto Reloading

- -
- -
- - -
-

Currently the app simply displays a default index page with some information about the application. The default index page is located under grails-app/views/index.gsp.

-
-
-

Go ahead and edit that file, and change some thing on the page, as shown below (line 54):

-
-
-
-
    <div id="content" role="main">
-        <section class="row colset-2-its">
-            <h1>Welcome to My First Grails Project</h1>  (1)
-
-
-
- - - - - -
1Change the text of the <h1> tag.
-
-
-

Save your changes and refresh the page in the browser. You should see your change on the home page. Grails will auto-reload changes to views, controllers, domain classes and other artefacts when it detects changes, so you don’t need to restart the app upon every change.

-
-
- - - - - -
- - -Major changes to domain class associations, renaming classes, and other operations that affect the "wiring" of the application may not successfully reload. -
-
- - -

5 Domain Classes

- -
- -
- - -
-

Grails is a Model View Controller (MVC) framework, based upon the Spring Boot project. Typically an MVC application divides the responsibilities of the app into three categories:

-
-
-
    -
  1. -

    Model - code that defines and manages the data

    -
  2. -
  3. -

    View - code that manages the presentation of data (e.g, HTML page)

    -
  4. -
  5. -

    Controller - code that defines the logic of the web application, and manages the communication between the model and the view. Controllers respond to requests, obtain data from the model, and pass it along to the view.

    -
  6. -
-
-
-

Typically an object-oriented MVC framework requires the developer to configure which classes correspond to each of the three categories above. However, Grails goes farther than most frameworks by following a "Convention over Configuration" approach to development. This means that for many artefact types in Grails (controllers, views, etc), you simply create a file in a particular directory in your project, and Grails will automatically wire it into your application without any additional configuration on your part.

-
-
- - - - - -
- - -Handling the mapping of domain classes to database tables (and other persistent stores) is the job of GORM, the Grails Object Relational Mapper. GORM is a powerful tool in the Grails framework, and can even be used standalone outside of a Grails project. It supports relational databases (via Hibernate) as well as MongoDb, Neo4j, Redis and Cassandra datasources. Please see the GORM documentation for more information. -
-
-
-

When building a MVC application, it is typical to start with the "M" (Model), also known as the "Domain model". In Grails, your domain model is defined with Groovy classes under grails-app/domain. Let’s create a domain class.

-
- - -

5.1 Creating a Domain Class

- -
- -
- - -
-

Domain classes can be generated by Grails (in which case Grails will helpfully create a unit test automatically), or you can simply create the file yourself.

-
-
-
-
$ ./grailsw create-domain-class Vehicle
-
-| Created grails-app/domain/org/grails/guides/Vehicle.groovy
-| Created src/test/groovy/org/grails/guides/VehicleSpec.groovy
-
-
-
-

This will generate two Groovy files, one being our domain class, and the other a unit test. Let’s see what our domain class looks like.

-
-
-
-
package org.grails.guides
-
-class Vehicle {
-
-    static constraints = {
-    }
-}
-
-
-
-

Right now our domain class has no properties, and no constraints. That’s not very interesting, but it’s worth noting that this is all that’s needed to wire up a persistent domain class in our application. By default, Hibernate will be used to configure a datasource (an in-memory H2 database by default) and create tables and associations for all Groovy classes under grails-app/domain. Let’s add some properties to this domain class:

-
-
-
-
package org.grails.guides
-
-class Vehicle {
-
-    String name (1)
-
-    String make
-    String model
-
-    static constraints = { (2)
-        name maxSize: 255
-        make inList: ['Ford', 'Chevrolet', 'Nissan']
-        model nullable: true
-    }
-}
-
-
-
- - - - - - - - - -
1Properties will be used to create columns in the database (assuming a relational database is used)
2Constraints are used to enforce valid data in each field - Grails provides a rich set of constraints for common scenarios, and you can define custom constraints as well
-
-
-

Please see the Grails documentation for a complete list and documentation of how to use domain classes and constraints

-
- - -

5.2 DB Console

- -
- -
- - -
-

If you run the app again, you should see the same page as before. However, you can login to the DB Console and view your new database table.

-
-
-

Browse to http://localhost:8080/dbconsole and login. The default username is sa, without a password. The default JDBC URL is: jdbc:h2:mem:devDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE

-
-
-
-DB Console -
-
-
- - - - - -
- - -You can view the JDBC url in application.yml, under environments development dataSource url -
-
-
-

Once you’ve logged in to the DB Console, you should see your new VEHICLES table in the left-hand sidebar. Click the + icon to expand the table - you should see a list of columns, including the three String fields we just defined, name, make, and model.

-
-
-
-DB Console -
-
- - -

5.3 Expanding the Domain Model

- -
- -
- - -
-

For our Vehicle class, it doesn’t really make sense that make and model are plain Strings, since models should actually be associated with makes. Let’s update our domain model to be more robust.

-
-
-

Create two new domain classes:

-
-
-
-
$ ./grailsw create-domain-class Make
-
-| Created grails-app/domain/org/grails/guides/Make.groovy
-| Created src/test/groovy/org/grails/guides/Make.groovy
-
-$ ./grailsw create-domain-class Model
-
-| Created grails-app/domain/org/grails/guides/Model.groovy
-| Created src/test/groovy/org/grails/guides/Model.groovy
-
-
-
-

Edit these two files with the following content:

-
-
-
grails-app/domain/org/grails/guides/Make.groovy
-
-
package org.grails.guides
-
-class Make {
-
-    String name
-
-    static constraints = {
-    }
-
-    String toString() {
-        name
-    }
-}
-
-
-
-
grails-app/domain/org/grails/guides/Model.groovy
-
-
package org.grails.guides
-
-class Model {
-
-    String name
-
-    static belongsTo = [ make: Make ]
-
-    static constraints = {
-    }
-
-    String toString() {
-        name
-    }
-}
-
-
-
- - - - - -
- - -The belongsTo property is one of several properties that GORM uses to determine associations between domain classes. Others include hasMany and hasOne. For more information please see the GORM documentation. -
-
-
-

Now, update Vehicle.groovy to use the new Make and Model classes instead of String.

-
-
-
grails-app/domain/org/grails/guides/Vehicle.groovy
-
-
package org.grails.guides
-
-@SuppressWarnings('GrailsDomainReservedSqlKeywordName')
-class Vehicle {
-
-    Integer year
-
-    String name
-    Model model
-    Make make
-
-    static constraints = {
-        year min: 1900
-        name maxSize: 255
-    }
-}
-
-
-
-

Grails (via GORM) will now create three tables in our database, for our three domain classes, and create the necessary associations between the tables. Run the app again and open the DB Console to view the new tables.

-
- - -

5.4 Bootstrapping Data

- -
- -
- - -
-

Every Grails project includes a BootStrap.groovy file under grails-app/init. This file can be used for any custom logic you want to happen during application startup. One excellent use of the file is to preload some data in our database. Let’s create a few instances of our three domain classes.

-
-
-

Edit grails-app/init/org/grails/guides/BootStrap.groovy, as shown below:

-
-
-
grails-app/init/org/grails/guides/BootStrap.groovy
-
-
package org.grails.guides
-
-import groovy.transform.CompileStatic
-
-@CompileStatic
-class BootStrap {
-
-    MakeService makeService
-    ModelService modelService
-    VehicleService vehicleService
-    def init = { servletContext ->
-
-        Make nissan = makeService.save('Nissan')
-        Make ford = makeService.save( 'Ford')
-
-        Model titan = modelService.save('Titan', nissan)
-        Model leaf = modelService.save('Leaf', nissan)
-        Model windstar = modelService.save('Windstar', ford)
-
-        vehicleService.save('Pickup', nissan, titan, 2012).save()
-        vehicleService.save('Economy', nissan, leaf, 2014).save()
-        vehicleService.save('Minivan', ford, windstar, 1990).save()
-    }
-    def destroy = {
-    }
-}
-
-
-
-

Now restart the application, and browse to the DBConsole, you should be able to expand the three tables and see our newly created data.

-
- - -

5.5 Datasources

- -
- -
- - -
-

By default, Grails configures an in-memory H2 database, which is dropped and recreated every time the app is restarted. This will be sufficient for our purposes in this guide, however, you can easily change this to a local database instance by configuring your own datasource. We’ll use MySQL as an example.

-
- - -

5.6 Configure MySQL Datasource

- -
- -
- - -
-

Edit build.gradle

-
-
-
build.gradle
-
-
dependencies {
-    //...
-
-    runtime 'mysql:mysql-connector-java:5.1.40' (1)
-
-
-
- - - - - -
1Add the MySQL JDBC driver as a dependency
-
-
- - - - - -
- - -Be sure to add the dependency to the dependencies section of the build.gradle file, and not the buildscript/dependencies section. The former is for application dependencies (needed at compile time, runtime, or for testing), whereas the buildscript dependencies is for those needed as part of the Gradle build process (managing static assets, for example). -
-
-
-

Edit application.yml

-
-
-
grails-app/conf/application.yml
-
-
dataSource:
-    pooled: true
-    jmxExport: true
-    driverClassName: com.mysql.jdbc.Driver   (1)
-    dialect: org.hibernate.dialect.MySQL5InnoDBDialect
-    username: sa
-    password: testing
-environments:
-    development:
-        dataSource:
-            dbCreate: update
-            url: jdbc:mysql://127.0.0.1:3306/myapp  (2)
-
-
-
- - - - - - - - - -
1Change the driverClassName and dialect to MySQL settings
2This assumes you have a local MySQL instance with a database named myapp
-
- - -

5.7 Grails Console

- -
- -
- - -
-

Right now we don’t have any controllers or views set up to play with our domain model. We’ll get there shortly, but for now, let’s fire up the Grails Console so we can explore what Grails and GORM have to offer.

-
-
-

If the application is still running, shut it down with either kbd:[Ctrl+C], or (if running Grails in Interactive Mode, the stop-app command).

-
-
-

Launch the Grails Console:

-
-
-
-
$ ./grailsw console
-
-
-
-

The Grails Console application will launch. This application is based on the Groovy Console, but has the added benefit that our entire Grails application is up and running in the background, so we can access our domain classes and even persist to the database from the Console.

-
-
-

Try playing with our new domain model from the Console. Here’s a simple script to get you started - again, refer to the GORM documentation for more details on querying, persistence, configuration and more.

-
-
-
docs/console.groovy
-
-
import org.grails.guides.*
-
-def vehicles = Vehicle.list()
-
-println vehicles.size()
-
-def pickup = Vehicle.findByName("Pickup")
-
-println pickup.name
-println pickup.make.name
-println pickup.model.name
-
-def nissan = Make.findByName("Nissan")
-
-def nissans = Vehicle.findAllByMake(nissan)
-
-println nissans.size()
-
-
- - -

6 Controllers

- -
- -
- - -
-

This section will focus on the basics of creating a controller and defining actions.

-
-
- - - - - -
- - -While not part of the "MVC" triad, Grails also provides support for services. In a Grails application of any complexity, it is considered best practice to keep core application logic in services. We’ll get to services later on in this guide. -
-
-
-

Following the convention over configuration principle, Grails will configure any Groovy classes under grails-app/controllers/ as controllers, without any additional configuration. You can create the Groovy class yourself, or use the create-controller command to generate the controller and an associated test spec.

-
-
-
-
$ ./grailsw create-controller org.grails.guides.Home
-
-| Created grails-app/controllers/org/grails/guides/HomeController.groovy
-| Created src/test/groovy/org/grails/guides/HomeControllerSpec.groovy
-
-
-
- - - - - -
- - -Note that Grails adds the *Controller suffix automatically. -
-
-
-

Let’s take a look at our new controller.

-
-
-
grails-app/controllers/org/grails/guides/HomeController.groovy
-
-
package org.grails.guides
-
-class HomeController {
-
-    def index() { }
-}
-
-
-
-

Grails has created a controller with a single action. Actions are public methods in a controller, which can respond to requests. Typically, a controller action will receive a request, obtain some data (optionally using parameters or a body of the request, if present), and render the result to the browser (e.g, as a webpage). Controller actions can also redirect requests, forward, call service methods, and return HTTP response codes. See the Grails documentation for more on controller actions.

-
-
-

We don’t have any need for logic in this particular action just yet, but we would like it to render a page. We’ll look at GSP pages in more detail in the [Views] section, but for now, let’s create a very simple GSP page for our HomeController.index action to display.

-
-
-

Create the file index.gsp under the grails-app/views/home directory.

-
-
-
grails-app/views/home/index.gsp
-
-
<html>
-<html>
-<head>
-    <meta name="layout" content="main"/>
-    <title>Home Page</title>
-</head>
-<body>
-
-<div id="content" role="main">
-    <section class="row colset-2-its">
-        <h1>Welcome to our Home Page!</h1>
-    </section>
-</div>
-
-</body>
-</html>
-
-
-
-

Run the application again and browser to http:localhost:8080/home. You should see your new page.

-
-
-

By convention, Grails will map controller actions to views with the same name, in the grails-app/views/[controller name] directory. You can override this and specify a particular view (or render different content altogether).

-
-
-

We’ll cover views and GSPs in more detail in the next section, but for now, you should note that our index.gsp file is basically an HTML page, with a couple unusual tags. Feel free to modify this new home page however you would like.

-
- - -

6.1 URL Mappings

- -
- -
- - -
-

Now that we have our new "home" page, it would be nice if it was the app’s landing page instead of the Grails default. To do that, we’ll need to change our UrlMappings.groovy file.

-
-
-

Grails uses the UrlMappings.groovy file to route request to the proper controller and action. They can be as simple as URI strings that redirect to a controller and/or action, or they can include wildcards and constraints and become quite sophisticated.

-
-
- - - - - -
- - -Learn much more about URL Mappings from the Grails documentation -
-
-
-

Let’s look at the default URLMappings.groovy file.

-
-
-
grails-app/controllers/org/grails/guides/UrlMappings.groovy
-
-
package org.grails.guides
-
-class UrlMappings {
-
-    static mappings = {
-        "/$controller/$action?/$id?(.$format)?"{  (1)
-            constraints {
-                // apply constraints here
-            }
-        }
-
-        "/"(view:"/index")   (2)
-        "500"(view:'/error')
-        "404"(view:'/notFound')
-    }
-}
-
-
-
- - - - - - - - - -
1Grails default URL mapping - this rule causes requests to be mapped to controller and action (and optionally ID and/or format) based on names. So home/index will map to HomeController, index action
2This URL mapping points the root URI (/) to a specific view.
-
-
-

Let’s change the / rule to point to our new HomeController. Edit the line as follows:

-
-
-
grails-app/controllers/org/grails/guides/UrlMappings.groovy
-
-
package org.grails.guides
-
-class UrlMappings {
-
-    static mappings = {
-//...
-
-        "/"(controller:"home")   (1)
-//...
-    }
-}
-
-
-
- - - - - -
1Change view: "/index" to controller: "home"
-
-
-

By convention, a request to a controller without an action name will go to an index action, if it exists (if not, an error will be thrown). You can change this behavior if you want by specifying a defaultAction property in the controller:

-
-
-
grails-app/controllers/org/grails/guides/HomeController.groovy
-
-
package org.grails.guides
-
-class HomeController {
-
-    static defaultAction = "homePage"
-
-    def homePage() { } (1)
-}
-
-
-
- - - - - -
1Don’t make this change, this is just for demonstration purposes
-
-
-

Now that you’ve changed the / rule to point to your new HomeController, if you restart the app and browser to http://localhost:8080, you should be presented with your new home page.

-
- - -

6.2 Scaffolding

- -
- -
- - -
-

We’d like to have actions to allow us to create new domain class instances and persist them to the database. In addition, we’d like to have the ability to edit existing instances and even delete them. Normally all this functionality would require a lot of coding, but Grails gives us a headstart with scaffolding.

-
-
- - - - - -
- - -Learn more about scaffolding in the Grails documentation. -
-
- - -

6.3 Dynamic Scaffolding

- -
- -
- - -
-

Now that we have a home page, let’s create controllers to manage the domain model we created earlier. Create 3 new controllers for each of the domain classes, Vehicle, Make, and Model.

-
-
-
-
$ ./grailsw create-controller Vehicle
-
-| Created grails-app/controllers/org/grails/guides/VehicleController.groovy
-| Created src/test/groovy/org/grails/guides/VehicleControllerSpec.groovy
-
-$ ./grailsw create-controller Make
-
-| Created grails-app/controllers/org/grails/guides/MakeController.groovy
-| Created src/test/groovy/org/grails/guides/MakeControllerSpec.groovy
-
-$ ./grailsw create-controller Model
-
-| Created grails-app/controllers/org/grails/guides/ModelController.groovy
-| Created src/test/groovy/org/grails/guides/ModelControllerSpec.groovy
-
-
-
-

To use scaffolding, edit the three controllers we just created, and replace the default index action with a scaffolding property as shown in the examples below.

-
-
-
grails-app/controllers/org/grails/guides/VehicleController.groovy
-
-
package org.grails.guides
-
-class VehicleController {
-
-    static scaffold = Vehicle
-}
-
-
-
-
grails-app/controllers/org/grails/guides/MakeControler.groovy
-
-
package org.grails.guides
-
-class MakeControler {
-
-    static scaffold = Make
-}
-
-
-
-
grails-app/controllers/org/grails/guides/ModelController.groovy
-
-
package org.grails.guides
-
-class ModelController {
-
-    static scaffold = Model
-}
-
-
-
-

With the scaffold property set, Grails will now generate all necessary CRUD (Create, Read, Update, Delete) actions for the respective domain classes. It will also dynamically generate views with list, create, show and edit pages using our domain properties and associations. This can give you a big leg up when putting together the beginnings of an application.

-
-
-

Restart the app, and browse to http://localhost:8080/vehicle - you should see a list of the Vehicle instances we added to our BootStrap. Try out the new views and create, view, edit and delete some instances. You can also do the same with the Model and Make controllers.

-
- - -

6.4 Static Scaffolding

- -
- -
- - -
-

Dynamic scaffolding is powerful and sometimes will provide all the functionality you need (especially for an administrative site where data access is more important than presentation). But it’s quite likely that you will feel the need to customize the generated views and controllers, either to change their appearance or to add custom logic and functionality. Grails anticipates this need and provides a set of generate commands that can generate the controllers and/or views that you just saw, allowing you to modify them to suite your needs.

-
-
-

To generate the views (and continue to use the dynamic scaffolding):

-
-
-
-
$ ./grailsw generate-views Vehicle
-
-
-
-

To generate the controller (and continue to use the dynamic GSP views):

-
-
-
-
$ ./grailsw generate-controller Vehicle
-
-
-
-

To both views and controllers (bypassing all dynamic generation):

-
-
-
-
$ ./grailsw generate-all Vehicle
-
-
-
-

The generated controller will be placed under grails-app/controller, and the generated views will be placed under grails-app/views/vehicle.

-
-
- - - - - -
- - -To override existing files, use th -force flag along with the generate-* command: ./grailsw generate-all com.example.Vehicle -force -
-
-
-

Let’s generate the controller and views for Vehicle and take a look at the resulting controller.

-
-
-
-
$ ./grailsw generate-all Vehicle -force
-
-
-
-

Open the VehicleController.groovy file at grails-app/controllers/org/grails/guides/.

-
-
-
-
import static org.springframework.http.HttpStatus.NOT_FOUND
-import static org.springframework.http.HttpStatus.OK
-import static org.springframework.http.HttpStatus.CREATED
-import org.grails.guides.Vehicle
-import grails.gorm.transactions.Transactional
-
-@SuppressWarnings(['LineLength'])
-@ReadOnly (1)
-class VehicleController {
-
-    static namespace = 'scaffolding'
-
-    static allowedMethods = [save: 'POST', update: 'PUT', delete: 'DELETE']
-
-    def index(Integer max) {
-        params.max = Math.min(max ?: 10, 100) (2)
-        respond Vehicle.list(params), model:[vehicleCount: Vehicle.count()] (3)
-    }
-
-    def show(Vehicle vehicle) {
-        respond vehicle (3)
-    }
-
-    @SuppressWarnings(['FactoryMethodName', 'GrailsMassAssignment'])
-    def create() {
-        respond new Vehicle(params) (3)
-    }
-
-    @Transactional (1)
-    def save(Vehicle vehicle) {
-        if (vehicle == null) {
-            transactionStatus.setRollbackOnly()
-            notFound()
-            return
-        }
-
-        if (vehicle.hasErrors()) {
-            transactionStatus.setRollbackOnly()
-            respond vehicle.errors, view:'create'  (3)
-            return
-        }
-
-        vehicle.save flush:true
-
-        request.withFormat {  (4)
-            form multipartForm {
-                (5)
-                flash.message = message(code: 'default.created.message', args: [message(code: 'vehicle.label', default: 'Vehicle'), vehicle.id])
-                redirect vehicle
-            }
-            '*' { respond vehicle, [status: CREATED] } (3)
-        }
-    }
-
-    def edit(Vehicle vehicle) {
-        respond vehicle (3)
-    }
-
-    @Transactional (1)
-    def update(Vehicle vehicle) {
-        if (vehicle == null) {
-            transactionStatus.setRollbackOnly()
-            notFound()
-            return
-        }
-
-        if (vehicle.hasErrors()) {
-            transactionStatus.setRollbackOnly()
-            respond vehicle.errors, view:'edit' (3)
-            return
-        }
-
-        vehicle.save flush:true
-
-        request.withFormat {
-            form multipartForm {
-                (5)
-                flash.message = message(code: 'default.updated.message', args: [message(code: 'vehicle.label', default: 'Vehicle'), vehicle.id])
-                redirect vehicle (6)
-            }
-            '*' { respond vehicle, [status: OK] } (3)
-        }
-    }
-
-    @Transactional (1)
-    def delete(Vehicle vehicle) {
-
-        if (vehicle == null) {
-            transactionStatus.setRollbackOnly()
-            notFound()
-            return
-        }
-
-        vehicle.delete flush:true
-
-        request.withFormat {
-            form multipartForm {
-                (5)
-                flash.message = message(code: 'default.deleted.message', args: [message(code: 'vehicle.label', default: 'Vehicle'), vehicle.id])
-                redirect action: 'index', method: 'GET' (6)
-            }
-            '*' { render status: NO_CONTENT } (7)
-        }
-    }
-
-    protected void notFound() {
-        request.withFormat {
-            form multipartForm {
-                (5)
-                flash.message = message(code: 'default.not.found.message', args: [message(code: 'vehicle.label', default: 'Vehicle'), params.id])
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
1The @Transactional annotation configures the transactional behavior of the controller or method. Transactions are used to manage persistence and other complicated operations that should be completed together (and potentially rolled-back if any one of the steps fails). For more information on transactions, see the Grails documentation
2The params object is available to all controllers, and contains a map of any URL parameters on the request. You can refer to any parameter by name to retrieve the value: params.myCustomParameter will match this URL parameter: [url]?myCustomParameter=hello. See the Grails documentation for more detail.
3The respond method takes an object to return to the requestor, using content negotiation to choose the correct type (for example, a request’s Accept header might specify JSON or XML). respond also can accept a map of arguments, such as model (which defines the way the data is loaded on a page). For more on how to use the respond method, see the Grails documentation.
4request is available on all controllers, and is an instance of the Servlet API’s HttpServletRequest class. You can access request headers, store properties in the request scope, and get information about the requestor using this object. For more see the Grails documentation on request.
5flash is a map that stores objects within the session for the next request, automatically clearing them after that next request completes. This is useful for passing error messages or other data that you want the next request to access. For more, see the Grails documentation on flash.
6The redirect method is a simple one - it allows the action to redirect the request to another action, controller, or a URI. You can also pass along parameters with the redirect. See the Grails documentation on redirect for more.
7The render method is a less sophisticated version of respond - it doesn’t perform content negotiation, so you have to specify exactly what you want to render. You can render plain text, a view or template, an HTTP response code, or any object that has a String representation. See the Grails documentation.
-
-
-

That’s a lot of code! Generating and modifying a scaffold controller is a good learning exercise, so feel free to experiment and modify this code - you can always revert back to the version in the completed project of this guide.

-
- - -

6.5 Render a response

- -
- -
- - -
-

Let’s modify our HomeController to render some custom content on our home page. Edit grails-app/controllers/org/grails/guides/HomeController.groovy.

-
-
-
grails-app/controllers/org/grails/guides/HomeController.groovy
-
-
package org.grails.guides
-
-class HomeController {
-
-    def index() {
-        respond([name: session.name ?: 'User', vehicleTotal: Vehicle.count()]) (1)
-    }
-
-    def updateName(String name) {
-        session.name = name (2)
-
-        flash.message = "Name has been updated" (3)
-
-        redirect action: 'index' (4)
-    }
-
-}
-
-
-
- - - - - - - - - - - - - - - - - -
1We’re calling the respond method to render a Groovy map of content to the requestor, containing a name property from the session (defaulting to "User" if no session value exists) and the current total of Vehicle instances from GORM’s count method.
2session is an instance of the Servlet API’s`HttpSession` class, and is available in every controller. We can retrieve and store properties in the session - in this case, we’re storing a String with the attribute name in the session. For more see the Grails documentation.
3We’re using flash scope to set a message to display upon the next request
4We don’t have any specific content to display in this action, so we issue a redirect back to the index action (note that in Groovy method parenthesis are optional so long as there is at least one argument).
-
-
-

We’ve updated our index action to render some custom content to the page, and we’ve created a new action updateName, which takes a String parameter and saves it to the session for later retrieval. However, we need to update our view to 1. display the newly available content, and 2. provide some means of calling updateName and setting the session attribute.

-
-
-

Edit grails-app/views/home/index.gsp:

-
-
-
grails-app/views/home/index.gsp
-
-
<html>
-<html>
-<head>
-    <meta name="layout" content="main"/>
-    <title>Home Page</title>
-</head>
-<body>
-
-<div id="content" role="main">
-    <section class="row colset-2-its">
-        <h1>Welcome ${name}!</h1> (1)
-
-        <h4>${flash.message}</h4>  (2)
-
-        <p>There are ${vehicleTotal} vehicles in the database.</p> (1)
-
-        <form action="/home/updateName" method="post" style="margin: 0 auto; width:320px"> (3)
-            <input type="text" name="name" value="" id="name">
-            <input type="submit" name="Update name" value="Update name" id="Update name">
-        </form>
-
-    </section>
-</div>
-
-</body>
-</html>
-
-
-
- - - - - - - - - - - - - -
1We can refer to any values in our "model" by name in a GSP page, using Groovy String Expressions ${name} ${vehicleTotal}
2Here we’re accessing our flash.message property - if it’s null then nothing will render here.
3This is a plain HTML form that will submit the name text field to the updateName action we just created.
-
-
-

Run the app and you should see our new message in the <h1> header: "Welcome User!", as well as the current total of Vehicle instances in the database.

-
-
-

Try entering your own name in the form and submitting it - you should see the page reload and your own name will replace "User". Refresh the page a few times. Because we stored the name in the session, it will persist as long as the session is valid.

-
-
-

Content Negotiation

-
-
-

Remember we used the respond method, instead of the simpler render method to send our "model" to the page. This means we can get our model using other formats besides an HTML page, such as JSON or XML.

-
-
-

Run the following command in a terminal (while the app is running)

-
-
-
-
$ curl -i -H "Accept: application/json" "http://localhost:8080/home/index"
-
-HTTP/1.1 200
-X-Application-Context: application:development
-Set-Cookie: JSESSIONID=008B45AAA1A820CE5C9FDC2741D345F3;path=/;HttpOnly
-Content-Type: application/json;charset=UTF-8
-Transfer-Encoding: chunked
-Date: Wed, 11 Jan 2017 04:06:57 GMT
-
-{"name":"User","vehicleTotal":3}
-
-
-
-

We’ve used curl to call our index action, but we’ve changed our Accept header to application/json. Now instead of an HTML page, we receive the same data in JSON.

-
-
-

You can request difference content types in the browser as well, thanks to Grails' default URL Mappings (shown below):

-
-
-
grails-app/controllers/org/grails/guides/UrlMappings.groovy
-
-
        "/$controller/$action?/$id?(.$format)?" {
-            constraints {
-                // apply constraints here
-            }
-        }
-
-
-
-

Note the (.$format)? token in the mapping. This will match a suffix on our URL such as .json or .xml. Test this out in your browser.

-
-
-

Browse to http://localhost:8080/home/index.json. You should see the same JSON body we retrieved using curl.

-
-
-

Try changing .json to .xml. You should see an XML representation of the model. Content negotiation allows your controllers to be very versatile and to return appropriate data to different clients from the same actions.

-
-
-
- - -

7 Views

- -
- -
- - -
-

Views are the third component of the MVC pattern. Views are responsible for presenting data to the user (which might be a browser page, an API endpoint, or some other sort of consumer). In many apps, views are HTML pages designed to be loaded in a browser. However, it is perfectly reasonable for a "view" to be an XML or JSON document, depending on the type of client requesting the view.

-
-
-

Grails' primary view technology is Groovy Server Pages. It follows many of the conventions of JSP and ASP, but naturally it is based upon the Groovy language. GSP pages are essentially HTML documents, but they support a number of special tags (typically prefix with g:) to allow programmatic control over your views. You can even write arbitrary Groovy code in your GSP pages, but this is strongly discouraged - ideally, the GSP page should only contain view-related logic and content; any sort of data manipulation or processing should happen in the controller (or in a service) prior to the view being rendered.

-
-
-

You’ve already worked with GSP views in this guide, but let’s quickly cover the basics.

-
-
-

Layouts

-
-
-

GSP views in an application often need to share some general structure, and perhaps some shared assets like JavaScript files. Grails uses the SiteMesh templating technology to support the idea of "layouts", which are essentially GSP template files that your GSP page can "inherit".

-
-
-

By convention, layouts are located under grails-app/views/layouts. Grails includes a main.gsp template in the default project, and that’s the one that Grails scaffolding uses, as well as the default home page. We’re using it as well. To use a GSP layout, simply specify the name of the layout using a <meta name="layout"> tag:

-
-
-
-
<html>
-<html>
-<head>
-    <meta name="layout" content="main"/>
-</head>
-<!-- ... -->
-
-
-
-

You can create your own layouts as well. Let’s create a new layout for our home page.

-
-
-
-
$ vim grails-app/views/layouts/public.gsp
-
-
-
-

Edit your new layout. We’ll be copying the existing main.gsp as a start, but we’ll add a custom logo image and remove some layout code we don’t need on our pages.

-
-
-
grails-app/views/layouts/public.gsp
-
-
<!doctype html>
-<html lang="en" class="no-js">
-<head>
-    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
-    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
-    <title>
-        <g:layoutTitle default="Auto Catalog"/>
-    </title>
-    <meta name="viewport" content="width=device-width, initial-scale=1"/>
-
-    <asset:stylesheet src="application.css"/>
-
-    <g:layoutHead/>
-</head>
-<body>
-
-<div class="navbar navbar-default navbar-static-top" role="navigation">
-    <div class="container">
-        <div class="navbar-header">
-            <a class="navbar-brand" href="/#">
-                <i class="fa grails-icon">
-                    <asset:image src="logo.png"/>
-                </i> Auto Catalog
-            </a>
-        </div>
-        <div class="navbar-collapse collapse" aria-expanded="false" style="height: 0.8px;">
-            <ul class="nav navbar-nav navbar-right">
-                <g:pageProperty name="page.nav" />
-            </ul>
-        </div>
-    </div>
-</div>
-
-<g:layoutBody/>
-
-<div class="footer" role="contentinfo"></div>
-
-</body>
-</html>
-
-
-
-

The key points of this layout are the <g:layoutBody> and <g:layoutHead> tags. These tags are substituted by SiteMesh for the <head> and <body> sections of any GSP page that uses the layout.

-
-
- - - - - -
- - -Feel free to provide your own logo.png image, or use the one from the completed project (or download at this link). Place the image in the grails-app/assets/images/ directory, and the layout should render it instead of the Grails logo. -
-
-
- - - - - -
- - -Don’t worry about the <asset> tags in the new layout just yet - we’ll be covering those shortly. -
-
-
-

Now edit the home/index.gsp view to use the new public layout.

-
-
-
grails-app/views/home/index.gsp
-
-
<html>
-<head>
-    <meta name="layout" content="public"/> (1)
-    <title>Home Page</title>
-
-
-
- - - - - -
1Change "main" to "public"
-
-
-

Refresh the page (or restart the app) and you should see the new layout in action. Feel free to modify the public.gsp layout further if you wish.

-
-
-
- - -

7.1 Views Resolution

- -
- -
- - -
-

How does Grails choose which view to render? By convention, Grails looks for views -under the grails-app/views directory. It will attempt to resolve views to controller -actions by matching the controller name with a directory under the views directory. -E.g., HomeController will resolve to grails-app/views/home. Grails will then map -actions to GSP pages with the same name. E.g, index will resolve to index.gsp.

-
-
-

You can also render a particular view from your controller action (overriding Grails' conventions) using the render method:

-
-
-
-
class SomeController {
-    def someAction() {
-        render view: 'anotherView'
-    }
-}
-
-
-
-

This will attempt to resolve to a anotherView.gsp page under grails-app/views/some/. If you would like to resolve a view that is not under the controller’s own view directory, use a leading / to specifiy an absolute path from grails-app/views:

-
-
-
-
class SomeController {
-    def someAction() {
-        render view: '/another/view'
-    }
-}
-
-
-
-

This will resolve to view.gsp under grails-app/views/another/.

-
- - -

7.2 GSP

- -
- -
- - -
-

GSP pages have access to a rich set of tags. We’ve seen a few in action already. You can get much more detail on the available GSP tags (including how to define your own custom tags) from the Grails documentation.

-
-
-

Let’s soup up our index.gsp page a bit with some GSP tags.

-
-
-

Edit grails-app/views/home/index.gsp

-
-
-
grails-app/views/home/index.gsp
-
-
<%@ page import="Vehicle" %>
-<html>
-<head>
-    <meta name="layout" content="public"/>
-    <title>Home Page</title>
-</head>
-<body>
-
-<div id="content" role="main">
-    <section class="row colset-2-its">
-        <h1>Welcome ${name}!</h1>
-        <g:if test="${flash.message}"> (1)
-            <div class="message" role="status">${flash.message}</div>
-        </g:if>
-
-        <p>There are ${vehicleTotal} vehicles in the database.</p>
-
-        <ul>
-        <g:each in="${Vehicle.list()}" var="vehicle">
-            <li>
-                <g:link controller="vehicle" action="show" id="${vehicle.id}">
-                    ${vehicle.name} - ${vehicle.year} ${vehicle.make.name} ${vehicle.model.name}
-                </g:link>
-            </li>
-        </g:each>
-        </ul>
-
-        <g:form action="updateName" style="margin: 0 auto; width:320px"> (2)
-            <g:textField name="name" value="" />
-            <g:submitButton name="Update name" />
-        </g:form>
-
-    </section>
-</div>
-
-</body>
-</html>
-
-
-
- - - - - - - - - -
1Rather than rendering flash.message regardless of whether it exists, we use the <g:if> tag to test if there is a message, and then render the message (using some custom styling).
2Replace the plain HTML <form> tags with their GSP equivalents.
-
-
-

Let’s take a closer look at <g:if>.

-
-
-
-
    <g:if test="${isThisTrue}}>
-        Some content
-    </g:if>
-
-
-
-

GSP tags can optionally accept attributes, such as test in this example. Different tags require different types of attributes, but typically you will end up passing a Groovy Expression like in this example. Any Groovy code between the ${ and } will be evaluated (on the server) and the result will be substituted on the rendered page.

-
-
- - - - - -
- - -You can use Groovy Expressions anywhere on the GSP page, not just in tags. See the ${flash.message} in our index.gsp page. -
-
-
-

Other tag attributes might accept plain strings or numbers. E.g, <g:form action="updateName">

-
-
-

A GSP tag can also optionally include a body. In the case of <g:if>, the body will only be rendered if the test expression evaluates to true (following the Groovy Truth convention). Other GSP tags (like <g:form>) simply include the body within the resulting HTML output.

-
- - -

7.3 GSP Tags Iteration

- -
- -
- - -
-

Iteration

-
-
-

There are GSP tags for iteration as well - a very useful one is <g:each>. Let’s try it out:

-
-
-
grails-app/views/home/index.gsp
-
-
<%@ page import="Vehicle" %> (1)
-<html>
-<!-- ... -->
-        <p>There are ${vehicleTotal} vehicles in the database.</p>
-
-        <ul>
-            <g:each in="${Vehicle.list()}" var="vehicle">  (2)
-                <li>
-                    <! -- ... -->
-                </li>
-            </g:each>
-        </ul>
-
-<!-- ... -->
-
-
-
-

The <g:each> tag iterates through a collection of objects, provided by the in attribute. var sets the name for each object in the collection. Grails will iterate through the collection (in this case, the list of Vehicles returned by Vehicle.list()) and render the body of the <g:each> tag for every item.

-
-
- - - - - - - - - -
1This is a JSP-style expression that allows executing arbitrary Groovy code (without rendering a result). We’re using it here to import our Vehicle class. This is highly discouraged - we’ll explain why shortly.
2Bad practice, accessing the Domain model directly from the view
-
-
- - - - - -
- - -This sort of code is a bad idea - we are accessing our domain model (Vehicle) directly from our view, which tightly couples two separate parts of the application, and in general leads to very messy code. The better way to accomplish this feature is to get the Vehicle list in the HomeController.index action, and add the list to our model object (the one being passed to respond). Then we could refer to the list in the same way that we access name and vehicleTotal. Go ahead and change the controller and view to use this better approach - the completed project has this change already made if you need help. -
-
-
-
- - - - -
- -
- - -
-

Let’s take a look at one more common GSP tag: <g:link>

-
-
-
grails-app/views/home/index.gsp
-
-
        ${vehicle.name} - ${vehicle.year} ${vehicle.make.name} ${vehicle.model.name}
-    </g:link>
-</li>
-
-
-
-

<g:link> renders an HTML <a> tag, but it has the advantage in that it allows you to specify your link target following Grails conventions, such as this example (using the controller, action and id attributes). <g:link> is also smart enough to follow our URL mappings, so if we change the URL mapping for vehicle/show, the <g:link> tag will still render the correct URL. There’s many more attributes supported by <g:link> - see the Grails documentation for more.

-
- - -

7.5 Asset Pipeline

- -
- -
- - -
-

You may have noticed a few <asset> tags in our GSP pages. These tags are provided by the Asset Pipeline plugin, which is Grails' default tool for managing static assets (images, CSS, JavaScript files, etc). The Asset Pipeline plugin provides a set of custom GSP tags, but unlike the tags we’ve been exploring, it uses the asset prefix (or namespace).

-
-
-

The most common <asset> tags are listed below:

-
-
-
-
<asset:javascript src="myscript.js" /> (1)
-
-<asset:image src="myimage.png" /> (2)
-
-<asset:stylesheet src="mystyles.css" /> (3)
-
-
-
- - - - - - - - - - - - - -
1This tag loads JavaScript files from grails-app/assets/javascripts
2This tag loads images from grails-app/assets/images
3This tag loads CSS files from grails-app/assets/stylesheets
-
-
-

As you can see, the Asset Pipeline follows a convention over configuration approach, following the precedent of Grails. However, Asset Pipeline is a very powerful framework, and includes a rich plugin ecosystem - you can find plugins to render LESS and SASS files, CoffeeScript, Ember, Angular, JSX (React), and more.

-
-
-

Asset Pipeline also supports minification and compression of your assets, and much more.

-
-
- - - - - -
- - -Visit asset-pipeline.com for much more information on using the Asset Pipeline, including a directory of available plugins. -
-
- - -

7.6 Add Javascript Asset

- -
- -
- - -
-

Let’s use the Asset Pipeline plugin to add jQuery to our page. Grails includes jQuery by default. -The version of Grails used in this guide included jQuery 2.2.0 by default:

-
-
-
-
_grails-app/assets/javascripts/jquery-2.2.0.min.js_
-
-
-
-

but let’s instead download the latest version. Download jQuery from https://code.jquery.com/jquery-3.1.1.js

-
-
-

Save jquery-3.1.1.js to grails-app/assets/javascripts.

-
-
-

Edit grails-app/views/home/index.gsp, add the following snippet in the head block.

-
-
-
grails-app/views/home/index.gsp
-
-
<asset:javascript src="jquery-3.1.1.js" />
-
-<script type="text/javascript">
-  $( document ).ready(function() {
-    console.log( "jQuery 3.1.1 loaded!" );
-  });
-</script>
-
-
-
-

Refresh the page, and open your browser’s developer console. You should see the string jQuery 3.1.1 loaded! in the console logs.

-
- - -

8 Services

- -
- -
- - -
-

Grails provides a "service layer", which are classes that encapsulate business logic and are wired (using dependency injection) into the application context, so that any controller can inject and use them. Services are the preferred vehicle for most application logic, not controllers.

-
-
-

If this seems confusing, think of it this way: controllers are intended to respond to the request and return a response. Services can be reused across many controllers (as well as in domain classes and from other services). Services are more versatile and can help you keep your controllers clean and prevent duplication of business logic. It is often easier to write unit tests against service methods than against controller actions, as well.

-
-
- - - - - -
- - -Controllers are for "web logic", services for "business logic" -
-
-
-

Again by convention, Grails will configure any Groovy classes within the grails-app/services directory as services. Services will be "wired" as Spring beans in the Grails application context, which means you can refer to them simply by name from any other Spring bean (including controllers and domain classes).

-
-
-

Let’s add a feature to generate an estimated value for Vehicle, based on the make, model and year. We’ll put this logic in a service and call it from our application code.

-
-
-

Create a new service using the create-service Grails command:

-
-
-
-
$ ./grailsw create-service ValueEstimateService
-
-| Created grails-app/services/org/grails/guides/ValueEstimateService.groovy
-| Created src/test/groovy/org/grails/guides/ValueEstimateServiceSpec.groovy
-
-
-
-

Edit grails-app/services/org/grails/guides/ValueEstimateService.groovy

-
-
-
grails-app/services/org/grails/guides/ValueEstimateService.groovy
-
-
package org.grails.guides
-
-import grails.gorm.transactions.Transactional
-
-@Transactional
-class ValueEstimateService {
-
-    def serviceMethod() {
-
-    }
-}
-
-
-
-

Grails has provided a serviceMethod stub as an example. Delete it and replace it with the following content:

-
-
-
grails-app/services/org/grails/guides/ValueEstimateService.groovy
-
-
package org.grails.guides
-
-import grails.gorm.transactions.Transactional
-
-@Transactional
-class ValueEstimateService {
-
-    def getEstimate(Vehicle vehicle) {
-        log.info 'Estimating vehicle value...'
-
-        //TODO: Call third-party valuation API
-        Math.round (vehicle.name.size() + vehicle.model.name.size() * vehicle.year) * 2
-    }
-}
-
-
-
-

Obviously this method of estimating a vehicle’s value is pretty contrived! In reality you would likely call a third-party webservice to get a valuation, or perhaps run a query against your own database. However, the point of this example is to show the sort of "business logic" that can be placed in services, rather than being calculated within a controller or view.

-
-
-

Now, let’s use our new service.

-
-
-

Edit grails-app/controllers/org/grails/guides/VehicleController.groovy (the scaffolded controller we generated earlier), and add the property shown below:

-
-
-
grails-app/controllers/org/grails/guides/VehicleController.groovy
-
-
static namespace = 'scaffolding'
-
-
-
- - - - - -
1By simply defining a property in our controller with the same name as our service class, Grails will inject a reference to the service for us.
-
-
-

Now (still editing VehicleController.groovy), modify the show action as seen below:

-
-
-
grails-app/controllers/org/grails/guides/VehicleController.groovy
-
-
}
-
-def show(Vehicle vehicle) {
-
-
-
-

We’ve added a new property to the model, called estimatedValue. The value of this property is the result of calling our service method, getEstimate, to which we pass the vehicle property we are about to render.

-
-
-

Now, on the show page, we can access the estimatedValue property and display it on the page. Edit grails-app/views/vehicle/show.gsp as shown below:

-
-
-
grails-app/views/vehicle/show.gsp
-
-
<div id="show-vehicle" class="content scaffold-show" role="main">
-    <h1><g:message code="default.show.label" args="[entityName]" /></h1>
-    <h1>Estimated Value: <g:formatNumber number="${estimatedValue}" type="currency" currencyCode="USD" /></h1>
-
-
-
-
-

Restart the application and browse to the show page for a Vehicle, such as http://localhost:8080/vehicle/show/1. You should see the "Estimated Value" on the page

-
- - -

9 Testing your App

- -
- -
- - -
-

Testing is a vital part of web application development. Grails provides support for three types of testing: unit tests, integration tests, and functional tests. Unit tests are usually the simplest kind, focusing on a specific piece of code without depending on other parts of your app. Integration tests require the Grails environment to be up and running, and are used to test features that depend on database or network resources. Functional tests require the app to be running, and are designed to exercise the application almost as a user would, by making HTTP requests against it. These tend to be the most complex tests to write.

-
-
-

The testing framework used by Grails is Spock. Spock provides an expressive syntax for writing test cases, based on the Groovy language, and so is a great fit for Grails. It includes a JUnit runner, which means IDE support is effectively universal (any IDE that can run JUnit tests can run Spock tests).

-
-
- - - - - -
- - -Spock is a rich framework (even outside of Grails applications) and well worth your time to master, if you haven’t already. Check out the extensive documentation for an introduction to Spock. -
-
-
-

Grails tests are stored (by convention) in the src/test/groovy directory (unit tests) and src/integration-test/groovy directory (integration/functional tests).

-
-
-

You can run your Grails tests using the test-app command:

-
-
-
-
$ ./grailsw test-app
-
-
-
-

If you want to run only unit tests or integration/functional tests, you can pass in a command line flag to choose one or the other.

-
-
-
-
$ ./grailsw test-app -unit
-$ ./grailsw test-app -integration
-
-
-
-

You can also run a specific test by passing the test class as an argument:

-
-
-
-
$ ./grailsw test-app org.grails.guides.MyTestSpec
-
-
-
-

Writing tests is a very broad topic and worthy of its own guide. In practice, the simplest (and often most useful) tests are unit tests, so let’s write a simple unit test to exercise our ValueEstimateService.

-
-
-

Grails automatically creates a test spec for services created with the create-service command. Open src/test/groovy/org/grails/guides/ValueEstimateServiceSpec.

-
-
-
src/test/groovy/org/grails/guides/ValueEstimateServiceSpec
-
-
package org.grails.guides
-
-import grails.testing.gorm.DataTest
-import grails.testing.services.ServiceUnitTest
-import spock.lang.Specification
-
-class ValueEstimateServiceSpec extends Specification implements ServiceUnitTest<ValueEstimateService>, DataTest {
-
-    def setup() {
-    }
-
-    def cleanup() {
-    }
-
-    void "test something"() {
-        expect:"fix me"
-            true == false
-    }
-}
-
-
-
-

Currently our test spec has one test, "test something", which asserts that true == false. Grails is helpfully encouraging you to do the right thing by kicking things off with a failing test.

-
-
-

Try running the test, just to confirm it fails:

-
-
-
-
$ /grailsw test-app org.grails.guides.ValueEstimateServiceSpec
-
-...
-> There were failing tests. See the report at: file:///Users/dev/projects/creating-your-first-grails-app/complete/build/reports/tests/test/index.html
-
-BUILD FAILED
-
-Total time: 6.353 secs
-| Tests FAILED Test execution failed
-
-
-
-

Now that we’ve confirmed our test is failing, let’s edit this test case to exercise our getEstimate method. Edit src/test/groovy/org/grails/guides/ValueEstimateServiceSpec.

-
-
-
src/test/groovy/org/grails/guides/ValueEstimateServiceSpec.groovy
-
-
package org.grails.guides
-
-import grails.testing.gorm.DataTest
-import grails.testing.services.ServiceUnitTest
-import spock.lang.Specification
-
-class ValueEstimateServiceSpec extends Specification implements ServiceUnitTest<ValueEstimateService>, DataTest {
-
-    void setupSpec() { (1)
-        mockDomain Make
-        mockDomain Model
-        mockDomain Vehicle
-    }
-
-    void testEstimateRetrieval() {
-        given: 'a vehicle'
-        def make = new Make(name: 'Test')
-        def model = new Model(name: 'Test', make: make)
-        def vehicle = new Vehicle(year: 2000, make: make, model: model, name: 'Test Vehicle')
-
-        when: 'service is called'
-        def estimate = service.getEstimate(vehicle)
-
-        then: 'a non-zero result is returned'
-        estimate > 0
-
-        and: 'estimate is not too large'
-        estimate < 1000000
-    }
-}
-
-
-
- - - - - -
1When mocking multiple objects using the updated testing framework in Grails 3.3 we now perform mocks during setup and no -longer require the @Mock annotation.
-
-
-

We’ve kept things pretty simple in this test, since we don’t have very complex logic to test, but also so you can focus on the basic formula of the Spock test case. Spock provides a set of keywords which allow you to lay out your test in a very human-readable form.

-
-
-
    -
  • -

    given indicates a "setup" block - here you can set up any objects or variables needed to complete your test.

    -
  • -
  • -

    when and then are one of the most common "pairings" in Spock (another one, not used here, is expect/where - they define a statement and an expected result.

    -
  • -
  • -

    and simply continues on the current then block, but it allows you to specify your expectations for multiple assertions. Note that all of these blocks accept (optionally) a string description, which makes your test even more readable. E.g., when: "this method is called", then: "expect this result".

    -
  • -
-
-
-

Go ahead and rerun this test - if all’s well, it should pass with flying colors.

-
-
-
-
$ ./grailsw test-app org.grails.guides.ValueEstimateServiceSpec
-
-...
-
-BUILD SUCCESSFUL
-
-| Tests PASSED
-
-
- - -

10 Deploying your App

- -
- -
- - -
-

The final step in developing a Grails application is building the finished project into a deployable package. Typically Java web applications are deployed as WAR files, and Grails makes this easy with the war command:

-
-
-
-
$ ./grailsw war
-...
-BUILD SUCCESSFUL
-
-| Built application to build/libs using environment: production
-
-
-
- - - - - -
- - -
-

We didn’t cover the topic of configuration in this guide (although we did make some edits to our config files), but it’s worth mentioning here that Grails supports to concept of Environments, such as "development", "test", and "production". Each environment can have its own config properties and values, so you can have different settings between your development and production systems. By default, the war command uses the "production" environment - you can override this using the -Dgrails.env flag, like so:

-
-
-
-
$ ./grailsw war -Dgrails.env=development
-...
-BUILD SUCCESSFUL
-
-| Built application to build/libs using environment: development
-
-
-
-
-
-

Once we have our WAR file, we can deploy it in any JEE container, such as Tomcat.

-
-
-

Congratulations! You’ve built your first Grails application.

-
- - -

11 Do you need help with Grails?

- -
- -
- - -

Object Computing, Inc. (OCI) sponsored the creation of this Guide. A variety of consulting and support services are available.

- - - - - - - - - -

OCI is Home to Grails

- -

Meet the Team

- -
- - -
-