显示导航

使用和测试第三方 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 如何完成指南

要开始操作,请执行以下操作

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 客户端库添加到我们的项目。添加下一个依赖项

build.gradle
    compile "io.micronaut:micronaut-http-client"

如果您是 Windows 用户,则需要在build.gradle中包含此部分

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 交互。

apiKey
API 密钥可能需要几分钟才能激活。

3.2 将响应解析为 JAVA 类

创建几个 JAVA POJO(普通旧 Java 对象),以将OpenWeatherMap JSON 响应映射到类。

src/main/java/org/openweathermap/CurrentWeather.java
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
}
src/main/java/org/openweathermap/Clouds.java
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
}
src/main/java/org/openweathermap/Coordinate.java
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
}
src/main/java/org/openweathermap/Rain.java
package org.openweathermap;

import io.micronaut.core.annotation.Introspected;

@Introspected
public class Rain {
    private Integer lastThreeHours;

    public Rain() {
    }
//getters and setters
}
src/main/java/org/openweathermap/Unit.java
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();
    }
}
src/main/java/org/openweathermap/Weather.java
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
}
src/main/java/org/openweathermap/Sys.java
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
}
src/main/java/org/openweathermap/Wind.java
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 服务

创建以下服务

grails-app/services/org/openweathermap/OpenweathermapService.groovy
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中定义它们

grails-app/conf/application.yml
openweather:
    appid: 1f6c7d09a28f1ddccf70c06e2cb75ee4
    cityName: London
    countryCode: uk

3.4 Ersatz

若要测试网络代码,请将依赖项添加到Ersatz

build.gradle
    testCompile 'com.stehno.ersatz:ersatz:2.0.0'

Ersatz Server 是一个用于测试 HTTP 客户端的“模拟”HTTP 服务器库。它允许配置服务器端请求/响应期望,以便您的客户端库可以进行真实的 HTTP 调用,并获得真正的预先配置的响应,而不是伪造的存根。

首先,实现一项测试,以验证当 REST API 返回401时(例如,当 API 密钥无效时),OpenweathermapService.currentWeather方法返回 null。

src/test/groovy/org/openweathermap/OpenweathermapServiceSpec.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 "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 类中。

src/test/groovy/org/openweathermap/OpenweathermapServiceSpec.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`。

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]
    }
}

在 `UrlMapping.groovy` 中将根 URL 映射到此控制器。

"/"(controller: 'home')`

3.7 标签库

创建一个标签库来帮助自己,封装一些渲染方面。

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.8 视图

创建下一个 GSP,将收集的天气信息以 HTML 页面形式呈现出来。

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>

添加下一个 CSS 片段以设置天气预报样式。

grails-app/assets/stylesheets/main.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 密钥,你将看到伦敦天气预测。

homepage

5 Grails 帮助

Object Computing, Inc. (OCI) 赞助了本指南的创建。提供各种咨询和支持服务。

OCI 是 Grails 的家

认识团队