Skip to content

Commit 14b4975

Browse files
authored
Clickhousify samples endpoint (cBioPortal#11393)
* add sample column store controller, service, repository, and mapper classes * add sample_type, sequenced, and copy_number_segment_present to sample_derived * add fields required for detailed projection * add meta samples mappers * add remaining endpoints * reorganize files in favor of the clickhouse only clean architecture * reduce number of sample_derived columns by using join for the detailed projection * replace sample service with individual use cases * use ArrayType Handler wherever possible * add tests * add support for sample filter in hasPermission
1 parent 662613e commit 14b4975

25 files changed

+2272
-24
lines changed
Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
package org.cbioportal.application.rest.vcolumnstore;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.Parameter;
5+
import io.swagger.v3.oas.annotations.media.ArraySchema;
6+
import io.swagger.v3.oas.annotations.media.Content;
7+
import io.swagger.v3.oas.annotations.media.Schema;
8+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
9+
import jakarta.validation.Valid;
10+
import jakarta.validation.constraints.Max;
11+
import jakarta.validation.constraints.Min;
12+
import org.cbioportal.application.rest.mapper.SampleMapper;
13+
import org.cbioportal.application.rest.response.SampleDTO;
14+
import org.cbioportal.legacy.model.CancerStudy;
15+
import org.cbioportal.domain.sample.Sample;
16+
import org.cbioportal.domain.sample.usecase.SampleUseCases;
17+
import org.cbioportal.legacy.model.meta.BaseMeta;
18+
import org.cbioportal.legacy.service.StudyService;
19+
import org.cbioportal.legacy.service.exception.PatientNotFoundException;
20+
import org.cbioportal.legacy.service.exception.SampleNotFoundException;
21+
import org.cbioportal.legacy.service.exception.StudyNotFoundException;
22+
import org.cbioportal.legacy.utils.security.AccessLevel;
23+
import org.cbioportal.legacy.utils.security.PortalSecurityConfig;
24+
import org.cbioportal.legacy.web.parameter.Direction;
25+
import org.cbioportal.legacy.web.parameter.HeaderKeyConstants;
26+
import org.cbioportal.legacy.web.parameter.PagingConstants;
27+
import org.cbioportal.legacy.web.parameter.SampleFilter;
28+
import org.cbioportal.legacy.web.parameter.sort.SampleSortBy;
29+
import org.cbioportal.shared.enums.ProjectionType;
30+
import org.springframework.beans.factory.annotation.Value;
31+
import org.springframework.context.annotation.Profile;
32+
import org.springframework.http.HttpHeaders;
33+
import org.springframework.http.HttpStatus;
34+
import org.springframework.http.MediaType;
35+
import org.springframework.http.ResponseEntity;
36+
import org.springframework.security.access.prepost.PreAuthorize;
37+
import org.springframework.validation.annotation.Validated;
38+
import org.springframework.web.bind.annotation.GetMapping;
39+
import org.springframework.web.bind.annotation.PathVariable;
40+
import org.springframework.web.bind.annotation.PostMapping;
41+
import org.springframework.web.bind.annotation.RequestBody;
42+
import org.springframework.web.bind.annotation.RequestMapping;
43+
import org.springframework.web.bind.annotation.RequestParam;
44+
import org.springframework.web.bind.annotation.RestController;
45+
46+
import java.util.List;
47+
48+
@RestController
49+
@RequestMapping("/api/column-store")
50+
@Validated
51+
@Profile("clickhouse")
52+
public class ColumnStoreSampleController {
53+
public static final int SAMPLE_MAX_PAGE_SIZE = 10000000;
54+
private static final String SAMPLE_DEFAULT_PAGE_SIZE = "10000000";
55+
56+
private final SampleUseCases sampleUseCases;
57+
58+
private final StudyService studyService;
59+
60+
@Value("${authenticate}")
61+
private String authenticate;
62+
63+
public ColumnStoreSampleController(
64+
SampleUseCases sampleUseCases,
65+
StudyService studyService
66+
) {
67+
this.sampleUseCases = sampleUseCases;
68+
this.studyService = studyService;
69+
}
70+
71+
@GetMapping(value = "/samples", produces = MediaType.APPLICATION_JSON_VALUE)
72+
@Operation(description = "Get all samples matching keyword")
73+
@ApiResponse(
74+
responseCode = "200",
75+
description = "OK",
76+
content = @Content(array = @ArraySchema(schema = @Schema(implementation = Sample.class)))
77+
)
78+
public ResponseEntity<List<SampleDTO>> getSamplesByKeyword(
79+
@Parameter(description = "Search keyword that applies to the study ID")
80+
@RequestParam(required = false)
81+
String keyword,
82+
@Parameter(description = "Level of detail of the response")
83+
@RequestParam(defaultValue = "SUMMARY")
84+
ProjectionType projection,
85+
@Parameter(description = "Page size of the result list")
86+
@Max(PagingConstants.MAX_PAGE_SIZE)
87+
@Min(PagingConstants.MIN_PAGE_SIZE)
88+
@RequestParam(defaultValue = PagingConstants.DEFAULT_PAGE_SIZE)
89+
Integer pageSize,
90+
@Parameter(description = "Page number of the result list")
91+
@Min(PagingConstants.MIN_PAGE_NUMBER)
92+
@RequestParam(defaultValue = PagingConstants.DEFAULT_PAGE_NUMBER)
93+
Integer pageNumber,
94+
@Parameter(description = "Name of the property that the result list is sorted by")
95+
@RequestParam(required = false)
96+
SampleSortBy sortBy,
97+
@Parameter(description = "Direction of the sort")
98+
@RequestParam(defaultValue = "ASC")
99+
Direction direction
100+
) {
101+
String sort = sortBy == null ? null : sortBy.getOriginalValue();
102+
List<String> studyIds = null;
103+
104+
// TODO is there a better way to do this? something like @PreAuthorize?
105+
// (this code segment is duplicate of the legacy SampleController)
106+
if (PortalSecurityConfig.userAuthorizationEnabled(authenticate)) {
107+
/*
108+
If using auth, filter the list of samples returned using the list of study ids the
109+
user has access to. If the user has access to no studies, the endpoint should not 403,
110+
but instead return an empty list.
111+
*/
112+
studyIds = studyService
113+
.getAllStudies(
114+
null,
115+
ProjectionType.SUMMARY.name(), // force to summary so that post filter doesn't NPE
116+
PagingConstants.MAX_PAGE_SIZE,
117+
0,
118+
null,
119+
direction.name(),
120+
null,
121+
AccessLevel.READ
122+
)
123+
.stream()
124+
.map(CancerStudy::getCancerStudyIdentifier)
125+
.toList();
126+
}
127+
128+
if (projection == ProjectionType.META) {
129+
HttpHeaders responseHeaders = getMetaSamplesHeaders(keyword, studyIds);
130+
return new ResponseEntity<>(responseHeaders, HttpStatus.OK);
131+
}
132+
else {
133+
List<Sample> samples = sampleUseCases.getAllSamplesUseCase().execute(
134+
keyword,
135+
studyIds,
136+
projection,
137+
pageSize,
138+
pageNumber,
139+
sort,
140+
direction.name()
141+
);
142+
143+
return new ResponseEntity<>(SampleMapper.INSTANCE.toDtos(samples), HttpStatus.OK);
144+
}
145+
}
146+
147+
@PreAuthorize("hasPermission(#sampleFilter, 'SampleFilter', T(org.cbioportal.legacy.utils.security.AccessLevel).READ)")
148+
@PostMapping(
149+
value = "/samples/fetch",
150+
consumes = MediaType.APPLICATION_JSON_VALUE,
151+
produces = MediaType.APPLICATION_JSON_VALUE
152+
)
153+
@Operation(description = "Fetch samples by ID")
154+
@ApiResponse(
155+
responseCode = "200",
156+
description = "OK",
157+
content = @Content(array = @ArraySchema(schema = @Schema(implementation = Sample.class)))
158+
)
159+
public ResponseEntity<List<SampleDTO>> fetchSamples(
160+
@Parameter(required = true, description = "List of sample identifiers")
161+
@Valid
162+
@RequestBody(required = false)
163+
SampleFilter sampleFilter,
164+
@Parameter(description = "Level of detail of the response")
165+
@RequestParam(defaultValue = "SUMMARY")
166+
ProjectionType projection
167+
) {
168+
if (projection == ProjectionType.META) {
169+
HttpHeaders responseHeaders = fetchMetaSamplesHeaders(sampleFilter);
170+
return new ResponseEntity<>(responseHeaders, HttpStatus.OK);
171+
}
172+
else {
173+
List<Sample> samples = sampleUseCases.fetchSamplesUseCase().execute(sampleFilter, projection);
174+
return new ResponseEntity<>(SampleMapper.INSTANCE.toDtos(samples), HttpStatus.OK);
175+
}
176+
}
177+
178+
@PreAuthorize("hasPermission(#studyId, 'CancerStudyId', T(org.cbioportal.legacy.utils.security.AccessLevel).READ)")
179+
@GetMapping(value = "/studies/{studyId}/samples", produces = MediaType.APPLICATION_JSON_VALUE)
180+
@Operation(description = "Get all samples in a study")
181+
@ApiResponse(
182+
responseCode = "200",
183+
description = "OK",
184+
content = @Content(array = @ArraySchema(schema = @Schema(implementation = Sample.class)))
185+
)
186+
public ResponseEntity<List<SampleDTO>> getAllSamplesInStudy(
187+
@Parameter(required = true, description = "Study ID e.g. acc_tcga")
188+
@PathVariable
189+
String studyId,
190+
@Parameter(description = "Level of detail of the response")
191+
@RequestParam(defaultValue = "SUMMARY")
192+
ProjectionType projection,
193+
@Parameter(description = "Page size of the result list")
194+
@Max(SAMPLE_MAX_PAGE_SIZE)
195+
@Min(PagingConstants.MIN_PAGE_SIZE)
196+
@RequestParam(defaultValue = SAMPLE_DEFAULT_PAGE_SIZE)
197+
Integer pageSize,
198+
@Parameter(description = "Page number of the result list")
199+
@Min(PagingConstants.MIN_PAGE_NUMBER)
200+
@RequestParam(defaultValue = PagingConstants.DEFAULT_PAGE_NUMBER)
201+
Integer pageNumber,
202+
@Parameter(description = "Name of the property that the result list is sorted by")
203+
@RequestParam(required = false)
204+
SampleSortBy sortBy,
205+
@Parameter(description = "Direction of the sort")
206+
@RequestParam(defaultValue = "ASC")
207+
Direction direction
208+
) throws StudyNotFoundException {
209+
if (projection == ProjectionType.META) {
210+
HttpHeaders responseHeaders = getMetaSamplesInStudyHeaders(studyId);
211+
return new ResponseEntity<>(responseHeaders, HttpStatus.OK);
212+
} else {
213+
List<Sample> samples = sampleUseCases.getAllSamplesInStudyUseCase().execute(
214+
studyId,
215+
projection,
216+
pageSize,
217+
pageNumber,
218+
sortBy == null ? null : sortBy.getOriginalValue(),
219+
direction.name()
220+
);
221+
222+
return new ResponseEntity<>(SampleMapper.INSTANCE.toDtos(samples), HttpStatus.OK);
223+
}
224+
}
225+
226+
@PreAuthorize("hasPermission(#studyId, 'CancerStudyId', T(org.cbioportal.legacy.utils.security.AccessLevel).READ)")
227+
@GetMapping(value = "/studies/{studyId}/samples/{sampleId}", produces = MediaType.APPLICATION_JSON_VALUE)
228+
@Operation(description = "Get a sample in a study")
229+
@ApiResponse(
230+
responseCode = "200",
231+
description = "OK",
232+
content = @Content(schema = @Schema(implementation = Sample.class))
233+
)
234+
public ResponseEntity<SampleDTO> getSampleInStudy(
235+
@Parameter(required = true, description = "Study ID e.g. acc_tcga")
236+
@PathVariable
237+
String studyId,
238+
@Parameter(required = true, description = "Sample ID e.g. TCGA-OR-A5J2-01")
239+
@PathVariable
240+
String sampleId
241+
) throws SampleNotFoundException, StudyNotFoundException {
242+
return new ResponseEntity<>(
243+
SampleMapper.INSTANCE.toSampleDTO(
244+
sampleUseCases.getSampleInStudyUseCase().execute(studyId, sampleId)
245+
),
246+
HttpStatus.OK
247+
);
248+
}
249+
250+
@PreAuthorize("hasPermission(#studyId, 'CancerStudyId', T(org.cbioportal.legacy.utils.security.AccessLevel).READ)")
251+
@GetMapping(value = "/studies/{studyId}/patients/{patientId}/samples", produces = MediaType.APPLICATION_JSON_VALUE)
252+
@Operation(description = "Get all samples of a patient in a study")
253+
@ApiResponse(
254+
responseCode = "200",
255+
description = "OK",
256+
content = @Content(array = @ArraySchema(schema = @Schema(implementation = Sample.class)))
257+
)
258+
public ResponseEntity<List<SampleDTO>> getAllSamplesOfPatientInStudy(
259+
@Parameter(required = true, description = "Study ID e.g. acc_tcga")
260+
@PathVariable
261+
String studyId,
262+
@Parameter(required = true, description = "Patient ID e.g. TCGA-OR-A5J2")
263+
@PathVariable
264+
String patientId,
265+
@Parameter(description = "Level of detail of the response")
266+
@RequestParam(defaultValue = "SUMMARY")
267+
ProjectionType projection,
268+
@Parameter(description = "Page size of the result list")
269+
@Max(SAMPLE_MAX_PAGE_SIZE)
270+
@Min(PagingConstants.MIN_PAGE_SIZE)
271+
@RequestParam(defaultValue = SAMPLE_DEFAULT_PAGE_SIZE)
272+
Integer pageSize,
273+
@Parameter(description = "Page number of the result list")
274+
@Min(PagingConstants.MIN_PAGE_NUMBER)
275+
@RequestParam(defaultValue = PagingConstants.DEFAULT_PAGE_NUMBER)
276+
Integer pageNumber,
277+
@Parameter(description = "Name of the property that the result list is sorted by")
278+
@RequestParam(required = false)
279+
SampleSortBy sortBy,
280+
@Parameter(description = "Direction of the sort")
281+
@RequestParam(defaultValue = "ASC") Direction direction
282+
) throws PatientNotFoundException, StudyNotFoundException {
283+
if (projection == ProjectionType.META) {
284+
HttpHeaders responseHeaders = getMetaSamplesOfPatientInStudyHeaders(studyId, patientId);
285+
return new ResponseEntity<>(responseHeaders, HttpStatus.OK);
286+
} else {
287+
List<Sample> samples = sampleUseCases.getAllSamplesOfPatientInStudyUseCase().execute(
288+
studyId,
289+
patientId,
290+
projection,
291+
pageSize,
292+
pageNumber,
293+
sortBy == null ? null : sortBy.getOriginalValue(),
294+
direction.name()
295+
);
296+
297+
return new ResponseEntity<>(SampleMapper.INSTANCE.toDtos(samples), HttpStatus.OK);
298+
}
299+
}
300+
301+
private HttpHeaders fetchMetaSamplesHeaders(
302+
SampleFilter sampleFilter
303+
) {
304+
HttpHeaders httpHeaders = new HttpHeaders();
305+
BaseMeta baseMeta = sampleUseCases.fetchMetaSamplesUseCase().execute(sampleFilter);
306+
httpHeaders.add(HeaderKeyConstants.TOTAL_COUNT, baseMeta.getTotalCount().toString());
307+
308+
return httpHeaders;
309+
}
310+
311+
private HttpHeaders getMetaSamplesHeaders(String keyword, List<String> studyIds) {
312+
HttpHeaders httpHeaders = new HttpHeaders();
313+
httpHeaders.add(
314+
HeaderKeyConstants.TOTAL_COUNT,
315+
sampleUseCases.getMetaSamplesUseCase().execute(keyword, studyIds).getTotalCount().toString()
316+
);
317+
318+
return httpHeaders;
319+
}
320+
321+
private HttpHeaders getMetaSamplesInStudyHeaders(String studyId) throws StudyNotFoundException {
322+
HttpHeaders httpHeaders = new HttpHeaders();
323+
httpHeaders.add(
324+
HeaderKeyConstants.TOTAL_COUNT,
325+
sampleUseCases.getMetaSamplesInStudyUseCase().execute(studyId).getTotalCount().toString()
326+
);
327+
328+
return httpHeaders;
329+
}
330+
331+
private HttpHeaders getMetaSamplesOfPatientInStudyHeaders(
332+
String studyId,
333+
String patientId
334+
) throws StudyNotFoundException, PatientNotFoundException
335+
{
336+
HttpHeaders httpHeaders = new HttpHeaders();
337+
httpHeaders.add(
338+
HeaderKeyConstants.TOTAL_COUNT,
339+
sampleUseCases.getMetaSamplesOfPatientInStudyUseCase().execute(studyId, patientId).getTotalCount().toString()
340+
);
341+
342+
return httpHeaders;
343+
}
344+
}

src/main/java/org/cbioportal/application/security/CancerStudyPermissionEvaluator.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import java.util.*;
3737
import java.util.stream.Collectors;
3838

39+
import org.cbioportal.application.security.util.CancerStudyExtractorUtil;
3940
import org.cbioportal.legacy.model.CancerStudy;
4041
import org.cbioportal.legacy.model.MolecularProfile;
4142
import org.cbioportal.legacy.model.MolecularProfileCaseIdentifier;
@@ -48,6 +49,7 @@
4849
import org.cbioportal.legacy.web.parameter.GenericAssayDataCountFilter;
4950
import org.cbioportal.legacy.web.parameter.GenomicDataCountFilter;
5051
import org.cbioportal.legacy.web.parameter.MolecularProfileCasesGroupAndAlterationTypeFilter;
52+
import org.cbioportal.legacy.web.parameter.SampleFilter;
5153
import org.cbioportal.legacy.web.parameter.StudyViewFilter;
5254
import org.slf4j.Logger;
5355
import org.slf4j.LoggerFactory;
@@ -194,6 +196,13 @@ public boolean hasPermission(Authentication authentication, Serializable targetI
194196
return hasAccessToSampleLists(authentication, (Collection<String>) targetId, permission);
195197
} else if (targetType.contains("Filter")) {
196198
switch (targetId) {
199+
case SampleFilter sampleFilter -> {
200+
return hasAccessToCancerStudies(
201+
authentication,
202+
CancerStudyExtractorUtil.extractCancerStudyIdsFromSampleFilter(sampleFilter, this.cacheMapUtil),
203+
permission
204+
);
205+
}
197206
case StudyViewFilter studyViewFilter -> {
198207
return hasAccessToCancerStudies(authentication, studyViewFilter.getUniqueStudyIds(), permission);
199208
}
@@ -218,7 +227,6 @@ public boolean hasPermission(Authentication authentication, Serializable targetI
218227
}
219228
return hasAccessToCancerStudies(authentication, studyIds, permission);
220229
}
221-
222230
case GenericAssayDataCountFilter genericAssayDataCountFilter -> {
223231
Set<String> studyIds = new HashSet<>();
224232
if (genericAssayDataCountFilter.getStudyViewFilter() != null) {

0 commit comments

Comments
 (0)