diff --git a/modules/nf-commons/src/main/nextflow/serde/gson/RuntimeTypeAdapterFactory.java b/modules/nf-commons/src/main/nextflow/serde/gson/RuntimeTypeAdapterFactory.java index fe51535f60..90d14f2954 100644 --- a/modules/nf-commons/src/main/nextflow/serde/gson/RuntimeTypeAdapterFactory.java +++ b/modules/nf-commons/src/main/nextflow/serde/gson/RuntimeTypeAdapterFactory.java @@ -251,6 +251,18 @@ public RuntimeTypeAdapterFactory registerSubtype(Class type) { return registerSubtype(type, type.getSimpleName()); } + protected Class getSubTypeFromLabel(String label){ + return labelToSubtype.get(label); + } + + protected String getLabelFromSubtype(Class subType){ + return subtypeToLabel.get(subType); + } + + protected String getTypeFieldName(){ + return typeFieldName; + } + @Override public TypeAdapter create(Gson gson, TypeToken type) { if (type == null) { diff --git a/modules/nf-lineage/src/main/nextflow/lineage/serde/LinTypeAdapterFactory.groovy b/modules/nf-lineage/src/main/nextflow/lineage/serde/LinTypeAdapterFactory.groovy index 7e6cba7b7f..6d0516a232 100644 --- a/modules/nf-lineage/src/main/nextflow/lineage/serde/LinTypeAdapterFactory.groovy +++ b/modules/nf-lineage/src/main/nextflow/lineage/serde/LinTypeAdapterFactory.groovy @@ -16,7 +16,6 @@ package nextflow.lineage.serde import com.google.gson.Gson -import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonParseException import com.google.gson.JsonParser @@ -24,7 +23,6 @@ import com.google.gson.TypeAdapter import com.google.gson.reflect.TypeToken import com.google.gson.stream.JsonReader import com.google.gson.stream.JsonWriter - import groovy.transform.CompileStatic import nextflow.lineage.model.v1beta1.FileOutput import nextflow.lineage.model.v1beta1.LinModel @@ -34,7 +32,6 @@ import nextflow.lineage.model.v1beta1.Workflow import nextflow.lineage.model.v1beta1.WorkflowOutput import nextflow.lineage.model.v1beta1.WorkflowRun import nextflow.serde.gson.RuntimeTypeAdapterFactory - /** * Class to serialize LiSerializable objects including the Lineage model version. * @@ -42,7 +39,9 @@ import nextflow.serde.gson.RuntimeTypeAdapterFactory */ @CompileStatic class LinTypeAdapterFactory extends RuntimeTypeAdapterFactory { + public static final String VERSION_FIELD = 'version' + public static final String SPEC_FIELD = 'spec' public static final String CURRENT_VERSION = LinModel.VERSION LinTypeAdapterFactory() { @@ -53,7 +52,6 @@ class LinTypeAdapterFactory extends RuntimeTypeAdapterFactory { .registerSubtype(TaskRun, TaskRun.simpleName) .registerSubtype(TaskOutput, TaskOutput.simpleName) .registerSubtype(FileOutput, FileOutput.simpleName) - } @Override @@ -70,39 +68,43 @@ class LinTypeAdapterFactory extends RuntimeTypeAdapterFactory { return new TypeAdapter() { @Override void write(JsonWriter out, R value) throws IOException { - def json = delegate.toJsonTree(value) - if (json instanceof JsonObject) { - json = addVersion(json) - } - gson.toJson(json, out) + final object = new JsonObject() + object.addProperty(VERSION_FIELD, CURRENT_VERSION) + String label = getLabelFromSubtype(value.class) + if (!label) + throw new JsonParseException("Not registered class ${value.class}") + object.addProperty(getTypeFieldName(), label) + def json = gson.toJsonTree(value) + object.add(SPEC_FIELD, json) + gson.toJson(object, out) } @Override R read(JsonReader reader) throws IOException { - def json = JsonParser.parseReader(reader) - if (json instanceof JsonObject) { - def obj = (JsonObject) json - def versionEl = obj.get(VERSION_FIELD) - if (versionEl == null || versionEl.asString != CURRENT_VERSION) { - throw new JsonParseException("Invalid or missing version") - } + final obj = JsonParser.parseReader(reader)?.getAsJsonObject() + if( obj==null ) + throw new JsonParseException("Parsed JSON object is null") + final versionEl = obj.get(VERSION_FIELD) + if (versionEl == null || versionEl.asString != CURRENT_VERSION) { + throw new JsonParseException("Invalid or missing '${VERSION_FIELD}' JSON property") + } + final typeEl = obj.get(getTypeFieldName()) + if( typeEl==null ) + throw new JsonParseException("JSON property '${getTypeFieldName()}' not found") + + // Check if this is the new format (has 'spec' field) or old format (data at root level) + final specEl = obj.get(SPEC_FIELD)?.asJsonObject + if ( specEl != null ) { + // New format: data is wrapped in 'spec' field + specEl.add(getTypeFieldName(), typeEl) + return (R) delegate.fromJsonTree(specEl) + } else { + // Old format: data is at root level, just remove version field obj.remove(VERSION_FIELD) + return (R) delegate.fromJsonTree(obj) } - return delegate.fromJsonTree(json) } } } - private static JsonObject addVersion(JsonObject json){ - if( json.has(VERSION_FIELD) ) - throw new JsonParseException("object already defines a field named ${VERSION_FIELD}") - - JsonObject clone = new JsonObject(); - clone.addProperty(VERSION_FIELD, CURRENT_VERSION) - for (Map.Entry e : json.entrySet()) { - clone.add(e.getKey(), e.getValue()); - } - return clone - } - } diff --git a/modules/nf-lineage/src/test/nextflow/lineage/cli/LinCommandImplTest.groovy b/modules/nf-lineage/src/test/nextflow/lineage/cli/LinCommandImplTest.groovy index 396e62bfec..00a51d36c3 100644 --- a/modules/nf-lineage/src/test/nextflow/lineage/cli/LinCommandImplTest.groovy +++ b/modules/nf-lineage/src/test/nextflow/lineage/cli/LinCommandImplTest.groovy @@ -387,27 +387,27 @@ class LinCommandImplTest extends Specification{ def expectedOutput = '''diff --git 12345 67890 --- 12345 +++ 67890 -@@ -1,16 +1,16 @@ - { +@@ -2,16 +2,16 @@ "version": "lineage/v1beta1", "kind": "FileOutput", -- "path": "path/to/file", -+ "path": "path/to/file2", - "checksum": { -- "value": "45372qe", -+ "value": "42472qet", - "algorithm": "nextflow", - "mode": "standard" - }, -- "source": "lid://123987/file.bam", -+ "source": "lid://123987/file2.bam", - "workflowRun": "lid://123987/", - "taskRun": null, -- "size": 1234, -+ "size": 1235, - "createdAt": "1970-01-02T10:17:36.789Z", - "modifiedAt": "1970-01-02T10:17:36.789Z", - "labels": null + "spec": { +- "path": "path/to/file", ++ "path": "path/to/file2", + "checksum": { +- "value": "45372qe", ++ "value": "42472qet", + "algorithm": "nextflow", + "mode": "standard" + }, +- "source": "lid://123987/file.bam", ++ "source": "lid://123987/file2.bam", + "workflowRun": "lid://123987/", + "taskRun": null, +- "size": 1234, ++ "size": 1235, + "createdAt": "1970-01-02T10:17:36.789Z", + "modifiedAt": "1970-01-02T10:17:36.789Z", + "labels": null ''' when: diff --git a/modules/nf-lineage/src/test/nextflow/lineage/fs/LinFileSystemProviderTest.groovy b/modules/nf-lineage/src/test/nextflow/lineage/fs/LinFileSystemProviderTest.groovy index 1800e7b393..2d5e83194d 100644 --- a/modules/nf-lineage/src/test/nextflow/lineage/fs/LinFileSystemProviderTest.groovy +++ b/modules/nf-lineage/src/test/nextflow/lineage/fs/LinFileSystemProviderTest.groovy @@ -123,7 +123,7 @@ class LinFileSystemProviderTest extends Specification { def output = data.resolve("output.txt") output.text = "Hello, World!" outputMeta.mkdirs() - outputMeta.resolve(".data.json").text = '{"version":"lineage/v1beta1","kind":"FileOutput","path":"'+output.toString()+'"}' + outputMeta.resolve(".data.json").text = '{"version":"lineage/v1beta1","kind":"FileOutput","spec":{"path":"'+output.toString()+'"}}' Global.session = Mock(Session) { getConfig()>>config } and: @@ -179,7 +179,7 @@ class LinFileSystemProviderTest extends Specification { def config = [lineage:[store:[location:wdir.toString()]]] def outputMeta = wdir.resolve("12345") outputMeta.mkdirs() - outputMeta.resolve(".data.json").text = '{"version":"lineage/v1beta1","kind":"WorkflowRun","sessionId":"session","name":"run_name","params":[{"type":"String","name":"param1","value":"value1"}]}' + outputMeta.resolve(".data.json").text = '{"version":"lineage/v1beta1","kind":"WorkflowRun","spec":{"sessionId":"session","name":"run_name","params":[{"type":"String","name":"param1","value":"value1"}]}}' Global.session = Mock(Session) { getConfig()>>config } and: @@ -238,7 +238,7 @@ class LinFileSystemProviderTest extends Specification { def output = data.resolve("output.txt") output.text = "Hello, World!" outputMeta.mkdirs() - outputMeta.resolve(".data.json").text = '{"version":"lineage/v1beta1","kind":"FileOutput","path":"'+output.toString()+'"}' + outputMeta.resolve(".data.json").text = '{"version":"lineage/v1beta1","kind":"FileOutput","spec":{"path":"'+output.toString()+'"}}' Global.session = Mock(Session) { getConfig()>>config } and: @@ -278,8 +278,8 @@ class LinFileSystemProviderTest extends Specification { output1.resolve('file3.txt').text = 'file3' wdir.resolve('12345/output1').mkdirs() wdir.resolve('12345/output2').mkdirs() - wdir.resolve('12345/.data.json').text = '{"version":"lineage/v1beta1","kind":"TaskRun"}' - wdir.resolve('12345/output1/.data.json').text = '{"version":"lineage/v1beta1","kind":"FileOutput", "path": "' + output1.toString() + '"}' + wdir.resolve('12345/.data.json').text = '{"version":"lineage/v1beta1","kind":"TaskRun","spec":{"name":"dummy"}}' + wdir.resolve('12345/output1/.data.json').text = '{"version":"lineage/v1beta1","kind":"FileOutput","spec":{"path": "' + output1.toString() + '"}}' and: def config = [lineage:[store:[location:wdir.toString()]]] @@ -403,7 +403,7 @@ class LinFileSystemProviderTest extends Specification { output.resolve('abc').text = 'file1' output.resolve('.foo').text = 'file2' wdir.resolve('12345/output').mkdirs() - wdir.resolve('12345/output/.data.json').text = '{"version":"lineage/v1beta1","kind":"FileOutput", "path": "' + output.toString() + '"}' + wdir.resolve('12345/output/.data.json').text = '{"version":"lineage/v1beta1","kind":"FileOutput","spec":{"path": "' + output.toString() + '"}}' and: def provider = new LinFileSystemProvider() def lid1 = provider.getPath(LinPath.asUri('lid://12345/output/abc')) @@ -423,7 +423,7 @@ class LinFileSystemProviderTest extends Specification { def file = data.resolve('abc') file.text = 'Hello' wdir.resolve('12345/abc').mkdirs() - wdir.resolve('12345/abc/.data.json').text = '{"version":"lineage/v1beta1","kind":"FileOutput", "path":"' + file.toString() + '"}' + wdir.resolve('12345/abc/.data.json').text = '{"version":"lineage/v1beta1","kind":"FileOutput","spec":{"path":"' + file.toString() + '"}}' and: Global.session = Mock(Session) { getConfig()>>config } and: diff --git a/modules/nf-lineage/src/test/nextflow/lineage/fs/LinPathTest.groovy b/modules/nf-lineage/src/test/nextflow/lineage/fs/LinPathTest.groovy index fdb16cf5f8..b7ec578655 100644 --- a/modules/nf-lineage/src/test/nextflow/lineage/fs/LinPathTest.groovy +++ b/modules/nf-lineage/src/test/nextflow/lineage/fs/LinPathTest.groovy @@ -161,9 +161,9 @@ class LinPathTest extends Specification { wdir.resolve('12345/output1').mkdirs() wdir.resolve('12345/path/to/file2.txt').mkdirs() - wdir.resolve('12345/.data.json').text = '{"version":"lineage/v1beta1","kind":"TaskRun"}' - wdir.resolve('12345/output1/.data.json').text = '{"version":"lineage/v1beta1","kind":"FileOutput", "path": "' + outputFolder.toString() + '"}' - wdir.resolve('12345/path/to/file2.txt/.data.json').text = '{"version":"lineage/v1beta1","kind":"FileOutput", "path": "' + outputFile.toString() + '"}' + wdir.resolve('12345/.data.json').text = '{"version":"lineage/v1beta1","kind":"TaskRun","spec":{"name":"test"}}' + wdir.resolve('12345/output1/.data.json').text = '{"version":"lineage/v1beta1","kind":"FileOutput","spec":{"path": "' + outputFolder.toString() + '"}}' + wdir.resolve('12345/path/to/file2.txt/.data.json').text = '{"version":"lineage/v1beta1","kind":"FileOutput","spec":{"path": "' + outputFile.toString() + '"}}' def time = OffsetDateTime.now() def wfResultsMetadata = new LinEncoder().withPrettyPrint(true).encode(new WorkflowOutput(time, "lid://1234", [new Parameter( "Path", "a", "lid://1234/a.txt")])) wdir.resolve('5678/').mkdirs() diff --git a/modules/nf-lineage/src/test/nextflow/lineage/serde/LinEncoderTest.groovy b/modules/nf-lineage/src/test/nextflow/lineage/serde/LinEncoderTest.groovy index ee4265363a..da9e749b98 100644 --- a/modules/nf-lineage/src/test/nextflow/lineage/serde/LinEncoderTest.groovy +++ b/modules/nf-lineage/src/test/nextflow/lineage/serde/LinEncoderTest.groovy @@ -167,7 +167,7 @@ class LinEncoderTest extends Specification{ def encoded = encoder.encode(wfResults) def object = encoder.decode(encoded) then: - encoded == '{"version":"lineage/v1beta1","kind":"WorkflowOutput","createdAt":null,"workflowRun":"lid://1234","output":null}' + encoded == '{"version":"lineage/v1beta1","kind":"WorkflowOutput","spec":{"createdAt":null,"workflowRun":"lid://1234","output":null}}' def result = object as WorkflowOutput result.createdAt == null diff --git a/modules/nf-lineage/src/test/nextflow/lineage/serde/LinTypeAdapterFactoryTest.groovy b/modules/nf-lineage/src/test/nextflow/lineage/serde/LinTypeAdapterFactoryTest.groovy new file mode 100644 index 0000000000..2e17255495 --- /dev/null +++ b/modules/nf-lineage/src/test/nextflow/lineage/serde/LinTypeAdapterFactoryTest.groovy @@ -0,0 +1,181 @@ +/* + * Copyright 2013-2025, Seqera Labs + * + * Licensed 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 nextflow.lineage.serde + + +import nextflow.lineage.model.v1beta1.FileOutput +import nextflow.lineage.model.v1beta1.LinModel +import nextflow.lineage.model.v1beta1.TaskRun +import spock.lang.Specification + +class LinTypeAdapterFactoryTest extends Specification { + + def 'should read both new and old JSON formats'() { + given: + def encoder = new LinEncoder() + + when: 'create old format JSON (without spec wrapper)' + def oldFormatJson = """ + { + "version": "${LinModel.VERSION}", + "kind": "FileOutput", + "path": "/path/to/file", + "checksum": { + "value": "hash_value", + "algorithm": "hash_algorithm", + "mode": "standard" + }, + "source": "lid://source", + "workflow": "lid://workflow", + "task": "lid://task", + "size": 1234 + } + """ + + and: 'deserialize old format' + def oldResult = encoder.decode(oldFormatJson) + + then: 'should work correctly with backward compatibility' + oldResult instanceof FileOutput + oldResult.path == "/path/to/file" + oldResult.checksum.value == "hash_value" + oldResult.source == "lid://source" + oldResult.size == 1234 + + when: 'create new format JSON (with spec wrapper)' + def newFormatJson = """ + { + "version": "${LinModel.VERSION}", + "kind": "FileOutput", + "spec": { + "path": "/path/to/file", + "checksum": { + "value": "hash_value", + "algorithm": "hash_algorithm", + "mode": "standard" + }, + "source": "lid://source", + "workflow": "lid://workflow", + "task": "lid://task", + "size": 1234 + } + } + """ + + and: 'deserialize new format' + def newResult = encoder.decode(newFormatJson) + + then: 'should work correctly' + newResult instanceof FileOutput + newResult.path == "/path/to/file" + newResult.checksum.value == "hash_value" + newResult.source == "lid://source" + newResult.size == 1234 + } + + def 'should handle TaskRun old format'() { + given: + def encoder = new LinEncoder() + + when: 'create old format TaskRun JSON' + def oldFormatJson = """ + { + "version": "${LinModel.VERSION}", + "kind": "TaskRun", + "sessionId": "session123", + "name": "testTask", + "codeChecksum": { + "value": "hash123", + "algorithm": "nextflow", + "mode": "standard" + }, + "script": "echo hello", + "input": [], + "container": "ubuntu:latest", + "conda": null, + "spack": null, + "architecture": "amd64", + "globalVars": {}, + "binEntries": [] + } + """ + + and: 'deserialize old format' + def result = encoder.decode(oldFormatJson) + + then: 'should work correctly' + result instanceof TaskRun + result.sessionId == "session123" + result.name == "testTask" + result.codeChecksum.value == "hash123" + result.script == "echo hello" + result.container == "ubuntu:latest" + result.architecture == "amd64" + } + + def 'should reject JSON without version field'() { + given: + def encoder = new LinEncoder() + + when: 'try to deserialize JSON without version' + def invalidJson = """ + { + "kind": "FileOutput", + "path": "/path/to/file" + } + """ + encoder.decode(invalidJson) + + then: 'should throw exception' + thrown(Exception) + } + + def 'should reject JSON with wrong version'() { + given: + def encoder = new LinEncoder() + + when: 'try to deserialize JSON with wrong version' + def invalidJson = """ + { + "version": "wrong/version", + "kind": "FileOutput", + "path": "/path/to/file" + } + """ + encoder.decode(invalidJson) + + then: 'should throw exception' + thrown(Exception) + } + + def 'should reject JSON without kind field'() { + given: + def encoder = new LinEncoder() + + when: 'try to deserialize JSON without kind' + def invalidJson = """ + { + "version": "${LinModel.VERSION}", + "path": "/path/to/file" + } + """ + encoder.decode(invalidJson) + + then: 'should throw exception' + thrown(Exception) + } +}