How to import exercise data to Strava

This blog post shows how I uploaded FIT and GPX exercise files to Strava via a custom web application.

My first plan was to parse FIT files and then upload exercise data to the Strava (create activity API). I implemented the first application version of that using the FastFitParser (C#) library. After implementation, I found information about Strava upload capabilities which allow you to upload FIT and GPX directly without any conversion. This was a much straightforward solution so I decided to stick with that. If you are more interested in FIT file standards and specifications you can find more information from FIT SDK documentation.

During the implementation, I found a few interesting libraries which help you to integrate into Strava. I didn't use them but you should be aware of them. StravaSharp is a Strava V3 API wrapper that makes Strava endpoints and entities consumable in .NET projects. RestSharp is a simple REST and HTTP API wrapper client for .NET applications. Actually, StravaSharp is using this RestSharp library behind the hood.

First few words about Strava APIs and documentation.

Strava APIs

You should be familiar with OAuth2 because Strava uses OAuth2 with the V3 API. OAuth allows external applications to request authorization for a user’s data. It allows users to grant and revoke API access on a per-application basis and keeps users’ authentication details safe.

OAuth2 flow

  1. User access to the client service which uploads FIT and GPX files
  2. The client application redirects the user to the Strava. The user authenticates and then grants access
  3. Authorization code is returned to the client
  4. Access Token is fetched with an authorization code
  5. Client application uploads user's data to the Strava resource service (API) using Access Token (Bearer Token)

Strava API documentation

You can find Strava API documentation from the links above.

Create a Strava API application

Through Strava App you can get access to Strava APIs. You can configure your Strava app from the settings ("My API Application") after you have logged in to strava.com. Currently, the free account has 600 requests every 15 minutes and 30000 daily limits which you should know when developing an app. Free accounts can have one API application per account.

When you scroll the page down you can give the Application name, website, and authorization callback domain. The authorization callback domain should be the destination domain where your application will deployed. You can use localhost during the application development. My application is named as a Triathlon Dashboard App because the same application is used also for other purposes. Maybe later more about Triathlon Dashboard App:).

Set default gears to your Strava account

Strava has a nice feature to track kilometers that are linked to your gears like shoes or bikes. When old exercises from history (FIT and GPX) are uploaded to Strava they are linked to your default gears so this messes up your statistics. I decided to create temporary empty default gears so my stats remain intact.

ASP.NET Core Web application

Authorize request to Strava

The button redirects the user to the Strava authorization service. 

OAuth authorization redirection contains the following elements in the URL:

ClientId = your unique Id of the application
Response Type = code (endpoint returns Authorization Code)
Redirect URI = URL where the user is returned after authentication and authorization
Scopes = Permissions to the resource data. Write scope should be determined because we're uploading data on behalf of the user to the Strava

 public IActionResult StravaAuthorizationRedirection()
        {
            var stravaAuthorizationEndpointUrl = _appSettings.StravaAuthorizationEndpointUrl;
            var stravaClientId = _appSettings.StravaClientId;
            var redirectUri = _appSettings.RedirectUri;
            var scopes = _appSettings.Scopes;

            if (string.IsNullOrEmpty(stravaAuthorizationEndpointUrl) ||
                string.IsNullOrEmpty(stravaClientId) ||
                string.IsNullOrEmpty(redirectUri) ||
                string.IsNullOrEmpty(scopes))
                return View("Error");

            var url = $"{stravaAuthorizationEndpointUrl}?" +
                $"client_id={stravaClientId}&" +
                $"response_type=code" +
                $"&redirect_uri={redirectUri}&" +
                $"scope={scopes}&" +
                $"approval_prompt=force";

            return Redirect(url);
        }

Strava Authorization views lookthe this:

After Strava authorization user will be redirected back to the Client application.

Authorization code and access token handling

StravaAuthorization action receives an authorization code from the Strava authorization server and then retrieves an access token. Access Token is stored temporarily to ASP.NET TempData.

public async Task<IActionResult> StravaAuthorization(string state, string code)
        {
            var accessToken = await _stravaService.GetAccessTokenAsync(code);

            if (accessToken == null)
                return View("Error");

            TempData["AccessToken"] = accessToken.Token;
            return View("Upload");
        }

I created a separate StravaService class to handle Strava-related operations. GetAccessTokenAsync methods retrieve access tokens by authorization code.

public class StravaService : IStravaService
    {
        private readonly IHttpClientFactory _httpClientFactory;
        private readonly AppSettings _appSettings;
        private IHttpContextAccessor _httpContextAccessor;

        public StravaService(System.Net.Http.IHttpClientFactory httpClientFactory, IOptions<AppSettings> settings, IHttpContextAccessor httpContextAccessor)
        {
            _httpClientFactory = httpClientFactory;
            _appSettings = settings?.Value ?? throw new ArgumentNullException(nameof(settings));
            _httpContextAccessor = httpContextAccessor;

            if (_appSettings.StravaApiUrl == null) { throw new ArgumentNullException(nameof(_appSettings.StravaApiUrl)); }
            if (_appSettings.StravaTokenEndpointUrl == null) { throw new ArgumentNullException(nameof(_appSettings.StravaTokenEndpointUrl)); }

        }

       private async Task<HttpResponseMessage> CreateTokenSendRequest(HttpMethod method, StringContent content = null)
        {
            HttpResponseMessage result = null;
            var request = new HttpRequestMessage(method, new Uri(_appSettings.StravaTokenEndpointUrl));
            if (content != null)
            {
                request.Content = content;
            }
            var client = _httpClientFactory.CreateClient();
            result = await client.SendAsync(request);
            return result;
        }

        public async Task<AccessToken> GetAccessTokenAsync(string authorizationCode)
        {
            string stringcont = $"client_id={_appSettings.StravaClientId}&client_secret={WebUtility.UrlEncode(_appSettings.StravaClientSecret)}&code={WebUtility.UrlEncode(authorizationCode)}";
            var content = new StringContent(stringcont, Encoding.UTF8, "application/x-www-form-urlencoded");
            var response = await CreateTokenSendRequest(HttpMethod.Post, content);
            response.EnsureSuccessStatusCode();
            var stringResponse = await response.Content.ReadAsStringAsync();            
            return JsonConvert.DeserializeObject<AccessToken>(stringResponse);
        }

}

