diff --git a/src/main/scala/com.snowplowanalytics/weather/providers/openweather/Errors.scala b/src/main/scala/com.snowplowanalytics/weather/Errors.scala similarity index 97% rename from src/main/scala/com.snowplowanalytics/weather/providers/openweather/Errors.scala rename to src/main/scala/com.snowplowanalytics/weather/Errors.scala index 72d2a57..d949e5d 100644 --- a/src/main/scala/com.snowplowanalytics/weather/providers/openweather/Errors.scala +++ b/src/main/scala/com.snowplowanalytics/weather/Errors.scala @@ -10,7 +10,7 @@ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ -package com.snowplowanalytics.weather.providers.openweather +package com.snowplowanalytics.weather import io.circe.generic.JsonCodec diff --git a/src/main/scala/com.snowplowanalytics/weather/HttpTransport.scala b/src/main/scala/com.snowplowanalytics/weather/HttpTransport.scala new file mode 100644 index 0000000..d4e9e2d --- /dev/null +++ b/src/main/scala/com.snowplowanalytics/weather/HttpTransport.scala @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2015-2018 Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.weather + +// cats +import cats.effect.Sync +import cats.syntax.either._ + +// circe +import io.circe.{Decoder, Json} +import io.circe.parser.parse + +// hammock +import hammock.{Hammock, HttpResponse, Method, Status, Uri} +import hammock.jvm.Interpreter + +// This library +import Errors._ + +class HttpTransport[F[_]: Sync](apiHost: String, apiKey: String, ssl: Boolean = true) extends Transport[F] { + import HttpTransport._ + + private implicit val interpreter = Interpreter[F] + + def receive[W <: WeatherResponse: Decoder](request: WeatherRequest): F[Either[WeatherError, W]] = { + val scheme = if (ssl) "https" else "http" + val authority = Uri.Authority(None, Uri.Host.Other(apiHost), None) + val baseUri = Uri(Some(scheme), Some(authority)) + + val uri = request.constructQuery(baseUri, apiKey) + + Hammock + .request(Method.GET, uri, Map()) + .map(uri => processResponse(uri)) + .exec[F] + } +} + +object HttpTransport { + + /** + * Decode response case class from HttpResponse body + * + * @param response full HTTP response + * @return either error or decoded case class + */ + private def processResponse[A: Decoder](response: HttpResponse): Either[WeatherError, A] = + getResponseContent(response) + .flatMap(parseJson) + .flatMap(json => extractWeather(json)) + + /** + * Convert the response to string + * + * @param response full HTTP response + * @return either entity content of HTTP response or WeatherError (AuthorizationError / HTTPError) + */ + private def getResponseContent(response: HttpResponse): Either[WeatherError, String] = + response.status match { + case Status.OK => Right(response.entity.content.toString) + case Status.Unauthorized => Left(AuthorizationError) + case _ => Left(HTTPError(s"Request failed with status ${response.status.code}")) + } + + private def parseJson(content: String): Either[ParseError, Json] = + parse(content) + .leftMap(e => + ParseError( + s"OpenWeatherMap Error when trying to parse following json: \n$content\n\nMessage from the parser:\n ${e.message}")) + + /** + * Transform JSON into parseable format and try to extract specified response + * + * @param response response json + * @tparam W specific response case class from + * `com.snowplowanalytics.weather.providers.openweather.Responses` + * @return either weather error or response case class + */ + private[weather] def extractWeather[W: Decoder](response: Json): Either[WeatherError, W] = + response.as[W].leftFlatMap { _ => + response.as[ErrorResponse] match { + case Right(error) => Left(error) + case Left(_) => Left(ParseError(s"Could not extract ${Decoder[W].toString} from ${response.toString}")) + } + } +} diff --git a/src/main/scala/com.snowplowanalytics/weather/TimeoutHttpTransport.scala b/src/main/scala/com.snowplowanalytics/weather/TimeoutHttpTransport.scala new file mode 100644 index 0000000..af50474 --- /dev/null +++ b/src/main/scala/com.snowplowanalytics/weather/TimeoutHttpTransport.scala @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2015-2018 Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.weather + +// Scala +import scala.concurrent.ExecutionContext + +// cats +import cats.effect.{Concurrent, Timer} +import cats.syntax.functor._ + +// circe +import io.circe.Decoder + +// This library +import Errors.{TimeoutError, WeatherError} + +import scala.concurrent.duration.FiniteDuration + +class TimeoutHttpTransport[F[_]: Concurrent](apiHost: String, + apiKey: String, + requestTimeout: FiniteDuration, + ssl: Boolean = true)(implicit val executionContext: ExecutionContext) + extends HttpTransport[F](apiHost, apiKey, ssl) { + + import TimeoutHttpTransport._ + + private val timer: Timer[F] = Timer.derive[F] + + override def receive[W <: WeatherResponse: Decoder](request: WeatherRequest): F[Either[Errors.WeatherError, W]] = + timeout(super.receive(request), requestTimeout, timer) + +} + +object TimeoutHttpTransport { + + /** + * Apply timeout to the `operation` parameter. To be replaced by Concurrent[F].timeout in cats-effect 1.0.0 + * + * @param operation The operation we want to run with a timeout + * @param duration Duration to timeout after + * @return either Left(TimeoutError) or a result of the operation, wrapped in F + */ + private def timeout[F[_]: Concurrent, W](operation: F[Either[WeatherError, W]], + duration: FiniteDuration, + timer: Timer[F]): F[Either[WeatherError, W]] = + Concurrent[F] + .race(operation, timer.sleep(duration)) + .map { + case Left(value) => value + case Right(_) => Left(TimeoutError(s"OpenWeatherMap request timed out after ${duration.toSeconds} seconds")) + } +} diff --git a/src/main/scala/com.snowplowanalytics/weather/Transport.scala b/src/main/scala/com.snowplowanalytics/weather/Transport.scala new file mode 100644 index 0000000..6c1c95c --- /dev/null +++ b/src/main/scala/com.snowplowanalytics/weather/Transport.scala @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2015-2018 Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.weather + +// circe +import io.circe.Decoder + +// This library +import Errors.WeatherError + +trait Transport[F[_]] { + + /** + * Main client logic for Request => Response function, + * where Response is wrapped in tparam `F` + * + * @param request request built by client method + * @tparam W type of weather response to extract + * @return extracted either error or weather wrapped in `F` + */ + def receive[W <: WeatherResponse: Decoder](request: WeatherRequest): F[Either[WeatherError, W]] + +} diff --git a/src/main/scala/com.snowplowanalytics/weather/package.scala b/src/main/scala/com.snowplowanalytics/weather/package.scala index 69f5e05..7eda82f 100644 --- a/src/main/scala/com.snowplowanalytics/weather/package.scala +++ b/src/main/scala/com.snowplowanalytics/weather/package.scala @@ -12,9 +12,16 @@ */ package com.snowplowanalytics +import hammock.Uri + package object weather { type Timestamp = Int type Day = Int // 0:00:00 timestamp of day + trait WeatherRequest { + def constructQuery(baseUri: Uri, apiKey: String): Uri + } + trait WeatherResponse + } diff --git a/src/main/scala/com.snowplowanalytics/weather/providers/openweather/Client.scala b/src/main/scala/com.snowplowanalytics/weather/providers/openweather/Client.scala deleted file mode 100644 index a2168f8..0000000 --- a/src/main/scala/com.snowplowanalytics/weather/providers/openweather/Client.scala +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright (c) 2015-2018 Snowplow Analytics Ltd. All rights reserved. - * - * This program is licensed to you under the Apache License Version 2.0, - * and you may not use this file except in compliance with the Apache License Version 2.0. - * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the Apache License Version 2.0 is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. - */ -package com.snowplowanalytics.weather -package providers.openweather - -// Scala -import scala.language.higherKinds - -// cats -import cats.syntax.either._ - -// circe -import io.circe.{Decoder, Json} - -// This library -import Errors._ -import Implicits._ -import Responses._ -import Requests._ - -/** - * Base client trait with defines client methods such as `historyById`, `forecastByName` - * common for subclasses - * - * @tparam F effect type - */ -trait Client[F[_]] { - - /** - * Main client logic for Request => Response function, - * where Response is wrapped in tparam `F` - * - * @param owmRequest request built by client method - * @tparam W type of weather response to extract - * @return extracted either error or weather wrapped in `F` - */ - def receive[W <: OwmResponse: Decoder](owmRequest: OwmRequest): F[Either[WeatherError, W]] - - /** - * Get historical data by city id - * Docs: http://bugs.openweathermap.org/projects/api/wiki/Api_2_5_history#By-city-id - * - * @param id id of the city - * @param start start (unix time, UTC) - * @param end end (unix time, UTC) - * @param cnt count of returned data - * @param measure one of predefined `Api.Measures` to constrain accuracy - * @return either error or history wrapped in `F` - */ - def historyById(id: Int, - start: OptArg[Int] = None, - end: OptArg[Int] = None, - cnt: OptArg[Int] = None, - measure: OptArg[Api.Measures.Value] = None): F[Either[WeatherError, History]] = { - val request = OwmHistoryRequest("city", - Map("id" -> id.toString) - ++ ("start" -> start) - ++ ("end" -> end) - ++ ("cnt" -> cnt) - ++ ("type" -> measure.map(_.toString))) - receive(request) - } - - /** - * Get historical data by city name - * Docs: http://bugs.openweathermap.org/projects/api/wiki/Api_2_5_history#By-city-name - * - * @param name name of the city - * @param country optional two-letter code - * @param start start (unix time, UTC) - * @param end end (unix time, UTC) - * @param cnt count of returned data - * @param measure one of predefined `Api.Measures` to constrain accuracy - * @return either error or history wrapped in `F` - */ - def historyByName(name: String, - country: OptArg[String] = None, - start: OptArg[Int] = None, - end: OptArg[Int] = None, - cnt: OptArg[Int] = None, - measure: OptArg[Api.Measures.Value] = None): F[Either[WeatherError, History]] = { - val query = name + country.map("," + _).getOrElse("") - val request = OwmHistoryRequest("city", - Map("q" -> query) - ++ ("start" -> start) - ++ ("end" -> end) - ++ ("cnt" -> cnt) - ++ ("type" -> measure.map(_.toString))) - receive(request) - } - - /** - * Get historical data by city name - * Docs: http://bugs.openweathermap.org/projects/api/wiki/Api_2_5_history#By-city-name - * - * @param lat lattitude - * @param lon longitude - * @param start start (unix time, UTC) - * @param end end (unix time, UTC) - * @param cnt count of returned data - * @param measure one of predefined `Api.Measures` to constrain accuracy - * @return either error or history wrapped in `F` - */ - def historyByCoords(lat: Float, - lon: Float, - start: OptArg[Int] = None, - end: OptArg[Int] = None, - cnt: OptArg[Int] = None, - measure: OptArg[Api.Measures.Value] = None): F[Either[WeatherError, History]] = { - val request = OwmHistoryRequest("city", - Map("lat" -> lat.toString, "lon" -> lon.toString) - ++ ("start" -> start) - ++ ("end" -> end) - ++ ("cnt" -> cnt) - ++ ("type" -> measure.map(_.toString))) - receive(request) - } - - /** - * Get forecast data by city id - * Docs: http://bugs.openweathermap.org/projects/api/wiki/Api_2_5_forecast#Get-forecast-by-city-id - * - * @param id id of the city - * @return either error or forecast wrapped in `F` - */ - def forecastById(id: Int, cnt: OptArg[Int] = None): F[Either[WeatherError, Forecast]] = - receive(OwmForecastRequest("city", Map("id" -> id.toString, "cnt" -> cnt.toString))) - - /** - * Get 5 day/3 hour forecast data by city name - * Docs: http://openweathermap.org/forecast#5days - * - * @param name name of the city - * @param country optional two-letter code - * @param cnt count of returned data - * @return either error or forecast wrapped in `F` - */ - def forecastByName(name: String, - country: OptArg[String], - cnt: OptArg[Int] = None): F[Either[WeatherError, Forecast]] = { - val query = name + country.map("," + _).getOrElse("") - receive(OwmForecastRequest("city", Map("q" -> query, "cnt" -> cnt.toString))) - } - - /** - * Get forecast data for coordinates - * - * @param lat latitude - * @param lon longitude - * @return either error or forecast wrapped in `F` - */ - def forecastByCoords(lat: Float, lon: Float, cnt: OptArg[Int] = None): F[Either[WeatherError, Weather]] = - receive(OwmForecastRequest("weather", Map("lat" -> lat.toString, "lon" -> lon.toString, "cnt" -> cnt.toString))) - - /** - * Get current weather data by city id - * Docs: http://bugs.openweathermap.org/projects/api/wiki/Api_2_5_weather#3-By-city-ID - * - * @param id id of the city - * @return either error or current weather wrapped in `F` - */ - def currentById(id: Int): F[Either[WeatherError, Current]] = - receive(OwmCurrentRequest("weather", Map("id" -> id.toString))) - - /** - * Get 5 day/3 hour forecast data by city name - * Docs: http://openweathermap.org/forecast#5days - * - * @param name name of the city - * @param country optional two-letter code - * @param cnt count of returned data - * @return either error or forecast wrapped in `F` - */ - def currentByName(name: String, - country: OptArg[String], - cnt: OptArg[Int] = None): F[Either[WeatherError, Current]] = { - val query = name + country.map("," + _).getOrElse("") - receive(OwmCurrentRequest("weather", Map("q" -> query, "cnt" -> cnt.toString))) - } - - /** - * Get current weather data by city coordinates - * Docs: http://bugs.openweathermap.org/projects/api/wiki/Api_2_5_weather#2-By-geographic-coordinate - * - * @param lat latitude - * @param lon longitude - * @return either error or current weather wrapped in `F` - */ - def currentByCoords(lat: Float, lon: Float): F[Either[WeatherError, Current]] = - receive(OwmCurrentRequest("weather", Map("lat" -> lat.toString, "lon" -> lon.toString))) - - /** - * Transform JSON into parseable format and try to extract specified response - * - * @param response response json - * @tparam W specific response case class from - * `com.snowplowanalytics.weather.providers.openweather.Responses` - * @return either weather error or response case class - */ - protected[openweather] def extractWeather[W: Decoder](response: Json): Either[WeatherError, W] = - response.as[W].leftFlatMap { _ => - response.as[ErrorResponse] match { - case Right(error) => Left(error) - case Left(_) => Left(ParseError(s"Could not extract ${Decoder[W].toString} from ${response.toString}")) - } - } -} diff --git a/src/main/scala/com.snowplowanalytics/weather/providers/openweather/OpenWeatherMap.scala b/src/main/scala/com.snowplowanalytics/weather/providers/openweather/OpenWeatherMap.scala new file mode 100644 index 0000000..071d446 --- /dev/null +++ b/src/main/scala/com.snowplowanalytics/weather/providers/openweather/OpenWeatherMap.scala @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2015-2018 Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package com.snowplowanalytics.weather +package providers.openweather + +// Scala +import scala.concurrent.duration._ +import scala.concurrent.ExecutionContext + +// cats +import cats.effect.{Concurrent, Sync} +import cats.syntax.flatMap._ +import cats.syntax.functor._ +import cats.syntax.monadError._ + +// LruMap +import com.snowplowanalytics.lrumap.LruMap + +// This library +import CacheUtils.CacheKey +import Errors.{InvalidConfigurationError, WeatherError} +import Responses.History + +object OpenWeatherMap { + + /** + * Create `OwmClient` with specified underlying `Transport` + * @param transport instance of `Transport` which will do the actual sending of data + */ + private[openweather] def basicClient[F[_]: Sync](transport: Transport[F]): OwmClient[F] = + new OwmClient[F](transport) + + /** + * Create `OwmClient` with `HttpTransport` instance + * @param appId API key from OpenWeatherMap + * @param apiHost URL to the OpenWeatherMap API endpoints + * @param ssl whether to use https + */ + def basicClient[F[_]: Sync](appId: String, + apiHost: String = "api.openweathermap.org", + ssl: Boolean = true): OwmClient[F] = + basicClient(new HttpTransport[F](apiHost, appId, ssl)) + + /** + * Create `OwmCacheClient` with `TimeoutHttpTransport` instance + * @param appId API key from OpenWeatherMap + * @param cacheSize amount of history requests storing in cache + * it's better to store whole OWM packet (5000/50000/150000) + * plus some space for errors (~1%) + * @param geoPrecision nth part of 1 to which latitude and longitude will be rounded + * stored in cache. For eg. coordinate 45.678 will be rounded to + * values 46.0, 45.5, 45.7, 45.78 by geoPrecision 1,2,10,100 respectively + * geoPrecision 1 will give ~60km infelicity if worst case; 2 ~30km etc + * @param host URL to the OpenWeatherMap API endpoints + * @param timeout time after which active request will be considered failed + * @param ssl whether to use https + */ + def cacheClient[F[_]: Concurrent]( + appId: String, + cacheSize: Int = 5100, + geoPrecision: Int = 1, + host: String = "history.openweathermap.org", + timeout: FiniteDuration = 5.seconds, + ssl: Boolean = true)(implicit executionContext: ExecutionContext): F[OwmCacheClient[F]] = + cacheClient(cacheSize, geoPrecision, new TimeoutHttpTransport[F](host, appId, timeout, ssl)) + + /** + * Create `OwmCacheClient` with specified underlying `Transport` + * @param cacheSize amount of history requests storing in cache + * it's better to store whole OWM packet (5000/50000/150000) + * plus some space for errors (~1%) + * @param geoPrecision nth part of 1 to which latitude and longitude will be rounded + * stored in cache. For eg. coordinate 45.678 will be rounded to + * values 46.0, 45.5, 45.7, 45.78 by geoPrecision 1,2,10,100 respectively + * geoPrecision 1 will give ~60km infelicity if worst case; 2 ~30km etc + * @param transport instance of `Transport` which will do the actual sending of data + */ + private[openweather] def cacheClient[F[_]: Concurrent](cacheSize: Int, + geoPrecision: Int, + transport: Transport[F]): F[OwmCacheClient[F]] = + Concurrent[F].unit + .ensure(InvalidConfigurationError("geoPrecision must be greater than 0"))(_ => geoPrecision > 0) + .ensure(InvalidConfigurationError("cacheSize must be greater than 0"))(_ => cacheSize > 0) + .flatMap(_ => LruMap.create[F, CacheKey, Either[WeatherError, History]](cacheSize)) + .map(cache => new OwmCacheClient(cache, geoPrecision, transport)) + +} diff --git a/src/main/scala/com.snowplowanalytics/weather/providers/openweather/OwmCacheClient.scala b/src/main/scala/com.snowplowanalytics/weather/providers/openweather/OwmCacheClient.scala index 7600018..22584f8 100644 --- a/src/main/scala/com.snowplowanalytics/weather/providers/openweather/OwmCacheClient.scala +++ b/src/main/scala/com.snowplowanalytics/weather/providers/openweather/OwmCacheClient.scala @@ -13,18 +13,10 @@ package com.snowplowanalytics.weather package providers.openweather -// Scala -import scala.concurrent.ExecutionContext -import scala.concurrent.duration._ - // cats import cats.syntax.functor._ import cats.syntax.flatMap._ -import cats.syntax.monadError._ -import cats.effect.{Concurrent, Timer} - -// circe -import io.circe.Decoder +import cats.effect.Concurrent // LruMap import com.snowplowanalytics.lrumap.LruMap @@ -34,7 +26,6 @@ import org.joda.time.DateTime // This library import Errors._ -import Requests._ import Responses._ import CacheUtils.{CacheKey, Position} @@ -42,20 +33,12 @@ import CacheUtils.{CacheKey, Position} * Blocking OpenWeatherMap client with history (only) cache * Uses AsyncOwmClient under the hood, has the same method set, but also uses timeouts * - * WARNING. This client uses pro.openweathermap.org for data access, - * It will not work with free OWM licenses. + * WARNING. Caching will not work with free OWM licenses - history plan is required */ -class OwmCacheClient[F[_]: Concurrent] private ( - cache: LruMap[F, CacheKey, Either[WeatherError, History]], - val geoPrecision: Int, - client: OwmClient[F], - val requestTimeout: FiniteDuration)(implicit val executionContext: ExecutionContext) - extends Client[F] { - - private val timer: Timer[F] = Timer.derive[F] - - def receive[W <: OwmResponse: Decoder](request: OwmRequest): F[Either[WeatherError, W]] = - timeout(client.receive(request), requestTimeout) +class OwmCacheClient[F[_]: Concurrent] private[openweather] (cache: LruMap[F, CacheKey, Either[WeatherError, History]], + val geoPrecision: Int, + transport: Transport[F]) + extends OwmClient[F](transport) { /** * Search history in cache and if not found request and await it from server @@ -118,65 +101,4 @@ class OwmCacheClient[F[_]: Concurrent] private ( timestamp: Int): Either[WeatherError, Weather] = history.right.flatMap(_.pickCloseIn(timestamp)) - /** - * Apply timeout to the `operation` parameter. To be replaced by Concurrent[F].timeout in cats-effect 1.0.0 - * - * @param operation The operation we want to run with a timeout - * @param duration Duration to timeout after - * @return either Left(TimeoutError) or a result of the operation, wrapped in F - */ - private def timeout[W](operation: F[Either[WeatherError, W]], duration: FiniteDuration): F[Either[WeatherError, W]] = - Concurrent[F] - .race(operation, timer.sleep(duration)) - .map { - case Left(value) => value - case Right(_) => Left(TimeoutError(s"OpenWeatherMap request timed out after ${duration.toSeconds} seconds")) - } -} - -/** - * Companion object for OwmClient with default transport based on akka http - */ -object OwmCacheClient { - - /** - * Create OwmCacheClient with singleton-placed (in-scala-weather) akka system - * @param appId API key from OpenWeatherMap - * @param cacheSize amount of history requests storing in cache - * it's better to store whole OWM packet (5000/50000/150000) - * plus some space for errors (~1%) - * @param geoPrecision nth part of 1 to which latitude and longitude will be rounded - * stored in cache. For eg. coordinate 45.678 will be rounded to - * values 46.0, 45.5, 45.7, 45.78 by geoPrecision 1,2,10,100 respectively - * geoPrecision 1 will give ~60km infelicity if worst case; 2 ~30km etc - * @param host URL to the OpenWeatherMap API endpoints - * @param timeout time after which active request will be considered failed - */ - def apply[F[_]: Concurrent]( - appId: String, - cacheSize: Int = 5100, - geoPrecision: Int = 1, - host: String = "pro.openweathermap.org", - timeout: FiniteDuration = 5.seconds)(implicit executionContext: ExecutionContext): F[OwmCacheClient[F]] = - apply(cacheSize, geoPrecision, OwmClient(appId, host), timeout) - - /** - * Create OwmCacheClient with underlying client - * @param cacheSize amount of history requests storing in cache - * it's better to store whole OWM packet (5000/50000/150000) - * plus some space for errors (~1%) - * @param geoPrecision nth part of 1 to which latitude and longitude will be rounded - * stored in cache. For eg. coordinate 45.678 will be rounded to - * values 46.0, 45.5, 45.7, 45.78 by geoPrecision 1,2,10,100 respectively - * geoPrecision 1 will give ~60km infelicity if worst case; 2 ~30km etc - * @param client instance of `OwmClient` which will do all underlying work - * @param timeout time after which active request will be considered failed - */ - def apply[F[_]: Concurrent](cacheSize: Int, geoPrecision: Int, client: OwmClient[F], timeout: FiniteDuration)( - implicit executionContext: ExecutionContext): F[OwmCacheClient[F]] = - Concurrent[F].unit - .ensure(InvalidConfigurationError("geoPrecision must be greater than 0"))(_ => geoPrecision > 0) - .ensure(InvalidConfigurationError("cacheSize must be greater than 0"))(_ => cacheSize > 0) - .flatMap(_ => LruMap.create[F, CacheKey, Either[WeatherError, History]](cacheSize)) - .map(cache => new OwmCacheClient(cache, geoPrecision, client, timeout)) } diff --git a/src/main/scala/com.snowplowanalytics/weather/providers/openweather/OwmClient.scala b/src/main/scala/com.snowplowanalytics/weather/providers/openweather/OwmClient.scala index 562534d..2e30acf 100644 --- a/src/main/scala/com.snowplowanalytics/weather/providers/openweather/OwmClient.scala +++ b/src/main/scala/com.snowplowanalytics/weather/providers/openweather/OwmClient.scala @@ -13,75 +13,171 @@ package com.snowplowanalytics.weather package providers.openweather -// cats -import cats.effect.Sync -import cats.implicits._ - -// circe -import io.circe.parser.parse -import io.circe.{Decoder, Json} - -// hammock -import hammock.{Hammock, HttpResponse, Method, Status, Uri} -import hammock.jvm.Interpreter - // This library import Errors._ +import Implicits._ import Responses._ import Requests._ /** - * Asynchronous OpenWeatherMap client + * Non-caching OpenWeatherMap client * - * @param appId API key + * @tparam F effect type */ -case class OwmClient[F[_]: Sync](appId: String, apiHost: String = "api.openweathermap.org", ssl: Boolean = false) - extends Client[F] { +class OwmClient[F[_]] private[openweather] (transport: Transport[F]) { - private implicit val interpreter = Interpreter[F] + /** + * Get historical data by city id + * Docs: http://bugs.openweathermap.org/projects/api/wiki/Api_2_5_history#By-city-id + * + * @param id id of the city + * @param start start (unix time, UTC) + * @param end end (unix time, UTC) + * @param cnt count of returned data + * @param measure one of predefined `Api.Measures` to constrain accuracy + * @return either error or history wrapped in `F` + */ + def historyById(id: Int, + start: OptArg[Int] = None, + end: OptArg[Int] = None, + cnt: OptArg[Int] = None, + measure: OptArg[Api.Measures.Value] = None): F[Either[WeatherError, History]] = { + val request = OwmHistoryRequest("city", + Map("id" -> id.toString) + ++ ("start" -> start) + ++ ("end" -> end) + ++ ("cnt" -> cnt) + ++ ("type" -> measure.map(_.toString))) + transport.receive(request) + } - def receive[W <: OwmResponse: Decoder](request: OwmRequest): F[Either[WeatherError, W]] = { + /** + * Get historical data by city name + * Docs: http://bugs.openweathermap.org/projects/api/wiki/Api_2_5_history#By-city-name + * + * @param name name of the city + * @param country optional two-letter code + * @param start start (unix time, UTC) + * @param end end (unix time, UTC) + * @param cnt count of returned data + * @param measure one of predefined `Api.Measures` to constrain accuracy + * @return either error or history wrapped in `F` + */ + def historyByName(name: String, + country: OptArg[String] = None, + start: OptArg[Int] = None, + end: OptArg[Int] = None, + cnt: OptArg[Int] = None, + measure: OptArg[Api.Measures.Value] = None): F[Either[WeatherError, History]] = { + val query = name + country.map("," + _).getOrElse("") + val request = OwmHistoryRequest("city", + Map("q" -> query) + ++ ("start" -> start) + ++ ("end" -> end) + ++ ("cnt" -> cnt) + ++ ("type" -> measure.map(_.toString))) + transport.receive(request) + } - val scheme = if (ssl) "https" else "http" - val authority = Uri.Authority(None, Uri.Host.Other(apiHost), None) - val baseUri = Uri(Some(scheme), Some(authority)) + /** + * Get historical data by city name + * Docs: http://bugs.openweathermap.org/projects/api/wiki/Api_2_5_history#By-city-name + * + * @param lat lattitude + * @param lon longitude + * @param start start (unix time, UTC) + * @param end end (unix time, UTC) + * @param cnt count of returned data + * @param measure one of predefined `Api.Measures` to constrain accuracy + * @return either error or history wrapped in `F` + */ + def historyByCoords(lat: Float, + lon: Float, + start: OptArg[Int] = None, + end: OptArg[Int] = None, + cnt: OptArg[Int] = None, + measure: OptArg[Api.Measures.Value] = None): F[Either[WeatherError, History]] = { + val request = OwmHistoryRequest("city", + Map("lat" -> lat.toString, "lon" -> lon.toString) + ++ ("start" -> start) + ++ ("end" -> end) + ++ ("cnt" -> cnt) + ++ ("type" -> measure.map(_.toString))) + transport.receive(request) + } - val uri = request.constructQuery(baseUri, appId) + /** + * Get forecast data by city id + * Docs: http://bugs.openweathermap.org/projects/api/wiki/Api_2_5_forecast#Get-forecast-by-city-id + * + * @param id id of the city + * @return either error or forecast wrapped in `F` + */ + def forecastById(id: Int, cnt: OptArg[Int] = None): F[Either[WeatherError, Forecast]] = + transport.receive(OwmForecastRequest("city", Map("id" -> id.toString, "cnt" -> cnt.toString))) - Hammock - .request(Method.GET, uri, Map()) - .map(uri => processResponse(uri)) - .exec[F] + /** + * Get 5 day/3 hour forecast data by city name + * Docs: http://openweathermap.org/forecast#5days + * + * @param name name of the city + * @param country optional two-letter code + * @param cnt count of returned data + * @return either error or forecast wrapped in `F` + */ + def forecastByName(name: String, + country: OptArg[String], + cnt: OptArg[Int] = None): F[Either[WeatherError, Forecast]] = { + val query = name + country.map("," + _).getOrElse("") + transport.receive(OwmForecastRequest("city", Map("q" -> query, "cnt" -> cnt.toString))) } /** - * Decode response case class from HttpResponse body + * Get forecast data for coordinates + * + * @param lat latitude + * @param lon longitude + * @return either error or forecast wrapped in `F` + */ + def forecastByCoords(lat: Float, lon: Float, cnt: OptArg[Int] = None): F[Either[WeatherError, Weather]] = + transport.receive( + OwmForecastRequest("weather", Map("lat" -> lat.toString, "lon" -> lon.toString, "cnt" -> cnt.toString))) + + /** + * Get current weather data by city id + * Docs: http://bugs.openweathermap.org/projects/api/wiki/Api_2_5_weather#3-By-city-ID * - * @param response full HTTP response - * @return either error or decoded case class + * @param id id of the city + * @return either error or current weather wrapped in `F` */ - private def processResponse[A: Decoder](response: HttpResponse): Either[WeatherError, A] = - getResponseContent(response) - .flatMap(parseJson) - .flatMap(json => extractWeather(json)) + def currentById(id: Int): F[Either[WeatherError, Current]] = + transport.receive(OwmCurrentRequest("weather", Map("id" -> id.toString))) /** - * Convert the response to string + * Get 5 day/3 hour forecast data by city name + * Docs: http://openweathermap.org/forecast#5days * - * @param response full HTTP response - * @return either entity content of HTTP response or WeatherError (AuthorizationError / HTTPError) + * @param name name of the city + * @param country optional two-letter code + * @param cnt count of returned data + * @return either error or forecast wrapped in `F` */ - private def getResponseContent(response: HttpResponse): Either[WeatherError, String] = - response.status match { - case Status.OK => Right(response.entity.content.toString) - case Status.Unauthorized => Left(AuthorizationError) - case _ => Left(HTTPError(s"Request failed with status ${response.status.code}")) - } + def currentByName(name: String, + country: OptArg[String], + cnt: OptArg[Int] = None): F[Either[WeatherError, Current]] = { + val query = name + country.map("," + _).getOrElse("") + transport.receive(OwmCurrentRequest("weather", Map("q" -> query, "cnt" -> cnt.toString))) + } - private def parseJson(content: String): Either[ParseError, Json] = - parse(content) - .leftMap(e => - ParseError( - s"OpenWeatherMap Error when trying to parse following json: \n$content\n\nMessage from the parser:\n ${e.message}")) + /** + * Get current weather data by city coordinates + * Docs: http://bugs.openweathermap.org/projects/api/wiki/Api_2_5_weather#2-By-geographic-coordinate + * + * @param lat latitude + * @param lon longitude + * @return either error or current weather wrapped in `F` + */ + def currentByCoords(lat: Float, lon: Float): F[Either[WeatherError, Current]] = + transport.receive(OwmCurrentRequest("weather", Map("lat" -> lat.toString, "lon" -> lon.toString))) } diff --git a/src/main/scala/com.snowplowanalytics/weather/providers/openweather/Requests.scala b/src/main/scala/com.snowplowanalytics/weather/providers/openweather/Requests.scala index e3a0d8b..fc7c5a7 100644 --- a/src/main/scala/com.snowplowanalytics/weather/providers/openweather/Requests.scala +++ b/src/main/scala/com.snowplowanalytics/weather/providers/openweather/Requests.scala @@ -15,11 +15,10 @@ package com.snowplowanalytics.weather.providers.openweather import cats.data.NonEmptyList import hammock.Uri -private[weather] object Requests { +// This library +import com.snowplowanalytics.weather.WeatherRequest - sealed trait WeatherRequest { - def constructQuery(baseUri: Uri, apiKey: String): Uri - } +private[weather] object Requests { sealed trait OwmRequest extends WeatherRequest { val endpoint: Option[String] diff --git a/src/main/scala/com.snowplowanalytics/weather/providers/openweather/Responses.scala b/src/main/scala/com.snowplowanalytics/weather/providers/openweather/Responses.scala index 242142e..51ab528 100644 --- a/src/main/scala/com.snowplowanalytics/weather/providers/openweather/Responses.scala +++ b/src/main/scala/com.snowplowanalytics/weather/providers/openweather/Responses.scala @@ -23,7 +23,7 @@ import Errors._ * Case classes used for extracting data from JSON */ object Responses { - sealed abstract trait OwmResponse + sealed trait OwmResponse extends WeatherResponse // RESPONSES diff --git a/src/test/scala/com.snowplowanalytics.weather/providers/openweather/CacheSpec.scala b/src/test/scala/com.snowplowanalytics.weather/providers/openweather/CacheSpec.scala index f4248a2..5922fbf 100644 --- a/src/test/scala/com.snowplowanalytics.weather/providers/openweather/CacheSpec.scala +++ b/src/test/scala/com.snowplowanalytics.weather/providers/openweather/CacheSpec.scala @@ -13,9 +13,6 @@ package com.snowplowanalytics.weather package providers.openweather -// scala -import scala.concurrent.duration._ - // cats import cats.effect.IO import cats.syntax.either._ @@ -52,74 +49,74 @@ class CacheSpec(implicit val ec: ExecutionEnv) extends Specification with Mockit IO.pure(TimeoutError("java.util.concurrent.TimeoutException: Futures timed out after [1 second]").asLeft) def e1 = { - val asyncClient = mock[OwmClient[IO]].defaultReturn(emptyHistoryResponse) + val transport = mock[Transport[IO]].defaultReturn(emptyHistoryResponse) val action = for { - client <- OwmCacheClient(2, 1, asyncClient, 5.seconds) + client <- OpenWeatherMap.cacheClient(2, 1, transport) _ <- client.getCachedOrRequest(4.44f, 3.33f, 100) _ <- client.getCachedOrRequest(4.44f, 3.33f, 100) _ <- client.getCachedOrRequest(4.44f, 3.33f, 100) } yield () action.unsafeRunSync() - there.was(1.times(asyncClient).receive(any[OwmRequest])(any())) + there.was(1.times(transport).receive(any[OwmRequest])(any())) } def e2 = { - val asyncClient = mock[OwmClient[IO]] - asyncClient + val transport = mock[TimeoutHttpTransport[IO]] + transport .receive[History](any[OwmRequest])(any()) .returns(timeoutErrorResponse) .thenReturn(emptyHistoryResponse) val action = for { - client <- OwmCacheClient(2, 1, asyncClient, 5.seconds) + client <- OpenWeatherMap.cacheClient(2, 1, transport) _ <- client.getCachedOrRequest(4.44f, 3.33f, 100) _ <- client.getCachedOrRequest(4.44f, 3.33f, 100) } yield () action.unsafeRunSync() - there.was(2.times(asyncClient).receive(any[OwmRequest])(any())) + there.was(2.times(transport).receive(any[OwmRequest])(any())) } def e3 = { - val asyncClient = mock[OwmClient[IO]].defaultReturn(emptyHistoryResponse) + val transport = mock[Transport[IO]].defaultReturn(emptyHistoryResponse) val action = for { - client <- OwmCacheClient(2, 1, asyncClient, 5.seconds) + client <- OpenWeatherMap.cacheClient(2, 1, transport) _ <- client.getCachedOrRequest(4.44f, 3.33f, 100) _ <- client.getCachedOrRequest(6.44f, 3.33f, 100) _ <- client.getCachedOrRequest(8.44f, 3.33f, 100) _ <- client.getCachedOrRequest(4.44f, 3.33f, 100) } yield () action.unsafeRunSync() - there.was(4.times(asyncClient).receive(any[OwmRequest])(any())) + there.was(4.times(transport).receive(any[OwmRequest])(any())) } def e4 = { - val asyncClient = mock[OwmClient[IO]].defaultReturn(emptyHistoryResponse) + val transport = mock[Transport[IO]].defaultReturn(emptyHistoryResponse) val action = for { - client <- OwmCacheClient(10, 1, asyncClient, 5.seconds) + client <- OpenWeatherMap.cacheClient(10, 1, transport) _ <- client.getCachedOrRequest(10.4f, 32.1f, 1447070440) // Nov 9 12:00:40 2015 GMT _ <- client.getCachedOrRequest(10.1f, 32.312f, 1447063607) // Nov 9 10:06:47 2015 GMT _ <- client.getCachedOrRequest(10.2f, 32.4f, 1447096857) // Nov 9 19:20:57 2015 GMT } yield () action.unsafeRunSync() - there.was(1.times(asyncClient).receive(any[OwmRequest])(any())) + there.was(1.times(transport).receive(any[OwmRequest])(any())) } def e5 = { - val asyncClient = mock[OwmClient[IO]].defaultReturn(emptyHistoryResponse) + val transport = mock[Transport[IO]].defaultReturn(emptyHistoryResponse) val action = for { - client <- OwmCacheClient(10, 2, asyncClient, 5.seconds) + client <- OpenWeatherMap.cacheClient(10, 2, transport) _ <- client.getCachedOrRequest(10.8f, 32.1f, 1447070440) // Nov 9 12:00:40 2015 GMT _ <- client.getCachedOrRequest(10.1f, 32.312f, 1447063607) // Nov 9 10:06:47 2015 GMT _ <- client.getCachedOrRequest(10.2f, 32.4f, 1447096857) // Nov 9 19:20:57 2015 GMT } yield () action.unsafeRunSync() - there.was(2.times(asyncClient).receive(any[OwmRequest])(any())) + there.was(2.times(transport).receive(any[OwmRequest])(any())) } def e6 = - OwmCacheClient[IO]("KEY", geoPrecision = 0).unsafeRunSync() must throwA[InvalidConfigurationError] + OpenWeatherMap.cacheClient[IO]("KEY", geoPrecision = 0).unsafeRunSync() must throwA[InvalidConfigurationError] def e7 = - OwmCacheClient[IO]("KEY", cacheSize = -1).unsafeRunSync() must throwA[InvalidConfigurationError] + OpenWeatherMap.cacheClient[IO]("KEY", cacheSize = 0).unsafeRunSync() must throwA[InvalidConfigurationError] } diff --git a/src/test/scala/com.snowplowanalytics.weather/providers/openweather/ExtractSpec.scala b/src/test/scala/com.snowplowanalytics.weather/providers/openweather/ExtractSpec.scala index a29854b..026743b 100644 --- a/src/test/scala/com.snowplowanalytics.weather/providers/openweather/ExtractSpec.scala +++ b/src/test/scala/com.snowplowanalytics.weather/providers/openweather/ExtractSpec.scala @@ -10,27 +10,21 @@ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ -package com.snowplowanalytics.weather -package providers.openweather +package com.snowplowanalytics.weather.providers.openweather // Scala import scala.io.Source -// cats -import cats.Id -import cats.syntax.either._ - // specs2 import org.specs2.Specification // circe -import io.circe.Decoder import io.circe.parser.parse // This library -import Errors._ +import com.snowplowanalytics.weather.Errors._ +import com.snowplowanalytics.weather.HttpTransport import Responses._ -import Requests.OwmRequest class ExtractSpec extends Specification { def is = s2""" @@ -49,43 +43,39 @@ class ExtractSpec extends Specification { extract history from error JSON $e6 """ - private val dummyClient = new Client[Id] { - def receive[W <: OwmResponse: Decoder](owmRequest: OwmRequest) = ??? - } - def e1 = { val weather = parse(Source.fromURL(getClass.getResource("/history.json")).mkString) - .flatMap(dummyClient.extractWeather[History]) + .flatMap(HttpTransport.extractWeather[History]) weather must beRight } def e2 = { val weather = parse(Source.fromURL(getClass.getResource("/history-empty.json")).mkString) - .flatMap(dummyClient.extractWeather[History]) + .flatMap(HttpTransport.extractWeather[History]) weather.map(_.list.length) must beRight(0) } def e3 = { val weather = parse(Source.fromURL(getClass.getResource("/current.json")).mkString) - .flatMap(dummyClient.extractWeather[Current]) + .flatMap(HttpTransport.extractWeather[Current]) weather.map(_.main.humidity) must beRight(62) } def e4 = { val weather = parse(Source.fromURL(getClass.getResource("/forecast.json")).mkString) - .flatMap(dummyClient.extractWeather[Forecast]) + .flatMap(HttpTransport.extractWeather[Forecast]) weather.map(_.cod) must beRight("200") } def e5 = { val weather = parse(Source.fromURL(getClass.getResource("/empty.json")).mkString) - .flatMap(dummyClient.extractWeather[History]) + .flatMap(HttpTransport.extractWeather[History]) weather.map(_.cod) must beLeft } def e6 = { val weather = parse(Source.fromURL(getClass.getResource("/nodata.json")).mkString) - .flatMap(dummyClient.extractWeather[History]) + .flatMap(HttpTransport.extractWeather[History]) weather.map(_.cod) must beLeft(ErrorResponse(Some("404"), "no data")) } } diff --git a/src/test/scala/com.snowplowanalytics.weather/providers/openweather/ClientSpec.scala b/src/test/scala/com.snowplowanalytics.weather/providers/openweather/OwmClientSpec.scala similarity index 73% rename from src/test/scala/com.snowplowanalytics.weather/providers/openweather/ClientSpec.scala rename to src/test/scala/com.snowplowanalytics.weather/providers/openweather/OwmClientSpec.scala index 648edb9..8fb88fa 100644 --- a/src/test/scala/com.snowplowanalytics.weather/providers/openweather/ClientSpec.scala +++ b/src/test/scala/com.snowplowanalytics.weather/providers/openweather/OwmClientSpec.scala @@ -18,6 +18,10 @@ import org.joda.time.DateTime // cats import cats.effect.IO +import cats.syntax.either._ + +// circe +import io.circe.Decoder // tests import org.specs2.concurrent.ExecutionEnv @@ -26,10 +30,11 @@ import org.specs2.mock.Mockito import org.mockito.ArgumentMatchers.{eq => eqTo} // this library +import Errors.WeatherError import Requests.OwmHistoryRequest import Responses.History -class ClientSpec(implicit val ec: ExecutionEnv) extends Specification with Mockito { +class OwmClientSpec(implicit val ec: ExecutionEnv) extends Specification with Mockito { def is = s2""" OWM Client API test @@ -37,11 +42,12 @@ class ClientSpec(implicit val ec: ExecutionEnv) extends Specification with Mocki Implicits for DateTime work as expected (without imports) $e1 """ - val emptyHistoryResponse = IO.pure(Right(History(BigInt(100), "0", List()))) + val emptyHistoryResponse = IO.pure(History(BigInt(100), "0", List()).asRight[WeatherError]) def e1 = { - val client = spy(new OwmClient[IO]("KEY")) - doReturn(emptyHistoryResponse).when(client).receive(any[OwmHistoryRequest])(any()) + val transport = mock[Transport[IO]] + transport.receive(any[WeatherRequest])(any[Decoder[WeatherResponse]]).returns(emptyHistoryResponse) + val client = OpenWeatherMap.basicClient[IO](transport) val expectedRequest = OwmHistoryRequest( "city", Map( @@ -51,7 +57,7 @@ class ClientSpec(implicit val ec: ExecutionEnv) extends Specification with Mocki ) ) client.historyByCoords(0.00f, 0.00f, DateTime.parse("2015-12-11T02:12:41.000+07:00")) - there.was(1.times(client).receive(eqTo(expectedRequest))(any())) + there.was(1.times(transport).receive(eqTo(expectedRequest))(any())) } } diff --git a/src/test/scala/com.snowplowanalytics.weather/providers/openweather/ServerSpec.scala b/src/test/scala/com.snowplowanalytics.weather/providers/openweather/ServerSpec.scala index 571d371..b37929e 100644 --- a/src/test/scala/com.snowplowanalytics.weather/providers/openweather/ServerSpec.scala +++ b/src/test/scala/com.snowplowanalytics.weather/providers/openweather/ServerSpec.scala @@ -51,8 +51,8 @@ class ServerSpec extends Specification with ScalaCheck with ExecutionEnvironment """ val host = "history.openweathermap.org" - val client = OwmClient[IO](owmKey.get, host) - val sslClient = OwmClient[IO](owmKey.get, host, ssl = true) + val client = OpenWeatherMap.basicClient[IO](owmKey.get, host) + val sslClient = OpenWeatherMap.basicClient[IO](owmKey.get, host, ssl = true) def testCities(cities: Vector[Position], client: OwmClient[IO]) = forAll(genPredefinedPosition(cities), genLastWeekTimeStamp) { (position: Position, timestamp: Timestamp) => @@ -67,7 +67,7 @@ class ServerSpec extends Specification with ScalaCheck with ExecutionEnvironment def e2 = testCities(TestData.randomCities, client).set(maxSize = 15, minTestsOk = 15) def e3 = { - val client = OwmClient[IO]("INVALID-KEY", host) + val client = OpenWeatherMap.basicClient[IO]("INVALID-KEY", host) val result = client.historyById(1).unsafeRunTimed(5.seconds) result must beSome result.get must beLeft(AuthorizationError) diff --git a/src/test/scala/com.snowplowanalytics.weather/providers/openweather/TimeCacheSpec.scala b/src/test/scala/com.snowplowanalytics.weather/providers/openweather/TimeCacheSpec.scala index 11c5ef3..a1ea690 100644 --- a/src/test/scala/com.snowplowanalytics.weather/providers/openweather/TimeCacheSpec.scala +++ b/src/test/scala/com.snowplowanalytics.weather/providers/openweather/TimeCacheSpec.scala @@ -13,9 +13,6 @@ package com.snowplowanalytics.weather package providers.openweather -// scala -import scala.concurrent.duration._ - // cats import cats.effect.IO @@ -88,13 +85,13 @@ class TimeCacheSpec(implicit val ec: ExecutionEnv) extends Specification with Mo "end" -> "1450051200" // "2015-12-14T00:00:00.000+00:00" ) ) - val asyncClient = mock[OwmClient[IO]].defaultReturn(emptyHistoryResponse) + val transport = mock[Transport[IO]].defaultReturn(emptyHistoryResponse) val action = for { - client <- OwmCacheClient(2, 1, asyncClient, 5.seconds) + client <- OpenWeatherMap.cacheClient(2, 1, transport) _ <- client.getCachedOrRequest(4.44f, 3.33f, newDayInKranoyarsk) } yield () action.unsafeRunSync() - there.was(1.times(asyncClient).receive(eqTo(expectedRequest))(any())) + there.was(1.times(transport).receive(eqTo(expectedRequest))(any())) } }