org.kegbot.api.KegbotApiImpl.java Source code

Java tutorial

Introduction

Here is the source code for org.kegbot.api.KegbotApiImpl.java

Source

/*
 * Copyright 2014 Bevbot LLC <info@bevbot.com>
 *
 * This file is part of the Kegtab package from the Kegbot project. For
 * more information on Kegtab or Kegbot, see <http://kegbot.org/>.
 *
 * Kegtab is free software: you can redistribute it and/or modify it under
 * the terms of the GNU General Public License as published by the Free
 * Software Foundation, version 2.
 *
 * Kegtab is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
 * more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with Kegtab. If not, see <http://www.gnu.org/licenses/>.
 */
package org.kegbot.api;

import android.content.Context;
import android.os.SystemClock;
import android.util.Log;

import com.google.common.base.Charsets;
import com.google.common.base.Joiner;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.io.ByteStreams;
import com.google.protobuf.GeneratedMessage;
import com.google.protobuf.Message.Builder;
import com.squareup.okhttp.MediaType;
import com.squareup.okhttp.OkHttpClient;
import com.squareup.okhttp.Request;
import com.squareup.okhttp.RequestBody;
import com.squareup.okhttp.Response;
import com.squareup.okhttp.ResponseBody;

import org.apache.http.NameValuePair;
import org.codehaus.jackson.JsonNode;
import org.codehaus.jackson.JsonParseException;
import org.codehaus.jackson.map.JsonMappingException;
import org.codehaus.jackson.map.ObjectMapper;
import org.kegbot.app.KegbotApplication;
import org.kegbot.app.config.AppConfiguration;
import org.kegbot.app.util.TimeSeries;
import org.kegbot.app.util.Utils;
import org.kegbot.app.util.Version;
import org.kegbot.backend.Backend;
import org.kegbot.backend.BackendException;
import org.kegbot.proto.Api.RecordTemperatureRequest;
import org.kegbot.proto.Models;
import org.kegbot.proto.Models.AuthenticationToken;
import org.kegbot.proto.Models.Controller;
import org.kegbot.proto.Models.Drink;
import org.kegbot.proto.Models.FlowMeter;
import org.kegbot.proto.Models.FlowToggle;
import org.kegbot.proto.Models.Image;
import org.kegbot.proto.Models.Keg;
import org.kegbot.proto.Models.KegTap;
import org.kegbot.proto.Models.Session;
import org.kegbot.proto.Models.SoundEvent;
import org.kegbot.proto.Models.SystemEvent;
import org.kegbot.proto.Models.ThermoLog;
import org.kegbot.proto.Models.User;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.CookieHandler;
import java.net.CookieManager;
import java.net.URLEncoder;
import java.security.SecureRandom;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import javax.annotation.Nullable;

public class KegbotApiImpl implements Backend {

    private static final String TAG = KegbotApiImpl.class.getSimpleName();

    private static final byte[] HYPHENS = { '-', '-' };
    private static final byte[] CRLF = { '\r', '\n' };

    private final AppConfiguration mConfig;
    private final CookieManager mCookieManager;
    private final OkHttpClient mClient;
    private final String mUserAgent;

    public KegbotApiImpl(AppConfiguration config, String userAgent) {
        mConfig = config;
        mCookieManager = new CookieManager();
        mClient = new OkHttpClient();
        mClient.setCookieHandler(mCookieManager);
        CookieHandler.setDefault(mCookieManager);
        mUserAgent = userAgent;
    }

    public static KegbotApiImpl fromContext(Context context) {
        final AppConfiguration config = KegbotApplication.get(context).getConfig();
        final String userAgent = Utils.getUserAgent(context);
        return new KegbotApiImpl(config, userAgent);
    }

    private static String getUrlParamsString(Map<String, String> params) {
        if (params == null || params.isEmpty()) {
            return "";
        }

        final List<String> parts = Lists.newArrayList();
        for (Map.Entry<String, String> param : params.entrySet()) {
            try {
                parts.add(String.format("%s=%s", URLEncoder.encode(param.getKey(), "utf-8"),
                        URLEncoder.encode(param.getValue(), "utf-8")));
            } catch (UnsupportedEncodingException e) {
                throw new RuntimeException(e);
            }
        }
        return Joiner.on('&').join(parts);
    }

