001/* 002 * Copyright 2019 DuraSpace, Inc. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.fcrepo.migration.handlers.ocfl; 018 019import edu.wisc.library.ocfl.api.MutableOcflRepository; 020import edu.wisc.library.ocfl.api.OcflObjectUpdater; 021import edu.wisc.library.ocfl.api.OcflOption; 022import edu.wisc.library.ocfl.api.model.ObjectVersionId; 023import edu.wisc.library.ocfl.api.model.VersionInfo; 024import org.apache.commons.io.FileUtils; 025import org.apache.commons.lang3.SystemUtils; 026import org.fcrepo.storage.ocfl.CommitType; 027import org.fcrepo.storage.ocfl.InteractionModel; 028import org.fcrepo.storage.ocfl.OcflObjectSession; 029import org.fcrepo.storage.ocfl.OcflVersionInfo; 030import org.fcrepo.storage.ocfl.PersistencePaths; 031import org.fcrepo.storage.ocfl.ResourceContent; 032import org.fcrepo.storage.ocfl.ResourceHeaders; 033 034import java.io.IOException; 035import java.io.InputStream; 036import java.io.UncheckedIOException; 037import java.net.URLDecoder; 038import java.net.URLEncoder; 039import java.nio.charset.StandardCharsets; 040import java.nio.file.Files; 041import java.nio.file.Path; 042import java.nio.file.StandardCopyOption; 043import java.time.OffsetDateTime; 044import java.util.Arrays; 045import java.util.HashSet; 046import java.util.List; 047import java.util.Set; 048import java.util.stream.Collectors; 049import java.util.stream.Stream; 050 051/** 052 * Barebones OcflObjectSession implementation that writes F3 resources to OCFL without F6 resource headers. 053 * Operations other than writing are not supported. 054 * 055 * @author pwinckles 056 */ 057public class PlainOcflObjectSession implements OcflObjectSession { 058 059 private final MutableOcflRepository ocflRepo; 060 private final String sessionId; 061 private final String ocflObjectId; 062 private final VersionInfo versionInfo; 063 private final Path objectStaging; 064 065 private final OcflOption[] ocflOptions; 066 067 private final Set<String> deletePaths; 068 069 private boolean closed = false; 070 071 /** 072 * @param sessionId the session's id 073 * @param ocflRepo the OCFL client 074 * @param ocflObjectId the OCFL object id 075 * @param objectStaging the object's staging directory 076 */ 077 public PlainOcflObjectSession(final String sessionId, 078 final MutableOcflRepository ocflRepo, 079 final String ocflObjectId, 080 final Path objectStaging) { 081 this.sessionId = sessionId; 082 this.ocflRepo = ocflRepo; 083 this.ocflObjectId = ocflObjectId; 084 this.objectStaging = objectStaging; 085 086 this.versionInfo = new VersionInfo(); 087 this.ocflOptions = new OcflOption[] {OcflOption.MOVE_SOURCE, OcflOption.OVERWRITE}; 088 this.deletePaths = new HashSet<>(); 089 } 090 091 @Override 092 public String sessionId() { 093 return sessionId; 094 } 095 096 @Override 097 public String ocflObjectId() { 098 return ocflObjectId; 099 } 100 101 @Override 102 public synchronized ResourceHeaders writeResource(final ResourceHeaders headers, final InputStream content) { 103 enforceOpen(); 104 105 final var paths = resolvePersistencePaths(headers); 106 107 final var contentPath = encode(paths.getContentFilePath()); 108 109 final var contentDst = createStagingPath(contentPath); 110 write(content, contentDst); 111 return headers; 112 } 113 114 @Override 115 public synchronized void writeHeaders(final ResourceHeaders headers) { 116 throw new UnsupportedOperationException("Not implemented"); 117 } 118 119 @Override 120 public void versionCreationTimestamp(final OffsetDateTime timestamp) { 121 versionInfo.setCreated(timestamp); 122 } 123 124 @Override 125 public void versionAuthor(final String name, final String address) { 126 versionInfo.setUser(name, address); 127 } 128 129 @Override 130 public void versionMessage(final String message) { 131 versionInfo.setMessage(message); 132 } 133 134 @Override 135 public synchronized void commit() { 136 enforceOpen(); 137 closed = true; 138 139 if (Files.exists(objectStaging)) { 140 ocflRepo.updateObject(ObjectVersionId.head(ocflObjectId), versionInfo, updater -> { 141 if (Files.exists(objectStaging)) { 142 if (SystemUtils.IS_OS_WINDOWS) { 143 addDecodedPaths(updater, ocflOptions); 144 } else { 145 updater.addPath(objectStaging, ocflOptions); 146 } 147 } 148 }); 149 } 150 151 // Because the of the way ArchiveGroupHandler works, a commit will only ever contains only adds or only deletes 152 if (!deletePaths.isEmpty()) { 153 ocflRepo.updateObject(ObjectVersionId.head(ocflObjectId), versionInfo, updater -> { 154 deletePaths.forEach(updater::removeFile); 155 }); 156 } 157 158 cleanup(); 159 } 160 161 @Override 162 public void abort() { 163 if (!closed) { 164 closed = true; 165 cleanup(); 166 } 167 } 168 169 @Override 170 public void close() { 171 abort(); 172 } 173 174 @Override 175 public void rollback() { 176 throw new UnsupportedOperationException("Rollback is not supported"); 177 } 178 179 @Override 180 public boolean isOpen() { 181 return !closed; 182 } 183 184 @Override 185 public void deleteContentFile(final ResourceHeaders headers) { 186 enforceOpen(); 187 188 final var paths = resolvePersistencePaths(headers); 189 deletePaths.add(paths.getContentFilePath()); 190 } 191 192 @Override 193 public void deleteResource(final String resourceId) { 194 throw new UnsupportedOperationException("Not implemented"); 195 } 196 197 @Override 198 public ResourceHeaders readHeaders(final String resourceId) { 199 throw new UnsupportedOperationException("Not implemented"); 200 } 201 202 @Override 203 public ResourceHeaders readHeaders(final String resourceId, final String versionNumber) { 204 throw new UnsupportedOperationException("Not implemented"); 205 } 206 207 @Override 208 public ResourceContent readContent(final String resourceId) { 209 throw new UnsupportedOperationException("Not implemented"); 210 } 211 212 @Override 213 public ResourceContent readContent(final String resourceId, final String versionNumber) { 214 throw new UnsupportedOperationException("Not implemented"); 215 } 216 217 @Override 218 public List<OcflVersionInfo> listVersions(final String resourceId) { 219 throw new UnsupportedOperationException("Not implemented"); 220 } 221 222 @Override 223 public Stream<ResourceHeaders> streamResourceHeaders() { 224 throw new UnsupportedOperationException("Not implemented"); 225 } 226 227 @Override 228 public void commitType(final CommitType commitType) { 229 throw new UnsupportedOperationException("Not implemented"); 230 } 231 232 @Override 233 public boolean containsResource(final String resourceId) { 234 return ocflRepo.containsObject(ocflObjectId); 235 } 236 237 private Path stagingPath(final String path) { 238 return objectStaging.resolve(path); 239 } 240 241 private Path createStagingPath(final String path) { 242 final var stagingPath = stagingPath(path); 243 244 try { 245 Files.createDirectories(stagingPath.getParent()); 246 } catch (IOException e) { 247 throw new UncheckedIOException(e); 248 } 249 250 return stagingPath; 251 } 252 253 private void write(final InputStream content, final Path destination) { 254 if (content != null) { 255 try { 256 Files.copy(content, destination, StandardCopyOption.REPLACE_EXISTING); 257 } catch (IOException e) { 258 throw new UncheckedIOException(e); 259 } 260 } 261 } 262 263 private PersistencePaths resolvePersistencePaths(final ResourceHeaders headers) { 264 final var resourceId = headers.getId(); 265 final PersistencePaths paths; 266 267 if (InteractionModel.ACL.getUri().equals(headers.getInteractionModel())) { 268 throw new UnsupportedOperationException("ACLs are not supported"); 269 } else if (InteractionModel.NON_RDF.getUri().equals(headers.getInteractionModel())) { 270 paths = PersistencePaths.nonRdfResource(ocflObjectId, resourceId); 271 } else if (headers.getInteractionModel() != null) { 272 paths = PersistencePaths.rdfResource(ocflObjectId, resourceId); 273 } else { 274 throw new IllegalArgumentException( 275 String.format("Interaction model for resource %s must be populated.", resourceId)); 276 } 277 278 return paths; 279 } 280 281 private String encode(final String value) { 282 if (SystemUtils.IS_OS_WINDOWS) { 283 final String encoded; 284 if (value.contains("/")) { 285 encoded = Arrays.stream(value.split("/")) 286 .map(s -> URLEncoder.encode(s, StandardCharsets.UTF_8)) 287 .collect(Collectors.joining("/")); 288 } else { 289 encoded = URLEncoder.encode(value, StandardCharsets.UTF_8); 290 } 291 return encoded; 292 } 293 return value; 294 } 295 296 private void addDecodedPaths(final OcflObjectUpdater updater, final OcflOption... ocflOptions) { 297 try (var paths = Files.walk(objectStaging)) { 298 paths.filter(Files::isRegularFile).forEach(file -> { 299 final var logicalPath = windowsStagingPathToLogicalPath(file); 300 updater.addPath(file, logicalPath, ocflOptions); 301 }); 302 } catch (IOException e) { 303 throw new UncheckedIOException(e); 304 } 305 } 306 307 private String windowsStagingPathToLogicalPath(final Path path) { 308 final var normalized = objectStaging.relativize(path).toString() 309 .replace("\\", "/"); 310 return URLDecoder.decode(normalized, StandardCharsets.UTF_8); 311 } 312 313 private void cleanup() { 314 if (Files.exists(objectStaging)) { 315 FileUtils.deleteQuietly(objectStaging.toFile()); 316 } 317 } 318 319 private void enforceOpen() { 320 if (closed) { 321 throw new IllegalStateException( 322 String.format("Session %s is already closed!", sessionId)); 323 } 324 } 325 326}