Writing an OpenWeatherMap SDK for Android

In my last post, I introduced how to write a Java model class to model the OpenWeatherMap API. Specifically, it models the response from the API using the SDK documentation. Today, I’m writing a manager class to implement an SDK for accessing the API, again using the SDK documentation.

Let’s start with the basics. You generally need to sign up so that your app can access the API. OpenWeatherMap is no different. Once you have signed up, you will be given an API key. You should generate an API key for each and every mobile app that will use the API. I’d even go so far as to generate an API key for different versions. That way you can easily revoke access later.

Your mobile app needs to get this API key somehow. Most developers will just embed the API key in their code, and I’m going to do this as well. However, you could also store the API key in a JSON blob, for example in a JSON file that is served via Amazon S3. In this case, your mobile app would first download and decode the JSON file, then configure the manager.

You also need to be able to make HTTP calls. I use the okhttp library for this. It’s more friendly than the standard library and is available for both iOS and Android, making my life easier as I switch between platforms. You can also use the standard Android library (HttpURLConnection) or another library if you so desire. To add the okhttp library to your app, edit your app’s build.gradle file and add the following line:

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
        exclude group: 'com.android.support', module: 'support-annotations'
    })
    compile 'com.android.support:appcompat-v7:25.3.1'
    compile 'com.android.support.constraint:constraint-layout:1.0.2'
    compile 'com.google.code.gson:gson:2.8.0'
    compile 'com.squareup.okhttp3:okhttp:3.8.0'
    testCompile 'junit:junit:4.12'
}

Sync your workspace to download the new library. Now, let’s move on to the actual implementation. I like to write a “Manager” class. In this case, it will be called WeatherManager. It’s job is to hide the implementation details of the actual SDK call. The rest of my app will call the exposed API and the underlying details can change if they like. In addition, my WeatherManager API will throw a WeatherException – a standard exception method to hide the internal details. Here is my exception class:

package com.shellmonger.weatherapp;

/**
 * Exception for reporting problems in the Weather API
 */
public class WeatherException extends Exception {
    /**
     * Create a new weather exception object
     * @param message message
     * @param inner inner exception
     */
    public WeatherException(String message, Exception inner) {
        super(message, inner);
    }
}

My WeatherManager API is deceptively simple:

package com.shellmonger.weatherapp;

import com.shellmonger.weatherapp.models.WeatherResponse;

import android.location.Location;
import java.net.URL;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

/**
 * Manager for the OpenWeatherMap API
 */
public class WeatherManager {
    /**
     * The API Key for the Open Weather Map API.  Replace this with your own key
     */
    private final static String API_KEY = "f27b22f8035e3756d3e146ee7ae948ca";

    /**
     * The Base URL of the Open Weather Map API.
     */
    private final static String BASE_URL = "http://api.openweathermap.org/data/2.5/weather";

    /**
     * The HTTP Client implementation to use
     */
    private OkHttpClient httpClient;

    /**
     * Instantiate a new object for handling the OpenWeatherMap API - instantiates a
     * default OkHttpClient()
     */
    public WeatherManager() {
        httpClient = new OkHttpClient();
    }

    /**
     * Version of the constructor that allows the specification of a HTTP Client.  It must
     * be an OkHttpClient implementation
     * @param client The HTTP Client to use.
     */
    public WeatherManager(OkHttpClient client) {
        httpClient = client;
    }

    /**
     * Call the OpenWeatherMap API using the specified query.  You should never call this
     * directly - use one of the convenience methods above.
     *
     * @param query the query to send to the OpenWeatherMap API
     * @return A WeatherResponse object
     * @throws WeatherException if the request could not be completed
     */
    private WeatherResponse getWeather(String query) throws WeatherException {
        try {
            URL url = new URL(BASE_URL + "?" + query + "&appid=" + API_KEY);
            Request request = new Request.Builder().url(url).build();
            Response response = httpClient.newCall(request).execute();
            String jsonResponse = response.body().string();
            return WeatherResponse.fromJson(jsonResponse);
        } catch (Exception ex) {
            throw new WeatherException("HTTP GET failed", ex);
        }

    }
}

There are two constructors. The mobile app will call the default constructor. However, I may want to mock the HTTP Client in my unit tests. The getWeather() method uses the OkHttp library to do the HTTP GET call. The code here comes directly from the okhttp documentation.

