From 7ca09f0f27c9f78cffa21e140545bc2b6ef87dd4 Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 12:18:02 +0200 Subject: [PATCH] feat(komoot): add completed activities preview import flow Signed-off-by: Marcus Fihlon --- .../fitpub/config/SecurityConfig.java | 2 + .../controller/KomootImportController.java | 61 +++++ .../KomootImportViewController.java | 18 ++ .../model/dto/KomootActivitiesResponse.java | 13 + .../model/dto/KomootActivitySummaryDTO.java | 20 ++ .../fitpub/model/dto/KomootImportRequest.java | 24 ++ .../fitpub/service/KomootImportService.java | 171 ++++++++++++ src/main/resources/application.yml | 3 + .../templates/activities/komoot.html | 246 ++++++++++++++++++ src/main/resources/templates/layout.html | 5 + .../service/KomootImportServiceTest.java | 108 ++++++++ 11 files changed, 671 insertions(+) create mode 100644 src/main/java/net/javahippie/fitpub/controller/KomootImportController.java create mode 100644 src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java create mode 100644 src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java create mode 100644 src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java create mode 100644 src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java create mode 100644 src/main/java/net/javahippie/fitpub/service/KomootImportService.java create mode 100644 src/main/resources/templates/activities/komoot.html create mode 100644 src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java diff --git a/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java b/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java index 3847ba4..aff2bae 100644 --- a/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java +++ b/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java @@ -87,6 +87,7 @@ public class SecurityConfig { // Protected view pages - require authentication .requestMatchers("/activities", "/activities/upload").authenticated() + .requestMatchers("/komoot-import").authenticated() .requestMatchers("/profile", "/profile/**", "/settings").authenticated() .requestMatchers("/notifications").authenticated() .requestMatchers("/analytics", "/analytics/**").authenticated() @@ -149,6 +150,7 @@ public class SecurityConfig { // Protected endpoints - Batch Import API .requestMatchers("/api/batch-import/**").authenticated() + .requestMatchers("/api/komoot-import/**").authenticated() // Protected endpoints - Privacy Zones API .requestMatchers("/api/privacy-zones/**").authenticated() diff --git a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java new file mode 100644 index 0000000..3a2e621 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java @@ -0,0 +1,61 @@ +package net.javahippie.fitpub.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; +import net.javahippie.fitpub.model.dto.KomootImportRequest; +import net.javahippie.fitpub.service.KomootImportService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * REST API for previewing completed Komoot activities. + */ +@RestController +@RequestMapping("/api/komoot-import") +@RequiredArgsConstructor +@Slf4j +public class KomootImportController { + + private final KomootImportService komootImportService; + + @PostMapping("/activities") + public ResponseEntity listActivities( + @Valid @RequestBody KomootImportRequest request, + Authentication authentication + ) { + log.info("User {} requested Komoot activity preview for Komoot ID {}", + authentication.getName(), request.userId()); + KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request); + return ResponseEntity.ok(response); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException e) { + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(error -> error.getDefaultMessage() != null ? error.getDefaultMessage() : "Invalid request") + .orElse("Invalid request"); + return ResponseEntity.badRequest().body(new ErrorResponse(message)); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity handleIllegalState(IllegalStateException e) { + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorResponse(e.getMessage())); + } + + record ErrorResponse(String error) {} +} diff --git a/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java b/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java new file mode 100644 index 0000000..d3ad006 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java @@ -0,0 +1,18 @@ +package net.javahippie.fitpub.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * Serves the Komoot import preview page. + */ +@Controller +public class KomootImportViewController { + + @GetMapping("/komoot-import") + public String komootImportPage(Model model) { + model.addAttribute("pageTitle", "Komoot Import"); + return "activities/komoot"; + } +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java new file mode 100644 index 0000000..c9223d7 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java @@ -0,0 +1,13 @@ +package net.javahippie.fitpub.model.dto; + +import java.util.List; + +/** + * Response payload for the Komoot import preview. + */ +public record KomootActivitiesResponse( + String userId, + int totalCount, + List activities +) { +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java new file mode 100644 index 0000000..830f838 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java @@ -0,0 +1,20 @@ +package net.javahippie.fitpub.model.dto; + +import java.time.OffsetDateTime; + +/** + * Reduced activity representation returned by the Komoot import preview. + */ +public record KomootActivitySummaryDTO( + long id, + String name, + String sport, + String status, + String type, + OffsetDateTime date, + Double distanceMeters, + Integer durationSeconds, + Integer timeInMotionSeconds, + Double elevationUp +) { +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java new file mode 100644 index 0000000..1851749 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java @@ -0,0 +1,24 @@ +package net.javahippie.fitpub.model.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +/** + * Request payload for fetching completed activities from Komoot. + * + *

The password is only used for the current request and is never persisted.

+ */ +public record KomootImportRequest( + @NotBlank + @Email + String email, + + @NotBlank + String password, + + @NotBlank + @Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only") + String userId +) { +} diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java new file mode 100644 index 0000000..cb483ed --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -0,0 +1,171 @@ +package net.javahippie.fitpub.service; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; +import net.javahippie.fitpub.model.dto.KomootActivitySummaryDTO; +import net.javahippie.fitpub.model.dto.KomootImportRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +/** + * Fetches a temporary preview of completed Komoot activities for an authenticated FitPub user. + * + *

Komoot does not expose a public API for this use case. This service currently talks to the + * same web API endpoints used by the Komoot website and therefore depends on their current + * behavior.

+ */ +@Service +@RequiredArgsConstructor +@Slf4j +public class KomootImportService { + + private static final int PAGE_SIZE = 100; + private static final String KOMOOT_LANGUAGE = "en"; + + private final RestTemplate restTemplate; + + @Value("${fitpub.komoot.base-url:https://www.komoot.com}") + private String komootBaseUrl; + + public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request) { + List activities = new ArrayList<>(); + + URI nextUri = buildInitialUri(request.userId()); + HttpEntity httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password())); + + try { + while (nextUri != null) { + ResponseEntity response = restTemplate.exchange( + nextUri, HttpMethod.GET, httpEntity, JsonNode.class); + + JsonNode root = response.getBody(); + if (root == null) { + throw new IllegalStateException("Komoot returned an empty response body."); + } + extractActivities(root, activities); + nextUri = extractNextUri(root); + } + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new IllegalArgumentException("Komoot login failed. Check email, password and Komoot ID.", e); + } catch (HttpClientErrorException.NotFound e) { + throw new IllegalArgumentException("Komoot user or activities endpoint not found for the given Komoot ID.", e); + } catch (RestClientException e) { + throw new IllegalStateException("Failed to reach Komoot. The remote service may be unavailable.", e); + } catch (Exception e) { + throw new IllegalStateException("Failed to parse Komoot activity list.", e); + } + + log.info("Fetched {} completed Komoot activities for user ID {}", activities.size(), request.userId()); + return new KomootActivitiesResponse(request.userId(), activities.size(), activities); + } + + private URI buildInitialUri(String userId) { + String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; + return UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + userId + "/tours/") + .queryParam("type", "tour_recorded") + .queryParam("status", "private") + .queryParam("name", "") + .queryParam("hl", KOMOOT_LANGUAGE) + .queryParam("sort_field", "date") + .queryParam("sort_direction", "desc") + .queryParam("page", 0) + .queryParam("limit", PAGE_SIZE) + .build() + .toUri(); + } + + private HttpHeaders buildHeaders(String email, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(List.of(MediaType.parseMediaType("application/hal+json"), MediaType.APPLICATION_JSON)); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAcceptLanguageAsLocales(List.of(java.util.Locale.ENGLISH)); + headers.set(HttpHeaders.USER_AGENT, + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"); + headers.set(HttpHeaders.AUTHORIZATION, basicAuth(email, password)); + return headers; + } + + private String basicAuth(String email, String password) { + String credentials = email + ":" + password; + String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + return "Basic " + encoded; + } + + private void extractActivities(JsonNode root, List activities) { + JsonNode tours = root.path("_embedded").path("tours"); + if (!tours.isArray()) { + return; + } + + for (JsonNode tour : tours) { + activities.add(new KomootActivitySummaryDTO( + tour.path("id").asLong(), + nullableText(tour, "name"), + nullableText(tour, "sport"), + nullableText(tour, "status"), + nullableText(tour, "type"), + parseDate(tour.path("date").asText(null)), + nullableDouble(tour, "distance"), + nullableInteger(tour, "duration"), + nullableInteger(tour, "time_in_motion"), + nullableDouble(tour, "elevation_up") + )); + } + } + + private URI extractNextUri(JsonNode root) { + String nextHref = root.path("_links").path("next").path("href").asText(null); + if (nextHref == null || nextHref.isBlank()) { + return null; + } + + if (nextHref.startsWith("http://") || nextHref.startsWith("https://")) { + return URI.create(nextHref); + } + + String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; + String normalizedNextHref = nextHref.startsWith("/") ? nextHref : "/" + nextHref; + return URI.create(normalizedBaseUrl + normalizedNextHref); + } + + private OffsetDateTime parseDate(String value) { + if (value == null || value.isBlank()) { + return null; + } + return OffsetDateTime.parse(value); + } + + private String nullableText(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : value.asText(); + } + + private Double nullableDouble(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : value.asDouble(); + } + + private Integer nullableInteger(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : value.asInt(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3a4e4e5..a6b7f92 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -104,6 +104,9 @@ fitpub: enabled: ${WEATHER_ENABLED:false} api-key: ${OPENWEATHERMAP_API_KEY:} + komoot: + base-url: ${KOMOOT_BASE_URL:https://www.komoot.com} + # Logging configuration logging: level: diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html new file mode 100644 index 0000000..5c354f3 --- /dev/null +++ b/src/main/resources/templates/activities/komoot.html @@ -0,0 +1,246 @@ + + + + + Komoot Import + + + +
+
+
+

+ + Komoot Import +

+ +
+
Phase 1: Preview only
+
+ Your Komoot credentials are only used for this request and are not stored in FitPub. +
+
+ Komoot does not provide a public API for this flow. This preview currently depends on the + same web API endpoints used by the Komoot website and may stop working if Komoot changes them. +
+
+ + + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
You can find the Komoot ID in your Komoot account settings.
+
+
+ +
+ +
+
+
+
+ +
+
+

+ + Completed Activities +

+ 0 +
+ +
+ + + + + + + + + + + + +
NameDateSportDistanceDurationElevation
+
+
+
+
+
+ + + + + + diff --git a/src/main/resources/templates/layout.html b/src/main/resources/templates/layout.html index 2ddd461..f78e5d6 100644 --- a/src/main/resources/templates/layout.html +++ b/src/main/resources/templates/layout.html @@ -97,6 +97,11 @@ Batch Import +
  • + + Komoot Import + +
  • diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java new file mode 100644 index 0000000..3b1f2fe --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -0,0 +1,108 @@ +package net.javahippie.fitpub.service; + +import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; +import net.javahippie.fitpub.model.dto.KomootImportRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.client.ExpectedCount.once; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class KomootImportServiceTest { + + private RestTemplate restTemplate; + private MockRestServiceServer server; + private KomootImportService service; + + @BeforeEach + void setUp() { + restTemplate = new RestTemplate(); + server = MockRestServiceServer.bindTo(restTemplate).build(); + service = new KomootImportService(restTemplate); + ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com"); + } + + @Test + void shouldFetchAndMergePagedCompletedActivities() { + String authHeader = "Basic " + Base64.getEncoder() + .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); + + server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&status=private&name=&hl=en&sort_field=date&sort_direction=desc&page=0&limit=100")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andRespond(withSuccess(""" + { + "_embedded": { + "tours": [ + { + "id": 1001, + "name": "Evening Ride", + "sport": "touringbicycle", + "status": "private", + "type": "tour_recorded", + "date": "2026-04-27T18:15:00+02:00", + "distance": 42350.4, + "duration": 8120, + "time_in_motion": 7800, + "elevation_up": 520.2 + } + ] + }, + "_links": { + "next": { + "href": "/api/v007/users/123456/tours?type=tour_recorded&page=1&limit=100" + } + } + } + """, MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours?type=tour_recorded&page=1&limit=100")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andRespond(withSuccess(""" + { + "_embedded": { + "tours": [ + { + "id": 1002, + "name": "Lunch Walk", + "sport": "hike", + "status": "private", + "type": "tour_recorded", + "date": "2026-04-26T12:30:00+02:00", + "distance": 5120.0, + "duration": 3600, + "time_in_motion": 3400, + "elevation_up": 75.0 + } + ] + }, + "_links": {} + } + """, MediaType.APPLICATION_JSON)); + + KomootActivitiesResponse response = service.fetchCompletedActivities( + new KomootImportRequest("user@example.com", "secret", "123456")); + + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.activities()).hasSize(2); + assertThat(response.activities().get(0).id()).isEqualTo(1001L); + assertThat(response.activities().get(0).timeInMotionSeconds()).isEqualTo(7800); + assertThat(response.activities().get(1).name()).isEqualTo("Lunch Walk"); + + server.verify(); + } +}