blob: 7fed11d6590fd6a9468acd4747a085ab7f1b0b74 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.causeway.applib.value;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InvalidObjectException;
import java.io.ObjectInputStream;
import java.io.OutputStream;
import java.io.Serializable;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Objects;
import java.util.Optional;
import javax.activation.MimeType;
import javax.activation.MimeTypeParseException;
import javax.inject.Named;
import javax.xml.bind.annotation.adapters.XmlAdapter;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import org.springframework.lang.Nullable;
import org.apache.causeway.applib.CausewayModuleApplib;
import org.apache.causeway.applib.annotation.Value;
import org.apache.causeway.applib.jaxb.PrimitiveJaxbAdapters;
import org.apache.causeway.commons.functional.Try;
import org.apache.causeway.commons.internal.base._NullSafe;
import org.apache.causeway.commons.internal.base._Strings;
import org.apache.causeway.commons.internal.exceptions._Exceptions;
import org.apache.causeway.commons.internal.image._Images;
import org.apache.causeway.commons.io.DataSource;
import org.apache.causeway.commons.io.HashUtils;
import org.apache.causeway.commons.io.HashUtils.HashAlgorithm;
import org.apache.causeway.commons.io.ZipUtils;
import org.apache.causeway.commons.io.ZipUtils.ZipOptions;
import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.val;
import lombok.extern.log4j.Log4j2;
/**
* Represents a binary large object.
*
* <p>
* Conceptually you can consider it as a set of bytes (a picture, a video etc),
* though in fact it wraps three pieces of information:
* </p>
* <ul>
* <li>
* the set of bytes
* </li>
* <li>
* a name
* </li>
* <li>
* a mime type
* </li>
* </ul>
*
* @see Clob
* @since 1.x {@index}
*/
@Named(CausewayModuleApplib.NAMESPACE + ".value.Blob")
@Value
@XmlJavaTypeAdapter(Blob.JaxbToStringAdapter.class) // for JAXB view model support
@Log4j2
public final class Blob implements NamedWithMimeType {
private static final long serialVersionUID = SerializationProxy.serialVersionUID;
// -- FACTORIES
/**
* Returns a new {@link Blob} of given {@code name}, {@code mimeType} and {@code content}.
* <p>
* {@code name} may or may not include the desired filename extension, as it
* is guaranteed, that the resulting {@link Blob} has the appropriate extension
* as constraint by the given {@code mimeType}.
* <p>
* For more fine-grained control use one of the {@link Blob} constructors directly.
* @param name - may or may not include the desired filename extension
* @param mimeType
* @param content - bytes
* @return new {@link Blob}
*/
public static Blob of(final String name, final CommonMimeType mimeType, final byte[] content) {
val fileName = _Strings.asFileNameWithExtension(name, mimeType.getProposedFileExtensions());
return new Blob(fileName, mimeType.getMimeType(), content);
}
/**
* Returns a new {@link Blob} of given {@code name}, {@code mimeType} and content from {@code dataSource},
* wrapped with a {@link Try}.
* <p>
* {@code name} may or may not include the desired filename extension, as it
* is guaranteed, that the resulting {@link Blob} has the appropriate extension
* as constraint by the given {@code mimeType}.
* <p>
* For more fine-grained control use one of the {@link Blob} factories directly.
* @param name - may or may not include the desired filename extension
* @param mimeType
* @param dataSource - the {@link DataSource} to be opened for reading
* @return new {@link Blob}
*/
public static Try<Blob> tryRead(final String name, final CommonMimeType mimeType, final DataSource dataSource) {
return dataSource.tryReadAsBytes()
.mapSuccess(bytes->Blob.of(name, mimeType, bytes.orElse(null)));
}
/**
* Shortcut for {@code tryRead(name, mimeType, DataSource.ofFile(file))}
* @see #tryRead(String, org.apache.causeway.applib.value.NamedWithMimeType.CommonMimeType, DataSource)
*/
public static Try<Blob> tryRead(final String name, final CommonMimeType mimeType, final File file) {
return tryRead(name, mimeType, DataSource.ofFile(file));
}
// --
private final MimeType mimeType;
private final byte[] bytes;
private final String name;
public Blob(final String name, final String primaryType, final String subtype, final byte[] bytes) {
this(name, CommonMimeType.newMimeType(primaryType, subtype), bytes);
}
public Blob(final String name, final String mimeTypeBase, final byte[] bytes) {
this(name, CommonMimeType.newMimeType(mimeTypeBase), bytes);
}
public Blob(final String name, final MimeType mimeType, final byte[] bytes) {
if(name == null) {
throw new IllegalArgumentException("Name cannot be null");
}
if(mimeType == null) {
throw new IllegalArgumentException("MimeType cannot be null");
}
if(name.contains(":")) {
throw new IllegalArgumentException("Name cannot contain ':'");
}
if(bytes == null) {
throw new IllegalArgumentException("Bytes cannot be null");
}
this.name = name;
this.mimeType = mimeType;
this.bytes = bytes;
}
@Override
public String getName() {
return name;
}
@Override
public MimeType getMimeType() {
return mimeType;
}
public byte[] getBytes() {
return bytes;
}
// -- UTILITIES
/**
* Converts to a {@link Clob}, using given {@link Charset}
* for the underlying byte[] to String conversion.
*/
public Clob toClob(final @NonNull Charset charset) {
return new Clob(getName(), getMimeType(), _Strings.ofBytes(getBytes(), charset));
}
/**
* Does not close the OutputStream.
*/
@SneakyThrows
public void writeBytesTo(final @Nullable OutputStream os) {
if(os==null) {
return;
}
if(bytes!=null) {
os.write(bytes);
}
}
/**
* Writes this {@link Blob} to the file represented by
* the specified <code>File</code> object.
* <p>
* If the file exists but is a directory rather than a regular file, does
* not exist but cannot be created, or cannot be opened for any other
* reason then a <code>FileNotFoundException</code> is thrown.
*
* @param file the file to be opened for writing; if <code>null</code> this method does nothing
* @see java.io.FileOutputStream
*/
@SneakyThrows
public void writeTo(final @Nullable File file) {
if(file==null) {
return; // just ignore
}
try(val os = new FileOutputStream(file)){
writeBytesTo(os);
}
}
/**
* Returns a new {@link DataSource} for underlying byte array.
* @see DataSource
*/
public DataSource asDataSource() {
return DataSource.ofBytes(_NullSafe.toNonNull(getBytes()));
}
/**
* Returns a new {@link Blob} that has this Blob's underlying byte array
* zipped into a zip-entry using this Blob's name.
*/
public Blob zip() {
return zip(getName());
}
/**
* Returns a new {@link Blob} that has this Blob's underlying byte array
* zipped into a zip-entry with given zip-entry name.
* @param zipEntryNameIfAny - if null or empty this Blob's name is used
*/
public Blob zip(final @Nullable String zipEntryNameIfAny) {
val zipEntryName = _Strings.nonEmpty(zipEntryNameIfAny)
.orElseGet(this::getName);
val zipBuilder = ZipUtils.zipEntryBuilder();
zipBuilder.add(zipEntryName, getBytes());
return Blob.of(getName()+".zip", CommonMimeType.ZIP, zipBuilder.toBytes());
}
public Blob unZip(final @NonNull CommonMimeType resultingMimeType) {
return unZip(resultingMimeType, ZipOptions.builder().build());
}
public Blob unZip(final @NonNull CommonMimeType resultingMimeType, final @NonNull ZipOptions zipOptions) {
return ZipUtils.firstZipEntry(asDataSource(), zipOptions) // assuming first entry is the one we want
.map(zipEntryDataSource->Blob.of(
zipEntryDataSource.zipEntry().getName(),
resultingMimeType,
zipEntryDataSource.bytes()))
.orElseThrow(()->_Exceptions
.unrecoverable("failed to unzip blob, no entry found %s", getName()));
}
// -- HASHING
public Try<HashUtils.Hash> tryHash(final @NonNull HashAlgorithm hashAlgorithm) {
return HashUtils.tryDigest(hashAlgorithm, bytes, 4*1024); // 4k default
}
public String md5Hex() {
return tryHash(HashAlgorithm.MD5)
.valueAsNonNullElseFail()
.asHexString();
}
public String sha256Hex() {
return tryHash(HashAlgorithm.SHA256)
.valueAsNonNullElseFail()
.asHexString();
}
// -- OBJECT CONTRACT
@Override
public boolean equals(final Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
final Blob blob = (Blob) o;
return Objects.equals(mimeType.toString(), blob.mimeType.toString()) &&
Arrays.equals(bytes, blob.bytes) &&
Objects.equals(name, blob.name);
}
@Override
public int hashCode() {
int result = Objects.hash(mimeType.toString(), name);
result = 31 * result + Arrays.hashCode(bytes);
return result;
}
@Override
public String toString() {
return getName() + " [" + getMimeType().getBaseType() + "]: " + getBytes().length + " bytes";
}
/**
* (thread-safe)
* @implNote see also BlobValueSemanticsProvider
*/
public static final class JaxbToStringAdapter extends XmlAdapter<String, Blob> {
private final PrimitiveJaxbAdapters.BytesAdapter bytesAdapter = new PrimitiveJaxbAdapters.BytesAdapter(); // thread-safe
@Override
public Blob unmarshal(final String data) throws Exception {
if(data==null) {
return null;
}
final int colonIdx = data.indexOf(':');
final String name = data.substring(0, colonIdx);
final int colon2Idx = data.indexOf(":", colonIdx+1);
final String mimeTypeBase = data.substring(colonIdx+1, colon2Idx);
final String payload = data.substring(colon2Idx+1);
final byte[] bytes = bytesAdapter.unmarshal(payload);
try {
return new Blob(name, new MimeType(mimeTypeBase), bytes);
} catch (MimeTypeParseException e) {
throw new RuntimeException(e);
}
}
@Override
public String marshal(final Blob blob) throws Exception {
if(blob==null) {
return null;
}
String s = blob.getName() +
':' +
blob.getMimeType().getBaseType() +
':' +
bytesAdapter.marshal(blob.getBytes());
return s;
}
}
/**
* @return optionally the payload as a {@link BufferedImage} based on whether
* this Blob's MIME type identifies as image and whether the payload is not empty
*/
public Optional<BufferedImage> asImage() {
val bytes = getBytes();
if(bytes == null) {
return Optional.empty();
}
val mimeType = getMimeType();
if(mimeType == null || !mimeType.getPrimaryType().equals("image")) {
return Optional.empty();
}
try {
val img = _Images.fromBytes(getBytes());
return Optional.ofNullable(img);
} catch (Exception e) {
log.error("failed to read image data", e);
return Optional.empty();
}
}
// -- SERIALIZATION PROXY
private Object writeReplace() {
return new SerializationProxy(this);
}
private void readObject(final ObjectInputStream stream) throws InvalidObjectException {
throw new InvalidObjectException("Proxy required");
}
private static class SerializationProxy implements Serializable {
/**
* Generated, based on String, String, bytes[]
*/
private static final long serialVersionUID = -950845631214162726L;
private final String name;
private final String mimeTypeBase;
private final byte[] bytes;
private SerializationProxy(final Blob blob) {
this.name = blob.getName();
this.mimeTypeBase = blob.getMimeType().getBaseType();
this.bytes = blob.getBytes();
}
private Object readResolve() {
return new Blob(name, mimeTypeBase, bytes);
}
}
}