使用和测试第三方 REST API
针对处理 HTTP 请求的代码使用 Ersatz(一个“模拟”HTTP 库),以进行测试
作者: Sergio del Amo
Grails 版本 4.0.1
1 Grails 培训
Grails 培训 - 由创建并积极维护 Grails 框架的人员开发并提供!.
2 入门
在本指南中,你将创建一个 Grails 应用程序,该程序使用第三方 REST API。此外,我们将使用“模拟”HTTP 库来测试与该外部服务进行交互的代码。
2.1 你需要什么
要完成本指南,你需要以下内容
-
时间
-
一个合适的文本编辑器或 IDE
-
安装了 JDK 1.8 或更高版本,并正确配置了
JAVA_HOME
2.2 如何完成指南
要开始操作,请执行以下操作
-
下载并解压源代码
或
-
克隆 Git 代码库
git clone https://github.com/grails-guides/grails-mock-http-server.git
Grails 指南代码库包含两个文件夹
-
initial
初始项目。通常是一个简单的 Grails 应用程序,其中包含一些附加代码,让你可以快速上手。 -
complete
一个完整的示例。这是按照指南提供的步骤操作,并将这些更改应用于initial
文件夹的结果。
要完成指南,请转到 initial
文件夹
-
在
grails-guides/grails-mock-http-server/initial
中使用cd
然后按照下一部分中的说明进行操作。
如果您cd 进入grails-guides/grails-mock-http-server/complete ,您可以直接转到完整示例 |
3 编写应用程序
第一步是将 Micronaut HTTP 客户端库添加到我们的项目。添加下一个依赖项
compile "io.micronaut:micronaut-http-client"
如果您是 Windows 用户,则需要在build.gradle
中包含此部分
webdriverBinaries {
chromedriver {
version = '77.0.3865.40'
architecture = 'X86'
}
geckodriver '0.24.0'
}
3.1 Open Weather Map
OpenWeatherMap 是一款 Web 应用程序,它提供了一个允许您执行以下操作的 API
获取当前天气状况、16 天内的每日预报以及您所在城市 5 天内的每 3 小时更新一次的预报。对于您的参考,我们提供了有用的统计数据、图表和这一天的历史图表。交互式地图展示降水、云层、气压,以及您周围的风向。
他们有一个免费套餐,允许您获取某个城市的当前天气数据。
注册后,您将获得一个 API 密钥。您需要一个 API 密钥才能与 Open Weather Map API 交互。
API 密钥可能需要几分钟才能激活。 |
3.2 将响应解析为 JAVA 类
创建几个 JAVA POJO(普通旧 Java 对象),以将OpenWeatherMap
JSON 响应映射到类。
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 服务
创建以下服务
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 | 若要获取当前天气,请发出一个提供城市名称、国家代码和 API 密钥(作为查询参数)的 GET 请求。 |
2 | 如果收到 200 - 正常 - 响应,将 JSON 数据解析为 Groovy 类。 |
3 | 如果答案不是 200。例如,401;方法将返回 null。 |
前一个服务使用几个配置参数。请在application.yml
中定义它们
openweather:
appid: 1f6c7d09a28f1ddccf70c06e2cb75ee4
cityName: London
countryCode: uk
3.4 Ersatz
若要测试网络代码,请将依赖项添加到Ersatz
testCompile 'com.stehno.ersatz:ersatz:2.0.0'
Ersatz Server 是一个用于测试 HTTP 客户端的“模拟”HTTP 服务器库。它允许配置服务器端请求/响应期望,以便您的客户端库可以进行真实的 HTTP 调用,并获得真正的预先配置的响应,而不是伪造的存根。
首先,实现一项测试,以验证当 REST API 返回401
时(例如,当 API 密钥无效时),OpenweathermapService.currentWeather
方法返回 null。
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 | 声明期望,即带有查询参数的到OpenWeather 路径的 GET 请求。 |
2 | 声明要验证的条件,在本示例中,我们希望验证端点只被命中一次。 |
3 | 告诉模拟服务器为本次测试返回 401。 |
4 | Ersatz 启动一个嵌入式 Undertow 服务器,将网络请求根目录设定为此服务器,而不是 OpenWeather API 服务器。 |
5 | 验证Ersatz服务器状态。 |
6 | 请务必停止服务器。 |
接下来,测试当服务器返回 200 及一个 JSON 有效负载时,是否将 JSON 有效负载正确解析到 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 | 声明响应编码器,使用 Ersatz 提供的编码器将 `Map` 转换为 `application/json` 内容。 |
2 | 定义响应内容为一个 `Map`,该 `Map` 将由已定义的编码器(上方)转换为 JSON。 |
http://stehno.com/ersatz/guide/#shadow_jar[Ersatz 用户指南的 Shadow Jar 部分]: Ersatz 使用的 Undertow 嵌入式版本会导致某些也使用 Undertow 的服务器框架(例如 Grails 和 Spring 启动)出现问题。如果你在使用标准 jar 分发时遇到错误,请尝试使用安全分发,它是一个 Shadow Jar,其中包括 Undertow 库及其在 jar 中重新打包的 JBoss 依赖项。_
3.5 运行测试
要运行测试
./grailsw
grails> test-app
grails> open test-report
或
./gradlew check
open build/reports/tests/index.html
3.6 根 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.7 标签库
创建一个标签库来帮助自己,封装一些渲染方面。
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 视图
创建下一个 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 密钥,你将看到伦敦天气预测。