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 boolean isOpen() { 176 return !closed; 177 } 178 179 @Override 180 public void deleteContentFile(final ResourceHeaders headers) { 181 enforceOpen(); 182 183 final var paths = resolvePersistencePaths(headers); 184 deletePaths.add(paths.getContentFilePath()); 185 } 186 187 @Override 188 public void deleteResource(final String resourceId) { 189 throw new UnsupportedOperationException("Not implemented"); 190 } 191 192 @Override 193 public ResourceHeaders readHeaders(final String resourceId) { 194 throw new UnsupportedOperationException("Not implemented"); 195 } 196 197 @Override 198 public ResourceHeaders readHeaders(final String resourceId, final String versionNumber) { 199 throw new UnsupportedOperationException("Not implemented"); 200 } 201 202 @Override 203 public ResourceContent readContent(final String resourceId) { 204 throw new UnsupportedOperationException("Not implemented"); 205 } 206 207 @Override 208 public ResourceContent readContent(final String resourceId, final String versionNumber) { 209 throw new UnsupportedOperationException("Not implemented"); 210 } 211 212 @Override 213 public List<OcflVersionInfo> listVersions(final String resourceId) { 214 throw new UnsupportedOperationException("Not implemented"); 215 } 216 217 @Override 218 public Stream<ResourceHeaders> streamResourceHeaders() { 219 throw new UnsupportedOperationException("Not implemented"); 220 } 221 222 @Override 223 public void commitType(final CommitType commitType) { 224 throw new UnsupportedOperationException("Not implemented"); 225 } 226 227 @Override 228 public boolean containsResource(final String resourceId) { 229 throw new UnsupportedOperationException("Not implemented"); 230 } 231 232 private Path stagingPath(final String path) { 233 return objectStaging.resolve(path); 234 } 235 236 private Path createStagingPath(final String path) { 237 final var stagingPath = stagingPath(path); 238 239 try { 240 Files.createDirectories(stagingPath.getParent()); 241 } catch (IOException e) { 242 throw new UncheckedIOException(e); 243 } 244 245 return stagingPath; 246 } 247 248 private void write(final InputStream content, final Path destination) { 249 if (content != null) { 250 try { 251 Files.copy(content, destination, StandardCopyOption.REPLACE_EXISTING); 252 } catch (IOException e) { 253 throw new UncheckedIOException(e); 254 } 255 } 256 } 257 258 private PersistencePaths resolvePersistencePaths(final ResourceHeaders headers) { 259 final var resourceId = headers.getId(); 260 final PersistencePaths paths; 261 262 if (InteractionModel.ACL.getUri().equals(headers.getInteractionModel())) { 263 throw new UnsupportedOperationException("ACLs are not supported"); 264 } else if (InteractionModel.NON_RDF.getUri().equals(headers.getInteractionModel())) { 265 paths = PersistencePaths.nonRdfResource(ocflObjectId, resourceId); 266 } else if (headers.getInteractionModel() != null) { 267 paths = PersistencePaths.rdfResource(ocflObjectId, resourceId); 268 } else { 269 throw new IllegalArgumentException( 270 String.format("Interaction model for resource %s must be populated.", resourceId)); 271 } 272 273 return paths; 274 } 275 276 private String encode(final String value) { 277 if (SystemUtils.IS_OS_WINDOWS) { 278 final String encoded; 279 if (value.contains("/")) { 280 encoded = Arrays.stream(value.split("/")) 281 .map(s -> URLEncoder.encode(s, StandardCharsets.UTF_8)) 282 .collect(Collectors.joining("/")); 283 } else { 284 encoded = URLEncoder.encode(value, StandardCharsets.UTF_8); 285 } 286 return encoded; 287 } 288 return value; 289 } 290 291 private void addDecodedPaths(final OcflObjectUpdater updater, final OcflOption... ocflOptions) { 292 try (var paths = Files.walk(objectStaging)) { 293 paths.filter(Files::isRegularFile).forEach(file -> { 294 final var logicalPath = windowsStagingPathToLogicalPath(file); 295 updater.addPath(file, logicalPath, ocflOptions); 296 }); 297 } catch (IOException e) { 298 throw new UncheckedIOException(e); 299 } 300 } 301 302 private String windowsStagingPathToLogicalPath(final Path path) { 303 final var normalized = objectStaging.relativize(path).toString() 304 .replace("\\", "/"); 305 return URLDecoder.decode(normalized, StandardCharsets.UTF_8); 306 } 307 308 private void cleanup() { 309 if (Files.exists(objectStaging)) { 310 FileUtils.deleteQuietly(objectStaging.toFile()); 311 } 312 } 313 314 private void enforceOpen() { 315 if (closed) { 316 throw new IllegalStateException( 317 String.format("Session %s is already closed!", sessionId)); 318 } 319 } 320 321}