Starva Connector
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
|
||||
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
||||
<quarkus.platform.version>3.26.3</quarkus.platform.version>
|
||||
<quarkus.platform.version>3.30.4</quarkus.platform.version>
|
||||
<skipITs>true</skipITs>
|
||||
<surefire-plugin.version>3.5.3</surefire-plugin.version>
|
||||
</properties>
|
||||
@@ -39,14 +39,20 @@
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-rest-jackson</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-mongodb-client</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-rest-client</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-rest-client-jackson</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-cache</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-scheduler</artifactId>
|
||||
@@ -55,9 +61,42 @@
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-arc</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-rest-client-jackson</artifactId>
|
||||
<artifactId>quarkus-hibernate-orm</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-jdbc-postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
<artifactId>quarkus-flyway</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.hibernate.orm</groupId>
|
||||
<artifactId>hibernate-jpamodelgen</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.40</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-lang3</artifactId>
|
||||
<version>3.19.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
<artifactId>commons-text</artifactId>
|
||||
<version>1.14.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.quarkus</groupId>
|
||||
@@ -93,7 +132,17 @@
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>${compiler-plugin.version}</version>
|
||||
<configuration>
|
||||
<parameters>true</parameters>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<version>1.18.34</version>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>org.hibernate.orm</groupId>
|
||||
<artifactId>hibernate-jpamodelgen</artifactId>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package com.peakup;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Path("/hello")
|
||||
public class ExampleResource {
|
||||
|
||||
@Inject
|
||||
@RestClient
|
||||
private MyRemoteService myRemoteService;
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public Hello hello() {
|
||||
Set<MyRemoteService.Extension> restClientExtensions = myRemoteService.getExtensionsById("io.quarkus:quarkus-hibernate-validator");
|
||||
return new Hello("Hello from Quarkus REST: " + restClientExtensions.stream().map( MyRemoteService.Extension::getId).collect( Collectors.joining(", ")));
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.peakup;
|
||||
|
||||
public class Hello {
|
||||
|
||||
private String message;
|
||||
|
||||
public Hello(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.peakup;
|
||||
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
|
||||
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* To use it via injection.
|
||||
* <p>
|
||||
* {@code
|
||||
*
|
||||
* @Inject
|
||||
* @RestClient MyRemoteService myRemoteService;
|
||||
* <p>
|
||||
* public void doSomething() {
|
||||
* Set<MyRemoteService.Extension> restClientExtensions = myRemoteService.getExtensionsById("io.quarkus:quarkus-hibernate-validator");
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
@RegisterRestClient(baseUri = "https://stage.code.quarkus.io/api")
|
||||
public interface MyRemoteService {
|
||||
|
||||
@GET
|
||||
@Path("/extensions")
|
||||
Set<Extension> getExtensionsById(@QueryParam("id") String id);
|
||||
|
||||
class Extension {
|
||||
public String id;
|
||||
public String name;
|
||||
public String shortName;
|
||||
public List<String> keywords;
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.peakup.athlete.dao;
|
||||
|
||||
import com.peakup.athlete.model.Athlete;
|
||||
import com.peakup.dao.AbstractDAO;
|
||||
import com.peakup.dao.AbstractTimestampedDAO;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
||||
@ApplicationScoped
|
||||
public class AthleteDAO extends AbstractTimestampedDAO<Athlete> {
|
||||
|
||||
@Override
|
||||
protected Class<Athlete> getEntityClass() {
|
||||
return Athlete.class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.peakup.athlete.model;
|
||||
|
||||
import com.peakup.dao.PersistableEnum;
|
||||
|
||||
public enum ActivityType implements PersistableEnum<ActivityType> {
|
||||
RIDE(1),
|
||||
RUN(2),
|
||||
SWIM(3),
|
||||
OPENWATER(4),
|
||||
;
|
||||
|
||||
private final int key;
|
||||
|
||||
ActivityType(int key) {
|
||||
this.key=key;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getKey() {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.peakup.athlete.model;
|
||||
|
||||
import com.peakup.model.AbstractTimestampedEntity;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Athlete extends AbstractTimestampedEntity {
|
||||
|
||||
@Column
|
||||
private String firstName;
|
||||
|
||||
@Column
|
||||
private String lastName;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.peakup.athlete.model;
|
||||
|
||||
import com.peakup.model.AbstractEntity;
|
||||
import com.peakup.model.AbstractTimestampedEntity;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.OneToOne;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Statistics extends AbstractEntity {
|
||||
|
||||
@OneToOne
|
||||
@JoinColumn(name = "athlete_id")
|
||||
private Athlete athlete;
|
||||
|
||||
@OneToOne
|
||||
@JoinColumn(name = "total_id")
|
||||
private StatisticsTotal total;
|
||||
@OneToOne
|
||||
@JoinColumn(name = "total_yearly_id")
|
||||
private StatisticsTotal totalYearly;
|
||||
@OneToOne
|
||||
@JoinColumn(name = "total_recent_id")
|
||||
private StatisticsTotal totalRecent;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.peakup.athlete.model;
|
||||
|
||||
import com.peakup.dao.AbstractEnumConverter;
|
||||
import com.peakup.model.AbstractEntity;
|
||||
import com.peakup.model.AbstractTimestampedEntity;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Convert;
|
||||
import jakarta.persistence.Entity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class StatisticsTotal extends AbstractTimestampedEntity {
|
||||
|
||||
@Convert(converter = AbstractEnumConverter.ActivityTypeConverter.class)
|
||||
private ActivityType activity;
|
||||
|
||||
@Convert(converter = AbstractEnumConverter.StatisticsTypeConverter.class)
|
||||
private StatisticsType statisticsType;
|
||||
|
||||
@Column
|
||||
private int count;
|
||||
|
||||
@Column
|
||||
private long movingTimeSeconds;
|
||||
|
||||
@Column
|
||||
private long distanceMeter;
|
||||
|
||||
@Column
|
||||
private long elevationMeter;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.peakup.athlete.model;
|
||||
|
||||
import com.peakup.dao.PersistableEnum;
|
||||
import org.hibernate.boot.internal.Abstract;
|
||||
|
||||
public enum StatisticsType implements PersistableEnum<StatisticsType> {
|
||||
TOTAL(1),
|
||||
TOTAL_YEARLY(2),
|
||||
TOTAL_RECENT(3), // 4 Weeks
|
||||
TOTAL_DURATION(4)
|
||||
;
|
||||
|
||||
private final int key;
|
||||
|
||||
StatisticsType(int key) {
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
|
||||
public static StatisticsType fromKey(int key) {
|
||||
for (StatisticsType e : values()) {
|
||||
if (e.key == key) return e;
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown key: " + key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer getKey() {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
37
peakup-backend/src/main/java/com/peakup/common/Assert.java
Normal file
37
peakup-backend/src/main/java/com/peakup/common/Assert.java
Normal file
@@ -0,0 +1,37 @@
|
||||
package com.peakup.common;
|
||||
|
||||
public class Assert {
|
||||
private Assert() {}
|
||||
|
||||
public static void isTrue(boolean expression, String message) {
|
||||
if (!expression) {
|
||||
throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
|
||||
public static void isTrue(boolean expression) {
|
||||
isTrue(expression, null);
|
||||
}
|
||||
|
||||
public static void notNull(Object object, String message) {
|
||||
if (object == null) {
|
||||
throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
|
||||
public static void notNull(Object object) {
|
||||
notNull(object, null);
|
||||
}
|
||||
|
||||
public static void isEqual(Object objectA, Object objectB) {
|
||||
if (objectA == objectB) {
|
||||
return;
|
||||
}
|
||||
if (objectA == null || objectB == null ) {
|
||||
throw new AssertionError("ObjectA is "+objectA +" and ObjectB is "+objectB);
|
||||
}
|
||||
if (!objectA.equals(objectB)) {
|
||||
throw new AssertionError("ObjectA is "+objectA +" and ObjectB is "+objectB);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.peakup.common;
|
||||
|
||||
public class PersistenceException extends RuntimeException {
|
||||
public PersistenceException(String msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.peakup.common;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.jspecify.annotations.Nullable;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@ApplicationScoped
|
||||
public class TokenCache {
|
||||
|
||||
private Cache<Long, String> cache = Caffeine.newBuilder()
|
||||
.expireAfterWrite(10, TimeUnit.SECONDS)
|
||||
.maximumSize(100)
|
||||
.<Long, String>build();
|
||||
|
||||
public String getBearerToken(Long athleteId) {
|
||||
String token = cache.getIfPresent(athleteId);
|
||||
if (StringUtils.isEmpty(token) ) {
|
||||
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
public void invalidate(Long athleteId) {
|
||||
cache.invalidate(athleteId);
|
||||
}
|
||||
|
||||
public void invalidateAll() {
|
||||
cache.invalidateAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.peakup.connector.strava;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
@Path("/oauth")
|
||||
@RegisterRestClient(configKey = "strava-api")
|
||||
@ApplicationScoped
|
||||
public interface StravaAuthClient {
|
||||
|
||||
@POST
|
||||
@Path("/token")
|
||||
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
Map<String, Object> refreshToken(
|
||||
@FormParam("client_id") String clientId,
|
||||
@FormParam("client_secret") String clientSecret,
|
||||
@FormParam("grant_type") String grantType,
|
||||
@FormParam("refresh_token") String refreshToken
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.peakup.connector.strava;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
|
||||
|
||||
@ApplicationScoped
|
||||
@Path("/api/v3")
|
||||
@RegisterRestClient(configKey = "strava-api")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
public interface StravaClient {
|
||||
|
||||
@GET
|
||||
@Path("/athlete")
|
||||
Athlete getAthlete(@HeaderParam("Authorization") String bearerToken);
|
||||
|
||||
public class Athlete {
|
||||
public Long id;
|
||||
public String username;
|
||||
public String firstname;
|
||||
public String lastname;
|
||||
public String city;
|
||||
public String country;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.peakup.connector.strava.dao;
|
||||
|
||||
import com.peakup.athlete.model.Athlete;
|
||||
import com.peakup.connector.strava.model.StravaConnector;
|
||||
import com.peakup.connector.strava.model.StravaConnector_;
|
||||
import com.peakup.dao.AbstractDAO;
|
||||
import com.peakup.model.AbstractEntity_;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.CriteriaQuery;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
|
||||
@ApplicationScoped
|
||||
public class StravaConnectorDAO extends AbstractDAO<StravaConnector> {
|
||||
|
||||
@Override
|
||||
protected Class<StravaConnector> getEntityClass() {
|
||||
return StravaConnector.class;
|
||||
}
|
||||
|
||||
public StravaConnector getByAthleteId(long athleteId) {
|
||||
final CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
CriteriaQuery<StravaConnector> cq = cb.createQuery(getEntityClass());
|
||||
Root<StravaConnector> root = cq.from(getEntityClass());
|
||||
cq.select(root).where(cb.equal(root.get(StravaConnector_.ATHLETE_ID), athleteId));
|
||||
return em.createQuery(cq).getSingleResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.peakup.connector.strava.model;
|
||||
|
||||
import com.peakup.model.AbstractTimestampedEntity;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class StravaConnector extends AbstractTimestampedEntity {
|
||||
|
||||
@Column
|
||||
private Long athleteId; // loose coupling
|
||||
|
||||
@Column
|
||||
private String stravaAthleteId;
|
||||
|
||||
@Column
|
||||
private String accessToken;
|
||||
|
||||
@Column
|
||||
private String refreshToken;
|
||||
|
||||
@Column
|
||||
private long expiresAt;
|
||||
|
||||
@Column
|
||||
private String clientId;
|
||||
|
||||
@Column
|
||||
private String clientSecret;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.peakup.connector.strava.scheduler;
|
||||
|
||||
import com.peakup.common.Assert;
|
||||
import com.peakup.connector.strava.StravaAuthClient;
|
||||
import com.peakup.connector.strava.dao.StravaConnectorDAO;
|
||||
import com.peakup.connector.strava.model.StravaConnector;
|
||||
import io.quarkus.scheduler.Scheduled;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
@ApplicationScoped
|
||||
public class StravaScheduler {
|
||||
private static final Logger LOG = Logger.getLogger(StravaScheduler.class);
|
||||
|
||||
@RestClient
|
||||
StravaAuthClient stravaAuthClient;
|
||||
|
||||
@Inject
|
||||
StravaConnectorDAO stravaConnectorDAO;
|
||||
|
||||
@Scheduled(every = "10m")
|
||||
@Transactional
|
||||
void checkRefreshToken() {
|
||||
long currentTimeInSeconds = System.currentTimeMillis() / 1000;
|
||||
LOG.info("Current timestamp " +currentTimeInSeconds);
|
||||
List<StravaConnector> stravaConnectors = stravaConnectorDAO.getAll();
|
||||
for (StravaConnector stravaConnector : stravaConnectors) {
|
||||
LOG.info("Strava Connector ID: " + stravaConnector.getAthleteId() + " Token expires at " + stravaConnector.getExpiresAt());
|
||||
if (currentTimeInSeconds > stravaConnector.getExpiresAt()) {
|
||||
LOG.info("The token for athlete " +stravaConnector.getAthleteId() + " has been expired for " + (currentTimeInSeconds - stravaConnector.getExpiresAt()) + " seconds." );
|
||||
Map<String, Object> result = stravaAuthClient.refreshToken(stravaConnector.getClientId(), stravaConnector.getClientSecret(), "refresh_token", stravaConnector.getRefreshToken());
|
||||
Assert.isEqual(result.get("token_type"), "Bearer");
|
||||
stravaConnector.setAccessToken(result.get("access_token").toString());
|
||||
stravaConnector.setRefreshToken(result.get("refresh_token").toString());
|
||||
stravaConnector.setExpiresAt(currentTimeInSeconds);
|
||||
stravaConnector.setExpiresAt((Integer)result.get("expires_at"));
|
||||
stravaConnectorDAO.update(stravaConnector);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
68
peakup-backend/src/main/java/com/peakup/dao/AbstractDAO.java
Normal file
68
peakup-backend/src/main/java/com/peakup/dao/AbstractDAO.java
Normal file
@@ -0,0 +1,68 @@
|
||||
package com.peakup.dao;
|
||||
|
||||
import com.peakup.common.PersistenceException;
|
||||
import com.peakup.model.AbstractEntity;
|
||||
import com.peakup.model.AbstractEntity_;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.CriteriaQuery;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public abstract class AbstractDAO<T extends AbstractEntity> {
|
||||
|
||||
@PersistenceContext
|
||||
protected EntityManager em;
|
||||
|
||||
public T getById(long id) {
|
||||
CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
CriteriaQuery<T> cq = cb.createQuery(getEntityClass());
|
||||
Root<T> root = cq.from(getEntityClass());
|
||||
cq.select(root).where(cb.equal(root.get(AbstractEntity_.id), id));
|
||||
return em.createQuery(cq).getSingleResult();
|
||||
}
|
||||
|
||||
public List<T> getAll() {
|
||||
final CriteriaQuery<T> criteria = getTypedCriteriaQuery();
|
||||
criteria.select(criteria.from(getEntityClass()));
|
||||
return em.createQuery(criteria).getResultList();
|
||||
}
|
||||
|
||||
private CriteriaQuery<T> getTypedCriteriaQuery() {
|
||||
CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
final CriteriaQuery<T> criteria = cb.createQuery(getEntityClass());
|
||||
return criteria;
|
||||
}
|
||||
|
||||
protected abstract Class<T> getEntityClass();
|
||||
|
||||
protected EntityManager getEntityManager() {
|
||||
return em;
|
||||
}
|
||||
|
||||
public void insert(T entity) {
|
||||
if (entity.getId() != null) {
|
||||
throw new PersistenceException(
|
||||
"Entity already has an id, use update() instead: " + entity
|
||||
);
|
||||
}
|
||||
em.persist(entity); // nach persist ist entity attached
|
||||
}
|
||||
public void update(T entity) {
|
||||
if (!em.contains(entity)) {
|
||||
throw new PersistenceException(
|
||||
"Entity is not managed (attached). Use merge() or re-fetch before update: " + entity
|
||||
);
|
||||
}
|
||||
// Änderungen werden automatisch von JPA übernommen
|
||||
}
|
||||
public void delete(T entity) {
|
||||
if (!em.contains(entity)) {
|
||||
entity = em.merge(entity); // sichergehen, dass attached
|
||||
}
|
||||
em.remove(entity);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.peakup.dao;
|
||||
|
||||
|
||||
import com.peakup.athlete.model.ActivityType;
|
||||
import com.peakup.athlete.model.StatisticsType;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
|
||||
public abstract class AbstractEnumConverter<V extends PersistableEnum> implements AttributeConverter<V, Integer> {
|
||||
|
||||
protected AbstractEnumConverter() {
|
||||
super();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Integer convertToDatabaseColumn(V attribute) {
|
||||
return (attribute != null) ? attribute.getKey() : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public V convertToEntityAttribute(Integer dbData) {
|
||||
if (dbData == null) return null;
|
||||
return getEnumByKey(dbData);
|
||||
}
|
||||
|
||||
public abstract V getEnumByKey(Integer key);
|
||||
|
||||
@Converter(autoApply = true)
|
||||
public static class StatisticsTypeConverter extends AbstractEnumConverter<StatisticsType> {
|
||||
public StatisticsTypeConverter() {
|
||||
super();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StatisticsType getEnumByKey(Integer key) {
|
||||
return PersistableEnum.getEnumByKey(key, StatisticsType.values());
|
||||
}
|
||||
}
|
||||
|
||||
@Converter(autoApply = true)
|
||||
public static class ActivityTypeConverter extends AbstractEnumConverter<ActivityType> {
|
||||
public ActivityTypeConverter() {
|
||||
super();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ActivityType getEnumByKey(Integer key) {
|
||||
return PersistableEnum.getEnumByKey(key, ActivityType.values());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.peakup.dao;
|
||||
|
||||
import com.peakup.common.PersistenceException;
|
||||
import com.peakup.model.AbstractEntity;
|
||||
import com.peakup.model.AbstractEntity_;
|
||||
import com.peakup.model.AbstractTimestampedEntity;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||
import jakarta.persistence.criteria.CriteriaQuery;
|
||||
import jakarta.persistence.criteria.Root;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
public abstract class AbstractTimestampedDAO<T extends AbstractTimestampedEntity> extends AbstractDAO<T> {
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.peakup.dao;
|
||||
|
||||
public interface PersistableEnum<V extends PersistableEnum > {
|
||||
Integer getKey();
|
||||
|
||||
static <T extends PersistableEnum> T getEnumByKey(Integer key, T[] values) {
|
||||
for (T v : values) {
|
||||
if (v.getKey().equals( key)) return v;
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown key: " + key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.peakup.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@MappedSuperclass
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public abstract class AbstractEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "global_seq")
|
||||
@SequenceGenerator(name = "global_seq", sequenceName = "global_id_seq", allocationSize = 1)
|
||||
@Column(name = "ID")
|
||||
private Long id;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.peakup.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.MappedSuperclass;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@MappedSuperclass
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public abstract class AbstractTimestampedEntity extends AbstractEntity {
|
||||
|
||||
@Column
|
||||
private LocalDateTime modifiedAt;
|
||||
|
||||
@Column
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
quarkus.http.port=8111
|
||||
|
||||
quarkus.datasource.db-kind=postgresql
|
||||
quarkus.datasource.username=peakup
|
||||
quarkus.datasource.password=peakup
|
||||
quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/peakup
|
||||
|
||||
# Flyway aktivieren
|
||||
quarkus.hibernate-orm.sql-load-script=no-file
|
||||
quarkus.hibernate-orm.physical-naming-strategy=org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy
|
||||
|
||||
quarkus.flyway.migrate-at-start=true
|
||||
quarkus.flyway.clean-at-start=false
|
||||
|
||||
# strava
|
||||
strava-api/mp-rest/url=https://www.strava.com
|
||||
|
||||
23
peakup-backend/src/main/resources/db/migration/V1__init.sql
Normal file
23
peakup-backend/src/main/resources/db/migration/V1__init.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- Tabelle für Athlete
|
||||
CREATE TABLE athlete (
|
||||
id BIGSERIAL PRIMARY KEY, -- Auto-Inkrement
|
||||
first_name VARCHAR(255),
|
||||
last_name VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
modified_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Tabelle für StravaConnector
|
||||
CREATE TABLE strava_connector (
|
||||
id BIGSERIAL PRIMARY KEY, -- Auto-Inkrement
|
||||
athlete_id BIGINT UNIQUE, -- Unique Constraint
|
||||
strava_athlete_id VARCHAR(255),
|
||||
access_token VARCHAR(512),
|
||||
refresh_token VARCHAR(512),
|
||||
expires_at BIGINT,
|
||||
client_id VARCHAR(255),
|
||||
client_secret VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
modified_at TIMESTAMP DEFAULT NOW(),
|
||||
CONSTRAINT fk_athlete FOREIGN KEY (athlete_id) REFERENCES athlete(id)
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- ============================================
|
||||
-- GLOBAL SEQUENCE für alle Entities
|
||||
-- ============================================
|
||||
CREATE SEQUENCE global_id_seq START WITH 100 INCREMENT BY 1;
|
||||
@@ -0,0 +1,75 @@
|
||||
-- ============================================
|
||||
-- Flyway Migration: Create Statistics and StatisticsTotal Tables
|
||||
-- ============================================
|
||||
|
||||
-- Optional: falls du Sequenzen zur ID-Generierung nutzt:
|
||||
-- CREATE SEQUENCE statistics_total_id_seq START WITH 1 INCREMENT BY 1;
|
||||
-- CREATE SEQUENCE statistics_id_seq START WITH 1 INCREMENT BY 1;
|
||||
|
||||
-- ==================================================
|
||||
-- TABLE: STATISTICS_TOTAL
|
||||
-- ==================================================
|
||||
CREATE TABLE statistics_total (
|
||||
id BIGINT PRIMARY KEY,
|
||||
activity INTEGER, -- Enum: ActivityType
|
||||
statistics_type INTEGER, -- Enum: StatisticsType
|
||||
count INT NOT NULL,
|
||||
moving_time_seconds BIGINT NOT NULL,
|
||||
distance_meter BIGINT NOT NULL,
|
||||
elevation_meter BIGINT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
modified_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Optional: falls du Trigger für automatische updated_at-Pflege willst:
|
||||
-- CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
-- RETURNS TRIGGER AS $$
|
||||
-- BEGIN
|
||||
-- NEW.updated_at = NOW();
|
||||
-- RETURN NEW;
|
||||
-- END;
|
||||
-- $$ language 'plpgsql';
|
||||
--
|
||||
-- CREATE TRIGGER update_statistics_total_updated_at
|
||||
-- BEFORE UPDATE ON statistics_total
|
||||
-- FOR EACH ROW
|
||||
-- EXECUTE PROCEDURE update_updated_at_column();
|
||||
|
||||
-- ==================================================
|
||||
-- TABLE: STATISTICS
|
||||
-- ==================================================
|
||||
CREATE TABLE statistics (
|
||||
id BIGINT PRIMARY KEY,
|
||||
athlete_id BIGINT, -- Verweis auf Athlete
|
||||
total_id BIGINT,
|
||||
total_yearly_id BIGINT,
|
||||
total_recent_id BIGINT,
|
||||
|
||||
CONSTRAINT fk_statistics_athlete
|
||||
FOREIGN KEY (athlete_id)
|
||||
REFERENCES athlete (id)
|
||||
ON DELETE SET NULL,
|
||||
|
||||
CONSTRAINT fk_statistics_total
|
||||
FOREIGN KEY (total_id)
|
||||
REFERENCES statistics_total (id)
|
||||
ON DELETE SET NULL,
|
||||
|
||||
CONSTRAINT fk_statistics_total_yearly
|
||||
FOREIGN KEY (total_yearly_id)
|
||||
REFERENCES statistics_total (id)
|
||||
ON DELETE SET NULL,
|
||||
|
||||
CONSTRAINT fk_statistics_total_recent
|
||||
FOREIGN KEY (total_recent_id)
|
||||
REFERENCES statistics_total (id)
|
||||
ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- ==================================================
|
||||
-- INDEXES
|
||||
-- ==================================================
|
||||
CREATE INDEX idx_statistics_athlete_id ON statistics (athlete_id);
|
||||
CREATE INDEX idx_statistics_total_id ON statistics (total_id);
|
||||
CREATE INDEX idx_statistics_total_yearly_id ON statistics (total_yearly_id);
|
||||
CREATE INDEX idx_statistics_total_recent_id ON statistics (total_recent_id);
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.peakup;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusIntegrationTest;
|
||||
|
||||
@QuarkusIntegrationTest
|
||||
class ExampleResourceIT extends ExampleResourceTest {
|
||||
// Execute the same tests but in packaged mode.
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.peakup;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.CoreMatchers.is;
|
||||
|
||||
@QuarkusTest
|
||||
class ExampleResourceTest {
|
||||
@Test
|
||||
void testHelloEndpoint() {
|
||||
given()
|
||||
.when().get("/hello")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body(is("Hello from Quarkus REST"));
|
||||
}
|
||||
|
||||
}
|
||||
36
peakup-backend/src/test/java/com/peakup/dao/DAOTest.java
Normal file
36
peakup-backend/src/test/java/com/peakup/dao/DAOTest.java
Normal file
@@ -0,0 +1,36 @@
|
||||
package com.peakup.dao;
|
||||
|
||||
import com.peakup.athlete.dao.AthleteDAO;
|
||||
import com.peakup.athlete.model.Athlete;
|
||||
import com.peakup.connector.strava.dao.StravaConnectorDAO;
|
||||
import com.peakup.connector.strava.model.StravaConnector;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@QuarkusTest
|
||||
class DAOTest {
|
||||
|
||||
@Inject
|
||||
AthleteDAO athleteDAO;
|
||||
|
||||
|
||||
@Inject
|
||||
StravaConnectorDAO stravaConnectorDAO;
|
||||
|
||||
@Test
|
||||
@Transactional
|
||||
void testGetAthleteById() {
|
||||
Athlete found = athleteDAO.getById(1);
|
||||
assertNotNull(found);
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional
|
||||
void testGetStravaConnctorByAthleteId() {
|
||||
StravaConnector found = stravaConnectorDAO.getByAthleteId(1);
|
||||
assertNotNull(found);
|
||||
}
|
||||
}
|
||||
0
resources/insert_athlete.sql
Normal file
0
resources/insert_athlete.sql
Normal file
100
resources/strava-api.json
Normal file
100
resources/strava-api.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"_type": "export",
|
||||
"__export_format": 4,
|
||||
"__export_date": "2025-09-19T12:00:00.000Z",
|
||||
"__export_source": "insomnia.desktop.app:v2025.1.0",
|
||||
"resources": [
|
||||
{
|
||||
"_id": "wrk_strava",
|
||||
"_type": "workspace",
|
||||
"name": "Strava API",
|
||||
"scope": "collection"
|
||||
},
|
||||
{
|
||||
"_id": "env_strava",
|
||||
"_type": "environment",
|
||||
"parentId": "wrk_strava",
|
||||
"name": "Base Environment",
|
||||
"data": {
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"redirect_uri": "http://localhost/exchange_token",
|
||||
"access_token": "",
|
||||
"refresh_token": "",
|
||||
"athlete_id": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"_id": "req_auth_token",
|
||||
"_type": "request",
|
||||
"parentId": "wrk_strava",
|
||||
"name": "Get Access Token",
|
||||
"method": "POST",
|
||||
"url": "https://www.strava.com/oauth/token",
|
||||
"body": {
|
||||
"mimeType": "application/x-www-form-urlencoded",
|
||||
"params": [
|
||||
{ "name": "client_id", "value": "{{ client_id }}" },
|
||||
{ "name": "client_secret", "value": "{{ client_secret }}" },
|
||||
{ "name": "code", "value": "AUTHORIZATION_CODE" },
|
||||
{ "name": "grant_type", "value": "authorization_code" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"_id": "req_refresh_token",
|
||||
"_type": "request",
|
||||
"parentId": "wrk_strava",
|
||||
"name": "Refresh Access Token",
|
||||
"method": "POST",
|
||||
"url": "https://www.strava.com/oauth/token",
|
||||
"body": {
|
||||
"mimeType": "application/x-www-form-urlencoded",
|
||||
"params": [
|
||||
{ "name": "client_id", "value": "{{ client_id }}" },
|
||||
{ "name": "client_secret", "value": "{{ client_secret }}" },
|
||||
{ "name": "grant_type", "value": "refresh_token" },
|
||||
{ "name": "refresh_token", "value": "{{ refresh_token }}" }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"_id": "req_athlete",
|
||||
"_type": "request",
|
||||
"parentId": "wrk_strava",
|
||||
"name": "Get Logged-In Athlete",
|
||||
"method": "GET",
|
||||
"url": "https://www.strava.com/api/v3/athlete",
|
||||
"headers": [
|
||||
{ "name": "Authorization", "value": "Bearer {{ access_token }}" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"_id": "req_activities",
|
||||
"_type": "request",
|
||||
"parentId": "wrk_strava",
|
||||
"name": "Get Athlete Activities",
|
||||
"method": "GET",
|
||||
"url": "https://www.strava.com/api/v3/athlete/activities",
|
||||
"headers": [
|
||||
{ "name": "Authorization", "value": "Bearer {{ access_token }}" }
|
||||
],
|
||||
"parameters": [
|
||||
{ "name": "per_page", "value": "10" },
|
||||
{ "name": "page", "value": "1" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"_id": "req_activity",
|
||||
"_type": "request",
|
||||
"parentId": "wrk_strava",
|
||||
"name": "Get Activity by ID",
|
||||
"method": "GET",
|
||||
"url": "https://www.strava.com/api/v3/activities/{{ activity_id }}",
|
||||
"headers": [
|
||||
{ "name": "Authorization", "value": "Bearer {{ access_token }}" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user