显示导航

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

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

Grails 指南存储库包含两个文件夹

  • initial 初始项目。通常是一个简单的 Grails 应用程序,带有一些其他代码,以便让你抢先一步。

  • complete 一个已完成的示例。它是完成指南中介绍的步骤并将这些更改应用于 initial 文件夹的结果。

若要完成本指南,请转到 initial 文件夹

  • cdgrails-guides/grails-mock-http-server/initial

并按照后续章节中的说明进行操作。

如果你cdgrails-guides/grails-mock-http-server/complete,则可以直接进入完成的示例

3 编写应用程序

第一步是将 HTTP 客户端库添加到我们的项目中。添加下一个依赖项

build.gradle
    compile "org.grails:grails-datastore-rest-client"

3.1 Open Weather Map

OpenWeatherMap 是一款 Web 应用程序,该应用程序提供了一个 API,允许您

获取您所在城市当天的天气、16 天的天气预报以及 5 天的每三小时天气预报。可以参考天气统计、图形和历史上的今天图表。交互式地图显示您所在位置周围的降水量、云层、大气压力、风向。

他们有一个免费计划,该计划允许您获取一个城市的天气数据当前数据

在您注册后,将收到一个 API 密钥。您需要一个 API 密钥才能与 Open Weather Map API 交互。

apiKey
API 密钥可能需要一段时间才能激活。

3.2 将响应解析到 Groovy 类中

创建多个 Groovy POGO(纯 Groovy 对象)来将OpenWeatherMap JSON 响应映射到类中。

src/main/groovy/org/openweathermap/CurrentWeather.groovy
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
}
src/main/groovy/org/openweathermap/Clouds.groovy
package org.openweathermap

class Clouds {
    Integer cloudiness
}
src/main/groovy/org/openweathermap/Coordinate.groovy
package org.openweathermap

import groovy.transform.CompileStatic

@CompileStatic
class Coordinate {
    BigDecimal longitude
    BigDecimal latitude

}
src/main/groovy/org/openweathermap/Rain.groovy
package org.openweathermap

import groovy.transform.CompileStatic

@CompileStatic
class Rain {
    Integer lastThreeHours
}
src/main/groovy/org/openweathermap/Unit.groovy
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
    }
}
src/main/groovy/org/openweathermap/Weather.groovy
package org.openweathermap

import groovy.transform.CompileStatic

@CompileStatic
class Weather {
    Long id
    String main
    String description
    String icon
}
src/main/groovy/org/openweathermap/Sys.groovy
package org.openweathermap

import groovy.transform.CompileStatic

@CompileStatic
class Sys {
    Long id
    String type
    String message
    String country
    Integer sunrise
    Integer sunset
}
src/main/groovy/org/openweathermap/Wind.groovy
package org.openweathermap

import groovy.transform.CompileStatic

@CompileStatic
class Wind {
    BigDecimal speed
    BigDecimal deg
}

3.3 Open Weather 服务

创建下一个服务

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

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

3.4 将响应解析到 Groovy 类中

创建下一个类将 JSON 有效负载的解析包装到 Groovy 类中。

grails-app/utils/org/openweathermap/OpenweathermapParser.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依赖项

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

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

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

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

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

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.8 标签库

创建一个标签库来帮助你封装一些渲染方面

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

创建下一个 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 的母公司

认识团队