Pollito Dev
January 18, 2024

Contract-Driven Development 7: We get weather forecast!

Posted on January 18, 2024  •  7 minutes  • 1455 words  • Other languages:  Español

First success scenario.

Check the github repo

This is a continuation of Contract-Driven Development 6: Using a better plugin .

Everything we’ll do here, you can find in in the github repo.

Spring City Explorer - Backend: Branch feature/cdd-7

Quick reminder

Back in Contract-Driven Development 3: Creation of contracts , we established a diagram defining the basic architecture of the system. In this blog, we finally achieve the selected white area. Diagram

Generating the client

1. Add the generation in the plugin

For this, we add the following execution in the openapi generator maven plugin, in the pom.xml

<execution>
    <id>generation for weatherstack</id>
    <goals>
        <goal>generate</goal>
    </goals>
    <configuration>
        <inputSpec>${project.basedir}/src/main/resources/openapi/feignclient/weatherstack.yaml</inputSpec>
        <generatorName>java</generatorName>
        <library>feign</library>
        <output>${project.build.directory}/generated-sources/openapi/</output>
        <apiPackage>com.weatherstack.api</apiPackage>
        <modelPackage>com.weatherstack.models</modelPackage>
        <configOptions>
            <feignClient>true</feignClient>
            <interfaceOnly>true</interfaceOnly>
            <useEnumCaseInsensitive>true</useEnumCaseInsensitive>
        </configOptions>
    </configuration>
</execution>

2. Configure the generated api interface

Being WeatherApi the generated interface in com.weatherstack.api, the standard way to configure it would be as follows:

@Configuration
@ComponentScans(
    value = {
      @ComponentScan(
          basePackages = {
            "com.weatherstack.api",
          })
    })
@RequiredArgsConstructor
public class WeatherApiConfig {

  private final WeatherProperties weatherProperties;

  @Bean
  public WeatherApi weatherApi() {
    return Feign.builder()
       .client(new OkHttpClient())
        .encoder(new GsonEncoder())
        .decoder(new GsonDecoder())
        .logger(new Slf4jLogger(WeatherApi.class))
        .logLevel(Logger.Level.FULL)
        .target(WeatherApi.class, weatherProperties.getBaseUrl());
  }
}

But when executing this that configuration, we see that some values are null on response:

class Weather {
    request: class Request {
        type: City
        query: Lisbon, Portugal
        language: en
        unit: m
    }
    location: class Location {
        name: Lisbon
        country: Portugal
        region: Lisboa
        lat: 38.717
        lon: -9.133
        timezoneId: null
        _localtime: null
        localtimeEpoch: null
        utcOffset: null
    }
    current: class Current {
        observationTime: null
        temperature: 18
        weatherCode: null
        weatherIcons: null
        weatherDescriptions: null
        windSpeed: null
        windDegree: null
        windDir: null
        pressure: 1005
        precip: 0.2
        humidity: 77
        cloudcover: 75
        feelslike: 18
        uvIndex: null
        visibility: 10
    }
}

If we compare that with the OAS in weatherstack.yaml, we see a pattern here: all the null values corresponds with keys in the OAS that are snake_case.

weatherstack OAS

For solving this, we have to create a custom response decoder.

3. Create a custom response decoder

public class WeatherResponseDecoder implements Decoder {

  @Override
  public Object decode(Response response, Type type) throws IOException, FeignException {
    try (BufferedReader reader =
        new BufferedReader(
            new InputStreamReader(response.body().asInputStream(), StandardCharsets.UTF_8))) {

      String responseBody = reader.lines().collect(Collectors.joining());

      WeatherStackError error = new Gson().fromJson(responseBody, WeatherStackError.class);
      if (error != null && Boolean.FALSE.equals(error.getSuccess())) {
        throw new WeatherException(error);
      }

      return new GsonBuilder()
          .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
          .create()
          .fromJson(responseBody.toString(), type);
    }
  }
}

A decoder consists in 3 parts:

  1. Response reader.
  2. Error checking.
    • If error exists, treat the error.
    • In this example, I throw a custom exception.
  3. Response decoder.
    • Notice here that instead of returning a plain new GsonDecoder(), we instead return a GsonBuilder() with a policy for snake case.

