├── grails-app ├── i18n │ └── messages.properties ├── conf │ ├── JsonRestApiUrlMappings.groovy │ ├── BuildConfig.groovy │ ├── DataSource.groovy │ └── Config.groovy ├── views │ └── error.gsp ├── domain │ └── todo │ │ └── Todo.groovy └── controllers │ └── org │ └── grails │ └── plugins │ └── rest │ └── JsonRestApiController.groovy ├── .gitignore ├── application.properties ├── scripts ├── _Uninstall.groovy ├── _Install.groovy └── _Upgrade.groovy ├── src └── groovy │ └── org │ └── grails │ └── plugins │ ├── util │ └── TextUtil.groovy │ ├── rest │ ├── NumberToDomainInstanceEditor.groovy │ ├── JsonDateEditorRegistrar.groovy │ ├── JsonRestApiPropertyEditorRegistrar.groovy │ ├── JSONApiRegistry.groovy │ └── JSONDomainMarshaller.groovy │ └── test │ └── GenericRestFunctionalTests.groovy ├── web-app └── WEB-INF │ ├── sitemesh.xml │ ├── applicationContext.xml │ └── tld │ ├── spring.tld │ ├── c.tld │ ├── grails.tld │ └── fmt.tld ├── test ├── functional │ └── todo │ │ ├── TodoFunctionalTests.groovy │ │ └── TodoUnderscoreFunctionalTests.groovy └── unit │ └── json │ └── rest │ └── api │ ├── TextUtilTests.groovy │ ├── JSONApiRegistryTests.groovy │ └── JsonRestApiControllerTests.groovy ├── JsonRestApiGrailsPlugin.groovy ├── README.md └── LICENSE.txt /grails-app/i18n/messages.properties: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | classes 2 | target 3 | .settings 4 | .classpath 5 | .project 6 | plugin.xml 7 | *.zip 8 | jrebel.lic 9 | logs 10 | -------------------------------------------------------------------------------- /application.properties: -------------------------------------------------------------------------------- 1 | #Grails Metadata file 2 | #Fri May 17 12:39:40 CEST 2013 3 | app.grails.version=2.1.1 4 | app.name=json-rest-api 5 | plugins.functional-test=2.0.RC1 6 | plugins.hibernate=2.1.1 7 | plugins.tomcat=2.1.1 8 | -------------------------------------------------------------------------------- /scripts/_Uninstall.groovy: -------------------------------------------------------------------------------- 1 | // 2 | // This script is executed by Grails when the plugin is uninstalled from project. 3 | // Use this script if you intend to do any additional clean-up on uninstall, but 4 | // beware of messing up SVN directories! 5 | // 6 | -------------------------------------------------------------------------------- /src/groovy/org/grails/plugins/util/TextUtil.groovy: -------------------------------------------------------------------------------- 1 | package org.grails.plugins.util 2 | 3 | class TextUtil { 4 | 5 | static String pluralize (def token) { 6 | token?.size() > 0 ? (token[token?.size()-1] == "s" ? "${token}es" : "${token}s") : "" 7 | } 8 | 9 | static String emberPluralize (def token) { 10 | token?.size() > 0 ? "${token}s" : "" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /scripts/_Install.groovy: -------------------------------------------------------------------------------- 1 | // 2 | // This script is executed by Grails after plugin was installed to project. 3 | // This script is a Gant script so you can use all special variables provided 4 | // by Gant (such as 'baseDir' which points on project base dir). You can 5 | // use 'ant' to access a global instance of AntBuilder 6 | // 7 | // For example you can create directory under project tree: 8 | // 9 | // ant.mkdir(dir:"${basedir}/grails-app/jobs") 10 | // 11 | -------------------------------------------------------------------------------- /scripts/_Upgrade.groovy: -------------------------------------------------------------------------------- 1 | // 2 | // This script is executed by Grails during application upgrade ('grails upgrade' 3 | // command). This script is a Gant script so you can use all special variables 4 | // provided by Gant (such as 'baseDir' which points on project base dir). You can 5 | // use 'ant' to access a global instance of AntBuilder 6 | // 7 | // For example you can create directory under project tree: 8 | // 9 | // ant.mkdir(dir:"${basedir}/grails-app/jobs") 10 | // 11 | -------------------------------------------------------------------------------- /web-app/WEB-INF/sitemesh.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/groovy/org/grails/plugins/rest/NumberToDomainInstanceEditor.groovy: -------------------------------------------------------------------------------- 1 | package org.grails.plugins.rest; 2 | 3 | import java.beans.PropertyEditorSupport; 4 | 5 | public class NumberToDomainInstanceEditor extends PropertyEditorSupport { 6 | private final Class domainClass 7 | 8 | public NumberToDomainInstanceEditor(Class domainClass) { 9 | this.domainClass = domainClass 10 | } 11 | 12 | @Override 13 | public void setValue(Object value) { 14 | if (domainClass.isAssignableFrom(value.class)) 15 | super.setValue(value) 16 | else if (value instanceof Number) { 17 | def instance = domainClass.get(value) 18 | super.setValue(instance) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/groovy/org/grails/plugins/rest/JsonDateEditorRegistrar.groovy: -------------------------------------------------------------------------------- 1 | package org.grails.plugins.rest; 2 | 3 | // Enables JSON serialized dates to be properly deserialized 4 | // 5 | // Ref: http://stackoverflow.com/questions/2871977/binding-a-grails-date-from-params-in-a-controller 6 | 7 | import org.springframework.beans.PropertyEditorRegistrar 8 | import org.springframework.beans.PropertyEditorRegistry 9 | import org.springframework.beans.propertyeditors.CustomDateEditor 10 | import java.text.SimpleDateFormat 11 | 12 | public class JsonDateEditorRegistrar implements PropertyEditorRegistrar { 13 | 14 | public void registerCustomEditors(PropertyEditorRegistry registry) { 15 | 16 | registry.registerCustomEditor(Date, new CustomDateEditor(new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"), true)) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/groovy/org/grails/plugins/rest/JsonRestApiPropertyEditorRegistrar.groovy: -------------------------------------------------------------------------------- 1 | package org.grails.plugins.rest; 2 | 3 | import org.springframework.beans.PropertyEditorRegistrar; 4 | import org.springframework.beans.PropertyEditorRegistry; 5 | 6 | import org.codehaus.groovy.grails.commons.GrailsApplication 7 | 8 | public class JsonRestApiPropertyEditorRegistrar implements PropertyEditorRegistrar { 9 | private final GrailsApplication application 10 | 11 | public JsonRestApiPropertyEditorRegistrar(GrailsApplication application) { 12 | this.application = application 13 | } 14 | 15 | public void registerCustomEditors(PropertyEditorRegistry reg) { 16 | JSONApiRegistry.registry.each { name, className -> 17 | Class clazz = application.getClassForName(className) 18 | reg.registerCustomEditor(clazz, new NumberToDomainInstanceEditor(clazz)); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/functional/todo/TodoFunctionalTests.groovy: -------------------------------------------------------------------------------- 1 | package todo 2 | 3 | import org.apache.commons.logging.LogFactory 4 | import org.grails.plugins.test.GenericRestFunctionalTests 5 | 6 | import com.grailsrocks.functionaltest.BrowserTestCase 7 | 8 | 9 | @Mixin(GenericRestFunctionalTests) 10 | class TodoFunctionalTests extends BrowserTestCase { 11 | 12 | def log = LogFactory.getLog(getClass()) 13 | def messageSource 14 | 15 | void setUp() { 16 | super.setUp() 17 | } 18 | 19 | void tearDown() { 20 | super.tearDown() 21 | } 22 | 23 | 24 | void testList() { 25 | genericTestList(new Todo(title:"title.one")) 26 | } 27 | 28 | void testCreate() { 29 | genericTestCreate(new Todo(title:"title.one")) 30 | } 31 | 32 | void testShow() { 33 | genericTestShow(new Todo(title:"title.one")) 34 | } 35 | 36 | void testUpdate() { 37 | genericTestUpdate(new Todo(title:"title.one"), [title:"title.two"]) 38 | } 39 | 40 | void testDelete() { 41 | genericTestDelete(new Todo(title:"title.one")) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /grails-app/conf/JsonRestApiUrlMappings.groovy: -------------------------------------------------------------------------------- 1 | import org.codehaus.groovy.grails.commons.ConfigurationHolder 2 | import org.grails.plugins.rest.JSONApiRegistry 3 | 4 | 5 | class JsonRestApiUrlMappings { 6 | 7 | 8 | static mappings = { 9 | //TODO Replace if a real solution is offered to these 10 | // http://jira.grails.org/browse/GRAILS-8508 11 | // http://jira.grails.org/browse/GRAILS-8616 12 | // http://jira.grails.org/browse/GRAILS-8598 13 | // 14 | // otherwise let's not suffer goofy workarounds unless forced to 15 | def config = ConfigurationHolder.config.grails.'json-rest-api' 16 | def root = config.root ? config.root : '/api' 17 | 18 | "${root}/$domain" (controller: 'jsonRestApi') { 19 | entity = { JSONApiRegistry.getEntity(params.domain) } // Registry recognizes plural form 20 | action = [ GET: 'list', POST: 'create' ] 21 | } 22 | 23 | "${root}/$domain/$id" (controller: 'jsonRestApi') { 24 | entity = { JSONApiRegistry.getEntity(params.domain) } // Registry recognizes plural form 25 | action = [ GET: 'show', PUT: 'update', DELETE: 'delete' ] 26 | } 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /test/functional/todo/TodoUnderscoreFunctionalTests.groovy: -------------------------------------------------------------------------------- 1 | package todo 2 | 3 | import org.apache.commons.logging.LogFactory 4 | import org.grails.plugins.test.GenericRestFunctionalTests 5 | 6 | import com.grailsrocks.functionaltest.BrowserTestCase 7 | 8 | 9 | @Mixin(GenericRestFunctionalTests) 10 | class TodoUnderscoreFunctionalTests extends BrowserTestCase { 11 | 12 | def log = LogFactory.getLog(getClass()) 13 | def messageSource 14 | 15 | void setUp() { 16 | super.setUp() 17 | } 18 | 19 | void tearDown() { 20 | super.tearDown() 21 | } 22 | 23 | 24 | void testList() { 25 | genericTestList(new Todo(title:"title.one", expose:'todo_underscore')) 26 | } 27 | 28 | void testCreate() { 29 | genericTestCreate(new Todo(title:"title.one", expose:'todo_underscore')) 30 | } 31 | 32 | void testShow() { 33 | genericTestShow(new Todo(title:"title.one", expose:'todo_underscore')) 34 | } 35 | 36 | void testUpdate() { 37 | genericTestUpdate(new Todo(title:"title.one", expose:'todo_underscore'), [title:"title.two"]) 38 | } 39 | 40 | void testDelete() { 41 | genericTestDelete(new Todo(title:"title.one", expose:'todo_underscore')) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /grails-app/conf/BuildConfig.groovy: -------------------------------------------------------------------------------- 1 | grails.project.class.dir = "target/classes" 2 | grails.project.test.class.dir = "target/test-classes" 3 | grails.project.test.reports.dir = "target/test-reports" 4 | //grails.project.war.file = "target/${appName}-${appVersion}.war" 5 | grails.project.dependency.resolution = { 6 | // inherit Grails' default dependencies 7 | inherits("global") { 8 | // uncomment to disable ehcache 9 | // excludes 'ehcache' 10 | } 11 | log "warn" // log level of Ivy resolver, either 'error', 'warn', 'info', 'debug' or 'verbose' 12 | repositories { 13 | grailsPlugins() 14 | grailsHome() 15 | grailsCentral() 16 | 17 | // uncomment the below to enable remote dependency resolution 18 | // from public Maven repositories 19 | //mavenLocal() 20 | //mavenCentral() 21 | //mavenRepo "http://snapshots.repository.codehaus.org" 22 | //mavenRepo "http://repository.codehaus.org" 23 | //mavenRepo "http://download.java.net/maven/2/" 24 | //mavenRepo "http://repository.jboss.com/maven2/" 25 | } 26 | dependencies { 27 | // specify dependencies here under either 'build', 'compile', 'runtime', 'test' or 'provided' scopes eg. 28 | 29 | // runtime 'mysql:mysql-connector-java:5.1.13' 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /grails-app/conf/DataSource.groovy: -------------------------------------------------------------------------------- 1 | dataSource { 2 | pooled = true 3 | driverClassName = "org.h2.Driver" 4 | username = "sa" 5 | password = "" 6 | } 7 | hibernate { 8 | cache.use_second_level_cache = true 9 | cache.use_query_cache = false 10 | cache.region.factory_class = 'net.sf.ehcache.hibernate.EhCacheRegionFactory' 11 | } 12 | // environment specific settings 13 | environments { 14 | development { 15 | dataSource { 16 | dbCreate = "create-drop" // one of 'create', 'create-drop', 'update', 'validate', '' 17 | url = "jdbc:h2:mem:devDb;MVCC=TRUE;LOCK_TIMEOUT=10000" 18 | } 19 | } 20 | test { 21 | dataSource { 22 | dbCreate = "update" 23 | url = "jdbc:h2:mem:testDb;MVCC=TRUE;LOCK_TIMEOUT=10000" 24 | } 25 | } 26 | production { 27 | dataSource { 28 | dbCreate = "update" 29 | url = "jdbc:h2:prodDb;MVCC=TRUE;LOCK_TIMEOUT=10000" 30 | pooled = true 31 | properties { 32 | maxActive = -1 33 | minEvictableIdleTimeMillis=1800000 34 | timeBetweenEvictionRunsMillis=1800000 35 | numTestsPerEvictionRun=3 36 | testOnBorrow=true 37 | testWhileIdle=true 38 | testOnReturn=true 39 | validationQuery="SELECT 1" 40 | } 41 | } 42 | } 43 | } 44 | s -------------------------------------------------------------------------------- /test/unit/json/rest/api/TextUtilTests.groovy: -------------------------------------------------------------------------------- 1 | package json.rest.api 2 | 3 | import grails.test.mixin.* 4 | import grails.test.mixin.support.* 5 | 6 | import org.apache.commons.logging.LogFactory 7 | import org.grails.plugins.util.TextUtil 8 | import org.junit.* 9 | 10 | /** 11 | * See the API for {@link grails.test.mixin.support.GrailsUnitTestMixin} for usage instructions 12 | * @author kent.butler@gmail.com 13 | */ 14 | @TestMixin(GrailsUnitTestMixin) 15 | class TextUtilTests { 16 | 17 | def controller 18 | def log = LogFactory.getLog(getClass()) 19 | 20 | void setUp() { 21 | } 22 | 23 | void tearDown() { 24 | // Tear down logic here 25 | } 26 | 27 | @Test 28 | void testPluralNonS() { 29 | log.debug("------------ testPluralNonS() -------------") 30 | 31 | def result = TextUtil.pluralize "duck" 32 | 33 | assertEquals "ducks", result 34 | } 35 | 36 | @Test 37 | void testPluralS() { 38 | log.debug("------------ testPluralS() -------------") 39 | 40 | def result = TextUtil.pluralize "moss" 41 | 42 | assertEquals "mosses", result 43 | } 44 | 45 | @Test 46 | void testNull() { 47 | log.debug("------------ testNull() -------------") 48 | 49 | def result = TextUtil.pluralize null 50 | 51 | assertEquals "", result 52 | } 53 | 54 | @Test 55 | void testEmpty() { 56 | log.debug("------------ testEmpty() -------------") 57 | 58 | def result = TextUtil.pluralize '' 59 | 60 | assertEquals "", result 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /test/unit/json/rest/api/JSONApiRegistryTests.groovy: -------------------------------------------------------------------------------- 1 | package json.rest.api 2 | 3 | import grails.test.mixin.* 4 | import grails.test.mixin.support.* 5 | 6 | import org.apache.commons.logging.LogFactory 7 | import org.grails.plugins.rest.JSONApiRegistry 8 | import org.grails.plugins.util.TextUtil 9 | import org.junit.* 10 | 11 | /** 12 | * See the API for {@link grails.test.mixin.support.GrailsUnitTestMixin} for usage instructions 13 | * @author kent.butler@gmail.com 14 | */ 15 | @TestMixin(GrailsUnitTestMixin) 16 | class JSONApiRegistryTests { 17 | 18 | def controller 19 | def log = LogFactory.getLog(getClass()) 20 | 21 | void setUp() { 22 | } 23 | 24 | void tearDown() { 25 | // Tear down logic here 26 | } 27 | 28 | @Test 29 | void testBasic() { 30 | log.debug("------------ testBasic() -------------") 31 | 32 | JSONApiRegistry.register("horse", "dc.Horse") 33 | 34 | assertNotNull JSONApiRegistry.getEntity("horse") 35 | assertNotNull JSONApiRegistry.getEntity("horses") 36 | assertNotNull JSONApiRegistry.getSingular("horse") 37 | assertNotNull JSONApiRegistry.getSingular("horses") 38 | } 39 | 40 | @Test 41 | void testBasicS() { 42 | log.debug("------------ testBasicS() -------------") 43 | 44 | JSONApiRegistry.register("toads", "dc.Toads") 45 | 46 | assertNotNull JSONApiRegistry.getEntity("toads") 47 | assertNotNull JSONApiRegistry.getEntity("toadses") 48 | assertNotNull JSONApiRegistry.getSingular("toads") 49 | assertNotNull JSONApiRegistry.getSingular("toadses") 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /grails-app/views/error.gsp: -------------------------------------------------------------------------------- 1 | 2 | 3 | Grails Runtime Exception 4 | 24 | 25 | 26 | 27 |

