How do I render a map of domain objects using a Grails 4 JSON View

Viewed 256

This is a follow on questions to: How to Render a Map as a property in a Grails 4 JSON View

I have the following JSON view and I would like to render the values of mealsByPerson map using the _breakfast.gson template. In addition, I would like to be able to pass the allCaps model attribute from _foo.gson to breakfast.gson

/foo/_foo.gson

import rendermapexample.Breakfast

model {
    Float cost
    Date date
    Map<String, Breakfast> mealsByPerson
    Boolean allCaps
}

json {
    date date
    cost cost
    mealsByPerson g.render(mealsByPerson){}  //HOW DO I PASS `allCaps` to this template?

    // This doesn't work  
    // mealsByPerson g.render(mealsByPerson, model: [allCaps: true]){} 
}

/breaskfast/_breaskfast.gson

import rendermapexample.Breakfast

model {
    Breakfast breakfast
    Boolean allCaps
}

json {
    meat allCaps ? breakfast.meat.toUpperCase() : breakfast.meat
    eggs allCaps ? breakfast.eggs.toUpperCase() : breakfast.eggs
    side allCaps ? breakfast.side.toUpperCase() : breakfast.side
}

FooController

package rendermapexample

class FooController {
    static responseFormats = ['json', 'xml']
    
    def index() {
        Map<String, Breakfast> mealsByPerson = [
            Tom: new Breakfast(meat: "bacon", eggs: "scrambled", side: "hashbrowns"),
            Jack: new Breakfast(meat: "sausage", eggs: "over easy", side: "pancakes")
        ]

        render template: "foo", model: [
            cost: 12.34f, 
            date: new Date(), 
            mealsByPerson: mealsByPerson, 
            allCaps: params.boolean("allCaps")
        ]
    }
}

Desired output

http://localhost:8080/foo

{
    "cost": 12.34,
    "date": "2021-09-25T01:11:39Z",
    "mealsByPerson": {
        "Tom": {
            "eggs": "scrambled",
            "meat": "bacon",
            "side": "hashbrowns"
        },
        "Jack": {
            "eggs": "over easy",
            "meat": "sausage",
            "side": "pancakes"
        }
    }
}

http://localhost:8080/foo?allCaps=true

{
    "cost": 12.34,
    "date": "2021-09-25T01:11:39Z",
    "mealsByPerson": {
        "Tom": {
            "eggs": "SCRAMBLED",
            "meat": "BACON",
            "side": "HASHBROWNS"
        },
        "Jack": {
            "eggs": "OVER EASY",
            "meat": "SAUSAGE",
            "side": "PANCAKES"
        }
    }
}

Example Project

https://github.com/tonyerskine/rendermapexample

3 Answers

UPDATE: Please review my improved answer.

This is my (kind of) old-school approach:

First, since the allCaps requirement might be useful not only for that particular controller/action, I would add an asMap method to the Breakfast domain class itself. It capitalizes all String properties if its param allCaps is true, and returns a Map with all object's properties:

class Breakfast {

    String meat
    String eggs
    String side
    Integer number // Just another random propperty 

    static constraints = {
    }

    // Generic version, We asume we need allCaps for all String properties  
    def asMap(boolean allCaps=false) {
        def breakfast = [:]
        this.properties.each { key, value ->
            if (value && value.class == String && allCaps == true) {
                breakfast[key] = value.toUpperCase() 
            } else {
                breakfast[key] = value
            } 
        }
        return breakfast
    }

    // An alternate/sillier version 
    def asMapSimpler(boolean allCaps=false) {
        return [
            meat:meat.toUpperCase(),
            eggs:eggs.toUpperCase(),
            side:side.toUpperCase(),
            number: number
        ]
    }

}

Next, we can use the asMap method in FooController:

class FooController {
    static responseFormats = ['json', 'xml']
    
    def index() {

        // default: false 
        def allCaps = params.boolean("allCaps") ?: false

        Map<String, Map> mealsByPerson = [
            Tom: new Breakfast(meat: "bacon", eggs: "scrambled", side: "hashbrowns").asMap(allCaps),
            Jack: new Breakfast(meat: "sausage", eggs: "over easy", side: "pancakes").asMap(allCaps)
        ]

        render template: "foo", model: [
            cost: 12.34f,
            date: new Date(),
            mealsByPerson: mealsByPerson,
            allCaps: allCaps
        ]
    }
}

Revisiting the problem

I revisited the problem and my previous answer, and decided to post this one instead of editing the former, addressing here a couple of comments/suggestions received to improve my proposed solution.

I moved presentation logic away from the domain class. To achieve that, I've explored three different approaches:

  1. Using a Grails Service
  2. Coding in the JSON View side
  3. Using only templates

Just for the sake of clarity, I added the following mappings in URLMappings.groovy:

   "/approach1"(controller: 'foo', action:'approach1')
   "/approach2"(controller: 'foo', action:'approach2')
   "/approach3"(controller: 'foo', action:'approach3')

Approach 1: Using a Grails Service

Please note that in FooController the service bean fooService is included as a parameter in the respond call to the JSON View.

FooService.groovy

package rendermapexample

import grails.gorm.transactions.Transactional

@Transactional
class FooService {

    def toAllCaps(mealsByPerson) {        
        mealsByPerson.each { person, breakfast ->
            def breakfastMap = [:]
            breakfast.properties.each { key, value ->
                if (value && value.class == String) {
                    breakfastMap[key] = value.toUpperCase() 
                } else {
                    breakfastMap[key] = value
                } 
            }
            mealsByPerson[person] = breakfastMap 
        }
        return mealsByPerson
    }

}

FooController.groovy

package rendermapexample

class FooController {
    static responseFormats = ['json', 'xml']

