March 6, 2017

Ratpacked: Render Optional Type Instance

Ratpack uses renderers to render objects with the render method of the Context class. Ratpack has several renderers that are available automatically. One of those renderers is the OptionalRenderer. When we want to render an Optional object this renderer is selected by Ratpack. If the Optional instance has a value the value is passed to the render method. If the value is not present a 404 client error is returned.

In the following example application we have a RecipeRepository class with a findRecipeByName method. This method returns Promise<Optional<Recipe>>:

// File: src/main/java/mrhaki/ratpack/RecipeRepository.java
package mrhaki.ratpack;

import ratpack.exec.Promise;

import java.util.Optional;

public interface RecipeRepository {
    Promise<Optional<Recipe>> findRecipeByName(final String name);
}

We have a Handler that will use the findRecipeByName method and then render the Optional<Recipe> object. The following example application shows the handler implementation:

// File: src/main/java/mrhaki/ratpack/Application.java
package mrhaki.ratpack;

import ratpack.func.Action;
import ratpack.handling.Chain;
import ratpack.handling.Handler;
import ratpack.registry.RegistrySpec;
import ratpack.server.RatpackServer;

import java.util.Optional;

public class Application {

    public static void main(String[] args) throws Exception {
        new Application().startServer();
    }
    
    void startServer() throws Exception {
        RatpackServer.start(server -> server
                .registryOf(registry())
                .handlers(chain()));
    }
    
    private Action<RegistrySpec> registry() {
        return registry -> registry
                .add(new RecipeRenderer())
                .add(RecipeRepository.class, new RecipesList());
    }

    private Action<Chain> chain() {
        return chain -> chain.post("recipe", recipeHandler());
    }

    private Handler recipeHandler() {
        return ctx -> ctx
                .parse(RecipeRequest.class)
                .flatMap(recipeRequest -> ctx
                        .get(RecipeRepository.class)
                        .findRecipeByName(recipeRequest.getName()))
                .then((Optional<Recipe> optionalRecipe) -> ctx.render(optionalRecipe));
    }

}

The application also uses a custom RecipeRenderer. This renderer is used when the Optional<Recipe> has a value:

// File: src/main/java/mrhaki/ratpack/RecipeRenderer.java
package mrhaki.ratpack;

import ratpack.handling.Context;
import ratpack.render.RendererSupport;

import static ratpack.jackson.Jackson.json;

public class RecipeRenderer extends RendererSupport<Recipe> {
    @Override
    public void render(final Context ctx, final Recipe recipe) throws Exception {
        ctx.render(json(recipe));
    }
}

Let's write a specification where we can test that a client error with status code 404 is returned when the Optional is empty. Otherwise the actual value is rendered:

// File: src/test/groovy/mrhaki/ratpack/ApplicationSpec.groovy
package mrhaki.ratpack

import groovy.json.JsonSlurper
import ratpack.exec.Promise
import ratpack.http.MediaType
import ratpack.impose.ImpositionsSpec
import ratpack.impose.UserRegistryImposition
import ratpack.registry.Registry
import ratpack.test.MainClassApplicationUnderTest
import spock.lang.Specification
import spock.lang.Subject

import static groovy.json.JsonOutput.toJson

class ApplicationSpec extends Specification {
    
    private RecipeRepository recipeMock = Mock()
    
    @Subject
    private aut = new MainClassApplicationUnderTest(Application) {
        @Override
        protected void addImpositions(final ImpositionsSpec impositions) {
            // Add mock for RecipeRepository.
            impositions.add(UserRegistryImposition.of(Registry.of { registry ->
                registry.add(RecipeRepository, recipeMock)
            }))
        }
    }
    
    private httpClient = aut.httpClient
    
    void 'response status 404 when Optional<Recipe> is empty'() {
        when:
        def response = httpClient.requestSpec { requestSpec ->
            requestSpec.headers.set 'Content-type', MediaType.APPLICATION_JSON
            requestSpec.body { body ->
                body.text(toJson(name: 'sushi'))
            }
        }.post('recipe')
        
        then:
        1 * recipeMock.findRecipeByName('sushi') >> Promise.value(Optional.empty())
        
        and:
        response.statusCode == 404
    }

    void 'render Recipe when Optional<Recipe> is not empty'() {
        when:
        def response = httpClient.requestSpec { requestSpec ->
            requestSpec.headers.set 'Content-type', MediaType.APPLICATION_JSON
            requestSpec.body { body ->
                body.text(toJson(name: 'macaroni'))
            }
        }.post('recipe')

        then:
        1 * recipeMock.findRecipeByName('macaroni') >> Promise.value(Optional.of(new Recipe('macaroni')))

        and:
        response.statusCode == 200
        
        and:
        def recipe = new JsonSlurper().parseText(response.body.text)
        recipe.name == 'macaroni'
    }

}

Written with Ratpack 1.4.5.