Show Navigation

Consume and test a third-party REST API

Use Ersatz, a "mock" HTTP library, for testing code dealing with HTTP requests

Authors: Sergio del Amo

Grails Version: 4.0.1

1 Grails Training

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

2 Getting Started

In this guide you are going to create a Grails app which consumes a third party REST API. Moreover, we will use a "mock" HTTP library to test the code which interacts with this external service.

2.1 What you will need

To complete this guide, you will need the following:

  • Some time on your hands

  • A decent text editor or IDE

  • JDK 1.8 or greater installed with JAVA_HOME configured appropriately

2.2 How to complete the guide

To get started do the following:

or

The Grails guides repositories contain two folders:

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

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

To complete the guide, go to the initial folder

  • cd into grails-guides/grails-mock-http-server/initial

and follow the instructions in the next sections.

You can go right to the completed example if you cd into grails-guides/grails-mock-http-server/complete

3 Writing the Application

The first step is to add the Micronaut HTTP client library to our project. Add the next dependency:

build.gradle
    compile "io.micronaut:micronaut-http-client"

3.1 Open Weather Map

OpenWeatherMap is a web application which offers an API which allows you to:

Get current weather, daily forecast for 16 days, and 3-hourly forecast 5 days for your city. Helpful stats, graphics, and this day in history charts are available for your reference. Interactive maps show precipitation, clouds, pressure, wind around your location.

They have a FREE plan which allows you to get the Current Weather Data of a city.

After you register, you get an API Key. You will need an API key to interact with the Open Weather Map API.

apiKey
the API key may take several minutes to become active.

3.2 Parse Response into JAVA classes

Create several JAVA POJOs (Plain Old Java Objects) to map the OpenWeatherMap JSON response to classes.

src/main/java/org/openweathermap/CurrentWeather.java
package org.openweathermap;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.micronaut.core.annotation.Introspected;

import java.util.List;

@Introspected
public class CurrentWeather {
    private Main main;

    @JsonProperty("coord")
    private Coordinate coordinate;

    private List<Weather> weather;
    private Wind wind;
    private Sys sys;
    private Rain rain;
    private Clouds clouds;
    private String base;
    private Integer dt;

    @JsonProperty("id")
    private Long cityId;

    @JsonProperty("name")
    private String cityName;

    @JsonProperty("cod")
    private Integer code;

    private Integer visibility;

    public CurrentWeather() {
    }

//getters and setters
}
src/main/java/org/openweathermap/Clouds.java
package org.openweathermap;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.micronaut.core.annotation.Introspected;

@Introspected
public class Clouds {
    @JsonProperty("all")
    private Integer cloudiness;

    public Clouds() {
    }
//getters and setters
}
src/main/java/org/openweathermap/Coordinate.java
package org.openweathermap;

import com.fasterxml.jackson.annotation.JsonProperty;
import io.micronaut.core.annotation.Introspected;

import java.math.BigDecimal;

@Introspected
public class Coordinate {

    @JsonProperty("long")
    private BigDecimal longitude;

    @JsonProperty("lat")
    private BigDecimal latitude;

    public Coordinate() {
    }
//getters and setters
}
src/main/java/org/openweathermap/Rain.java
package org.openweathermap;

import io.micronaut.core.annotation.Introspected;

@Introspected
public class Rain {
    private Integer lastThreeHours;

    public Rain() {
    }
//getters and setters
}
src/main/java/org/openweathermap/Unit.java
package org.openweathermap;

public enum Unit {
    Standard, Imperial, Metric;

    public static Unit unitWithString(String str) {
        if ( str != null) {
            if ( str.toLowerCase().equals("metric") ) {
                return Unit.Metric;
            } else if ( str.toLowerCase().equals("imperial") ) {
                return Unit.Imperial;
            }
        }
        return Unit.Standard;
    }

    @Override
    public String toString() {
        return this.name().toLowerCase();
    }
}
src/main/java/org/openweathermap/Weather.java
package org.openweathermap;

import io.micronaut.core.annotation.Introspected;