    private static RequestBody formBody(Map<String, String> params) {
        return RequestBody.create(MediaType.parse("application/x-www-form-urlencoded"),
                getUrlParamsString(params).getBytes());
    }

    private static String getBoundary() {
        return String.format("-------MultiPart%s", new SecureRandom().nextLong());
    }

    /** Builds a multi-part form body. */
    private static RequestBody formBody(Map<String, String> params, Map<String, File> files)
            throws KegbotApiException {
        final String boundary = getBoundary();
        final byte[] boundaryBytes = boundary.getBytes();

        final ByteArrayOutputStream bos = new ByteArrayOutputStream();

        final byte[] outputBytes;
        try {
            // Form data.
            for (final Map.Entry<String, String> param : params.entrySet()) {
                bos.write(HYPHENS);
                bos.write(boundaryBytes);
                bos.write(CRLF);
                bos.write(String.format("Content-Disposition: form-data; name=\"%s\"", param.getKey()).getBytes());
                bos.write(CRLF);
                bos.write(CRLF);
                bos.write(param.getValue().getBytes());
                bos.write(CRLF);
            }

            // Files
            for (final Map.Entry<String, File> entry : files.entrySet()) {
                final String entityName = entry.getKey();
                final File file = entry.getValue();

                bos.write(HYPHENS);
                bos.write(boundaryBytes);
                bos.write(CRLF);
                bos.write(String.format("Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"", entityName,
                        file.getName()).getBytes());
                bos.write(CRLF);
                bos.write(CRLF);

                final FileInputStream fis = new FileInputStream(file);
                try {
                    ByteStreams.copy(fis, bos);
                } finally {
                    fis.close();
                }
                bos.write(CRLF);
            }
            bos.write(HYPHENS);
            bos.write(boundaryBytes);
            bos.write(HYPHENS);
            bos.write(CRLF);

            bos.flush();
            outputBytes = bos.toByteArray();
        } catch (IOException e) {
            throw new KegbotApiException(e);
        }

        return RequestBody.create(MediaType.parse("multipart/form-data;boundary=" + boundary), outputBytes);
    }

    private String apiUrl() {
        return String.format("%s/v1", Strings.nullToEmpty(mConfig.getApiUrl()));
    }

    private String apiKey() {
        return Strings.nullToEmpty(mConfig.getApiKey());
    }

    @Override
    public void start(Context context) {
    }

    private Request.Builder newRequest(final String path) {
        Request.Builder builder = new Request.Builder().url(apiUrl() + path).addHeader("User-Agent", mUserAgent)
                .addHeader("X-Kegbot-Api-Key", apiKey());
        return builder;
    }

    private JsonNode requestJson(Request request) throws KegbotApiException {
        final Response response;
        final long startTime = SystemClock.elapsedRealtime();
        try {
            response = mClient.newCall(request).execute();
        } catch (IOException e) {
            Log.w(TAG, String.format("--> %s %s [ERR]", request.method(), request.urlString()));
            throw new KegbotApiException(e);
        }
        final long endTime = SystemClock.elapsedRealtime();

        final int responseCode = response.code();
        final String logMessage = String.format("--> %s %s [%s] %sms", request.method(), request.urlString(),
                responseCode, endTime - startTime);
        if (responseCode >= 200 && responseCode < 300) {
            Log.d(TAG, logMessage);
        } else {
            Log.w(TAG, logMessage);
        }
        final ResponseBody body = response.body();

        final JsonNode rootNode;
        try {
            try {
                final ObjectMapper mapper = new ObjectMapper();
                rootNode = mapper.readValue(body.byteStream(), JsonNode.class);
            } finally {
                body.close();
            }
        } catch (JsonParseException e) {
            throw new KegbotApiMalformedResponseException(e);
        } catch (JsonMappingException e) {
            throw new KegbotApiMalformedResponseException(e);
        } catch (IOException e) {
            throw new KegbotApiException(e);
        }

        boolean success = false;
        try {
            // Handle structural errors.
            if (!rootNode.has("meta")) {
                throw new KegbotApiMalformedResponseException("Response is missing 'meta' field.");
            }
            final JsonNode meta = rootNode.get("meta");
            if (!meta.isContainerNode()) {
                throw new KegbotApiMalformedResponseException("'meta' field is wrong type.");
            }

            final String message;
            if (rootNode.has("error") && rootNode.get("error").has("message")) {
                message = rootNode.get("error").get("message").getTextValue();
            } else {
                message = null;
            }

            // Handle HTTP errors.
            if (responseCode < 200 || responseCode >= 400) {
                switch (responseCode) {
                case 401:
                    throw new NotAuthorizedException(message);
                case 404:
                    throw new KegbotApi404(message);
                case 405:
                    throw new MethodNotAllowedException(message);
                default:
                    if (message != null) {
                        throw new KegbotApiServerError(message);
                    } else {
                        throw new KegbotApiServerError("Server error, response code=" + responseCode);
                    }
                }
            }

            success = true;
            return rootNode;
        } finally {
            if (!success) {
                Log.d(TAG, "Response JSON was: " + rootNode.toString());
            }
        }
    }

