Skip to content

Commit

Permalink
Merge pull request #112 from MORE-Platform/111_participant_ids_endpoint
Browse files Browse the repository at this point in the history
#111 Add an Endpoint to List Participants for External Services
  • Loading branch information
ja-fra authored Apr 23, 2024
2 parents 949df27 + 7c69888 commit d7f359d
Show file tree
Hide file tree
Showing 8 changed files with 189 additions and 32 deletions.
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.ParticipantTransformer;
import io.redlink.more.data.exception.BadRequestException;
import io.redlink.more.data.model.ApiRoutingInfo;
import io.redlink.more.data.model.RoutingInfo;
Expand All @@ -27,15 +29,14 @@
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)
public class ExternalDataApiV1Controller implements ExternalDataApi {

private static final Logger LOG = LoggerFactory.getLogger(DataApiV1Controller.class);
private static final Logger LOG = LoggerFactory.getLogger(ExternalDataApiV1Controller.class);

private final ExternalService externalService;
private final ElasticService elasticService;

Expand All @@ -45,27 +46,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(ParticipantTransformer::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 +75,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
@@ -0,0 +1,36 @@
package io.redlink.more.data.controller.transformer;

import io.redlink.more.data.api.app.v1.model.ParticipantDTO;
import io.redlink.more.data.api.app.v1.model.ParticipantStatusDTO;
import io.redlink.more.data.api.app.v1.model.ParticipantStudyGroupDTO;
import io.redlink.more.data.model.Participant;

public final class ParticipantTransformer {

private ParticipantTransformer() {}

public static ParticipantDTO toDTO(Participant participant) {
if (participant == null) {
return null;
}
return new ParticipantDTO(
String.valueOf(participant.id()),
participant.alias(),
ParticipantStatusDTO.fromValue(participant.status()),
toGroupDto(participant),
BaseTransformers.toOffsetDateTime(participant.start())
);
}

private static ParticipantStudyGroupDTO toGroupDto(Participant participant) {
if (participant.studyGroupId().isPresent()) {
final int groupId = participant.studyGroupId().getAsInt();
return new ParticipantStudyGroupDTO(
String.valueOf(groupId),
participant.studyGroupTitle()
);
} else {
return null;
}
}
}
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
13 changes: 13 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,13 @@
package io.redlink.more.data.model;

import java.time.Instant;
import java.util.OptionalInt;

public record Participant(
int id,
String alias,
String status,
OptionalInt studyGroupId,
String studyGroupTitle,
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,12 @@ 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, sg.study_group_id, sg.title as study_group_title, start " +
"FROM participants p LEFT OUTER JOIN study_groups sg ON ( p.study_id = sg.study_id AND p.study_group_id = sg.study_group_id ) " +
"WHERE p.study_id = :study_id " +
"AND (p.study_group_id = :study_group_id OR :study_group_id::INT IS NULL)";

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 +198,15 @@ public Optional<SimpleParticipant> findParticipant(RoutingInfo routingInfo) {
}
}

public List<Participant> listParticipants(long studyId, OptionalInt groupId) {
return namedTemplate.query(
SQL_LIST_PARTICIPANTS_BY_STUDY,
new MapSqlParameterSource()
.addValue("study_id", studyId)
.addValue("study_group_id", groupId.isPresent() ? groupId.getAsInt() : null),
getParticipantRowMapper());
}

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 +362,17 @@ 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"),
DbUtils.readOptionalInt(rs, "study_group_id"),
rs.getString("study_group_title"),
toInstant(rs.getTimestamp("start"))
);
}

private static RowMapper<RoutingInfo> getRoutingInfoMapper() {
return ((row, rowNum) ->
new RoutingInfo(
Expand Down
30 changes: 26 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,27 @@ 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 +85,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);
}
}
62 changes: 62 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,47 @@ components:
- dataValue
- timestamp

Participant:
type: object
description: A participant for a study
properties:
participantId:
type: string
alias:
type: string
status:
$ref: '#/components/schemas/ParticipantStatus'
studyGroup:
type: object
properties:
groupId:
type: string
name:
type: string
required:
- groupId
- name
nullable: true
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

0 comments on commit d7f359d

Please sign in to comment.