@Introspected
public class Weather {
    private Long id;
    private String main;
    private String description;
    private String icon;

    public Weather() {
    }
//getters and setters
}
src/main/java/org/openweathermap/Sys.java
package org.openweathermap;

import io.micronaut.core.annotation.Introspected;

@Introspected
public class Sys {
    private Long id;
    private String type;
    private String message;
    private String country;
    private Integer sunrise;
    private Integer sunset;

    public Sys() {

    }
//getters and setters
}
src/main/java/org/openweathermap/Wind.java
package org.openweathermap;

import io.micronaut.core.annotation.Introspected;

import java.math.BigDecimal;

@Introspected
public class Wind {
    private BigDecimal speed;
    private BigDecimal deg;

    public Wind() {

    }
//getters and setters
}

3.3 Open Weather Service

Create the next service:

grails-app/services/org/openweathermap/OpenweathermapService.groovy
package org.openweathermap

import grails.config.Config
import grails.core.support.GrailsConfigurationAware
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.BlockingHttpClient
import io.micronaut.http.client.HttpClient
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.client.exceptions.HttpClientResponseException
import io.micronaut.http.uri.UriBuilder
import org.grails.web.json.JSONObject



@CompileStatic
class OpenweathermapService implements GrailsConfigurationAware {
    String appid
    String cityName
    String countryCode
    BlockingHttpClient client

    @Override
    void setConfiguration(Config co) {
        setupHttpClient(co.getProperty('openweather.url', String, 'http://api.openweathermap.org'))
        appid = co.getProperty('openweather.appid', String)
        cityName = co.getProperty('openweather.cityName', String)
        countryCode = co.getProperty('openweather.countryCode', String)
    }

    void setupHttpClient(String url) {
        this.client = HttpClient.create(url.toURL()).toBlocking()
    }
    CurrentWeather currentWeather(Unit units = Unit.Standard) {
        currentWeather(cityName, countryCode, units)
    }

    CurrentWeather currentWeather(String cityName, String countryCode, Unit unit = Unit.Standard) {
        try {
            HttpRequest request = HttpRequest.GET(currentWeatherUri(cityName, countryCode, unit))
            return client.retrieve(request, CurrentWeather)

        } catch (HttpClientResponseException e) {
            return null (3)
        }
    }

    URI currentWeatherUri(String cityName, String countryCode, Unit unit = Unit.Standard) {
        UriBuilder uriBuilder = UriBuilder.of('/data/2.5/weather')
                .queryParam('q', "${cityName},${countryCode}".toString())
                .queryParam('appid', appid)
        String unitParam = unitParameter(unit)
        if (unitParam) {
            uriBuilder = uriBuilder.queryParam('units', unitParam)
        }
        uriBuilder.build()
    }

    String unitParameter(Unit unit)  {
        unit == Unit.Standard ? null : unit?.toString()
    }
}
1 To get the current weather, do a GET request providing the city name, country code and API Key as query parameters.
2 In case of a 200 - OK - response, parse the JSON data into Groovy classes.
3 if the answer is not 200. For example, 401; the method returns null.

The previous service uses several configuration parameters. Define them in application.yml

grails-app/conf/application.yml
openweather:
    appid: 1f6c7d09a28f1ddccf70c06e2cb75ee4
    cityName: London
    countryCode: uk

3.4 Ersatz

To test the networking code, add a dependency to Ersatz

build.gradle
    testCompile 'com.stehno.ersatz:ersatz:2.0.0'

Ersatz Server is a "mock" HTTP server library for testing HTTP clients. It allows for server-side request/response expectations to be configured so that your client library can make real HTTP calls and get back real pre-configured responses rather than fake stubs.

First, implement a test which verifies that the OpenweathermapService.currentWeather method returns null when the REST API returns 401. For example, when the API Key is invalid.

src/test/groovy/org/openweathermap/OpenweathermapServiceSpec.groovy
package org.openweathermap

import com.stehno.ersatz.ErsatzServer
import com.stehno.ersatz.cfg.ContentType
import com.stehno.ersatz.encdec.Encoders
import grails.testing.services.ServiceUnitTest
import spock.lang.IgnoreIf
import spock.lang.Specification

