Menu

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

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

During the implementation I found a few interested libraries which helps you to integrate to Strava. I didn't use them but you should be aware about them. StravaSharp is a Strava V3 API wrapper which makes Strava endpoints and entities consumable in .NET projects. RestSharp is 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 to 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. Client application redirects user to the Strava. User authenticates and then grants access
  3. Authorization code is returned to the client
  4. Access Token is fetched with 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.

Strava Developer site
Strava API v3 reference
Strava API Swagger
Strava Upload API
Strava Webhook Events API
Strava Authentication

Create 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 strava.com. Currently free account has 600 requests every 15 minutes and 30000 daily limits which you should know when developing an app. Free account can have one API application per account.

undefined

When you scroll page down you can give Application name, web site and authorization callback domain. Authorization callback domain should be 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 same application is used also in other purposes. Maybe later more about Triathlon Dashboard App:).

undefined

Set default gears to your Strava account

Strava has a nice feature to track kilometers which are linked to you 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 remains intact.

undefined

ASP.NET Core Web application

I created a following Visual Studio solution structure. Strava.Upload.UI is a normal ASP.NET Core MVC web application created by a default template. Models projects contains common entities related to solution.

undefined

This application has three UI views:

  1. Strava authorization View
  2. Upload View
  3. Response View

Strava authorization view

undefined

Authorization.cshtml

Button redirects user to the Strava authorization service. OAuth authorization redirection contains the following elements in the URL:

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

https://www.strava.com/oauth/authorize?client_id=xxxxx&response_type=code&redirect_uri=https://localhost:44305/Home/StravaAuthorization/&scope=activity:write&approval_prompt=force

<h1>Strava authorization</h1>

<a href="@Url.Action("StravaAuthorizationRedirection","Home")" class="btn btn-primary btn-lg">Strava authorization</a>

HomeController.cs

 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);
        }

AppSettings.cs

public class AppSettings
    {
        public string StravaTokenEndpointUrl { get; set; }
        public string StravaApiUrl { get; set; }
        public string StravaAuthorizationUrl { get; set; }
        public string StravaClientId { get; set; }
        public string StravaClientSecret { get; set; }
        public string StravaAuthorizationEndpointUrl { get; set; }
        public string RedirectUri { get; set; }
        public string Scopes { get; set; }
    }

Strava Authorization views looks this:

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

undefined

HomeController.cs

StravaAuthorization method receives authorization code from Strava authorization server and then retrieves access token. Access Token is stored temporary 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");
        }

StravaService.cs

GetAccessTokenAsync methods retrieves access token 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);
        }

}

IStravaService.cs

 public interface IStravaService
    {
        Task<AccessToken> GetAccessTokenAsync(string authorizationCode);
    }

AccessToken.cs

Entity which represents Access Token which is returned from Strava.

 public class AccessToken
    {
        [JsonProperty("token_type")]
        public string TokenType { get; set; }

        [JsonProperty("access_token")]
        public string Token { get; set; }

        [JsonProperty("athlete")]
        public Athlete Athlete { get; set; }
    }

Athelete.cs

Entity which represents Athelete which is returned from Strava with Access Token.

 public class Athlete
    {
        [JsonProperty("id")]
        public long Id { get; set; }

        [JsonProperty("resource_state")]
        public long ResourceState { get; set; }
    }

Startup.cs

  public void ConfigureServices(IServiceCollection services)
        {
            services.Configure<CookiePolicyOptions>(options =>
            {
                // This lambda determines whether user consent for non-essential cookies is needed for a given request.
                options.CheckConsentNeeded = context => true;
                options.MinimumSameSitePolicy = SameSiteMode.None;
            });


            services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);

            services.AddOptions();
            services.Configure<AppSettings>(Configuration.GetSection("AppSettings"));
            services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();

            services.AddHttpClient("FiveMinutesTimeOutClient", c => {
                c.Timeout = new TimeSpan(0, 5, 0);
            });

            services.AddSingleton<IStravaService, StravaService>();

        }

Upload View

undefined

Upload.cshtml

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" />
}

HomeController.cs

Upload method retrieves 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;
        }

StravaService.cs

StravaService class and interface is extended with the following method which uploads multipart/form data to the Strava upload API endpoint. 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 more 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 Postman console to see details.

undefined

UploadResponse.cs

Entity which represents response which is returned from Upload method.

public class UploadResponse
    {
        public int id { get; set; }
        public string external_id { get; set; }
        public object error { get; set; }
        public string status { get; set; }
        public object activity_id { get; set; }
    }

Response View

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

undefined

Log in to Strava and check migrated data

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

undefined

After migration I have 736 activities.

undefined

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

undefined

ASP.NET Core request body size limits

ASP.NET Core 2.0 enforces 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 request body size limit from here. GPF files might be quite large and you should increase 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 faced any major problems with Strava APIs. Maybe the hardest part was to find right header and body combination when uploading programmatically multipart/form-data files to Strava Upload API using with HttpClient. I first tested upload process with Postman and Postman console information gave nice information.

I'm really pleased because now my exercise history is in one place:)