Grails Runtime Exception

28 |

Error Details

29 | 30 |
31 | Error ${request.'javax.servlet.error.status_code'}: ${request.'javax.servlet.error.message'.encodeAsHTML()}
32 | Servlet: ${request.'javax.servlet.error.servlet_name'}
33 | URI: ${request.'javax.servlet.error.request_uri'}
34 | 35 | Exception Message: ${exception.message?.encodeAsHTML()}
36 | Caused by: ${exception.cause?.message?.encodeAsHTML()}
37 | Class: ${exception.className}
38 | At Line: [${exception.lineNumber}]
39 | Code Snippet:
40 |
41 | 42 | ${cs?.encodeAsHTML()}
43 |
44 |
45 |
46 |
47 | 48 |

Stack Trace

49 |
50 |
${it.encodeAsHTML()}
51 |
52 |
53 | 54 | -------------------------------------------------------------------------------- /src/groovy/org/grails/plugins/rest/JSONApiRegistry.groovy: -------------------------------------------------------------------------------- 1 | package org.grails.plugins.rest 2 | 3 | import org.grails.plugins.util.TextUtil 4 | 5 | class JSONApiRegistry { 6 | static registry = [:] 7 | static singulars = [:] 8 | 9 | /** 10 | * Register a given entity name with its full domain classname 11 | * Will also automatically register the plural version of the entity name 12 | * @param name 13 | * @param className 14 | * @return 15 | */ 16 | static register(def name, def className) { 17 | registry[name] = className 18 | // Register the plural, if not already 19 | registry[TextUtil.pluralize(name)] = className 20 | // ISSUE: Ember by default pluralizes using only "s" - producing "person/persons" and "class/classs" - 21 | // although the following operation will be redundant in most cases, 22 | // allow for wider compatibility by supporting both Ember style and others styled closer to English 23 | // i.e. the following will redundantly replace the previous "person/persons" registration, or in the 24 | // case of "class/classes" it will register a second mapping "class/classs" 25 | registry[TextUtil.emberPluralize(name)] = className 26 | // Keep track of plural->singular mapping, and singular->singular for convenience 27 | singulars[name] = name 28 | singulars[TextUtil.pluralize(name)] = name 29 | // ISSUE: See pluralization ISSUE comment above 30 | singulars[TextUtil.emberPluralize(name)] = name 31 | } 32 | 33 | static getEntity(def token) { 34 | return registry[token] 35 | } 36 | 37 | /** 38 | * See if a singular form of the given token has been registered; 39 | * used in processing incoming URLs to identify the root form of the word 40 | * @param token 41 | * @return 42 | */ 43 | static getSingular(def token) { 44 | singulars[token] 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /web-app/WEB-INF/applicationContext.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | Grails application factory bean 9 | 10 | 11 | 12 | 13 | 14 | A bean that manages Grails plugins 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | classpath*:**/grails-app/**/*.groovy 33 | 34 | 35 | 36 | 38 | 39 | utf-8 40 | 41 | 42 | -------------------------------------------------------------------------------- /grails-app/conf/Config.groovy: -------------------------------------------------------------------------------- 1 | // configuration for plugin testing - will not be included in the plugin zip 2 | 3 | log4j = { 4 | // Example of changing the log pattern for the default console 5 | // appender: 6 | // 7 | //appenders { 8 | // console name:'stdout', layout:pattern(conversionPattern: '%c{2} %m%n') 9 | //} 10 | 11 | error 'org.codehaus.groovy.grails.web.servlet', // controllers 12 | 'org.codehaus.groovy.grails.web.pages', // GSP 13 | 'org.codehaus.groovy.grails.web.sitemesh', // layouts 14 | 'org.codehaus.groovy.grails.web.mapping.filter', // URL mapping 15 | 'org.codehaus.groovy.grails.web.mapping', // URL mapping 16 | 'org.codehaus.groovy.grails.commons', // core / classloading 17 | 'org.codehaus.groovy.grails.plugins', // plugins 18 | 'org.codehaus.groovy.grails.orm.hibernate', // hibernate integration 19 | 'org.springframework', 20 | 'org.hibernate', 21 | 'net.sf.ehcache.hibernate' 22 | 23 | warn 'org.mortbay.log' 24 | } 25 | 26 | // 27 | // This is how you change the root URL for this plugin: 28 | // 29 | // grails.'json-rest-api'.root = '/json' 30 | // 31 | grails.views.default.codec="none" // none, html, base64 32 | grails.views.gsp.encoding="UTF-8" 33 | 34 | environments { 35 | test { 36 | grails.logging.jul.usebridge = false 37 | log4j = { 38 | appenders { 39 | rollingFile name:"plugin", maxFileSize:"10000KB", maxBackupIndex:10, file:"logs/json-rest-api.log",layout:pattern(conversionPattern: '%d{yyyy-MM-dd HH:mm:ss,SSS z} [%t] %-5p[%c]: %m%n') 40 | console name:'stacktrace' // to get stacktraces out to the console 41 | } 42 | 43 | debug 'json.rest.api','grails.app','org.grails.plugins.rest',additivity = true 44 | warn 'org.codehaus.groovy','org.grails.plugin','grails.spring','net.sf.ehcache','grails.plugin', 45 | 'org.apache','com.gargoylesoftware.htmlunit','org.codehaus.groovy.grails.orm.hibernate','org.hibernate' 46 | 47 | root { 48 | debug 'plugin','stacktrace' 49 | additivity = true 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /JsonRestApiGrailsPlugin.groovy: -------------------------------------------------------------------------------- 1 | import org.codehaus.groovy.grails.commons.GrailsClassUtils 2 | import org.grails.plugins.rest.JSONApiRegistry 3 | import org.grails.plugins.rest.JsonRestApiPropertyEditorRegistrar 4 | import org.grails.plugins.rest.JsonDateEditorRegistrar 5 | 6 | class JsonRestApiGrailsPlugin { 7 | // the plugin version 8 | def version = "1.0.12-SNAPSHOT" 9 | // the version or versions of Grails the plugin is designed for 10 | def grailsVersion = "1.3.0 > *" 11 | // the other plugins this plugin depends on 12 | def dependsOn = [:] 13 | // resources that are excluded from plugin packaging 14 | def pluginExcludes = [ 15 | "grails-app/views/error.gsp" 16 | ] 17 | 18 | def author = "Matthias Hryniszak" 19 | def authorEmail = "padcom@gmail.com" 20 | def title = "JSON RESTful API for GORM" 21 | def description = '''\\ 22 | This plugin provides effortless JSON API for GORM classes 23 | ''' 24 | 25 | // URL to the plugin's documentation 26 | def documentation = "http://grails.org/plugin/json-rest-api" 27 | 28 | def doWithWebDescriptor = { xml -> 29 | // TODO Implement additions to web.xml (optional), this event occurs before 30 | } 31 | 32 | def doWithSpring = { 33 | jsonRestApiPropertyEditorRegistrar(JsonRestApiPropertyEditorRegistrar, ref("grailsApplication")) 34 | customPropertyEditorRegistrar(JsonDateEditorRegistrar) 35 | } 36 | 37 | def doWithDynamicMethods = { ctx -> 38 | application.domainClasses.each { domainClass -> 39 | def resource = domainClass.getStaticPropertyValue('expose', String) 40 | if (resource) { 41 | println "Registering domain class: ${domainClass.fullName} exposed as ${resource} and its plural" 42 | JSONApiRegistry.register(resource, domainClass.fullName) 43 | } 44 | } 45 | } 46 | 47 | def doWithApplicationContext = { applicationContext -> 48 | grails.converters.JSON.registerObjectMarshaller(new org.grails.plugins.rest.JSONDomainMarshaller(application)) 49 | } 50 | 51 | def onChange = { event -> 52 | // TODO Implement code that is executed when any artefact that this plugin is 53 | // watching is modified and reloaded. The event contains: event.source, 54 | // event.application, event.manager, event.ctx, and event.plugin. 55 | } 56 | 57 | def onConfigChange = { event -> 58 | // TODO Implement code that is executed when the project configuration changes. 59 | // The event is the same as for 'onChange'. 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/groovy/org/grails/plugins/rest/JSONDomainMarshaller.groovy: -------------------------------------------------------------------------------- 1 | package org.grails.plugins.rest 2 | 3 | // 4 | // CustomDomainMarshaller.groovy by Siegfried Puchbauer 5 | // 6 | // http://stackoverflow.com/questions/1700668/grails-jsonp-callback-without-id-and-class-in-json-file/1701258#1701258 7 | // 8 | 9 | import grails.converters.JSON; 10 | import org.codehaus.groovy.grails.commons.GrailsApplication; 11 | import org.codehaus.groovy.grails.commons.DomainClassArtefactHandler; 12 | import org.codehaus.groovy.grails.web.converters.ConverterUtil; 13 | import org.codehaus.groovy.grails.web.converters.exceptions.ConverterException; 14 | import org.codehaus.groovy.grails.web.converters.marshaller.ObjectMarshaller; 15 | import org.codehaus.groovy.grails.web.json.JSONWriter; 16 | import org.springframework.beans.BeanUtils; 17 | 18 | public class JSONDomainMarshaller implements ObjectMarshaller { 19 | 20 | static EXCLUDED = ['metaClass','class','version'] 21 | 22 | private GrailsApplication application 23 | 24 | public JSONDomainMarshaller(GrailsApplication application) { 25 | this.application = application 26 | } 27 | 28 | public boolean supports(Object object) { 29 | return isDomainClass(object.getClass()) 30 | } 31 | 32 | private getCustomApi(clazz) { 33 | clazz.declaredFields.name.contains('api') ? clazz.api : null 34 | } 35 | 36 | public void marshalObject(Object o, JSON json) throws ConverterException { 37 | JSONWriter writer = json.getWriter(); 38 | try { 39 | writer.object(); 40 | def properties = BeanUtils.getPropertyDescriptors(o.getClass()); 41 | def excludedFields = getCustomApi(o.class)?.excludedFields 42 | for (property in properties) { 43 | String name = property.getName(); 44 | if(!(EXCLUDED.contains(name) || excludedFields?.contains(name))) { 45 | def readMethod = property.getReadMethod(); 46 | if (readMethod != null) { 47 | def value = readMethod.invoke(o, (Object[]) null); 48 | if (value instanceof List || value instanceof Set) { 49 | writer.key(name); 50 | writer.array() 51 | value.each { item -> 52 | if (isDomainClass(item.getClass())) { 53 | json.convertAnother(item.id); 54 | } else { 55 | json.convertAnother(item); 56 | } 57 | } 58 | writer.endArray() 59 | } else if (isDomainClass(value.getClass())) { 60 | writer.key(name); 61 | json.convertAnother(value.id); 62 | } else { 63 | writer.key(name); 64 | json.convertAnother(value); 65 | } 66 | } 67 | } 68 | } 69 | writer.endObject(); 70 | } catch (Exception e) { 71 | throw new ConverterException("Exception in JSONDomainMarshaller", e); 72 | } 73 | } 74 | 75 | private boolean isDomainClass(Class clazz) { 76 | String name = ConverterUtil.trimProxySuffix(clazz.getName()); 77 | return application.isArtefactOfType(DomainClassArtefactHandler.TYPE, name); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /grails-app/domain/todo/Todo.groovy: -------------------------------------------------------------------------------- 1 | package todo 2 | 3 | import org.codehaus.groovy.grails.web.json.JSONObject 4 | 5 | 6 | 7 | class Todo { 8 | 9 | String title 10 | boolean isCompleted 11 | 12 | static constraints = { 13 | title(blank:false, nullable:false,maxSize:64) 14 | isCompleted(default:false) 15 | } 16 | 17 | 18 | String toString() { 19 | StringBuilder sb = new StringBuilder() 20 | sb.append("\n id: ").append(id) 21 | sb.append("\n Title: ").append(title) 22 | sb.append("\n Completed: ").append(isCompleted) 23 | sb.toString() 24 | } 25 | 26 | 27 | // --- json-rest-api artifacts --- 28 | 29 | static expose = 'todo' // Expose as REST API using json-rest-api plugin 30 | // this will be the entity name on the URL 31 | static api = [ 32 | // If allowing json-rest-api to use 'as JSON' to render, you may exclude 33 | // unwanted fields here (done with its registered ObjectMarshaller) 34 | excludedFields: [ "attached", "errors", "properties" ], 35 | // You may override how the list() operation performs its search here 36 | list : { params -> Todo.list(params) }, 37 | count: { params -> Todo.count() } 38 | ] 39 | 40 | 41 | /* 42 | // This is the standard way to override JSON marshalling for a class 43 | // It uses a ClosureOjectMarshaller[sic] to select fields for marshalling 44 | // It is less efficient for the plugin which is based on JSONObject, but this will be 45 | // used if you do not define a 'toJSON' method. 46 | // NOTE: if using this approach, the json-rest-api marshaller will NOT be used, hence the 47 | // api.excludedFields if defined will be ignored 48 | // Example taken from http://grails.org/Converters+Reference 49 | static { 50 | grails.converters.JSON.registerObjectMarshaller(Todo) { 51 | // you can filter here the key-value pairs to output: 52 | return it.properties.findAll {k,v -> k != 'passwd'} 53 | } 54 | } 55 | */ 56 | 57 | 58 | /** 59 | * Rending this object into a JSONObject; allows more flexibility and efficiency in how 60 | * the object is eventually included in larger JSON structures before ultimate rendering; 61 | * MessageSource offered for i18n conversion before exporting for user audience. 62 | * @param messageSource 63 | * @return 64 | */ 65 | JSONObject toJSON(def messageSource) { 66 | JSONObject json = new JSONObject() 67 | json.put('id', id) 68 | json.put('title', title) 69 | json.put('isCompleted', isCompleted) 70 | return json 71 | } 72 | 73 | /** 74 | * Custom bind from JSON; this has efficiency since the grails request.JSON object offers 75 | * a JSONObject directly 76 | * @param json 77 | */ 78 | void fromJSON (JSONObject json) { 79 | [ 80 | "title" 81 | ].each(optStr.curry(json, this)) 82 | [ 83 | "isCompleted" 84 | ].each(optBoolean.curry(json, this)) 85 | } 86 | 87 | 88 | static Closure optStr = {json, obj, prop -> 89 | if(json?.has(prop)) { 90 | String propVal = (json?.isNull(prop)) ? null : json?.getString(prop)?.trim() 91 | obj[prop] = propVal 92 | } 93 | } 94 | 95 | static Closure optInt = {json, obj, prop -> 96 | if(json?.has(prop)) 97 | obj[prop] = (json?.isNull(prop)) ? null : json?.getInt(prop) 98 | } 99 | 100 | static Closure optBoolean = {json, obj, prop -> 101 | if(json?.has(prop)) 102 | obj[prop] = (json?.isNull(prop)) ? null : json?.getBoolean(prop) 103 | } 104 | 105 | } 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Description of Changes in this Branch 2 | 3 | ### Upgraded to Grails 2.1.1 4 | 5 | Grails update. 6 | 7 | ### Support for the JSON REST dialect used by EmberJS 8 | 9 | Want to serve objects up to EmberJS? It's default RESTAdapter is a little finicky. You will need to provide it with [something 10 | that looks like this](http://kentbutlercs.blogspot.hu/2013/02/emberjs-notes-and-gotchas.html). 11 | 12 | To accomplish this with this fork of the json-rest-api plugin, you need to set the following flags in your grails-app/conf/Config.groovy: 13 | 14 | // Set to 'true' to package JSON objects inside a node named for the object 15 | // Value of 'false' will use 'data' as the node name instead 16 | // Default value is false 17 | // Enable this for EmberJS 18 | grails.'json-rest-api'.useEntityRootName = true 19 | 20 | // Set to 'true' to exclude meta-data from the JSON containing the rendered object 21 | // Value of 'false' will include meta-data fields such as 'count' and 'status' 22 | // Default value is false 23 | // Enable this for EmberJS 24 | grails.'json-rest-api'.dataFieldsOnly = true 25 | 26 | // Set to 'true' to wrap JSON results in a top level of meta-data including 'success'/[true/false] 27 | // and 'message' fields. Data is placed at this top level under the node specified by the 28 | // 'useEntityRootName' flag. 29 | // Value of 'false' will render only data. 30 | // Default value is false 31 | // Disable this for EmberJS 32 | grails.'json-rest-api'.wrapResults = false 33 | 34 | For example, for the 'show' action this will produce output like: 35 | 36 | {"todo":{"id":1,"title":"eat","completed":false}} 37 | 38 | instead of: 39 | 40 | {"data":{"id":1,"title":"eat","completed":false},"success":true} 41 | 42 | For the 'list' action, enabling these flags gives you: 43 | 44 | {"todo":[{"id":1,"title":"eat","completed":false},{"id":2,"title":"sleep","completed":false},{"id":3,"title":"work","completed":false},{"id":4,"title":"title","completed":false},{"id":5,"title":"title","completed":false},{"id":6,"title":"title","completed":false},{"id":7,"title":"title","completed":false},{"id":8,"title":"title","completed":false},{"id":9,"title":"title","completed":false},{"id":10,"title":"title","completed":false},{"id":11,"title":"title","completed":false}]} 45 | 46 | and with the flags disabled: 47 | 48 | {"count":7,"data":[{"id":1,"title":"eat","completed":false},{"id":2,"title":"sleep","completed":false},{"id":3,"title":"work","completed":false},{"id":4,"title":"title","completed":false},{"id":5,"title":"title","completed":false},{"id":6,"title":"title","completed":false},{"id":7,"title":"title","completed":false}],"success":true} 49 | 50 | ### Quick Start 51 | 52 | Run the Plugin: 53 | 54 | * [Install Grails 2.1](http://grails.org/download) 55 | 56 | * Clone this project 57 | 58 | git clone https://github.com/kentbutler/grails-json-rest-api.git 59 | 60 | * Run the project's built-in test cases 61 | 62 | grails test-app -functional 63 | 64 | and observe the output to see how the REST interface behaves. 65 | 66 | * Add to your Grails project - add the following to grails-app/conf/BuildConfig.groovy (one possible way) 67 | 68 | // Adding Plugin-in: grails-json-rest 69 | grails.plugin.location.jsonrest = "../grails-json-rest-api" 70 | 71 | 72 | ### Examples 73 | 74 | * See the sample TodoMVC app and run its functional tests. 75 | 76 | - [See the 'Installing' section of the blog writeup](http://kentbutlercs.blogspot.hu/2013/03/emberjs-putting-rest-service-behind.html). 77 | 78 | see the tests defined in test driver 79 | 80 | todomvc-grails-emberjs/test/functional/todo/TodoFunctionalTests.groovy 81 | 82 | and domain class JSON methods. 83 | 84 | - copy/modify for your own domain object. Test with 85 | 86 | grails test-app -functional 87 | 88 | 89 | ### Rendering JSON 90 | 91 | The branch adds an optional way to render the JSON for your domain classes. I did this because the Grails way wants to render the JSON immediately to a Writer, and I have often found cases where my domain class will get rolled into a larger JSON result, and/or I want to customize the rendering of the JSON to do things like i18n the text going out. I find it more flexible to produce a `JSONObject` from my domain class and let the user decide later when to actually render the JSON. Also in an OO perspective it makes more sense to place the rendering of the object within its class definition. 92 | 93 | ------------------------ 94 | 95 | #### How it Works 96 | 97 | This plugin offers 3 different ways to render your domain classes into JSON: 98 | 99 | * Grails Standard Approach 100 | - register an `ObjectMarshaller` in Bootstrap or in your domain class directly 101 | 102 | * Use the plugin's built-in `ObjectMarshaller`, which allows you to exclude fields using the 'custom api' object 103 | - specify 'excludedFields' as a list of field names 104 | 105 | * Create method `JSONObject toJSON(def messageSource)` in your domain class which will provide: 106 | - ability to apply i18n conversion to object fields while rendering 107 | - return a JSONObject for more flexibility in rendering of JSON throughout your app 108 | - most efficient style for the plugin 109 | 110 | See the ToDo domain class in the [TodoMVC sample app](https://github.com/kentbutler/todomvc-grails-emberjs) for examples of these 3 approaches. 111 | 112 | 113 | ### Unmarshalling from JSON 114 | 115 | I added an optional analagous `fromJSON()` method to the domain class for more flexibility in receiving and unpackaging data from the web client. 116 | 117 | ------------------------------------- 118 | 119 | #### How it Works 120 | 121 | The plugin offers 2 different ways to render your objects from encoded JSON: 122 | 123 | * Add domain class method `void fromJSON(JSONObject)` - for more control over transforming input 124 | 125 | * Grails Standard Approach 126 | - uses `JSON.parse()` to produce a `JSONObject` 127 | - transformed into a domain class as
 128 | 129 | def myObj = MyObj.class.newInstance()
 130 | myObj.properties = JSON.parse(inputString) 131 | 132 | See the generic functional test class in the [TodoMVC sample app](https://github.com/kentbutler/todomvc-grails-emberjs) for examples of usage. 133 | 134 | 135 | ### Implementation 136 | 137 | Changes to the base plugin are mainly contained in: 138 | 139 | * `JsonRestApiController.groovy` 140 | - change to support knowledge of the name of the entity when creating JSON 141 | 142 | * `JsonRestApiGrailsPlugin.groovy` 143 | - change to register the domain class using both singular and plural forms of the domain class name 144 | - necessary to support EmberJS requests using plural - [see this table](http://kentbutlercs.blogspot.hu/2013/02/emberjs-notes-and-gotchas.html) 145 | 146 | * added `org.grails.plugins.util.TextUtil` 147 | - for pluralizing entity names 148 | 149 | * added `org.grails.plugins.test.GenericRestFunctionalTests` 150 | - provides a way to test the REST interface apps which use this plugin offline 151 | - sample use of this located [in this project](https://github.com/kentbutler/todomvc-grails-emberjs.git) 152 | 153 | 154 | ### Limitations 155 | 156 | * The pluralization strategy of the plugin does not support arbitrary mapping of entity name to plural name [as allowable in EmberJS](http://emberjs.com/guides/models/the-rest-adapter/#toc_pluralization-customization), for the sake of simplicity. 157 | - hence instead of "person/people", simply use the EmberJS default "person/persons" 158 | 159 | 160 | 161 | -------------------------------------------------------------------------------- /web-app/WEB-INF/tld/spring.tld: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 1.1.1 7 | 8 | 1.2 9 | 10 | Spring 11 | 12 | http://www.springframework.org/tags 13 | 14 | Spring Framework JSP Tag Library. Authors: Rod Johnson, Juergen Hoeller 15 | 16 | 17 | 18 | 19 | htmlEscape 20 | org.springframework.web.servlet.tags.HtmlEscapeTag 21 | JSP 22 | 23 | 24 | Sets default HTML escape value for the current page. 25 | Overrides a "defaultHtmlEscape" context-param in web.xml, if any. 26 | 27 | 28 | 29 | defaultHtmlEscape 30 | true 31 | true 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | escapeBody 40 | org.springframework.web.servlet.tags.EscapeBodyTag 41 | JSP 42 | 43 | 44 | Escapes its enclosed body content, applying HTML escaping and/or JavaScript escaping. 45 | The HTML escaping flag participates in a page-wide or application-wide setting 46 | (i.e. by HtmlEscapeTag or a "defaultHtmlEscape" context-param in web.xml). 47 | 48 | 49 | 50 | htmlEscape 51 | false 52 | true 53 | 54 | 55 | 56 | javaScriptEscape 57 | false 58 | true 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | message 67 | org.springframework.web.servlet.tags.MessageTag 68 | JSP 69 | 70 | 71 | Retrieves the message with the given code, or text if code isn't resolvable. 72 | The HTML escaping flag participates in a page-wide or application-wide setting 73 | (i.e. by HtmlEscapeTag or a "defaultHtmlEscape" context-param in web.xml). 74 | 75 | 76 | 77 | code 78 | false 79 | true 80 | 81 | 82 | 83 | arguments 84 | false 85 | true 86 | 87 | 88 | 89 | text 90 | false 91 | true 92 | 93 | 94 | 95 | var 96 | false 97 | true 98 | 99 | 100 | 101 | scope 102 | false 103 | true 104 | 105 | 106 | 107 | htmlEscape 108 | false 109 | true 110 | 111 | 112 | 113 | javaScriptEscape 114 | false 115 | true 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | theme 124 | org.springframework.web.servlet.tags.ThemeTag 125 | JSP 126 | 127 | 128 | Retrieves the theme message with the given code, or text if code isn't resolvable. 129 | The HTML escaping flag participates in a page-wide or application-wide setting 130 | (i.e. by HtmlEscapeTag or a "defaultHtmlEscape" context-param in web.xml). 131 | 132 | 133 | 134 | code 135 | false 136 | true 137 | 138 | 139 | 140 | arguments 141 | false 142 | true 143 | 144 | 145 | 146 | text 147 | false 148 | true 149 | 150 | 151 | 152 | var 153 | false 154 | true 155 | 156 | 157 | 158 | scope 159 | false 160 | true 161 | 162 | 163 | 164 | htmlEscape 165 | false 166 | true 167 | 168 | 169 | 170 | javaScriptEscape 171 | false 172 | true 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | hasBindErrors 181 | org.springframework.web.servlet.tags.BindErrorsTag 182 | JSP 183 | 184 | 185 | Provides Errors instance in case of bind errors. 186 | The HTML escaping flag participates in a page-wide or application-wide setting 187 | (i.e. by HtmlEscapeTag or a "defaultHtmlEscape" context-param in web.xml). 188 | 189 | 190 | 191 | errors 192 | org.springframework.validation.Errors 193 | 194 | 195 | 196 | name 197 | true 198 | true 199 | 200 | 201 | 202 | htmlEscape 203 | false 204 | true 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | nestedPath 213 | org.springframework.web.servlet.tags.NestedPathTag 214 | JSP 215 | 216 | 217 | Sets a nested path to be used by the bind tag's path. 218 | 219 | 220 | 221 | nestedPath 222 | java.lang.String 223 | 224 | 225 | 226 | path 227 | true 228 | true 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | bind 237 | org.springframework.web.servlet.tags.BindTag 238 | JSP 239 | 240 | 241 | Provides BindStatus object for the given bind path. 242 | The HTML escaping flag participates in a page-wide or application-wide setting 243 | (i.e. by HtmlEscapeTag or a "defaultHtmlEscape" context-param in web.xml). 244 | 245 | 246 | 247 | status 248 | org.springframework.web.servlet.support.BindStatus 249 | 250 | 251 | 252 | path 253 | true 254 | true 255 | 256 | 257 | 258 | ignoreNestedPath 259 | false 260 | true 261 | 262 | 263 | 264 | htmlEscape 265 | false 266 | true 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | transform 275 | org.springframework.web.servlet.tags.TransformTag 276 | JSP 277 | 278 | 279 | Provides transformation of variables to Strings, using an appropriate 280 | custom PropertyEditor from BindTag (can only be used inside BindTag). 281 | The HTML escaping flag participates in a page-wide or application-wide setting 282 | (i.e. by HtmlEscapeTag or a "defaultHtmlEscape" context-param in web.xml). 283 | 284 | 285 | 286 | value 287 | true 288 | true 289 | 290 | 291 | 292 | var 293 | false 294 | true 295 | 296 | 297 | 298 | scope 299 | false 300 | true 301 | 302 | 303 | 304 | htmlEscape 305 | false 306 | true 307 | 308 | 309 | 310 | 311 | 312 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /test/unit/json/rest/api/JsonRestApiControllerTests.groovy: -------------------------------------------------------------------------------- 1 | package json.rest.api 2 | 3 | import grails.converters.JSON 4 | import grails.test.mixin.* 5 | import grails.test.mixin.support.* 6 | import org.codehaus.groovy.grails.web.json.JSONObject 7 | 8 | import org.apache.commons.logging.LogFactory 9 | import org.grails.plugins.rest.JsonRestApiController 10 | import org.junit.* 11 | 12 | /** 13 | * See the API for {@link grails.test.mixin.support.GrailsUnitTestMixin} for usage instructions 14 | * @author kent.butler@gmail.com 15 | */ 16 | @TestMixin(GrailsUnitTestMixin) 17 | class JsonRestApiControllerTests { 18 | 19 | def controller 20 | def log = LogFactory.getLog(getClass()) 21 | def obj 22 | def grailsApplication 23 | 24 | void setUp() { 25 | grailsApplication = new org.codehaus.groovy.grails.commons.DefaultGrailsApplication() 26 | 27 | // Default config state 28 | grailsApplication.config.grails.'json-rest-api'.wrapResults = true 29 | grailsApplication.config.grails.'json-rest-api'.useEntityRootName = true 30 | grailsApplication.config.grails.'json-rest-api'.dataFieldsOnly = false 31 | 32 | controller = new JsonRestApiController() 33 | controller.grailsApplication = grailsApplication 34 | 35 | // By default we want to include the usual status parameters in generated output JSON 36 | obj = [(JsonRestApiController.DATA_FIELDS_ONLY): false] 37 | 38 | } 39 | 40 | void tearDown() { 41 | // Tear down logic here 42 | } 43 | 44 | 45 | /** 46 | * we need a real class as our domain class; doesn't need HBM instrumenting 47 | * at this level of testing though 48 | */ 49 | class Person { 50 | int id 51 | String firstName 52 | String lastName 53 | 54 | // Have the controller use this method for rendering JSON since 55 | // the Grails converter plugin will not be loaded for testing 56 | JSONObject toJSON(def msgSource) { 57 | def json = new JSONObject() 58 | json.put("id", id) 59 | json.put("firstName", firstName) 60 | json.put("lastName", lastName) 61 | return json 62 | } 63 | } 64 | 65 | void resetGlobals(def wrap, def useRoot, def dataFields) { 66 | grailsApplication.config.grails.'json-rest-api'.wrapResults = wrap 67 | grailsApplication.config.grails.'json-rest-api'.useEntityRootName = useRoot 68 | grailsApplication.config.grails.'json-rest-api'.dataFieldsOnly = dataFields 69 | controller.wrapResults = null 70 | controller.useEntityRootName = null 71 | controller.dataFieldsOnly = null 72 | } 73 | 74 | 75 | void testRenderJsonNoData() { 76 | log.debug("------------ testRenderJsonNoData() -------------") 77 | assertNotNull controller 78 | 79 | obj << [success: false, message: 'Could not find record'] 80 | 81 | resetGlobals(true, true, false) 82 | 83 | def result = controller.renderJSON(obj, obj) 84 | assertNotNull result 85 | assertTrue "Result was instead: $result", result.indexOf("Could not find record") >= 0 86 | 87 | def parsed = JSON.parse(result) 88 | assertNotNull parsed 89 | assertNotNull "success field not found in $result", parsed.success 90 | assertNotNull "message field not found in $result", parsed.message 91 | assertEquals false, parsed.success 92 | assertEquals 'Could not find record', parsed.message 93 | } 94 | 95 | void testRenderJsonNoDataUnwrapped() { 96 | log.debug("------------ testRenderJsonNoDataUnwrapped() -------------") 97 | assertNotNull controller 98 | 99 | obj << [success: false, message: 'Could not find record'] 100 | 101 | resetGlobals(false, true, false) 102 | 103 | def result = controller.renderJSON(obj, obj) 104 | assertNotNull result 105 | assertEquals "{}", result 106 | } 107 | 108 | 109 | void testRenderJsonWithClass() { 110 | log.debug("------------ testRenderJsonWithClass() -------------") 111 | assertNotNull controller 112 | resetGlobals(true, true, false) 113 | 114 | def dc = new Person(id: 1, firstName:'Bob', lastName:'Bear') 115 | obj << [success: true, data: dc] 116 | 117 | def result = controller.renderJSON(obj, obj) 118 | assertNotNull result 119 | assertTrue "Result was instead: $result", result.indexOf("Bear") > 0 120 | 121 | def parsed = JSON.parse(result) 122 | assertNotNull parsed 123 | assertNotNull "success field not found in $result", parsed.success 124 | assertEquals true, parsed.success 125 | 126 | assertNotNull "data field not found in $result", parsed.data 127 | assertNotNull parsed.data.id 128 | assertEquals 1, parsed.data.id 129 | assertNotNull parsed.data.firstName 130 | assertEquals 'Bob', parsed.data.firstName 131 | assertNotNull parsed.data.lastName 132 | assertEquals 'Bear', parsed.data.lastName 133 | } 134 | 135 | void testRenderJsonWithClassUnwrapped() { 136 | log.debug("------------ testRenderJsonWithClassUnwrapped() -------------") 137 | assertNotNull controller 138 | resetGlobals(false, true, false) 139 | 140 | def dc = new Person(id: 1, firstName:'Bob', lastName:'Bear') 141 | obj << [success: true, data: dc] 142 | 143 | def result = controller.renderJSON(obj, obj) 144 | assertNotNull result 145 | assertTrue "Result was instead: $result", result.indexOf("Bear") > 0 146 | 147 | def parsed = JSON.parse(result) 148 | assertNotNull parsed 149 | 150 | assertNotNull parsed.id 151 | assertEquals 1, parsed.id 152 | assertNotNull parsed.firstName 153 | assertEquals 'Bob', parsed.firstName 154 | assertNotNull parsed.lastName 155 | assertEquals 'Bear', parsed.lastName 156 | } 157 | 158 | 159 | void testRenderJsonWithClassEntityRoot() { 160 | log.debug("------------ testRenderJsonWithClassEntityRoot() -------------") 161 | assertNotNull controller 162 | resetGlobals(true, true, false) 163 | 164 | def dc = new Person(id: 1, firstName:'Bob', lastName:'Bear') 165 | obj << [success: true, person: dc] 166 | 167 | def result = controller.renderJSON(obj, obj, 'person') 168 | assertNotNull result 169 | assertTrue "Result was instead: $result", result.indexOf("Bear") > 0 170 | 171 | def parsed = JSON.parse(result) 172 | assertNotNull parsed 173 | assertNotNull "success field not found in $result", parsed.success 174 | assertEquals true, parsed.success 175 | 176 | assertNotNull "person field not found in $result", parsed.person 177 | assertNotNull parsed.person.id 178 | assertEquals 1, parsed.person.id 179 | assertNotNull parsed.person.firstName 180 | assertEquals 'Bob', parsed.person.firstName 181 | assertNotNull parsed.person.lastName 182 | assertEquals 'Bear', parsed.person.lastName 183 | } 184 | 185 | void testRenderJsonWithEmptyList() { 186 | log.debug("------------ testRenderJsonWithEmptyList() -------------") 187 | assertNotNull controller 188 | resetGlobals(true, true, false) 189 | 190 | obj << [success: true, data: [], message:'No results found'] 191 | 192 | def result = controller.renderJSON(obj, obj) 193 | assertNotNull result 194 | assertTrue "Result was instead: $result", result.indexOf("No results found") >= 0 195 | 196 | def parsed = JSON.parse(result) 197 | assertNotNull parsed 198 | assertNotNull "success field not found in $result", parsed.success 199 | assertNotNull "message field not found in $result", parsed.message 200 | assertEquals true, parsed.success 201 | assertEquals 'No results found', parsed.message 202 | 203 | } 204 | 205 | void testRenderJsonWithEmptyListUnwrapped() { 206 | log.debug("------------ testRenderJsonWithEmptyListUnwrapped() -------------") 207 | assertNotNull controller 208 | resetGlobals(false, true, false) 209 | 210 | obj << [success: true, data: [], message:'No results found'] 211 | 212 | def result = controller.renderJSON(obj, obj) 213 | assertNotNull result 214 | log.debug("Result was: $result") 215 | assertEquals result, "{}" 216 | 217 | def parsed = JSON.parse(result) 218 | assertNotNull parsed 219 | 220 | } 221 | 222 | void testRenderJsonWithEmptyListNoMessage() { 223 | log.debug("------------ testRenderJsonWithEmptyListNoMessage() -------------") 224 | assertNotNull controller 225 | resetGlobals(true, true, false) 226 | 227 | obj << [success: true, data: []] 228 | 229 | def result = controller.renderJSON(obj, obj) 230 | assertNotNull result 231 | assertTrue "Result was instead: $result", result.indexOf("No data available") >= 0 232 | 233 | def parsed = JSON.parse(result) 234 | assertNotNull parsed 235 | assertNotNull "success field not found in $result", parsed.success 236 | assertNotNull "message field not found in $result", parsed.message 237 | assertEquals true, parsed.success 238 | assertEquals 'No data available', parsed.message 239 | } 240 | 241 | void testRenderJsonWithDataList() { 242 | log.debug("------------ testRenderJsonWithDataList() -------------") 243 | assertNotNull controller 244 | resetGlobals(true, true, false) 245 | 246 | def dc1 = new Person(id: 1, firstName:'Bilbo', lastName:'Baggins') 247 | def dc2 = new Person(id: 2, firstName:'Frodo', lastName:'Baggins') 248 | def dc3 = new Person(id: 3, firstName:'Mrs.', lastName:'Baggins') 249 | 250 | obj << [success: true, data: [dc1,dc2,dc3]] 251 | 252 | def result = controller.renderJSON(obj, obj, 'data', true) // <-- note we render as a List 253 | assertNotNull result 254 | assertTrue result.indexOf("Baggins") > 0 255 | 256 | def parsed = JSON.parse(result) 257 | assertNotNull parsed 258 | assertNotNull "success field not found in $result", parsed.success 259 | assertEquals true, parsed.success 260 | 261 | assertNotNull "count field not found in $result", parsed.count 262 | assertEquals 3, parsed.count 263 | 264 | assertNotNull "data field not found in $result", parsed.data 265 | assertNotNull "data[0] field not found in $result", parsed.data[0] 266 | assertNotNull parsed.data[0].id 267 | assertEquals 1, parsed.data[0].id 268 | assertNotNull parsed.data[0].firstName 269 | assertEquals 'Bilbo', parsed.data[0].firstName 270 | assertNotNull parsed.data[0].lastName 271 | assertEquals 'Baggins', parsed.data[0].lastName 272 | } 273 | 274 | void testRenderJsonWithDataListUnwrapped() { 275 | log.debug("------------ testRenderJsonWithDataListUnwrapped() -------------") 276 | assertNotNull controller 277 | resetGlobals(false, true, false) 278 | 279 | def dc1 = new Person(id: 1, firstName:'Bilbo', lastName:'Baggins') 280 | def dc2 = new Person(id: 2, firstName:'Frodo', lastName:'Baggins') 281 | def dc3 = new Person(id: 3, firstName:'Mrs.', lastName:'Baggins') 282 | 283 | obj << [success: true, data: [dc1,dc2,dc3]] 284 | 285 | def result = controller.renderJSON(obj, obj, 'data', true) // <-- note we render as a List 286 | assertNotNull result 287 | assertTrue result.indexOf("Baggins") > 0 288 | 289 | def parsed = JSON.parse(result) 290 | assertNotNull parsed 291 | 292 | assertNotNull "data not found in $result", parsed 293 | assertNotNull "array field not found in $result", parsed[0] 294 | assertNotNull parsed[0].id 295 | assertEquals 1, parsed[0].id 296 | assertNotNull parsed[0].firstName 297 | assertEquals 'Bilbo', parsed[0].firstName 298 | assertNotNull parsed[0].lastName 299 | assertEquals 'Baggins', parsed[0].lastName 300 | } 301 | 302 | void testRenderJsonWithDataListEntityRoot() { 303 | log.debug("------------ testRenderJsonWithDataListEntityRoot() -------------") 304 | assertNotNull controller 305 | resetGlobals(true, true, false) 306 | 307 | def dc1 = new Person(id: 1, firstName:'Bilbo', lastName:'Baggins') 308 | def dc2 = new Person(id: 2, firstName:'Frodo', lastName:'Baggins') 309 | def dc3 = new Person(id: 3, firstName:'Mrs.', lastName:'Baggins') 310 | 311 | obj << [success: true, persons: [dc1,dc2,dc3]] 312 | 313 | def result = controller.renderJSON(obj, obj, 'persons', true) // <-- note we render as a List 314 | assertNotNull result 315 | assertTrue "Result contains unexpected data: $result", result.indexOf("count") > 0 316 | 317 | def parsed = JSON.parse(result) 318 | assertNotNull parsed 319 | assertNotNull "success field not found in $result", parsed.success 320 | assertEquals true, parsed.success 321 | 322 | assertNotNull "count field not found in $result", parsed.count 323 | assertEquals 3, parsed.count 324 | 325 | assertNotNull "persons field not found in $result", parsed.persons 326 | assertNotNull "persons[0] field not found in $result", parsed.persons[0] 327 | assertNotNull parsed.persons[0].id 328 | assertEquals 1, parsed.persons[0].id 329 | assertNotNull parsed.persons[0].firstName 330 | assertEquals 'Bilbo', parsed.persons[0].firstName 331 | assertNotNull parsed.persons[0].lastName 332 | assertEquals 'Baggins', parsed.persons[0].lastName 333 | } 334 | 335 | 336 | } 337 | -------------------------------------------------------------------------------- /grails-app/controllers/org/grails/plugins/rest/JsonRestApiController.groovy: -------------------------------------------------------------------------------- 1 | package org.grails.plugins.rest 2 | 3 | import grails.converters.* 4 | 5 | import org.apache.commons.logging.LogFactory 6 | import org.codehaus.groovy.grails.web.json.JSONArray 7 | import org.codehaus.groovy.grails.web.json.JSONObject 8 | import org.springframework.web.servlet.support.RequestContextUtils as RCU 9 | 10 | class JsonRestApiController { 11 | 12 | def log = LogFactory.getLog(getClass()) 13 | def messageSource 14 | def grailsApplication 15 | static def wrapResults 16 | static def useEntityRootName 17 | static def dataFieldsOnly 18 | static final String WRAP_RESULTS = 'wrapResults' 19 | static final String DEFAULT_ENTITY_ROOT = 'data' 20 | static final String DATA_FIELDS_ONLY = 'dataFieldsOnly' 21 | 22 | 23 | def list = { 24 | def result = [ success: true ] 25 | def entity = grailsApplication.getClassForName(params.entity) 26 | def entityRoot = resolveEntityRoot(params, true) // we just need the property to be resolved 27 | 28 | if (useEntityRootName) { 29 | // in other words, mimic incoming URL in rendered list 30 | entityRoot = params.domain 31 | } 32 | 33 | if (entity) { 34 | def api = getCustomApi(entity) 35 | if (api?.list instanceof Closure) 36 | result[entityRoot] = api.list(params) 37 | else 38 | result[entityRoot] = entity.list(params) 39 | if (api?.count instanceof Closure) 40 | result.count = api.count(params) 41 | else 42 | result.count = entity.count() 43 | } else { 44 | result.success = false 45 | result.message = "Entity ${params.entity} not found" 46 | } 47 | render text: renderJSON(result, params, entityRoot, true), contentType: 'application/json', status: result.success ? 200 : 500 48 | } 49 | 50 | def show = { 51 | log.debug("Request params: $params") 52 | def entity = grailsApplication.getClassForName(params.entity) 53 | def entityRoot = resolveEntityRoot(params) 54 | def query = retrieveRecord(entity, entityRoot) 55 | if (query.result[entityRoot]) { 56 | def o = query.result[entityRoot] 57 | log.debug("returned result is type: ${o.class.name}") 58 | } else { log.debug("no query result #####") } 59 | 60 | render text: renderJSON(query.result, params, entityRoot), contentType: 'application/json', status: query.status 61 | } 62 | 63 | def create = { 64 | log.debug("*** Processing create() ***") 65 | def entity = grailsApplication.getClassForName(params.entity) 66 | def entityRoot = resolveEntityRoot(params) 67 | def wrapped = resolveWrapResults(params) // if wrapped then expect incoming data wrapped also 68 | 69 | def result = [ success: true ] 70 | def status = 200 71 | 72 | if (entity) { 73 | def obj = entity.newInstance() 74 | log.debug("Resolved entityRoot [$entityRoot] with JSON: ${request.JSON}") 75 | def json = wrapped ? request.JSON?.opt(entityRoot) : request.JSON 76 | if (json) { 77 | log.debug("creating from $json") 78 | bindFromJSON(obj, json) 79 | } 80 | else { 81 | log.debug("create(): no JSON request data available") 82 | } 83 | 84 | obj.validate() 85 | if (obj.hasErrors()) { 86 | status = 500 87 | result.message = extractErrors(obj).join(";") 88 | result.success = false 89 | } else { 90 | result[entityRoot] = obj.save(flush: true) 91 | log.debug("Returning saved object under root [${entityRoot}]: ${result[entityRoot]}") 92 | } 93 | } else { 94 | result.success = false 95 | result.message = "Entity ${params.entity} not found" 96 | status = 500 97 | } 98 | render text: renderJSON(result, params, entityRoot), contentType: 'application/json', status: status 99 | } 100 | 101 | def update = { 102 | def entity = grailsApplication.getClassForName(params.entity) 103 | def entityRoot = resolveEntityRoot(params) 104 | def wrapped = resolveWrapResults(params) // if wrapped then expect incoming data wrapped also 105 | 106 | def query = retrieveRecord(entity, entityRoot) 107 | def obj 108 | if (query.result.success) { 109 | obj = query.result[entityRoot] 110 | log.debug("update: located object to update: $obj") 111 | def json = wrapped ? request.JSON?.opt(entityRoot) : request.JSON 112 | 113 | log.debug("update: binding input data: $json") 114 | if (json) { 115 | bindFromJSON(obj, json) 116 | } 117 | else { 118 | log.debug("update(): no JSON request data available") 119 | } 120 | 121 | obj.validate() 122 | if (obj.hasErrors()) { 123 | query.status = 500 124 | query.result.message = extractErrors(query.result[entityRoot]).join(";") 125 | query.result.success = false 126 | } else { 127 | log.debug("update: saving ") 128 | obj = obj.save(flush:true) 129 | } 130 | } 131 | render text: renderJSON(query.result, params, entityRoot), contentType: 'application/json', status: query.status 132 | } 133 | 134 | def delete = { 135 | def entity = grailsApplication.getClassForName(params.entity) 136 | def entityRoot = resolveEntityRoot(params) 137 | 138 | def query = retrieveRecord(entity, entityRoot) 139 | try { 140 | if (query.result.success) { 141 | log.debug("**** deleting entity: ${query.result[entityRoot].id} ****") 142 | query.result[entityRoot].delete(flush: true) 143 | query.result[entityRoot] = null // To return an empty value in response 144 | } 145 | } catch (Exception e) { 146 | query.result.success = false 147 | query.result.message = e.message 148 | query.status = 500 149 | } 150 | render text: renderJSON(query.result, params, entityRoot), contentType: 'application/json', status: query.status 151 | } 152 | 153 | private getCustomApi(clazz) { 154 | clazz.declaredFields.name.contains('api') ? clazz.api : null 155 | } 156 | 157 | private boolean resolveWrapResults(def params) { 158 | if (params.containsKey(WRAP_RESULTS)) { 159 | // prefer URl overrides, mainly for testing 160 | wrapResults = new Boolean(params.wrapResults) 161 | } 162 | else if (wrapResults == null) { 163 | wrapResults = resolveFromConfig(WRAP_RESULTS) 164 | } 165 | return wrapResults 166 | } 167 | 168 | 169 | private String resolveEntityRoot(def params, boolean multi=false) { 170 | def entityName = params?.domain 171 | log.debug("Resolving entityRoot for name [$entityName] and multi [$multi]") 172 | if (params.containsKey('useEntityRootName')) { 173 | // prefer URl overrides, mainly for testing 174 | useEntityRootName = new Boolean(params.useEntityRootName) 175 | } 176 | else if (useEntityRootName == null) { 177 | useEntityRootName = resolveFromConfig('useEntityRootName') 178 | } 179 | else { 180 | log.debug("using existing value for [useEntityRootName] ") 181 | } 182 | // entityRoot name must always be the singular - hence root 183 | def root = useEntityRootName ? (JSONApiRegistry.getSingular(entityName)) : DEFAULT_ENTITY_ROOT 184 | log.debug("entityRoot resolved as $root") 185 | return root 186 | } 187 | 188 | private boolean resolveDataFieldsOnly(def params) { 189 | if (params.containsKey(DATA_FIELDS_ONLY)) { 190 | // prefer URl overrides, mainly for testing 191 | dataFieldsOnly = new Boolean(params.dataFieldsOnly) 192 | } 193 | else if (dataFieldsOnly == null) { 194 | dataFieldsOnly = resolveFromConfig(DATA_FIELDS_ONLY) 195 | } 196 | return dataFieldsOnly 197 | } 198 | 199 | private def resolveFromConfig(def property) { 200 | assert grailsApplication != null 201 | def config = grailsApplication.config.grails.'json-rest-api' 202 | log.debug("looking up $property in config: ${config}") 203 | return config[property] 204 | } 205 | 206 | private String renderJSON (def obj, def params, String entityRoot=DEFAULT_ENTITY_ROOT, def renderAsList=false) { 207 | log.debug("Rendering domainClass as JSON under node '$entityRoot'") 208 | if (obj?.success == false) { 209 | log.debug("Result was error with message: ${obj.message}") 210 | } 211 | def json = new JSONObject() 212 | 213 | resolveWrapResults(params) // resolve return style for results 214 | resolveDataFieldsOnly(params) // make sure we know what to include 215 | 216 | if (!obj[entityRoot]) { 217 | log.debug("obj.$entityRoot is null, rendering as empty list") 218 | // Do not alter the response statuses, our only job here is to render into JSON; 219 | // If there is no data and no message though, do give some indication why there was no JSON 220 | obj.message = obj.message ? obj.message : "No data available" 221 | obj[entityRoot] = [] 222 | } 223 | 224 | JSONArray dcList = new JSONArray() // this is for our domain classes 225 | def mc, dc 226 | def supportsToJson = false 227 | 228 | if (java.util.Collection.class.isAssignableFrom(obj[entityRoot].class)) { 229 | log.debug("domainClass node is a list") 230 | if (obj[entityRoot].size() > 0) { 231 | dc = obj[entityRoot][0] 232 | mc = obj[entityRoot][0].metaClass 233 | } 234 | else { 235 | // Empty list...do nothing 236 | } 237 | } 238 | else { 239 | log.debug("domainClass node is a domain class") 240 | dc = obj[entityRoot] 241 | mc = obj[entityRoot].metaClass 242 | // Normalize the single entity as a list 243 | obj[entityRoot] = [ obj[entityRoot] ] 244 | } 245 | 246 | // Does our domain class support the toJSON() method? 247 | supportsToJson = dc ? mc?.respondsTo(dc, 'toJSON') : false 248 | log.debug("obj.$entityRoot ${supportsToJson?'supports':'does not support'} toJSON") 249 | 250 | log.debug("rendering object list") 251 | obj[entityRoot].each() { dcObj -> 252 | if (supportsToJson) { 253 | // Custom convert domain object 254 | dcList.add(dcObj.toJSON(messageSource)) 255 | } 256 | else { 257 | // Use the registered ObjectMarshaller for the domain class 258 | // This is possibly the catch-all marshaller that this plugin registered, or the user could 259 | // have registered a custom marshaller for the domain class 260 | // Note this requires an extra step of parsing the rendered String back into a JSONObject, 261 | // but the ObjectMarshaller interface gives us little choice 262 | // if we want the flexibility of using a JSONObject 263 | def dcObjConverter = (dcObj as JSON) 264 | dcList.add(new JSONObject(dcObjConverter.toString())) 265 | } 266 | } 267 | 268 | if (wrapResults) { 269 | // This output style allows for options of how to package results 270 | 271 | // Render full object without re-rendering domain class 272 | if (!dataFieldsOnly) { 273 | json.put("success",obj.success) 274 | json.put("message",obj.message) 275 | } 276 | if (renderAsList) { 277 | json.put(entityRoot, dcList) 278 | if (!dataFieldsOnly) { 279 | json.put("count", dcList.size()) 280 | } 281 | } 282 | else if (dcList.size() > 0){ 283 | log.debug("Render single entity") 284 | json.put(entityRoot, dcList.getJSONObject(0)) 285 | } 286 | } 287 | else { 288 | // This output style allows for ONLY DATA 289 | if (renderAsList) { 290 | json = dcList 291 | } 292 | else if (dcList.size() > 0){ 293 | log.debug("Render single entity") 294 | json = dcList.getJSONObject(0) 295 | } 296 | else { 297 | log.warn("renderJSON has no single result to render for object: $obj") 298 | } 299 | } 300 | 301 | def jsonStr = json.toString() 302 | log.debug("Rendered as: ${jsonStr}") 303 | return jsonStr 304 | } 305 | 306 | private void bindFromJSON(def obj, def args) { 307 | if (obj?.metaClass?.respondsTo(obj, 'fromJSON')) { 308 | log.debug("bindFromJSON: binding fromJSON()") 309 | obj.fromJSON(args) 310 | } 311 | else { 312 | log.debug("bindFromJSON: no metaClass; binding from properties") 313 | obj.properties = args 314 | } 315 | } 316 | 317 | 318 | /* 319 | * returns Map with: 320 | * status - [200|404|500] 321 | * result[:] ==> 322 | * success - [true|false] 323 | * message - only if success == false 324 | * [data|] - the entity 325 | */ 326 | private retrieveRecord(Class entity, String entityRoot) { 327 | def result = [ success: true ] 328 | def status = 200 329 | if (entity) { 330 | def obj = entity.get(params.id) 331 | if (obj) { 332 | result[entityRoot] = obj 333 | } else { 334 | result.success = false 335 | result.message = "Object with id=${params.id} not found" 336 | status = 404 337 | } 338 | } else { 339 | result.success = false 340 | result.message = "Entity ${params.entity} not found" 341 | status = 500 342 | } 343 | 344 | [ result: result, status: status ] 345 | } 346 | 347 | private extractErrors(model) { 348 | def locale = RCU.getLocale(request) 349 | model.errors.fieldErrors.collect { error -> 350 | messageSource.getMessage(error, locale) 351 | } 352 | } 353 | 354 | } 355 | -------------------------------------------------------------------------------- /web-app/WEB-INF/tld/c.tld: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | JSTL 1.1 core library 9 | JSTL core 10 | 1.1 11 | c 12 | http://java.sun.com/jsp/jstl/core 13 | 14 | 15 | 16 | Provides core validation features for JSTL tags. 17 | 18 | 19 | org.apache.taglibs.standard.tlv.JstlCoreTLV 20 | 21 | 22 | 23 | 24 | 25 | Catches any Throwable that occurs in its body and optionally 26 | exposes it. 27 | 28 | catch 29 | org.apache.taglibs.standard.tag.common.core.CatchTag 30 | JSP 31 | 32 | 33 | Name of the exported scoped variable for the 34 | exception thrown from a nested action. The type of the 35 | scoped variable is the type of the exception thrown. 36 | 37 | var 38 | false 39 | false 40 | 41 | 42 | 43 | 44 | 45 | Simple conditional tag that establishes a context for 46 | mutually exclusive conditional operations, marked by 47 | <when> and <otherwise> 48 | 49 | choose 50 | org.apache.taglibs.standard.tag.common.core.ChooseTag 51 | JSP 52 | 53 | 54 | 55 | 56 | Simple conditional tag, which evalutes its body if the 57 | supplied condition is true and optionally exposes a Boolean 58 | scripting variable representing the evaluation of this condition 59 | 60 | if 61 | org.apache.taglibs.standard.tag.rt.core.IfTag 62 | JSP 63 | 64 | 65 | The test condition that determines whether or 66 | not the body content should be processed. 67 | 68 | test 69 | true 70 | true 71 | boolean 72 | 73 | 74 | 75 | Name of the exported scoped variable for the 76 | resulting value of the test condition. The type 77 | of the scoped variable is Boolean. 78 | 79 | var 80 | false 81 | false 82 | 83 | 84 | 85 | Scope for var. 86 | 87 | scope 88 | false 89 | false 90 | 91 | 92 | 93 | 94 | 95 | Retrieves an absolute or relative URL and exposes its contents 96 | to either the page, a String in 'var', or a Reader in 'varReader'. 97 | 98 | import 99 | org.apache.taglibs.standard.tag.rt.core.ImportTag 100 | org.apache.taglibs.standard.tei.ImportTEI 101 | JSP 102 | 103 | 104 | The URL of the resource to import. 105 | 106 | url 107 | true 108 | true 109 | 110 | 111 | 112 | Name of the exported scoped variable for the 113 | resource's content. The type of the scoped 114 | variable is String. 115 | 116 | var 117 | false 118 | false 119 | 120 | 121 | 122 | Scope for var. 123 | 124 | scope 125 | false 126 | false 127 | 128 | 129 | 130 | Name of the exported scoped variable for the 131 | resource's content. The type of the scoped 132 | variable is Reader. 133 | 134 | varReader 135 | false 136 | false 137 | 138 | 139 | 140 | Name of the context when accessing a relative 141 | URL resource that belongs to a foreign 142 | context. 143 | 144 | context 145 | false 146 | true 147 | 148 | 149 | 150 | Character encoding of the content at the input 151 | resource. 152 | 153 | charEncoding 154 | false 155 | true 156 | 157 | 158 | 159 | 160 | 161 | The basic iteration tag, accepting many different 162 | collection types and supporting subsetting and other 163 | functionality 164 | 165 | forEach 166 | org.apache.taglibs.standard.tag.rt.core.ForEachTag 167 | org.apache.taglibs.standard.tei.ForEachTEI 168 | JSP 169 | 170 | 171 | Collection of items to iterate over. 172 | 173 | items 174 | false 175 | true 176 | java.lang.Object 177 | 178 | 179 | 180 | If items specified: 181 | Iteration begins at the item located at the 182 | specified index. First item of the collection has 183 | index 0. 184 | If items not specified: 185 | Iteration begins with index set at the value 186 | specified. 187 | 188 | begin 189 | false 190 | true 191 | int 192 | 193 | 194 | 195 | If items specified: 196 | Iteration ends at the item located at the 197 | specified index (inclusive). 198 | If items not specified: 199 | Iteration ends when index reaches the value 200 | specified. 201 | 202 | end 203 | false 204 | true 205 | int 206 | 207 | 208 | 209 | Iteration will only process every step items of 210 | the collection, starting with the first one. 211 | 212 | step 213 | false 214 | true 215 | int 216 | 217 | 218 | 219 | Name of the exported scoped variable for the 220 | current item of the iteration. This scoped 221 | variable has nested visibility. Its type depends 222 | on the object of the underlying collection. 223 | 224 | var 225 | false 226 | false 227 | 228 | 229 | 230 | Name of the exported scoped variable for the 231 | status of the iteration. Object exported is of type 232 | javax.servlet.jsp.jstl.core.LoopTagStatus. This scoped variable has nested 233 | visibility. 234 | 235 | varStatus 236 | false 237 | false 238 | 239 | 240 | 241 | 242 | 243 | Iterates over tokens, separated by the supplied delimeters 244 | 245 | forTokens 246 | org.apache.taglibs.standard.tag.rt.core.ForTokensTag 247 | JSP 248 | 249 | 250 | String of tokens to iterate over. 251 | 252 | items 253 | true 254 | true 255 | java.lang.String 256 | 257 | 258 | 259 | The set of delimiters (the characters that 260 | separate the tokens in the string). 261 | 262 | delims 263 | true 264 | true 265 | java.lang.String 266 | 267 | 268 | 269 | Iteration begins at the token located at the 270 | specified index. First token has index 0. 271 | 272 | begin 273 | false 274 | true 275 | int 276 | 277 | 278 | 279 | Iteration ends at the token located at the 280 | specified index (inclusive). 281 | 282 | end 283 | false 284 | true 285 | int 286 | 287 | 288 | 289 | Iteration will only process every step tokens 290 | of the string, starting with the first one. 291 | 292 | step 293 | false 294 | true 295 | int 296 | 297 | 298 | 299 | Name of the exported scoped variable for the 300 | current item of the iteration. This scoped 301 | variable has nested visibility. 302 | 303 | var 304 | false 305 | false 306 | 307 | 308 | 309 | Name of the exported scoped variable for the 310 | status of the iteration. Object exported is of 311 | type 312 | javax.servlet.jsp.jstl.core.LoopTag 313 | Status. This scoped variable has nested 314 | visibility. 315 | 316 | varStatus 317 | false 318 | false 319 | 320 | 321 | 322 | 323 | 324 | Like <%= ... >, but for expressions. 325 | 326 | out 327 | org.apache.taglibs.standard.tag.rt.core.OutTag 328 | JSP 329 | 330 | 331 | Expression to be evaluated. 332 | 333 | value 334 | true 335 | true 336 | 337 | 338 | 339 | Default value if the resulting value is null. 340 | 341 | default 342 | false 343 | true 344 | 345 | 346 | 347 | Determines whether characters <,>,&,'," in the 348 | resulting string should be converted to their 349 | corresponding character entity codes. Default value is 350 | true. 351 | 352 | escapeXml 353 | false 354 | true 355 | 356 | 357 | 358 | 359 | 360 | 361 | Subtag of <choose> that follows <when> tags 362 | and runs only if all of the prior conditions evaluated to 363 | 'false' 364 | 365 | otherwise 366 | org.apache.taglibs.standard.tag.common.core.OtherwiseTag 367 | JSP 368 | 369 | 370 | 371 | 372 | Adds a parameter to a containing 'import' tag's URL. 373 | 374 | param 375 | org.apache.taglibs.standard.tag.rt.core.ParamTag 376 | JSP 377 | 378 | 379 | Name of the query string parameter. 380 | 381 | name 382 | true 383 | true 384 | 385 | 386 | 387 | Value of the parameter. 388 | 389 | value 390 | false 391 | true 392 | 393 | 394 | 395 | 396 | 397 | Redirects to a new URL. 398 | 399 | redirect 400 | org.apache.taglibs.standard.tag.rt.core.RedirectTag 401 | JSP 402 | 403 | 404 | The URL of the resource to redirect to. 405 | 406 | url 407 | false 408 | true 409 | 410 | 411 | 412 | Name of the context when redirecting to a relative URL 413 | resource that belongs to a foreign context. 414 | 415 | context 416 | false 417 | true 418 | 419 | 420 | 421 | 422 | 423 | Removes a scoped variable (from a particular scope, if specified). 424 | 425 | remove 426 | org.apache.taglibs.standard.tag.common.core.RemoveTag 427 | empty 428 | 429 | 430 | Name of the scoped variable to be removed. 431 | 432 | var 433 | true 434 | false 435 | 436 | 437 | 438 | Scope for var. 439 | 440 | scope 441 | false 442 | false 443 | 444 | 445 | 446 | 447 | 448 | Sets the result of an expression evaluation in a 'scope' 449 | 450 | set 451 | org.apache.taglibs.standard.tag.rt.core.SetTag 452 | JSP 453 | 454 | 455 | Name of the exported scoped variable to hold the value 456 | specified in the action. The type of the scoped variable is 457 | whatever type the value expression evaluates to. 458 | 459 | var 460 | false 461 | false 462 | 463 | 464 | 465 | Expression to be evaluated. 466 | 467 | value 468 | false 469 | true 470 | 471 | 472 | 473 | Target object whose property will be set. Must evaluate to 474 | a JavaBeans object with setter property property, or to a 475 | java.util.Map object. 476 | 477 | target 478 | false 479 | true 480 | 481 | 482 | 483 | Name of the property to be set in the target object. 484 | 485 | property 486 | false 487 | true 488 | 489 | 490 | 491 | Scope for var. 492 | 493 | scope 494 | false 495 | false 496 | 497 | 498 | 499 | 500 | 501 | Creates a URL with optional query parameters. 502 | 503 | url 504 | org.apache.taglibs.standard.tag.rt.core.UrlTag 505 | JSP 506 | 507 | 508 | Name of the exported scoped variable for the 509 | processed url. The type of the scoped variable is 510 | String. 511 | 512 | var 513 | false 514 | false 515 | 516 | 517 | 518 | Scope for var. 519 | 520 | scope 521 | false 522 | false 523 | 524 | 525 | 526 | URL to be processed. 527 | 528 | value 529 | false 530 | true 531 | 532 | 533 | 534 | Name of the context when specifying a relative URL 535 | resource that belongs to a foreign context. 536 | 537 | context 538 | false 539 | true 540 | 541 | 542 | 543 | 544 | 545 | Subtag of <choose> that includes its body if its 546 | condition evalutes to 'true' 547 | 548 | when 549 | org.apache.taglibs.standard.tag.rt.core.WhenTag 550 | JSP 551 | 552 | 553 | The test condition that determines whether or not the 554 | body content should be processed. 555 | 556 | test 557 | true 558 | true 559 | boolean 560 | 561 | 562 | 563 | 564 | -------------------------------------------------------------------------------- /web-app/WEB-INF/tld/grails.tld: -------------------------------------------------------------------------------- 1 | 2 | 7 | The Grails custom tag library 8 | 0.2 9 | grails 10 | http://grails.codehaus.org/tags 11 | 12 | 13 | link 14 | org.codehaus.groovy.grails.web.taglib.jsp.JspLinkTag 15 | JSP 16 | 17 | action 18 | false 19 | true 20 | 21 | 22 | controller 23 | false 24 | true 25 | 26 | 27 | id 28 | false 29 | true 30 | 31 | 32 | url 33 | false 34 | true 35 | 36 | 37 | params 38 | false 39 | true 40 | 41 | true 42 | 43 | 44 | form 45 | org.codehaus.groovy.grails.web.taglib.jsp.JspFormTag 46 | JSP 47 | 48 | action 49 | false 50 | true 51 | 52 | 53 | controller 54 | false 55 | true 56 | 57 | 58 | id 59 | false 60 | true 61 | 62 | 63 | url 64 | false 65 | true 66 | 67 | 68 | method 69 | true 70 | true 71 | 72 | true 73 | 74 | 75 | select 76 | org.codehaus.groovy.grails.web.taglib.jsp.JspSelectTag 77 | JSP 78 | 79 | name 80 | true 81 | true 82 | 83 | 84 | value 85 | false 86 | true 87 | 88 | 89 | optionKey 90 | false 91 | true 92 | 93 | 94 | optionValue 95 | false 96 | true 97 | 98 | true 99 | 100 | 101 | datePicker 102 | org.codehaus.groovy.grails.web.taglib.jsp.JspDatePickerTag 103 | empty 104 | 105 | name 106 | true 107 | true 108 | 109 | 110 | value 111 | false 112 | true 113 | 114 | 115 | precision 116 | false 117 | true 118 | 119 | false 120 | 121 | 122 | currencySelect 123 | org.codehaus.groovy.grails.web.taglib.jsp.JspCurrencySelectTag 124 | empty 125 | 126 | name 127 | true 128 | true 129 | 130 | 131 | value 132 | false 133 | true 134 | 135 | true 136 | 137 | 138 | localeSelect 139 | org.codehaus.groovy.grails.web.taglib.jsp.JspLocaleSelectTag 140 | empty 141 | 142 | name 143 | true 144 | true 145 | 146 | 147 | value 148 | false 149 | true 150 | 151 | true 152 | 153 | 154 | timeZoneSelect 155 | org.codehaus.groovy.grails.web.taglib.jsp.JspTimeZoneSelectTag 156 | empty 157 | 158 | name 159 | true 160 | true 161 | 162 | 163 | value 164 | false 165 | true 166 | 167 | true 168 | 169 | 170 | checkBox 171 | org.codehaus.groovy.grails.web.taglib.jsp.JspCheckboxTag 172 | empty 173 | 174 | name 175 | true 176 | true 177 | 178 | 179 | value 180 | true 181 | true 182 | 183 | true 184 | 185 | 186 | hasErrors 187 | org.codehaus.groovy.grails.web.taglib.jsp.JspHasErrorsTag 188 | JSP 189 | 190 | model 191 | false 192 | true 193 | 194 | 195 | bean 196 | false 197 | true 198 | 199 | 200 | field 201 | false 202 | true 203 | 204 | false 205 | 206 | 207 | eachError 208 | org.codehaus.groovy.grails.web.taglib.jsp.JspEachErrorTag 209 | JSP 210 | 211 | model 212 | false 213 | true 214 | 215 | 216 | bean 217 | false 218 | true 219 | 220 | 221 | field 222 | false 223 | true 224 | 225 | false 226 | 227 | 228 | renderErrors 229 | org.codehaus.groovy.grails.web.taglib.jsp.JspEachErrorTag 230 | JSP 231 | 232 | model 233 | false 234 | true 235 | 236 | 237 | bean 238 | false 239 | true 240 | 241 | 242 | field 243 | false 244 | true 245 | 246 | 247 | as 248 | true 249 | true 250 | 251 | false 252 | 253 | 254 | message 255 | org.codehaus.groovy.grails.web.taglib.jsp.JspMessageTag 256 | JSP 257 | 258 | code 259 | false 260 | true 261 | 262 | 263 | error 264 | false 265 | true 266 | 267 | 268 | default 269 | false 270 | true 271 | 272 | false 273 | 274 | 275 | remoteFunction 276 | org.codehaus.groovy.grails.web.taglib.jsp.JspRemoteFunctionTag 277 | empty 278 | 279 | before 280 | false 281 | true 282 | 283 | 284 | after 285 | false 286 | true 287 | 288 | 289 | action 290 | false 291 | true 292 | 293 | 294 | controller 295 | false 296 | true 297 | 298 | 299 | id 300 | false 301 | true 302 | 303 | 304 | url 305 | false 306 | true 307 | 308 | 309 | params 310 | false 311 | true 312 | 313 | 314 | asynchronous 315 | false 316 | true 317 | 318 | 319 | method 320 | false 321 | true 322 | 323 | 324 | update 325 | false 326 | true 327 | 328 | 329 | onSuccess 330 | false 331 | true 332 | 333 | 334 | onFailure 335 | false 336 | true 337 | 338 | 339 | onComplete 340 | false 341 | true 342 | 343 | 344 | onLoading 345 | false 346 | true 347 | 348 | 349 | onLoaded 350 | false 351 | true 352 | 353 | 354 | onInteractive 355 | false 356 | true 357 | 358 | true 359 | 360 | 361 | remoteLink 362 | org.codehaus.groovy.grails.web.taglib.jsp.JspRemoteLinkTag 363 | JSP 364 | 365 | before 366 | false 367 | true 368 | 369 | 370 | after 371 | false 372 | true 373 | 374 | 375 | action 376 | false 377 | true 378 | 379 | 380 | controller 381 | false 382 | true 383 | 384 | 385 | id 386 | false 387 | true 388 | 389 | 390 | url 391 | false 392 | true 393 | 394 | 395 | params 396 | false 397 | true 398 | 399 | 400 | asynchronous 401 | false 402 | true 403 | 404 | 405 | method 406 | false 407 | true 408 | 409 | 410 | update 411 | false 412 | true 413 | 414 | 415 | onSuccess 416 | false 417 | true 418 | 419 | 420 | onFailure 421 | false 422 | true 423 | 424 | 425 | onComplete 426 | false 427 | true 428 | 429 | 430 | onLoading 431 | false 432 | true 433 | 434 | 435 | onLoaded 436 | false 437 | true 438 | 439 | 440 | onInteractive 441 | false 442 | true 443 | 444 | true 445 | 446 | 447 | formRemote 448 | org.codehaus.groovy.grails.web.taglib.jsp.JspFormRemoteTag 449 | JSP 450 | 451 | before 452 | false 453 | true 454 | 455 | 456 | after 457 | false 458 | true 459 | 460 | 461 | action 462 | false 463 | true 464 | 465 | 466 | controller 467 | false 468 | true 469 | 470 | 471 | id 472 | false 473 | true 474 | 475 | 476 | url 477 | false 478 | true 479 | 480 | 481 | params 482 | false 483 | true 484 | 485 | 486 | asynchronous 487 | false 488 | true 489 | 490 | 491 | method 492 | false 493 | true 494 | 495 | 496 | update 497 | false 498 | true 499 | 500 | 501 | onSuccess 502 | false 503 | true 504 | 505 | 506 | onFailure 507 | false 508 | true 509 | 510 | 511 | onComplete 512 | false 513 | true 514 | 515 | 516 | onLoading 517 | false 518 | true 519 | 520 | 521 | onLoaded 522 | false 523 | true 524 | 525 | 526 | onInteractive 527 | false 528 | true 529 | 530 | true 531 | 532 | 533 | invokeTag 534 | org.codehaus.groovy.grails.web.taglib.jsp.JspInvokeGrailsTagLibTag 535 | JSP 536 | 537 | it 538 | java.lang.Object 539 | true 540 | NESTED 541 | 542 | 543 | tagName 544 | true 545 | true 546 | 547 | true 548 | 549 | 550 | 551 | -------------------------------------------------------------------------------- /web-app/WEB-INF/tld/fmt.tld: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | JSTL 1.1 i18n-capable formatting library 9 | JSTL fmt 10 | 1.1 11 | fmt 12 | http://java.sun.com/jsp/jstl/fmt 13 | 14 | 15 | 16 | Provides core validation features for JSTL tags. 17 | 18 | 19 | org.apache.taglibs.standard.tlv.JstlFmtTLV 20 | 21 | 22 | 23 | 24 | 25 | Sets the request character encoding 26 | 27 | requestEncoding 28 | org.apache.taglibs.standard.tag.rt.fmt.RequestEncodingTag 29 | empty 30 | 31 | 32 | Name of character encoding to be applied when 33 | decoding request parameters. 34 | 35 | value 36 | false 37 | true 38 | 39 | 40 | 41 | 42 | 43 | Stores the given locale in the locale configuration variable 44 | 45 | setLocale 46 | org.apache.taglibs.standard.tag.rt.fmt.SetLocaleTag 47 | empty 48 | 49 | 50 | A String value is interpreted as the 51 | printable representation of a locale, which 52 | must contain a two-letter (lower-case) 53 | language code (as defined by ISO-639), 54 | and may contain a two-letter (upper-case) 55 | country code (as defined by ISO-3166). 56 | Language and country codes must be 57 | separated by hyphen (-) or underscore 58 | (_). 59 | 60 | value 61 | true 62 | true 63 | 64 | 65 | 66 | Vendor- or browser-specific variant. 67 | See the java.util.Locale javadocs for 68 | more information on variants. 69 | 70 | variant 71 | false 72 | true 73 | 74 | 75 | 76 | Scope of the locale configuration variable. 77 | 78 | scope 79 | false 80 | false 81 | 82 | 83 | 84 | 85 | 86 | Specifies the time zone for any time formatting or parsing actions 87 | nested in its body 88 | 89 | timeZone 90 | org.apache.taglibs.standard.tag.rt.fmt.TimeZoneTag 91 | JSP 92 | 93 | 94 | The time zone. A String value is interpreted as 95 | a time zone ID. This may be one of the time zone 96 | IDs supported by the Java platform (such as 97 | "America/Los_Angeles") or a custom time zone 98 | ID (such as "GMT-8"). See 99 | java.util.TimeZone for more information on 100 | supported time zone formats. 101 | 102 | value 103 | true 104 | true 105 | 106 | 107 | 108 | 109 | 110 | Stores the given time zone in the time zone configuration variable 111 | 112 | setTimeZone 113 | org.apache.taglibs.standard.tag.rt.fmt.SetTimeZoneTag 114 | empty 115 | 116 | 117 | The time zone. A String value is interpreted as 118 | a time zone ID. This may be one of the time zone 119 | IDs supported by the Java platform (such as 120 | "America/Los_Angeles") or a custom time zone 121 | ID (such as "GMT-8"). See java.util.TimeZone for 122 | more information on supported time zone 123 | formats. 124 | 125 | value 126 | true 127 | true 128 | 129 | 130 | 131 | Name of the exported scoped variable which 132 | stores the time zone of type 133 | java.util.TimeZone. 134 | 135 | var 136 | false 137 | false 138 | 139 | 140 | 141 | Scope of var or the time zone configuration 142 | variable. 143 | 144 | scope 145 | false 146 | false 147 | 148 | 149 | 150 | 151 | 152 | Loads a resource bundle to be used by its tag body 153 | 154 | bundle 155 | org.apache.taglibs.standard.tag.rt.fmt.BundleTag 156 | JSP 157 | 158 | 159 | Resource bundle base name. This is the bundle's 160 | fully-qualified resource name, which has the same 161 | form as a fully-qualified class name, that is, it uses 162 | "." as the package component separator and does not 163 | have any file type (such as ".class" or ".properties") 164 | suffix. 165 | 166 | basename 167 | true 168 | true 169 | 170 | 171 | 172 | Prefix to be prepended to the value of the message 173 | key of any nested <fmt:message> action. 174 | 175 | prefix 176 | false 177 | true 178 | 179 | 180 | 181 | 182 | 183 | Loads a resource bundle and stores it in the named scoped variable or 184 | the bundle configuration variable 185 | 186 | setBundle 187 | org.apache.taglibs.standard.tag.rt.fmt.SetBundleTag 188 | empty 189 | 190 | 191 | Resource bundle base name. This is the bundle's 192 | fully-qualified resource name, which has the same 193 | form as a fully-qualified class name, that is, it uses 194 | "." as the package component separator and does not 195 | have any file type (such as ".class" or ".properties") 196 | suffix. 197 | 198 | basename 199 | true 200 | true 201 | 202 | 203 | 204 | Name of the exported scoped variable which stores 205 | the i18n localization context of type 206 | javax.servlet.jsp.jstl.fmt.LocalizationC 207 | ontext. 208 | 209 | var 210 | false 211 | false 212 | 213 | 214 | 215 | Scope of var or the localization context 216 | configuration variable. 217 | 218 | scope 219 | false 220 | false 221 | 222 | 223 | 224 | 225 | 226 | Maps key to localized message and performs parametric replacement 227 | 228 | message 229 | org.apache.taglibs.standard.tag.rt.fmt.MessageTag 230 | JSP 231 | 232 | 233 | Message key to be looked up. 234 | 235 | key 236 | false 237 | true 238 | 239 | 240 | 241 | Localization context in whose resource 242 | bundle the message key is looked up. 243 | 244 | bundle 245 | false 246 | true 247 | 248 | 249 | 250 | Name of the exported scoped variable 251 | which stores the localized message. 252 | 253 | var 254 | false 255 | false 256 | 257 | 258 | 259 | Scope of var. 260 | 261 | scope 262 | false 263 | false 264 | 265 | 266 | 267 | 268 | 269 | Supplies an argument for parametric replacement to a containing 270 | <message> tag 271 | 272 | param 273 | org.apache.taglibs.standard.tag.rt.fmt.ParamTag 274 | JSP 275 | 276 | 277 | Argument used for parametric replacement. 278 | 279 | value 280 | false 281 | true 282 | 283 | 284 | 285 | 286 | 287 | Formats a numeric value as a number, currency, or percentage 288 | 289 | formatNumber 290 | org.apache.taglibs.standard.tag.rt.fmt.FormatNumberTag 291 | JSP 292 | 293 | 294 | Numeric value to be formatted. 295 | 296 | value 297 | false 298 | true 299 | 300 | 301 | 302 | Specifies whether the value is to be 303 | formatted as number, currency, or 304 | percentage. 305 | 306 | type 307 | false 308 | true 309 | 310 | 311 | 312 | Custom formatting pattern. 313 | 314 | pattern 315 | false 316 | true 317 | 318 | 319 | 320 | ISO 4217 currency code. Applied only 321 | when formatting currencies (i.e. if type is 322 | equal to "currency"); ignored otherwise. 323 | 324 | currencyCode 325 | false 326 | true 327 | 328 | 329 | 330 | Currency symbol. Applied only when 331 | formatting currencies (i.e. if type is equal 332 | to "currency"); ignored otherwise. 333 | 334 | currencySymbol 335 | false 336 | true 337 | 338 | 339 | 340 | Specifies whether the formatted output 341 | will contain any grouping separators. 342 | 343 | groupingUsed 344 | false 345 | true 346 | 347 | 348 | 349 | Maximum number of digits in the integer 350 | portion of the formatted output. 351 | 352 | maxIntegerDigits 353 | false 354 | true 355 | 356 | 357 | 358 | Minimum number of digits in the integer 359 | portion of the formatted output. 360 | 361 | minIntegerDigits 362 | false 363 | true 364 | 365 | 366 | 367 | Maximum number of digits in the 368 | fractional portion of the formatted output. 369 | 370 | maxFractionDigits 371 | false 372 | true 373 | 374 | 375 | 376 | Minimum number of digits in the 377 | fractional portion of the formatted output. 378 | 379 | minFractionDigits 380 | false 381 | true 382 | 383 | 384 | 385 | Name of the exported scoped variable 386 | which stores the formatted result as a 387 | String. 388 | 389 | var 390 | false 391 | false 392 | 393 | 394 | 395 | Scope of var. 396 | 397 | scope 398 | false 399 | false 400 | 401 | 402 | 403 | 404 | 405 | Parses the string representation of a number, currency, or percentage 406 | 407 | parseNumber 408 | org.apache.taglibs.standard.tag.rt.fmt.ParseNumberTag 409 | JSP 410 | 411 | 412 | String to be parsed. 413 | 414 | value 415 | false 416 | true 417 | 418 | 419 | 420 | Specifies whether the string in the value 421 | attribute should be parsed as a number, 422 | currency, or percentage. 423 | 424 | type 425 | false 426 | true 427 | 428 | 429 | 430 | Custom formatting pattern that determines 431 | how the string in the value attribute is to be 432 | parsed. 433 | 434 | pattern 435 | false 436 | true 437 | 438 | 439 | 440 | Locale whose default formatting pattern (for 441 | numbers, currencies, or percentages, 442 | respectively) is to be used during the parse 443 | operation, or to which the pattern specified 444 | via the pattern attribute (if present) is 445 | applied. 446 | 447 | parseLocale 448 | false 449 | true 450 | 451 | 452 | 453 | Specifies whether just the integer portion of 454 | the given value should be parsed. 455 | 456 | integerOnly 457 | false 458 | true 459 | 460 | 461 | 462 | Name of the exported scoped variable which 463 | stores the parsed result (of type 464 | java.lang.Number). 465 | 466 | var 467 | false 468 | false 469 | 470 | 471 | 472 | Scope of var. 473 | 474 | scope 475 | false 476 | false 477 | 478 | 479 | 480 | 481 | 482 | Formats a date and/or time using the supplied styles and pattern 483 | 484 | formatDate 485 | org.apache.taglibs.standard.tag.rt.fmt.FormatDateTag 486 | empty 487 | 488 | 489 | Date and/or time to be formatted. 490 | 491 | value 492 | true 493 | true 494 | 495 | 496 | 497 | Specifies whether the time, the date, or both 498 | the time and date components of the given 499 | date are to be formatted. 500 | 501 | type 502 | false 503 | true 504 | 505 | 506 | 507 | Predefined formatting style for dates. Follows 508 | the semantics defined in class 509 | java.text.DateFormat. Applied only 510 | when formatting a date or both a date and 511 | time (i.e. if type is missing or is equal to 512 | "date" or "both"); ignored otherwise. 513 | 514 | dateStyle 515 | false 516 | true 517 | 518 | 519 | 520 | Predefined formatting style for times. Follows 521 | the semantics defined in class 522 | java.text.DateFormat. Applied only 523 | when formatting a time or both a date and 524 | time (i.e. if type is equal to "time" or "both"); 525 | ignored otherwise. 526 | 527 | timeStyle 528 | false 529 | true 530 | 531 | 532 | 533 | Custom formatting style for dates and times. 534 | 535 | pattern 536 | false 537 | true 538 | 539 | 540 | 541 | Time zone in which to represent the formatted 542 | time. 543 | 544 | timeZone 545 | false 546 | true 547 | 548 | 549 | 550 | Name of the exported scoped variable which 551 | stores the formatted result as a String. 552 | 553 | var 554 | false 555 | false 556 | 557 | 558 | 559 | Scope of var. 560 | 561 | scope 562 | false 563 | false 564 | 565 | 566 | 567 | 568 | 569 | Parses the string representation of a date and/or time 570 | 571 | parseDate 572 | org.apache.taglibs.standard.tag.rt.fmt.ParseDateTag 573 | JSP 574 | 575 | 576 | Date string to be parsed. 577 | 578 | value 579 | false 580 | true 581 | 582 | 583 | 584 | Specifies whether the date string in the 585 | value attribute is supposed to contain a 586 | time, a date, or both. 587 | 588 | type 589 | false 590 | true 591 | 592 | 593 | 594 | Predefined formatting style for days 595 | which determines how the date 596 | component of the date string is to be 597 | parsed. Applied only when formatting a 598 | date or both a date and time (i.e. if type 599 | is missing or is equal to "date" or "both"); 600 | ignored otherwise. 601 | 602 | dateStyle 603 | false 604 | true 605 | 606 | 607 | 608 | Predefined formatting styles for times 609 | which determines how the time 610 | component in the date string is to be 611 | parsed. Applied only when formatting a 612 | time or both a date and time (i.e. if type 613 | is equal to "time" or "both"); ignored 614 | otherwise. 615 | 616 | timeStyle 617 | false 618 | true 619 | 620 | 621 | 622 | Custom formatting pattern which 623 | determines how the date string is to be 624 | parsed. 625 | 626 | pattern 627 | false 628 | true 629 | 630 | 631 | 632 | Time zone in which to interpret any time 633 | information in the date string. 634 | 635 | timeZone 636 | false 637 | true 638 | 639 | 640 | 641 | Locale whose predefined formatting styles 642 | for dates and times are to be used during 643 | the parse operation, or to which the 644 | pattern specified via the pattern 645 | attribute (if present) is applied. 646 | 647 | parseLocale 648 | false 649 | true 650 | 651 | 652 | 653 | Name of the exported scoped variable in 654 | which the parsing result (of type 655 | java.util.Date) is stored. 656 | 657 | var 658 | false 659 | false 660 | 661 | 662 | 663 | Scope of var. 664 | 665 | scope 666 | false 667 | false 668 | 669 | 670 | 671 | 672 | -------------------------------------------------------------------------------- /src/groovy/org/grails/plugins/test/GenericRestFunctionalTests.groovy: -------------------------------------------------------------------------------- 1 | package org.grails.plugins.test 2 | 3 | import grails.converters.JSON 4 | 5 | import org.apache.commons.logging.LogFactory 6 | import org.codehaus.groovy.grails.commons.DefaultGrailsDomainClass 7 | import org.codehaus.groovy.grails.web.json.JSONArray 8 | 9 | 10 | /** 11 | * Mixin to a project functional test and provide the domain class to test. 12 | * 13 | * @author kent.butler@gmail.com 14 | * 15 | */ 16 | class GenericRestFunctionalTests { 17 | 18 | def log = LogFactory.getLog(getClass()) 19 | 20 | protected void setUp() { 21 | // Cannot access the grailsApplication from a functional test 22 | // See http://jira.codehaus.org/browse/GEB-175 23 | } 24 | 25 | 26 | def cloneObject(def obj) { 27 | if (!obj) return null 28 | 29 | def clazz = obj.class 30 | def d = new DefaultGrailsDomainClass(clazz) 31 | 32 | log.trace("Cloning ${clazz?.name} from ====> ${d.persistantProperties}") 33 | 34 | def newObj = clazz.newInstance() 35 | d.persistantProperties.each { 36 | log.trace("copying ${it.name} ${it.association ? '(association)':''}") 37 | newObj[it.name] = obj[it.name] 38 | } 39 | return newObj 40 | } 41 | 42 | 43 | /** 44 | * Tests the REST list() action for the class of the given object.
45 | * Example: GET http://localhost:8080/cook/api/rawIngredient 46 | *
Tests both plugin modes: