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: 3.3.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.7 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 a HTTP client library to our project. Add the next dependency:

build.gradle
    compile "org.grails:grails-datastore-rest-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 Groovy classes

Create several Groovy POGOs (Plain Old Groovy Objects) to map the OpenWeatherMap JSON response to classes.

src/main/groovy/org/openweathermap/CurrentWeather.groovy
package org.openweathermap

import groovy.transform.CompileStatic

@CompileStatic
class CurrentWeather {
    Main main
    Coordinate coordinate
    List<Weather> weatherList
    Wind wind
    Sys sys
    Rain rain
    Clouds clouds
    String base
    Integer dt
    Long cityId
    String cityName
    Integer code
    Integer visibility
}
src/main/groovy/org/openweathermap/Clouds.groovy
package org.openweathermap

class Clouds {
    Integer cloudiness
}
src/main/groovy/org/openweathermap/Coordinate.groovy
package org.openweathermap

import groovy.transform.CompileStatic

@CompileStatic
class Coordinate {
    BigDecimal longitude
    BigDecimal latitude

}
src/main/groovy/org/openweathermap/Rain.groovy
package org.openweathermap

import groovy.transform.CompileStatic

@CompileStatic
class Rain {
    Integer lastThreeHours
}
src/main/groovy/org/openweathermap/Unit.groovy
package org.openweathermap

import groovy.transform.CompileStatic

@CompileStatic
enum Unit {
    Standard, Imperial, Metric

    static Unit unitWithString(String str) {
        if ( str ) {
            if ( str.toLowerCase() == 'metric' ) {
                return Unit.Metric
            } else if ( str.toLowerCase() == 'imperial' ) {
                return Unit.Imperial
            }
        }
        Unit.Standard
    }
}
src/main/groovy/org/openweathermap/Weather.groovy
package org.openweathermap

import groovy.transform.CompileStatic

@CompileStatic
class Weather {
    Long id
    String main
    String description
    String icon
}
src/main/groovy/org/openweathermap/Sys.groovy
package org.openweathermap

import groovy.transform.CompileStatic

@CompileStatic
class Sys {
    Long id
    String type
    String message
    String country
    Integer sunrise
    Integer sunset
}
src/main/groovy/org/openweathermap/Wind.groovy
package org.openweathermap

import groovy.transform.CompileStatic

@CompileStatic
class Wind {
    BigDecimal speed
    BigDecimal deg
}

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 grails.plugins.rest.client.RestBuilder
import grails.plugins.rest.client.RestResponse
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic

@CompileStatic
class OpenweathermapService implements GrailsConfigurationAware {
    String appid
    String cityName
    String countryCode
    String openWeatherUrl

    @Override
    void setConfiguration(Config co) {
        openWeatherUrl = 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)
    }
    @CompileDynamic
    CurrentWeather currentWeather(Unit units = Unit.Standard) {
        currentWeather(cityName, countryCode, units)
    }


    @CompileDynamic
    CurrentWeather currentWeather(String cityName, String countryCode, Unit unit = Unit.Standard) {
        RestBuilder rest = new RestBuilder()
        String url = "${openWeatherUrl}/data/2.5/weather?q={city},{countryCode}&appid={appid}"
        Map params = [city: cityName, countryCode: countryCode, appid: appid]
        String unitParam = unitParameter(unit)
        if ( unitParam ) {
            params.units = unitParam
            url += "&units={units}"
        }
        RestResponse restResponse = rest.get(url) { (1)
            urlVariables params
        }

        if ( restResponse.statusCode.value() == 200 && restResponse.json ) {
            return OpenweathermapParser.currentWeatherFromJSONElement(restResponse.json) (2)
        }
        null (3)
    }

  /**
    * @return null if Standard Unit
    */
    String unitParameter(Unit unit)  {
        switch ( unit ) {
            case Unit.Metric:
                return 'metric'
            case Unit.Imperial:
                return 'imperial'
            default:
                return null
        }
    }
}
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 Parse Response into Groovy Classes

Create the next class to encapsulte the parsing of JSON payload into Groovy Classes.

grails-app/utils/org/openweathermap/OpenweathermapParser.groovy
package org.openweathermap

import groovy.transform.CompileStatic
import org.grails.web.json.JSONElement
import groovy.transform.CompileDynamic

@CompileStatic
class OpenweathermapParser  {

    @CompileDynamic
    static Coordinate coordinateFromJsonElement(JSONElement json) {
        Coordinate coordinate = new Coordinate()
        if ( json.long ) {
            coordinate.longitude = json.long as BigDecimal
        }
        if ( json.lat ) {
            coordinate.latitude = json.lat as BigDecimal
        }
        coordinate
    }

