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:
-
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 the Micronaut HTTP client library to our project. Add the next dependency:
compile "io.micronaut:micronaut-http-client"
If you are a windows user you need to have this in build.gradle
:
webdriverBinaries {
chromedriver {
version = '77.0.3865.40'
architecture = 'X86'
}
geckodriver '0.24.0'
}
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 JAVA classes
Create several JAVA POJOs (Plain Old Java Objects) to map the OpenWeatherMap
JSON response to classes.
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
}
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
}
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
}
package org.openweathermap;
import io.micronaut.core.annotation.Introspected;
@Introspected
public class Rain {
private Integer lastThreeHours;
public Rain() {
}
//getters and setters
}
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();
}
}
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
}
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
}
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:
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
openweather:
appid: 1f6c7d09a28f1ddccf70c06e2cb75ee4
cityName: London
countryCode: uk
3.4 Ersatz
To test the networking code, add a dependency to Ersatz
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.
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.
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:
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:
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.
<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.