diff --git a/src/main/java/io/redlink/more/data/configuration/SecurityConfig.java b/src/main/java/io/redlink/more/data/configuration/SecurityConfig.java index 585949a..ad0927d 100644 --- a/src/main/java/io/redlink/more/data/configuration/SecurityConfig.java +++ b/src/main/java/io/redlink/more/data/configuration/SecurityConfig.java @@ -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 diff --git a/src/main/java/io/redlink/more/data/controller/ExternalDataApiV1Controller.java b/src/main/java/io/redlink/more/data/controller/ExternalDataApiV1Controller.java index 5384296..60050ae 100644 --- a/src/main/java/io/redlink/more/data/controller/ExternalDataApiV1Controller.java +++ b/src/main/java/io/redlink/more/data/controller/ExternalDataApiV1Controller.java @@ -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; @@ -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; @@ -45,27 +46,26 @@ public ExternalDataApiV1Controller(ExternalService externalService, ElasticServi } @Override - public ResponseEntity storeExternalBulk(String moreApiToken, EndpointDataBulkDTO endpointDataBulkDTO) { + public ResponseEntity> 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 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 = 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()) @@ -75,13 +75,13 @@ public ResponseEntity 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 { diff --git a/src/main/java/io/redlink/more/data/controller/transformer/ParticipantTransformer.java b/src/main/java/io/redlink/more/data/controller/transformer/ParticipantTransformer.java new file mode 100644 index 0000000..559063e --- /dev/null +++ b/src/main/java/io/redlink/more/data/controller/transformer/ParticipantTransformer.java @@ -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; + } + } +} diff --git a/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java b/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java index 1aa5979..d46a2df 100644 --- a/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java +++ b/src/main/java/io/redlink/more/data/controller/transformer/StudyTransformer.java @@ -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 { diff --git a/src/main/java/io/redlink/more/data/model/Participant.java b/src/main/java/io/redlink/more/data/model/Participant.java new file mode 100644 index 0000000..6f2bfba --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/Participant.java @@ -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 +) {} diff --git a/src/main/java/io/redlink/more/data/repository/StudyRepository.java b/src/main/java/io/redlink/more/data/repository/StudyRepository.java index dc085c6..df37d27 100644 --- a/src/main/java/io/redlink/more/data/repository/StudyRepository.java +++ b/src/main/java/io/redlink/more/data/repository/StudyRepository.java @@ -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 = ?"; @@ -192,6 +198,15 @@ public Optional findParticipant(RoutingInfo routingInfo) { } } + public List 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 listObservations(long studyId, int groupId, int participantId, boolean filterByGroup) { if(filterByGroup) { return jdbcTemplate.query(SQL_LIST_OBSERVATIONS_BY_STUDY, getObservationRowMapper(), studyId, groupId).stream() @@ -347,6 +362,17 @@ private static RowMapper getObservationRowMapper() { ); } + private static RowMapper 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 getRoutingInfoMapper() { return ((row, rowNum) -> new RoutingInfo( diff --git a/src/main/java/io/redlink/more/data/service/ExternalService.java b/src/main/java/io/redlink/more/data/service/ExternalService.java index a6beda6..61ec1eb 100644 --- a/src/main/java/io/redlink/more/data/service/ExternalService.java +++ b/src/main/java/io/redlink/more/data/service/ExternalService.java @@ -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; @@ -32,13 +36,27 @@ public ExternalService(StudyRepository repository, PasswordEncoder passwordEncod this.repository = repository; this.passwordEncoder = passwordEncoder; } - public Optional 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 = 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) { @@ -67,4 +85,8 @@ public Interval getIntervalForObservation(Long studyId, Integer observationId, I }) .orElseThrow(BadRequestException::TimeFrame); } + + public List listParticipants(Long studyId, OptionalInt studyGroupId) { + return repository.listParticipants(studyId, studyGroupId); + } } diff --git a/src/main/resources/openapi/ExternalAPI.yaml b/src/main/resources/openapi/ExternalAPI.yaml index cd8ae01..6c0e4f2 100644 --- a/src/main/resources/openapi/ExternalAPI.yaml +++ b/src/main/resources/openapi/ExternalAPI.yaml @@ -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: @@ -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