    private JsonNode getJson(String path) throws KegbotApiException {
        final Request.Builder request = newRequest(path);
        return requestJson(request.build());
    }

    private JsonNode postJson(String path, Map<String, String> params) throws KegbotApiException {
        final Request.Builder request = newRequest(path).post(formBody(params));
        return requestJson(request.build());
    }

    private JsonNode deleteJson(String path) throws KegbotApiException {
        final Request.Builder request = newRequest(path).delete();
        return requestJson(request.build());
    }

    private JsonNode postJson(String path, Map<String, String> params, Map<String, File> files)
            throws KegbotApiException {
        final Request.Builder request = newRequest(path).post(formBody(params, files));
        return requestJson(request.build());
    }

    private <T extends GeneratedMessage> List<T> getProto(String path, Builder builder) throws KegbotApiException {
        JsonNode result = getJson(path);
        if (result.has("object")) {
            final T resultMessage = getSingleProto(builder, result.get("object"));
            return Collections.singletonList(resultMessage);
        } else {
            final List<T> results = Lists.newArrayList();
            final JsonNode objects = result.get("objects");
            final Iterator<JsonNode> iter = objects.getElements();
            while (iter.hasNext()) {
                @SuppressWarnings("unchecked")
                final T res = (T) getSingleProto(builder, iter.next());
                results.add(res);
            }
            return results;
        }
    }

    private <T extends GeneratedMessage> T getSingleProto(String path, Builder builder) throws KegbotApiException {
        JsonNode result = getJson(path);
        return getSingleProto(builder, result.get("object"));
    }

    private <T extends GeneratedMessage> T getSingleProto(Builder builder, JsonNode root) {
        builder.clear();
        @SuppressWarnings("unchecked")
        final T result = (T) ProtoEncoder.toProto(builder, root).build();
        return result;
    }

    private <T extends GeneratedMessage> T postProto(String path, Builder builder, Map<String, String> params)
            throws KegbotApiException {
        JsonNode result = postJson(path, params);
        return getSingleProto(builder, result.get("object"));
    }

    private <T extends GeneratedMessage> T postProto(String path, Builder builder, Map<String, String> params,
            Map<String, File> files) throws KegbotApiException {
        JsonNode result = postJson(path, params, files);
        return getSingleProto(builder, result.get("object"));
    }

    public Version getVersion() throws KegbotApiException {
        final String version = getJson("/version").get("object").get("server_version").getTextValue();
        return Version.fromString(version);
    }

    public boolean supportsDeviceLink() throws KegbotApiException {
        try {
            return getVersion().compareTo(Version.fromString("0.9.27")) >= 0;
        } catch (KegbotApi404 e) {
            return false;
        }
    }

    public String startDeviceLink(final String deviceName) throws KegbotApiException {
        final Map<String, String> params = Maps.newLinkedHashMap();
        params.put("name", deviceName);
        debug("Starting device link ...");
        final JsonNode result = postJson("/devices/link/", params);
        final String code = result.get("object").get("code").getTextValue();
        if (Strings.isNullOrEmpty(code)) {
            throw new KegbotApiException("Pairing code was empty.");
        }
        return code;
    }