Don’t forget to set the custom decoder in the configuration.

@Configuration
@ComponentScans(
    value = {
      @ComponentScan(
          basePackages = {
            "com.weatherstack.api",
          })
    })
@RequiredArgsConstructor
public class WeatherApiConfig {

  private final WeatherProperties weatherProperties;

  @Bean
  public WeatherApi weatherApi() {
    return Feign.builder()
        .client(new OkHttpClient())
        .encoder(new GsonEncoder())
        .decoder(new WeatherResponseDecoder()) // <-- HERE
        .logger(new Slf4jLogger(WeatherApi.class))
        .logLevel(Logger.Level.FULL)
        .target(WeatherApi.class, weatherProperties.getBaseUrl());
  }
}

This decoder is almost perfect. When executing we get the following values:

class Weather {
    request: class Request {
        type: City
        query: Lisbon, Portugal
        language: en
        unit: m
    }
    location: class Location {
        name: Lisbon
        country: Portugal
        region: Lisboa
        lat: 38.717
        lon: -9.133
        timezoneId: Europe/Lisbon
        _localtime: null
        localtimeEpoch: 1705679640
        utcOffset: 0.0
    }
    current: class Current {
        observationTime: 03:54 PM
        temperature: 18
        weatherCode: 116
        weatherIcons: [
            https://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0002_sunny_intervals.png
        ]
        weatherDescriptions: [
            Partly cloudy
        ]
        windSpeed: 22
        windDegree: 330
        windDir: NNW
        pressure: 1005
        precip: 0.2
        humidity: 77
        cloudcover: 75
        feelslike: 18
        uvIndex: 3
        visibility: 10
    }
}

_localtime still null. And a new question arises: why is _localtime and not localtime? why the underscore?

4. Dealing with OpenAPI Generator reserved words

Here’s the list of reserved words in OpenAPI Generator .

If any of these words are used in the components schema in your specification yaml file, then when autogenerating, the field will have a lowercase at the beginning. This is our case with the field _localtime.

What are our options now?

  1. Accept that _localtime is gonna be null: Maybe even delete it from the yaml file
  2. Creating a custom deserealizer: This implies manual work. State how each field in the response is gonna be desearilze and mapped into the Java object. We are going with this one for the moment.
  3. OpenAPI has a supported vendor extensions section . We can check that sometime in the future.

5. Create a custom deserealizer

Lots of manual work stating “this value goes here”. I personally don’t like it much cause is very easy to make a typo, mix properties, or forget some of them. Nevertheless, is a good exercise and example about how to do it.

public class WeatherDeserializer implements JsonDeserializer<Weather> {
  @Override
  public Weather deserialize(
      JsonElement jsonElement, Type type, JsonDeserializationContext jsonDeserializationContext)
      throws JsonParseException {
    JsonObject jsonObject = jsonElement.getAsJsonObject();
    JsonObject requestObj = jsonObject.getAsJsonObject("request");
    JsonObject locationObj = jsonObject.getAsJsonObject("location");
    JsonObject currentObj = jsonObject.getAsJsonObject("current");

    return new Weather()
        .request(
            new Request()
                .type(LocationTypeEnum.fromValue(requestObj.get("type").getAsString()))
                .query(requestObj.get("query").getAsString())
                .language(requestObj.get("language").getAsString())
                .unit(UnitEnum.fromValue(requestObj.get("unit").getAsString())))
        .location(
            new Location()
                .name(locationObj.get("name").getAsString())
                .country(locationObj.get("country").getAsString())
                .region(locationObj.get("region").getAsString())
                .lat(locationObj.get("lat").getAsString())
                .lon(locationObj.get("lon").getAsString())
                .timezoneId(locationObj.get("timezone_id").getAsString())
                ._localtime(locationObj.get("localtime").getAsString())
                .localtimeEpoch(locationObj.get("localtime_epoch").getAsInt())
                .utcOffset(locationObj.get("utc_offset").getAsString()))
        .current(
            new Current()
                .observationTime(currentObj.get("observation_time").getAsString())
                .temperature(currentObj.get("temperature").getAsInt())
                .weatherCode(currentObj.get("weather_code").getAsInt())
                .weatherIcons(
                    Arrays.asList(
                        jsonDeserializationContext.deserialize(
                            currentObj.get("weather_icons"), String[].class)))
                .weatherDescriptions(
                    Arrays.asList(
                        jsonDeserializationContext.deserialize(
                            currentObj.get("weather_descriptions"), String[].class)))
                .windSpeed(currentObj.get("wind_speed").getAsInt())
                .windDegree(currentObj.get("wind_degree").getAsInt())
                .windDir(currentObj.get("wind_dir").getAsString())
                .pressure(currentObj.get("pressure").getAsInt())
                .precip(currentObj.get("precip").getAsFloat())
                .humidity(currentObj.get("humidity").getAsInt())
                .cloudcover(currentObj.get("cloudcover").getAsInt())
                .feelslike(currentObj.get("feelslike").getAsInt())
                .uvIndex(currentObj.get("uv_index").getAsInt())
                .visibility(currentObj.get("visibility").getAsInt()));
  }
}

