001/**
002 * Copyright 2015 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 */
016package org.fcrepo.http.api;
017
018
019import static com.google.common.collect.Iterators.concat;
020import static com.google.common.collect.Iterators.filter;
021import static com.google.common.collect.Iterators.transform;
022import static com.hp.hpl.jena.rdf.model.ModelFactory.createDefaultModel;
023import static com.hp.hpl.jena.vocabulary.RDF.type;
024import static javax.ws.rs.core.HttpHeaders.CACHE_CONTROL;
025import static javax.ws.rs.core.MediaType.APPLICATION_OCTET_STREAM_TYPE;
026import static javax.ws.rs.core.Response.ok;
027import static javax.ws.rs.core.Response.status;
028import static javax.ws.rs.core.Response.temporaryRedirect;
029import static javax.ws.rs.core.Response.Status.PARTIAL_CONTENT;
030import static javax.ws.rs.core.Response.Status.REQUESTED_RANGE_NOT_SATISFIABLE;
031import static org.apache.commons.lang3.StringUtils.isBlank;
032import static org.apache.jena.riot.RDFLanguages.contentTypeToLang;
033
034import static org.fcrepo.kernel.api.FedoraJcrTypes.LDP_BASIC_CONTAINER;
035import static org.fcrepo.kernel.api.FedoraJcrTypes.LDP_DIRECT_CONTAINER;
036import static org.fcrepo.kernel.api.FedoraJcrTypes.LDP_INDIRECT_CONTAINER;
037import static org.fcrepo.kernel.api.RdfLexicon.BASIC_CONTAINER;
038import static org.fcrepo.kernel.api.RdfLexicon.CONTAINER;
039import static org.fcrepo.kernel.api.RdfLexicon.DIRECT_CONTAINER;
040import static org.fcrepo.kernel.api.RdfLexicon.INDIRECT_CONTAINER;
041import static org.fcrepo.kernel.api.RdfLexicon.LDP_NAMESPACE;
042import static org.fcrepo.kernel.api.RdfLexicon.isManagedNamespace;
043import static org.fcrepo.kernel.modeshape.rdf.ManagedRdf.isManagedTriple;
044
045import java.io.IOException;
046import java.io.InputStream;
047import java.net.URI;
048import java.net.URISyntaxException;
049import java.util.Date;
050import java.util.Iterator;
051import java.util.function.Predicate;
052
053import javax.inject.Inject;
054import javax.jcr.AccessDeniedException;
055import javax.jcr.Binary;
056import javax.jcr.RepositoryException;
057import javax.jcr.Session;
058import javax.servlet.http.HttpServletResponse;
059import javax.ws.rs.BadRequestException;
060import javax.ws.rs.BeanParam;
061import javax.ws.rs.WebApplicationException;
062import javax.ws.rs.core.CacheControl;
063import javax.ws.rs.core.Context;
064import javax.ws.rs.core.EntityTag;
065import javax.ws.rs.core.MediaType;
066import javax.ws.rs.core.Request;
067import javax.ws.rs.core.Response;
068
069import com.fasterxml.jackson.core.JsonParseException;
070import org.apache.jena.atlas.RuntimeIOException;
071import org.apache.jena.riot.RiotException;
072import org.fcrepo.http.commons.api.rdf.HttpTripleUtil;
073import org.fcrepo.http.commons.domain.MultiPrefer;
074import org.fcrepo.http.commons.domain.PreferTag;
075import org.fcrepo.http.commons.domain.Range;
076import org.fcrepo.http.commons.domain.ldp.LdpPreferTag;
077import org.fcrepo.http.commons.responses.RangeRequestInputStream;
078import org.fcrepo.kernel.api.exception.InvalidChecksumException;
079import org.fcrepo.kernel.api.exception.MalformedRdfException;
080import org.fcrepo.kernel.api.exception.RepositoryRuntimeException;
081import org.fcrepo.kernel.api.models.Container;
082import org.fcrepo.kernel.api.models.FedoraBinary;
083import org.fcrepo.kernel.api.models.FedoraResource;
084import org.fcrepo.kernel.api.models.NonRdfSource;
085import org.fcrepo.kernel.api.models.NonRdfSourceDescription;
086import org.fcrepo.kernel.api.services.policy.StoragePolicyDecisionPoint;
087import org.fcrepo.kernel.api.utils.iterators.RdfStream;
088import org.fcrepo.kernel.modeshape.rdf.impl.AclRdfContext;
089import org.fcrepo.kernel.modeshape.rdf.impl.SkolemNodeRdfContext;
090import org.fcrepo.kernel.modeshape.rdf.impl.ChildrenRdfContext;
091import org.fcrepo.kernel.modeshape.rdf.impl.ContentRdfContext;
092import org.fcrepo.kernel.modeshape.rdf.impl.HashRdfContext;
093import org.fcrepo.kernel.modeshape.rdf.impl.LdpContainerRdfContext;
094import org.fcrepo.kernel.modeshape.rdf.impl.LdpIsMemberOfRdfContext;
095import org.fcrepo.kernel.modeshape.rdf.impl.LdpRdfContext;
096import org.fcrepo.kernel.modeshape.rdf.impl.ParentRdfContext;
097import org.fcrepo.kernel.modeshape.rdf.impl.PropertiesRdfContext;
098import org.fcrepo.kernel.modeshape.rdf.impl.ReferencesRdfContext;
099import org.fcrepo.kernel.modeshape.rdf.impl.RootRdfContext;
100import org.fcrepo.kernel.modeshape.rdf.impl.TypeRdfContext;
101import org.fcrepo.kernel.modeshape.services.TransactionServiceImpl;
102
103import org.apache.jena.riot.Lang;
104import org.glassfish.jersey.media.multipart.ContentDisposition;
105import org.jvnet.hk2.annotations.Optional;
106import com.google.common.collect.ImmutableList;
107import com.google.common.collect.Iterators;
108import com.hp.hpl.jena.graph.Triple;
109import com.hp.hpl.jena.rdf.model.Model;
110import com.hp.hpl.jena.rdf.model.Statement;
111
112/**
113 * An abstract class that sits between AbstractResource and any resource that
114 * wishes to share the routines for building responses containing binary
115 * content.
116 *
117 * @author Mike Durbin
118 * @author ajs6f
119 */
120public abstract class ContentExposingResource extends FedoraBaseResource {
121
122    public static final MediaType MESSAGE_EXTERNAL_BODY = MediaType.valueOf("message/external-body");
123
124    @Context protected Request request;
125    @Context protected HttpServletResponse servletResponse;
126
127    @Inject
128    @Optional
129    private HttpTripleUtil httpTripleUtil;
130
131    @BeanParam
132    protected MultiPrefer prefer;
133
134    @Inject
135    @Optional
136    StoragePolicyDecisionPoint storagePolicyDecisionPoint;
137
138    protected FedoraResource resource;
139
140    private static final long MAX_BUFFER_SIZE = 10240000;
141
142    private static final Predicate<Triple> IS_MANAGED_TYPE = t -> t.getPredicate().equals(type.asNode()) &&
143            isManagedNamespace.test(t.getObject().getNameSpace());
144
145    protected abstract String externalPath();
146
147    protected Response getContent(final String rangeValue,
148                                  final RdfStream rdfStream) throws IOException {
149        if (resource() instanceof FedoraBinary) {
150
151            final String contentTypeString = ((FedoraBinary) resource()).getMimeType();
152
153            final Lang lang = contentTypeToLang(contentTypeString);
154
155            if (!contentTypeString.equals("text/plain") && lang != null) {
156
157                final String format = lang.getName().toUpperCase();
158
159                final InputStream content = ((FedoraBinary) resource()).getContent();
160
161                final Model inputModel = createDefaultModel()
162                        .read(content,  (resource()).toString(), format);
163
164                rdfStream.concat(Iterators.transform(inputModel.listStatements(), Statement::asTriple));
165            } else {
166
167                final MediaType mediaType = MediaType.valueOf(contentTypeString);
168                if (MESSAGE_EXTERNAL_BODY.isCompatible(mediaType)
169                        && mediaType.getParameters().containsKey("access-type")
170                        && mediaType.getParameters().get("access-type").equals("URL")
171                        && mediaType.getParameters().containsKey("URL") ) {
172                    try {
173                        return temporaryRedirect(new URI(mediaType.getParameters().get("URL"))).build();
174                    } catch (final URISyntaxException e) {
175                        throw new RepositoryRuntimeException(e);
176                    }
177                }
178                return getBinaryContent(rangeValue);
179            }
180
181        } else {
182            rdfStream.concat(getResourceTriples());
183            if (prefer != null) {
184                prefer.getReturn().addResponseHeaders(servletResponse);
185            }
186        }
187        servletResponse.addHeader("Vary", "Accept, Range, Accept-Encoding, Accept-Language");
188
189        return ok(rdfStream).build();
190    }
191
192    protected RdfStream getResourceTriples() {
193        // use the thing described, not the description, for the subject of descriptive triples
194        if (resource() instanceof NonRdfSourceDescription) {
195            resource = ((NonRdfSourceDescription) resource()).getDescribedResource();
196        }
197        final PreferTag returnPreference;
198
199        if (prefer != null && prefer.hasReturn()) {
200            returnPreference = prefer.getReturn();
201        } else if (prefer != null && prefer.hasHandling()) {
202            returnPreference = prefer.getHandling();
203        } else {
204            returnPreference = PreferTag.emptyTag();
205        }
206
207        final LdpPreferTag ldpPreferences = new LdpPreferTag(returnPreference);
208
209        final RdfStream rdfStream = new RdfStream();
210
211        final Predicate<Triple> tripleFilter = ldpPreferences.prefersServerManaged() ? x -> true :
212            IS_MANAGED_TYPE.or(isManagedTriple::test).negate();
213
214        if (ldpPreferences.prefersServerManaged()) {
215            rdfStream.concat(getTriples(LdpRdfContext.class));
216        }
217
218        rdfStream.concat(filter(getTriples(TypeRdfContext.class), tripleFilter::test));
219
220        rdfStream.concat(filter(getTriples(PropertiesRdfContext.class), tripleFilter::test));
221
222        if (!returnPreference.getValue().equals("minimal")) {
223
224            // Additional server-managed triples about this resource
225            if (ldpPreferences.prefersServerManaged()) {
226                rdfStream.concat(getTriples(AclRdfContext.class));
227                rdfStream.concat(getTriples(RootRdfContext.class));
228                rdfStream.concat(getTriples(ContentRdfContext.class));
229                rdfStream.concat(getTriples(ParentRdfContext.class));
230            }
231
232            // containment triples about this resource
233            if (ldpPreferences.prefersContainment()) {
234                rdfStream.concat(getTriples(ChildrenRdfContext.class));
235            }
236
237            // LDP container membership triples for this resource
238            if (ldpPreferences.prefersMembership()) {
239                rdfStream.concat(getTriples(LdpContainerRdfContext.class));
240                rdfStream.concat(getTriples(LdpIsMemberOfRdfContext.class));
241            }
242
243            // Embed all hash and blank nodes
244            rdfStream.concat(filter(getTriples(HashRdfContext.class), tripleFilter::test));
245            rdfStream.concat(filter(getTriples(SkolemNodeRdfContext.class), tripleFilter::test));
246
247            // Include inbound references to this object
248            if (ldpPreferences.prefersReferences()) {
249                rdfStream.concat(getTriples(ReferencesRdfContext.class));
250            }
251
252            // Embed the children of this object
253            if (ldpPreferences.prefersEmbed()) {
254
255                final Iterator<FedoraResource> children = resource().getChildren();
256
257                rdfStream.concat(filter(concat(transform(children, child ->
258                child.getTriples(translator(),
259                        ImmutableList.of(
260                                TypeRdfContext.class, PropertiesRdfContext.class, SkolemNodeRdfContext.class)))),
261                        tripleFilter::test));
262
263            }
264        }
265
266        if (httpTripleUtil != null && ldpPreferences.prefersServerManaged()) {
267            httpTripleUtil.addHttpComponentModelsForResourceToStream(rdfStream, resource(), uriInfo, translator());
268        }
269
270
271        return rdfStream;
272    }
273
274    /**
275     * Get the binary content of a datastream
276     *
277     * @param rangeValue the range value
278     * @return Binary blob
279     * @throws IOException if io exception occurred
280     */
281    protected Response getBinaryContent(final String rangeValue)
282            throws IOException {
283            final FedoraBinary binary = (FedoraBinary)resource();
284
285            // we include an explicit etag, because the default behavior is to use the JCR node's etag, not
286            // the jcr:content node digest. The etag is only included if we are not within a transaction.
287            final String txId = TransactionServiceImpl.getCurrentTransactionId(session());
288            if (txId == null) {
289                checkCacheControlHeaders(request, servletResponse, binary, session());
290            }
291            final CacheControl cc = new CacheControl();
292            cc.setMaxAge(0);
293            cc.setMustRevalidate(true);
294            Response.ResponseBuilder builder;
295
296            if (rangeValue != null && rangeValue.startsWith("bytes")) {
297
298                final Range range = Range.convert(rangeValue);
299
300                final long contentSize = binary.getContentSize();
301
302                final String endAsString;
303
304                if (range.end() == -1) {
305                    endAsString = Long.toString(contentSize - 1);
306                } else {
307                    endAsString = Long.toString(range.end());
308                }
309
310                final String contentRangeValue =
311                        String.format("bytes %s-%s/%s", range.start(),
312                                endAsString, contentSize);
313
314                if (range.end() > contentSize ||
315                        (range.end() == -1 && range.start() > contentSize)) {
316
317                    builder = status(REQUESTED_RANGE_NOT_SATISFIABLE)
318                            .header("Content-Range", contentRangeValue);
319                } else {
320                    final long rangeStart = range.start();
321                    final long rangeSize = range.size() == -1 ? contentSize - rangeStart : range.size();
322                    final long remainingBytes = contentSize - rangeStart;
323                    final long bufSize = rangeSize < remainingBytes ? rangeSize : remainingBytes;
324
325                    if (bufSize < MAX_BUFFER_SIZE) {
326                        // Small size range content retrieval use javax.jcr.Binary to improve performance
327                        final byte[] buf = new byte[(int) bufSize];
328
329                        final Binary binaryContent = binary.getBinaryContent();
330                        try {
331                            binaryContent.read(buf, rangeStart);
332                        } catch (final RepositoryException e1) {
333                            throw new RepositoryRuntimeException(e1);
334                        }
335                        binaryContent.dispose();
336
337                        builder = status(PARTIAL_CONTENT).entity(buf)
338                                .header("Content-Range", contentRangeValue);
339                    } else {
340                        // For large range content retrieval, go with the InputStream class to balance
341                        // the memory usage, though this is a rare case in range content retrieval.
342                        final InputStream content = binary.getContent();
343                        final RangeRequestInputStream rangeInputStream =
344                                new RangeRequestInputStream(content, range.start(), range.size());
345
346                        builder = status(PARTIAL_CONTENT).entity(rangeInputStream)
347                                .header("Content-Range", contentRangeValue);
348                    }
349                }
350
351            } else {
352                final InputStream content = binary.getContent();
353                builder = ok(content);
354            }
355
356
357            // we set the content-type explicitly to avoid content-negotiation from getting in the way
358            return builder.type(binary.getMimeType())
359                    .cacheControl(cc)
360                    .build();
361
362        }
363
364    protected RdfStream getTriples(final Class<? extends RdfStream> x) {
365        return getTriples(resource(), x);
366    }
367
368    protected RdfStream getTriples(final FedoraResource resource, final Class<? extends RdfStream> x) {
369        return resource.getTriples(translator(), x);
370    }
371
372    protected URI getUri(final FedoraResource resource) {
373        try {
374            final String uri = translator().reverse().convert(resource).getURI();
375            return new URI(uri);
376        } catch (final URISyntaxException e) {
377            throw new BadRequestException(e);
378        }
379    }
380
381    protected FedoraResource resource() {
382        if (resource == null) {
383            resource = getResourceFromPath(externalPath());
384        }
385        return resource;
386    }
387
388
389    /**
390     * Add any resource-specific headers to the response
391     * @param resource the resource
392     */
393    protected void addResourceHttpHeaders(final FedoraResource resource) {
394        if (resource instanceof FedoraBinary) {
395
396            final FedoraBinary binary = (FedoraBinary)resource;
397            final ContentDisposition contentDisposition = ContentDisposition.type("attachment")
398                    .fileName(binary.getFilename())
399                    .creationDate(binary.getCreatedDate())
400                    .modificationDate(binary.getLastModifiedDate())
401                    .size(binary.getContentSize())
402                    .build();
403
404            servletResponse.addHeader("Content-Type", binary.getMimeType());
405            servletResponse.addHeader("Content-Length", String.valueOf(binary.getContentSize()));
406            servletResponse.addHeader("Accept-Ranges", "bytes");
407            servletResponse.addHeader("Content-Disposition", contentDisposition.toString());
408        }
409
410        servletResponse.addHeader("Link", "<" + LDP_NAMESPACE + "Resource>;rel=\"type\"");
411
412        if (resource instanceof NonRdfSource) {
413            servletResponse.addHeader("Link", "<" + LDP_NAMESPACE + "NonRDFSource>;rel=\"type\"");
414        } else if (resource instanceof Container) {
415            servletResponse.addHeader("Link", "<" + CONTAINER.getURI() + ">;rel=\"type\"");
416            if (resource.hasType(LDP_BASIC_CONTAINER)) {
417                servletResponse.addHeader("Link", "<" + BASIC_CONTAINER.getURI() + ">;rel=\"type\"");
418            } else if (resource.hasType(LDP_DIRECT_CONTAINER)) {
419                servletResponse.addHeader("Link", "<" + DIRECT_CONTAINER.getURI() + ">;rel=\"type\"");
420            } else if (resource.hasType(LDP_INDIRECT_CONTAINER)) {
421                servletResponse.addHeader("Link", "<" + INDIRECT_CONTAINER.getURI() + ">;rel=\"type\"");
422            } else {
423                servletResponse.addHeader("Link", "<" + BASIC_CONTAINER.getURI() + ">;rel=\"type\"");
424            }
425        } else {
426            servletResponse.addHeader("Link", "<" + LDP_NAMESPACE + "RDFSource>;rel=\"type\"");
427        }
428
429    }
430
431    /**
432     * Evaluate the cache control headers for the request to see if it can be served from
433     * the cache.
434     *
435     * @param request the request
436     * @param servletResponse the servlet response
437     * @param resource the fedora resource
438     * @param session the session
439     */
440    protected static void checkCacheControlHeaders(final Request request,
441                                                   final HttpServletResponse servletResponse,
442                                                   final FedoraResource resource,
443                                                   final Session session) {
444        evaluateRequestPreconditions(request, servletResponse, resource, session, true);
445        addCacheControlHeaders(servletResponse, resource, session);
446    }
447
448    /**
449     * Add ETag and Last-Modified cache control headers to the response
450     * @param servletResponse the servlet response
451     * @param resource the fedora resource
452     * @param session the session
453     */
454    protected static void addCacheControlHeaders(final HttpServletResponse servletResponse,
455                                                 final FedoraResource resource,
456                                                 final Session session) {
457
458        final String txId = TransactionServiceImpl.getCurrentTransactionId(session);
459        if (txId != null) {
460            // Do not add caching headers if in a transaction
461            return;
462        }
463
464        final FedoraResource mutableResource = resource instanceof NonRdfSourceDescription
465                ? ((NonRdfSourceDescription) resource).getDescribedResource() : resource;
466        final EntityTag etag = new EntityTag(mutableResource.getEtagValue());
467        final Date date = mutableResource.getLastModifiedDate();
468
469        if (!etag.getValue().isEmpty()) {
470            servletResponse.addHeader("ETag", etag.toString());
471        }
472
473        if (date != null) {
474            servletResponse.addDateHeader("Last-Modified", date.getTime());
475        }
476    }
477
478    /**
479     * Evaluate request preconditions to ensure the resource is the expected state
480     * @param request the request
481     * @param servletResponse the servlet response
482     * @param resource the resource
483     * @param session the session
484     */
485    protected static void evaluateRequestPreconditions(final Request request,
486                                                       final HttpServletResponse servletResponse,
487                                                       final FedoraResource resource,
488                                                       final Session session) {
489        evaluateRequestPreconditions(request, servletResponse, resource, session, false);
490    }
491
492    private static void evaluateRequestPreconditions(final Request request,
493                                                     final HttpServletResponse servletResponse,
494                                                     final FedoraResource resource,
495                                                     final Session session,
496                                                     final boolean cacheControl) {
497
498        final String txId = TransactionServiceImpl.getCurrentTransactionId(session);
499        if (txId != null) {
500            // Force cache revalidation if in a transaction
501            servletResponse.addHeader(CACHE_CONTROL, "must-revalidate");
502            servletResponse.addHeader(CACHE_CONTROL, "max-age=0");
503            return;
504        }
505
506        final EntityTag etag = new EntityTag(resource.getEtagValue());
507        final Date date = resource.getLastModifiedDate();
508        final Date roundedDate = new Date();
509
510        if (date != null) {
511            roundedDate.setTime(date.getTime() - date.getTime() % 1000);
512        }
513
514        Response.ResponseBuilder builder = request.evaluatePreconditions(etag);
515        if ( builder != null ) {
516            builder = builder.entity("ETag mismatch");
517        } else {
518            builder = request.evaluatePreconditions(roundedDate);
519            if ( builder != null ) {
520                builder = builder.entity("Date mismatch");
521            }
522        }
523
524        if (builder != null && cacheControl ) {
525            final CacheControl cc = new CacheControl();
526            cc.setMaxAge(0);
527            cc.setMustRevalidate(true);
528            // here we are implicitly emitting a 304
529            // the exception is not an error, it's genuinely
530            // an exceptional condition
531            builder = builder.cacheControl(cc).lastModified(date).tag(etag);
532        }
533        if (builder != null) {
534            throw new WebApplicationException(builder.build());
535        }
536    }
537
538    protected static MediaType getSimpleContentType(final MediaType requestContentType) {
539        return requestContentType != null ? new MediaType(requestContentType.getType(), requestContentType.getSubtype())
540                : APPLICATION_OCTET_STREAM_TYPE;
541    }
542
543    protected static boolean isRdfContentType(final String contentTypeString) {
544        return contentTypeToLang(contentTypeString) != null;
545    }
546
547    protected void replaceResourceBinaryWithStream(final FedoraBinary result,
548                                                   final InputStream requestBodyStream,
549                                                   final ContentDisposition contentDisposition,
550                                                   final MediaType contentType,
551                                                   final String checksum) throws InvalidChecksumException {
552        final URI checksumURI = checksumURI(checksum);
553        final String originalFileName = contentDisposition != null ? contentDisposition.getFileName() : "";
554        final String originalContentType = contentType != null ? contentType.toString() : "";
555
556        result.setContent(requestBodyStream,
557                originalContentType,
558                checksumURI,
559                originalFileName,
560                storagePolicyDecisionPoint);
561    }
562
563    protected void replaceResourceWithStream(final FedoraResource resource,
564                                             final InputStream requestBodyStream,
565                                             final MediaType contentType,
566                                             final RdfStream resourceTriples) throws MalformedRdfException {
567        final Lang format = contentTypeToLang(contentType.toString());
568
569        final Model inputModel = createDefaultModel();
570        try {
571            inputModel.read(requestBodyStream, getUri(resource).toString(), format.getName().toUpperCase());
572
573        } catch (final RiotException e) {
574            throw new BadRequestException("RDF was not parsable: " + e.getMessage(), e);
575
576        } catch (final RuntimeIOException e) {
577            if (e.getCause() instanceof JsonParseException) {
578                throw new MalformedRdfException(e.getCause());
579            }
580            throw new RepositoryRuntimeException(e);
581        }
582
583        resource.replaceProperties(translator(), inputModel, resourceTriples);
584    }
585
586    protected void patchResourcewithSparql(final FedoraResource resource,
587            final String requestBody,
588            final RdfStream resourceTriples)
589                    throws MalformedRdfException, AccessDeniedException {
590        if (resource instanceof NonRdfSourceDescription) {
591            // update the described resource instead
592            ((NonRdfSourceDescription) resource).getDescribedResource()
593                    .updateProperties(translator(), requestBody, resourceTriples);
594        } else {
595            resource.updateProperties(translator(), requestBody, resourceTriples);
596        }
597    }
598
599    /**
600     * Create a checksum URI object.
601     **/
602    private static URI checksumURI( final String checksum ) {
603        if (!isBlank(checksum)) {
604            return URI.create(checksum);
605        }
606        return null;
607    }
608}