    /** Returns an apikey once linked, {@code null} otherwise. */
    public String pollDeviceLink(final String code) throws KegbotApiException {
        final JsonNode response = getJson("/devices/link/status/" + code).get("object");
        if (response.has("linked") && response.get("linked").getBooleanValue()) {
            return response.get("api_key").getTextValue();
        }
        return null;
    }

    @Deprecated
    public void login(String username, String password) throws KegbotApiException {
        Map<String, String> params = Maps.newLinkedHashMap();
        params.put("username", username);
        params.put("password", password);
        debug("Logging in as username=" + username + " password=XXX");
        postJson("/login/", params);
    }

    @Deprecated
    public String getApiKey() throws KegbotApiException {
        final JsonNode result = getJson("/get-api-key/");
        final JsonNode rootNode = result.get("object");
        if (rootNode == null) {
            throw new KegbotApiServerError("Invalid response.");
        }

        final JsonNode keyNode = rootNode.get("api_key");
        if (keyNode == null) {
            throw new KegbotApiServerError("Invalid response.");
        }

        final String apiKey = keyNode.getValueAsText();
        debug("Got api key:" + apiKey);
        mConfig.setApiKey(apiKey);
        return apiKey();
    }

    @Override
    public List<SoundEvent> getSoundEvents() throws KegbotApiException {
        return getProto("/sound-events/", SoundEvent.newBuilder());
    }

    @Override
    public List<KegTap> getTaps() throws KegbotApiException {
        return getProto("/taps/", KegTap.newBuilder());
    }

    @Override
    public KegTap createTap(String tapName) throws BackendException {
        final Map<String, String> params = ImmutableMap.<String, String>builder().put("name", tapName).build();

        return postProto("/taps/", KegTap.newBuilder(), params);
    }

    @Override
    public void deleteTap(KegTap tap) throws KegbotApiException {
        final String path = "/taps/" + tap.getId();
        deleteJson(path);
    }

    @Override
    public AuthenticationToken getAuthToken(String authDevice, String tokenValue) throws KegbotApiException {
        try {
            authDevice = URLEncoder.encode(authDevice, Charsets.UTF_8.name());
            tokenValue = URLEncoder.encode(tokenValue, Charsets.UTF_8.name());
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }

        try {
            return getSingleProto("/auth-tokens/" + authDevice + "/" + tokenValue + "/",
                    AuthenticationToken.newBuilder());
        } catch (KegbotApi404 e) {
            return null;
        }
    }

    @Override
    public Keg endKeg(Keg keg) throws KegbotApiException {
        return (Keg) postProto("/kegs/" + keg.getId() + "/end/", Keg.newBuilder(), null);
    }

    @Override
    public KegTap startKeg(KegTap tap, String beerName, String brewerName, String styleName, String kegType)
            throws KegbotApiException {
        final Map<String, String> params = ImmutableMap.<String, String>builder().put("beer_name", beerName)
                .put("brewer_name", brewerName).put("style_name", styleName).put("keg_size", kegType).build();

        return postProto("/taps/" + tap.getId() + "/activate/", KegTap.newBuilder(), params);
    }

    @Override
    public List<SystemEvent> getEvents() throws KegbotApiException {
        return getProto("/events/", SystemEvent.newBuilder());
    }

    @Override
    public List<SystemEvent> getEventsSince(final long sinceEventId) throws KegbotApiException {
        final List<NameValuePair> params = Lists.newArrayList();
        return getProto("/events/?since=" + sinceEventId, SystemEvent.newBuilder());
    }

    @Override
    public JsonNode getSessionStats(int sessionId) throws KegbotApiException {
        return getJson("/sessions/" + sessionId + "/stats/").get("object");
    }

