Skip to content

Commit 9e45eb7

Browse files
authored
Merge pull request #470 from RADAR-base/file-upload-endpoint
Impl. file upload endpoint
2 parents a43ee84 + d559e0f commit 9e45eb7

17 files changed

+837
-1
lines changed

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ ext {
4141
radarSpringAuthVersion = '1.2.1'
4242
springSecurityVersion = '6.0.2'
4343
hibernateValidatorVersion = '8.0.0.Final'
44+
minioVersion = '8.5.10'
4445
}
4546

4647
sourceSets {
@@ -65,6 +66,7 @@ dependencies {
6566
implementation('org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure:' + springBootVersion)
6667
implementation('org.springframework.security.oauth:spring-security-oauth2:' + springOauth2Version)
6768
runtimeOnly("org.hibernate.validator:hibernate-validator:$hibernateValidatorVersion")
69+
implementation("io.minio:minio:$minioVersion")
6870

6971
// Open API spec
7072
implementation(group: 'org.springdoc', name: 'springdoc-openapi-starter-webmvc-ui', version: springDocVersion)

src/main/java/org/radarbase/appserver/config/ApplicationConfig.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131

3232
@Configuration
3333
@EnableJpaAuditing
34-
@EnableConfigurationProperties({FcmServerConfig.class})
34+
@EnableConfigurationProperties({FcmServerConfig.class, S3StorageProperties.class})
3535
@EnableTransactionManagement
3636
@EnableAsync
3737
@EnableScheduling
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
*
3+
* * Copyright 2024 The Hyve
4+
* *
5+
* * Licensed under the Apache License, Version 2.0 (the "License");
6+
* * you may not use this file except in compliance with the License.
7+
* * You may obtain a copy of the License at
8+
* *
9+
* * http://www.apache.org/licenses/LICENSE-2.0
10+
* *
11+
* * Unless required by applicable law or agreed to in writing, software
12+
* * distributed under the License is distributed on an "AS IS" BASIS,
13+
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* * See the License for the specific language governing permissions and
15+
* * limitations under the License.
16+
*
17+
*/
18+
19+
package org.radarbase.appserver.config;
20+
21+
import lombok.Data;
22+
import org.springframework.boot.context.properties.ConfigurationProperties;
23+
24+
@Data
25+
@ConfigurationProperties("radar.storage.s3")
26+
public class S3StorageProperties {
27+
private String url;
28+
private String accessKey;
29+
private String secretKey;
30+
private String bucketName;
31+
private Path path = new Path();
32+
33+
@Data
34+
public static class Path {
35+
private String prefix;
36+
private boolean collectPerDay;
37+
}
38+
}

src/main/java/org/radarbase/appserver/controller/PathsUtil.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,14 @@ public class PathsUtil {
3030

3131
public static final String USER_PATH = "users";
3232
public static final String PROJECT_PATH = "projects";
33+
public static final String TOPIC_PATH = "topics";
34+
public static final String FILE_PATH = "files";
3335
public static final String MESSAGING_NOTIFICATION_PATH = "messaging/notifications";
3436
public static final String MESSAGING_DATA_PATH = "messaging/data";
3537
public static final String PROTOCOL_PATH = "protocols";
3638
public static final String PROJECT_ID_CONSTANT = "{projectId}";
3739
public static final String SUBJECT_ID_CONSTANT = "{subjectId}";
40+
public static final String TOPIC_ID_CONSTANT = "{topicId}";
3841
public static final String NOTIFICATION_ID_CONSTANT = "{notificationId}";
3942
public static final String NOTIFICATION_STATE_EVENTS_PATH = "state_events";
4043
public static final String QUESTIONNAIRE_SCHEDULE_PATH = "questionnaire/schedule";
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
*
3+
* * Copyright 2024 The Hyve
4+
* *
5+
* * Licensed under the Apache License, Version 2.0 (the "License");
6+
* * you may not use this file except in compliance with the License.
7+
* * You may obtain a copy of the License at
8+
* *
9+
* * http://www.apache.org/licenses/LICENSE-2.0
10+
* *
11+
* * Unless required by applicable law or agreed to in writing, software
12+
* * distributed under the License is distributed on an "AS IS" BASIS,
13+
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* * See the License for the specific language governing permissions and
15+
* * limitations under the License.
16+
*
17+
*/
18+
19+
package org.radarbase.appserver.controller;
20+
21+
import lombok.extern.slf4j.Slf4j;
22+
import org.radarbase.appserver.config.AuthConfig.AuthEntities;
23+
import org.radarbase.appserver.config.AuthConfig.AuthPermissions;
24+
import org.radarbase.appserver.service.storage.StorageService;
25+
import org.springframework.beans.factory.annotation.Autowired;
26+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
27+
import org.springframework.http.ResponseEntity;
28+
import org.springframework.web.bind.annotation.CrossOrigin;
29+
import org.springframework.web.bind.annotation.PathVariable;
30+
import org.springframework.web.bind.annotation.PostMapping;
31+
import org.springframework.web.bind.annotation.RequestMethod;
32+
import org.springframework.web.bind.annotation.RequestParam;
33+
import org.springframework.web.bind.annotation.RestController;
34+
import org.springframework.web.multipart.MultipartFile;
35+
import radar.spring.auth.common.Authorized;
36+
import radar.spring.auth.common.PermissionOn;
37+
38+
import java.net.URI;
39+
import java.net.URISyntaxException;
40+
41+
/**
42+
* Resource Endpoint for uploading assets to a data store.
43+
*
44+
* @author Pim van Nierop
45+
*/
46+
@CrossOrigin
47+
@RestController
48+
@ConditionalOnProperty(value = "radar.file-upload.enabled", havingValue = "true")
49+
@Slf4j
50+
public class UploadController {
51+
52+
@Autowired
53+
private transient StorageService storageService;
54+
55+
@Authorized(
56+
permission = AuthPermissions.CREATE,
57+
entity = AuthEntities.MEASUREMENT,
58+
permissionOn = PermissionOn.SUBJECT
59+
)
60+
@PostMapping(
61+
"/" + PathsUtil.PROJECT_PATH + "/" + PathsUtil.PROJECT_ID_CONSTANT +
62+
"/" + PathsUtil.USER_PATH + "/" + PathsUtil.SUBJECT_ID_CONSTANT +
63+
"/" + PathsUtil.FILE_PATH +
64+
"/" + PathsUtil.TOPIC_PATH + "/" + PathsUtil.TOPIC_ID_CONSTANT +
65+
"/upload")
66+
@CrossOrigin(
67+
origins = "*",
68+
allowedHeaders = "*",
69+
exposedHeaders = "Location", // needed to get the URI of the uploaded file in aRMT
70+
methods = { RequestMethod.POST }
71+
)
72+
public ResponseEntity<?> subjectFileUpload(
73+
@RequestParam("file") MultipartFile file,
74+
@PathVariable String projectId,
75+
@PathVariable String subjectId,
76+
@PathVariable String topicId) throws URISyntaxException {
77+
78+
log.info("Storing file for project: {}, subject: {}, topic: {}", projectId, subjectId, topicId);
79+
80+
String filePath = storageService.store(file, projectId, subjectId, topicId);
81+
return ResponseEntity.created(new URI(filePath)).build();
82+
}
83+
84+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package org.radarbase.appserver.exception;
2+
3+
import org.springframework.http.HttpStatus;
4+
import org.springframework.web.bind.annotation.ResponseStatus;
5+
6+
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
7+
public class FileStorageException extends RuntimeException {
8+
private static final long serialVersionUID = -793674245766939L;
9+
10+
public FileStorageException(String message) {
11+
super(message);
12+
}
13+
14+
public FileStorageException(String message, Object object) {
15+
super(message + " " + object.toString());
16+
}
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package org.radarbase.appserver.exception;
2+
3+
import org.springframework.http.HttpStatus;
4+
import org.springframework.web.bind.annotation.ResponseStatus;
5+
6+
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
7+
public class InvalidFileDetailsException extends IllegalArgumentException {
8+
private static final long serialVersionUID = -793674245766939L;
9+
10+
public InvalidFileDetailsException(String message) {
11+
super(message);
12+
}
13+
14+
public InvalidFileDetailsException(String message, Object object) {
15+
super(message + " " + object.toString());
16+
}
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package org.radarbase.appserver.exception;
2+
3+
import org.springframework.http.HttpStatus;
4+
import org.springframework.web.bind.annotation.ResponseStatus;
5+
6+
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
7+
public class InvalidPathDetailsException extends IllegalArgumentException {
8+
private static final long serialVersionUID = -793674245766939L;
9+
10+
public InvalidPathDetailsException(String message) {
11+
super(message);
12+
}
13+
14+
public InvalidPathDetailsException(String message, Object object) {
15+
super(message + " " + object.toString());
16+
}
17+
}

src/main/java/org/radarbase/appserver/exception/handler/ResponseEntityExceptionHandler.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,21 @@ public final ResponseEntity<ErrorDetails> handleInvalidUserDetailsException(Exce
6464
return handleEntityWithCause(ex, request);
6565
}
6666

67+
@ExceptionHandler(InvalidFileDetailsException.class)
68+
public final ResponseEntity<ErrorDetails> handleInvalidFileDetailsException(Exception ex, WebRequest request) {
69+
return handleEntityWithCause(ex, request);
70+
}
71+
72+
@ExceptionHandler(InvalidPathDetailsException.class)
73+
public final ResponseEntity<ErrorDetails> handleInvalidPathDetailsException(Exception ex, WebRequest request) {
74+
return handleEntityWithCause(ex, request);
75+
}
76+
77+
@ExceptionHandler(FileStorageException.class)
78+
public final ResponseEntity<ErrorDetails> handleFileStorageException(Exception ex, WebRequest request) {
79+
return handleEntityWithCause(ex, request);
80+
}
81+
6782
public ResponseEntity<ErrorDetails> handleEntityWithCause(Exception ex, WebRequest request) {
6883
String cause = ex.getCause() != null ? ex.getCause().getMessage() : null;
6984
HttpStatus status = ex.getClass().getAnnotation(ResponseStatus.class).value();
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
*
3+
* * Copyright 2024 The Hyve
4+
* *
5+
* * Licensed under the Apache License, Version 2.0 (the "License");
6+
* * you may not use this file except in compliance with the License.
7+
* * You may obtain a copy of the License at
8+
* *
9+
* * http://www.apache.org/licenses/LICENSE-2.0
10+
* *
11+
* * Unless required by applicable law or agreed to in writing, software
12+
* * distributed under the License is distributed on an "AS IS" BASIS,
13+
* * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* * See the License for the specific language governing permissions and
15+
* * limitations under the License.
16+
*
17+
*/
18+
19+
package org.radarbase.appserver.service.storage;
20+
21+
import io.minio.BucketExistsArgs;
22+
import io.minio.MinioClient;
23+
import org.radarbase.appserver.config.S3StorageProperties;
24+
import org.springframework.beans.factory.annotation.Autowired;
25+
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
26+
import org.springframework.stereotype.Component;
27+
28+
@Component
29+
@ConditionalOnExpression("${radar.file-upload.enabled:false} and 's3' == '${radar.storage.type:}'")
30+
public class MinioClientInitializer {
31+
32+
private transient MinioClient minioClient;
33+
private transient String bucketName;
34+
35+
@Autowired
36+
private transient S3StorageProperties s3StorageProperties;
37+
38+
private void initClient() {
39+
try {
40+
minioClient =
41+
MinioClient.builder()
42+
.endpoint(s3StorageProperties.getUrl())
43+
.credentials(s3StorageProperties.getAccessKey(), s3StorageProperties.getSecretKey())
44+
.build();
45+
bucketName = s3StorageProperties.getBucketName();
46+
boolean found =
47+
minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
48+
if (!found) {
49+
throw new RuntimeException(String.format("S3 bucket '%s' does not exist", bucketName));
50+
}
51+
} catch (Exception e) {
52+
throw new RuntimeException("Could not connect to S3", e);
53+
}
54+
}
55+
56+
public MinioClient getClient() {
57+
if (minioClient == null) {
58+
initClient();
59+
}
60+
return minioClient;
61+
}
62+
63+
public String getBucketName() {
64+
return bucketName;
65+
}
66+
67+
}

0 commit comments

Comments
 (0)