/*
* 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.googlefit;
import com.fasterxml.jackson.databind.JsonNode;
import org.openmhealth.shim.*;
import org.openmhealth.shim.googlefit.mapper.*;
import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
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.OAuth2AccessDeniedException;
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.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2RefreshToken;
import org.springframework.stereotype.Component;
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.net.URI;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.List;
import static java.util.Collections.singletonList;
import static org.slf4j.LoggerFactory.getLogger;
import static org.springframework.http.ResponseEntity.ok;
/**
* Encapsulates parameters specific to the Google Fit REST API and processes requests for Google Fit data from shimmer.
*
* @author Eric Jain
* @author Chris Schaefbauer
*/
@Component
@ConfigurationProperties(prefix = "openmhealth.shim.googlefit")
public class GoogleFitShim extends OAuth2ShimBase {
private static final Logger logger = getLogger(GoogleFitShim.class);
public static final String SHIM_KEY = "googlefit";
private static final String DATA_URL = "https://www.googleapis.com/fitness/v1/users/me/dataSources";
private static final String AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/auth";
private static final String TOKEN_URL = "https://accounts.google.com/o/oauth2/token";
public static final List<String> GOOGLE_FIT_SCOPES = Arrays.asList(
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/fitness.activity.read",
"https://www.googleapis.com/auth/fitness.body.read"
);
@Autowired
public GoogleFitShim(ApplicationAccessParametersRepo applicationParametersRepo,
AuthorizationRequestParametersRepo authorizationRequestParametersRepo,
AccessParametersRepo accessParametersRepo,
ShimServerConfig shimServerConfig) {
super(applicationParametersRepo, authorizationRequestParametersRepo, accessParametersRepo, shimServerConfig);
}
@Override
public String getLabel() {
return "Google Fit";
}
@Override
public String getShimKey() {
return SHIM_KEY;
}
@Override
public String getBaseAuthorizeUrl() {
return AUTHORIZE_URL;
}
@Override
public String getBaseTokenUrl() {
return TOKEN_URL;
}
@Override
public List<String> getScopes() {
return GOOGLE_FIT_SCOPES;
}
public AuthorizationCodeAccessTokenProvider getAuthorizationCodeAccessTokenProvider() {
return new GoogleAuthorizationCodeAccessTokenProvider();
}
@Override
public ShimDataType[] getShimDataTypes() {
return new GoogleFitDataTypes[] {
GoogleFitDataTypes.ACTIVITY,
GoogleFitDataTypes.BODY_HEIGHT,
GoogleFitDataTypes.BODY_WEIGHT,
GoogleFitDataTypes.HEART_RATE,
GoogleFitDataTypes.STEP_COUNT,
GoogleFitDataTypes.CALORIES_BURNED};
}
public enum GoogleFitDataTypes implements ShimDataType {
ACTIVITY("derived:com.google.activity.segment:com.google.android.gms:merge_activity_segments"),
BODY_HEIGHT("derived:com.google.height:com.google.android.gms:merge_height"),
BODY_WEIGHT("derived:com.google.weight:com.google.android.gms:merge_weight"),
HEART_RATE("derived:com.google.heart_rate.bpm:com.google.android.gms:merge_heart_rate_bpm"),
STEP_COUNT("derived:com.google.step_count.delta:com.google.android.gms:merge_step_deltas"),
CALORIES_BURNED("derived:com.google.calories.expended:com.google.android.gms:merge_calories_expended");
private final String streamId;
GoogleFitDataTypes(String streamId) {
this.streamId = streamId;
}
public String getStreamId() {
return streamId;
}
}
protected ResponseEntity<ShimDataResponse> getData(OAuth2RestOperations restTemplate,
ShimDataRequest shimDataRequest) throws ShimException {
final GoogleFitDataTypes googleFitDataType;
try {
googleFitDataType = GoogleFitDataTypes.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.");
}
OffsetDateTime todayInUTC =
LocalDate.now().atStartOfDay().atOffset(ZoneOffset.UTC);
OffsetDateTime startDateInUTC = shimDataRequest.getStartDateTime() == null ?
todayInUTC.minusDays(1) : shimDataRequest.getStartDateTime();
long startTimeNanos = (startDateInUTC.toEpochSecond() * 1000000000) + startDateInUTC.toInstant().getNano();
OffsetDateTime endDateInUTC = shimDataRequest.getEndDateTime() == null ?
todayInUTC.plusDays(1) :
shimDataRequest.getEndDateTime().plusDays(1); // We are inclusive of the last day, so add 1 day to get
// the end of day on the last day, which captures the
// entire last day
long endTimeNanos = (endDateInUTC.toEpochSecond() * 1000000000) + endDateInUTC.toInstant().getNano();
UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(DATA_URL)
.pathSegment(googleFitDataType.getStreamId(), "datasets", "{startDate}-{endDate}");
// TODO: Add limits back into the request once Google has fixed the 'limit' query parameter and paging
URI uriRequest = uriBuilder.buildAndExpand(startTimeNanos, endTimeNanos).encode().toUri();
ResponseEntity<JsonNode> responseEntity;
try {
responseEntity = restTemplate.getForEntity(uriRequest, JsonNode.class);
}
catch (HttpClientErrorException | HttpServerErrorException e) {
// TODO figure out how to handle this
logger.error("A request for Google Fit data failed.", e);
throw e;
}
if (shimDataRequest.getNormalize()) {
GoogleFitDataPointMapper<?> dataPointMapper;
switch ( googleFitDataType ) {
case BODY_WEIGHT:
dataPointMapper = new GoogleFitBodyWeightDataPointMapper();
break;
case BODY_HEIGHT:
dataPointMapper = new GoogleFitBodyHeightDataPointMapper();
break;
case ACTIVITY:
dataPointMapper = new GoogleFitPhysicalActivityDataPointMapper();
break;
case STEP_COUNT:
dataPointMapper = new GoogleFitStepCountDataPointMapper();
break;
case HEART_RATE:
dataPointMapper = new GoogleFitHeartRateDataPointMapper();
break;
case CALORIES_BURNED:
dataPointMapper = new GoogleFitCaloriesBurnedDataPointMapper();
break;
default:
throw new UnsupportedOperationException();
}
return ok().body(ShimDataResponse.result(GoogleFitShim.SHIM_KEY, dataPointMapper.asDataPoints(
singletonList(responseEntity.getBody()))));
}
else {
return ok().body(ShimDataResponse.result(GoogleFitShim.SHIM_KEY, responseEntity.getBody()));
}
}
@Override
protected String getAuthorizationUrl(UserRedirectRequiredException exception) {
final OAuth2ProtectedResourceDetails resource = getResource();
UriComponentsBuilder uriBuilder = UriComponentsBuilder
.fromUriString(exception.getRedirectUri())
.queryParam("state", exception.getStateKey())
.queryParam("client_id", resource.getClientId())
.queryParam("response_type", "code")
.queryParam("access_type", "offline")
.queryParam("approval_prompt", "force")
.queryParam("scope", StringUtils.collectionToDelimitedString(resource.getScope(), " "))
.queryParam("redirect_uri", getCallbackUrl());
return uriBuilder.build().encode().toUriString();
}
/**
* Simple overrides to base spring class from oauth.
*/
public class GoogleAuthorizationCodeAccessTokenProvider extends AuthorizationCodeAccessTokenProvider {
public GoogleAuthorizationCodeAccessTokenProvider() {
this.setTokenRequestEnhancer(new GoogleTokenRequestEnhancer());
}
@Override
protected HttpMethod getHttpMethod() {
return HttpMethod.POST;
}
@Override
public OAuth2AccessToken refreshAccessToken(
OAuth2ProtectedResourceDetails resource,
OAuth2RefreshToken refreshToken, AccessTokenRequest request)
throws UserRedirectRequiredException,
OAuth2AccessDeniedException {
OAuth2AccessToken accessToken = super.refreshAccessToken(resource, refreshToken, request);
// Google does not replace refresh tokens, so we need to hold on to the existing refresh token...
if (accessToken.getRefreshToken() == null) {
((DefaultOAuth2AccessToken) accessToken).setRefreshToken(refreshToken);
}
return accessToken;
}
}
/**
* Adds parameters required by Google to authorization token requests.
*/
private class GoogleTokenRequestEnhancer implements RequestEnhancer {
@Override
public void enhance(AccessTokenRequest request,
OAuth2ProtectedResourceDetails resource,
MultiValueMap<String, String> form, HttpHeaders headers) {
form.set("client_id", resource.getClientId());
form.set("client_secret", resource.getClientSecret());
if (request.getStateKey() != null) {
form.set("redirect_uri", getCallbackUrl());
}
}
}
}