    @Override
    public FlowMeter calibrateMeter(FlowMeter meter, double ticksPerMl) throws BackendException {
        final Map<String, String> params = ImmutableMap.<String, String>builder()
                .put("ticks_per_ml", Double.valueOf(ticksPerMl).toString())
                .put("ml_per_tick", Double.valueOf(1.0 / ticksPerMl).toString()).build();
        return postProto("/flow-meters/" + meter.getId(), FlowMeter.newBuilder(), params);
    }

    @Override
    public User getUser(String username) throws KegbotApiException {
        return getSingleProto("/users/" + username, User.newBuilder());
    }

    @Override
    public List<User> getUsers() throws KegbotApiException {
        return getProto("/users/", User.newBuilder());
    }

    @Override
    public Session getCurrentSession() throws KegbotApiException {
        final List<Session> sessions = getProto("/sessions/?limit=1", Session.newBuilder());
        if (sessions.isEmpty()) {
            return null;
        }
        final Session session = sessions.get(0);
        if (session.getIsActive()) {
            return session;
        }
        return null;
    }

    @Override
    public Drink recordDrink(String tapName, long volumeMl, long ticks, @Nullable String shout,
            @Nullable String username, @Nullable String recordDate, long durationMillis,
            @Nullable TimeSeries timeSeries, @Nullable File picture) throws BackendException {

        final ImmutableMap.Builder<String, String> paramBuilder = ImmutableMap.builder();
        paramBuilder.put("ticks", String.valueOf(ticks));

        if (volumeMl > 0) {
            paramBuilder.put("volume_ml", String.valueOf(volumeMl));
        }

        if (!Strings.isNullOrEmpty(username)) {
            paramBuilder.put("username", username);
        }

        if (!Strings.isNullOrEmpty(recordDate)) {
            try {
                paramBuilder.put("record_date", recordDate); // new API
            } catch (IllegalArgumentException e) {
                // Ignore.
            }
        }

        if (durationMillis > 0) {
            // TODO: Fix API to report this in millis.
            paramBuilder.put("duration", String.valueOf(durationMillis / 1000));
        }

        // TODO: Handle spilled.

        if (!Strings.isNullOrEmpty(shout)) {
            paramBuilder.put("shout", shout);
        }

        if (timeSeries != null) {
            paramBuilder.put("tick_time_series", timeSeries.asString());
        }

        if (picture != null) {
            final Map<String, File> files = ImmutableMap.of("photo", picture);
            return postProto("/taps/" + tapName, Drink.newBuilder(), paramBuilder.build(), files);
        } else {
            return postProto("/taps/" + tapName, Drink.newBuilder(), paramBuilder.build());
        }
    }

    @Override
    public ThermoLog recordTemperature(final RecordTemperatureRequest request) throws KegbotApiException {
        if (!request.isInitialized()) {
            throw new KegbotApiException("Request is missing required field(s)");
        }

        final String sensorName = request.getSensorName();
        final String sensorValue = String.valueOf(request.getTempC());

        final Map<String, String> params = Maps.newLinkedHashMap();
        params.put("temp_c", sensorValue);
        return (ThermoLog) postProto("/thermo-sensors/" + sensorName, ThermoLog.newBuilder(), params);
    }

    @Override
    public Image attachPictureToDrink(int drinkId, File picture) throws KegbotApiException {
        final String url = String.format("/drinks/%s/add-photo/", Integer.valueOf(drinkId));
        final Map<String, File> files = ImmutableMap.of("photo", picture);
        return postProto(url, Image.newBuilder(), null, files);
    }

    @Override
    public User createUser(String username, String email, String password, String imagePath)
            throws KegbotApiException {
        final ImmutableMap.Builder<String, String> builder = ImmutableMap.<String, String>builder()
                .put("username", username).put("email", email).put("accepted_terms", "true");

        if (!Strings.isNullOrEmpty(password)) {
            builder.put("password", password);
        }

        if (!Strings.isNullOrEmpty(imagePath)) {
            final Map<String, File> files = ImmutableMap.of("photo", new File(imagePath));
            return postProto("/new-user/", User.newBuilder(), builder.build(), files);
        } else {
            return postProto("/new-user/", User.newBuilder(), builder.build());
        }
    }

