/*
* Copyright (c) 2017 wetransform GmbH
*
* All rights reserved. This program and the accompanying materials are made
* available under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation, either version 3 of the License,
* or (at your option) any later version.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this distribution. If not, see <http://www.gnu.org/licenses/>.
*
* Contributors:
* wetransform GmbH <http://www.wetransform.to>
*/
package eu.esdihumboldt.hale.io.haleconnect.internal;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicLong;
import org.apache.commons.lang.StringUtils;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import com.haleconnect.api.projectstore.v1.ApiCallback;
import com.haleconnect.api.projectstore.v1.ApiResponse;
import com.haleconnect.api.projectstore.v1.api.BucketsApi;
import com.haleconnect.api.projectstore.v1.api.FilesApi;
import com.haleconnect.api.projectstore.v1.api.PermissionsApi;
import com.haleconnect.api.projectstore.v1.model.BucketDetail;
import com.haleconnect.api.projectstore.v1.model.BucketIdent;
import com.haleconnect.api.projectstore.v1.model.Feedback;
import com.haleconnect.api.projectstore.v1.model.NewBucket;
import com.haleconnect.api.user.v1.ApiException;
import com.haleconnect.api.user.v1.api.LoginApi;
import com.haleconnect.api.user.v1.api.OrganisationsApi;
import com.haleconnect.api.user.v1.api.UsersApi;
import com.haleconnect.api.user.v1.model.Credentials;
import com.haleconnect.api.user.v1.model.OrganisationInfo;
import com.haleconnect.api.user.v1.model.Token;
import com.haleconnect.api.user.v1.model.UserInfo;
import de.fhg.igd.slf4jplus.ALogger;
import de.fhg.igd.slf4jplus.ALoggerFactory;
import eu.esdihumboldt.hale.common.core.io.ProgressIndicator;
import eu.esdihumboldt.hale.common.core.io.supplier.DefaultInputSupplier;
import eu.esdihumboldt.hale.common.core.io.supplier.LocatableInputSupplier;
import eu.esdihumboldt.hale.io.haleconnect.BasePathManager;
import eu.esdihumboldt.hale.io.haleconnect.HaleConnectException;
import eu.esdihumboldt.hale.io.haleconnect.HaleConnectOrganisationInfo;
import eu.esdihumboldt.hale.io.haleconnect.HaleConnectProjectInfo;
import eu.esdihumboldt.hale.io.haleconnect.HaleConnectService;
import eu.esdihumboldt.hale.io.haleconnect.HaleConnectServiceListener;
import eu.esdihumboldt.hale.io.haleconnect.HaleConnectSession;
import eu.esdihumboldt.hale.io.haleconnect.HaleConnectUrnBuilder;
import eu.esdihumboldt.hale.io.haleconnect.HaleConnectUserInfo;
import eu.esdihumboldt.hale.io.haleconnect.Owner;
import eu.esdihumboldt.hale.io.haleconnect.project.SharingOptions;
/**
* hale connect service facade implementation
*
* @author Florian Esser
*/
public class HaleConnectServiceImpl implements HaleConnectService, BasePathManager {
private static final ALogger log = ALoggerFactory.getLogger(HaleConnectServiceImpl.class);
private final CopyOnWriteArraySet<HaleConnectServiceListener> listeners = new CopyOnWriteArraySet<HaleConnectServiceListener>();
private final ConcurrentHashMap<String, String> basePaths = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, HaleConnectUserInfo> userInfoCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, HaleConnectOrganisationInfo> orgInfoCache = new ConcurrentHashMap<>();
private HaleConnectSession session;
/**
* @see eu.esdihumboldt.hale.io.haleconnect.HaleConnectService#addListener(eu.esdihumboldt.hale.io.haleconnect.HaleConnectServiceListener)
*/
@Override
public void addListener(HaleConnectServiceListener listener) {
listeners.add(listener);
}
@Override
public void clearSession() {
this.session = null;
notifyLoginStateChanged();
}
/**
* @see eu.esdihumboldt.hale.io.haleconnect.HaleConnectService#createProject(java.lang.String,
* java.lang.String, eu.esdihumboldt.hale.io.haleconnect.Owner,
* boolean)
*/
@Override
public String createProject(String name, String author, Owner owner, boolean versionControl)
throws HaleConnectException {
if (!this.isLoggedIn()) {
throw new HaleConnectException("Not logged in");
}
String apiKey = this.getSession().getToken();
NewBucket newBucket = new NewBucket();
newBucket.setName(name);
newBucket.setVersionControl(versionControl);
final BucketIdent id;
try {
BucketsApi bucketsApi = ProjectStoreHelper.getBucketsApi(this, apiKey);
// POST /buckets
id = bucketsApi.createBucket(newBucket, getContextOrganisation());
Owner bucketOwner = UserServiceHelper.toOwner(id.getUserId(), id.getOrgId());
// PUT /buckets/{ownerType}/{ownerId}/{bucketID}/p/author
bucketsApi.setBucketProperty(bucketOwner.getType().getJsonValue(), bucketOwner.getId(),
id.getTransformationproject(), "author", author);
} catch (com.haleconnect.api.projectstore.v1.ApiException e) {
throw new HaleConnectException(e.getMessage(), e);
}
return id.getTransformationproject();
}
/**
* @see eu.esdihumboldt.hale.io.haleconnect.BasePathResolver#getBasePath(String)
*/
@Override
public String getBasePath(String service) {
return basePaths.get(service);
}
/**
* @see eu.esdihumboldt.hale.io.haleconnect.HaleConnectService#getBasePathManager()
*/
@Override
public BasePathManager getBasePathManager() {
return this;
}
/**
* @see eu.esdihumboldt.hale.io.haleconnect.HaleConnectService#getOrganisationInfo(java.lang.String)
*/
@Override
public HaleConnectOrganisationInfo getOrganisationInfo(String orgId)
throws HaleConnectException {
if (!this.isLoggedIn()) {
return null;
}
if (!orgInfoCache.containsKey(orgId)) {
OrganisationsApi api = UserServiceHelper.getOrganisationsApi(this,
this.getSession().getToken());
try {
OrganisationInfo org = api.getOrganisation(orgId);
orgInfoCache.put(org.getId(),
new HaleConnectOrganisationInfo(org.getId(), org.getName()));
} catch (ApiException e) {
throw new HaleConnectException(e.getMessage(), e);
}
}
return orgInfoCache.get(orgId);
}
/**
* @see eu.esdihumboldt.hale.io.haleconnect.HaleConnectService#getProject(Owner,
* String)
*/
@Override
public HaleConnectProjectInfo getProject(Owner owner, String projectId)
throws HaleConnectException {
BucketDetail bucketDetail;
try {
bucketDetail = ProjectStoreHelper.getBucketsApi(this, this.getSession().getToken())
.getBucketInfo(owner.getType().getJsonValue(), owner.getId(), projectId);
} catch (com.haleconnect.api.projectstore.v1.ApiException e) {
throw new HaleConnectException(e.getMessage(), e);
}
return processBucketDetail(bucketDetail);
}
/**
* @see eu.esdihumboldt.hale.io.haleconnect.HaleConnectService#getProjects()
*/
@Override
public List<HaleConnectProjectInfo> getProjects() throws HaleConnectException {
List<BucketDetail> bucketDetails;
try {
bucketDetails = ProjectStoreHelper.getBucketsApi(this, this.getSession().getToken())
.getBuckets(null, true);
} catch (com.haleconnect.api.projectstore.v1.ApiException e) {
throw new HaleConnectException(e.getMessage(), e);
}
return processBucketDetails(bucketDetails);
}
@Override
public ListenableFuture<List<HaleConnectProjectInfo>> getProjectsAsync()
throws HaleConnectException {
final SettableFuture<List<HaleConnectProjectInfo>> future = SettableFuture.create();
try {
ProjectStoreHelper.getBucketsApi(this, this.getSession().getToken()).getBucketsAsync(
getContextOrganisation(), true, new ApiCallback<List<BucketDetail>>() {
@Override
public void onDownloadProgress(long bytesRead, long contentLength,
boolean done) {
// Ignored
}
@Override
public void onFailure(com.haleconnect.api.projectstore.v1.ApiException e,
int statusCode, Map<String, List<String>> responseHeaders) {
future.setException(new HaleConnectException(e.getMessage(), e,
statusCode, responseHeaders));
}
@Override
public void onSuccess(List<BucketDetail> result, int statusCode,
Map<String, List<String>> responseHeaders) {
future.set(Collections.unmodifiableList(processBucketDetails(result)));
}
@Override
public void onUploadProgress(long bytesWritten, long contentLength,
boolean done) {
// Ignored
}
});
} catch (com.haleconnect.api.projectstore.v1.ApiException e) {
throw new HaleConnectException(e.getMessage(), e);
}
return future;
}
@Override
public HaleConnectSession getSession() {
return session;
}
/**
* @see eu.esdihumboldt.hale.io.haleconnect.HaleConnectService#getUserInfo(java.lang.String)
*/
@Override
public HaleConnectUserInfo getUserInfo(String userId) throws HaleConnectException {
if (!this.isLoggedIn()) {
return null;
}
if (!userInfoCache.containsKey(userId)) {
UsersApi api = UserServiceHelper.getUsersApi(this, this.getSession().getToken());
try {
UserInfo info = api.getProfile(userId);
userInfoCache.put(info.getId(), new HaleConnectUserInfo(info.getId(),
info.getScreenName(), info.getFullName()));
} catch (ApiException e) {
throw new HaleConnectException(e.getMessage(), e);
}
}
return userInfoCache.get(userId);
}
/**
* @see eu.esdihumboldt.hale.io.haleconnect.HaleConnectService#isLoggedIn()
*/
@Override
public boolean isLoggedIn() {
return session != null;
}
/**
* @see eu.esdihumboldt.hale.io.haleconnect.HaleConnectService#loadProject(Owner,
* String)
*/
@Override
public LocatableInputSupplier<InputStream> loadProject(Owner owner, String projectId)
throws HaleConnectException {
if (!isLoggedIn()) {
throw new IllegalStateException("Not logged in.");
}
FilesApi api = ProjectStoreHelper.getFilesApi(this, this.getSession().getToken());
final ApiResponse<File> response;
try {
response = api.getProjectFilesAsZipWithHttpInfo(owner.getType().getJsonValue(),
owner.getId(), projectId);
} catch (com.haleconnect.api.projectstore.v1.ApiException e) {
throw new HaleConnectException(e.getMessage(), e);
}
return new DefaultInputSupplier(HaleConnectUrnBuilder.buildProjectUrn(owner, projectId)) {
@Override
public InputStream getInput() throws IOException {
return new BufferedInputStream(new FileInputStream(response.getData()));
}
};
}
/**
* @see eu.esdihumboldt.hale.io.haleconnect.HaleConnectService#login(java.lang.String,
* java.lang.String)
*/
@Override
public boolean login(String username, String password) throws HaleConnectException {
LoginApi loginApi = UserServiceHelper.getLoginApi(this);
Credentials credentials = UserServiceHelper.buildCredentials(username, password);
try {
Token token = loginApi.login(credentials);
if (token != null) {
UsersApi usersApi = UserServiceHelper.getUsersApi(this, token.getToken());
// First get the current user's profile to obtain the user ID
// required to fetch the extended profile (including the user's
// roles/organisations) in the next step
UserInfo shortProfile = usersApi.getProfileOfCurrentUser();
session = new HaleConnectSessionImpl(username, token.getToken(),
usersApi.getProfile(shortProfile.getId()));
notifyLoginStateChanged();
}
else {
clearSession();
}
} catch (ApiException e) {
if (e.getCode() == 401) {
clearSession();
}
else {
throw new HaleConnectException(e.getMessage(), e);
}
}
return isLoggedIn();
}
/**
* @see eu.esdihumboldt.hale.io.haleconnect.HaleConnectService#removeListener(eu.esdihumboldt.hale.io.haleconnect.HaleConnectServiceListener)
*/
@Override
public void removeListener(HaleConnectServiceListener listener) {
listeners.remove(listener);
}
/**
* @see eu.esdihumboldt.hale.io.haleconnect.BasePathManager#setBasePath(String,
* String)
*/
@Override
public void setBasePath(String service, String basePath) {
if (service == null || basePath == null) {
throw new NullPointerException("service and basePath must not be null");
}
while (basePath.endsWith("/")) {
basePath = StringUtils.removeEnd(basePath, "/");
}
basePaths.put(service, basePath);
}
@Override
public ListenableFuture<Boolean> uploadProjectFileAsync(String projectId, Owner owner, File file,
ProgressIndicator progress) throws HaleConnectException {
if (!this.isLoggedIn()) {
throw new HaleConnectException("Not logged in");
}
String apiKey = this.getSession().getToken();
// PUT /buckets/{ownerType}/{ownerId}/{bucketID}/name
FilesApi filesApi = ProjectStoreHelper.getFilesApi(this, apiKey);
// refactor to reuse code in both sync and async methods
SettableFuture<Boolean> future = SettableFuture.create();
try {
// POST /raw
int totalWork = computeTotalWork(file);
progress.begin("Uploading project archive", totalWork);
filesApi.addFilesAsync(owner.getType().getJsonValue(), owner.getId(), projectId, file,
createUploadFileCallback(future, progress, file, totalWork));
} catch (
com.haleconnect.api.projectstore.v1.ApiException e) {
throw new HaleConnectException(e.getMessage(), e);
}
return future;
}
/**
* @see eu.esdihumboldt.hale.io.haleconnect.HaleConnectService#uploadProjectFile(java.lang.String,
* eu.esdihumboldt.hale.io.haleconnect.Owner, java.io.File,
* eu.esdihumboldt.hale.common.core.io.ProgressIndicator)
*/
@Override
public boolean uploadProjectFile(String projectId, Owner owner, File file,
ProgressIndicator progress) throws HaleConnectException {
if (!this.isLoggedIn()) {
throw new HaleConnectException("Not logged in");
}
String apiKey = this.getSession().getToken();
SettableFuture<Boolean> future = SettableFuture.create();
try {
FilesApi filesApi = ProjectStoreHelper.getFilesApi(this, apiKey);
// POST /raw
int totalWork = computeTotalWork(file);
ApiCallback<Feedback> apiCallback = createUploadFileCallback(future, progress, file,
totalWork);
progress.begin("Uploading project archive", totalWork);
filesApi.addFilesAsync(owner.getType().getJsonValue(), owner.getId(), projectId, file,
apiCallback);
return future.get();
} catch (com.haleconnect.api.projectstore.v1.ApiException | InterruptedException
| ExecutionException e) {
throw new HaleConnectException(e.getMessage(), e);
}
}
private int computeTotalWork(File file) {
int totalWork;
// Support upload progress only for files where its size in
// KiB fits into an int. Round up to next KiB.
long sizeKiB = (file.length() >> 10) + 1;
if (sizeKiB > Integer.MAX_VALUE) {
totalWork = ProgressIndicator.UNKNOWN;
}
else {
totalWork = Math.toIntExact(sizeKiB);
}
return totalWork;
}
@Override
public boolean verifyCredentials(String username, String password) throws HaleConnectException {
try {
return UserServiceHelper.getLoginApi(this)
.login(UserServiceHelper.buildCredentials(username, password)) != null;
} catch (ApiException e) {
if (e.getCode() == 401) {
return false;
}
else {
throw new HaleConnectException(e.getMessage(), e);
}
}
}
private String getContextOrganisation() {
if (!this.isLoggedIn()) {
return null;
}
List<String> orgIds = this.getSession().getOrganisationIds();
if (orgIds.isEmpty()) {
return null;
}
// XXX Cannot handle multiple organisations!
return orgIds.iterator().next();
}
private void notifyLoginStateChanged() {
for (HaleConnectServiceListener listener : listeners) {
listener.loginStateChanged(isLoggedIn());
}
}
/**
* Convert a list of {@link BucketDetail}s received from the project store
* to a list of {@link HaleConnectProjectInfo}
*
* @param bucketDetails bucket details
* @return list of hale connect project info
*/
private List<HaleConnectProjectInfo> processBucketDetails(List<BucketDetail> bucketDetails) {
List<HaleConnectProjectInfo> result = new ArrayList<>();
for (BucketDetail bucket : bucketDetails) {
if (bucket.getId() != null) {
result.add(processBucketDetail(bucket));
}
}
return result;
}
private HaleConnectProjectInfo processBucketDetail(BucketDetail bucket) {
String author = null;
if (bucket.getProperties() instanceof Map<?, ?>) {
@SuppressWarnings("unchecked")
Map<Object, Object> properties = (Map<Object, Object>) bucket.getProperties();
if (properties.containsKey("author")) {
author = properties.get("author").toString();
}
}
HaleConnectUserInfo user = null;
HaleConnectOrganisationInfo org = null;
try {
if (!StringUtils.isEmpty(bucket.getId().getUserId())) {
user = this.getUserInfo(bucket.getId().getUserId());
}
if (!StringUtils.isEmpty(bucket.getId().getOrgId())) {
org = this.getOrganisationInfo(bucket.getId().getOrgId());
}
} catch (HaleConnectException e) {
log.error(e.getMessage(), e);
}
return new HaleConnectProjectInfo(bucket.getId().getTransformationproject(), user, org,
bucket.getName(), author);
}
private ApiCallback<Feedback> createUploadFileCallback(final SettableFuture<Boolean> future,
final ProgressIndicator progress, final File file, final int totalWork) {
return new ApiCallback<Feedback>() {
AtomicLong chunkWritten = new AtomicLong(0);
AtomicLong bytesReported = new AtomicLong(0);
@Override
public void onDownloadProgress(long bytesRead, long contentLength, boolean done) {
// not required
}
@Override
public void onFailure(com.haleconnect.api.projectstore.v1.ApiException e,
int statusCode, Map<String, List<String>> responseHeaders) {
progress.end();
future.setException(
new HaleConnectException(e.getMessage(), e, statusCode, responseHeaders));
}
@Override
public void onSuccess(Feedback result, int statusCode,
Map<String, List<String>> responseHeaders) {
if (result.getError()) {
log.error(MessageFormat.format("Error uploading project file \"{0}\": {1}",
file.getAbsolutePath(), result.getMessage()));
future.set(false);
}
else {
future.set(true);
}
progress.end();
}
@Override
public void onUploadProgress(long bytesWritten, long contentLength, boolean done) {
// bytesWritten contains the accumulated amount of bytes written
if (totalWork != ProgressIndicator.UNKNOWN) {
// Wait until at least 1 KiB was written
long chunk = chunkWritten.get();
chunk += bytesWritten - bytesReported.get();
if (chunk >= 1024) {
long workToReport = chunk >> 10;
// cannot overflow, total size in KiB
// is guaranteed to be < Integer.MAX_VALUE
progress.advance(Math.toIntExact(workToReport));
chunk -= workToReport << 10;
// chunkWritten now always < 1024
}
chunkWritten.set(chunk);
bytesReported.set(bytesWritten);
}
}
};
}
@Override
public boolean setProjectSharingOptions(String projectId, Owner owner, SharingOptions options)
throws HaleConnectException {
BucketsApi bucketsApi = ProjectStoreHelper.getBucketsApi(this,
this.getSession().getToken());
Feedback feedback;
try {
feedback = bucketsApi.setBucketProperty(owner.getType().getJsonValue(), owner.getId(),
projectId, "sharingOptions", options);
} catch (com.haleconnect.api.projectstore.v1.ApiException e) {
throw new HaleConnectException(e.getMessage(), e);
}
if (feedback.getError()) {
log.error(MessageFormat.format(
"Error setting sharing options for hale connect project {0}", projectId));
return false;
}
return true;
}
@Override
public boolean testProjectPermission(String permission, String projectId)
throws HaleConnectException {
PermissionsApi api = ProjectStoreHelper.getPermissionsApi(this,
this.getSession().getToken());
try {
api.testBucketPermission(permission, projectId);
} catch (com.haleconnect.api.projectstore.v1.ApiException e) {
if (e.getCode() == 403) {
// not allowed
return false;
}
// other codes indicate client error or server-side exception
throw new HaleConnectException(e.getMessage(), e);
}
return true;
}
@SuppressWarnings("unchecked")
@Override
public boolean testUserPermission(String resourceType, String role, String permission)
throws HaleConnectException {
com.haleconnect.api.user.v1.api.PermissionsApi api = UserServiceHelper
.getPermissionsApi(this, this.getSession().getToken());
try {
Map<String, Object> permissions = (Map<String, Object>) api
.getResourcePermissionInfo(resourceType, permission);
if ("user".equals(role)) {
Object userPermission = permissions.get("user");
return "true".equals(userPermission.toString());
}
else {
// Interpret role as orgId
Object orgPermission = permissions.get("organisations");
if (orgPermission instanceof Map) {
// keySet is set of organisation ids
Map<String, Object> orgPermissions = (Map<String, Object>) orgPermission;
Object conditions = Optional.ofNullable(orgPermissions.get(role))
.orElse(Collections.EMPTY_LIST);
if ("false".equals(conditions.toString())) {
return false;
}
else if (conditions instanceof List) {
return ((List<?>) conditions).stream()
.anyMatch(cond -> "organisation".equals(cond.toString()));
}
}
}
} catch (ApiException e) {
throw new HaleConnectException(e.getMessage(), e);
}
return false;
}
/**
* @see eu.esdihumboldt.hale.io.haleconnect.HaleConnectService#setProjectName(java.lang.String,
* eu.esdihumboldt.hale.io.haleconnect.Owner, java.lang.String)
*/
@Override
public boolean setProjectName(String projectId, Owner owner, String name)
throws HaleConnectException {
// Build custom call because BucketsApi.setProjectName() is broken
// (does not support plain text body)
String path = MessageFormat.format("/buckets/{0}/{1}/{2}/name",
owner.getType().getJsonValue(), owner.getId(), projectId);
Feedback feedback = ProjectStoreHelper.executePlainTextCallWithFeedback("PUT", path, name,
this, this.getSession().getToken());
if (feedback.getError()) {
log.error(MessageFormat.format(
"Error setting name \"{0}\" for hale connect project {1}", name, projectId));
return false;
}
return true;
}
}