    def fooService

    def approach1() {

        Map<String, Breakfast>  mealsByPerson = [
            Tom: new Breakfast(meat: "bacon", eggs: "scrambled", side: "hashbrowns"),
            Jack: new Breakfast(meat: "sausage", eggs: "over easy", side: "pancakes")
        ]

        respond cost: 12.34f,
                date: new Date(),
                mealsByPerson: mealsByPerson,
                allCaps: params.boolean("allCaps"),
                fooService: fooService
        
    }
}

approach1.gson

import rendermapexample.Breakfast
import rendermapexample.FooService

model {
    Float cost
    Date date
    Map<String, Breakfast> mealsByPerson
    Boolean allCaps
    FooService fooService
}

json {
    date date
    cost cost   
    mealsByPerson g.render(allCaps ? fooService.toAllCaps(mealsByPerson) : mealsByPerson)
}

Of course, instead of passing the fooService bean as a parameter, we could place toAllCaps code into a POJO static utility class, and then import it in approach2.gson.

Approach 2: Coding in the JSON View side

In case more control is needed on the JSON View side, we could move toAllCaps function from FooService.groovy to approach1.gson, and just discard FooService.groovy.

FooController.groovy

package rendermapexample

class FooController {
    static responseFormats = ['json', 'xml']

    def approach2() {

        Map<String, Breakfast>  mealsByPerson = [
            Tom: new Breakfast(meat: "bacon", eggs: "scrambled", side: "hashbrowns"),
            Jack: new Breakfast(meat: "sausage", eggs: "over easy", side: "pancakes")
        ]

        respond cost: 12.34f,
                date: new Date(),
                mealsByPerson: mealsByPerson,
                allCaps: params.boolean("allCaps")
        
    }
}

approach2.gson

import rendermapexample.Breakfast

model {
    Float cost
    Date date
    Map<String, Breakfast> mealsByPerson
    Boolean allCaps
}

json {
    date date
    cost cost   
    mealsByPerson g.render(allCaps ? toAllCaps(mealsByPerson) : mealsByPerson)
}


def toAllCaps(mealsByPerson) {        
    mealsByPerson.each { person, breakfast ->
        def breakfastMap = [:]
        breakfast.properties.each { key, value ->
            if (value && value.class == String) {
                breakfastMap[key] = value.toUpperCase() 
            } else {
                breakfastMap[key] = value
            } 
        }
        mealsByPerson[person] = breakfastMap 
    }
    return mealsByPerson
}

Approach 3: Using only templates

Here I intended to do less traditional groovy coding, and rely more on JSON View templates, as outlined in the official documentation.

Please note the following caveats:

  1. I'm using an ArrayList variant of mealsByPerson because some features of JSON View templates require an object that implements the Iterator interface. Important: This results in a JSON Array of separate, individual objects rather than a single JSON object containing all the map entries as described in the original question.

  2. I had to disable static compilation for JSON Views. This is because some of the JSON object names within mealsByPerson are dynamic (i.e. they are not just labels but actual data). Namely "Tom" and "Jack" object names from the original post.

application.yml

grails:
    views:
        json:
            compileStatic: false

FooController.groovy

package rendermapexample

class FooController {
    static responseFormats = ['json', 'xml']

    def approach3() {
        
        ArrayList mealsByPerson = [
            Tom: new Breakfast(meat: "bacon", eggs: "scrambled", side: "hashbrowns"),
            Jack: new Breakfast(meat: "sausage", eggs: "over easy", side: "pancakes")
        ].collect()

        respond cost: 12.34f,
                date: new Date(),
                mealsByPerson: mealsByPerson,
                allCaps: params.boolean("allCaps")
    }
}

approach3.gson

import rendermapexample.Breakfast

model {
    Float cost
    Date date
    ArrayList mealsByPerson
    Boolean allCaps
}

json {
    date date
    cost cost   
    mealsByPerson tmpl.mealsByPerson(mealsByPerson, [allCaps: allCaps]) 
}

_mealsByPerson.gson

import rendermapexample.Breakfast

model {
    Map.Entry mealsByPerson
    Boolean allCaps
}

String person = mealsByPerson.key
Breakfast breakfast = mealsByPerson.value

json {
    "${person}" tmpl.breakfast(breakfast:breakfast, allCaps:allCaps) 
}

_breakfast.gson

import rendermapexample.Breakfast

model {
    Breakfast breakfast
    Boolean allCaps
}


json {
    if (allCaps) {
        meat breakfast.meat.toUpperCase()
        eggs breakfast.eggs.toUpperCase()
        side breakfast.side.toUpperCase()
    } else {
        meat breakfast.meat
        eggs breakfast.eggs
        side breakfast.side
    }
}

So this finally worked

_foo.gson

import rendermapexample.Breakfast

model {
    Float cost
    Date date
    Map<String, Breakfast> mealsByPerson
    Boolean allCaps
}

json {
    date date
    cost cost
    mealsByPerson tmpl."/foo/mealsByPerson2"([mealsByPerson: mealsByPerson, allCaps: allCaps])
}

_mealsByPerson2.gson

model {
    Map mealsByPerson
    Boolean allCaps
}

json {
    for(Map.Entry entry : mealsByPerson) {
        "${entry.key}" tmpl."/breakfast/breakfast"(breakfast:entry.value, allCaps:allCaps)
    }
}

Note: This does NOT work for _mealByPerson.gson

You must use a standard loop rather than an each closure for this to work

model {
    Map mealsByPerson
    Boolean allCaps
}

json {
    mealsByPerson.each { Map.Entry entry ->
        "${entry.key}" tmpl."/breakfast/breakfast"(breakfast:entry.value, allCaps:allCaps)
    }
}
Related