@IgnoreIf( { System.getenv('TRAVIS') as boolean } )
class OpenweathermapServiceSpec extends Specification implements ServiceUnitTest<OpenweathermapService> {

    def "For an unauthorized key, null is return"() {
        given:
        ErsatzServer ersatz = new ErsatzServer()
        String city = 'London'
        String countryCode = 'uk'
        String appid = 'XXXXX'
        ersatz.expectations {
            GET('/data/2.5/weather') { (1)
                query('q', "${city},${countryCode}")
                query('appid', appid)
                called(1) (2)
                responder {
                    code(401) (3)
                }
            }
        }
        service.setupHttpClient(ersatz.httpUrl) (4)
        service.appid = appid

        when:
        CurrentWeather currentWeather = service.currentWeather(city, countryCode)

        then:
        !currentWeather

        and:
        ersatz.verify() (5)

        cleanup:
        ersatz.stop() (6)
    }
}
1 Declare expectations, a GET request to the OpenWeather path with query parameters.
2 Declare conditions to be verified, in this example we want to to verify the endpoint is hit only one time.
3 Tell the mock server to return 401 for this test.
4 Ersatz starts an embedded Undertow server, root the networking requests to this server instead of to the OpenWeather API server.
5 Verify the ersatz servers conditions.
6 Rember to stop the server

Next, test that when the server returns 200 and a JSON payload, the JSON payload is parsed correctly into Groovy classes.

src/test/groovy/org/openweathermap/OpenweathermapServiceSpec.groovy
package org.openweathermap

import com.stehno.ersatz.ErsatzServer
import com.stehno.ersatz.cfg.ContentType
import com.stehno.ersatz.encdec.Encoders
import grails.testing.services.ServiceUnitTest
import spock.lang.IgnoreIf
import spock.lang.Specification

@IgnoreIf( { System.getenv('TRAVIS') as boolean } )
class OpenweathermapServiceSpec extends Specification implements ServiceUnitTest<OpenweathermapService> {

    def "A CurrentWeather object is built from JSON Payload"() {
        given:
        ErsatzServer ersatz = new ErsatzServer()
        String city = 'London'
        String countryCode = 'uk'
        String appid = 'XXXXX'
        ersatz.expectations {
            GET('/data/2.5/weather') {
                query('q', "${city},${countryCode}")
                query('appid', appid)
                called(1)
                responder {
                    encoder(ContentType.APPLICATION_JSON, Map, Encoders.json) (1)
                    code(200)
                    body([
                        coord     : [lon: -0.13, lat: 51.51],
                        weather   : [[id: 803, main: 'Clouds', description: 'broken clouds', icon: '04d']],
                        base      : 'stations',
                        main      : [temp: 20.81, pressure: 1017, humidity: 53, temp_min: 19, temp_max: 22],
                        visibility: 10000,
                        wind      : [speed: 3.6, deg: 180, gust: 9.8],
                        clouds    : [all: 75],
                        dt        : 1502707800,
                        sys       : [type: 1, id: 5091, message: 0.0029, country: "GB", sunrise: 1502685920, sunset: 1502738622],
                        id        : 2643743,
                        name      : 'London',
                        cod       : 200
                    ], ContentType.APPLICATION_JSON) (2)
                }
            }
        }
        service.setupHttpClient(ersatz.httpUrl)
        service.appid = appid

        when:
        CurrentWeather currentWeather = service.currentWeather(city, countryCode)

        then:
        currentWeather

        currentWeather.cityName == 'London'
        currentWeather.code == 200
        currentWeather.cityId == 2643743
        currentWeather.main.temperature == 20.81
        currentWeather.main.pressure == 1017
        currentWeather.main.humidity == 53
        currentWeather.main.tempMin == 19
        currentWeather.main.tempMax == 22
        currentWeather.weather
        currentWeather.weather[0].main == 'Clouds'
        currentWeather.weather[0].id == 803
        currentWeather.weather[0].main == 'Clouds'
        currentWeather.weather[0].description == 'broken clouds'
        currentWeather.weather[0].icon == '04d'
        currentWeather.visibility == 10000
        currentWeather.wind.speed == 3.6
        currentWeather.wind.deg == 180
        currentWeather.clouds.cloudiness == 75
        currentWeather.base == 'stations'
        currentWeather.dt == 1502707800
        currentWeather.coordinate

        and:
        ersatz.verify()

        cleanup:
        ersatz.stop()
    }

}
1 Declare a response encoder to convert a Map into application/json content using an Ersatz-provided encoder.
2 Define the response content as a Map which will be converted to JSON by the defined encoder (above).

