From 64803b5fae42bfb63ed19c7b4467600e66d534c2 Mon Sep 17 00:00:00 2001 From: Jennifer Strater Date: Mon, 30 May 2016 12:22:35 -0500 Subject: [PATCH] Add Grails sample app Closes gh-250 --- docs/src/docs/asciidoc/getting-started.adoc | 4 + samples/rest-notes-grails/.gitignore | 17 ++ samples/rest-notes-grails/LICENSE | 201 ++++++++++++++++++ samples/rest-notes-grails/README.md | 2 + samples/rest-notes-grails/build.gradle | 79 +++++++ samples/rest-notes-grails/gradle.properties | 2 + .../rest-notes-grails/gradle/restdocs.gradle | 27 +++ .../grails-app/conf/application.yml | 98 +++++++++ .../grails-app/conf/logback.groovy | 23 ++ .../grails-app/conf/spring/resources.groovy | 3 + .../grails-app/controllers/UrlMappings.groovy | 8 + .../com/example/IndexController.groovy | 31 +++ .../InternalServerErrorController.groovy | 11 + .../com/example/NotFoundController.groovy | 11 + .../grails-app/domain/com/example/Note.groovy | 16 ++ .../grails-app/domain/com/example/Tag.groovy | 15 ++ .../grails-app/i18n/messages.properties | 56 +++++ .../grails-app/init/BootStrap.groovy | 14 ++ .../init/com/example/Application.groovy | 10 + samples/rest-notes-grails/src/docs/index.adoc | 148 +++++++++++++ .../com/example/ApiDocumentationSpec.groovy | 146 +++++++++++++ 21 files changed, 922 insertions(+) create mode 100644 samples/rest-notes-grails/.gitignore create mode 100644 samples/rest-notes-grails/LICENSE create mode 100644 samples/rest-notes-grails/README.md create mode 100644 samples/rest-notes-grails/build.gradle create mode 100644 samples/rest-notes-grails/gradle.properties create mode 100644 samples/rest-notes-grails/gradle/restdocs.gradle create mode 100644 samples/rest-notes-grails/grails-app/conf/application.yml create mode 100644 samples/rest-notes-grails/grails-app/conf/logback.groovy create mode 100644 samples/rest-notes-grails/grails-app/conf/spring/resources.groovy create mode 100644 samples/rest-notes-grails/grails-app/controllers/UrlMappings.groovy create mode 100644 samples/rest-notes-grails/grails-app/controllers/com/example/IndexController.groovy create mode 100644 samples/rest-notes-grails/grails-app/controllers/com/example/InternalServerErrorController.groovy create mode 100644 samples/rest-notes-grails/grails-app/controllers/com/example/NotFoundController.groovy create mode 100644 samples/rest-notes-grails/grails-app/domain/com/example/Note.groovy create mode 100644 samples/rest-notes-grails/grails-app/domain/com/example/Tag.groovy create mode 100644 samples/rest-notes-grails/grails-app/i18n/messages.properties create mode 100644 samples/rest-notes-grails/grails-app/init/BootStrap.groovy create mode 100644 samples/rest-notes-grails/grails-app/init/com/example/Application.groovy create mode 100644 samples/rest-notes-grails/src/docs/index.adoc create mode 100644 samples/rest-notes-grails/src/integration-test/groovy/com/example/ApiDocumentationSpec.groovy diff --git a/docs/src/docs/asciidoc/getting-started.adoc b/docs/src/docs/asciidoc/getting-started.adoc index 094c64c2..2a1d161c 100644 --- a/docs/src/docs/asciidoc/getting-started.adoc +++ b/docs/src/docs/asciidoc/getting-started.adoc @@ -18,6 +18,10 @@ If you want to jump straight in, a number of sample applications are available: | Gradle | Demonstrates the use of Spring REST Docs with http://rest-assured.io[REST Assured]. +| {samples}/rest-notes-grails[Grails] +| Gradle +| Demonstrates the use of Spring REST docs with https://grails.org[Grails] and https://github.com/spockframework/spock[Spock] + | {samples}/rest-notes-slate[Slate] | Gradle | Demonstrates the use of Spring REST Docs with Markdown and diff --git a/samples/rest-notes-grails/.gitignore b/samples/rest-notes-grails/.gitignore new file mode 100644 index 00000000..a521ca89 --- /dev/null +++ b/samples/rest-notes-grails/.gitignore @@ -0,0 +1,17 @@ +Thumbs.db +.DS_Store +.gradle +build/ +classes/ +.idea +*.iml +*.ipr +*.iws +.project +.settings +.classpath +gradlew* +gradle/wrapper + + +src/docs/generated-snippets diff --git a/samples/rest-notes-grails/LICENSE b/samples/rest-notes-grails/LICENSE new file mode 100644 index 00000000..8dada3ed --- /dev/null +++ b/samples/rest-notes-grails/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/samples/rest-notes-grails/README.md b/samples/rest-notes-grails/README.md new file mode 100644 index 00000000..06c410f7 --- /dev/null +++ b/samples/rest-notes-grails/README.md @@ -0,0 +1,2 @@ +# grails-spring-restdocs-example +Example of adding spring rest docs to sample grails project diff --git a/samples/rest-notes-grails/build.gradle b/samples/rest-notes-grails/build.gradle new file mode 100644 index 00000000..cc8b269b --- /dev/null +++ b/samples/rest-notes-grails/build.gradle @@ -0,0 +1,79 @@ +buildscript { + ext { + grailsVersion = project.grailsVersion + } + repositories { + mavenLocal() + maven { url "https://repo.grails.org/grails/core" } + maven { url 'https://repo.spring.io/libs-snapshot' } + } + dependencies { + classpath "org.grails:grails-gradle-plugin:$grailsVersion" + classpath "org.grails.plugins:hibernate:4.3.10.5" + classpath 'org.ajoberstar:gradle-git:1.1.0' + } +} + +plugins { + id "io.spring.dependency-management" version "0.5.4.RELEASE" + id 'org.asciidoctor.convert' version '1.5.3' +} + +version "0.1" +group "com.example" + +apply plugin: "spring-boot" +apply plugin: "war" +apply plugin: 'eclipse' +apply plugin: 'idea' +apply plugin: "org.grails.grails-web" + +ext { + grailsVersion = project.grailsVersion + gradleWrapperVersion = project.gradleWrapperVersion +} + +repositories { + mavenLocal() + maven { url "https://repo.grails.org/grails/core" } +} + +dependencyManagement { + imports { + mavenBom "org.grails:grails-bom:$grailsVersion" + } + applyMavenExclusions false +} + +dependencies { + compile "org.springframework.boot:spring-boot-starter-logging" + compile "org.springframework.boot:spring-boot-starter-actuator" + compile "org.springframework.boot:spring-boot-autoconfigure" + compile "org.springframework.boot:spring-boot-starter-tomcat" + compile "org.grails:grails-plugin-url-mappings" + compile "org.grails:grails-plugin-rest" + compile "org.grails:grails-plugin-interceptors" + compile "org.grails:grails-plugin-services" + compile "org.grails:grails-plugin-datasource" + compile "org.grails:grails-plugin-databinding" + compile "org.grails:grails-plugin-async" + compile "org.grails:grails-web-boot" + compile "org.grails:grails-logging" + + compile "org.grails.plugins:hibernate" + compile "org.grails.plugins:cache" + compile "org.hibernate:hibernate-ehcache" + + runtime "com.h2database:h2" + + testCompile "org.grails:grails-plugin-testing" + testCompile "org.grails.plugins:geb" + + console "org.grails:grails-console" +} + +task wrapper(type: Wrapper) { + gradleVersion = gradleWrapperVersion +} + +apply from: 'gradle/restdocs.gradle' diff --git a/samples/rest-notes-grails/gradle.properties b/samples/rest-notes-grails/gradle.properties new file mode 100644 index 00000000..1b1c50f8 --- /dev/null +++ b/samples/rest-notes-grails/gradle.properties @@ -0,0 +1,2 @@ +grailsVersion=3.0.15 +gradleWrapperVersion=2.3 diff --git a/samples/rest-notes-grails/gradle/restdocs.gradle b/samples/rest-notes-grails/gradle/restdocs.gradle new file mode 100644 index 00000000..9b655d2e --- /dev/null +++ b/samples/rest-notes-grails/gradle/restdocs.gradle @@ -0,0 +1,27 @@ +dependencies { + testCompile 'org.springframework.restdocs:spring-restdocs-restassured:1.1.0.M1' +} + +ext { + snippetsDir = file('src/docs/generated-snippets') +} + +task cleanTempDirs(type: Delete) { + delete fileTree(dir: 'src/docs/generated-snippets') +} + +test { + dependsOn cleanTempDirs + outputs.dir snippetsDir +} + +asciidoctor { + mustRunAfter test + mustRunAfter integrationTest + inputs.dir snippetsDir + sourceDir = file('src/docs') + separateOutputDirs = false + attributes 'snippets': snippetsDir +} + +build.dependsOn asciidoctor diff --git a/samples/rest-notes-grails/grails-app/conf/application.yml b/samples/rest-notes-grails/grails-app/conf/application.yml new file mode 100644 index 00000000..6d7d00e8 --- /dev/null +++ b/samples/rest-notes-grails/grails-app/conf/application.yml @@ -0,0 +1,98 @@ +--- +grails: + profile: web-api + codegen: + defaultPackage: com.example +info: + app: + name: '@info.app.name@' + version: '@info.app.version@' + grailsVersion: '@info.app.grailsVersion@' +spring: + groovy: + template: + check-template-location: false + +--- +grails: + mime: + disable: + accept: + header: + userAgents: + - Gecko + - WebKit + - Presto + - Trident + types: + all: '*/*' + atom: application/atom+xml + css: text/css + csv: text/csv + form: application/x-www-form-urlencoded + html: + - text/html + - application/xhtml+xml + js: text/javascript + json: + - application/json + - text/json + multipartForm: multipart/form-data + rss: application/rss+xml + text: text/plain + hal: + - application/hal+json + - application/hal+xml + xml: + - text/xml + - application/xml + urlmapping: + cache: + maxsize: 1000 + controllers: + defaultScope: singleton + converters: + encoding: UTF-8 + hibernate: + cache: + queries: false + +--- +dataSource: + pooled: true + jmxExport: true + driverClassName: org.h2.Driver + username: sa + password: + +environments: + development: + dataSource: + dbCreate: create-drop + url: jdbc:h2:mem:devDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE + test: + dataSource: + dbCreate: update + url: jdbc:h2:mem:testDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE + production: + dataSource: + dbCreate: update + url: jdbc:h2:./prodDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE + properties: + jmxEnabled: true + initialSize: 5 + maxActive: 50 + minIdle: 5 + maxIdle: 25 + maxWait: 10000 + maxAge: 600000 + timeBetweenEvictionRunsMillis: 5000 + minEvictableIdleTimeMillis: 60000 + validationQuery: SELECT 1 + validationQueryTimeout: 3 + validationInterval: 15000 + testOnBorrow: true + testWhileIdle: true + testOnReturn: false + jdbcInterceptors: ConnectionState + defaultTransactionIsolation: 2 # TRANSACTION_READ_COMMITTED diff --git a/samples/rest-notes-grails/grails-app/conf/logback.groovy b/samples/rest-notes-grails/grails-app/conf/logback.groovy new file mode 100644 index 00000000..2f7c41c1 --- /dev/null +++ b/samples/rest-notes-grails/grails-app/conf/logback.groovy @@ -0,0 +1,23 @@ +import grails.util.BuildSettings +import grails.util.Environment + +// See http://logback.qos.ch/manual/groovy.html for details on configuration +appender('STDOUT', ConsoleAppender) { + encoder(PatternLayoutEncoder) { + pattern = "%level %logger - %msg%n" + } +} + +root(ERROR, ['STDOUT']) + +def targetDir = BuildSettings.TARGET_DIR +if (Environment.isDevelopmentMode() && targetDir) { + appender("FULL_STACKTRACE", FileAppender) { + file = "${targetDir}/stacktrace.log" + append = true + encoder(PatternLayoutEncoder) { + pattern = "%level %logger - %msg%n" + } + } + logger("StackTrace", ERROR, ['FULL_STACKTRACE'], false) +} diff --git a/samples/rest-notes-grails/grails-app/conf/spring/resources.groovy b/samples/rest-notes-grails/grails-app/conf/spring/resources.groovy new file mode 100644 index 00000000..fa950068 --- /dev/null +++ b/samples/rest-notes-grails/grails-app/conf/spring/resources.groovy @@ -0,0 +1,3 @@ +// Place your Spring DSL code here +beans = { +} diff --git a/samples/rest-notes-grails/grails-app/controllers/UrlMappings.groovy b/samples/rest-notes-grails/grails-app/controllers/UrlMappings.groovy new file mode 100644 index 00000000..98be2a3d --- /dev/null +++ b/samples/rest-notes-grails/grails-app/controllers/UrlMappings.groovy @@ -0,0 +1,8 @@ +class UrlMappings { + + static mappings = { + "/"(controller: 'index') + "500"(controller: 'InternalServerError') + "404"(controller: 'NotFound') + } +} diff --git a/samples/rest-notes-grails/grails-app/controllers/com/example/IndexController.groovy b/samples/rest-notes-grails/grails-app/controllers/com/example/IndexController.groovy new file mode 100644 index 00000000..9ed3bc20 --- /dev/null +++ b/samples/rest-notes-grails/grails-app/controllers/com/example/IndexController.groovy @@ -0,0 +1,31 @@ +package com.example + +import grails.core.GrailsApplication +import grails.util.Environment + +class IndexController { + + GrailsApplication grailsApplication + + def index() { + render(contentType: 'application/json') { + message = "Welcome to Grails!" + environment = Environment.current.name + appversion = grailsApplication.metadata['info.app.version'] + grailsversion = grailsApplication.metadata['info.app.grailsVersion'] + appprofile = grailsApplication.config.grails?.profile + groovyversion = GroovySystem.getVersion() + jvmversion = System.getProperty('java.version') + controllers = array { + for (c in grailsApplication.controllerClasses) { + controller([name: c.fullName]) + } + } + plugins = array { + for (p in grailsApplication.mainContext.pluginManager.allPlugins) { + plugin([name: p.fullName]) + } + } + } + } +} diff --git a/samples/rest-notes-grails/grails-app/controllers/com/example/InternalServerErrorController.groovy b/samples/rest-notes-grails/grails-app/controllers/com/example/InternalServerErrorController.groovy new file mode 100644 index 00000000..d5c16901 --- /dev/null +++ b/samples/rest-notes-grails/grails-app/controllers/com/example/InternalServerErrorController.groovy @@ -0,0 +1,11 @@ +package com.example + +class InternalServerErrorController { + + def index() { + render(contentType: 'application/json') { + error = 500 + message = "Internal server error" + } + } +} diff --git a/samples/rest-notes-grails/grails-app/controllers/com/example/NotFoundController.groovy b/samples/rest-notes-grails/grails-app/controllers/com/example/NotFoundController.groovy new file mode 100644 index 00000000..8527921d --- /dev/null +++ b/samples/rest-notes-grails/grails-app/controllers/com/example/NotFoundController.groovy @@ -0,0 +1,11 @@ +package com.example + +class NotFoundController { + + def index() { + render(contentType: 'application/json') { + error = 404 + message = "Not Found" + } + } +} diff --git a/samples/rest-notes-grails/grails-app/domain/com/example/Note.groovy b/samples/rest-notes-grails/grails-app/domain/com/example/Note.groovy new file mode 100644 index 00000000..d13fbff5 --- /dev/null +++ b/samples/rest-notes-grails/grails-app/domain/com/example/Note.groovy @@ -0,0 +1,16 @@ +package com.example + +import grails.rest.Resource + +@Resource(uri='/notes', formats = ['json', 'xml']) +class Note { + Long id + String title + String body + + static hasMany = [tags: Tag] + static mapping = { + tags joinTable: [name: "mm_notes_tags", key: 'mm_note_id' ] + } +} + diff --git a/samples/rest-notes-grails/grails-app/domain/com/example/Tag.groovy b/samples/rest-notes-grails/grails-app/domain/com/example/Tag.groovy new file mode 100644 index 00000000..f9b33d11 --- /dev/null +++ b/samples/rest-notes-grails/grails-app/domain/com/example/Tag.groovy @@ -0,0 +1,15 @@ +package com.example + +import grails.rest.Resource + +@Resource(uri='/tags', formats = ['json', 'xml']) +class Tag { + Long id + String name + + static hasMany = [notes: Note] + static belongsTo = Note + static mapping = { + notes joinTable: [name: "mm_notes_tags", key: 'mm_tag_id'] + } +} diff --git a/samples/rest-notes-grails/grails-app/i18n/messages.properties b/samples/rest-notes-grails/grails-app/i18n/messages.properties new file mode 100644 index 00000000..b0451362 --- /dev/null +++ b/samples/rest-notes-grails/grails-app/i18n/messages.properties @@ -0,0 +1,56 @@ +default.doesnt.match.message=Property [{0}] of class [{1}] with value [{2}] does not match the required pattern [{3}] +default.invalid.url.message=Property [{0}] of class [{1}] with value [{2}] is not a valid URL +default.invalid.creditCard.message=Property [{0}] of class [{1}] with value [{2}] is not a valid credit card number +default.invalid.email.message=Property [{0}] of class [{1}] with value [{2}] is not a valid e-mail address +default.invalid.range.message=Property [{0}] of class [{1}] with value [{2}] does not fall within the valid range from [{3}] to [{4}] +default.invalid.size.message=Property [{0}] of class [{1}] with value [{2}] does not fall within the valid size range from [{3}] to [{4}] +default.invalid.max.message=Property [{0}] of class [{1}] with value [{2}] exceeds maximum value [{3}] +default.invalid.min.message=Property [{0}] of class [{1}] with value [{2}] is less than minimum value [{3}] +default.invalid.max.size.message=Property [{0}] of class [{1}] with value [{2}] exceeds the maximum size of [{3}] +default.invalid.min.size.message=Property [{0}] of class [{1}] with value [{2}] is less than the minimum size of [{3}] +default.invalid.validator.message=Property [{0}] of class [{1}] with value [{2}] does not pass custom validation +default.not.inlist.message=Property [{0}] of class [{1}] with value [{2}] is not contained within the list [{3}] +default.blank.message=Property [{0}] of class [{1}] cannot be blank +default.not.equal.message=Property [{0}] of class [{1}] with value [{2}] cannot equal [{3}] +default.null.message=Property [{0}] of class [{1}] cannot be null +default.not.unique.message=Property [{0}] of class [{1}] with value [{2}] must be unique + +default.paginate.prev=Previous +default.paginate.next=Next +default.boolean.true=True +default.boolean.false=False +default.date.format=yyyy-MM-dd HH:mm:ss z +default.number.format=0 + +default.created.message={0} {1} created +default.updated.message={0} {1} updated +default.deleted.message={0} {1} deleted +default.not.deleted.message={0} {1} could not be deleted +default.not.found.message={0} not found with id {1} +default.optimistic.locking.failure=Another user has updated this {0} while you were editing + +default.home.label=Home +default.list.label={0} List +default.add.label=Add {0} +default.new.label=New {0} +default.create.label=Create {0} +default.show.label=Show {0} +default.edit.label=Edit {0} + +default.button.create.label=Create +default.button.edit.label=Edit +default.button.update.label=Update +default.button.delete.label=Delete +default.button.delete.confirm.message=Are you sure? + +# Data binding errors. Use "typeMismatch.$className.$propertyName to customize (eg typeMismatch.Book.author) +typeMismatch.java.net.URL=Property {0} must be a valid URL +typeMismatch.java.net.URI=Property {0} must be a valid URI +typeMismatch.java.util.Date=Property {0} must be a valid Date +typeMismatch.java.lang.Double=Property {0} must be a valid number +typeMismatch.java.lang.Integer=Property {0} must be a valid number +typeMismatch.java.lang.Long=Property {0} must be a valid number +typeMismatch.java.lang.Short=Property {0} must be a valid number +typeMismatch.java.math.BigDecimal=Property {0} must be a valid number +typeMismatch.java.math.BigInteger=Property {0} must be a valid number +typeMismatch=Property {0} is type-mismatched diff --git a/samples/rest-notes-grails/grails-app/init/BootStrap.groovy b/samples/rest-notes-grails/grails-app/init/BootStrap.groovy new file mode 100644 index 00000000..1fba8002 --- /dev/null +++ b/samples/rest-notes-grails/grails-app/init/BootStrap.groovy @@ -0,0 +1,14 @@ +import com.example.Note + +class BootStrap { + + def init = { servletContext -> + environments { + test { + new Note(title: 'Hello, World!', body: 'Hello from the Integration Test').save() + } + } + } + def destroy = { + } +} diff --git a/samples/rest-notes-grails/grails-app/init/com/example/Application.groovy b/samples/rest-notes-grails/grails-app/init/com/example/Application.groovy new file mode 100644 index 00000000..58ad5597 --- /dev/null +++ b/samples/rest-notes-grails/grails-app/init/com/example/Application.groovy @@ -0,0 +1,10 @@ +package com.example + +import grails.boot.GrailsApp +import grails.boot.config.GrailsAutoConfiguration + +class Application extends GrailsAutoConfiguration { + static void main(String[] args) { + GrailsApp.run(Application, args) + } +} diff --git a/samples/rest-notes-grails/src/docs/index.adoc b/samples/rest-notes-grails/src/docs/index.adoc new file mode 100644 index 00000000..231af2ec --- /dev/null +++ b/samples/rest-notes-grails/src/docs/index.adoc @@ -0,0 +1,148 @@ += Grails RESTful Notes API Guide +Andy Wilkinson; Jenn Strater +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 4 +:sectlinks: + +[[overview]] += Overview + +[[overview-http-verbs]] +== HTTP verbs + +Grails RESTful notes tries to adhere as closely as possible to standard HTTP and REST conventions in its +use of HTTP verbs. + +|=== +| Verb | Usage + +| `GET` +| Used to retrieve a resource + +| `POST` +| Used to create a new resource + +| `PATCH` +| Used to update an existing resource, including partial updates + +| `DELETE` +| Used to delete an existing resource +|=== + +[[overview-http-status-codes]] +== HTTP status codes + +Grails RESTful notes tries to adhere as closely as possible to standard HTTP and REST conventions in its +use of HTTP status codes. + +|=== +| Status code | Usage + +| `200 OK` +| The request completed successfully + +| `201 Created` +| A new resource has been created successfully. The resource's URI is available from the response's +`Location` header + +| `204 No Content` +| An update to an existing resource has been applied successfully + +| `400 Bad Request` +| The request was malformed. The response body will include an error providing further information + +| `404 Not Found` +| The requested resource did not exist +|=== + +[[resources]] += Resources + + +[[resources-index]] +== Index + +The index provides the entry point into the service. + + + +[[resources-index-access]] +=== Accessing the index + +A `GET` request is used to access the index + +==== Example request + +include::{snippets}/index-example/curl-request.adoc[] + +==== Response structure + +include::{snippets}/index-example/response-fields.adoc[] + +==== Example response + +include::{snippets}/index-example/http-response.adoc[] + + +[[resources-notes]] +== Notes + +The Notes resources is used to create and list notes + + + +[[resources-notes-list]] +=== Listing notes + +A `GET` request will list all of the service's notes. + +==== Response structure + +include::{snippets}/notes-list-example/response-fields.adoc[] + +==== Example request + +include::{snippets}/notes-list-example/curl-request.adoc[] + +==== Example response + +include::{snippets}/notes-list-example/http-response.adoc[] + + + +[[resources-notes-create]] +=== Creating a note + +A `POST` request is used to create a note + +==== Request structure + +include::{snippets}/notes-create-example/request-fields.adoc[] + +==== Example request + +include::{snippets}/notes-create-example/curl-request.adoc[] + +==== Example response + +include::{snippets}/notes-create-example/http-response.adoc[] + +[[resources-note-retrieve]] +=== Retrieve a note + +A `GET` request will retrieve the details of a note + +==== Response structure + +include::{snippets}/note-get-example/response-fields.adoc[] + +==== Example request + +include::{snippets}/note-get-example/curl-request.adoc[] + +==== Example response + +include::{snippets}/note-get-example/http-response.adoc[] diff --git a/samples/rest-notes-grails/src/integration-test/groovy/com/example/ApiDocumentationSpec.groovy b/samples/rest-notes-grails/src/integration-test/groovy/com/example/ApiDocumentationSpec.groovy new file mode 100644 index 00000000..949f8a56 --- /dev/null +++ b/samples/rest-notes-grails/src/integration-test/groovy/com/example/ApiDocumentationSpec.groovy @@ -0,0 +1,146 @@ +package com.example + +import org.springframework.restdocs.payload.JsonFieldType + +import static com.jayway.restassured.RestAssured.given +import static org.hamcrest.CoreMatchers.is +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields +import static org.springframework.restdocs.restassured.operation.preprocess.RestAssuredPreprocessors.modifyUris +import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.document +import static org.springframework.restdocs.restassured.RestAssuredRestDocumentation.documentationConfiguration + +import com.jayway.restassured.builder.RequestSpecBuilder +import com.jayway.restassured.specification.RequestSpecification +import grails.test.mixin.integration.Integration +import grails.transaction.Rollback +import org.junit.Rule +import org.springframework.restdocs.JUnitRestDocumentation +import org.springframework.http.MediaType +import spock.lang.Specification + +@Integration +@Rollback +class ApiDocumentationSpec extends Specification { + @Rule + JUnitRestDocumentation restDocumentation = new JUnitRestDocumentation('src/docs/generated-snippets') + + protected RequestSpecification documentationSpec + + void setup() { + this.documentationSpec = new RequestSpecBuilder() + .addFilter(documentationConfiguration(restDocumentation)) + .build() + } + + void 'test and document get request for /index'() { + expect: + given(this.documentationSpec) + .accept(MediaType.APPLICATION_JSON.toString()) + .filter(document('index-example', + preprocessRequest(modifyUris() + .host('api.example.com') + .removePort()), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath('message').description('Welcome to Grails!'), + fieldWithPath('environment').description("The running environment"), + fieldWithPath('appversion').description('version of the app that is running'), + fieldWithPath('grailsversion').description('the version of grails used in this project'), + fieldWithPath('appprofile').description('the profile of grails used in this project'), + fieldWithPath('groovyversion').description('the version of groovy used in this project'), + fieldWithPath('jvmversion').description('the version of the jvm used in this project'), + fieldWithPath('controllers').type(JsonFieldType.ARRAY).description('the list of available controllers'), + fieldWithPath('plugins').type(JsonFieldType.ARRAY).description('the plugins active for this project'), + ))) + .when() + .port(8080) + .get('/') + .then() + .assertThat() + .statusCode(is(200)) + } + + void 'test and document notes list request'() { + expect: + given(this.documentationSpec) + .accept(MediaType.APPLICATION_JSON.toString()) + .filter(document('notes-list-example', + preprocessRequest(modifyUris() + .host('api.example.com') + .removePort()), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath('[].class').description('the class of the resource'), + fieldWithPath('[].id').description('the id of the note'), + fieldWithPath('[].title').description('the title of the note'), + fieldWithPath('[].body').description('the body of the note'), + fieldWithPath('[].tags').type(JsonFieldType.ARRAY).description('the list of tags associated with the note'), + ))) + .when() + .port(8080) + .get('/notes') + .then() + .assertThat() + .statusCode(is(200)) + } + + void 'test and document create new note'() { + expect: + given(this.documentationSpec) + .accept(MediaType.APPLICATION_JSON.toString()) + .contentType(MediaType.APPLICATION_JSON.toString()) + .filter(document('notes-create-example', + preprocessRequest(modifyUris() + .host('api.example.com') + .removePort()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath('title').description('the title of the note'), + fieldWithPath('body').description('the body of the note'), + fieldWithPath('tags').type(JsonFieldType.ARRAY).description('a list of tags associated to the note') + ), + responseFields( + fieldWithPath('class').description('the class of the resource'), + fieldWithPath('id').description('the id of the note'), + fieldWithPath('title').description('the title of the note'), + fieldWithPath('body').description('the body of the note'), + fieldWithPath('tags').type(JsonFieldType.ARRAY).description('the list of tags associated with the note'), + ))) + .body('{ "body": "My test example", "title": "Eureka!", "tags": [{"name": "testing123"}] }') + .when() + .port(8080) + .post('/notes') + .then() + .assertThat() + .statusCode(is(201)) + } + + void 'test and document getting specific note'() { + expect: + given(this.documentationSpec) + .accept(MediaType.APPLICATION_JSON.toString()) + .filter(document('note-get-example', + preprocessRequest(modifyUris() + .host('api.example.com') + .removePort()), + preprocessResponse(prettyPrint()), + responseFields( + fieldWithPath('class').description('the class of the resource'), + fieldWithPath('id').description('the id of the note'), + fieldWithPath('title').description('the title of the note'), + fieldWithPath('body').description('the body of the note'), + fieldWithPath('tags').type(JsonFieldType.ARRAY).description('the list of tags associated with the note'), + ))) + .when() + .port(8080) + .get('/notes/1') + .then() + .assertThat() + .statusCode(is(200)) + } +}