Then, register it in the GsonBuilder, and we are good to go.

public class WeatherResponseDecoder implements Decoder {

  @Override
  public Object decode(Response response, Type type) throws IOException, FeignException {
    try (BufferedReader reader =
        new BufferedReader(
            new InputStreamReader(response.body().asInputStream(), StandardCharsets.UTF_8))) {

      String responseBody = reader.lines().collect(Collectors.joining());

      WeatherStackError error = new Gson().fromJson(responseBody, WeatherStackError.class);
      if (error != null && Boolean.FALSE.equals(error.getSuccess())) {
        throw new WeatherException(error);
      }

      return new GsonBuilder()
          .registerTypeAdapter(Weather.class, new WeatherDeserializer())
          .create()
          .fromJson(responseBody, type);
    }
  }
}

Now when executing, we have the proper response. First success! 🥳

class Weather {
    request: class Request {
        type: City
        query: Lisbon, Portugal
        language: en
        unit: m
    }
    location: class Location {
        name: Lisbon
        country: Portugal
        region: Lisboa
        lat: 38.717
        lon: -9.133
        timezoneId: Europe/Lisbon
        _localtime: 2024-11-28 13:01
        localtimeEpoch: 1705679640
        utcOffset: 0.0
    }
    current: class Current {
        observationTime: 03:54 PM
        temperature: 18
        weatherCode: 116
        weatherIcons: [
            https://cdn.worldweatheronline.com/images/wsymbols01_png_64/wsymbol_0002_sunny_intervals.png
        ]
        weatherDescriptions: [
            Partly cloudy
        ]
        windSpeed: 22
        windDegree: 330
        windDir: NNW
        pressure: 1005
        precip: 0.2
        humidity: 77
        cloudcover: 75
        feelslike: 18
        uvIndex: 3
        visibility: 10
    }
}

Misc changes

application.yml reads secret from enviroment variables

This is pretty straight foward and common practice for simple demo repos like this one: Add the enviroment variable in the run/debug configurations, and state in the application.yml that the value is there.

env var

client:
  weather:
    baseUrl: http://api.weatherstack.com
    secrets:
      key: ${WEATHER_API_KEY}

Created more tests

Some unit tests don’t hurt anyone.

Introduced faker to the tests

I got lazy here, so read what ChatGPT produced when I asked about faker in Java. Notice that I don’t use the default faker, instead I go for a fork that is also widely used in the bank.

Faker is a library commonly used in software testing, particularly for generating mock or dummy data. It’s available for various programming languages, including Java, and is a valuable tool for creating realistic, yet non-real, data sets for testing purposes.

Here’s why Faker is so useful:

It’s important to note that while Faker is excellent for generating a wide range of test data, it should not be used for generating data for benchmarks or load testing where specific data patterns or sizes are required. Also, remember that the reliability of your tests is as good as the quality of your test data - so while Faker is a great tool, it’s crucial to use it judiciously to ensure comprehensive testing.

Next steps

Hey, check me out!

You can find me here