Skip to content

Commit

Permalink
Introduce extensible validation mechanism
Browse files Browse the repository at this point in the history
This also introduces a built-in validator which makes sure the
spec content comply to the schema and more.
  • Loading branch information
fbiville committed Feb 28, 2024
1 parent 4024442 commit 49735ae
Show file tree
Hide file tree
Showing 14 changed files with 1,487 additions and 227 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,99 @@
*/
package org.neo4j.importer.v1;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion.VersionFlag;
import java.io.IOException;
import java.io.Reader;
import java.util.Iterator;
import java.util.ServiceLoader;
import org.neo4j.importer.v1.validation.InvalidSpecificationException;
import org.neo4j.importer.v1.validation.SpecificationException;
import org.neo4j.importer.v1.validation.SpecificationValidationResult;
import org.neo4j.importer.v1.validation.SpecificationValidationResult.Builder;
import org.neo4j.importer.v1.validation.SpecificationValidator;
import org.neo4j.importer.v1.validation.UndeserializableSpecificationException;
import org.neo4j.importer.v1.validation.UnparseableSpecificationException;

public class ImportSpecificationDeserializer {

private static final JsonMapper MAPPER = JsonMapper.builder()
.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS)
.build();

public static ImportSpecification deserialize(Reader spec) {
private static final JsonSchema SCHEMA = JsonSchemaFactory.getInstance(VersionFlag.V202012)
.getSchema(ImportSpecificationDeserializer.class.getResourceAsStream("/spec.v1.0.json"));

/**
* Returns an instance of {@link ImportSpecification} based on the provided {@link Reader} content.
* The result is guaranteed to be consistent with the specification JSON schema.
* <br/>
* If implementations of the {@link SpecificationValidator} SPI are provided, they will also run against the
* {@link ImportSpecification} instance before the latter is returned.
* <br/>
* If the parsing, deserialization or validation (standard or via SPI implementations) fail, a {@link SpecificationException}
* is going to be thrown.
*
* @return an {@link ImportSpecification}
* @throws SpecificationException if parsing, deserialization or validation fail
*/
public static ImportSpecification deserialize(Reader spec) throws SpecificationException {
JsonNode json = parse(spec);
// TODO: pre-processing
validate(SCHEMA, json);
ImportSpecification result = deserialize(json);
runExtraValidations(result);
return result;
}

private static void validate(JsonSchema schema, JsonNode json) throws InvalidSpecificationException {
Builder builder = SpecificationValidationResult.builder();
schema.validate(json)
.forEach(msg -> builder.addError(
msg.getInstanceLocation().toString(),
String.format("SCHM-%s", msg.getCode()),
msg.getMessage()));
SpecificationValidationResult result = builder.build();
if (!result.passes()) {
throw new InvalidSpecificationException(result);
}
}

private static ImportSpecification deserialize(JsonNode json) throws SpecificationException {
try {
return MAPPER.treeToValue(json, ImportSpecification.class);
} catch (JsonProcessingException e) {
throw new UndeserializableSpecificationException(
"The payload cannot be deserialized, despite a successful schema validation.\n"
+ "This is likely a bug, please open an issue in "
+ "https://github.com/neo4j/import-spec/issues/new and share the specification that caused the issue",
e);
}
}

private static void runExtraValidations(ImportSpecification spec) throws SpecificationException {
Iterator<SpecificationValidator> validators =
ServiceLoader.load(SpecificationValidator.class).iterator();
Builder builder = SpecificationValidationResult.builder();
while (validators.hasNext()) {
SpecificationValidator validator = validators.next();
builder.merge(validator.validate(spec));
}
SpecificationValidationResult result = builder.build();
if (!result.passes()) {
throw new InvalidSpecificationException(result);
}
}

private static JsonNode parse(Reader spec) throws SpecificationException {
try {
return MAPPER.readValue(spec, ImportSpecification.class);
return MAPPER.readTree(spec);
} catch (IOException e) {
throw new RuntimeException(String.format("Could not deserialize spec %s", spec), e);
throw new UnparseableSpecificationException(e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright (c) "Neo4j"
* Neo4j Sweden AB [http://neo4j.com]
*
* 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 org.neo4j.importer.v1.validation;

import java.util.Set;

public class InvalidSpecificationException extends SpecificationException {
public InvalidSpecificationException(SpecificationValidationResult result) {
super(String.format("Import specification is invalid, see report below:\n%s", validationReport(result)));
}

// TODO: wrap long error messages
private static String validationReport(SpecificationValidationResult result) {
Set<SpecificationError> errors = result.getErrors();
Set<SpecificationWarning> warnings = result.getWarnings();
return String.format(
"===============================================================================\n" + "Summary\n"
+ "===============================================================================\n"
+ "\t- %d error(s)\n"
+ "\t- %d warning(s)\n\n"
+ "%s"
+ "%s"
+ "===============================================================================",
errors.size(), warnings.size(), errorReport(errors), warningReport(warnings));
}

private static String errorReport(Set<SpecificationError> errors) {
if (errors.isEmpty()) {
return "";
}
StringBuilder builder = new StringBuilder();
builder.append("=== Errors ===\n");
errors.forEach((error) -> {
builder.append(String.format("\t- [%s] %s\n", error.getCode(), error.getMessage()));
});
return builder.toString();
}

private static String warningReport(Set<SpecificationWarning> warnings) {
if (warnings.isEmpty()) {
return "";
}
StringBuilder builder = new StringBuilder();
builder.append("\n=== Warnings ===\n");
warnings.forEach((warning) -> {
builder.append(String.format("\t- [%s] %s\n", warning.getCode(), warning.getMessage()));
});
return builder.toString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright (c) "Neo4j"
* Neo4j Sweden AB [http://neo4j.com]
*
* 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 org.neo4j.importer.v1.validation;

import java.util.Objects;

public final class SpecificationError {

private final String elementPath;
private final String code;
private final String message;

public SpecificationError(String elementPath, String code, String message) {
this.elementPath = elementPath;
this.code = code;
this.message = message;
}

public String getElementPath() {
return elementPath;
}

public String getCode() {
return code;
}

public String getMessage() {
return message;
}

@Override
public boolean equals(Object object) {
if (this == object) return true;
if (object == null || getClass() != object.getClass()) return false;
SpecificationError that = (SpecificationError) object;
return Objects.equals(elementPath, that.elementPath)
&& Objects.equals(code, that.code)
&& Objects.equals(message, that.message);
}

@Override
public int hashCode() {
return Objects.hash(elementPath, code, message);
}

@Override
public String toString() {
return "SpecificationError{" + "elementPath='"
+ elementPath + '\'' + ", code='"
+ code + '\'' + ", message='"
+ message + '\'' + '}';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright (c) "Neo4j"
* Neo4j Sweden AB [http://neo4j.com]
*
* 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 org.neo4j.importer.v1.validation;

public abstract class SpecificationException extends Exception {

public SpecificationException(String message) {
super(message);
}

public SpecificationException(String message, Throwable cause) {
super(message, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Copyright (c) "Neo4j"
* Neo4j Sweden AB [http://neo4j.com]
*
* 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 org.neo4j.importer.v1.validation;

import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;

public class SpecificationValidationResult {

private final Set<SpecificationError> errors;
private final Set<SpecificationWarning> warnings;

// visible for testing
SpecificationValidationResult(Set<SpecificationError> errors, Set<SpecificationWarning> warnings) {
this.errors = errors;
this.warnings = warnings;
}

public static Builder builder() {
return new Builder();
}

public boolean passes() {
return errors.isEmpty();
}

public Set<SpecificationError> getErrors() {
return errors;
}

public Set<SpecificationWarning> getWarnings() {
return warnings;
}

@Override
public boolean equals(Object object) {
if (this == object) return true;
if (object == null || getClass() != object.getClass()) return false;
SpecificationValidationResult that = (SpecificationValidationResult) object;
return Objects.equals(errors, that.errors) && Objects.equals(warnings, that.warnings);
}

@Override
public int hashCode() {
return Objects.hash(errors, warnings);
}

public static final class Builder {

private final Set<SpecificationError> errors = new LinkedHashSet<>();
private final Set<SpecificationWarning> warnings = new LinkedHashSet<>();

public Builder addError(String elementPath, String code, String message) {
return addError(new SpecificationError(elementPath, code, message));
}

public Builder addWarning(String elementPath, String code, String message) {
return addWarning(new SpecificationWarning(elementPath, code, message));
}

public Builder merge(SpecificationValidationResult other) {
errors.addAll(other.errors);
warnings.addAll(other.warnings);
return this;
}

public SpecificationValidationResult build() {
return new SpecificationValidationResult(errors, warnings);
}

private Builder addError(SpecificationError error) {
errors.add(error);
return this;
}

private Builder addWarning(SpecificationWarning warning) {
warnings.add(warning);
return this;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright (c) "Neo4j"
* Neo4j Sweden AB [http://neo4j.com]
*
* 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 org.neo4j.importer.v1.validation;

import org.neo4j.importer.v1.ImportSpecification;

public interface SpecificationValidator {

SpecificationValidationResult validate(ImportSpecification specification);
}
Loading

0 comments on commit 49735ae

Please sign in to comment.