/*
* Copyright 2016 Christoph Böhme
*
* 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.culturegraph.mf.test.validators;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.function.Consumer;
import org.culturegraph.mf.framework.StreamReceiver;
import org.culturegraph.mf.javaintegration.EventList;
import org.culturegraph.mf.javaintegration.EventList.Event;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Validates a stream of events using a list of expected stream events.
* If the stream is invalid, the error handler set via
* {@link #setErrorHandler(Consumer)} is called.
* <p>
* This module also ensures that the received event stream is well-formed.
*
* @see EventList
*
* @author Christoph Böhme
*
*/
public final class StreamValidator implements StreamReceiver {
public static final Consumer<String> DEFAULT_ERROR_HANDLER =
msg -> { /* do nothing */ };
private static final Logger LOG =
LoggerFactory.getLogger(StreamValidator.class);
private static final String CANNOT_CHANGE_OPTIONS =
"Cannot change options during validation";
private static final String VALIDATION_FAILED =
"Validation failed. Please reset the validator";
private static final String NO_RECORD_FOUND = "No record found: ";
private static final String NO_ENTITY_FOUND = "No entity found: ";
private static final String NO_LITERAL_FOUND = "No literal found: ";
private static final String UNCONSUMED_RECORDS_FOUND =
"Unconsumed records found";
private final Deque<List<EventNode>> stack = new ArrayDeque<>();
private final EventNode eventStream;
private boolean validating;
private boolean validationFailed;
private Consumer<String> errorHandler = DEFAULT_ERROR_HANDLER;
private boolean strictRecordOrder;
private boolean strictKeyOrder;
private boolean strictValueOrder;
private final WellformednessChecker wellformednessChecker =
new WellformednessChecker();
public StreamValidator(final List<Event> expectedStream) {
this.eventStream = new EventNode(null, null);
foldEventStream(this.eventStream, expectedStream.iterator());
wellformednessChecker.setErrorHandler(errorHandler);
resetStream();
}
/**
* Sets the error handler which is called when a invalid event stream is
* received.
* <p>
* The handler is called with a message describing the error.
*
* @param errorHandler a method which receives an error message
*/
public void setErrorHandler(final Consumer<String> errorHandler) {
this.errorHandler = errorHandler;
wellformednessChecker.setErrorHandler(errorHandler);
}
public Consumer<String> getErrorHandler() {
return errorHandler;
}
public boolean isStrictRecordOrder() {
return strictRecordOrder;
}
public void setStrictRecordOrder(final boolean strictRecordOrder) {
if (validating) {
throw new IllegalStateException(CANNOT_CHANGE_OPTIONS);
}
this.strictRecordOrder = strictRecordOrder;
}
public boolean isStrictKeyOrder() {
return strictKeyOrder;
}
public void setStrictKeyOrder(final boolean strictKeyOrder) {
if (validating) {
throw new IllegalStateException(CANNOT_CHANGE_OPTIONS);
}
this.strictKeyOrder = strictKeyOrder;
}
public boolean isStrictValueOrder() {
return strictValueOrder;
}
public void setStrictValueOrder(final boolean strictValueOrder) {
if (validating) {
throw new IllegalStateException(CANNOT_CHANGE_OPTIONS);
}
this.strictValueOrder = strictValueOrder;
}
@Override
public void startRecord(final String identifier) {
if (validationFailed) {
errorHandler.accept(VALIDATION_FAILED);
return;
}
wellformednessChecker.startRecord(identifier);
validating = true;
if (!openGroups(Event.Type.START_RECORD, identifier, strictRecordOrder, false)) {
validationFailed = true;
logEventStream();
errorHandler.accept(NO_RECORD_FOUND + identifier);
}
}
@Override
public void endRecord() {
if (validationFailed) {
errorHandler.accept(VALIDATION_FAILED);
return;
}
wellformednessChecker.endRecord();
if (!closeGroups()) {
validationFailed = true;
logEventStream();
errorHandler.accept(NO_RECORD_FOUND +
"No record matched the sequence of stream events");
}
}
@Override
public void startEntity(final String name) {
if (validationFailed) {
errorHandler.accept(VALIDATION_FAILED);
return;
}
wellformednessChecker.startEntity(name);
if (!openGroups(Event.Type.START_ENTITY, name, strictKeyOrder, strictValueOrder)) {
validationFailed = true;
logEventStream();
errorHandler.accept(NO_ENTITY_FOUND + name);
}
}
@Override
public void endEntity() {
if (validationFailed) {
errorHandler.accept(VALIDATION_FAILED);
}
wellformednessChecker.endEntity();
if (!closeGroups()) {
validationFailed = true;
logEventStream();
errorHandler.accept(NO_ENTITY_FOUND +
"No entity matched the sequence of stream events");
}
}
@Override
public void literal(final String name, final String value) {
if (validationFailed) {
errorHandler.accept(VALIDATION_FAILED);
return;
}
wellformednessChecker.literal(name, value);
final List<EventNode> stackFrame = stack.peek();
final Iterator<EventNode> iter = stackFrame.iterator();
while (iter.hasNext()) {
final EventNode eventNode = iter.next();
if (!consumeLiteral(eventNode, name, value)) {
resetGroup(eventNode);
iter.remove();
}
}
if (stackFrame.isEmpty()) {
validationFailed = true;
logEventStream();
errorHandler.accept(NO_LITERAL_FOUND + name + "=" + value);
}
}
@Override
public void resetStream() {
wellformednessChecker.resetStream();
validating = false;
validationFailed = false;
stack.clear();
stack.push(new LinkedList<>());
stack.peek().add(eventStream);
}
@Override
public void closeStream() {
if (validationFailed) {
errorHandler.accept(VALIDATION_FAILED);
return;
}
wellformednessChecker.closeStream();
validating = false;
stack.pop();
if (isGroupConsumed(eventStream)) {
eventStream.setConsumed(true);
} else {
validationFailed = true;
logEventStream();
errorHandler.accept(UNCONSUMED_RECORDS_FOUND);
}
}
private void foldEventStream(final EventNode parent,
final Iterator<Event> eventStream) {
while (eventStream.hasNext()) {
final Event event = eventStream.next();
if (event.getType() == Event.Type.LITERAL) {
parent.getChildren().add(new EventNode(event, parent));
} else if (event.getType() == Event.Type.START_RECORD
|| event.getType() == Event.Type.START_ENTITY) {
final EventNode newNode = new EventNode(event, parent);
parent.getChildren().add(newNode);
foldEventStream(newNode, eventStream);
} else if (event.getType() == Event.Type.END_RECORD
|| event.getType() == Event.Type.END_ENTITY) {
return;
}
}
}
private boolean openGroups(final Event.Type type, final String name,
final boolean strictKeyOrder, final boolean strictValueOrder) {
final List<EventNode> stackFrame = stack.peek();
stack.push(new LinkedList<>());
final Iterator<EventNode> iter = stackFrame.iterator();
while (iter.hasNext()) {
final EventNode eventNode = iter.next();
if (!consumeGroups(eventNode, type, name, strictKeyOrder, strictValueOrder)) {
resetGroup(eventNode);
iter.remove();
}
}
return !stackFrame.isEmpty();
}
private boolean closeGroups() {
EventNode lastMatchParent = null;
for (final EventNode eventNode : stack.pop()) {
if (eventNode.getParent() != lastMatchParent
&& isGroupConsumed(eventNode)) {
eventNode.setConsumed(true);
lastMatchParent = eventNode.getParent();
} else {
resetGroup(eventNode);
}
}
return lastMatchParent != null;
}
private boolean consumeGroups(final EventNode group, final Event.Type type,
final String name, final boolean strictKeyOrder,
final boolean strictValueOrder) {
boolean foundMatch = false;
for (final EventNode c : group.getChildren()) {
if (!c.isConsumed()) {
final Event event = c.getEvent();
if (compare(name, event.getName())) {
if (event.getType() == type) {
stack.peek().add(c);
foundMatch = true;
} else if (strictValueOrder) {
break;
}
}
if (strictKeyOrder) {
break;
}
}
}
return foundMatch;
}
private boolean consumeLiteral(final EventNode group, final String name,
final String value) {
boolean foundMatch = false;
for (final EventNode eventNode : group.getChildren()) {
if (!eventNode.isConsumed()) {
final Event event = eventNode.getEvent();
if (compare(name, event.getName())) {
if (event.getType() == Event.Type.LITERAL
&& compare(value, event.getValue())) {
eventNode.setConsumed(true);
foundMatch = true;
break;
} else if (strictValueOrder) {
break;
}
} else if (strictKeyOrder) {
break;
}
}
}
return foundMatch;
}
private boolean isGroupConsumed(final EventNode group) {
boolean consumed = true;
for (final EventNode c : group.getChildren()) {
consumed = consumed && c.isConsumed();
}
return consumed;
}
private void resetGroup(final EventNode group) {
if (group.getChildren() != null) {
for (final EventNode c : group.getChildren()) {
resetGroup(c);
c.setConsumed(false);
}
}
}
private boolean compare(final String str1, final String str2) {
if (str1 == null) {
return str2 == null;
}
return str1.equals(str2);
}
private void logEventStream() {
if (LOG.isInfoEnabled()) {
LOG.info("Event Stream: " + eventStream.toString());
}
}
/**
* Internal representation of stream events
*/
private static final class EventNode {
private static final String SEPARATOR = ", ";
private static final String CONSUMED_INDICATOR = "<OK>";
private final Event event;
private final EventNode parent;
private final List<EventNode> children;
private boolean consumed;
EventNode(final Event event, final EventNode parent) {
this.event = event;
this.parent = parent;
// The null-event is used to indicate the stream-start:
if (this.event == null || this.event.getType() == Event.Type.START_RECORD
|| this.event.getType() == Event.Type.START_ENTITY) {
children = new LinkedList<>();
} else {
children = null;
}
consumed = false;
}
Event getEvent() {
return event;
}
EventNode getParent() {
return parent;
}
List<EventNode> getChildren() {
return children;
}
boolean isConsumed() {
return consumed;
}
void setConsumed(final boolean consumed) {
this.consumed = consumed;
}
@Override
public String toString() {
final StringBuilder builder = new StringBuilder();
final String consumedIndicator;
if (consumed) {
consumedIndicator = CONSUMED_INDICATOR;
} else {
consumedIndicator = "";
}
if (event == null) {
appendChildren(builder);
} else {
switch (event.getType()) {
case START_RECORD:
builder.append(event.getName()).append(consumedIndicator).append("{");
appendChildren(builder);
builder.append("}");
break;
case START_ENTITY:
builder.append(event.getName()).append(consumedIndicator).append("[");
appendChildren(builder);
builder.append("]");
break;
case LITERAL:
builder.append(event.getName()).append("=").append(event.getValue())
.append(consumedIndicator);
break;
default:
break;
}
}
return builder.toString();
}
private void appendChildren(final StringBuilder builder) {
String sep = "";
for (final EventNode e : children) {
builder.append(sep);
builder.append(e.toString());
sep = SEPARATOR;
}
}
}
}