http://stehno.com/ersatz/guide/#shadow_jar[Shadow Jar section of the Ersatz User Guide]: The embedded version of Undertow used by Ersatz has caused issues with some server frameworks which also use Undertow (e.g. Grails, and Spring-boot). If you run into errors using the standard jar distribution, please try using the safe distribution, which is a shadowed jar which includes the Undertow library and its JBoss dependencies repackaged in the jar. _

3.5 Run Tests

To run the tests:

./grailsw
grails> test-app
grails> open test-report

or

./gradlew check
open build/reports/tests/index.html

3.6 Root Url to Weather Controller

Create a HomeController which uses the previous service:

grails-app/controllers/demo/HomeController.groovy
package demo

import groovy.transform.CompileStatic
import org.openweathermap.CurrentWeather
import org.openweathermap.OpenweathermapService
import org.openweathermap.Unit

@CompileStatic
class HomeController {
    OpenweathermapService openweathermapService

    def index(String unit) {
        Unit unitEnum = Unit.unitWithString(unit)
        CurrentWeather currentWeather = openweathermapService.currentWeather(unitEnum)
        [currentWeather: currentWeather, unit: unitEnum]
    }
}

In UrlMapping.groovy map the root URL to this controller:

"/"(controller: 'home')`

3.7 TagLib

Create a taglib to help you, to encapsulate some rendering aspects:

grails-app/taglib/org/openweathermap/OpenweathermapTagLib.groovy
package org.openweathermap

class OpenweathermapTagLib {
    static namespace = "openweather"

    def image = { attrs ->
        out << "<img src=\"http://openweathermap.org/img/w/${attrs.icon}.png\"/>"
    }

    def temperatureSymbol = { attrs ->
        if ( attrs.unit == Unit.Imperial ) {
            out << '°F'
        } else if ( attrs.unit == Unit.Metric ) {
            out << '°C'
        }

    }
}

3.8 View

Create the next GSPs to render the gathered Weather information as an HTML page.

grails-app/views/home/index.gsp
<html>
<head>
    <title>Current Weather</title>
    <meta name="layout" content="main" />
</head>
<body>
    <div id="content" role="main">
        <section class="row colset-2-its">
            <g:if test="${currentWeather}">
                <g:render template="/openweather/currentWeather"
                          model="[currentWeather: currentWeather, unit: unit]"/>
            </g:if>
        </section>
    </div>
</body>
</html>
grails-app/views/openweather/_currentWeather.gsp
<g:if test="${currentWeather.weatherList}">
    <g:each in="${currentWeather.weatherList}" var="weather">
        <div class="weatherBlock">
            <h2><b>${currentWeather.cityName}</b></h2>
            <h3>${currentWeather.main?.temperature} <openweather:temperatureSymbol unit="${unit}"/></h3>
            <openweather:image icon="${weather.icon}"/>
            <h4>${weather.description}</h4>
        </div>
    </g:each>
</g:if>

Add the next CSS snippet to style the weather forecast.

grails-app/assets/stylesheets/main.css
.weatherBlock {
     width: 150px;
     height: 200px;
     margin: 10px auto;
     text-align: center;
     border: 1px solid #c0d3db;
     float: left;
}

4 Running the Application

To run the application use the ./gradlew bootRun command which will start the application on port 8080.

If you setup a valid API Key in application.yml, you will see the London weather prediction.

homepage

5 Help with Grails

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

OCI is Home to Grails

Meet the Team