使用和测试第三方 REST API
使用“mock”HTTP 库 Ersatz 来测试与 HTTP 请求相关的代码
作者:塞尔吉奥·德尔·阿莫
Grails 版本 3.3.1
1 Grails 培训
Grails 培训 - 由创建并积极维护 Grails 框架的人员开发和提供!
2 入门
在本指南中,你将创建一个 Grails 应用程序,它会使用第三方 REST API。此外,我们将使用一个“mock”HTTP 库来测试与该外部服务进行交互的代码。
2.1 所需条件
要完成本指南,你需要以下内容
-
一些时间
-
一个像样的文本编辑器或 IDE
-
已安装 JDK 1.7 或更高版本,且已适当地配置了
JAVA_HOME
2.2 如何完成本指南
若要开始,请执行以下操作
-
下载并解压源文件
或
-
克隆 Git 存储库
git clone https://github.com/grails-guides/grails-mock-http-server.git
Grails 指南存储库包含两个文件夹
-
initial
初始项目。通常是一个简单的 Grails 应用程序,带有一些其他代码,以便让你抢先一步。 -
complete
一个已完成的示例。它是完成指南中介绍的步骤并将这些更改应用于initial
文件夹的结果。
若要完成本指南,请转到 initial
文件夹
-
cd
到grails-guides/grails-mock-http-server/initial
并按照后续章节中的说明进行操作。
如果你cd 到grails-guides/grails-mock-http-server/complete ,则可以直接进入完成的示例 |
3 编写应用程序
第一步是将 HTTP 客户端库添加到我们的项目中。添加下一个依赖项
compile "org.grails:grails-datastore-rest-client"
3.1 Open Weather Map
OpenWeatherMap 是一款 Web 应用程序,该应用程序提供了一个 API,允许您
获取您所在城市当天的天气、16 天的天气预报以及 5 天的每三小时天气预报。可以参考天气统计、图形和历史上的今天图表。交互式地图显示您所在位置周围的降水量、云层、大气压力、风向。
他们有一个免费计划,该计划允许您获取一个城市的天气数据当前数据。
在您注册后,将收到一个 API 密钥。您需要一个 API 密钥才能与 Open Weather Map API 交互。
API 密钥可能需要一段时间才能激活。 |
3.2 将响应解析到 Groovy 类中
创建多个 Groovy POGO(纯 Groovy 对象)来将OpenWeatherMap
JSON 响应映射到类中。
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 服务
创建下一个服务
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 | 要获取当前天气,只需发出 GET 请求,提供城市名称、国家/地区代码和 API 密钥作为查询参数即可。 |
2 | 如果是 200 - OK - 响应,则将 JSON 数据解析到 Groovy 类中。 |
3 | 如果答案不是 200。例如 401;该方法将返回 null。 |
上一个服务使用多个配置参数。在application.yml
中定义它们
openweather:
appid: 1f6c7d09a28f1ddccf70c06e2cb75ee4
cityName: London
countryCode: uk
3.4 将响应解析到 Groovy 类中
创建下一个类将 JSON 有效负载的解析包装到 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
若要测试网络代码,请添加一个Ersatz依赖项
testCompile 'com.stehno.ersatz:ersatz:1.5.0'
Ersatz Server 是一个“模拟”HTTP 服务器库,用于测试 HTTP 客户端。它允许配置服务器端的请求/响应预期,以便您的客户端库进行实际的 HTTP 调用并获得真正的预先配置的响应,而不是伪造的存根。
首先,实现一个测试来验证当 REST API 返回401
时,OpenweathermapService.currentWeather
方法返回 null。例如,当 API 密钥无效时。
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 | 声明预期,即对OpenWeather 路径使用查询参数发出的 GET 请求。 |
2 | 声明要验证的条件,在这个示例中,我们希望验证仅命中一次端点。 |
3 | 让模拟服务器返回此测试的 401。 |
4 | Ersatz 启动一个嵌入的 Undertow 服务器,将网络请求根目录到此服务器,而不是 OpenWeather API 服务器。 |
5 | 验证 Ersatz 服务器条件。 |
6 | 记得停止服务器 |
接下来,测试服务器返回 200 和 JSON 负载时,JSON 负载是否正确解析为 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 | 声明一个响应编码器,使用 Ersatz 提供的编码器将 Map 转换为 application/json 内容。 |
2 | 将响应内容定义为 Map ,它将由定义的编码器(上面)转换为 JSON。 |
从 Ersatz 版本 1.5.0 开始,内部 Undertow 嵌入式服务器版本与 Grails 不同步。如果你对 Grails 使用 Undertow 作为你的服务器,你可能会遇到类路径冲突或奇怪的错误。可以使用 Ersatz 依赖项的“安全”(影子操作)版本避免此情况(更多详细信息,请参见 Ersatz 用户指南的 Shadow Jar 部分)。 |
3.6 运行测试
要运行测试
./grailsw
grails> test-app
grails> open test-report
或
./gradlew check
open build/reports/tests/index.html
3.7 将 URL 根目录到天气控制器
创建一个使用先前服务的 HomeController
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]
}
}
在 UrlMapping.groovy
中将 URL 根目录映射到此控制器
"/"(controller: 'home')`
3.8 标签库
创建一个标签库来帮助你封装一些渲染方面
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 视图
创建下一个 GSP,将收集的天气信息呈现为 HTML 页面。
<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>
添加 다음 CSS 代码段为天气预报设置样式。
.weatherBlock {
width: 150px;
height: 200px;
margin: 10px auto;
text-align: center;
border: 1px solid #c0d3db;
float: left;
}
4 运行应用程序
要运行应用程序,请使用 ./gradlew bootRun
命令,它将在端口 8080 上启动应用程序。
如果你在 application.yml
中设置有效的 API 密钥,你将看到伦敦天气预报。