I don’t want developers calling the getWeather() method directly. I want to provide helper methods that “do the right thing”. There is one helper method for each API type within the OpenWeatherMap API:

    /**
     * Retrieve the weather baed on a city name
     * @param cityName the name of the city
     * @return the WeatherResponse object
     * @throws WeatherException if the request could not be completed
     */
    public WeatherResponse getWeatherByCityName(String cityName) throws WeatherException {
        return getWeather("q=" + cityName);
    }

    /**
     * Retrieve the weather based on an internal city ID
     * @param id the city ID
     * @return the WeatherResponse object
     * @throws WeatherException if the request could not be completed
     */
    public WeatherResponse getWeatherByCityId(long id) throws WeatherException {
        return getWeather("id=" + id);
    }

    /**
     * Retrieve the weather by GPS coordinates
     * @param longitude The longitude
     * @param latitude The latitude
     * @return the WeatherResponse object
     * @throws WeatherException if the request could not be completed
     */
    public WeatherResponse getWeatherByGps(double longitude, double latitude) throws WeatherException {
        return getWeather("lat=" + latitude + "&lon=" + longitude);
    }

    /**
     * Retrieve the weather by GPS coordinates
     * @param location the GPS location
     * @return the WeatherResponse object
     * @throws WeatherException if the request could not be completed
     */
    public WeatherResponse getWeatherByGps(Location location) throws WeatherException {
        return getWeatherByGps(location.getLatitude(), location.getLongitude());
    }

    /**
     * Retrieve the weather for a ZIP code
     * @param zipcode the ZIP code to use in retrieval
     * @return the WeatherResponse object
     * @throws WeatherException if the request could not be completed
     */
    public WeatherResponse getWeatherByZipCode(String zipcode) throws WeatherException {
        return getWeather("zip=" + zipcode);
    }

The mobile app will call the appropriate method to retrieve the data from the cloud – if an error occurs, the call will throw a WeatherException.

Testing

As always, I want to unit test the API. This will include both online and offline tests. The online tests are easy enough:

package com.shellmonger.weatherapp;

import com.shellmonger.weatherapp.models.WeatherResponse;
import org.junit.Test;
import static org.junit.Assert.*;

public class WeatherManagerTest {
    @Test
    public void canGetWeatherByCityName() throws WeatherException {
        WeatherManager mgr = new WeatherManager();
        WeatherResponse response = mgr.getWeatherByCityName("London,UK");
        assertEquals(response.getResponseCode(), 200);
    }

    @Test
    public void canGetWeatherByCityId() throws WeatherException {
        WeatherManager mgr = new WeatherManager();
        WeatherResponse response = mgr.getWeatherByCityId(2172797);
        assertEquals(response.getResponseCode(), 200);
    }

    @Test
    public void canGetWeatherByGps() throws WeatherException {
        WeatherManager mgr = new WeatherManager();
        WeatherResponse response = mgr.getWeatherByGps(145.77, -16.92);
        assertEquals(response.getResponseCode(), 200);
    }

    @Test
    public void canGetWeatherByZipcode() throws WeatherException {
        WeatherManager mgr = new WeatherManager();
        WeatherResponse response = mgr.getWeatherByZipCode("98155");
        assertEquals(response.getResponseCode(), 200);
    }
}

When I was doing this, I found that certain values that I had assumed were integers in the model were in fact floating point, so I had to change some types. That also changed the tests for the model. This indicates that it is important to do online tests.

