Something went wrong

Thank you for being patient! We're working hard on resolving the issue

Worked example: tento-strava - Lona Docs Log in

Worked example: tento-strava

A condensed dry run of building a hypothetical Strava integration that surfaces activities at :~strava:{authId}:activities.

File layout

tento/
└── tento-strava/
    ├── Cargo.toml
    └── src/
        ├── lib.rs
        ├── client.rs                # your Strava SDK
        ├── oauth.rs                 # StravaOAuthCredentials
        └── connector/
            ├── mod.rs               # Strava, StravaClient,
            │                          StravaBackendDeps, StravaAuth,
            │                          StravaParams
            └── activities/
                ├── mod.rs           # Activities Row impl
                └── logs.rs          # Logs Row impl

Cargo.toml

[package]
name = "tento-strava"
version = "0.1.0"
edition = "2024"

[features]
default = []
connector = ["dep:tento-lona-connector", "dep:tento-chrono", "dep:futures", "dep:uuid"]
sync-plugin = [
  "dep:lona-sync", "dep:async-trait",
  "dep:tento-lona-connector", "dep:tento-chrono", "dep:futures", "dep:uuid",
]

[dependencies]
tento-lona-connector = { workspace = true, optional = true }
tento-chrono         = { workspace = true, optional = true }
futures              = { workspace = true, optional = true }
uuid                 = { workspace = true, optional = true }
lona-sync            = { workspace = true, optional = true }
async-trait          = { workspace = true, optional = true }
tento-oauth.workspace = true
anyhow.workspace     = true
chrono.workspace     = true
serde.workspace      = true
serde_json.workspace = true
reqwest.workspace    = true

src/lib.rs

#![deny(missing_docs)]

//! Strava SDK + connector + sync-plugin.

pub mod client;
pub mod oauth;

#[cfg(feature = "connector")]
pub mod connector;

#[cfg(feature = "sync-plugin")]
pub mod sync_plugin;

pub use client::Client;

src/connector/mod.rs

//! Strava integration for `tento-lona-connector`.

#![deny(missing_docs)]

pub mod activities;

use std::sync::Arc;
use anyhow::Context;
use futures::future::BoxFuture;
use serde::Deserialize;
use tento_lona_connector::{
  AuthProvider, Cell, Integration, Owner, UserAuth, UserAuthExt,
};
use tento_oauth::oauth::Auth;

use crate::oauth::StravaOAuthCredentials;

/// Per-integration grab bag.
#[derive(Clone)]
pub struct StravaClient {
  /// HTTP client resolved from the user's Strava OAuth.
  pub http: Arc<crate::Client>,
}

/// Strava's deps contract.
pub trait StravaBackendDeps: Send + Sync {
  /// Persist a batch of cells onto the named row.
  fn persist_cells<'a>(
    &'a self,
    row_id: ::uuid::Uuid,
    cells: Vec<Cell>,
  ) -> BoxFuture<'a, anyhow::Result<()>>;

  /// Read recent log cells for `:logs` rows.
  fn recent_log_cells<'a>(
    &'a self,
    owner: &'a Owner,
    lookup_key: &'a str,
  ) -> BoxFuture<'a, anyhow::Result<Vec<Cell>>>;
}

/// Strava integration marker.
pub struct Strava;

/// Path-level params for `:~strava:{authId}:…` rowkeys.
#[derive(Deserialize, Clone, Debug)]
pub struct StravaParams {
  /// Per-account Strava auth identifier.
  #[serde(rename = "authId")]
  pub auth_id: String,
}

/// AuthProvider marker.
pub struct StravaAuth;

impl AuthProvider for StravaAuth {
  const KIND: &'static str = "strava";
  type AuthId = str;
  type Credentials = Auth<StravaOAuthCredentials>;
}

impl Integration for Strava {
  type Deps = dyn StravaBackendDeps;
  type Client = StravaClient;
  type Params = StravaParams;

  fn client<'a>(
    user: &'a dyn UserAuth,
    _deps: &'a dyn StravaBackendDeps,
    p: &'a Self::Params,
  ) -> BoxFuture<'a, anyhow::Result<Self::Client>> {
    Box::pin(async move {
      let auth = user
        .auth::<StravaAuth>(&p.auth_id)
        .await
        .context("strava auth lookup")?
        .ok_or_else(|| anyhow::anyhow!("no strava auth `{}`", p.auth_id))?;
      let handle = auth.acquire_handle().await?;
      Ok(StravaClient {
        http: Arc::new(crate::Client::from_handle(handle)),
      })
    })
  }
}

src/connector/activities/mod.rs

//! Strava activities — `:~strava:{authId}:activities`.

#![deny(missing_docs)]

pub mod logs;

use anyhow::Context;
use futures::future::BoxFuture;
use tento_chrono::{PartialDate, SemanticTime};
use tento_lona_connector::{Cell, Row, SyncCtx, SyncShape, wearable_window_for_stage};

use super::{Strava, StravaParams};

/// Strava activities sync row.
pub struct Activities;

impl Row for Activities {
  type Integration = Strava;
  type Params = ();
  const ROW_LABEL: &'static str = "strava-activities";

  fn lookup_key(int: &StravaParams, _: &()) -> String {
    format!(":~strava:{}:activities", int.auth_id)
  }

  fn sync_shape() -> Option<SyncShape> {
    Some(SyncShape {
      stages: &["viewport", "backfill"],
      ..Default::default()
    })
  }

  fn sync<'a>(ctx: SyncCtx<'a, Self>) -> BoxFuture<'a, anyhow::Result<usize>> {
    Box::pin(async move {
      let range = wearable_window_for_stage(ctx.stage);
      let activities = ctx
        .client
        .http
        .activities()
        .list_range(range)
        .await
        .context("strava activities.list_range")?;
      let cells: Vec<Cell> = activities
        .into_iter()
        .map(|a| Cell {
          id: Some(a.id.to_string()),
          date: SemanticTime::from_partial(PartialDate::Ymd(a.start_date.date_naive())),
          end_date: None,
          data: serde_json::to_value(&a).unwrap(),
        })
        .collect();
      let n = cells.len();
      if !cells.is_empty() {
        ctx.deps.persist_cells(ctx.row_id, cells).await?;
      }
      Ok(n)
    })
  }
}

Host wiring (in your embedder)

  1. Implement StravaBackendDeps on your host's deps struct (mirror lona-so/src/service/rowkey_deps.rs for Garmin's pattern).

  2. Add a "strava" match arm to your host's UserAuth credential resolver.

  3. Register the row pattern in your RowKeySchema block:

    [integration = ::tento_strava::connector::Strava] {
      ":~strava:{authId}" {
        ":activities" { get }
      }
    }
    
  4. Register with the orchestrator (if shipping sync-plugin) via register_row_with_sync::<strava::activities::Activities>(...).

That's the full path. Compare with tento/tento-garmin/ (workspace) to see the in-tree shape.