/* * Copyright 2015 Open mHealth * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.openmhealth.shim.fitbit; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.Lists; import org.openmhealth.schema.domain.omh.DataPoint; import org.openmhealth.shim.*; import org.openmhealth.shim.fitbit.mapper.*; import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.security.oauth2.client.OAuth2RestOperations; import org.springframework.security.oauth2.client.resource.OAuth2ProtectedResourceDetails; import org.springframework.security.oauth2.client.resource.UserRedirectRequiredException; import org.springframework.security.oauth2.client.token.AccessTokenRequest; import org.springframework.security.oauth2.client.token.RequestEnhancer; import org.springframework.security.oauth2.client.token.grant.code.AuthorizationCodeAccessTokenProvider; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.HttpServerErrorException; import org.springframework.web.util.UriComponentsBuilder; import java.io.IOException; import java.net.URI; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneId; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import static java.util.Collections.singletonList; import static org.openmhealth.shim.ShimDataResponse.result; import static org.openmhealth.shim.fitbit.FitbitShim.FitbitDataType.*; import static org.slf4j.LoggerFactory.getLogger; import static org.springframework.http.ResponseEntity.ok; /** * @author Danilo Bonilla * @author Chris Schaefbauer * @author Emerson Farrugia */ @Component @ConfigurationProperties(prefix = "openmhealth.shim.fitbit") public class FitbitShim extends OAuth2ShimBase { public static final String SHIM_KEY = "fitbit"; private static final String DATA_URL = "https://api.fitbit.com"; private static final String AUTHORIZE_URL = "https://www.fitbit.com/oauth2/authorize"; private static final String TOKEN_URL = "https://api.fitbit.com/oauth2/token"; private static final Logger logger = getLogger(FitbitShim.class); public static final List<String> FITBIT_SCOPES = Arrays.asList("activity", "heartrate", "sleep", "weight"); @Value("${openmhealth.shim.fitbit.partnerAccess:false}") protected boolean partnerAccess; @Autowired public FitbitShim(ApplicationAccessParametersRepo applicationParametersRepo, AuthorizationRequestParametersRepo authorizationRequestParametersRepo, AccessParametersRepo accessParametersRepo, ShimServerConfig shimServerConfig) { super(applicationParametersRepo, authorizationRequestParametersRepo, accessParametersRepo, shimServerConfig); } @Override public String getLabel() { return "Fitbit"; } @Override public List<String> getScopes() { return FITBIT_SCOPES; } @Override public String getShimKey() { return SHIM_KEY; } @Override public String getBaseAuthorizeUrl() { return AUTHORIZE_URL; } @Override public String getBaseTokenUrl() { return TOKEN_URL; } @Override public ShimDataType[] getShimDataTypes() { return values(); } @Override public AuthorizationCodeAccessTokenProvider getAuthorizationCodeAccessTokenProvider() { return new FitbitAuthorizationCodeAccessTokenProvider(); } public enum FitbitDataType implements ShimDataType { WEIGHT("body/log/weight"), SLEEP("sleep"), BODY_MASS_INDEX("body/log/weight"), STEPS("activities/steps"), ACTIVITY("activities"); private String endPoint; FitbitDataType(String endPoint) { this.endPoint = endPoint; } public String getEndPoint() { return endPoint; } } @Override protected String getAuthorizationUrl(UserRedirectRequiredException exception) { final OAuth2ProtectedResourceDetails resource = getResource(); String callbackUri = getCallbackUrl(); UriComponentsBuilder uriBuilder = UriComponentsBuilder .fromUriString(exception.getRedirectUri()) .queryParam("response_type", "code") .queryParam("client_id", resource.getClientId()) .queryParam("redirect_uri", callbackUri) .queryParam("scope", StringUtils.collectionToDelimitedString(resource.getScope(), " ")) .queryParam("state", exception.getStateKey()) .queryParam("prompt", "login"); return uriBuilder.build().encode().toUriString(); } @Override public ResponseEntity<ShimDataResponse> getData(OAuth2RestOperations restTemplate, ShimDataRequest shimDataRequest) throws ShimException { FitbitDataType fitbitDataType; try { fitbitDataType = valueOf(shimDataRequest.getDataTypeKey().trim().toUpperCase()); } catch (NullPointerException | IllegalArgumentException e) { throw new ShimException("Null or Invalid data type parameter: " + shimDataRequest.getDataTypeKey() + " in shimDataRequest, cannot retrieve data."); } /*** * Setup default date parameters */ OffsetDateTime today = LocalDate.now().atStartOfDay(ZoneId.of("Z")).toOffsetDateTime(); OffsetDateTime startDate = shimDataRequest.getStartDateTime() == null ? today.minusDays(1) : shimDataRequest.getStartDateTime(); OffsetDateTime endDate = shimDataRequest.getEndDateTime() == null ? today.plusDays(1) : shimDataRequest.getEndDateTime(); OffsetDateTime currentDate = startDate; if (usesDateRangeQuery(fitbitDataType)) { return getDataForDateRange(restTemplate, startDate, endDate, fitbitDataType, shimDataRequest.getNormalize()); } else { /** * Fitbit's API limits you to making a request for each given day of data for some endpoints. Thus we * make a request for each day in the submitted time range and then aggregate the response based on the * normalization parameter. */ List<ShimDataResponse> dayResponses = new ArrayList<>(); while (currentDate.toLocalDate().isBefore(endDate.toLocalDate()) || currentDate.toLocalDate().isEqual(endDate.toLocalDate())) { dayResponses.add(getDataForSingleDate(restTemplate, currentDate, fitbitDataType, shimDataRequest.getNormalize()).getBody()); currentDate = currentDate.plusDays(1); } return shimDataRequest.getNormalize() ? ok(aggregateNormalized(dayResponses)) : ok(aggregateIntoList(dayResponses)); } } /** * Determines whether a range query should be used for submitting requests based on the data type. Based on the * Fitbit API, we are able to use range queries for weight, BMI, and daily step summaries, without losing * information needed for schema mapping. */ private boolean usesDateRangeQuery(FitbitDataType fitbitDataType) { // partnerAccess means that intraday steps will be used, which are not accessible from a ranged query return fitbitDataType.equals(WEIGHT) || fitbitDataType.equals(BODY_MASS_INDEX) || (fitbitDataType.equals(STEPS) && !partnerAccess); } /** * Each 'dayResponse', when normalized, will have a type->list[objects] for the day. So we collect each daily map * to create an aggregate map of the full time range. */ @SuppressWarnings("unchecked") private ShimDataResponse aggregateNormalized(List<ShimDataResponse> dayResponses) { if (CollectionUtils.isEmpty(dayResponses)) { return ShimDataResponse.empty(FitbitShim.SHIM_KEY); } List<DataPoint> aggregateDataPoints = Lists.newArrayList(); for (ShimDataResponse dayResponse : dayResponses) { if (dayResponse.getBody() != null) { List<DataPoint> dayList = (List<DataPoint>) dayResponse.getBody(); for (DataPoint dataPoint : dayList) { aggregateDataPoints.add(dataPoint); } } } return result(FitbitShim.SHIM_KEY, aggregateDataPoints); } /** * Combines all response bodies for each day into a single response. * * @param dayResponses - daily responses to combine. * @return - Combined shim response. */ private ShimDataResponse aggregateIntoList(List<ShimDataResponse> dayResponses) { if (CollectionUtils.isEmpty(dayResponses)) { return ShimDataResponse.empty(FitbitShim.SHIM_KEY); } List<Object> responses = new ArrayList<>(); for (ShimDataResponse dayResponse : dayResponses) { if (dayResponse.getBody() != null) { responses.add(dayResponse.getBody()); } } return responses.size() == 0 ? ShimDataResponse.empty(FitbitShim.SHIM_KEY) : result(FitbitShim.SHIM_KEY, responses); } private ResponseEntity<ShimDataResponse> executeRequest( OAuth2RestOperations restTemplate, URI requestUri, boolean normalize, FitbitDataType fitbitDataType, String dateString ) throws ShimException { ResponseEntity<JsonNode> responseEntity; try { responseEntity = restTemplate.getForEntity(requestUri, JsonNode.class); } catch (HttpClientErrorException | HttpServerErrorException e) { // FIXME figure out how to handle this logger.error("A request for Fitbit data failed.", e); throw e; } if (normalize) { FitbitDataPointMapper dataPointMapper; switch ( fitbitDataType ) { case STEPS: if (partnerAccess) { dataPointMapper = new FitbitIntradayStepCountDataPointMapper(); } else { dataPointMapper = new FitbitStepCountDataPointMapper(); } break; case ACTIVITY: dataPointMapper = new FitbitPhysicalActivityDataPointMapper(); break; case WEIGHT: dataPointMapper = new FitbitBodyWeightDataPointMapper(); break; case SLEEP: dataPointMapper = new FitbitSleepDurationDataPointMapper(); break; case BODY_MASS_INDEX: dataPointMapper = new FitbitBodyMassIndexDataPointMapper(); break; default: throw new UnsupportedOperationException(); } return ok().body(result(FitbitShim.SHIM_KEY, dataPointMapper.asDataPoints(singletonList(responseEntity.getBody())))); } else { /** * For types that only allow us to retrieve a single day at a time, Fitbit does not always provide * date information since it is assumed we know what date we requested. However, this is problematic * when we are aggregating multiple single date responses, so we wrap each single day Fitbit data * point with date information. */ ObjectMapper objectMapper = new ObjectMapper(); String jsonContent = responseEntity.getBody().toString(); if (dateString != null) { jsonContent = "{\"result\": {\"date\": \"" + dateString + "\" " + ",\"content\": " + responseEntity.getBody().toString() + "}}"; } try { return ok().body(ShimDataResponse.result(FitbitShim.SHIM_KEY, objectMapper.readTree(jsonContent))); } catch (IOException e) { logger.error("Data returned in Fitbit request was not valid JSON.", e); throw new ShimException("Data returned in Fitbit request was not valid JSON"); } } } private ResponseEntity<ShimDataResponse> getDataForDateRange(OAuth2RestOperations restTemplate, OffsetDateTime fromTime, OffsetDateTime toTime, FitbitDataType fitbitDataType, boolean normalize) throws ShimException { String fromDateString = fromTime.toLocalDate().toString(); String toDateString = toTime.toLocalDate().toString(); UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(DATA_URL). path("/1/user/-/{fitbitDataTypeEndpoint}/date/{fromDateString}/{toDateString}.json"); URI requestUri = uriComponentsBuilder.buildAndExpand(fitbitDataType.getEndPoint(), fromDateString, toDateString).encode() .toUri(); return executeRequest(restTemplate, requestUri, normalize, fitbitDataType, null); } private ResponseEntity<ShimDataResponse> getDataForSingleDate(OAuth2RestOperations restTemplate, OffsetDateTime dateTime, FitbitDataType fitbitDataType, boolean normalize) throws ShimException { String dateString = dateTime.toLocalDate().toString(); UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUriString(DATA_URL). path("/1/user/-/{fitbitDataTypeEndpoint}/date/{dateString}{stepTimeSeries}.json"); URI endpointUrl = uriComponentsBuilder.buildAndExpand(fitbitDataType.getEndPoint(), dateString, (fitbitDataType == STEPS ? "/1d/1min" : "")).encode().toUri(); return executeRequest(restTemplate, endpointUrl, normalize, fitbitDataType, dateString); } public class FitbitAuthorizationCodeAccessTokenProvider extends AuthorizationCodeAccessTokenProvider { public FitbitAuthorizationCodeAccessTokenProvider() { this.setTokenRequestEnhancer(new FitbitTokenRequestEnhancer()); } @Override protected HttpMethod getHttpMethod() { return HttpMethod.POST; } } private class FitbitTokenRequestEnhancer implements RequestEnhancer { @Override public void enhance(AccessTokenRequest request, OAuth2ProtectedResourceDetails resource, MultiValueMap<String, String> form, HttpHeaders headers) { form.set("client_id", resource.getClientId()); form.set("redirect_uri", getCallbackUrl()); } } }