From ce14c7c01268f3265c709aba7193004a3ab724bc Mon Sep 17 00:00:00 2001 From: Pedro Sampaio Date: Fri, 21 Nov 2014 10:19:31 +0000 Subject: [PATCH 1/2] Adding support for Strava Adding support for Strava .Physical activity --- README.md | 4 + .../openmhealth/shim/ShimRegistryImpl.java | 14 +- .../openmhealth/shim/strava/StravaConfig.java | 59 ++++ .../openmhealth/shim/strava/StravaShim.java | 292 ++++++++++++++++++ .../src/main/resources/application.yaml | 3 + 5 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 shim-server/src/main/java/org/openmhealth/shim/strava/StravaConfig.java create mode 100644 shim-server/src/main/java/org/openmhealth/shim/strava/StravaShim.java diff --git a/README.md b/README.md index bbd4d2f9..7085ebcd 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ This repository contains a shim server, a shim server UI, and shims for third-pa * [Jawbone UP](https://jawbone.com/up/developer) * [RunKeeper](http://developer.runkeeper.com/healthgraph) ([application management portal](http://runkeeper.com/partner)) * [Withings](http://oauth.withings.com/api) +* [Strava](http://strava.github.io/api/) The above links point to the developer website of each API. You'll need to visit these websites to register your application and obtain authentication credentials for each of the shims you want to enable. @@ -238,6 +239,9 @@ The currently supported shims are * [omh:step-count](http://www.openmhealth.org/developers/schemas/#step-count) * sleep * [omh:sleep-duration](http://www.openmhealth.org/developers/schemas/#sleep-duration) +* strava + * activity + * [omh:physical-activity](http://www.openmhealth.org/developers/schemas/#physical-activity) You can learn more about these shims and endpoints on the Open mHealth [developer site](http://www.openmhealth.org/developers/getting-started/). diff --git a/shim-server/src/main/java/org/openmhealth/shim/ShimRegistryImpl.java b/shim-server/src/main/java/org/openmhealth/shim/ShimRegistryImpl.java index ed7d732b..ced3ee3a 100644 --- a/shim-server/src/main/java/org/openmhealth/shim/ShimRegistryImpl.java +++ b/shim-server/src/main/java/org/openmhealth/shim/ShimRegistryImpl.java @@ -28,6 +28,8 @@ import org.openmhealth.shim.runkeeper.RunkeeperShim; import org.openmhealth.shim.withings.WithingsConfig; import org.openmhealth.shim.withings.WithingsShim; +import org.openmhealth.shim.strava.StravaConfig; +import org.openmhealth.shim.strava.StravaShim; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @@ -71,6 +73,9 @@ public class ShimRegistryImpl implements ShimRegistry { @Autowired private WithingsConfig withingsConfig; + @Autowired + private StravaConfig stravaConfig; + private LinkedHashMap registryMap; public ShimRegistryImpl() { @@ -112,6 +117,12 @@ public void init() { new HealthvaultShim( authParametersRepo, shimServerConfig, healthvaultConfig)); } + + if (stravaConfig.getClientId() != null) { + registryMap.put(StravaShim.SHIM_KEY, + new StravaShim( + authParametersRepo, accessParametersRepo, shimServerConfig, stravaConfig)); + } } @Override @@ -135,7 +146,8 @@ public List getAvailableShims() { new RunkeeperShim(null, null, null, runkeeperConfig), new WithingsShim(null, null, withingsConfig), new HealthvaultShim(null, null, healthvaultConfig), - new FitbitShim(null, null, fitbitConfig) + new FitbitShim(null, null, fitbitConfig), + new StravaShim(null, null, null, stravaConfig) ); } diff --git a/shim-server/src/main/java/org/openmhealth/shim/strava/StravaConfig.java b/shim-server/src/main/java/org/openmhealth/shim/strava/StravaConfig.java new file mode 100644 index 00000000..f189aac2 --- /dev/null +++ b/shim-server/src/main/java/org/openmhealth/shim/strava/StravaConfig.java @@ -0,0 +1,59 @@ +/* + * Copyright 2014 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.strava; + +import org.openmhealth.shim.ApplicationAccessParameters; +import org.openmhealth.shim.ApplicationAccessParametersRepo; +import org.openmhealth.shim.ShimConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * @author Pedro Sampaio + */ +@Component +@ConfigurationProperties(prefix = "openmhealth.shim.strava") +public class StravaConfig implements ShimConfig { + + private String clientId; + + private String clientSecret; + + @Autowired + private ApplicationAccessParametersRepo applicationParametersRepo; + + public String getClientId() { + ApplicationAccessParameters parameters = + applicationParametersRepo.findByShimKey(StravaShim.SHIM_KEY); + return parameters != null ? parameters.getClientId() : clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getClientSecret() { + ApplicationAccessParameters parameters = + applicationParametersRepo.findByShimKey(StravaShim.SHIM_KEY); + return parameters != null ? parameters.getClientSecret() : clientSecret; + } + + public void setClientSecret(String clientSecret) { + this.clientSecret = clientSecret; + } +} diff --git a/shim-server/src/main/java/org/openmhealth/shim/strava/StravaShim.java b/shim-server/src/main/java/org/openmhealth/shim/strava/StravaShim.java new file mode 100644 index 00000000..460b0888 --- /dev/null +++ b/shim-server/src/main/java/org/openmhealth/shim/strava/StravaShim.java @@ -0,0 +1,292 @@ +/* + * Copyright 2014 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.strava; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.jayway.jsonpath.JsonPath; +import net.minidev.json.JSONObject; +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; +import org.joda.time.format.DateTimeFormat; +import org.joda.time.format.DateTimeFormatter; +import org.openmhealth.schema.pojos.Activity; +import org.openmhealth.schema.pojos.BodyWeight; +import org.openmhealth.schema.pojos.build.ActivityBuilder; +import org.openmhealth.schema.pojos.build.BodyWeightBuilder; +import org.openmhealth.schema.pojos.generic.DurationUnitValue; +import org.openmhealth.schema.pojos.generic.MassUnitValue; +import org.openmhealth.shim.*; +import org.springframework.http.*; +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.util.CollectionUtils; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; + +import java.io.IOException; +import java.util.*; + +import static org.openmhealth.schema.pojos.generic.LengthUnitValue.LengthUnit.m; + +/** + * Encapsulates parameters specific to strava api. + * + * @author Pedro Sampaio + */ +public class StravaShim extends OAuth2ShimBase { + + public static final String SHIM_KEY = "strava"; + + private static final String DATA_URL = "https://www.strava.com/api/v3/"; + + private static final String AUTHORIZE_URL = "https://www.strava.com/oauth/authorize"; + + private static final String TOKEN_URL = "https://www.strava.com/oauth/token"; + + private StravaConfig config; + + public static final ArrayList STRAVA_SCOPES = + new ArrayList(Arrays.asList("public")); + + public StravaShim(AuthorizationRequestParametersRepo authorizationRequestParametersRepo, + AccessParametersRepo accessParametersRepo, + ShimServerConfig shimServerConfig1, + StravaConfig stravaConfig) { + super(authorizationRequestParametersRepo, accessParametersRepo, shimServerConfig1); + this.config = stravaConfig; + } + + @Override + public String getLabel() { + return "Strava"; + } + + @Override + public String getShimKey() { + return SHIM_KEY; + } + + @Override + public String getClientSecret() { + return config.getClientSecret(); + } + + @Override + public String getClientId() { + return config.getClientId(); + } + + @Override + public String getBaseAuthorizeUrl() { + return AUTHORIZE_URL; + } + + @Override + public String getBaseTokenUrl() { + return TOKEN_URL; + } + + @Override + public List getScopes() { + return STRAVA_SCOPES; + } + + public AuthorizationCodeAccessTokenProvider getAuthorizationCodeAccessTokenProvider() { + AuthorizationCodeAccessTokenProvider provider = new AuthorizationCodeAccessTokenProvider(); + provider.setTokenRequestEnhancer(new StravaTokenRequestEnhancer()); + return provider; + } + + @Override + public ShimDataRequest getTriggerDataRequest() { + ShimDataRequest shimDataRequest = new ShimDataRequest(); + shimDataRequest.setDataTypeKey(StravaDataTypes.ACTIVITY.toString()); + shimDataRequest.setNumToReturn(1l); + return shimDataRequest; + } + + @Override + public ShimDataType[] getShimDataTypes() { + return new StravaDataTypes[]{ + StravaDataTypes.ACTIVITY}; + } + + //Example: 2014-11-22T22:11:00Z + private static DateTimeFormatter dateFormatter = DateTimeFormat.forPattern("yyyy-MM-dd'T'HH:mm:ss'Z"); + + public enum StravaDataTypes implements ShimDataType { + + ACTIVITY("athlete/activities", new JsonDeserializer() { + @Override + public ShimDataResponse deserialize(JsonParser jsonParser, + DeserializationContext deserializationContext) + throws IOException { + JsonNode responseNode = jsonParser.getCodec().readTree(jsonParser); + String rawJson = responseNode.toString(); + + List activities = new ArrayList<>(); + + JsonPath workoutsPath = JsonPath.compile("$.[*]"); + List strvWorkouts = JsonPath.read(rawJson, workoutsPath.getPath()); + if (CollectionUtils.isEmpty(strvWorkouts)) { + return ShimDataResponse.result(StravaShim.SHIM_KEY, null); + } + + ObjectMapper mapper = new ObjectMapper(); + for (Object rawWorkout : strvWorkouts) { + JsonNode strvWorkout = mapper.readTree(((JSONObject) rawWorkout).toJSONString()); + + DateTime startTime = + dateFormatter.withZone( DateTimeZone.UTC ) + .parseDateTime(strvWorkout.get("start_date").asText()); + + Activity activity = new ActivityBuilder() + .setActivityName(strvWorkout.get("name").asText()) + .setDistance( + strvWorkout.get("distance").asDouble(), m) + .withStartAndDuration( + startTime, + strvWorkout.get("moving_time").asDouble(), + DurationUnitValue.DurationUnit.sec).build(); + + activities.add(activity); + } + Map results = new HashMap<>(); + results.put(Activity.SCHEMA_ACTIVITY, activities); + return ShimDataResponse.result(StravaShim.SHIM_KEY, results); + } + }); + + private String endPoint; + + private JsonDeserializer normalizer; + + StravaDataTypes(String endPoint, JsonDeserializer normalizer) { + this.endPoint = endPoint; + this.normalizer = normalizer; + } + + @Override + public JsonDeserializer getNormalizer() { + return normalizer; + } + + public String getEndPoint() { + return endPoint; + } + } + + protected ResponseEntity getData(OAuth2RestOperations restTemplate, + ShimDataRequest shimDataRequest) throws ShimException { + String urlRequest = DATA_URL; + + final StravaDataTypes stravaDataType; + try { + stravaDataType = StravaDataTypes.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."); + } + + urlRequest += stravaDataType.getEndPoint() + "?"; + + long numToReturn = 100; + if (shimDataRequest.getNumToReturn() != null) { + numToReturn = shimDataRequest.getNumToReturn(); + } + + DateTime today = new DateTime(); + + DateTime startDate = shimDataRequest.getStartDate() == null ? + today.minusDays(1) : shimDataRequest.getStartDate(); + long startTimeTs = startDate.toDate().getTime() / 1000; + + DateTime endDate = shimDataRequest.getEndDate() == null ? + today.plusDays(1) : shimDataRequest.getEndDate(); + long endTimeTs = endDate.toDate().getTime() / 1000; + + urlRequest += "&after=" + startTimeTs; + urlRequest += "&before=" + endTimeTs; + urlRequest += "&per_page=" + numToReturn; + + ObjectMapper objectMapper = new ObjectMapper(); + + ResponseEntity responseEntity = restTemplate.getForEntity(urlRequest, byte[].class); + JsonNode json = null; + try { + if (shimDataRequest.getNormalize()) { + SimpleModule module = new SimpleModule(); + module.addDeserializer(ShimDataResponse.class, stravaDataType.getNormalizer()); + objectMapper.registerModule(module); + return new ResponseEntity<>( + objectMapper.readValue(responseEntity.getBody(), ShimDataResponse.class), HttpStatus.OK); + } else { + return new ResponseEntity<>( + ShimDataResponse.result(StravaShim.SHIM_KEY, objectMapper.readTree(responseEntity.getBody())), HttpStatus.OK); + } + } catch (IOException e) { + e.printStackTrace(); + throw new ShimException("Could not read response data."); + } + } + + protected AuthorizationRequestParameters getAuthorizationRequestParameters( + final String username, + final UserRedirectRequiredException exception) { + final OAuth2ProtectedResourceDetails resource = getResource(); + String authorizationUrl = exception.getRedirectUri() + + "?state=" + exception.getStateKey() + + "&client_id=" + resource.getClientId() + + "&scope=" + StringUtils.collectionToDelimitedString(resource.getScope(), " ") + + "&response_type=code" + + "&approval_prompt=force" + + "&redirect_uri=" + getCallbackUrl(); + + AuthorizationRequestParameters parameters = new AuthorizationRequestParameters(); + parameters.setRedirectUri(exception.getRedirectUri()); + parameters.setStateKey(exception.getStateKey()); + parameters.setHttpMethod(HttpMethod.GET); + parameters.setAuthorizationUrl(authorizationUrl); + return parameters; + } + + /** + * Adds strava required parameters to authorization token requests. + */ + private class StravaTokenRequestEnhancer implements RequestEnhancer { + @Override + public void enhance(AccessTokenRequest request, + OAuth2ProtectedResourceDetails resource, + MultiValueMap form, HttpHeaders headers) { + form.set("client_id", resource.getClientId()); + form.set("client_secret", resource.getClientSecret()); + form.set("grant_type", resource.getGrantType()); + form.set("redirect_uri", getCallbackUrl()); + } + } +} diff --git a/shim-server/src/main/resources/application.yaml b/shim-server/src/main/resources/application.yaml index 47b0bdc9..42b8a44a 100644 --- a/shim-server/src/main/resources/application.yaml +++ b/shim-server/src/main/resources/application.yaml @@ -38,3 +38,6 @@ openmhealth: # clientSecret: [YOUR_CLIENT_SECRET] #healthvault: # clientId: [YOUR_CLIENT_ID] + #strava: + # clientId: [YOUR_CLIENT_ID] + # clientSecret: [YOUR_CLIENT_SECRET] From 6f917fa97135fb0796f92f57a329046451605d4a Mon Sep 17 00:00:00 2001 From: Pedro Sampaio Date: Fri, 21 Nov 2014 23:24:04 +0000 Subject: [PATCH 2/2] shim panel: minor ui/ux tweaks --- shim-server-ui/app/styles/main.css | 4 + shim-server-ui/app/views/main.html | 204 +++++++++++++++-------------- 2 files changed, 107 insertions(+), 101 deletions(-) diff --git a/shim-server-ui/app/styles/main.css b/shim-server-ui/app/styles/main.css index 8211e88b..b9e48ceb 100644 --- a/shim-server-ui/app/styles/main.css +++ b/shim-server-ui/app/styles/main.css @@ -122,6 +122,10 @@ body { padding-left: 10px; } +.connected-row .glyphicon-calendar { + padding-left: 0px; +} + .sublink { margin-right: 30px; margin-bottom: 10px; diff --git a/shim-server-ui/app/views/main.html b/shim-server-ui/app/views/main.html index 863e6f9d..f6652d8f 100644 --- a/shim-server-ui/app/views/main.html +++ b/shim-server-ui/app/views/main.html @@ -79,117 +79,119 @@

-
    - -
  • -
    - {{shim.label}} - -
    -
    - -
    +
    +
      -
      +
    • +
      {{shim.label}} - -   - - +
      -
      - - - -
    • +
    -
  • -
+ + +