    @Override
    public AuthenticationToken assignToken(String authDevice, String tokenValue, String username)
            throws KegbotApiException {
        final String url = "/auth-tokens/" + authDevice + "/" + tokenValue + "/assign/";
        final Map<String, String> params = ImmutableMap.<String, String>builder().put("username", username).build();
        return postProto(url, AuthenticationToken.newBuilder(), params);
    }

    @Override
    public List<Controller> getControllers() throws BackendException {
        return getProto("/controllers/", Controller.newBuilder());
    }

    @Override
    public Controller updateController(Controller controller) throws BackendException {
        final Map<String, String> params = Maps.newLinkedHashMap();
        params.put("name", controller.getName());
        if (controller.hasSerialNumber()) {
            params.put("serial_number", controller.getSerialNumber());
        }
        if (controller.hasModelName()) {
            params.put("model_name", controller.getModelName());
        }

        return (Controller) postProto("/controllers/" + controller.getId(), Controller.newBuilder(), params);
    }

    @Override
    public List<FlowMeter> getFlowMeters() throws BackendException {
        return getProto("/flow-meters/", FlowMeter.newBuilder());
    }

    @Override
    public FlowMeter updateFlowMeter(FlowMeter flowMeter) throws BackendException {
        final Map<String, String> params = Maps.newLinkedHashMap();
        params.put("port_name", flowMeter.getPortName());

        return (FlowMeter) postProto("/flow-meters/" + flowMeter.getId(), FlowMeter.newBuilder(), params);
    }

    private synchronized void debug(String message) {
        Log.d(TAG, message);
    }

    @Override
    public Controller createController(String name, String serialNumber, String deviceType)
            throws BackendException {
        final Map<String, String> params = Maps.newLinkedHashMap();
        params.put("name", name);
        if (!Strings.isNullOrEmpty(serialNumber)) {
            params.put("serial_number", serialNumber);
        }
        if (!Strings.isNullOrEmpty(deviceType)) {
            params.put("model_name", deviceType);
        }

        return (Controller) postProto("/controllers/", Controller.newBuilder(), params);
    }

    @Override
    public FlowMeter createFlowMeter(Controller controller, String portName, double ticksPerMl)
            throws BackendException {
        final Map<String, String> params = Maps.newLinkedHashMap();
        params.put("controller", String.valueOf(controller.getId()));
        params.put("port_name", portName);
        params.put("ticks_per_ml", String.valueOf(ticksPerMl));
        return (FlowMeter) postProto("/flow-meters/", FlowMeter.newBuilder(), params);
    }

    @Override
    public KegTap connectMeter(KegTap tap, FlowMeter meter) throws BackendException {
        final Map<String, String> params = Maps.newLinkedHashMap();
        params.put("meter", String.valueOf(meter.getId()));
        return (KegTap) postProto("/taps/" + tap.getId() + "/connect-meter", KegTap.newBuilder(), params);
    }

    @Override
    public KegTap disconnectMeter(KegTap tap) throws BackendException {
        return (KegTap) postProto("/taps/" + tap.getId() + "/disconnect-meter", KegTap.newBuilder(), null);
    }

    @Override
    public List<FlowToggle> getFlowToggles() throws BackendException {
        return getProto("/flow-toggles/", FlowToggle.newBuilder());
    }

    @Override
    public Models.FlowToggle updateFlowToggle(Models.FlowToggle flowToggle) throws BackendException {
        final Map<String, String> params = Maps.newLinkedHashMap();
        params.put("port_name", flowToggle.getPortName());

        return (FlowToggle) postProto("/flow-toggles/" + flowToggle.getId(), FlowToggle.newBuilder(), params);
    }

    @Override
    public KegTap connectToggle(KegTap tap, Models.FlowToggle toggle) throws BackendException {
        final Map<String, String> params = Maps.newLinkedHashMap();
        params.put("toggle", String.valueOf(toggle.getId()));
        return (KegTap) postProto("/taps/" + tap.getId() + "/connect-toggle", KegTap.newBuilder(), params);
    }

    @Override
    public KegTap disconnectToggle(KegTap tap) throws BackendException {
        return (KegTap) postProto("/taps/" + tap.getId() + "/disconnect-toggle", KegTap.newBuilder(), null);
    }
}