For our Cardshifter Project we wanted to be able to load card definitions from file.
A card is defined as:
- Having an unique id.
- Having a set of resources, which are integers identified by a string.
- Having a set of attributes, which are strings identified by a string.
In the implementation every card gets mapped to an Entity
in our Entity-Component Framework, however that is to be considered an implementation detail and is not the intended focus of this review.
This code is also accompanied with tests, but as there are lots of tests, they require Entity-Component Framework knowledge and are all accompanied with their own XML file, they are also not included.
You can review all code at our Github project page on the develop branch and the tests can be found here.
Then now follows the implementation code:
CardLoadingException class
public class CardLoadingException extends Exception { private static final long serialVersionUID = 64626564562564598L; public CardLoadingException() { } public CardLoadingException(final String message) { super(message); } public CardLoadingException(final Throwable cause) { super(cause); } public CardLoadingException(final String message, final Throwable cause) { super(message, cause); } }
UncheckedCardLoadingException class
class UncheckedCardLoadingException extends RuntimeException { private static final long serialVersionUID = 6644614691255149498L; UncheckedCardLoadingException() { } UncheckedCardLoadingException(final String message) { super(message); } UncheckedCardLoadingException(final Throwable cause) { super(cause); } UncheckedCardLoadingException(final String message, final Throwable cause) { super(message, cause); } }
CardLoader interface
/** * An interface to be implemented by cardloaders. * * @author Frank van Heeswijk * @param <I> The input file type of the card loader on each individual load action */ public interface CardLoader<I> { /** * Loads cards from the given input resource. * * This is done by mapping the input resources to the given resources. * * @param input The input resource * @param entitySupplier A supplier function that supplies a new entity * @param resources The resources to be transformed to, may b enull to indicate no resources present * @param attributes The attributes to be transformed to, may be null to indicate no attributes present * @return A collection of Entity instances * @throws CardLoadingException If an error occured while loading the cards or a resource could not be mapped */ Collection<Entity> loadCards(I input, Supplier<Entity> entitySupplier, ECSResource[] resources, ECSAttribute[] attributes) throws CardLoadingException; }
CardLoaderHelper class
final class CardLoaderHelper { private CardLoaderHelper() { throw new UnsupportedOperationException(); } /** * Sanitizes the input of a tag, which can be a tag for example a resource or an attribute. * * The tag will be converted to lower case, trimmed and underscores will be removed. * * @param tag The tag to be sanitized * @return The sanitized tag */ static String sanitizeTag(final String tag) { return tag.toLowerCase(Locale.ENGLISH).trim().replace("_", ""); } /** * Returns the tags that are required when loading cards. * * @return The required tags */ static List<String> requiredTags() { return Arrays.asList("id"); } }
XmlCardLoader class
public class XmlCardLoader implements CardLoader<Path> { @Override public Collection<Entity> loadCards(final Path path, final Supplier<Entity> entitySupplier, final ECSResource[] resources, final ECSAttribute[] attributes) throws CardLoadingException { Objects.requireNonNull(path, "path"); Objects.requireNonNull(entitySupplier, "entitySupplier"); List<ECSResource> resourcesList = (resources == null) ? Arrays.asList() : Arrays.asList(resources); List<ECSAttribute> attributesList = (attributes == null) ? Arrays.asList() : Arrays.asList(attributes); try { SAXBuilder saxBuilder = new SAXBuilder(); Document document = saxBuilder.build(path.toFile()); XMLOutputter xmlOutputter = new XMLOutputter(Format.getCompactFormat().setExpandEmptyElements(true)); String unformattedXmlString = xmlOutputter.outputString(document); JacksonXmlModule xmlModule = new JacksonXmlModule(); xmlModule.setDefaultUseWrapper(false); ObjectMapper xmlMapper = new XmlMapper(xmlModule); CardInfo cardInfo = xmlMapper.readValue(unformattedXmlString, CardInfo.class); List<String> tags = Stream.concat(resourcesList.stream(), attributesList.stream()) .map(ecsElement -> sanitizeTag(ecsElement.toString())) .collect(Collectors.toList()); if (requiredTags().stream().anyMatch(tags::contains)) { throw new UncheckedCardLoadingException("Tags " + requiredTags() + " are required by default you cannot submit them in the resources or attributes."); } List<String> duplicateTags = tags.stream() .collect(Collectors.groupingBy(i -> i)) .entrySet() .stream() .filter(entry -> entry.getValue().size() > 1) .map(Entry::getKey) .collect(Collectors.toList()); if (!duplicateTags.isEmpty()) { throw new UncheckedCardLoadingException("Tags " + duplicateTags + " have been input multiple times, this is not allowed."); } Map<String, ECSResource> ecsResourcesMap = resourcesList.stream() .collect(Collectors.toMap(ecsResource -> sanitizeTag(ecsResource.toString()), i -> i)); Map<String, ECSAttribute> ecsAttributesMap = attributesList.stream() .collect(Collectors.toMap(ecsAttribute -> sanitizeTag(ecsAttribute.toString()), i -> i)); List<Card> cardList = cardInfo.getCards().getCards(); List<String> duplicateIds = cardList.stream() .collect(Collectors.groupingBy(Card::getId)) .entrySet() .stream() .filter(entry -> entry.getValue().size() > 1) .map(Entry::getKey) .collect(Collectors.toList()); if (!duplicateIds.isEmpty()) { throw new UncheckedCardLoadingException("Card ids " + duplicateIds + " are duplicaties, this is not allowed."); } return cardList.stream() .map(card -> { Entity entity = entitySupplier.get(); entity.addComponent(new IdComponent(card.getId())); ECSResourceMap resourceMap = ECSResourceMap.createFor(entity); ECSAttributeMap attributeMap = ECSAttributeMap.createFor(entity); card.getElements().forEach((sanitizedTag, value) -> { if (ecsResourcesMap.containsKey(sanitizedTag)) { resourceMap.set(ecsResourcesMap.get(sanitizedTag), Integer.parseInt(value.toString())); } else if (ecsAttributesMap.containsKey(sanitizedTag)) { attributeMap.set(ecsAttributesMap.get(sanitizedTag), value.toString()); } else { throw new UncheckedCardLoadingException("Element " + sanitizedTag + " has not been found in the supplied resource and attribute mappings where card id = " + card.getId()); } }); return entity; }) .collect(Collectors.toList()); } catch (UncheckedCardLoadingException ex) { throw new CardLoadingException(ex.getMessage(), ex.getCause()); } catch (Exception ex) { throw new CardLoadingException(ex); } } private static class CardInfo { @JacksonXmlProperty(localName = "Cards") private Cards cards; public Cards getCards() { if (cards == null) { cards = new Cards(); //fix for Cards instance being null on empty cards list } return cards; } } private static class Cards { @JacksonXmlProperty(localName = "Card") private Card[] cards; public List<Card> getCards() { if (cards == null) { cards = new Card[0]; } return Arrays.asList(cards); } } private static class Card { private String id; private boolean duplicateId = false; private final Map<String, Object> elements = new HashMap<>(); private boolean duplicateElements = false; private final List<String> duplicateElementTags = new ArrayList<>(); @JsonAnySetter private void addElement(final String tag, final Object value) { String sanitizedTag = sanitizeTag(tag); switch (sanitizedTag) { case "id": if (id != null) { duplicateId = true; return; } id = value.toString(); break; default: if (elements.containsKey(sanitizedTag)) { duplicateElements = true; duplicateElementTags.add(sanitizedTag); return; } elements.put(sanitizedTag, value); break; } } public String getId() { if (duplicateId) { throw new UncheckedCardLoadingException("Element id has duplicate entries"); } if (id == null) { throw new UncheckedCardLoadingException("Required element id has not been set"); } return id; } @JsonAnyGetter public Map<String, Object> getElements() { if (duplicateElements) { throw new UncheckedCardLoadingException("Elements " + duplicateElementTags + " have duplicate entries where card id = " + id); } return new HashMap<>(elements); } } }
It is probably also useful to include these interfaces:
ECSResource interface
/** * Interface for Resource types */ public interface ECSResource { /** * Returns the value of this resource associated with the given entity. * * @param entity The entity for which the resource value is to be retrieved * @return The resource value */ default int getFor(final Entity entity) { return ResourceRetriever.forResource(this).getFor(entity); } }
ECSAttribute interface
/** * Interface for Attribute types */ public interface ECSAttribute { /** * Returns the value of this attribute associated with the given entity. * * @param entity The entity for which the attribute value is to be retrieved * @return The attribute value */ default String getFor(final Entity entity) { return AttributeRetriever.forAttribute(this).getFor(entity); } }
The most important external libraries used for dealing with the XML data are JDOM2 for the preprocessing and Jackson for the deserialization.
I can give you the names of the tests to give a hint about for which things I test:
- testLoadNoCards
- testLoadNoCardsNullResourcesAndAttributes
- testLoadOneCard
- testLoadOneCardWithoutUsingECSResourceOrECSAttribute
- testLoadOneCardNoResourcesOrAttributes
- testLoadOneCardWithSpecialCharacters
- testLoadTwoCards
- testLoadFourCardsSanitizedResources
- testLoadFourCardsSanitizedAttributes
- testLoadTwoCardsVerifyIntegerAttribute
- testLoadFourCardsSanitizedResourcesIncorrectHealthResourcesMapping
- testLoadFourCardsSanitizedAttributesIncorrectCreatureTypeAttributesMapping
- testLoadOneCardWithDoubleResource
- testLoadOneCardWithDoubleAttribute
- testLoadOneCardWithDoubleResourceAndAttribute
- testLoadOneCardResourceNotFound
- testLoadOneCardAttributeNotFound
- testLoadOneCardWithDoubleSanitizedResources
- testLoadOneCardWithDoubleSanitizedAttributes
- testLoadTwoCardsWithDuplicateIds
- testLoadNoCardsWithIdResource
- testLoadNoCardsWithIdAttribute
An example implementation can be observed in the following snippet of the test code to load in a file with two cards:
<?xml version="1.0" encoding="UTF-8"?> <CardInfo> <Cards> <Card> <Id>1</Id> <Name>Test 1</Name> <Image>test1.jpg</Image> <CardType>testcard</CardType> <TR1>5</TR1> <TR2>-6</TR2> </Card> <Card> <Id>2</Id> <Name>Test 2</Name> <Image>test2.jpg</Image> <CardType>testcard2</CardType> <TR1>3</TR1> <TR2>-8</TR2> </Card> </Cards> </CardInfo>
Relevant declarations:
private static enum TestResources implements ECSResource { TR1, TR2; } private static enum TestAttributes implements ECSAttribute { NAME, IMAGE, CARDTYPE; }
Relevant test:
@Test public void testLoadTwoCards() throws URISyntaxException, CardLoadingException { Path xmlFile = Paths.get(getClass().getResource("two-cards.xml").toURI()); ECSGame game = new ECSGame(); XmlCardLoader xmlCardLoader = new XmlCardLoader(); Collection<Entity> entities = xmlCardLoader.loadCards(xmlFile, game::newEntity, TestResources.values(), TestAttributes.values()); Entity card = findEntityWithId(entities, "1"); assertEquals("1", card.getComponent(IdComponent.class).getId()); assertEquals("Test 1", TestAttributes.NAME.getFor(card)); assertEquals("test1.jpg", TestAttributes.IMAGE.getFor(card)); assertEquals("testcard", TestAttributes.CARDTYPE.getFor(card)); assertEquals(5, TestResources.TR1.getFor(card)); assertEquals(-6, TestResources.TR2.getFor(card)); Entity card2 = findEntityWithId(entities, "2"); assertEquals("2", card2.getComponent(IdComponent.class).getId()); assertEquals("Test 2", TestAttributes.NAME.getFor(card2)); assertEquals("test2.jpg", TestAttributes.IMAGE.getFor(card2)); assertEquals("testcard2", TestAttributes.CARDTYPE.getFor(card2)); assertEquals(3, TestResources.TR1.getFor(card2)); assertEquals(-8, TestResources.TR2.getFor(card2)); }
I'd like to get a review on all aspects. The XmlCardLoader
class for example has been refactored a few times to incorporate new constraints and I am intending that the refactorings do not leave their marks on the source code.