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 all 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.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
Loading