diff --git a/README.md b/README.md index 6df054a..67874f2 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,11 @@ This fork is based on https://github.com/bfriebel/requirements-interchange-forma # Supported Formats ReqIF file extensions .reqif and .reqifz (compressed). +Elements are matched by their local name, so it does not matter whether a +document puts the ReqIF elements into the default namespace +(``) or into a prefixed one (``). +The same holds for the embedded XHTML (`xhtml:div`, `reqif-xhtml:div`, ...). + # Build & Test The project builds with Maven (Java 17+): @@ -232,5 +237,62 @@ values too. The content category of generated spec objects is derived by the same `TypeClassifier` used when reading (`typeClassifier(...)` to override). -Not covered yet: writing .reqifz archives and ReqIF elements the parser -does not model (alternative ids, relation groups, tool extensions). +## Writing .reqifz archives + +Images travel with the document, stored under the path the XHTML +`object` elements reference: + +```java +Map pictures = Map.of("files/diagram.png", pngBytes); +new ReqIFzWriter().write(document, "spec.reqif", pictures, Path.of("spec.reqifz")); +``` + +An archive that was read can be re-packed - the documents are serialized +from the model (so modifications are included) while the images are +copied from the extracted files, without consuming the one-shot streams: + +```java +try (ReqIFz source = new ReqIFz("in.reqifz")) { + new ReqIFzWriter().write(source, Path.of("out.reqifz")); +} +``` + +# Validation + +`ReqIFValidator` checks a document before it is written: identifiers +present and globally unique, resolvable datatype and spec type +references, attribute values matching their spec type, enum value +references, relation endpoints, spec hierarchy targets and relation +group references. + +```java +new ReqIFValidator().validate(document).throwIfInvalid(); +new ReqIFWriter().write(document, Path.of("out.reqif")); +``` + +This is a model-level check. The OMG ReqIF XSD is **not bundled** with +this library for licensing reasons; if you have it, run a full XML +Schema validation on top: + +```java +ValidationResult schemaIssues = + new ReqIFValidator().validateAgainstSchema(document, Path.of("reqif.xsd")); +``` + +Not covered yet: identifiers duplicated within one category (the parser +keys its maps by identifier, so a duplicate has already replaced its +predecessor by the time the model exists). + +# Unmodelled ReqIF kinds + +The parser models the datatype and spec type kinds of the standard. Kinds +it does not know - vendor extensions or later ReqIF revisions - are kept +generically rather than dropped: the original element name is remembered +and written back unchanged, and values are carried as their raw +`THE-VALUE`. This applies to datatype definitions, attribute definitions, +attribute values and spec types, so a document round-trips without losing +or altering them. + +Limitation: a value of an unknown kind is only preserved when it is +carried in a `THE-VALUE` attribute. Kinds that store their value in child +elements are not covered. diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java index 32c10c3..5bd2ba2 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeDefinition.java @@ -17,6 +17,8 @@ public class AttributeDefinition { private String name; private Datatype type; private String defaultValue; + private String alternativeID; + private String sourceElementName; @@ -36,6 +38,21 @@ public Datatype getDataType() { public String getDefaultValue() { return this.defaultValue; } + + /** + * @return the IDENTIFIER of the optional ALTERNATIVE-ID, or null + */ + public String getAlternativeID() { + return this.alternativeID; + } + + /** + * @return the ATTRIBUTE-DEFINITION-* element name this definition was read + * from, or null when it was not created from a document + */ + public String getSourceElementName() { + return this.sourceElementName; + } @@ -56,6 +73,8 @@ public AttributeDefinition(Node attributeDefinition, Map dataT this.id = attributeDefinition.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); this.name = attributeDefinition.getAttributes().getNamedItem(ReqIFConst.LONG_NAME).getTextContent(); + this.alternativeID = XmlUtils.alternativeID(attributeDefinition); + this.sourceElementName = XmlUtils.localName(attributeDefinition); // Navigate by element (not by fixed child index) so both pretty-printed // and minified ReqIF files are handled. diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValue.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValue.java index 2ff009d..5a30cf4 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValue.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValue.java @@ -6,6 +6,7 @@ public class AttributeValue { private String name; protected Object value; private AttributeDefinition type; + private String sourceElementName; @@ -25,6 +26,20 @@ public AttributeDefinition getAttributeDefinitionType() { public String getDatatype() { return this.type.getDataType().getType(); } + + /** + * @return the ATTRIBUTE-VALUE-* element name this value was read from, or + * null when it was not created from a document. Needed to write back + * values of datatype kinds the parser does not model explicitly. + */ + public String getSourceElementName() { + return this.sourceElementName; + } + + public AttributeValue setSourceElementName(String sourceElementName) { + this.sourceElementName = sourceElementName; + return this; + } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueXHTML.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueXHTML.java index 4759e48..85b5c93 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueXHTML.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/attributes/AttributeValueXHTML.java @@ -1,18 +1,12 @@ package de.uni_stuttgart.ils.reqif4j.attributes; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import javax.xml.parsers.DocumentBuilderFactory; -import javax.xml.parsers.ParserConfigurationException; - import org.w3c.dom.Node; -import org.xml.sax.SAXException; +import de.uni_stuttgart.ils.reqif4j.util.XhtmlParser; import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; import de.uni_stuttgart.ils.reqif4j.xhtml.XHTMLElement; import de.uni_stuttgart.ils.reqif4j.xhtml.XHTMLElementDiv; @@ -63,7 +57,6 @@ public class AttributeValueXHTML extends AttributeValue { private static final String T_LIST_END = "/L"; private static final String VARIABLE_NAME_MISSING = "VARIABLE_NAME_MISSING"; - private static final String XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml"; XHTMLElementDiv divValue; @@ -94,28 +87,11 @@ public AttributeValueXHTML(Node xhtmlContent, AttributeDefinition type) { public AttributeValueXHTML(String value, AttributeDefinition type) { super(value, type); - Node div = value == null || value.isBlank() ? null : parseDiv(value); + Node div = value == null || value.isBlank() ? null : XhtmlParser.parseDiv(value); this.divValue = div == null ? null : new XHTMLElementDiv(div); this.value = deconstructXHTML(this.divValue); } - private static Node parseDiv(String markup) { - - String trimmed = markup.trim(); - String document = trimmed.startsWith("" + trimmed + ""; - try { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - return factory.newDocumentBuilder() - .parse(new ByteArrayInputStream(document.getBytes(StandardCharsets.UTF_8))) - .getDocumentElement(); - } catch (SAXException | IOException | ParserConfigurationException e) { - throw new IllegalArgumentException("XHTML value is not well-formed: " + markup, e); - } - } - public XHTMLElementDiv getDivValue() { return this.divValue; } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java index a276faa..e820184 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/ReqIFBuilder.java @@ -65,6 +65,7 @@ public class ReqIFBuilder { private final List specRelations = new ArrayList(); private final List specifications = new ArrayList(); + private final Map identifiers = new LinkedHashMap(); private final HeaderBuilder headerBuilder = new HeaderBuilder(); private TypeClassifier typeClassifier = TypeClassifier.defaultClassifier(); @@ -92,6 +93,7 @@ public ReqIFBuilder header(Consumer header) { /** Adds a datatype the builder has no shorthand for. */ public ReqIFBuilder datatype(Datatype datatype) { + claim(datatype.getID(), "datatype"); this.datatypes.put(datatype.getID(), datatype); return this; } @@ -145,7 +147,8 @@ public ReqIFBuilder specRelationType(String id, String name, Consumer attributes) { - attributes.accept(new SpecTypeBuilder(specType, this.datatypes)); + claim(specType.getID(), "spec type"); + attributes.accept(new SpecTypeBuilder(specType, this.datatypes, this::claim)); this.specTypes.put(specType.getID(), specType); return this; } @@ -155,6 +158,7 @@ private ReqIFBuilder specType(SpecType specType, Consumer attri public ReqIFBuilder specObject(String id, String specTypeID, Consumer values) { + claim(id, "spec object"); SpecType specType = requireSpecType(specTypeID); ValuesBuilder builder = new ValuesBuilder(specType); values.accept(builder); @@ -173,6 +177,7 @@ public ReqIFBuilder specObject(String id, String specTypeID) { public ReqIFBuilder specRelation(String id, String specTypeID, String sourceID, String targetID, Consumer values) { + claim(id, "spec relation"); SpecType specType = requireSpecType(specTypeID); requireSpecObject(sourceID); requireSpecObject(targetID); @@ -194,6 +199,7 @@ public ReqIFBuilder specRelation(String id, String specTypeID, String sourceID, public ReqIFBuilder specification(String id, String name, String specTypeID, Consumer content) { + claim(id, "specification"); SpecType specType = requireSpecType(specTypeID); SpecificationBuilder builder = new SpecificationBuilder(specType); content.accept(builder); @@ -230,6 +236,22 @@ public ReqIFDocument build() { } + /** + * ReqIF identifiers must be unique across the whole document, so a clash is + * refused instead of silently replacing the earlier element. + */ + void claim(String id, String kind) { + + if (id == null || id.isBlank()) { + throw new ReqIFBuildException("A " + kind + " needs an identifier"); + } + String previousKind = this.identifiers.put(id, kind); + if (previousKind != null) { + throw new ReqIFBuildException("Identifier '" + id + "' is already used by a " + previousKind + + "; identifiers must be unique across the document"); + } + } + private SpecType requireSpecType(String specTypeID) { SpecType specType = this.specTypes.get(specTypeID); @@ -259,6 +281,11 @@ public EnumerationBuilder value(String id, String name, String key) { } public EnumerationBuilder value(String id, String name, String key, String otherContent) { + for (DatatypeEnumerationValue existing : this.values) { + if (existing.getID().equals(id)) { + throw new ReqIFBuildException("Enum value '" + id + "' is declared twice"); + } + } this.values.add(new DatatypeEnumerationValue(id, name, key, otherContent)); return this; } @@ -287,6 +314,7 @@ public SpecificationBuilder child(String hierarchyID, String specObjectID) { public SpecificationBuilder child(String hierarchyID, String specObjectID, Consumer children) { + claim(hierarchyID, "spec hierarchy"); HierarchyBuilder hierarchy = new HierarchyBuilder(hierarchyID, specObjectID); children.accept(hierarchy); this.hierarchies.add(hierarchy); @@ -323,6 +351,7 @@ public HierarchyBuilder child(String hierarchyID, String specObjectID) { public HierarchyBuilder child(String hierarchyID, String specObjectID, Consumer children) { + claim(hierarchyID, "spec hierarchy"); HierarchyBuilder hierarchy = new HierarchyBuilder(hierarchyID, specObjectID); children.accept(hierarchy); this.children.add(hierarchy); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/SpecTypeBuilder.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/SpecTypeBuilder.java index 68e111b..64f8e31 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/build/SpecTypeBuilder.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/build/SpecTypeBuilder.java @@ -18,10 +18,13 @@ public class SpecTypeBuilder { private final SpecType specType; private final Map datatypes; + private final java.util.function.BiConsumer claim; - SpecTypeBuilder(SpecType specType, Map datatypes) { + SpecTypeBuilder(SpecType specType, Map datatypes, + java.util.function.BiConsumer claim) { this.specType = specType; this.datatypes = datatypes; + this.claim = claim; } @@ -63,6 +66,7 @@ public SpecTypeBuilder enumerationAttribute(String id, String name, String datat List defaultValueRefs) { Datatype datatype = requireDatatype(datatypeID, ReqIFConst.ENUMERATION); + this.claim.accept(id, "attribute definition"); this.specType.addAttributeDefinition( new AttributeDefinitionEnumeration(id, name, datatype, multiValued, defaultValueRefs)); return this; @@ -76,6 +80,7 @@ public SpecTypeBuilder enumerationAttribute(String id, String name, String datat public SpecTypeBuilder attribute(String id, String name, String datatypeID, String defaultValue) { Datatype datatype = requireDatatype(datatypeID, null); + this.claim.accept(id, "attribute definition"); this.specType.addAttributeDefinition(new AttributeDefinition(id, name, datatype, defaultValue)); return this; } @@ -85,6 +90,7 @@ private SpecTypeBuilder attribute(String id, String name, String datatypeID, Str String defaultValue) { Datatype datatype = requireDatatype(datatypeID, expectedCategory); + this.claim.accept(id, "attribute definition"); this.specType.addAttributeDefinition(new AttributeDefinition(id, name, datatype, defaultValue)); return this; } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/Datatype.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/Datatype.java index ebc9b88..0af27de 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/Datatype.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/datatypes/Datatype.java @@ -7,6 +7,7 @@ public class Datatype { private String name; private String type; private String sourceElementName; + private String alternativeID; @@ -32,6 +33,18 @@ public String getSourceElementName() { return this.sourceElementName; } + /** + * @return the IDENTIFIER of the optional ALTERNATIVE-ID, or null + */ + public String getAlternativeID() { + return this.alternativeID; + } + + public Datatype setAlternativeID(String alternativeID) { + this.alternativeID = alternativeID; + return this; + } + diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFConst.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFConst.java index f9cc869..ed585db 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFConst.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFConst.java @@ -25,6 +25,16 @@ public class ReqIFConst { public final static String DEFINITION = "DEFINITION"; public final static String SPEC_TYPES = "SPEC-TYPES"; public final static String SPEC_ATTRIBUTES = "SPEC-ATTRIBUTES"; + public final static String ALTERNATIVE_ID = "ALTERNATIVE-ID"; + public final static String TOOL_EXTENSIONS = "TOOL-EXTENSIONS"; + public final static String SPEC_RELATION_GROUPS = "SPEC-RELATION-GROUPS"; + public final static String RELATION_GROUP = "RELATION-GROUP"; + public final static String RELATION_GROUP_TYPE = "RELATION-GROUP-TYPE"; + public final static String RELATION_GROUP_TYPE_REF = "RELATION-GROUP-TYPE-REF"; + public final static String SOURCE_SPECIFICATION = "SOURCE-SPECIFICATION"; + public final static String TARGET_SPECIFICATION = "TARGET-SPECIFICATION"; + public final static String SPECIFICATION_REF = "SPECIFICATION-REF"; + public final static String SPEC_RELATION_REF = "SPEC-RELATION-REF"; public final static String SPEC_OBJECTS = "SPEC-OBJECTS"; public final static String SPEC_OBJECT = "SPEC-OBJECT"; public final static String SPEC_RELATIONS = "SPEC-RELATIONS"; diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java index 8e9883f..f8ce6bb 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFCoreContent.java @@ -20,6 +20,7 @@ public class ReqIFCoreContent { private Map specObjects = new LinkedHashMap(); private Map specRelation = new LinkedHashMap<>(); private Map specifications = new LinkedHashMap(); + private Map relationGroups = new LinkedHashMap(); public Map getDatatypes() { @@ -62,6 +63,19 @@ public Specification getSpecification(String id) { return this.specifications.get(id); } + public Map getRelationGroups() { + return this.relationGroups; + } + + public RelationGroup getRelationGroup(String id) { + return this.relationGroups.get(id); + } + + public ReqIFCoreContent addRelationGroup(RelationGroup relationGroup) { + this.relationGroups.put(relationGroup.getID(), relationGroup); + return this; + } + public List getSpecificationsList() { List specifications = new ArrayList(); for (Specification specification : this.specifications.values()) { @@ -125,17 +139,19 @@ public ReqIFCoreContent(Element coreContent, TypeClassifier typeClassifier) { } - if (coreContent.getElementsByTagName("DATATYPES").item(0).hasChildNodes()) { + // Every element is matched by local name, so documents that put the + // ReqIF elements into a prefixed namespace are read as well. + Element datatypesElement = XmlUtils.firstDescendantByLocalName(coreContent, ReqIFConst.DATATYPES); + if (datatypesElement != null) { - NodeList dataTypes = coreContent.getElementsByTagName("DATATYPES").item(0).getChildNodes(); - for (int datatype = 0; datatype < dataTypes.getLength(); datatype++) { + for (Element dataType : XmlUtils.childElements(datatypesElement)) { - Node dataType = dataTypes.item(datatype); - String dataTypeNodeName = dataType.getNodeName(); - if (!dataTypeNodeName.equals(ReqIFConst._TEXT)) { + String dataTypeNodeName = XmlUtils.localName(dataType); + { String dataTypeID = dataType.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); String dataTypeName = dataType.getAttributes().getNamedItem(ReqIFConst.LONG_NAME).getTextContent(); + String dataTypeAlternativeID = XmlUtils.alternativeID(dataType); switch (dataTypeNodeName.substring(dataTypeNodeName.lastIndexOf("-") + 1)) { @@ -179,19 +195,21 @@ public ReqIFCoreContent(Element coreContent, TypeClassifier typeClassifier) { this.dataTypes.put(dataTypeID, new Datatype(dataTypeID, dataTypeName, ReqIFConst.UNDEFINED, dataTypeNodeName)); break; } + if (this.dataTypes.get(dataTypeID) != null) { + this.dataTypes.get(dataTypeID).setAlternativeID(dataTypeAlternativeID); + } } } } - if (coreContent.getElementsByTagName(ReqIFConst.SPEC_TYPES).item(0).hasChildNodes()) { + Element specTypesElement = XmlUtils.firstDescendantByLocalName(coreContent, ReqIFConst.SPEC_TYPES); + if (specTypesElement != null) { - NodeList specTypes = coreContent.getElementsByTagName(ReqIFConst.SPEC_TYPES).item(0).getChildNodes(); - for (int spectype = 0; spectype < specTypes.getLength(); spectype++) { + for (Element specType : XmlUtils.childElements(specTypesElement)) { - Node specType = specTypes.item(spectype); - String specTypeNodeName = specType.getNodeName(); - if (!specTypeNodeName.equals(ReqIFConst._TEXT)) { + String specTypeNodeName = XmlUtils.localName(specType); + { String specTypeID = specType.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); @@ -209,6 +227,10 @@ public ReqIFCoreContent(Element coreContent, TypeClassifier typeClassifier) { this.specTypes.put(specTypeID, new SpecRelationType(specType, this.dataTypes)); break; + case ReqIFConst.RELATION_GROUP_TYPE: + this.specTypes.put(specTypeID, new RelationGroupType(specType, this.dataTypes)); + break; + default: this.specTypes.put(specTypeID, new SpecType(specType, this.dataTypes)); break; @@ -218,45 +240,39 @@ public ReqIFCoreContent(Element coreContent, TypeClassifier typeClassifier) { } - if (coreContent.getElementsByTagName(ReqIFConst.SPEC_OBJECT).getLength() > 0) { - - NodeList specObjects = coreContent.getElementsByTagName(ReqIFConst.SPEC_OBJECT); - for (int specobj = 0; specobj < specObjects.getLength(); specobj++) { + for (Element specObj : XmlUtils.descendantsByLocalName(coreContent, ReqIFConst.SPEC_OBJECT)) { - Node specObj = specObjects.item(specobj); - String specObjID = specObj.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); - String specObjTypeRef = ((Element) specObj).getElementsByTagName(ReqIFConst.SPEC_OBJECT_TYPE_REF).item(0).getTextContent(); + String specObjID = XmlUtils.attribute(specObj, ReqIFConst.IDENTIFIER); + Element typeRef = XmlUtils.firstDescendantByLocalName(specObj, ReqIFConst.SPEC_OBJECT_TYPE_REF); + String specObjTypeRef = typeRef == null ? null : typeRef.getTextContent().trim(); - this.specObjects.put(specObjID, new SpecObject(specObj, this.specTypes.get(specObjTypeRef), typeClassifier)); - } + this.specObjects.put(specObjID, new SpecObject(specObj, this.specTypes.get(specObjTypeRef), typeClassifier)); } - if (coreContent.getElementsByTagName(ReqIFConst.SPEC_RELATION).getLength() > 0) { - NodeList specRelations = coreContent.getElementsByTagName(ReqIFConst.SPEC_RELATION); - for (int specrelation = 0; specrelation < specRelations.getLength(); specrelation++) { - - Node specRelation = specRelations.item(specrelation); - String specRelID = specRelation.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); - String specRelTypeRef = ((Element) specRelation).getElementsByTagName(ReqIFConst.SPEC_RELATION_TYPE_REF).item(0).getTextContent(); + for (Element specRelation : XmlUtils.descendantsByLocalName(coreContent, ReqIFConst.SPEC_RELATION)) { + String specRelID = XmlUtils.attribute(specRelation, ReqIFConst.IDENTIFIER); + Element typeRef = XmlUtils.firstDescendantByLocalName(specRelation, ReqIFConst.SPEC_RELATION_TYPE_REF); + String specRelTypeRef = typeRef == null ? null : typeRef.getTextContent().trim(); - this.specRelation.put(specRelID, new SpecRelation(specRelation, this.specTypes.get(specRelTypeRef))); - } + this.specRelation.put(specRelID, new SpecRelation(specRelation, this.specTypes.get(specRelTypeRef))); } - if (coreContent.getElementsByTagName(ReqIFConst.SPECIFICATION).getLength() > 0) { + for (Element relationGroup : XmlUtils.descendantsByLocalName(coreContent, ReqIFConst.RELATION_GROUP)) { + RelationGroup group = new RelationGroup(relationGroup); + this.relationGroups.put(group.getID(), group); + } + - NodeList specifications = coreContent.getElementsByTagName(ReqIFConst.SPECIFICATION); - for (int spec = 0; spec < specifications.getLength(); spec++) { + for (Element specification : XmlUtils.descendantsByLocalName(coreContent, ReqIFConst.SPECIFICATION)) { - Node specification = specifications.item(spec); - String specID = specification.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); - String specTypeRef = ((Element) specification).getElementsByTagName(ReqIFConst.SPEC_TYPE_REF).item(0).getTextContent(); + String specID = XmlUtils.attribute(specification, ReqIFConst.IDENTIFIER); + Element typeRef = XmlUtils.firstDescendantByLocalName(specification, ReqIFConst.SPEC_TYPE_REF); + String specTypeRef = typeRef == null ? null : typeRef.getTextContent().trim(); - this.specifications.put(specID, new Specification(specification, this.specTypes.get(specTypeRef), this.specObjects)); - } + this.specifications.put(specID, new Specification(specification, this.specTypes.get(specTypeRef), this.specObjects)); } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java index 38c79b5..5af0731 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFDocument.java @@ -3,9 +3,6 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; -import javax.xml.XMLConstants; -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import org.w3c.dom.Document; @@ -13,6 +10,8 @@ import org.xml.sax.SAXException; import de.uni_stuttgart.ils.reqif4j.specification.TypeClassifier; +import de.uni_stuttgart.ils.reqif4j.util.SecureXml; +import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; public class ReqIFDocument { @@ -25,6 +24,7 @@ public class ReqIFDocument { private ReqIFHeader header; private ReqIFCoreContent content; + private final java.util.List toolExtensions = new java.util.ArrayList<>(); public String getFilePath() { @@ -43,6 +43,16 @@ public ReqIFCoreContent getCoreContent() { return this.content; } + /** + * Tool-specific extensions of the document. They are not interpreted, only + * kept as their original nodes so they survive a round trip. + * + * @return the TOOL-EXTENSIONS nodes, empty if the document declares none + */ + public java.util.List getToolExtensions() { + return this.toolExtensions; + } + /** * Creates a document from an object model, for documents that are generated @@ -67,7 +77,7 @@ public ReqIFDocument(String filePath, TypeClassifier typeClassifier) throws File setTypeClassifier(typeClassifier); try { - this.reqifDocument = newDocumentBuilder().parse(this.filePath); + this.reqifDocument = SecureXml.newDocumentBuilder().parse(this.filePath); readDocument(); } catch (SAXException | IOException | ParserConfigurationException e) { @@ -86,7 +96,7 @@ public ReqIFDocument(InputStream is, String filePath, TypeClassifier typeClassif setTypeClassifier(typeClassifier); try { - this.reqifDocument = newDocumentBuilder().parse(is); + this.reqifDocument = SecureXml.newDocumentBuilder().parse(is); readDocument(); } catch (SAXException | IOException | ParserConfigurationException e) { @@ -105,7 +115,7 @@ public ReqIFDocument(InputStream is, String zipFilePath, String fileName, TypeCl setTypeClassifier(typeClassifier); try { - this.reqifDocument = newDocumentBuilder().parse(is); + this.reqifDocument = SecureXml.newDocumentBuilder().parse(is); readDocument(); } catch (SAXException | IOException | ParserConfigurationException e) { @@ -118,42 +128,23 @@ private void setTypeClassifier(TypeClassifier typeClassifier) { } - /** - * Creates a namespace-aware, XXE-hardened document builder. Namespace - * awareness is required so XHTML content with namespace prefixes - * (e.g. {@code xhtml:div}) can be matched by local name. - */ - private static DocumentBuilder newDocumentBuilder() throws ParserConfigurationException { - - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - - // Harden against XXE / entity expansion attacks - factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); - factory.setFeature("http://xml.org/sax/features/external-general-entities", false); - factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); - factory.setXIncludeAware(false); - factory.setExpandEntityReferences(false); - try { - factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); - factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); - } catch (IllegalArgumentException ignored) { - // parser implementation does not support these attributes - } - - return factory.newDocumentBuilder(); - } - private void readDocument() { - if (this.reqifDocument.getElementsByTagName(ReqIFConst.THE_HEADER).getLength() > 0 - && this.reqifDocument.getElementsByTagName(ReqIFConst.THE_HEADER).item(0).hasChildNodes()) { - this.header = new ReqIFHeader((Element) this.reqifDocument.getElementsByTagName(ReqIFConst.THE_HEADER).item(0)); + // Elements are matched by local name, so a document that puts the ReqIF + // elements into a prefixed namespace is read just like one using the + // default namespace. + Element theHeader = XmlUtils.firstDescendantByLocalName(this.reqifDocument, ReqIFConst.THE_HEADER); + if (theHeader != null && theHeader.hasChildNodes()) { + this.header = new ReqIFHeader(theHeader); } - if (this.reqifDocument.getElementsByTagName(ReqIFConst.CORE_CONTENT).getLength() == 0) { + Element coreContent = XmlUtils.firstDescendantByLocalName(this.reqifDocument, ReqIFConst.CORE_CONTENT); + if (coreContent == null) { throw new ReqIFParseException("Document contains no " + ReqIFConst.CORE_CONTENT + " element: " + this.fileName); } - this.content = new ReqIFCoreContent((Element) this.reqifDocument.getElementsByTagName(ReqIFConst.CORE_CONTENT).item(0), this.typeClassifier); + this.content = new ReqIFCoreContent(coreContent, this.typeClassifier); + + // Tool extensions are kept verbatim; the parser does not interpret them. + this.toolExtensions.addAll(XmlUtils.descendantsByLocalName(this.reqifDocument, ReqIFConst.TOOL_EXTENSIONS)); } private static String extractFileName(String path) { diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFHeader.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFHeader.java index 32c07f4..59b7e59 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFHeader.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFHeader.java @@ -2,6 +2,8 @@ import org.w3c.dom.Element; +import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; + public class ReqIFHeader { @@ -67,31 +69,48 @@ public String getCreationTime() { public ReqIFHeader(Element theHeader) { - - this.id = theHeader.getElementsByTagName(ReqIFConst.REQ_IF_HEADER).item(0).getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); - this.toolID = theHeader.getElementsByTagName(ReqIFConst.REQ_IF_TOOL_ID).item(0).getTextContent(); - - if(theHeader.getElementsByTagName(ReqIFConst.SOURCE_TOOL_ID).getLength() > 0) { - this.sourceToolID = theHeader.getElementsByTagName(ReqIFConst.SOURCE_TOOL_ID).item(0).getTextContent(); + + Element reqifHeader = XmlUtils.firstDescendantByLocalName(theHeader, ReqIFConst.REQ_IF_HEADER); + this.id = reqifHeader == null ? null : XmlUtils.attribute(reqifHeader, ReqIFConst.IDENTIFIER); + this.toolID = textOf(theHeader, ReqIFConst.REQ_IF_TOOL_ID); + + String sourceToolID = textOf(theHeader, ReqIFConst.SOURCE_TOOL_ID); + if(sourceToolID != null) { + this.sourceToolID = sourceToolID; } - if(theHeader.getElementsByTagName(ReqIFConst.REQ_IF_VERSION).getLength() > 0) { - this.reqifVersion = theHeader.getElementsByTagName(ReqIFConst.REQ_IF_VERSION).item(0).getTextContent(); + String reqifVersion = textOf(theHeader, ReqIFConst.REQ_IF_VERSION); + if(reqifVersion != null) { + this.reqifVersion = reqifVersion; } - if(theHeader.getElementsByTagName(ReqIFConst.COMMENT).getLength() > 0) { - this.comment = theHeader.getElementsByTagName(ReqIFConst.COMMENT).item(0).getTextContent(); - this.author = authorOf(this.comment); + String comment = textOf(theHeader, ReqIFConst.COMMENT); + if(comment != null) { + this.comment = comment; + this.author = authorOf(comment); } - if(theHeader.getElementsByTagName(ReqIFConst.CREATION_TIME).getLength() > 0) { - this.creationTime = theHeader.getElementsByTagName(ReqIFConst.CREATION_TIME).item(0).getTextContent(); - this.creationDate = creationDateOf(this.creationTime); + String creationTime = textOf(theHeader, ReqIFConst.CREATION_TIME); + if(creationTime != null) { + this.creationTime = creationTime; + this.creationDate = creationDateOf(creationTime); } - if(theHeader.getElementsByTagName(ReqIFConst.TITLE).getLength() > 0) { + String title = textOf(theHeader, ReqIFConst.TITLE); + if(title != null) { // The title is returned as written in the document; stripping a // "_Template" suffix was a tool-specific hack in a generic parser. - this.title = theHeader.getElementsByTagName(ReqIFConst.TITLE).item(0).getTextContent(); + this.title = title; } } + /** + * @return the text of the first descendant with that local name, or null. + * Matching by local name keeps documents readable that put the ReqIF + * elements into a prefixed namespace. + */ + private static String textOf(Element parent, String localName) { + + Element element = XmlUtils.firstDescendantByLocalName(parent, localName); + return element == null ? null : element.getTextContent(); + } + /** * Creates a header from plain values, for documents that are generated * instead of parsed. The author is derived from the comment and the diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFz.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFz.java index 2384931..18d6f62 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFz.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/reqif/ReqIFz.java @@ -16,6 +16,26 @@ public class ReqIFz extends ReqIFFile { private static final String EXTRACTION_SUFFIX = "_unzipped"; + private final Map pictureFiles = new java.util.LinkedHashMap<>(); + private final Map reqifFiles = new java.util.LinkedHashMap<>(); + + /** + * @return the extracted picture files, keyed by their archive entry name + * (forward slashes, as referenced by xhtml object data attributes). + * Unlike the input streams these can be read repeatedly, which is + * what re-packing an archive needs. + */ + public Map getPictureFiles() { + return this.pictureFiles; + } + + /** + * @return the extracted ReqIF files, keyed by their archive entry name + */ + public Map getReqIFFiles() { + return this.reqifFiles; + } + public ReqIFz(String filePath) throws IOException { this(filePath, TypeClassifier.defaultClassifier()); } @@ -73,12 +93,14 @@ public ReqIFz(String filePath, TypeClassifier typeClassifier) throws IOException // Process reqif files and associated images if (zipEntry.getName().endsWith("reqif")) { this.numberOfReqIFDocuments++; + this.reqifFiles.put(zipEntry.getName(), newFile); try (InputStream reqifIS = new FileInputStream(newFile)) { this.reqifDocuments.put(zipEntry.getName(), new ReqIFDocument(reqifIS, filePath, zipEntry.getName(), typeClassifier)); } } else if (zipEntry.getName().endsWith("png") || zipEntry.getName().endsWith("jpeg") || zipEntry.getName().endsWith("jpg")) { // Keyed by the archive entry path (forward slashes), which is // what xhtml object data attributes reference. + this.pictureFiles.put(zipEntry.getName(), newFile); picturesIS.put(zipEntry.getName(), new FileInputStream(newFile)); } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroup.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroup.java new file mode 100644 index 0000000..777fc1b --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroup.java @@ -0,0 +1,119 @@ +package de.uni_stuttgart.ils.reqif4j.specification; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; +import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; + +/** + * A group of relations between two specifications + * ({@code SPEC-RELATION-GROUPS/RELATION-GROUP}). + * + * Relation groups are optional in ReqIF but used by tools to bundle the links + * between a source and a target specification, for example a traceability view + * between a system and a software specification. + */ +public class RelationGroup { + + private String id; + private String name; + private String alternativeID; + private String relationGroupTypeRef; + private String sourceSpecificationRef; + private String targetSpecificationRef; + private final List specRelationRefs = new ArrayList(); + + + public String getID() { + return this.id; + } + + public String getName() { + return this.name; + } + + /** + * @return the IDENTIFIER of the optional ALTERNATIVE-ID, or null + */ + public String getAlternativeID() { + return this.alternativeID; + } + + /** + * @return the IDENTIFIER of the RELATION-GROUP-TYPE, or null + */ + public String getRelationGroupTypeRef() { + return this.relationGroupTypeRef; + } + + /** + * @return the IDENTIFIER of the source specification, or null + */ + public String getSourceSpecificationRef() { + return this.sourceSpecificationRef; + } + + /** + * @return the IDENTIFIER of the target specification, or null + */ + public String getTargetSpecificationRef() { + return this.targetSpecificationRef; + } + + /** + * @return the IDENTIFIERs of the relations belonging to this group + */ + public List getSpecRelationRefs() { + return Collections.unmodifiableList(this.specRelationRefs); + } + + + + + /** + * Creates a relation group from plain values, for documents that are + * generated instead of parsed. + */ + public RelationGroup(String id, String name, String relationGroupTypeRef, + String sourceSpecificationRef, String targetSpecificationRef, List specRelationRefs) { + + this.id = id; + this.name = name; + this.relationGroupTypeRef = relationGroupTypeRef; + this.sourceSpecificationRef = sourceSpecificationRef; + this.targetSpecificationRef = targetSpecificationRef; + if (specRelationRefs != null) { + this.specRelationRefs.addAll(specRelationRefs); + } + } + + public RelationGroup(Node relationGroup) { + + this.id = XmlUtils.attribute(relationGroup, ReqIFConst.IDENTIFIER); + this.name = XmlUtils.attribute(relationGroup, ReqIFConst.LONG_NAME); + this.alternativeID = XmlUtils.alternativeID(relationGroup); + + this.relationGroupTypeRef = refIn(relationGroup, ReqIFConst.TYPE, ReqIFConst.RELATION_GROUP_TYPE_REF); + this.sourceSpecificationRef = refIn(relationGroup, ReqIFConst.SOURCE_SPECIFICATION, ReqIFConst.SPECIFICATION_REF); + this.targetSpecificationRef = refIn(relationGroup, ReqIFConst.TARGET_SPECIFICATION, ReqIFConst.SPECIFICATION_REF); + + Element relations = XmlUtils.firstChildElementByLocalName(relationGroup, ReqIFConst.SPEC_RELATIONS); + if (relations != null) { + for (Element ref : XmlUtils.descendantsByLocalName(relations, ReqIFConst.SPEC_RELATION_REF)) { + this.specRelationRefs.add(ref.getTextContent().trim()); + } + } + } + + private static String refIn(Node parent, String wrapperName, String refName) { + + Element wrapper = XmlUtils.firstChildElementByLocalName(parent, wrapperName); + Element ref = XmlUtils.firstDescendantByLocalName(wrapper, refName); + return ref == null ? null : ref.getTextContent().trim(); + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroupType.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroupType.java new file mode 100644 index 0000000..a87a529 --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/RelationGroupType.java @@ -0,0 +1,27 @@ +package de.uni_stuttgart.ils.reqif4j.specification; + +import java.util.Map; + +import org.w3c.dom.Node; + +import de.uni_stuttgart.ils.reqif4j.datatypes.Datatype; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; + +public class RelationGroupType extends SpecType { + + + public RelationGroupType(Node specType, Map dataTypes) { + super(specType, dataTypes); + + this.type = ReqIFConst.RELATION_GROUP_TYPE; + } + + /** + * Creates a relation group type from plain values, for documents that are + * generated instead of parsed. + */ + public RelationGroupType(String id, String name) { + super(id, name, ReqIFConst.RELATION_GROUP_TYPE); + } + +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java index 7126f7b..220ed87 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecHierarchy.java @@ -13,6 +13,7 @@ import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueXHTML; import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueXHTMLElementList; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; +import de.uni_stuttgart.ils.reqif4j.util.XmlUtils; import de.uni_stuttgart.ils.reqif4j.xhtml.XHTMLNode; import de.uni_stuttgart.ils.reqif4j.xhtml.XHTMLElementDiv; @@ -20,6 +21,7 @@ public class SpecHierarchy { private String specHierarchyID; + private String alternativeID; private int hierarchyLvl; private int section; private SpecObject specObject; @@ -31,6 +33,13 @@ public class SpecHierarchy { public String getSpecHierarchyID() { return this.specHierarchyID; } + + /** + * @return the IDENTIFIER of the optional ALTERNATIVE-ID, or null + */ + public String getAlternativeID() { + return this.alternativeID; + } public String getSpecObjectID(){ return this.specObject.getID(); @@ -176,28 +185,22 @@ public SpecHierarchy(int hierarchyLvl, int section, Node specHierarchy, Map 0 - && ((Element)specHierarchy).getElementsByTagName(ReqIFConst.CHILDREN).item(0).getChildNodes().getLength() > 0 ) { - - NodeList children = ((Element)specHierarchy).getElementsByTagName(ReqIFConst.CHILDREN).item(0).getChildNodes(); - for(int child = 0; child < children.getLength(); child++) { - - Node newSpecHierarchy = children.item(child); - if(!newSpecHierarchy.getNodeName().equals(ReqIFConst._TEXT)) { - - String specHierarchyID = newSpecHierarchy.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); - - this.children.put(specHierarchyID, new SpecHierarchy(this.hierarchyLvl+1, section, newSpecHierarchy, specObjects)); - } + + Element childrenElement = XmlUtils.firstChildElementByLocalName(specHierarchy, ReqIFConst.CHILDREN); + if(childrenElement != null) { + + for(Element newSpecHierarchy: XmlUtils.childElements(childrenElement)) { + + String specHierarchyID = XmlUtils.attribute(newSpecHierarchy, ReqIFConst.IDENTIFIER); + + this.children.put(specHierarchyID, new SpecHierarchy(this.hierarchyLvl+1, section, newSpecHierarchy, specObjects)); } } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java index 76a7989..0273e0a 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecObject.java @@ -21,6 +21,7 @@ public class SpecObject { protected String id; protected SpecType specType; protected String type; + protected String alternativeID; protected TypeClassifier typeClassifier = TypeClassifier.defaultClassifier(); protected Map attributeValues = new HashMap(); @@ -30,7 +31,14 @@ public class SpecObject { public String getID() { return this.id; } - + + /** + * @return the IDENTIFIER of the optional ALTERNATIVE-ID, or null + */ + public String getAlternativeID() { + return this.alternativeID; + } + public String getType() { return this.type; } @@ -89,6 +97,7 @@ public boolean isText() { public SpecObject(Node specObject){ this.id = specObject.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); + this.alternativeID = XmlUtils.alternativeID(specObject); } /** @@ -122,6 +131,7 @@ public SpecObject(Node specObject, SpecType specType) { public SpecObject(Node specObject, SpecType specType, TypeClassifier typeClassifier) { this.id = specObject.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); + this.alternativeID = XmlUtils.alternativeID(specObject); this.specType = specType; this.typeClassifier = typeClassifier == null ? TypeClassifier.defaultClassifier() : typeClassifier; @@ -140,15 +150,15 @@ public SpecObject(Node specObject, SpecType specType, TypeClassifier typeClassif */ protected void readAttributeValues(Node specObject, SpecType specType) { - if( ((Element)specObject).getElementsByTagName(ReqIFConst.VALUES).getLength() > 0 - && ((Element)specObject).getElementsByTagName(ReqIFConst.VALUES).item(0).hasChildNodes() ) { - - NodeList attributeValues = ((Element)specObject).getElementsByTagName(ReqIFConst.VALUES).item(0).getChildNodes(); - for(int attval = 0; attval < attributeValues.getLength(); attval++) { - - Node attribute = attributeValues.item(attval); - String attValNodeName = attribute.getNodeName(); - if(!attValNodeName.equals(ReqIFConst._TEXT)) { + // VALUES is located by local name so prefixed ReqIF namespaces work too; + // only this object's own VALUES is taken, not a nested one. + Element valuesElement = XmlUtils.firstChildElementByLocalName(specObject, ReqIFConst.VALUES); + if(valuesElement != null) { + + for(Element attribute: XmlUtils.childElements(valuesElement)) { + + String attValNodeName = XmlUtils.localName(attribute); + { String attributeDefinitionRef = XmlUtils.firstChildElement( XmlUtils.firstChildElementByLocalName(attribute, ReqIFConst.DEFINITION)).getTextContent().trim(); @@ -211,8 +221,19 @@ protected void readAttributeValues(Node specObject, SpecType specType) { } this.attributeValues.put(attributeDefinitionName, new AttributeValueDouble(attributeValue, attributeDefinition)); break; - - default: break; + + // Values of datatype kinds the parser does not model are + // kept generically instead of being dropped, so they + // survive a round trip. + default: if(attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE) !=null) { + attributeValue = attribute.getAttributes().getNamedItem(ReqIFConst.THE_VALUE).getTextContent(); + }else{ + attributeValue = ""; + } + this.attributeValues.put(attributeDefinitionName, + new AttributeValue(attributeValue, attributeDefinition) + .setSourceElementName(XmlUtils.localName(attribute))); + break; } } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java index c63b6f9..b0c456c 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/SpecType.java @@ -19,6 +19,8 @@ public class SpecType { private String id; protected String name; protected String type; + protected String alternativeID; + protected String sourceElementName; @@ -26,6 +28,23 @@ public class SpecType { public String getID() { return this.id; } + + /** + * @return the IDENTIFIER of the optional ALTERNATIVE-ID, or null + */ + public String getAlternativeID() { + return this.alternativeID; + } + + /** + * @return the SPEC-TYPE element name this type was read from, or null when + * it was not created from a document. Needed to write back spec type + * kinds the parser does not model explicitly, instead of silently + * turning them into a SPEC-OBJECT-TYPE. + */ + public String getSourceElementName() { + return this.sourceElementName; + } public String getName() { return this.name; @@ -126,19 +145,18 @@ public SpecType(Node specType, Map dataTypes) { this.id = specType.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); this.name = specType.getAttributes().getNamedItem(ReqIFConst.LONG_NAME).getTextContent(); + this.alternativeID = XmlUtils.alternativeID(specType); + this.sourceElementName = XmlUtils.localName(specType); this.type = ReqIFConst.UNDEFINED; //Doors relationship definitionen habe keine ChildNodes Node specAttributes = XmlUtils.firstChildElementByLocalName(specType, ReqIFConst.SPEC_ATTRIBUTES); if(specAttributes != null) { - NodeList attributeDefinitions = specAttributes.getChildNodes(); + for(Node attributeDefinition: XmlUtils.childElements(specAttributes)) { - for(int specatt = 0; specatt < attributeDefinitions.getLength(); specatt++) { - - Node attributeDefinition = attributeDefinitions.item(specatt); - String attDefNodeName = attributeDefinition.getNodeName(); - if(!attDefNodeName.equals(ReqIFConst._TEXT)) { + String attDefNodeName = XmlUtils.localName(attributeDefinition); + { String attDefID = attributeDefinition.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java index 5d10bd3..a99c65d 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/specification/Specification.java @@ -28,6 +28,7 @@ public class Specification { private String id; private String name; + private String alternativeID; private SpecType type; private int numberOfSpecObjects; private int sectionCounter = 0; @@ -41,6 +42,13 @@ public class Specification { public String getID() { return this.id; } + + /** + * @return the IDENTIFIER of the optional ALTERNATIVE-ID, or null + */ + public String getAlternativeID() { + return this.alternativeID; + } /** * @return the value of an attribute named "Description", or null if the @@ -147,17 +155,16 @@ public Specification(Node specification, SpecType specType, Map 0 - && ((Element)specification).getElementsByTagName(ReqIFConst.VALUES).item(0).getChildNodes().getLength() > 0 ) { - - NodeList attributeValues = ((Element)specification).getElementsByTagName(ReqIFConst.VALUES).item(0).getChildNodes(); - for(int attval = 0; attval < attributeValues.getLength(); attval++) { - - Node attribute = attributeValues.item(attval); - String attValNodeName = attribute.getNodeName(); - if(!attValNodeName.equals(ReqIFConst._TEXT)) { + Element valuesElement = XmlUtils.firstChildElementByLocalName(specification, ReqIFConst.VALUES); + if(valuesElement != null) { + + for(Element attribute: XmlUtils.childElements(valuesElement)) { + + String attValNodeName = XmlUtils.localName(attribute); + { String attributeDefinitionRef = XmlUtils.firstChildElement( XmlUtils.firstChildElementByLocalName(attribute, ReqIFConst.DEFINITION)).getTextContent().trim(); @@ -222,7 +229,17 @@ public Specification(Node specification, SpecType specType, Map 0 - && ((Element)specification).getElementsByTagName(ReqIFConst.CHILDREN).item(0).getChildNodes().getLength() > 0 ) { - - NodeList children = ((Element)specification).getElementsByTagName(ReqIFConst.CHILDREN).item(0).getChildNodes(); - for(int child = 0; child < children.getLength(); child++) { - - Node specHierarchy = children.item(child); - if(!specHierarchy.getNodeName().equals(ReqIFConst._TEXT)) { - - String specHierarchyID = specHierarchy.getAttributes().getNamedItem(ReqIFConst.IDENTIFIER).getTextContent(); - - this.children.put(specHierarchyID, new SpecHierarchy(1, ++this.sectionCounter, specHierarchy, specObjects)); - } + Element childrenElement = XmlUtils.firstChildElementByLocalName(specification, ReqIFConst.CHILDREN); + if(childrenElement != null) { + + for(Element specHierarchy: XmlUtils.childElements(childrenElement)) { + + String specHierarchyID = XmlUtils.attribute(specHierarchy, ReqIFConst.IDENTIFIER); + + this.children.put(specHierarchyID, new SpecHierarchy(1, ++this.sectionCounter, specHierarchy, specObjects)); } } diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/SecureXml.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/SecureXml.java new file mode 100644 index 0000000..1e395fd --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/SecureXml.java @@ -0,0 +1,82 @@ +package de.uni_stuttgart.ils.reqif4j.util; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerConfigurationException; +import javax.xml.transform.TransformerFactory; +import javax.xml.validation.SchemaFactory; + +/** + * Factories for XML components with the same hardening applied everywhere. + * + * Every place that parses XML in this library must go through here; a parser + * created with the plain JDK defaults resolves DOCTYPEs and external entities + * (XXE). + */ +public final class SecureXml { + + private SecureXml() { + } + + /** + * @return a namespace-aware document builder that rejects DOCTYPEs and does + * not resolve external entities + */ + public static DocumentBuilder newDocumentBuilder() throws ParserConfigurationException { + + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + try { + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + } catch (IllegalArgumentException ignored) { + // parser implementation does not support these attributes + } + + return factory.newDocumentBuilder(); + } + + /** + * @return an identity transformer with secure processing enabled + */ + public static Transformer newTransformer() throws TransformerConfigurationException { + + TransformerFactory factory = TransformerFactory.newInstance(); + try { + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ""); + } catch (IllegalArgumentException | TransformerConfigurationException ignored) { + // implementation does not support these settings + } + return factory.newTransformer(); + } + + /** + * @param schemaLanguage e.g. {@link XMLConstants#W3C_XML_SCHEMA_NS_URI} + * @return a schema factory with secure processing enabled. Local schema + * files may still be resolved, because an XSD legitimately imports + * other XSDs from the same directory. + */ + public static SchemaFactory newSchemaFactory(String schemaLanguage) { + + SchemaFactory factory = SchemaFactory.newInstance(schemaLanguage); + try { + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setProperty(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setProperty(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "file"); + } catch (Exception ignored) { + // implementation does not support these settings + } + return factory; + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XhtmlParser.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XhtmlParser.java new file mode 100644 index 0000000..abaffd4 --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XhtmlParser.java @@ -0,0 +1,89 @@ +package de.uni_stuttgart.ils.reqif4j.util; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import javax.xml.parsers.ParserConfigurationException; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.xml.sax.SAXException; + +/** + * Parses the XHTML markup of an attribute value into a {@code div} element in + * the XHTML namespace. + * + * The markup may be given with or without a surrounding div. It is always + * parsed inside a synthetic root, and the result is only reused as the div when + * it really is one - matching on the string {@code "}. + */ +public final class XhtmlParser { + + public static final String XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml"; + + private static final String SYNTHETIC_ROOT = "reqif4j-xhtml-root"; + private static final String DIV = "div"; + + private XhtmlParser() { + } + + /** + * @param markup XHTML fragment, with or without a surrounding div + * @return a div element containing the markup, never null + * @throws IllegalArgumentException if the markup is not well-formed + */ + public static Element parseDiv(String markup) { + + String fragment = markup == null ? "" : markup.trim(); + String document = "<" + SYNTHETIC_ROOT + " xmlns=\"" + XHTML_NAMESPACE + "\">" + fragment + + ""; + + Element root; + try { + Document parsed = SecureXml.newDocumentBuilder() + .parse(new ByteArrayInputStream(document.getBytes(StandardCharsets.UTF_8))); + root = parsed.getDocumentElement(); + + } catch (SAXException | IOException | ParserConfigurationException e) { + throw new IllegalArgumentException("XHTML value is not well-formed: " + markup, e); + } + + Element onlyChild = onlyElementChild(root); + if (onlyChild != null && DIV.equals(XmlUtils.localName(onlyChild))) { + return onlyChild; + } + + // no surrounding div (or more than one top level element): wrap it + Element div = root.getOwnerDocument().createElementNS(XHTML_NAMESPACE, DIV); + while (root.hasChildNodes()) { + div.appendChild(root.getFirstChild()); + } + return div; + } + + /** + * @return the single element child of the node, or null if it has none or + * more than one (text next to it is ignored only when blank) + */ + private static Element onlyElementChild(Element parent) { + + Element found = null; + for (Node child = parent.getFirstChild(); child != null; child = child.getNextSibling()) { + + if (child.getNodeType() == Node.ELEMENT_NODE) { + if (found != null) { + return null; + } + found = (Element) child; + + } else if (child.getNodeType() == Node.TEXT_NODE && !child.getTextContent().isBlank()) { + // text outside the element means it cannot be the surrounding div + return null; + } + } + return found; + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XmlUtils.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XmlUtils.java index 3c232e0..ad2bc26 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XmlUtils.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/util/XmlUtils.java @@ -122,6 +122,18 @@ private static void collectDescendants(Node node, String localName, List + * new ReqIFValidator().validate(document).throwIfInvalid(); + * new ReqIFWriter().write(document, Path.of("out.reqif")); + * + * + * Validated are: identifiers present and globally unique, resolvable datatype + * and spec type references, attribute values matching their spec type, enum + * value references, relation endpoints, spec hierarchy targets and relation + * group references. + * + * This is a model-level check, not a schema check. The OMG ReqIF XSD is not + * bundled with this library; if you have it, pass it to + * {@link #validateAgainstSchema(ReqIFDocument, Path)} for a full XML Schema + * validation on top. + */ +public class ReqIFValidator { + + /** + * @return the findings; use {@link ValidationResult#isValid()} or + * {@link ValidationResult#throwIfInvalid()} + */ + public ValidationResult validate(ReqIFDocument document) { + + ValidationResult result = new ValidationResult(); + + if (document == null) { + result.error(null, "Document is null"); + return result; + } + if (document.getHeader() == null) { + result.warning(null, "Document has no THE-HEADER"); + } else if (isBlank(document.getHeader().getID())) { + result.error(null, "REQ-IF-HEADER has no IDENTIFIER"); + } + + ReqIFCoreContent content = document.getCoreContent(); + if (content == null) { + result.error(null, "Document has no CORE-CONTENT"); + return result; + } + + Map identifiers = new HashMap(); + checkDatatypes(content, result, identifiers); + checkSpecTypes(content, result, identifiers); + checkSpecObjects(content, result, identifiers); + checkSpecRelations(content, result, identifiers); + checkSpecifications(content, result, identifiers); + checkRelationGroups(content, result, identifiers); + + return result; + } + + + private void checkDatatypes(ReqIFCoreContent content, ValidationResult result, Map identifiers) { + + for (Datatype datatype : content.getDatatypes().values()) { + checkIdentifier(datatype.getID(), "DATATYPE-DEFINITION", result, identifiers); + } + } + + private void checkSpecTypes(ReqIFCoreContent content, ValidationResult result, Map identifiers) { + + for (SpecType specType : content.getSpecTypes().values()) { + checkIdentifier(specType.getID(), "SPEC-TYPE", result, identifiers); + + for (AttributeDefinition definition : specType.getAttributeDefinitions().values()) { + checkIdentifier(definition.getID(), "ATTRIBUTE-DEFINITION", result, identifiers); + + Datatype datatype = definition.getDataType(); + if (datatype == null) { + result.error(definition.getID(), "Attribute definition '" + definition.getName() + + "' references a datatype that does not exist"); + + } else if (content.getDatatype(datatype.getID()) == null) { + result.error(definition.getID(), "Attribute definition '" + definition.getName() + + "' references datatype " + datatype.getID() + ", which is not declared in DATATYPES"); + } + + checkEnumDefaults(content, definition, result); + } + } + } + + private void checkEnumDefaults(ReqIFCoreContent content, AttributeDefinition definition, ValidationResult result) { + + if (!(definition instanceof AttributeDefinitionEnumeration)) { + return; + } + AttributeDefinitionEnumeration enumDefinition = (AttributeDefinitionEnumeration) definition; + List defaults = enumDefinition.getDefaultValueRefs(); + + checkEnumRefs(definition, defaults, result, "default value"); + if (!enumDefinition.isMultiValued() && defaults.size() > 1) { + result.error(definition.getID(), "Attribute definition '" + definition.getName() + + "' is not MULTI-VALUED but declares " + defaults.size() + " default values"); + } + } + + private void checkEnumRefs(AttributeDefinition definition, List refs, ValidationResult result, + String what) { + + if (!(definition.getDataType() instanceof DatatypeEnumeration)) { + return; + } + DatatypeEnumeration datatype = (DatatypeEnumeration) definition.getDataType(); + for (String ref : refs) { + if (datatype.getEnumValueName(ref) == null) { + result.error(definition.getID(), "Attribute definition '" + definition.getName() + "' references " + + what + " " + ref + ", which datatype " + datatype.getID() + " does not declare"); + } + } + } + + private void checkSpecObjects(ReqIFCoreContent content, ValidationResult result, Map identifiers) { + + for (SpecObject specObject : content.getSpecObjects().values()) { + checkIdentifier(specObject.getID(), "SPEC-OBJECT", result, identifiers); + checkSpecTypeRef(content, specObject.getID(), specObject.getSpecTypeID(), "SPEC-OBJECT", result); + checkAttributeValues(specObject.getID(), specObject.getSpecTypeID(), content, + specObject.getAttributes(), result); + } + } + + private void checkSpecRelations(ReqIFCoreContent content, ValidationResult result, + Map identifiers) { + + for (SpecRelation relation : content.getSpecRelation().values()) { + checkIdentifier(relation.getID(), "SPEC-RELATION", result, identifiers); + checkSpecTypeRef(content, relation.getID(), relation.getRelationTypeRef(), "SPEC-RELATION", result); + + if (content.getSpecObject(relation.getSourceObjID()) == null) { + result.error(relation.getID(), "Relation source " + relation.getSourceObjID() + + " is not a known spec object"); + } + if (content.getSpecObject(relation.getTargetObjID()) == null) { + result.error(relation.getID(), "Relation target " + relation.getTargetObjID() + + " is not a known spec object"); + } + checkAttributeValues(relation.getID(), relation.getRelationTypeRef(), content, + relation.getAttributes(), result); + } + } + + private void checkSpecifications(ReqIFCoreContent content, ValidationResult result, + Map identifiers) { + + for (Specification specification : content.getSpecifications().values()) { + checkIdentifier(specification.getID(), "SPECIFICATION", result, identifiers); + checkSpecTypeRef(content, specification.getID(), specification.getSpecTypeID(), "SPECIFICATION", result); + checkAttributeValues(specification.getID(), specification.getSpecTypeID(), content, + specification.getAttributes(), result); + + for (SpecHierarchy hierarchy : specification.getAllSpecHierarchies()) { + checkIdentifier(hierarchy.getSpecHierarchyID(), "SPEC-HIERARCHY", result, identifiers); + + if (hierarchy.getSpecObject() == null) { + result.error(hierarchy.getSpecHierarchyID(), + "Spec hierarchy references a spec object that does not exist"); + + } else if (content.getSpecObject(hierarchy.getSpecObjectID()) == null) { + result.error(hierarchy.getSpecHierarchyID(), "Spec hierarchy references spec object " + + hierarchy.getSpecObjectID() + ", which is not declared in SPEC-OBJECTS"); + } + } + } + } + + private void checkRelationGroups(ReqIFCoreContent content, ValidationResult result, + Map identifiers) { + + for (RelationGroup group : content.getRelationGroups().values()) { + checkIdentifier(group.getID(), "RELATION-GROUP", result, identifiers); + + if (group.getRelationGroupTypeRef() != null + && content.getSpecType(group.getRelationGroupTypeRef()) == null) { + result.error(group.getID(), "Relation group references type " + group.getRelationGroupTypeRef() + + ", which is not declared in SPEC-TYPES"); + } + checkSpecificationRef(content, group, group.getSourceSpecificationRef(), "source", result); + checkSpecificationRef(content, group, group.getTargetSpecificationRef(), "target", result); + + for (String relationRef : group.getSpecRelationRefs()) { + if (content.getSpecRelation(relationRef) == null) { + result.error(group.getID(), "Relation group references relation " + relationRef + + ", which is not declared in SPEC-RELATIONS"); + } + } + } + } + + private void checkSpecificationRef(ReqIFCoreContent content, RelationGroup group, String specificationRef, + String role, ValidationResult result) { + + if (specificationRef != null && content.getSpecification(specificationRef) == null) { + result.error(group.getID(), "Relation group " + role + " specification " + specificationRef + + " is not declared in SPECIFICATIONS"); + } + } + + /** + * Every attribute value must belong to the spec type of its owner, and enum + * values must exist in the referenced datatype. + */ + private void checkAttributeValues(String ownerID, String specTypeID, ReqIFCoreContent content, + Map attributeValues, ValidationResult result) { + + SpecType specType = specTypeID == null ? null : content.getSpecType(specTypeID); + if (specType == null) { + return; + } + + for (AttributeValue attributeValue : attributeValues.values()) { + AttributeDefinition definition = attributeValue.getAttributeDefinitionType(); + + if (definition == null) { + result.error(ownerID, "Attribute value '" + attributeValue.getName() + "' has no definition"); + continue; + } + if (specType.getAttributeDefinition(definition.getID()) == null) { + result.error(ownerID, "Attribute value '" + attributeValue.getName() + "' uses definition " + + definition.getID() + ", which does not belong to spec type " + specTypeID); + } + if (attributeValue instanceof AttributeValueEnumeration) { + AttributeValueEnumeration enumValue = (AttributeValueEnumeration) attributeValue; + checkEnumRefs(definition, enumValue.getValueRefs(), result, "enum value"); + + if (definition instanceof AttributeDefinitionEnumeration + && !((AttributeDefinitionEnumeration) definition).isMultiValued() + && enumValue.getValueRefs().size() > 1) { + result.error(ownerID, "Attribute '" + attributeValue.getName() + "' is not MULTI-VALUED but has " + + enumValue.getValueRefs().size() + " values"); + } + } + } + } + + private void checkSpecTypeRef(ReqIFCoreContent content, String ownerID, String specTypeID, String kind, + ValidationResult result) { + + if (isBlank(specTypeID)) { + result.error(ownerID, kind + " has no type reference"); + + } else if (content.getSpecType(specTypeID) == null) { + result.error(ownerID, kind + " references type " + specTypeID + + ", which is not declared in SPEC-TYPES"); + } + } + + /** + * ReqIF identifiers must be present and unique across the whole document. + * + * Note that identifiers duplicated within one category cannot be + * detected here: the parser keys its maps by identifier, so a duplicate has + * already replaced its predecessor. + */ + private void checkIdentifier(String id, String kind, ValidationResult result, Map identifiers) { + + if (isBlank(id)) { + result.error(null, kind + " has no IDENTIFIER"); + return; + } + String previousKind = identifiers.put(id, kind); + if (previousKind != null) { + result.error(id, "Identifier is used twice: as " + previousKind + " and as " + kind); + } + } + + + /** + * Validates the written XML against an XML Schema. The OMG ReqIF XSD is not + * bundled with this library for licensing reasons; download it from the OMG + * and pass its path here. + * + * @param schema path to reqif.xsd + * @return the schema violations, empty if the document is schema-valid + */ + public ValidationResult validateAgainstSchema(ReqIFDocument document, Path schema) throws IOException { + + ValidationResult result = new ValidationResult(); + try { + SchemaFactory factory = SecureXml.newSchemaFactory(XMLConstants.W3C_XML_SCHEMA_NS_URI); + Schema xsd = factory.newSchema(schema.toFile()); + + Validator validator = xsd.newValidator(); + validator.setErrorHandler(new ErrorHandler() { + + @Override + public void warning(SAXParseException exception) { + result.warning(null, message(exception)); + } + + @Override + public void error(SAXParseException exception) { + result.error(null, message(exception)); + } + + @Override + public void fatalError(SAXParseException exception) { + result.error(null, message(exception)); + } + + private String message(SAXParseException exception) { + return "line " + exception.getLineNumber() + ": " + exception.getMessage(); + } + }); + validator.validate(new DOMSource(new ReqIFWriter().buildDocument(document))); + + } catch (SAXException e) { + result.error(null, "Schema validation failed: " + e.getMessage()); + } + return result; + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ValidationIssue.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ValidationIssue.java new file mode 100644 index 0000000..8bfdf25 --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ValidationIssue.java @@ -0,0 +1,48 @@ +package de.uni_stuttgart.ils.reqif4j.validate; + +/** + * A single finding of {@link ReqIFValidator}. + */ +public class ValidationIssue { + + public enum Severity { + /** The document is invalid; writing it produces a broken file. */ + ERROR, + /** The document is usable but violates a recommendation. */ + WARNING + } + + private final Severity severity; + private final String elementID; + private final String message; + + public ValidationIssue(Severity severity, String elementID, String message) { + this.severity = severity; + this.elementID = elementID; + this.message = message; + } + + public Severity getSeverity() { + return this.severity; + } + + /** + * @return the IDENTIFIER of the element the issue was found on, or null + */ + public String getElementID() { + return this.elementID; + } + + public String getMessage() { + return this.message; + } + + public boolean isError() { + return this.severity == Severity.ERROR; + } + + @Override + public String toString() { + return this.severity + (this.elementID == null ? "" : " [" + this.elementID + "]") + ": " + this.message; + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ValidationResult.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ValidationResult.java new file mode 100644 index 0000000..870cd0a --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/validate/ValidationResult.java @@ -0,0 +1,74 @@ +package de.uni_stuttgart.ils.reqif4j.validate; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * The findings of a validation run. + */ +public class ValidationResult { + + private final List issues = new ArrayList(); + + void add(ValidationIssue.Severity severity, String elementID, String message) { + this.issues.add(new ValidationIssue(severity, elementID, message)); + } + + void error(String elementID, String message) { + add(ValidationIssue.Severity.ERROR, elementID, message); + } + + void warning(String elementID, String message) { + add(ValidationIssue.Severity.WARNING, elementID, message); + } + + public List getIssues() { + return Collections.unmodifiableList(this.issues); + } + + public List getErrors() { + List errors = new ArrayList(); + for (ValidationIssue issue : this.issues) { + if (issue.isError()) { + errors.add(issue); + } + } + return errors; + } + + /** + * @return true if the document has no errors (warnings are tolerated) + */ + public boolean isValid() { + return getErrors().isEmpty(); + } + + /** + * Throws if the document has errors; useful before writing. + */ + public ValidationResult throwIfInvalid() { + + if (!isValid()) { + StringBuilder message = new StringBuilder("ReqIF document is invalid:"); + for (ValidationIssue issue : getErrors()) { + message.append("\n ").append(issue); + } + throw new IllegalStateException(message.toString()); + } + return this; + } + + @Override + public String toString() { + + if (this.issues.isEmpty()) { + return "no issues"; + } + StringBuilder text = new StringBuilder(); + for (ValidationIssue issue : this.issues) { + text.append(issue).append('\n'); + } + return text.toString(); + } +} diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java index 2d03975..8c704e1 100644 --- a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFWriter.java @@ -1,6 +1,5 @@ package de.uni_stuttgart.ils.reqif4j.write; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.OutputStream; import java.io.StringWriter; @@ -11,20 +10,16 @@ import java.util.Comparator; import java.util.List; -import javax.xml.parsers.DocumentBuilder; -import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import javax.xml.transform.OutputKeys; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerException; -import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; -import org.xml.sax.SAXException; import de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinition; import de.uni_stuttgart.ils.reqif4j.attributes.AttributeDefinitionEnumeration; @@ -40,11 +35,14 @@ import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFCoreContent; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFDocument; import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFHeader; +import de.uni_stuttgart.ils.reqif4j.specification.RelationGroup; import de.uni_stuttgart.ils.reqif4j.specification.SpecHierarchy; import de.uni_stuttgart.ils.reqif4j.specification.SpecObject; import de.uni_stuttgart.ils.reqif4j.specification.SpecRelation; import de.uni_stuttgart.ils.reqif4j.specification.SpecType; import de.uni_stuttgart.ils.reqif4j.specification.Specification; +import de.uni_stuttgart.ils.reqif4j.util.SecureXml; +import de.uni_stuttgart.ils.reqif4j.util.XhtmlParser; /** * Serializes a parsed {@link ReqIFDocument} back to ReqIF XML. @@ -138,6 +136,11 @@ public Document buildDocument(ReqIFDocument document) { } root.appendChild(coreContent(xml, document.getCoreContent())); + // tool extensions are copied verbatim + for (Node extension : document.getToolExtensions()) { + root.appendChild(xml.importNode(extension, true)); + } + return xml; } @@ -199,6 +202,14 @@ private Element coreContent(Document xml, ReqIFCoreContent content) { } reqifContent.appendChild(specRelations); + if (!content.getRelationGroups().isEmpty()) { + Element relationGroups = element(xml, ReqIFConst.SPEC_RELATION_GROUPS); + for (RelationGroup relationGroup : content.getRelationGroups().values()) { + relationGroups.appendChild(relationGroup(xml, relationGroup)); + } + reqifContent.appendChild(relationGroups); + } + Element specifications = element(xml, ReqIFConst.SPECIFICATIONS); for (Specification specification : content.getSpecifications().values()) { specifications.appendChild(specification(xml, specification)); @@ -224,6 +235,7 @@ private Element datatype(Document xml, Datatype datatype) { Element definition = element(xml, elementName); definition.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(datatype.getID())); definition.setAttribute(ReqIFConst.LONG_NAME, nullToEmpty(datatype.getName())); + appendAlternativeID(xml, definition, datatype.getAlternativeID()); if (datatype instanceof DatatypeInteger) { DatatypeInteger integer = (DatatypeInteger) datatype; @@ -267,14 +279,21 @@ private Element enumValue(Document xml, DatatypeEnumerationValue value) { private Element specType(Document xml, SpecType specType) { + // A spec type kind the parser does not model must keep its original + // element name; writing it as a SPEC-OBJECT-TYPE would silently change + // the document's meaning. String elementName = specType.getType(); if (elementName == null || ReqIFConst.UNDEFINED.equals(elementName)) { + elementName = specType.getSourceElementName(); + } + if (elementName == null) { elementName = ReqIFConst.SPEC_OBJECT_TYPE; } Element type = element(xml, elementName); type.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(specType.getID())); type.setAttribute(ReqIFConst.LONG_NAME, nullToEmpty(specType.getName())); + appendAlternativeID(xml, type, specType.getAlternativeID()); Element specAttributes = element(xml, ReqIFConst.SPEC_ATTRIBUTES); for (AttributeDefinition definition : specType.getAttributeDefinitions().values()) { @@ -295,6 +314,10 @@ private Element attributeDefinition(Document xml, AttributeDefinition definition return null; } String elementName = ReqIFElements.attributeDefinition(datatype.getType()); + if (elementName == null) { + // datatype kinds the parser does not model: keep the original name + elementName = definition.getSourceElementName(); + } if (elementName == null) { return null; } @@ -302,6 +325,7 @@ private Element attributeDefinition(Document xml, AttributeDefinition definition Element attributeDefinition = element(xml, elementName); attributeDefinition.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(definition.getID())); attributeDefinition.setAttribute(ReqIFConst.LONG_NAME, nullToEmpty(definition.getName())); + appendAlternativeID(xml, attributeDefinition, definition.getAlternativeID()); if (definition instanceof AttributeDefinitionEnumeration && ((AttributeDefinitionEnumeration) definition).isMultiValued()) { @@ -309,8 +333,11 @@ private Element attributeDefinition(Document xml, AttributeDefinition definition } Element type = element(xml, ReqIFConst.TYPE); - Element datatypeRef = element(xml, ReqIFElements.datatypeDefinitionRef( - ReqIFElements.datatypeDefinition(datatype.getType()))); + String datatypeElement = ReqIFElements.datatypeDefinition(datatype.getType()); + if (datatypeElement == null) { + datatypeElement = datatype.getSourceElementName(); + } + Element datatypeRef = element(xml, ReqIFElements.datatypeDefinitionRef(datatypeElement)); datatypeRef.setTextContent(nullToEmpty(datatype.getID())); type.appendChild(datatypeRef); attributeDefinition.appendChild(type); @@ -357,6 +384,7 @@ private Element specObject(Document xml, SpecObject specObject) { Element element = element(xml, ReqIFConst.SPEC_OBJECT); element.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(specObject.getID())); + appendAlternativeID(xml, element, specObject.getAlternativeID()); element.appendChild(values(xml, specObject.getAttributes())); element.appendChild(typeRef(xml, ReqIFConst.SPEC_OBJECT_TYPE, specObject.getSpecTypeID())); return element; @@ -366,6 +394,7 @@ private Element specRelation(Document xml, SpecRelation specRelation) { Element element = element(xml, ReqIFConst.SPEC_RELATION); element.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(specRelation.getID())); + appendAlternativeID(xml, element, specRelation.getAlternativeID()); element.appendChild(values(xml, specRelation.getAttributes())); element.appendChild(typeRef(xml, ReqIFConst.SPEC_RELATION_TYPE, specRelation.getRelationTypeRef())); element.appendChild(objectRef(xml, ReqIFConst.SOURCE, specRelation.getSourceObjID())); @@ -378,6 +407,7 @@ private Element specification(Document xml, Specification specification) { Element element = element(xml, ReqIFConst.SPECIFICATION); element.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(specification.getID())); element.setAttribute(ReqIFConst.LONG_NAME, nullToEmpty(specification.getName())); + appendAlternativeID(xml, element, specification.getAlternativeID()); element.appendChild(values(xml, specification.getAttributes())); element.appendChild(typeRef(xml, ReqIFConst.SPECIFICATION_TYPE, specification.getSpecTypeID())); @@ -401,6 +431,7 @@ private Element specHierarchy(Document xml, SpecHierarchy hierarchy) { Element element = element(xml, ReqIFConst.SPEC_HIERARCHY); element.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(hierarchy.getSpecHierarchyID())); + appendAlternativeID(xml, element, hierarchy.getAlternativeID()); if (hierarchy.getSpecObject() != null) { element.appendChild(objectRef(xml, ReqIFConst.OBJECT, hierarchy.getSpecObjectID())); @@ -457,6 +488,10 @@ private Element attributeValue(Document xml, AttributeValue attributeValue) { } String elementName = ReqIFElements.attributeValue(datatypeCategory); + if (elementName == null) { + // datatype kinds the parser does not model: keep the original name + elementName = attributeValue.getSourceElementName(); + } if (elementName == null) { return null; } @@ -517,30 +552,17 @@ private Node xhtmlContent(Document xml, AttributeValue attributeValue) { if (value == null || value.toString().isEmpty()) { return null; } - return xml.importNode(parseXhtml(value.toString()), true); + return xml.importNode(XhtmlParser.parseDiv(value.toString()), true); } - private Node parseXhtml(String markup) { - - String namespaced = markup.startsWith("" + markup + ""; - try { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - return factory.newDocumentBuilder() - .parse(new ByteArrayInputStream(namespaced.getBytes(StandardCharsets.UTF_8))) - .getDocumentElement(); - } catch (SAXException | IOException | ParserConfigurationException e) { - throw new ReqIFWriteException("XHTML attribute value is not well-formed: " + markup, e); - } - } - - private Element definitionRef(Document xml, AttributeDefinition definition) { Element definitionElement = element(xml, ReqIFConst.DEFINITION); - Element ref = element(xml, ReqIFElements.attributeDefinitionRef(definition.getDataType().getType())); + String refName = ReqIFElements.attributeDefinitionRef(definition.getDataType().getType()); + if (refName == null && definition.getSourceElementName() != null) { + refName = definition.getSourceElementName() + "-REF"; + } + Element ref = element(xml, refName); ref.setTextContent(nullToEmpty(definition.getID())); definitionElement.appendChild(ref); return definitionElement; @@ -564,6 +586,54 @@ private Element objectRef(Document xml, String wrapper, String specObjectID) { return element; } + private Element relationGroup(Document xml, RelationGroup relationGroup) { + + Element element = element(xml, ReqIFConst.RELATION_GROUP); + element.setAttribute(ReqIFConst.IDENTIFIER, nullToEmpty(relationGroup.getID())); + element.setAttribute(ReqIFConst.LONG_NAME, nullToEmpty(relationGroup.getName())); + appendAlternativeID(xml, element, relationGroup.getAlternativeID()); + + element.appendChild(wrappedRef(xml, ReqIFConst.TYPE, ReqIFConst.RELATION_GROUP_TYPE_REF, + relationGroup.getRelationGroupTypeRef())); + element.appendChild(wrappedRef(xml, ReqIFConst.SOURCE_SPECIFICATION, ReqIFConst.SPECIFICATION_REF, + relationGroup.getSourceSpecificationRef())); + element.appendChild(wrappedRef(xml, ReqIFConst.TARGET_SPECIFICATION, ReqIFConst.SPECIFICATION_REF, + relationGroup.getTargetSpecificationRef())); + + if (!relationGroup.getSpecRelationRefs().isEmpty()) { + Element relations = element(xml, ReqIFConst.SPEC_RELATIONS); + for (String ref : relationGroup.getSpecRelationRefs()) { + Element relationRef = element(xml, ReqIFConst.SPEC_RELATION_REF); + relationRef.setTextContent(ref); + relations.appendChild(relationRef); + } + element.appendChild(relations); + } + return element; + } + + private Element wrappedRef(Document xml, String wrapper, String refName, String id) { + + Element element = element(xml, wrapper); + Element ref = element(xml, refName); + ref.setTextContent(nullToEmpty(id)); + element.appendChild(ref); + return element; + } + + /** + * ALTERNATIVE-ID is the first child of an identifiable element. + */ + private void appendAlternativeID(Document xml, Element parent, String alternativeID) { + + if (alternativeID == null || alternativeID.isEmpty()) { + return; + } + Element element = element(xml, ReqIFConst.ALTERNATIVE_ID); + element.setAttribute(ReqIFConst.IDENTIFIER, alternativeID); + parent.appendChild(element); + } + private Element element(Document xml, String name) { return xml.createElementNS(REQIF_NAMESPACE, name); } @@ -584,10 +654,7 @@ private static String nullToEmpty(String value) { private Document newDocument() { try { - DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); - factory.setNamespaceAware(true); - DocumentBuilder builder = factory.newDocumentBuilder(); - return builder.newDocument(); + return SecureXml.newDocumentBuilder().newDocument(); } catch (ParserConfigurationException e) { throw new ReqIFWriteException("Failed to create an XML document", e); } @@ -595,7 +662,7 @@ private Document newDocument() { private Transformer transformer() { try { - Transformer transformer = TransformerFactory.newInstance().newTransformer(); + Transformer transformer = SecureXml.newTransformer(); transformer.setOutputProperty(OutputKeys.ENCODING, StandardCharsets.UTF_8.name()); transformer.setOutputProperty(OutputKeys.INDENT, this.indent ? "yes" : "no"); if (this.indent) { diff --git a/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFzWriter.java b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFzWriter.java new file mode 100644 index 0000000..edbc8ac --- /dev/null +++ b/src/main/java/de/uni_stuttgart/ils/reqif4j/write/ReqIFzWriter.java @@ -0,0 +1,163 @@ +package de.uni_stuttgart.ils.reqif4j.write; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFDocument; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFz; + +/** + * Writes a .reqifz archive: one or more ReqIF documents together with the + * images they reference. + * + * Image entries are stored under the path the XHTML {@code object} elements + * reference (forward slashes), so a written archive resolves its images the + * same way a tool-generated one does: + * + *
+ * Map<String, byte[]> pictures = Map.of("files/diagram.png", pngBytes);
+ * new ReqIFzWriter().write(document, "spec.reqif", pictures, Path.of("spec.reqifz"));
+ * 
+ * + * An archive that was read can be re-packed without consuming its picture + * streams, because {@link ReqIFz} keeps the extracted files: + * + *
+ * try (ReqIFz source = new ReqIFz("in.reqifz")) {
+ *     new ReqIFzWriter().write(source, Path.of("out.reqifz"));
+ * }
+ * 
+ */ +public class ReqIFzWriter { + + private final ReqIFWriter documentWriter; + + public ReqIFzWriter() { + this(new ReqIFWriter()); + } + + /** + * @param documentWriter writer used for the contained ReqIF documents + */ + public ReqIFzWriter(ReqIFWriter documentWriter) { + this.documentWriter = documentWriter; + } + + + /** + * Writes a single document without images. + */ + public void write(ReqIFDocument document, String entryName, Path archive) throws IOException { + write(document, entryName, null, archive); + } + + /** + * Writes a single document together with its images. + * + * @param entryName name of the ReqIF entry inside the archive, + * e.g. {@code spec.reqif} + * @param pictures image data keyed by the archive path referenced from the + * XHTML object elements, e.g. {@code files/diagram.png}; + * may be null + */ + public void write(ReqIFDocument document, String entryName, Map pictures, Path archive) + throws IOException { + + Map documents = new LinkedHashMap(); + documents.put(normalizeEntryName(entryName), document); + write(documents, pictures, archive); + } + + /** + * Writes several documents and their images into one archive. + */ + public void write(Map documents, Map pictures, Path archive) + throws IOException { + + if (documents == null || documents.isEmpty()) { + throw new ReqIFWriteException("A .reqifz archive must contain at least one ReqIF document"); + } + + // Collect and check the names first: a zip cannot hold the same entry + // twice, and the raw ZipException would not say which name clashed. + Map entries = new LinkedHashMap(); + for (Map.Entry document : documents.entrySet()) { + addEntry(entries, normalizeEntryName(document.getKey()), documentBytes(document.getValue())); + } + if (pictures != null) { + for (Map.Entry picture : pictures.entrySet()) { + addEntry(entries, normalizeEntryName(picture.getKey()), picture.getValue()); + } + } + + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(archive))) { + for (Map.Entry entry : entries.entrySet()) { + writeEntry(zip, entry.getKey(), entry.getValue()); + } + } + } + + /** + * Re-packs an archive that was read: the documents are serialized from the + * object model (so modifications are included) while the images are copied + * from the extracted files. + */ + public void write(ReqIFz source, Path archive) throws IOException { + + Map pictures = new LinkedHashMap(); + for (Map.Entry picture : source.getPictureFiles().entrySet()) { + pictures.put(picture.getKey(), Files.readAllBytes(picture.getValue().toPath())); + } + write(source.getReqIFDocuments(), pictures, archive); + } + + + private static void addEntry(Map entries, String name, byte[] content) { + + if (entries.containsKey(name)) { + throw new ReqIFWriteException("Archive entry '" + name + "' is added twice; " + + "document and picture names must be unique within the archive"); + } + entries.put(name, content); + } + + private byte[] documentBytes(ReqIFDocument document) throws IOException { + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + this.documentWriter.write(document, (OutputStream) bytes); + return bytes.toByteArray(); + } + + private static void writeEntry(ZipOutputStream zip, String name, byte[] content) throws IOException { + + zip.putNextEntry(new ZipEntry(name)); + if (content != null) { + zip.write(content); + } + zip.closeEntry(); + } + + /** + * Zip entries always use forward slashes, independent of the platform the + * archive is written on. + */ + private static String normalizeEntryName(String name) { + + if (name == null || name.isBlank()) { + throw new ReqIFWriteException("Archive entry name must not be empty"); + } + String normalized = name.replace('\\', '/'); + if (normalized.startsWith("/") || normalized.contains("../")) { + throw new ReqIFWriteException("Archive entry name must be a relative path without '..': " + name); + } + return normalized; + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/ModelCoverageTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/ModelCoverageTest.java new file mode 100644 index 0000000..48ff620 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/ModelCoverageTest.java @@ -0,0 +1,116 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; +import de.uni_stuttgart.ils.reqif4j.specification.RelationGroup; +import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriter; + +/** + * ReqIF elements the parser used to ignore: ALTERNATIVE-ID on identifiable + * elements, relation groups and tool extensions. They must be readable and + * survive a round trip, otherwise writing a document silently drops content. + */ +class ModelCoverageTest { + + private ReqIF original; + private ReqIF roundTripped; + + @BeforeEach + void roundTrip(@TempDir Path tempDir) throws Exception { + this.original = new ReqIF(TestFixtures.writeDefaultFixture(tempDir).toString()); + + Path out = tempDir.resolve("written.reqif"); + new ReqIFWriter().write(original.getReqIFDocument(), out); + this.roundTripped = new ReqIF(out.toString()); + } + + + @Test + void alternativeIdsAreRead() { + assertEquals("alt-dt-string", original.getReqIFCoreContent().getDatatype("dt-string").getAlternativeID()); + assertEquals("alt-st-req", original.getReqIFCoreContent().getSpecType("st-req").getAlternativeID()); + assertEquals("alt-so-1", original.getReqIFCoreContent().getSpecObject("so-1").getAlternativeID()); + assertEquals("alt-spec-1", original.getReqIFCoreContent().getSpecification("spec-1").getAlternativeID()); + } + + @Test + void elementsWithoutAlternativeIdReportNull() { + assertNull(original.getReqIFCoreContent().getSpecObject("so-2").getAlternativeID()); + assertNull(original.getReqIFCoreContent().getDatatype("dt-bool").getAlternativeID()); + } + + @Test + void alternativeIdsSurviveTheRoundTrip() { + assertEquals("alt-dt-string", roundTripped.getReqIFCoreContent().getDatatype("dt-string").getAlternativeID()); + assertEquals("alt-st-req", roundTripped.getReqIFCoreContent().getSpecType("st-req").getAlternativeID()); + assertEquals("alt-so-1", roundTripped.getReqIFCoreContent().getSpecObject("so-1").getAlternativeID()); + assertEquals("alt-spec-1", roundTripped.getReqIFCoreContent().getSpecification("spec-1").getAlternativeID()); + assertEquals("alt-rg-1", roundTripped.getReqIFCoreContent().getRelationGroup("rg-1").getAlternativeID()); + } + + @Test + void relationGroupIsRead() { + RelationGroup group = original.getReqIFCoreContent().getRelationGroup("rg-1"); + + assertNotNull(group, "SPEC-RELATION-GROUPS were formerly ignored entirely"); + assertEquals("System to Software", group.getName()); + assertEquals("st-relgroup", group.getRelationGroupTypeRef()); + assertEquals("spec-1", group.getSourceSpecificationRef()); + assertEquals("spec-1", group.getTargetSpecificationRef()); + assertEquals(List.of("sr-1"), group.getSpecRelationRefs()); + } + + @Test + void relationGroupSurvivesTheRoundTrip() { + RelationGroup group = roundTripped.getReqIFCoreContent().getRelationGroup("rg-1"); + + assertNotNull(group); + assertEquals("System to Software", group.getName()); + assertEquals("st-relgroup", group.getRelationGroupTypeRef()); + assertEquals(List.of("sr-1"), group.getSpecRelationRefs()); + } + + @Test + void relationGroupTypeKeepsItsKind() { + assertEquals(ReqIFConst.RELATION_GROUP_TYPE, + original.getReqIFCoreContent().getSpecType("st-relgroup").getType(), + "a RELATION-GROUP-TYPE must not be mistaken for a spec object type"); + assertEquals(ReqIFConst.RELATION_GROUP_TYPE, + roundTripped.getReqIFCoreContent().getSpecType("st-relgroup").getType(), + "and it must be written back under the right element name"); + } + + @Test + void toolExtensionsAreKept() { + assertEquals(1, original.getReqIFDocument().getToolExtensions().size(), + "TOOL-EXTENSIONS must be captured"); + } + + @Test + void toolExtensionsSurviveTheRoundTrip() { + assertEquals(1, roundTripped.getReqIFDocument().getToolExtensions().size(), + "tool extensions must be written back verbatim"); + + String xml = new ReqIFWriter().toXml(original.getReqIFDocument()); + assertTrue(xml.contains("REQ-IF-TOOL-EXTENSION"), "the extension element must appear in the output"); + assertTrue(xml.contains("view=\"table\""), "the extension content must be copied unchanged: " + xml); + } + + @Test + void writingStaysIdempotentWithTheNewElements() { + assertEquals(new ReqIFWriter().toXml(original.getReqIFDocument()), + new ReqIFWriter().toXml(roundTripped.getReqIFDocument())); + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/PrefixedNamespaceTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/PrefixedNamespaceTest.java new file mode 100644 index 0000000..0635500 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/PrefixedNamespaceTest.java @@ -0,0 +1,152 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValueEnumeration; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; +import de.uni_stuttgart.ils.reqif4j.specification.SpecHierarchy; +import de.uni_stuttgart.ils.reqif4j.specification.SpecObject; +import de.uni_stuttgart.ils.reqif4j.specification.Specification; +import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriter; + +/** + * The ReqIF elements themselves may sit in a prefixed namespace + * ({@code }) instead of the default one. Such a document used to + * fail with "Document contains no CORE-CONTENT", because elements were looked + * up by their qualified name. + */ +class PrefixedNamespaceTest { + + /** The shared fixture, with every ReqIF element moved to a "rif" prefix. */ + private static String prefixedFixture() { + + return TestFixtures.REQIF_FIXTURE + .replace("", "") + // prefix all remaining ReqIF elements (upper case names), but + // leave the xhtml ones and the xml declaration alone + .replaceAll("<(/?)(?!rif:|xhtml:|myTool:|\\?|!)([A-Z][A-Z0-9-]*)", "<$1rif:$2"); + } + + private ReqIF prefixed; + + @BeforeEach + void parsePrefixedDocument(@TempDir Path tempDir) throws Exception { + prefixed = new ReqIF(TestFixtures.write(tempDir, "prefixed.reqif", prefixedFixture()).toString()); + } + + + @Test + void headerIsRead() { + assertEquals("header-1", prefixed.getReqIFHeader().getID()); + assertEquals("TestDoc", prefixed.getReqIFHeader().getTitle()); + assertEquals("reqif4j", prefixed.getReqIFHeader().getToolID()); + assertEquals("2026-07-23T10:00:00Z", prefixed.getReqIFHeader().getCreationTime()); + assertEquals("Tester", prefixed.getReqIFHeader().getAuthor()); + } + + @Test + void datatypesAreRead() { + assertEquals(prefixedIdsOf(), List.copyOf(prefixed.getReqIFCoreContent().getDatatypes().keySet())); + assertEquals(ReqIFConst.ENUMERATION, prefixed.getReqIFCoreContent().getDatatype("dt-enum").getType()); + assertEquals("alt-dt-string", prefixed.getReqIFCoreContent().getDatatype("dt-string").getAlternativeID()); + } + + private static List prefixedIdsOf() { + return List.of("dt-string", "dt-bool", "dt-int", "dt-int-unbounded", "dt-string-unbounded", + "dt-date", "dt-xhtml", "dt-custom", "dt-enum"); + } + + @Test + void specTypesAndTheirKindsAreRead() { + assertEquals(ReqIFConst.SPEC_OBJECT_TYPE, prefixed.getReqIFCoreContent().getSpecType("st-req").getType()); + assertEquals(ReqIFConst.SPEC_RELATION_TYPE, prefixed.getReqIFCoreContent().getSpecType("st-rel").getType()); + assertEquals(ReqIFConst.RELATION_GROUP_TYPE, + prefixed.getReqIFCoreContent().getSpecType("st-relgroup").getType()); + assertEquals(ReqIFConst.SPECIFICATION_TYPE, + prefixed.getReqIFCoreContent().getSpecType("st-spec").getType()); + } + + @Test + void specObjectValuesAreRead() { + SpecObject so1 = prefixed.getReqIFCoreContent().getSpecObject("so-1"); + + assertEquals("First requirement", so1.getAttribute("Title")); + assertEquals(5, so1.getAttribute("Priority")); + + AttributeValueEnumeration colors = (AttributeValueEnumeration) so1.getAttributes().get("Colors"); + assertEquals(List.of("Red", "Green"), colors.getValues()); + } + + @Test + void xhtmlContentIsRead() { + String description = (String) prefixed.getReqIFCoreContent().getSpecObject("so-1").getAttribute("Description"); + + assertTrue(description.contains("span content"), description); + assertTrue(description.contains("files/image.png"), description); + } + + @Test + void relationsAndGroupsAreRead() { + assertEquals("so-1", prefixed.getReqIFCoreContent().getSpecRelation("sr-1").getSourceObjID()); + assertEquals("so-2", prefixed.getReqIFCoreContent().getSpecRelation("sr-1").getTargetObjID()); + + assertNotNull(prefixed.getReqIFCoreContent().getRelationGroup("rg-1")); + assertEquals(List.of("sr-1"), + prefixed.getReqIFCoreContent().getRelationGroup("rg-1").getSpecRelationRefs()); + } + + @Test + void specificationHierarchyIsRead() { + Specification spec = prefixed.getReqIFCoreContent().getSpecification("spec-1"); + + assertEquals("Main Spec", spec.getName()); + List children = spec.getChildren(); + assertEquals(1, children.size()); + assertEquals("so-1", children.get(0).getSpecObjectID()); + assertEquals("so-2", children.get(0).getChildren().get(0).getSpecObjectID()); + } + + @Test + void toolExtensionsAreRead() { + assertEquals(1, prefixed.getReqIFDocument().getToolExtensions().size()); + } + + @Test + void prefixedAndDefaultNamespaceYieldTheSameContent(@TempDir Path tempDir) throws Exception { + ReqIF plain = new ReqIF(TestFixtures.writeDefaultFixture(tempDir).toString()); + + // The writer always emits the ReqIF elements in the default namespace, + // so the whole core content must render identically. Tool extensions are + // excluded here: they are copied verbatim and therefore keep the prefix + // of the source document (see the test below). + assertEquals(coreContentOf(new ReqIFWriter().toXml(plain.getReqIFDocument())), + coreContentOf(new ReqIFWriter().toXml(prefixed.getReqIFDocument())), + "a prefixed document must produce the same content as the default-namespace one"); + } + + @Test + void toolExtensionsKeepThePrefixOfTheSourceDocument() { + String xml = new ReqIFWriter().toXml(prefixed.getReqIFDocument()); + + assertTrue(xml.contains("rif:TOOL-EXTENSIONS"), + "tool extensions are not interpreted, so they are copied unchanged: " + xml); + assertTrue(xml.contains("view=\"table\""), "their content must survive: " + xml); + } + + /** @return everything up to and including the closing CORE-CONTENT tag */ + private static String coreContentOf(String xml) { + return xml.substring(0, xml.indexOf("") + "".length()); + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFValidatorTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFValidatorTest.java new file mode 100644 index 0000000..5bf04e7 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFValidatorTest.java @@ -0,0 +1,192 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.uni_stuttgart.ils.reqif4j.attributes.AttributeValue; +import de.uni_stuttgart.ils.reqif4j.build.ReqIFBuilder; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFDocument; +import de.uni_stuttgart.ils.reqif4j.specification.RelationGroup; +import de.uni_stuttgart.ils.reqif4j.specification.SpecObject; +import de.uni_stuttgart.ils.reqif4j.validate.ReqIFValidator; +import de.uni_stuttgart.ils.reqif4j.validate.ValidationIssue; +import de.uni_stuttgart.ils.reqif4j.validate.ValidationResult; + +/** + * The validator catches referential problems before a document is written. + * The OMG XSD is not bundled, so this is a model-level check; XSD validation + * is available separately against a schema the caller supplies. + */ +class ReqIFValidatorTest { + + private static ValidationResult validate(ReqIFDocument document) { + return new ReqIFValidator().validate(document); + } + + private static boolean hasError(ValidationResult result, String fragment) { + return result.getErrors().stream().anyMatch(issue -> issue.getMessage().contains(fragment)); + } + + /** A minimal, valid document built with the builder. */ + private static ReqIFBuilder validBuilder() { + return ReqIFBuilder.create() + .header(h -> h.id("hdr-1").title("Spec").toolID("reqif4j")) + .stringDatatype("dt-string", "String", 255) + .enumerationDatatype("dt-enum", "Color", e -> e + .value("ev-red", "Red", "1") + .value("ev-blue", "Blue", "2")) + .specObjectType("st-req", "Requirement Type", t -> t + .stringAttribute("ad-title", "Title", "dt-string") + .enumerationAttribute("ad-color", "Colors", "dt-enum", false)) + .specificationType("st-spec", "Spec Type", t -> { }) + .specRelationType("st-rel", "satisfies", t -> { }) + .specObject("so-1", "st-req", o -> o.set("ad-title", "First")) + .specObject("so-2", "st-req") + .specRelation("sr-1", "st-rel", "so-1", "so-2") + .specification("spec-1", "Main", "st-spec", s -> s.child("sh-1", "so-1")); + } + + + @Test + void parsedFixtureIsValid(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF(TestFixtures.writeDefaultFixture(tempDir).toString()); + + ValidationResult result = validate(reqif.getReqIFDocument()); + + assertTrue(result.isValid(), "the fixture must validate cleanly, got:\n" + result); + } + + @Test + void generatedDocumentIsValid() { + assertTrue(validate(validBuilder().build()).isValid()); + } + + @Test + void missingSpecObjectOfARelationIsReported() { + ReqIFDocument document = validBuilder().build(); + // remove the relation target after building + document.getCoreContent().getSpecObjects().remove("so-2"); + + ValidationResult result = validate(document); + + assertFalse(result.isValid()); + assertTrue(hasError(result, "Relation target so-2 is not a known spec object"), result.toString()); + } + + @Test + void unknownSpecHierarchyTargetIsReported() { + ReqIFDocument document = validBuilder().build(); + document.getCoreContent().getSpecObjects().remove("so-1"); + + ValidationResult result = validate(document); + + assertTrue(hasError(result, "not declared in SPEC-OBJECTS"), result.toString()); + } + + @Test + void attributeValueFromAForeignSpecTypeIsReported() { + ReqIFDocument document = validBuilder().build(); + + // move a value of so-1 onto a spec object of a different type + SpecObject so1 = document.getCoreContent().getSpecObject("so-1"); + AttributeValue title = so1.getAttributes().get("Title"); + document.getCoreContent().addSpecType( + new de.uni_stuttgart.ils.reqif4j.specification.SpecObjectType("st-other", "Other Type")); + document.getCoreContent().addSpecObject(new SpecObject("so-3", + document.getCoreContent().getSpecType("st-other"), List.of(title))); + + ValidationResult result = validate(document); + + assertTrue(hasError(result, "does not belong to spec type st-other"), result.toString()); + } + + @Test + void singleValuedEnumWithSeveralValuesIsReported() { + ReqIFDocument document = validBuilder() + .specObject("so-multi", "st-req", o -> o.setEnum("ad-color", "ev-red", "ev-blue")) + .build(); + + ValidationResult result = validate(document); + + assertTrue(hasError(result, "is not MULTI-VALUED but has 2 values"), result.toString()); + } + + @Test + void identifierUsedTwiceAcrossCategoriesIsReported() { + ReqIFDocument document = validBuilder().build(); + // reuse the datatype id for a relation group + document.getCoreContent().addRelationGroup( + new RelationGroup("dt-string", "Clash", "st-rel", "spec-1", "spec-1", List.of())); + + ValidationResult result = validate(document); + + assertTrue(hasError(result, "Identifier is used twice"), result.toString()); + } + + @Test + void relationGroupWithUnknownReferencesIsReported() { + ReqIFDocument document = validBuilder().build(); + document.getCoreContent().addRelationGroup( + new RelationGroup("rg-1", "Group", "st-nope", "spec-nope", "spec-1", List.of("sr-nope"))); + + ValidationResult result = validate(document); + + assertTrue(hasError(result, "references type st-nope"), result.toString()); + assertTrue(hasError(result, "source specification spec-nope"), result.toString()); + assertTrue(hasError(result, "references relation sr-nope"), result.toString()); + } + + @Test + void missingHeaderIsOnlyAWarning() { + ReqIFDocument document = new ReqIFDocument(null, + validBuilder().build().getCoreContent()); + + ValidationResult result = validate(document); + + assertTrue(result.isValid(), "a missing header must not make the document invalid"); + assertEquals(1, result.getIssues().size()); + assertEquals(ValidationIssue.Severity.WARNING, result.getIssues().get(0).getSeverity()); + } + + @Test + void throwIfInvalidReportsEveryError() { + ReqIFDocument document = validBuilder().build(); + document.getCoreContent().getSpecObjects().remove("so-2"); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> validate(document).throwIfInvalid()); + + assertTrue(failure.getMessage().contains("ReqIF document is invalid")); + assertTrue(failure.getMessage().contains("so-2"), failure.getMessage()); + } + + @Test + void schemaValidationRunsAgainstASuppliedXsd(@TempDir Path tempDir) throws Exception { + // a deliberately narrow schema: the root element must be REQ-IF + String xsd = """ + + + + + """; + Path schema = TestFixtures.write(tempDir, "narrow.xsd", xsd); + + ValidationResult result = new ReqIFValidator() + .validateAgainstSchema(validBuilder().build(), schema); + + assertFalse(result.isValid(), + "a document whose root the schema does not declare must be reported"); + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFzWriterTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFzWriterTest.java new file mode 100644 index 0000000..6001070 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/ReqIFzWriterTest.java @@ -0,0 +1,171 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFz; +import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriteException; +import de.uni_stuttgart.ils.reqif4j.write.ReqIFzWriter; + +/** + * Writing .reqifz archives: images travel with the document, and an archive + * that was read can be re-packed without consuming its picture streams. + */ +class ReqIFzWriterTest { + + private static final byte[] PNG = {(byte) 0x89, 'P', 'N', 'G', 1, 2, 3}; + + private static List entriesOf(Path archive) throws IOException { + List names = new ArrayList<>(); + try (ZipFile zip = new ZipFile(archive.toFile())) { + zip.stream().forEach(entry -> names.add(entry.getName())); + } + return names; + } + + private static byte[] entryBytes(Path archive, String name) throws IOException { + try (ZipFile zip = new ZipFile(archive.toFile()); + InputStream in = zip.getInputStream(new ZipEntry(name))) { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + in.transferTo(bytes); + return bytes.toByteArray(); + } + } + + /** A .reqifz built by hand, as a tool would produce it. */ + private static Path createSourceArchive(Path dir) throws IOException { + Path archive = dir.resolve("source.reqifz"); + try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(archive.toFile()))) { + zos.putNextEntry(new ZipEntry("spec.reqif")); + zos.write(TestFixtures.REQIF_FIXTURE.getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("files/image.png")); + zos.write(PNG); + zos.closeEntry(); + } + return archive; + } + + + @Test + void archiveContainsDocumentAndPictures(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF(TestFixtures.writeDefaultFixture(tempDir).toString()); + Path archive = tempDir.resolve("out.reqifz"); + + new ReqIFzWriter().write(reqif.getReqIFDocument(), "spec.reqif", + Map.of("files/image.png", PNG), archive); + + assertTrue(entriesOf(archive).contains("spec.reqif")); + assertTrue(entriesOf(archive).contains("files/image.png"), + "images must be stored under the path the XHTML object references"); + assertArrayEquals(PNG, entryBytes(archive, "files/image.png")); + } + + @Test + void writtenArchiveIsReadableByTheParser(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF(TestFixtures.writeDefaultFixture(tempDir).toString()); + Path archive = tempDir.resolve("readable.reqifz"); + new ReqIFzWriter().write(reqif.getReqIFDocument(), "spec.reqif", + Map.of("files/image.png", PNG), archive); + + try (ReqIFz read = new ReqIFz(archive.toString())) { + assertEquals(1, read.getReqIFDocumentsCount()); + assertEquals("First requirement", read.getReqIFDocuments().get("spec.reqif") + .getCoreContent().getSpecObject("so-1").getAttribute("Title")); + + InputStream picture = read.getPictureInputStream("spec.reqif", "files/image.png"); + assertNotNull(picture, "the image must be resolvable by its object data URI"); + } + } + + @Test + void archiveCanBeRepackedWithoutConsumingTheStreams(@TempDir Path tempDir) throws Exception { + Path source = createSourceArchive(tempDir); + Path target = tempDir.resolve("repacked.reqifz"); + + try (ReqIFz read = new ReqIFz(source.toString())) { + new ReqIFzWriter().write(read, target); + + // the source object stays usable afterwards + assertNotNull(read.getPictureInputStream("spec.reqif", "files/image.png")); + } + + assertTrue(entriesOf(target).contains("spec.reqif")); + assertArrayEquals(PNG, entryBytes(target, "files/image.png"), + "pictures must be copied from the extracted files, not from consumed streams"); + + try (ReqIFz repacked = new ReqIFz(target.toString())) { + assertEquals("First requirement", repacked.getReqIFDocuments().get("spec.reqif") + .getCoreContent().getSpecObject("so-1").getAttribute("Title")); + } + } + + @Test + void severalDocumentsCanShareOneArchive(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF(TestFixtures.writeDefaultFixture(tempDir).toString()); + + Map documents = new LinkedHashMap<>(); + documents.put("a.reqif", reqif.getReqIFDocument()); + documents.put("sub/b.reqif", reqif.getReqIFDocument()); + + Path archive = tempDir.resolve("multi.reqifz"); + new ReqIFzWriter().write(documents, null, archive); + + assertTrue(entriesOf(archive).containsAll(List.of("a.reqif", "sub/b.reqif"))); + + try (ReqIFz read = new ReqIFz(archive.toString())) { + assertEquals(2, read.getReqIFDocumentsCount()); + } + } + + @Test + void backslashesInEntryNamesAreNormalized(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF(TestFixtures.writeDefaultFixture(tempDir).toString()); + Path archive = tempDir.resolve("slashes.reqifz"); + + new ReqIFzWriter().write(reqif.getReqIFDocument(), "spec.reqif", + Map.of("files\\image.png", PNG), archive); + + assertTrue(entriesOf(archive).contains("files/image.png"), + "zip entries always use forward slashes: " + entriesOf(archive)); + } + + @Test + void invalidEntryNamesAreRejected(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF(TestFixtures.writeDefaultFixture(tempDir).toString()); + Path archive = tempDir.resolve("bad.reqifz"); + + assertThrows(ReqIFWriteException.class, () -> new ReqIFzWriter() + .write(reqif.getReqIFDocument(), "../escape.reqif", archive), + "entry names must not escape the archive"); + assertThrows(ReqIFWriteException.class, () -> new ReqIFzWriter() + .write(reqif.getReqIFDocument(), "", archive)); + } + + @Test + void emptyArchiveIsRejected(@TempDir Path tempDir) { + assertThrows(ReqIFWriteException.class, () -> new ReqIFzWriter() + .write(Map.of(), null, tempDir.resolve("empty.reqifz"))); + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/SelfReviewFixesTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/SelfReviewFixesTest.java new file mode 100644 index 0000000..cd37a10 --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/SelfReviewFixesTest.java @@ -0,0 +1,205 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.uni_stuttgart.ils.reqif4j.build.ReqIFBuildException; +import de.uni_stuttgart.ils.reqif4j.build.ReqIFBuilder; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFDocument; +import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriteException; +import de.uni_stuttgart.ils.reqif4j.write.ReqIFzWriter; + +/** + * Findings of the self review: + * 4 - the builder silently overwrote duplicate identifiers, + * 5 - markup starting with "div" (e.g. divider) was mangled by string surgery, + * 6 - the XML parsers of the write path were not hardened, + * 7 - a duplicate archive entry surfaced as a raw ZipException. + */ +class SelfReviewFixesTest { + + private static ReqIFBuilder minimal() { + return ReqIFBuilder.create() + .header(h -> h.id("hdr").toolID("reqif4j")) + .stringDatatype("dt", "String", 255) + .xhtmlDatatype("dt-xhtml", "XHTML") + .specObjectType("st", "Type", t -> t + .stringAttribute("ad", "Title", "dt") + .xhtmlAttribute("ad-text", "Text", "dt-xhtml")) + .specificationType("st-spec", "Spec Type", t -> { }); + } + + + // ---------------------------------------------------------- finding 4 + + @Test + void duplicateDatatypeIdIsRejected() { + ReqIFBuildException failure = assertThrows(ReqIFBuildException.class, () -> ReqIFBuilder.create() + .stringDatatype("dt", "A", 10) + .stringDatatype("dt", "B", 20)); + + assertTrue(failure.getMessage().contains("already used by a datatype"), failure.getMessage()); + } + + @Test + void duplicateSpecObjectIdIsRejected() { + assertThrows(ReqIFBuildException.class, () -> minimal() + .specObject("so", "st", o -> o.set("ad", "first")) + .specObject("so", "st", o -> o.set("ad", "second"))); + } + + @Test + void identifierClashAcrossCategoriesIsRejected() { + assertThrows(ReqIFBuildException.class, () -> minimal().specObject("dt", "st"), + "a spec object must not reuse a datatype identifier"); + assertThrows(ReqIFBuildException.class, () -> minimal() + .specObject("so", "st") + .specification("so", "Main", "st-spec", s -> { })); + } + + @Test + void duplicateAttributeDefinitionAndHierarchyIdsAreRejected() { + assertThrows(ReqIFBuildException.class, () -> ReqIFBuilder.create() + .stringDatatype("dt", "String", 10) + .specObjectType("st", "Type", t -> t + .stringAttribute("ad", "A", "dt") + .stringAttribute("ad", "B", "dt"))); + + assertThrows(ReqIFBuildException.class, () -> minimal() + .specObject("so-1", "st") + .specObject("so-2", "st") + .specification("spec", "Main", "st-spec", s -> s + .child("sh", "so-1") + .child("sh", "so-2"))); + } + + @Test + void duplicateEnumValueIsRejected() { + assertThrows(ReqIFBuildException.class, () -> ReqIFBuilder.create() + .enumerationDatatype("dt-enum", "Color", e -> e + .value("ev", "Red", "1") + .value("ev", "Blue", "2"))); + } + + @Test + void blankIdentifierIsRejected() { + assertThrows(ReqIFBuildException.class, () -> ReqIFBuilder.create().stringDatatype("", "A", 10)); + } + + @Test + void distinctIdentifiersStillWork() { + ReqIFDocument document = minimal() + .specObject("so-1", "st", o -> o.set("ad", "first")) + .specObject("so-2", "st", o -> o.set("ad", "second")) + .build(); + + assertEquals(2, document.getCoreContent().getSpecObjects().size()); + } + + + // ---------------------------------------------------------- finding 5 + + @Test + void markupWhoseRootMerelyStartsWithDivIsNotMangled() { + ReqIFDocument document = minimal() + .specObject("so", "st", o -> o.setXhtml("ad-text", "x")) + .build(); + + String text = (String) document.getCoreContent().getSpecObject("so").getAttribute("Text"); + assertEquals("
x
", text, + "a divider element must be wrapped, not turned into a broken div"); + } + + @Test + void surroundingDivIsStillReused() { + ReqIFDocument document = minimal() + .specObject("so", "st", o -> o.setXhtml("ad-text", "

a

")) + .build(); + + assertEquals("

a

", + document.getCoreContent().getSpecObject("so").getAttribute("Text"), + "an existing div must not be wrapped a second time"); + } + + @Test + void severalTopLevelElementsAreWrapped() { + ReqIFDocument document = minimal() + .specObject("so", "st", o -> o.setXhtml("ad-text", "

a

b

")) + .build(); + + assertEquals("

a

b

", + document.getCoreContent().getSpecObject("so").getAttribute("Text")); + } + + @Test + void malformedMarkupStillFails() { + assertThrows(IllegalArgumentException.class, () -> minimal() + .specObject("so", "st", o -> o.setXhtml("ad-text", "

unclosed"))); + } + + + // ---------------------------------------------------------- finding 6 + + @Test + void doctypeInAGeneratedXhtmlValueIsRejected(@TempDir Path tempDir) throws Exception { + Path secret = tempDir.resolve("secret.txt"); + Files.writeString(secret, "TOP-SECRET-CONTENT"); + + String evil = "]>" + + "

&xxe;

"; + + assertThrows(IllegalArgumentException.class, () -> minimal() + .specObject("so", "st", o -> o.setXhtml("ad-text", evil)), + "the XHTML parser must reject DOCTYPEs instead of resolving entities"); + } + + @Test + void xxeIsNotResolvedInAParsedDocument(@TempDir Path tempDir) throws Exception { + Path secret = tempDir.resolve("secret.txt"); + Files.writeString(secret, "TOP-SECRET-CONTENT"); + + String xxe = "\n" + + "]>\n" + + "&xxe;"; + Path file = TestFixtures.write(tempDir, "xxe.reqif", xxe); + + assertThrows(RuntimeException.class, () -> new ReqIF(file.toString())); + } + + + // ---------------------------------------------------------- finding 7 + + @Test + void duplicateArchiveEntryGivesAClearError(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF(TestFixtures.writeDefaultFixture(tempDir).toString()); + + ReqIFWriteException failure = assertThrows(ReqIFWriteException.class, () -> new ReqIFzWriter() + .write(reqif.getReqIFDocument(), "spec.reqif", + Map.of("spec.reqif", new byte[] {1}), tempDir.resolve("dup.reqifz"))); + + assertTrue(failure.getMessage().contains("spec.reqif"), failure.getMessage()); + assertTrue(failure.getMessage().contains("added twice"), failure.getMessage()); + } + + @Test + void noArchiveIsLeftBehindOnADuplicateEntry(@TempDir Path tempDir) throws Exception { + ReqIF reqif = new ReqIF(TestFixtures.writeDefaultFixture(tempDir).toString()); + Path archive = tempDir.resolve("dup.reqifz"); + + assertThrows(ReqIFWriteException.class, () -> new ReqIFzWriter() + .write(reqif.getReqIFDocument(), "spec.reqif", Map.of("spec.reqif", new byte[] {1}), archive)); + + assertFalse(Files.exists(archive), + "the name clash must be detected before the archive file is created"); + } +} diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/TestFixtures.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/TestFixtures.java index e7b6cb4..869bf2e 100644 --- a/src/test/java/de/uni_stuttgart/ils/reqif4j/TestFixtures.java +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/TestFixtures.java @@ -31,7 +31,9 @@ private TestFixtures() { - + + + @@ -61,6 +63,7 @@ private TestFixtures() { + dt-string @@ -89,6 +92,9 @@ private TestFixtures() { + + + @@ -99,6 +105,7 @@ private TestFixtures() { + st-req @@ -143,8 +150,18 @@ private TestFixtures() { so-2 + + + + st-relgroup + spec-1 + spec-1 + sr-1 + + + st-spec @@ -165,6 +182,11 @@ private TestFixtures() { + + + + +
"""; diff --git a/src/test/java/de/uni_stuttgart/ils/reqif4j/UnmodelledElementsTest.java b/src/test/java/de/uni_stuttgart/ils/reqif4j/UnmodelledElementsTest.java new file mode 100644 index 0000000..5bb1eca --- /dev/null +++ b/src/test/java/de/uni_stuttgart/ils/reqif4j/UnmodelledElementsTest.java @@ -0,0 +1,128 @@ +package de.uni_stuttgart.ils.reqif4j; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIF; +import de.uni_stuttgart.ils.reqif4j.reqif.ReqIFConst; +import de.uni_stuttgart.ils.reqif4j.write.ReqIFWriter; + +/** + * ReqIF kinds this parser does not model explicitly must survive a round trip + * instead of being dropped (attribute definitions and values) or silently + * turned into something else (spec types written as SPEC-OBJECT-TYPE). + */ +class UnmodelledElementsTest { + + /** Fixture with a custom datatype, an attribute of it, and a custom spec type. */ + private static String fixtureWithUnmodelledKinds() { + return TestFixtures.REQIF_FIXTURE + .replace("", + "" + + "dt-custom" + + "" + + "") + .replace("", + "" + + "ad-custom" + + "" + + "") + .replace("" + + "dt-custom"), xml); + assertTrue(xml.contains("ad-custom"), xml); + } + + @Test + void unmodelledSpecTypeKeepsItsElementName(@TempDir Path tempDir) throws Exception { + ReqIF original = new ReqIF( + TestFixtures.write(tempDir, "in.reqif", fixtureWithUnmodelledKinds()).toString()); + String xml = new ReqIFWriter().toXml(original.getReqIFDocument()); + + assertTrue(xml.contains("