However, we can also do mock tests. This involves replacing the OkHttpClient with one that we generate, and allows us to test that the JSON we provide gets decoded properly when loaded via the HTTP client. We can set up a standard HTTP response by implementing an interceptor within the WeatherManagerTests.java file:

    class WeatherMockInterceptor implements Interceptor {
        private final MediaType MEDIA_JSON = MediaType.parse("application/json");

        @Override
        public Response intercept(Chain chain) throws IOException {
            String standardizedJson = "{\n" +
                    "  \"coord\": {\n" +
                    "    \"lon\": -0.13,\n" +
                    "    \"lat\": 51.51\n" +
                    "  },\n" +
                    "  \"weather\": [\n" +
                    "    {\n" +
                    "      \"id\": 300,\n" +
                    "      \"main\": \"Drizzle\",\n" +
                    "      \"description\": \"light intensity drizzle\",\n" +
                    "      \"icon\": \"09d\"\n" +
                    "    }\n" +
                    "  ],\n" +
                    "  \"base\": \"stations\",\n" +
                    "  \"main\": {\n" +
                    "    \"temp\": 280.32,\n" +
                    "    \"pressure\": 1012,\n" +
                    "    \"humidity\": 81,\n" +
                    "    \"temp_min\": 279.15,\n" +
                    "    \"temp_max\": 281.15\n" +
                    "  },\n" +
                    "  \"visibility\": 10000,\n" +
                    "  \"wind\": {\n" +
                    "    \"speed\": 4.1,\n" +
                    "    \"deg\": 80.001\n" +
                    "  },\n" +
                    "  \"clouds\": {\n" +
                    "    \"all\": 90\n" +
                    "  },\n" +
                    "  \"dt\": 1485789600,\n" +
                    "  \"sys\": {\n" +
                    "    \"type\": 1,\n" +
                    "    \"id\": 5091,\n" +
                    "    \"message\": 0.0103,\n" +
                    "    \"country\": \"GB\",\n" +
                    "    \"sunrise\": 1485762037,\n" +
                    "    \"sunset\": 1485794875\n" +
                    "  },\n" +
                    "  \"id\": 2643743,\n" +
                    "  \"name\": \"London\",\n" +
                    "  \"cod\": 200\n" +
                    "}";

            Response response = new Response.Builder()
                    .message("Intercepted")
                    .body(ResponseBody.create(MEDIA_JSON, standardizedJson))
                    .request(chain.request())
                    .protocol(Protocol.HTTP_2)
                    .code(200)
                    .build();

            return response;
        }
    }

This is boiler-plate code. The standardizedJson variable is the JSON that is required for the test. I can now create a new HTTP client (again, in the WeatherManagerTests.java):

    private OkHttpClient getClient() {
        return new OkHttpClient.Builder()
                .addInterceptor(new WeatherMockInterceptor())
                .build();
    }

This creates a standard OkHttpClient(), but adds the interceptor that I just implemented. Finally, I can run a test:

    @Test
    public void mockWeatherWorks() throws WeatherException {
        WeatherManager mgr = new WeatherManager(getClient());
        WeatherResponse response = mgr.getWeatherByCityName("London,UK");
        assertEquals(response.getResponseCode(), 200);

        assertEquals(51.51, response.getCoordinates().getLatitude(), 0.001);
        assertEquals(-0.13, response.getCoordinates().getLongitude(), 0.001);

        assertEquals(1, response.getWeatherConditions().length);
        assertEquals(300, response.getWeatherConditions()[0].getId());
        assertEquals("Drizzle", response.getWeatherConditions()[0].getShortDescription());
        assertEquals("light intensity drizzle", response.getWeatherConditions()[0].getLongDescription());
        assertEquals("09d", response.getWeatherConditions()[0].getIcon());

        assertEquals(280.32, response.getMain().getTemperature().getCurrentValue(), 0.001);
        assertEquals(279.15, response.getMain().getTemperature().getMinimumValue(), 0.001);
        assertEquals(281.15, response.getMain().getTemperature().getMaximumValue(), 0.001);
        assertEquals(1012.00, response.getMain().getPressure().getCurrentPressure(), 0.001);
        assertEquals(81.00, response.getMain().getHumidity(), 0.001);

        assertEquals(4.1, response.getWind().getSpeed(), 0.001);
        assertEquals(80.001, response.getWind().getDirection(), 0.001);

        assertEquals(90, response.getClouds().getCloudiness());

        assertEquals("GB", response.getSysInfo().getCountry());

        assertEquals(10000, response.getVisibility());
        assertEquals("London", response.getCityName());
        assertEquals(2643743, response.getCityId());
        assertEquals(200, response.getResponseCode());
    }

When I instantiate the WeatherManager(), I’m passing the modified OkHttpClient with the interceptor that doesn’t actually do the request – it just returns my JSON blob. I can now test the response for the actual values. I can’t do this on the “live network tests” because the data changes. The JSON I’m using to test the WeatherResponse() and the JSON in the interceptor are the same, so I can test the data the same way.

That’s it for this blog post. You can, as always, find the code on my GitHub Repository. In the next blog post, I’m going to look at some Android code for displaying the data in a basic manner.