Upload View

After Strava authentication and authorization user can upload GIT and FIT files via file upload control.

<h1>Upload data to Strava</h1>
<br />
@using (Html.BeginForm("Upload", "Home", FormMethod.Post, new { enctype = "multipart/form-data" }))
{
    @Html.AntiForgeryToken()
    <input type="file" name="files" id="files" multiple />
    <br />
    <input type="submit" class="btn btn-primary btn-lg" value="Upload" name="Upload" id="Upload" />
}

Upload logic

The upload method retrieves the uploaded file collection as a parameter.

[DisableRequestSizeLimit]
        [ValidateAntiForgeryToken]
        public async Task<IActionResult> Upload(IList<IFormFile> files)
        {
            if (files == null)
                return View("Error");

            if (files.Count == 0)
                return View("Error");

            var accessToken = TempData["AccessToken"];

            if (accessToken == null)
                return View("Error");

            var responses = new List<UploadResponse>();

            foreach (var file in files)
            {
                var fileName = string.Empty;
                try
                {
                    if (ValidateFile(file))
                    {
                        fileName = ContentDispositionHeaderValue.Parse(file.ContentDisposition).FileName.Trim('"');
                        var response = await _stravaService.UploadAsync(file, accessToken.ToString());
                        if(response != null)                        
                            responses.Add(response);                        
                    }
                }
                catch (Exception ex)
                {
                    responses.Add(new UploadResponse()
                    {
                        status = "failed" + ex.Message,
                        error = fileName
                    });
                }
                Thread.Sleep(2000);
            }
            return View("Response", responses);
        }

        private bool ValidateFile(IFormFile file)
        {
            if (file == null)
                return false;

            if (file.Length == 0)
                return false;
            
            return true;
        }

I created the UploadAsync method to StravaService which uploads multipart/form data to the Strava upload API endpoint. The upload method is described here.

 public async Task<UploadResponse> UploadAsync(IFormFile file, string accessToken)
        {
            var fileName = ContentDispositionHeaderValue.Parse(file.ContentDisposition).FileName.Trim('"');
            string extension = Path.GetExtension(file.FileName).Trim('.');

            UploadResponse uploadResponse = null;

            using (var stream = file.OpenReadStream())
            {
                using (var streamContent = new StreamContent(stream))
                {
                    streamContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data");
                    streamContent.Headers.ContentDisposition.Name = "\"file\"";
                    streamContent.Headers.ContentDisposition.FileName = "\"" + fileName + "\"";
                    streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
                    string boundary = Guid.NewGuid().ToString();
                    var content = new MultipartFormDataContent(boundary);
                    content.Headers.Remove("Content-Type");
                    content.Headers.TryAddWithoutValidation("Content-Type", "multipart/form-data; boundary=" + boundary);
                    content.Add(streamContent);
                    content.Add(new StringContent(extension), String.Format("\"{0}\"", "data_type"));
                    var response = await CreateSendRequest(HttpMethod.Post,"/uploads", accessToken, content);
                    response.EnsureSuccessStatusCode();
                    var stringResponse = await response.Content.ReadAsStringAsync();
                    uploadResponse = JsonConvert.DeserializeObject<UploadResponse>(stringResponse);
                }
            }       

            return uploadResponse;
        }

    private async Task<HttpResponseMessage> CreateSendRequest(HttpMethod method, string uri, string accessToken, MultipartFormDataContent content = null)
        {
            HttpResponseMessage result = null;
            var request = new HttpRequestMessage(method, new Uri(_appSettings.StravaApiUrl + uri));
            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
            if (content != null)
            {
                request.Content = content;
            }
            var client = _httpClientFactory.CreateClient();
            client.DefaultRequestHeaders.CacheControl = CacheControlHeaderValue.Parse("no-cache");
            result = await client.SendAsync(request);
            return result;
        }

If you want to investigate how request headers and body is filled you can use Postman. If you choose form data from body you can choose files to upload Strava. Remember to open the Postman console to see details.

Response View

After upload user will be redirected to the View which shows response messages from the Strava uploads method.

Log in to Strava and check the migrated data

Before migration, I had 234 exercises or activities as Strava calls them.

After migration, I have 736 activities.

Open a single activity and you can see that data is successfully uploaded.

ASP.NET Core requests body size limits

ASP.NET Core 2.0 enforces a 30MB (~28.6 MiB) max request body size limit. Under normal circumstances, there is no need to increase the size of the HTTP request. But when you are trying to upload large files (> 30MB), there is a need to increase the default allowed limit. More details about how to increase the requested body size limit from here. GPF files might be quite large and you should increase the request body size limit if you're uploading multiple files at once.

Summary

Strava authentication and API documentation are very good and readable. I didn't face any major problems with Strava APIs. Maybe the hardest part was to find the right header and body combination when uploading programmatically multipart/form-data files to Strava Upload API using HttpClient. I first tested the upload process with Postman and Postman console information gave nice information.

I'm pleased because now my exercise history is in one place😄

Comments