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:
-
Download and unzip the source
or
-
Clone the Git repository:
git clone https://github.com/grails-guides/grails-mock-http-server.git
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 theinitial
folder.
To complete the guide, go to the initial
folder
-
cd
intograils-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:
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.
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.
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
}
package org.openweathermap
class Clouds {
Integer cloudiness
}
package org.openweathermap
import groovy.transform.CompileStatic
@CompileStatic
class Coordinate {
BigDecimal longitude
BigDecimal latitude
}
package org.openweathermap
import groovy.transform.CompileStatic
@CompileStatic
class Rain {
Integer lastThreeHours
}
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
}
}
package org.openweathermap
import groovy.transform.CompileStatic
@CompileStatic
class Weather {
Long id
String main
String description
String icon
}
package org.openweathermap
import groovy.transform.CompileStatic
@CompileStatic
class Sys {
Long id
String type
String message
String country
Integer sunrise
Integer sunset
}
package org.openweathermap
import groovy.transform.CompileStatic
@CompileStatic
class Wind {
BigDecimal speed
BigDecimal deg
}
3.3 Open Weather Service
Create the next service:
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
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.
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
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.
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.
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:
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:
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.
<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>
<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.
.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.