Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#111 Add an Endpoint to List Participants for External Services #112

Merged
merged 6 commits into from
Apr 23, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ public SecurityFilterChain filterChain(HttpSecurity http,
//External Data Gateway
req.requestMatchers("/api/v1/external/bulk")
.permitAll();
req.requestMatchers("/api/v1/external/participants")
.permitAll();
req.requestMatchers("/api/v1/calendar/studies/*/calendar.ics")
.permitAll();
// all other apis require credentials
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@

import io.redlink.more.data.api.app.v1.model.EndpointDataBulkDTO;
import io.redlink.more.data.api.app.v1.model.ExternalDataDTO;
import io.redlink.more.data.api.app.v1.model.ParticipantDTO;
import io.redlink.more.data.api.app.v1.webservices.ExternalDataApi;
import io.redlink.more.data.controller.transformer.DataTransformer;
import io.redlink.more.data.controller.transformer.StudyTransformer;
import io.redlink.more.data.exception.BadRequestException;
import io.redlink.more.data.model.ApiRoutingInfo;
import io.redlink.more.data.model.RoutingInfo;
Expand All @@ -27,9 +29,7 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Base64;
import java.util.List;
import java.util.Optional;

@RestController
@RequestMapping(value = "/api/v1", produces = MediaType.APPLICATION_JSON_VALUE)
Expand All @@ -45,27 +45,26 @@ public ExternalDataApiV1Controller(ExternalService externalService, ElasticServi
}

@Override
public ResponseEntity<Void> storeExternalBulk(String moreApiToken, EndpointDataBulkDTO endpointDataBulkDTO) {
public ResponseEntity<List<ParticipantDTO>> listParticipants(String moreApiToken) {
try {
String[] split = moreApiToken.split("\\.");
String[] primaryKey = new String(Base64.getDecoder().decode(split[0])).split("-");
ApiRoutingInfo apiRoutingInfo = externalService.getRoutingInfo(moreApiToken);
return ResponseEntity.ok(
externalService.listParticipants(apiRoutingInfo.studyId(), apiRoutingInfo.studyGroupId())
.stream()
.map(StudyTransformer::toDTO)
.toList()
);
} catch(IndexOutOfBoundsException | NumberFormatException e) {
throw new AccessDeniedException("Invalid Token");
}
}

Long studyId = Long.valueOf(primaryKey[0]);
Integer observationId = Integer.valueOf(primaryKey[1]);
@Override
public ResponseEntity<Void> storeExternalBulk(String moreApiToken, EndpointDataBulkDTO endpointDataBulkDTO) {
try {
ApiRoutingInfo apiRoutingInfo = externalService.getRoutingInfo(moreApiToken);
Integer participantId = Integer.valueOf(endpointDataBulkDTO.getParticipantId());
Integer tokenId = Integer.valueOf(primaryKey[2]);
String secret = new String(Base64.getDecoder().decode(split[1]));

final Optional<ApiRoutingInfo> apiRoutingInfo = externalService.getRoutingInfo(
studyId,
observationId,
tokenId,
secret);
if(apiRoutingInfo.isEmpty()) {
throw new AccessDeniedException("Invalid token");
}

Interval interval = externalService.getIntervalForObservation(studyId, observationId, participantId);
Interval interval = externalService.getIntervalForObservation(apiRoutingInfo.studyId(), apiRoutingInfo.observationId(), participantId);

endpointDataBulkDTO.getDataPoints().stream()
.map(datapoint -> datapoint.getTimestamp().toInstant())
Expand All @@ -75,13 +74,13 @@ public ResponseEntity<Void> storeExternalBulk(String moreApiToken, EndpointDataB
.orElseThrow(BadRequestException::TimeFrame);

final RoutingInfo routingInfo = new RoutingInfo(
externalService.validateRoutingInfo(apiRoutingInfo.get(), participantId),
externalService.validateRoutingInfo(apiRoutingInfo, participantId),
participantId
);
try (LoggingUtils.LoggingContext ctx = LoggingUtils.createContext(routingInfo)) {
if(routingInfo.studyActive()) {
elasticService.storeDataPoints(
DataTransformer.createDataPoints(endpointDataBulkDTO, apiRoutingInfo.get(), observationId),
DataTransformer.createDataPoints(endpointDataBulkDTO, apiRoutingInfo, apiRoutingInfo.observationId()),
routingInfo
);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,11 @@
package io.redlink.more.data.controller.transformer;

import io.redlink.more.data.api.app.v1.model.*;
import io.redlink.more.data.model.Contact;
import io.redlink.more.data.model.Observation;
import io.redlink.more.data.model.SimpleParticipant;
import io.redlink.more.data.model.Study;
import io.redlink.more.data.model.*;
import io.redlink.more.data.schedule.SchedulerUtils;
import org.apache.commons.lang3.tuple.Pair;

import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;

public final class StudyTransformer {
Expand Down Expand Up @@ -45,6 +41,19 @@ public static SimpleParticipantDTO toDTO(SimpleParticipant participant) {
.alias(participant.alias());
}

public static ParticipantDTO toDTO(Participant participant) {
ja-fra marked this conversation as resolved.
Show resolved Hide resolved
if(participant == null) {
return null;
}
return new ParticipantDTO(
participant.id(),
participant.alias(),
ParticipantStatusDTO.fromValue(participant.status()),
participant.studyGroupId(),
BaseTransformers.toOffsetDateTime(participant.start())
);
}

public static ContactInfoDTO toDTO(Contact contact) {
return new ContactInfoDTO()
.institute(contact.institute())
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/io/redlink/more/data/model/Participant.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package io.redlink.more.data.model;

import java.time.Instant;

public record Participant(
int id,
String alias,
String status,
Integer studyGroupId,
Instant start
) {}
26 changes: 26 additions & 0 deletions src/main/java/io/redlink/more/data/repository/StudyRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ public class StudyRepository {
"SET status = :newStatus::participant_status, start = :start, modified = now() " +
"WHERE study_id = :study_id AND participant_id = :participant_id AND status = :oldStatus::participant_status";

private static final String SQL_LIST_PARTICIPANTS_BY_STUDY =
"SELECT participant_id, alias, status, study_group_id, start FROM participants " +
"WHERE study_id = ?";

private static final String SQL_LIST_PARTICIPANTS_BY_STUDY_AND_GROUP =
"SELECT participant_id, alias, status, study_group_id, start FROM participants " +
"WHERE study_id = ? AND study_group_id = ?";
ja-fra marked this conversation as resolved.
Show resolved Hide resolved

private static final String GET_OBSERVATION_PROPERTIES_FOR_PARTICIPANT =
"SELECT properties FROM participant_observation_properties " +
"WHERE study_id = ? AND participant_id = ? AND observation_id = ?";
Expand Down Expand Up @@ -192,6 +200,14 @@ public Optional<SimpleParticipant> findParticipant(RoutingInfo routingInfo) {
}
}

public List<Participant> listParticipants(long studyId, int groupId) {
if(groupId < 0) {
return jdbcTemplate.query(SQL_LIST_PARTICIPANTS_BY_STUDY, getParticipantRowMapper(), studyId);
ja-fra marked this conversation as resolved.
Show resolved Hide resolved
} else {
return jdbcTemplate.query(SQL_LIST_PARTICIPANTS_BY_STUDY_AND_GROUP, getParticipantRowMapper(), studyId, groupId);
}
}

private List<Observation> listObservations(long studyId, int groupId, int participantId, boolean filterByGroup) {
if(filterByGroup) {
return jdbcTemplate.query(SQL_LIST_OBSERVATIONS_BY_STUDY, getObservationRowMapper(), studyId, groupId).stream()
Expand Down Expand Up @@ -347,6 +363,16 @@ private static RowMapper<Observation> getObservationRowMapper() {
);
}

private static RowMapper<Participant> getParticipantRowMapper() {
return (rs, rowNul) -> new Participant(
rs.getInt("participant_id"),
rs.getString("alias"),
rs.getString("status"),
rs.getInt("study_group_id"),
toInstant(rs.getTimestamp("start"))
);
}

private static RowMapper<RoutingInfo> getRoutingInfoMapper() {
return ((row, rowNum) ->
new RoutingInfo(
Expand Down
29 changes: 25 additions & 4 deletions src/main/java/io/redlink/more/data/service/ExternalService.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,18 @@
import io.redlink.more.data.exception.BadRequestException;
import io.redlink.more.data.exception.NotFoundException;
import io.redlink.more.data.model.ApiRoutingInfo;
import io.redlink.more.data.model.Participant;
import io.redlink.more.data.model.scheduler.Event;
import io.redlink.more.data.model.scheduler.Interval;
import io.redlink.more.data.model.scheduler.RelativeEvent;
import io.redlink.more.data.repository.StudyRepository;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Base64;
import java.util.List;
import java.util.Optional;
import java.util.OptionalInt;

Expand All @@ -32,13 +36,26 @@ public ExternalService(StudyRepository repository, PasswordEncoder passwordEncod
this.repository = repository;
this.passwordEncoder = passwordEncoder;
}
public Optional<ApiRoutingInfo> getRoutingInfo(
Long studyId, Integer observationId, Integer tokenId, String apiSecret
public ApiRoutingInfo getRoutingInfo(
String moreApiToken
) {
return repository.getApiRoutingInfo(studyId, observationId, tokenId)
String[] split = moreApiToken.split("\\.");
String[] primaryKey = new String(Base64.getDecoder().decode(split[0])).split("-");

Long studyId = Long.valueOf(primaryKey[0]);
Integer observationId = Integer.valueOf(primaryKey[1]);
Integer tokenId = Integer.valueOf(primaryKey[2]);
String secret = new String(Base64.getDecoder().decode(split[1]));


final Optional<ApiRoutingInfo> apiRoutingInfo = repository.getApiRoutingInfo(studyId, observationId, tokenId)
.stream().filter(route ->
passwordEncoder.matches(apiSecret, route.secret()))
passwordEncoder.matches(secret, route.secret()))
.findFirst();
if (apiRoutingInfo.isEmpty()) {
throw new AccessDeniedException("Invalid token");
}
return apiRoutingInfo.get();
}

public ApiRoutingInfo validateRoutingInfo(ApiRoutingInfo routingInfo, Integer participantId) {
Expand Down Expand Up @@ -67,4 +84,8 @@ public Interval getIntervalForObservation(Long studyId, Integer observationId, I
})
.orElseThrow(BadRequestException::TimeFrame);
}

public List<Participant> listParticipants(Long studyId, OptionalInt studyGroupId) {
return repository.listParticipants(studyId, studyGroupId.orElse(Integer.MIN_VALUE));
}
}
55 changes: 55 additions & 0 deletions src/main/resources/openapi/ExternalAPI.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,27 @@ paths:
$ref: '#/components/responses/UnauthorizedApiKey'
'404':
description: not found

/external/participants:
get:
operationId: listParticipants
description: List participants for given study
tags:
- ExternalData
parameters:
- $ref: '#/components/parameters/ExternalApiToken'
responses:
'200':
description: Successfully returned list of participants for given study
content:
application/json:
schema:
type: array
items:
$ref : '#/components/schemas/Participant'
'401':
$ref: '#/components/responses/UnauthorizedApiKey'

/calendar/studies/{studyId}/calendar.ics:
get:
tags:
Expand Down Expand Up @@ -87,6 +108,40 @@ components:
- dataValue
- timestamp

Participant:
type: object
description: A participant for a study
properties:
participantId:
type: integer
format: int32
ja-fra marked this conversation as resolved.
Show resolved Hide resolved
alias:
type: string
status:
$ref: '#/components/schemas/ParticipantStatus'
studyGroup:
type: integer
format: int32
start:
type: string
format: date-time
required:
- participantId
- alias
- status
- studyGroup
- start

ParticipantStatus:
type: string
enum:
- new
- active
- abandoned
- kicked_out
- locked
default: new

parameters:
ExternalApiToken:
name: More-Api-Token
Expand Down
Loading