diff --git a/core/src/main/java/org/incenp/linkml/core/BinaryBlobConverter.java b/core/src/main/java/org/incenp/linkml/core/BinaryBlobConverter.java new file mode 100644 index 0000000..292027d --- /dev/null +++ b/core/src/main/java/org/incenp/linkml/core/BinaryBlobConverter.java @@ -0,0 +1,75 @@ +/* + * LinkML-Java - LinkML library for Java + * Copyright © 2026 Damien Goutte-Gattat + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions + * are met: + * + * (1) Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * (2) Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. + * + * (3) Neither the name of the copyright holder nor the names its + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS + * OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY + * WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +package org.incenp.linkml.core; + +import java.util.Base64; + +/** + * A converter for slots typed as xsd:base64Binary (represented as + * an array of byte). + *

+ * Of note, such a type currently does not exist in LinkML, though it can easily + * be added in a custom type declaration. The default Javagen templates won’t + * render it as a byte[], though. + */ +public class BinaryBlobConverter extends ScalarConverterBase { + + // Not sure of a better way to get the Class representing a byte[] array than + // having a dummy on which we can call getClass()... The correct way would be + // Byte.TYPE.arrayType(), but that method is only available from Java 12+. + private static byte[] array = new byte[1]; + + @Override + public Class getType() { + return array.getClass(); + } + + @Override + protected Object convertImpl(Object raw, ConverterContext ctx) throws LinkMLRuntimeException { + try { + return Base64.getDecoder().decode(raw.toString()); + } catch ( IllegalArgumentException e ) { + throw new LinkMLValueError("Invalid value, Base64-encoded binary blob expected", e); + } + } + + public Object serialise(Object object, ConverterContext ctx) throws LinkMLRuntimeException { + if ( getType().isInstance(object) ) { + return Base64.getEncoder().encodeToString((byte[]) object); + } else { + throw new LinkMLInternalError("Invalid value, array of bytes expected"); + } + } +} diff --git a/core/src/main/java/org/incenp/linkml/core/ConverterContext.java b/core/src/main/java/org/incenp/linkml/core/ConverterContext.java index 1ba40b7..3c3e9b1 100644 --- a/core/src/main/java/org/incenp/linkml/core/ConverterContext.java +++ b/core/src/main/java/org/incenp/linkml/core/ConverterContext.java @@ -168,6 +168,8 @@ public ConverterContext() { // whose range is set to the linkml:Any class). converters.put(Object.class, new TransparentConverter()); + addConverter(new BinaryBlobConverter()); + objectConverterProvider = (t) -> new ObjectConverter(t); typeResolver = new DefaultTypeDesignatorResolver(); } diff --git a/core/src/test/java/org/incenp/linkml/core/ObjectConverterTest.java b/core/src/test/java/org/incenp/linkml/core/ObjectConverterTest.java index 147784d..902613b 100644 --- a/core/src/test/java/org/incenp/linkml/core/ObjectConverterTest.java +++ b/core/src/test/java/org/incenp/linkml/core/ObjectConverterTest.java @@ -52,6 +52,7 @@ import org.incenp.linkml.core.samples.base.BaseURISelfDesignatedClass; import org.incenp.linkml.core.samples.base.ClassWithCustomConverter; import org.incenp.linkml.core.samples.base.ContainerOfAny; +import org.incenp.linkml.core.samples.base.ContainerOfBinaryData; import org.incenp.linkml.core.samples.base.ContainerOfBooleanValues; import org.incenp.linkml.core.samples.base.ContainerOfIRIIdentifiableObjects; import org.incenp.linkml.core.samples.base.ContainerOfIdentifiedSelfDesignatedObjects; @@ -773,6 +774,30 @@ void testParsingWithRefinedInheritedSlots() throws IOException { Assertions.assertEquals(2, tdf.getBars().get(0).getLength()); } + @Test + void testParsingBinaryBlobs() throws IOException { + ContainerOfBinaryData cobd = parseString("checksum: aGVsbG8=\nchecksums:\n - d29ybGQ=", + ContainerOfBinaryData.class); + Assertions.assertEquals("hello", new String(cobd.getChecksum())); + Assertions.assertEquals("world", new String(cobd.getChecksums().get(0))); + + // Can't use roundtrip for now because the generated Java code for binary blobs + // does not handle arrays the way we'd need it (two different arrays with the + // same contents are not equals). The Javagen template will need to be updated + // to + // use Arrays.hashCode() and Arrays.equals for those slots. + try { + Object raw = ctx.getConverter(cobd.getClass()).serialise(cobd, ctx); + Object cooked = ctx.getConverter(ContainerOfBinaryData.class).convert(raw, ctx); + Assertions.assertInstanceOf(ContainerOfBinaryData.class, cooked); + Assertions.assertEquals("hello", new String(((ContainerOfBinaryData) cooked).getChecksum())); + Assertions.assertEquals("world", new String(((ContainerOfBinaryData) cooked).getChecksums().get(0))); + } catch ( LinkMLRuntimeException e ) { + Assertions.fail("Unexpected exception", e); + } + + } + private T parse(String file, Class target) throws IOException { ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); FileInputStream stream = new FileInputStream(new File("src/test/resources/core/samples/", file)); diff --git a/core/src/test/java/org/incenp/linkml/core/samples/base/ContainerOfBinaryData.java b/core/src/test/java/org/incenp/linkml/core/samples/base/ContainerOfBinaryData.java new file mode 100644 index 0000000..80b4b78 --- /dev/null +++ b/core/src/test/java/org/incenp/linkml/core/samples/base/ContainerOfBinaryData.java @@ -0,0 +1,102 @@ +package org.incenp.linkml.core.samples.base; + +import java.net.URI; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.incenp.linkml.core.annotations.Converter; +import org.incenp.linkml.core.annotations.ExtensionHolder; +import org.incenp.linkml.core.annotations.Identifier; +import org.incenp.linkml.core.annotations.Inlined; +import org.incenp.linkml.core.annotations.LinkURI; +import org.incenp.linkml.core.annotations.Required; +import org.incenp.linkml.core.annotations.SlotName; +import org.incenp.linkml.core.annotations.TypeDesignator; +import org.incenp.linkml.core.CurieConverter; + +@LinkURI("https://incenp.org/dvlpt/linkml-java/tests/samples#ContainerOfBinaryData") +public class ContainerOfBinaryData { + + @LinkURI("https://incenp.org/dvlpt/linkml-java/tests/samples#checksum") + private byte[] checksum; + + @LinkURI("https://incenp.org/dvlpt/linkml-java/tests/samples#checksums") + private List checksums; + + public void setChecksum(byte[] checksum) { + this.checksum = checksum; + } + + public byte[] getChecksum() { + return this.checksum; + } + + public void setChecksums(List checksums) { + this.checksums = checksums; + } + + public List getChecksums() { + return this.checksums; + } + + public List getChecksums(boolean set) { + if ( this.checksums == null && set ) { + this.checksums = new ArrayList<>(); + } + return this.checksums; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + Object o; + sb.append("ContainerOfBinaryData("); + if ( (o = this.getChecksum()) != null ) { + sb.append("checksum="); + sb.append(o); + sb.append(","); + } + if ( (o = this.getChecksums()) != null ) { + sb.append("checksums="); + sb.append(o); + sb.append(","); + } + sb.append(")"); + return sb.toString(); + } + + @Override + public boolean equals(final Object o) { + if ( o == this ) return true; + if ( !(o instanceof ContainerOfBinaryData) ) return false; + final ContainerOfBinaryData other = (ContainerOfBinaryData) o; + if ( !other.canEqual((Object) this)) return false; + final Object this$checksum = this.getChecksum(); + final Object other$checksum = other.getChecksum(); + if ( this$checksum == null ? other$checksum != null : !this$checksum.equals(other$checksum) ) return false; + final Object this$checksums = this.getChecksums(); + final Object other$checksums = other.getChecksums(); + if ( this$checksums == null ? other$checksums != null : !this$checksums.equals(other$checksums) ) return false; + return true; + } + + protected boolean canEqual(final Object other) { + return other instanceof ContainerOfBinaryData; + } + + @Override + public int hashCode() { + final int PRIME = 59; + int result = 1; + final Object $checksum = this.getChecksum(); + result = result * PRIME + ($checksum == null ? 43 : $checksum.hashCode()); + final Object $checksums = this.getChecksums(); + result = result * PRIME + ($checksums == null ? 43 : $checksums.hashCode()); + return result; + } +} \ No newline at end of file diff --git a/core/src/test/linkml/schemas/org/incenp/linkml/core/samples/base/samples.yaml b/core/src/test/linkml/schemas/org/incenp/linkml/core/samples/base/samples.yaml index e9a74fa..4a687bc 100644 --- a/core/src/test/linkml/schemas/org/incenp/linkml/core/samples/base/samples.yaml +++ b/core/src/test/linkml/schemas/org/incenp/linkml/core/samples/base/samples.yaml @@ -10,6 +10,7 @@ license: https://spdx.org/licenses/BSD-3-Clause.html prefixes: linkml: https://w3id.org/linkml/ rt: https://incenp.org/dvlpt/linkml-java/tests/samples# + xsd: http://www.w3.org/2001/XMLSchema# default_prefix: rt default_range: string @@ -17,6 +18,13 @@ default_range: string imports: - linkml:types +types: + + blob: + uri: xsd:base64Binary + base: string + description: A Base64-encoded array of bytes. + classes: SimpleClass: @@ -482,6 +490,16 @@ classes: inlined: true inlined_as_list: false + ContainerOfBinaryData: + description: >- + A class with a slot expecting a binary blob. + attributes: + checksum: + range: blob + checksums: + range: blob + multivalued: true + enums: diff --git a/core/src/test/linkml/scripts/javagen.py b/core/src/test/linkml/scripts/javagen.py index 23ab158..5335fb4 100644 --- a/core/src/test/linkml/scripts/javagen.py +++ b/core/src/test/linkml/scripts/javagen.py @@ -12,6 +12,17 @@ def cleanup_dir(directory: Path) -> None: directory.rmdir() +# We need a custom generator for now to deal with the custom +# "binary blob" type. +class CustomGenerator(JavaGenerator): + + def map_type(self, t, required = False): + if t.uri == "xsd:base64Binary": + return "byte[]" + else: + return super().map_type(t, required) + + @click.option("--output-directory", type=click.Path(dir_okay=True, file_okay=False, path_type=Path), default=Path("core/src/test/java")) @@ -31,10 +42,10 @@ def cli(output_directory: Path, schema_directory: Path) -> None: cleaned_up_dirs[output_dir] = 1 package_name = package_dir.as_posix().replace("/", ".") - gen = JavaGenerator(schema, - true_enums=True, - use_aliases=True, - package=package_name) + gen = CustomGenerator(schema, + true_enums=True, + use_aliases=True, + package=package_name) gen.serialize(output_dir, template_variant="org.incenp.linkml")