    @CompileDynamic
    static Main mainFromJsonElement(JSONElement json) {
        Main main = new Main()
        if ( json.temp ) {
            main.temperature = json.temp as BigDecimal
        }
        if ( json.pressure ) {
            main.pressure = json.pressure as BigDecimal
        }
        if ( json.humidity ) {
            main.humidity = json.humidity as Integer
        }
        if ( json.temp_min ) {
            main.tempMin = json.temp_min as BigDecimal
        }
        if ( json.temp_max ) {
            main.tempMax = json.temp_max as BigDecimal
        }
        if ( json.seaLevel ) {
            main.seaLevel = json.seaLevel as BigDecimal
        }
        if ( json.groundLevel ) {
            main.groundLevel = json.groundLevel as BigDecimal
        }
        main
    }

    @CompileDynamic
    static Wind windFromJsonElement(JSONElement json) {
        Wind wind = new Wind()
        if ( json.speed ) {
            wind.speed = json.speed as BigDecimal
        }
        if ( json.deg ) {
            wind.deg = json.deg as BigDecimal
        }
        wind
    }

    @CompileDynamic
    static Sys sysFromJsonElement(JSONElement json) {
        Sys sys = new Sys()
        if ( json.id ) {
            sys.id = json.id as Long
        }
        if ( json.type ) {
            sys.type = json.type
        }
        if ( json.message ) {
            sys.message = json.message
        }
        if ( json.country ) {
            sys.country = json.country
        }
        if ( json.sunrise ) {
            sys.sunrise = json.sunrise as Integer
        }
        if ( json.sunset ) {
            sys.sunset = json.sunset as Integer
        }
        sys
    }

    @CompileDynamic
    static Weather weatherFromJsonElement(JSONElement json) {
        Weather weather = new Weather()
        if ( json.id ) {
            weather.id = json.id as Long
        }
        if ( json.main ) {
            weather.main = json.main
        }
        if ( json.description ) {
            weather.description = json.description
        }
        if ( json.icon ) {
            weather.icon = json.icon
        }
        weather
    }

    @CompileDynamic
    static CurrentWeather currentWeatherFromJSONElement(JSONElement json) {
        CurrentWeather currentWeather = new CurrentWeather()

        if ( json.coord ) {
            currentWeather.coordinate = coordinateFromJsonElement(json.coord)
        }
        if ( json.main ) {
            currentWeather.main = mainFromJsonElement(json.main)
        }
        if ( json.wind ) {
            currentWeather.wind = windFromJsonElement(json.wind)
        }
        if ( json.clouds ) {
            currentWeather.clouds = new Clouds()
            if ( json.clouds.all ) {
                currentWeather.clouds.cloudiness = json.clouds.all as Integer
            }
        }
        if ( json.sys ) {
            currentWeather.sys = sysFromJsonElement(json.sys)
        }
        if ( json.id ) {
            currentWeather.cityId = json.id as Long
        }
        if ( json.base ) {
            currentWeather.base = json.base
        }
        if ( json.name ) {
            currentWeather.cityName = json.name
        }
        if ( json.cod ) {
            currentWeather.code = json.cod as Integer
        }
        if ( json.visibility ) {
            currentWeather.visibility = json.visibility
        }
        if ( json.dt ) {
            currentWeather.dt = json.dt as Integer
        }

        if ( json.weather ) {
            currentWeather.weatherList = []
            for ( Object obj : json.weather ) {
                Weather weather = weatherFromJsonElement(obj)
                currentWeather.weatherList << weather
            }
        }
        currentWeather
    }
}

3.5 Ersatz

To test the networking code, add a dependency to Ersatz

build.gradle
    testCompile 'com.stehno.ersatz:ersatz:1.5.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.ContentType
import com.stehno.ersatz.Encoders
import com.stehno.ersatz.ErsatzServer
import grails.testing.services.ServiceUnitTest
import spock.lang.Specification

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.openWeatherUrl = 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.ContentType
import com.stehno.ersatz.Encoders
import com.stehno.ersatz.ErsatzServer
import grails.testing.services.ServiceUnitTest
import spock.lang.Specification

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)
                    content([
                        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.openWeatherUrl = ersatz.httpUrl
        service.appid = appid

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

        then:
        currentWeather
        currentWeather.weatherList[0].main == 'Clouds'
        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.weatherList[0].id == 803
        currentWeather.weatherList[0].main == 'Clouds'
        currentWeather.weatherList[0].description == 'broken clouds'
        currentWeather.weatherList[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).
As of Ersatz version 1.5.0 the internal Undertow embedded server version is out of sync with Grails. If you use Undertow as your server for Grails you may have classpath collisions or odd errors. This can be avoided by using the "safe" (shadowed) version of the Ersatz dependency (see the Shadow Jar section of the Ersatz User Guide for more details).

3.6 Run Tests

To run the tests:

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

or

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

3.7 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.8 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.9 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

OCI sponsored the creation of this Guide. OCI offers several Grails services:

Free consultation

The OCI Grails Team includes Grails co-founders, Jeff Scott Brown and Graeme Rocher. Check our Grails courses and learn from the engineers who developed, matured and maintain Grails.

Grails OCI Team