Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions modules/nextflow/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ dependencies {
api 'org.apache.commons:commons-compress:1.27.1' // For tar.gz extraction
api 'io.seqera:npr-api:0.22.0'
api 'io.seqera:npr-client:0.22.0'
api 'com.networknt:json-schema-validator:1.5.6'

testImplementation 'org.subethamail:subethasmtp:3.1.7'
testImplementation (project(':nf-lineage'))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import groovy.util.logging.Slf4j
import nextflow.cli.CmdBase
import nextflow.exception.AbortOperationException
import nextflow.module.ModuleReference
import nextflow.module.ModuleSchemaValidator
import nextflow.module.ModuleStorage
import nextflow.module.ModuleValidator
import nextflow.util.TestOnly
Expand All @@ -43,6 +44,9 @@ class CmdModuleValidate extends CmdBase {
@Parameter(description = "[namespace/name or path]", required = true)
List<String> args

@Parameter(names = '--schema', description = 'URL or local path of the JSON schema used to validate meta.yml')
String schema

@TestOnly
protected Path root

Expand All @@ -57,7 +61,8 @@ class CmdModuleValidate extends CmdBase {
throw new AbortOperationException("Incorrect number of arguments -- usage: nextflow module validate <namespace/name>")

final moduleDir = determineModuleDir(args[0])
final errors = ModuleValidator.validate(moduleDir)
final schemaLocation = schema ?: ModuleSchemaValidator.DEFAULT_SCHEMA_URL
final errors = ModuleValidator.validate(moduleDir, schemaLocation)

if( errors ) {
throw new AbortOperationException(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* Copyright 2013-2026, 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.module

import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths

import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.networknt.schema.JsonSchema
import com.networknt.schema.JsonSchemaFactory
import com.networknt.schema.SpecVersion
import com.networknt.schema.ValidationMessage
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import nextflow.exception.AbortOperationException
import org.yaml.snakeyaml.Yaml

/**
* Validates a module spec (meta.yml) against the Nextflow module JSON schema.
*
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
*/
@Slf4j
@CompileStatic
class ModuleSchemaValidator {

static final String DEFAULT_SCHEMA_URL =
'https://raw.githubusercontent.com/nextflow-io/schemas/refs/heads/main/module/v1/schema.json'

private static final ObjectMapper JSON_MAPPER = new ObjectMapper()

/**
* Validate a meta.yml file against the JSON schema located at the given
* URL or local file path.
*
* @param metaYaml Path to the meta.yml file to validate
* @param schemaLocation URL (http/https), file: URI, or local file path of the schema
* @return List of validation error messages, empty if the spec is valid
*/
static List<String> validate(Path metaYaml, String schemaLocation) {
final schemaText = loadSchema(schemaLocation)
final factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012)
Comment thread
pditommaso marked this conversation as resolved.
Outdated
final JsonSchema schema
try {
schema = factory.getSchema(schemaText)
}
catch( Exception e ) {
throw new AbortOperationException("Invalid module schema at '${schemaLocation}': ${e.message}", e)
}

Object yamlData
try( final stream = Files.newInputStream(metaYaml) ) {
yamlData = new Yaml().load(stream)
}
catch( Exception e ) {
throw new AbortOperationException("Failed to read module spec '${metaYaml}': ${e.message}", e)
}

final JsonNode node = JSON_MAPPER.valueToTree(yamlData)
final Set<ValidationMessage> messages = schema.validate(node)
return messages.collect { it.message }.toList()
}

static List<String> validate(Path metaYaml) {
return validate(metaYaml, DEFAULT_SCHEMA_URL)
}

/**
* Load the JSON schema text from a remote URL, file: URI, or local file path.
* Hard-fails with AbortOperationException on any I/O error.
*/
private static String loadSchema(String location) {
try {
if( location.startsWith('http://') || location.startsWith('https://') ) {
final url = new URL(location)
final conn = url.openConnection()
conn.setConnectTimeout(10_000)
conn.setReadTimeout(20_000)
try( final stream = conn.getInputStream() ) {
return new String(stream.readAllBytes(), 'UTF-8')
}
}
if( location.startsWith('file:') ) {
return Files.readString(Paths.get(URI.create(location)))
}
return Files.readString(Paths.get(location))
}
catch( Exception e ) {
throw new AbortOperationException(
"Failed to load module schema from '${location}': ${e.message}. " +
"Pass --schema <url-or-local-path> to override.", e)
}
}
}
16 changes: 4 additions & 12 deletions modules/nextflow/src/main/groovy/nextflow/module/ModuleSpec.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -71,22 +71,17 @@ class ModuleSpec {
Map<String, Object> _passthrough

/**
* Validate the module spec for required fields
* Validate Nextflow-specific module spec rules that are not expressed by
* the JSON schema (see ModuleSchemaValidator).
*
* @return List of validation errors (empty if valid)
*/
List<String> validate() {
final List<String> errors = []

if( !name )
errors << "Missing required field: name"

if( !version )
errors << "Missing required field: version"

if( !description )
errors << "Missing required field: description"

if( !license )
errors << "Missing required field: license"

Expand Down Expand Up @@ -123,11 +118,8 @@ class ModuleSpec {
return
}

if( !param.type || param.type == TODO_TYPE )
errors << "Missing type for ${name}${param.name ? " ($param.name)" : ''}".toString()

if( !param.description || param.description == TODO_DESCRIPTION )
errors << "Missing description for ${name}${param.name ? " ($param.name)" : ''}".toString()
if( param.description == TODO_DESCRIPTION )
errors << "Placeholder description for ${name}${param.name ? " ($param.name)" : ''}".toString()
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,23 @@ class ModuleValidator {
* An empty list means the module is valid.
*
* @param moduleDir
* @param schemaLocation URL or local path of the JSON schema used to validate meta.yml
*/
static List<String> validate(Path moduleDir) {
static List<String> validate(Path moduleDir, String schemaLocation) {
final errors = new ArrayList<String>()

// Level 1: validate module structure
errors.addAll(validateStructure(moduleDir))
if( errors )
return errors // can't proceed without required files

// Level 2: validate module spec (meta.yml)
// Level 2a: validate module spec (meta.yml) against the JSON schema
final manifestPath = moduleDir.resolve(ModuleStorage.MODULE_MANIFEST_FILE)
errors.addAll(ModuleSchemaValidator.validate(manifestPath, schemaLocation))
if( errors )
return errors

// Level 2b: validate Nextflow-specific rules not expressed by the schema
final spec = ModuleSpecFactory.fromYaml(manifestPath)
errors.addAll(spec.validate())
if( errors )
Expand All @@ -61,6 +67,10 @@ class ModuleValidator {
return errors
}

static List<String> validate(Path moduleDir) {
return validate(moduleDir, ModuleSchemaValidator.DEFAULT_SCHEMA_URL)
}

/**
* Check that required files exist.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,42 @@ class CmdModuleValidateTest extends Specification {
@TempDir
Path tempDir

private static final String SCHEMA_JSON = '''\
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": { "type": "string" },
"version": { "type": "string" },
"description": { "type": "string" },
"license": { "type": "string" },
"input": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"type": {
"type": "string",
"enum": ["boolean", "float", "integer", "string", "list", "map", "file", "directory"]
},
"description": { "type": "string" }
},
"required": ["type", "description"]
}
}
},
"required": ["name", "description"]
}
'''.stripIndent()

private Path schemaPath() {
final p = tempDir.resolve('schema.json')
if( !Files.exists(p) )
Files.writeString(p, SCHEMA_JSON)
return p
}

private Path createValidModule(String namespace='myorg', String name='hello') {
def moduleDir = tempDir.resolve("modules/$namespace/$name")
Files.createDirectories(moduleDir)
Expand Down Expand Up @@ -73,7 +109,7 @@ class CmdModuleValidateTest extends Specification {
def moduleDir = createValidModule()

when:
def errors = ModuleValidator.validate(moduleDir)
def errors = ModuleValidator.validate(moduleDir, schemaPath().toString())

then:
errors.isEmpty()
Expand All @@ -85,7 +121,7 @@ class CmdModuleValidateTest extends Specification {
Files.delete(moduleDir.resolve('main.nf'))

when:
def errors = ModuleValidator.validate(moduleDir)
def errors = ModuleValidator.validate(moduleDir, schemaPath().toString())

then:
errors.any { it.contains('main.nf') }
Expand All @@ -97,7 +133,7 @@ class CmdModuleValidateTest extends Specification {
Files.delete(moduleDir.resolve('meta.yml'))

when:
def errors = ModuleValidator.validate(moduleDir)
def errors = ModuleValidator.validate(moduleDir, schemaPath().toString())

then:
errors.any { it.contains('meta.yml') }
Expand All @@ -109,13 +145,13 @@ class CmdModuleValidateTest extends Specification {
Files.delete(moduleDir.resolve('README.md'))

when:
def errors = ModuleValidator.validate(moduleDir)
def errors = ModuleValidator.validate(moduleDir, schemaPath().toString())

then:
errors.any { it.contains('README.md') }
}

def 'should fail when meta.yml has missing required fields'() {
def 'should fail when meta.yml is missing schema-required fields'() {
given:
def moduleDir = createValidModule()
moduleDir.resolve('meta.yml').text = '''\
Expand All @@ -124,10 +160,31 @@ class CmdModuleValidateTest extends Specification {
'''.stripIndent()

when:
def errors = ModuleValidator.validate(moduleDir)
def errors = ModuleValidator.validate(moduleDir, schemaPath().toString())

then:
// schema-level validation runs first; reports missing required `description`
errors.any { it.contains('description') }
}

def 'should fail when meta.yml is missing nextflow-only fields'() {
given:
def moduleDir = createValidModule()
moduleDir.resolve('meta.yml').text = '''\
name: myorg/hello
description: A test module
input:
- name: greeting
type: string
description: A greeting string
'''.stripIndent()

when:
def errors = ModuleValidator.validate(moduleDir, schemaPath().toString())

then:
// schema passes, then ModuleSpec.validate() reports missing version + license
errors.any { it.contains('version') }
errors.any { it.contains('license') }
}

Expand All @@ -142,7 +199,7 @@ class CmdModuleValidateTest extends Specification {
'''.stripIndent()

when:
def errors = ModuleValidator.validate(moduleDir)
def errors = ModuleValidator.validate(moduleDir, schemaPath().toString())

then:
errors.any { it.contains('version') }
Expand Down
Loading
Loading