diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/DefinedRelationships.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/DefinedRelationships.java index 17edc04e..990364e1 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/DefinedRelationships.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/DefinedRelationships.java @@ -17,12 +17,13 @@ public boolean hasRelationship(final String relationshipName) { } public void addRelationship(final RelationshipVectorDefinition relationship) { + String relationshipName = relationship.getName().toLowerCase(); List relationshipsWithThisName = - relationships.get(relationship.getName()); + relationships.get(relationshipName); if (relationshipsWithThisName == null) { // there is no relationship with this name relationshipsWithThisName = new ArrayList<>(); - relationships.put(relationship.getName(), relationshipsWithThisName); + relationships.put(relationshipName, relationshipsWithThisName); } relationshipsWithThisName.add(relationship); diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/ERSchema.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/ERSchema.java index cabc74a1..9ff1ae12 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/ERSchema.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/ERSchema.java @@ -41,7 +41,7 @@ public RelationshipDefinition defineRelationship( RelationshipDefinition relationship = RelationshipDefinition.create( new RelationshipVectorDefinition(from, named, to, of)); - relationships.put(named, relationship); + relationships.put(relationshipKey(from, named, to), relationship); return relationship; } @@ -49,12 +49,11 @@ public boolean hasRelationshipNamed(final String relationshipName) { if (relationshipName == null) { return false; } - if (relationships.containsKey(relationshipName.toLowerCase())) { - return true; - } - // perhaps it is a reverse relationship? for (RelationshipDefinition defn : relationships.values()) { + if (defn.getFromRelationship().getName().equalsIgnoreCase(relationshipName)) { + return true; + } if (defn.isTwoWay()) { if (defn.getReversedRelationship().getName().equalsIgnoreCase(relationshipName)) { return true; @@ -65,6 +64,16 @@ public boolean hasRelationshipNamed(final String relationshipName) { return false; } + private String relationshipKey( + final EntityDefinition from, final String relationshipName, final EntityDefinition to) { + return String.format( + "%s:%s:%s", + from.getName().toLowerCase(), + relationshipName.toLowerCase(), + to.getName().toLowerCase()) + .toLowerCase(); + } + public List getEntityNames() { List names = new ArrayList(); names.addAll(entityDefinitions.keySet()); diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/EntityDefinition.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/EntityDefinition.java index bf29b5eb..bacd545b 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/EntityDefinition.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/EntityDefinition.java @@ -10,6 +10,7 @@ public class EntityDefinition { private int maxInstanceCount; // use -ve for no limit private final String name; private final String plural; + private String description; // TODO: consider adding candidate keys e.g. guid or id // TODO: consider adding composite keys e.g. name and id @@ -17,6 +18,7 @@ public class EntityDefinition { private final DefinedFields fields; private final DefinedRelationships definedRelationships; + private final Map views; private static final int NO_INSTANCE_LIMIT = -1; @@ -30,7 +32,9 @@ public EntityDefinition(String name, String plural, int maxInstanceCount) { this.plural = plural; definedRelationships = new DefinedRelationships(); fields = new DefinedFields(); + views = new HashMap<>(); this.maxInstanceCount = maxInstanceCount; + this.description = ""; // todo: add some validation to report against no primary key having been defined @@ -55,6 +59,19 @@ public String getPlural() { return plural; } + public EntityDefinition withDescription(final String description) { + this.description = description == null ? "" : description; + return this; + } + + public boolean hasDescription() { + return description != null && !description.trim().isEmpty(); + } + + public String getDescription() { + return description; + } + public void addField(Field aField) { fields.addField(aField); } @@ -136,4 +153,32 @@ public boolean hasAnyOfFieldNamesDefined(List fieldNames) { } return false; } + + public EntityViewDefinition defineView(final String viewName) { + final String normalizedName = viewName == null ? "" : viewName.trim(); + if (normalizedName.isEmpty()) { + throw new IllegalArgumentException("View name is required"); + } + if (views.containsKey(normalizedName)) { + throw new IllegalArgumentException( + String.format( + "View %s is already defined for entity %s", normalizedName, getName())); + } + + final EntityViewDefinition view = new EntityViewDefinition(this, normalizedName); + views.put(normalizedName, view); + return view; + } + + public boolean hasViewNamed(final String viewName) { + return views.containsKey(viewName); + } + + public EntityViewDefinition getViewNamed(final String viewName) { + return views.get(viewName); + } + + public Collection getViews() { + return Collections.unmodifiableCollection(views.values()); + } } diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/EntityViewDefinition.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/EntityViewDefinition.java new file mode 100644 index 00000000..1a38ed61 --- /dev/null +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/domain/definitions/EntityViewDefinition.java @@ -0,0 +1,125 @@ +package uk.co.compendiumdev.thingifier.core.domain.definitions; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +public final class EntityViewDefinition { + + private final EntityDefinition entity; + private final String name; + private final Set requestVisibleFields; + private final Set responseVisibleFields; + private final Set inputAllowedFields; + + EntityViewDefinition(final EntityDefinition entity, final String name) { + this.entity = entity; + this.name = name; + this.requestVisibleFields = new HashSet<>(entity.getFieldNames()); + this.responseVisibleFields = new HashSet<>(entity.getFieldNames()); + this.inputAllowedFields = new HashSet<>(entity.getFieldNames()); + } + + public String getName() { + return name; + } + + public EntityDefinition getEntity() { + return entity; + } + + public EntityViewDefinition showFields(final String... fieldNames) { + showRequestFields(fieldNames); + showResponseFields(fieldNames); + return this; + } + + public EntityViewDefinition hideFields(final String... fieldNames) { + hideRequestFields(fieldNames); + hideResponseFields(fieldNames); + return this; + } + + public EntityViewDefinition showRequestFields(final String... fieldNames) { + for (String fieldName : validFieldNames(fieldNames)) { + requestVisibleFields.add(fieldName); + } + return this; + } + + public EntityViewDefinition hideRequestFields(final String... fieldNames) { + for (String fieldName : validFieldNames(fieldNames)) { + requestVisibleFields.remove(fieldName); + } + return this; + } + + public EntityViewDefinition showResponseFields(final String... fieldNames) { + for (String fieldName : validFieldNames(fieldNames)) { + responseVisibleFields.add(fieldName); + } + return this; + } + + public EntityViewDefinition hideResponseFields(final String... fieldNames) { + for (String fieldName : validFieldNames(fieldNames)) { + responseVisibleFields.remove(fieldName); + } + return this; + } + + public EntityViewDefinition allowInputFields(final String... fieldNames) { + for (String fieldName : validFieldNames(fieldNames)) { + inputAllowedFields.add(fieldName); + } + return this; + } + + public EntityViewDefinition disallowInputFields(final String... fieldNames) { + for (String fieldName : validFieldNames(fieldNames)) { + inputAllowedFields.remove(fieldName); + } + return this; + } + + public boolean isRequestVisible(final String fieldName) { + return requestVisibleFields.contains(fieldName); + } + + public boolean isResponseVisible(final String fieldName) { + return responseVisibleFields.contains(fieldName); + } + + public boolean isInputAllowed(final String fieldName) { + return inputAllowedFields.contains(fieldName); + } + + public Set requestVisibleFields() { + return Collections.unmodifiableSet(requestVisibleFields); + } + + public Set responseVisibleFields() { + return Collections.unmodifiableSet(responseVisibleFields); + } + + public Set inputAllowedFields() { + return Collections.unmodifiableSet(inputAllowedFields); + } + + private Set validFieldNames(final String... fieldNames) { + if (fieldNames == null) { + return Set.of(); + } + final Set validNames = new HashSet<>(); + for (String fieldName : fieldNames) { + if (!entity.hasFieldNameDefined(fieldName)) { + throw new IllegalArgumentException( + String.format( + "Field %s is not defined for entity %s", + fieldName, entity.getName())); + } + validNames.add(fieldName); + } + return validNames; + } +} diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/RepositoryQuery.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/RepositoryQuery.java index 44cfa6cc..65387229 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/RepositoryQuery.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/RepositoryQuery.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.List; import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.relationship.RelationshipVectorDefinition; import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; import uk.co.compendiumdev.thingifier.core.repository.EntityInstanceQuery; import uk.co.compendiumdev.thingifier.core.repository.RelationshipRepository; @@ -60,12 +61,29 @@ public RepositoryQuery performQuery(final QueryFilterParams queryParams) { if (spec.hasRelationship()) { wasIntentToMatchInstance = true; - isCollection = true; foundItems = new ArrayList<>( relationshipRepository.listRelated( currentInstance, spec.relationshipName(), queryParams)); resultContainsDefinition = relatedEntityFor(currentInstance, spec.relationshipName()); + + if (shouldReturnRelationshipAsSingleInstance( + currentInstance, spec.relationshipName())) { + isCollection = false; + if (foundItems.isEmpty()) { + currentInstance = null; + lastMatchWasNothing = true; + lastMatchWasInstance = false; + return this; + } + + currentInstance = foundItems.get(0); + lastMatchWasNothing = false; + lastMatchWasInstance = true; + return this; + } + + isCollection = true; lastMatchWasNothing = false; lastMatchWasInstance = false; return this; @@ -120,4 +138,25 @@ private EntityDefinition relatedEntityFor( } return instance.getEntity().related().getRelationships(relationshipName).get(0).getTo(); } + + private boolean shouldReturnRelationshipAsSingleInstance( + final EntityInstance instance, final String relationshipName) { + if (!spec.singleTargetRelationshipsAsInstances()) { + return false; + } + + final RelationshipVectorDefinition relationship = + relationshipDefinitionFor(instance, relationshipName); + return relationship != null + && relationship.getCardinality().hasMaximumLimit() + && relationship.getCardinality().maximumLimit() == 1; + } + + private RelationshipVectorDefinition relationshipDefinitionFor( + final EntityInstance instance, final String relationshipName) { + if (instance.getEntity().related().getRelationships(relationshipName).isEmpty()) { + return null; + } + return instance.getEntity().related().getRelationships(relationshipName).get(0); + } } diff --git a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/RepositoryQuerySpec.java b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/RepositoryQuerySpec.java index 44f54d16..3c85670c 100644 --- a/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/RepositoryQuerySpec.java +++ b/ercoremodel/src/main/java/uk/co/compendiumdev/thingifier/core/query/RepositoryQuerySpec.java @@ -7,26 +7,40 @@ public final class RepositoryQuerySpec { private final EntityDefinition entity; private final String identifier; private final String relationshipName; + private final boolean singleTargetRelationshipsAsInstances; private RepositoryQuerySpec( - final EntityDefinition entity, final String identifier, final String relationshipName) { + final EntityDefinition entity, + final String identifier, + final String relationshipName, + final boolean singleTargetRelationshipsAsInstances) { this.entity = entity; this.identifier = identifier; this.relationshipName = relationshipName; + this.singleTargetRelationshipsAsInstances = singleTargetRelationshipsAsInstances; } public static RepositoryQuerySpec collection(final EntityDefinition entity) { - return new RepositoryQuerySpec(entity, null, null); + return new RepositoryQuerySpec(entity, null, null, true); } public static RepositoryQuerySpec instance( final EntityDefinition entity, final String identifier) { - return new RepositoryQuerySpec(entity, identifier, null); + return new RepositoryQuerySpec(entity, identifier, null, true); } public static RepositoryQuerySpec relationship( final EntityDefinition entity, final String identifier, final String relationshipName) { - return new RepositoryQuerySpec(entity, identifier, relationshipName); + return relationship(entity, identifier, relationshipName, true); + } + + public static RepositoryQuerySpec relationship( + final EntityDefinition entity, + final String identifier, + final String relationshipName, + final boolean singleTargetRelationshipsAsInstances) { + return new RepositoryQuerySpec( + entity, identifier, relationshipName, singleTargetRelationshipsAsInstances); } public EntityDefinition entity() { @@ -48,4 +62,8 @@ public boolean hasIdentifier() { public boolean hasRelationship() { return relationshipName != null; } + + public boolean singleTargetRelationshipsAsInstances() { + return singleTargetRelationshipsAsInstances; + } } diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/EntityRelModelTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/EntityRelModelTest.java index 336f5c7e..11f17a5b 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/EntityRelModelTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/EntityRelModelTest.java @@ -159,6 +159,34 @@ public void canCreateRelationships() { Assertions.assertTrue(erm.hasRelationshipNamed("things")); } + @Test + public void canCreateRelationshipsWithTheSameNameFromDifferentSources() { + EntityRelModel erm = new EntityRelModel(); + EntityDefinition robotModel = erm.createEntityDefinition("robotmodel", "robotmodels"); + EntityDefinition zone = erm.createEntityDefinition("zone", "zones"); + EntityDefinition robot = erm.createEntityDefinition("robot", "robots"); + + erm.createRelationshipDefinition(robotModel, robot, "robots", Cardinality.ONE_TO_MANY()); + erm.createRelationshipDefinition(zone, robot, "robots", new Cardinality(1, 24)); + + Assertions.assertEquals(2, erm.getRelationshipDefinitions().size()); + Assertions.assertTrue(erm.hasRelationshipNamed("robots")); + Assertions.assertEquals( + 1, + erm.getRelationshipDefinitions().stream() + .filter( + relationship -> + relationship.getFromRelationship().getFrom() == robotModel) + .count()); + Assertions.assertEquals( + 1, + erm.getRelationshipDefinitions().stream() + .filter( + relationship -> + relationship.getFromRelationship().getFrom() == zone) + .count()); + } + @Test public void canFindAReversedRelationship() { EntityRelModel erm = new EntityRelModel(); diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/domain/definitions/ThingDefinitionTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/domain/definitions/ThingDefinitionTest.java index 43adf90c..8a0fe1fb 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/domain/definitions/ThingDefinitionTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/domain/definitions/ThingDefinitionTest.java @@ -26,6 +26,59 @@ void entityDefinitionCreation() { System.out.println(eDefn.toString()); } + @Test + void canDescribeEntityDefinition() { + EntityDefinition eDefn = new EntityDefinition("Requirement", "Requirements"); + + Assertions.assertFalse(eDefn.hasDescription()); + Assertions.assertEquals("", eDefn.getDescription()); + + Assertions.assertSame(eDefn, eDefn.withDescription("A requirement in the model.")); + Assertions.assertTrue(eDefn.hasDescription()); + Assertions.assertEquals("A requirement in the model.", eDefn.getDescription()); + + eDefn.withDescription(null); + Assertions.assertFalse(eDefn.hasDescription()); + Assertions.assertEquals("", eDefn.getDescription()); + } + + @Test + void canDefineEntityViewsWithIndependentVisibilityAndInputRules() { + EntityDefinition eDefn = new EntityDefinition("cartitem", "cartitems"); + eDefn.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + eDefn.addFields( + Field.is("productId", FieldType.INTEGER), + Field.is("quantity", FieldType.INTEGER), + Field.is("unitPriceAtAdd", FieldType.FLOAT), + Field.is("stockAtAdd", FieldType.INTEGER)); + + EntityViewDefinition view = + eDefn.defineView("AddedCartItem") + .hideRequestFields("unitPriceAtAdd", "stockAtAdd") + .hideResponseFields("stockAtAdd") + .disallowInputFields("id"); + + Assertions.assertTrue(eDefn.hasViewNamed("AddedCartItem")); + Assertions.assertSame(view, eDefn.getViewNamed("AddedCartItem")); + Assertions.assertTrue(view.isRequestVisible("productId")); + Assertions.assertFalse(view.isRequestVisible("unitPriceAtAdd")); + Assertions.assertTrue(view.isResponseVisible("unitPriceAtAdd")); + Assertions.assertFalse(view.isResponseVisible("stockAtAdd")); + Assertions.assertTrue(view.isInputAllowed("unitPriceAtAdd")); + Assertions.assertFalse(view.isInputAllowed("id")); + } + + @Test + void entityViewsRejectUnknownFieldConfiguration() { + EntityDefinition eDefn = new EntityDefinition("cartitem", "cartitems"); + eDefn.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + + EntityViewDefinition view = eDefn.defineView("AddedCartItem"); + + Assertions.assertThrows( + IllegalArgumentException.class, () -> view.hideFields("missingField")); + } + @Test void addFieldToEntityDefinition() { EntityDefinition eDefn; diff --git a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/TodoManagerQueryEngineTest.java b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/TodoManagerQueryEngineTest.java index ea5d8812..ef29d441 100644 --- a/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/TodoManagerQueryEngineTest.java +++ b/ercoremodel/src/test/java/uk/co/compendiumdev/thingifier/core/query/TodoManagerQueryEngineTest.java @@ -49,7 +49,7 @@ public void createDefinitions() { todoManager .createRelationshipDefinition(project, todo, "tasks", Cardinality.ONE_TO_MANY()) - .whenReversed(Cardinality.ONE_TO_MANY(), "task-of"); + .whenReversed(Cardinality.ONE_TO_ONE(), "task-of"); todoManager.createRelationshipDefinition( project, category, "categories", Cardinality.ONE_TO_MANY()); @@ -143,16 +143,39 @@ public void canQueryRelationships() { Assertions.assertTrue(tasksForProject.contains(paperwork)); Assertions.assertTrue(tasksForProject.contains(filework)); - List projectsForTask = + RepositoryQuery projectForTaskQuery = query( - RepositoryQuerySpec.relationship( - todo, paperwork.getPrimaryKeyValue(), "task-of")) - .getListEntityInstances(); + RepositoryQuerySpec.relationship( + todo, paperwork.getPrimaryKeyValue(), "task-of")); + List projectsForTask = projectForTaskQuery.getListEntityInstances(); + Assertions.assertFalse(projectForTaskQuery.isResultACollection()); + Assertions.assertTrue(projectForTaskQuery.lastMatchWasInstance()); + Assertions.assertEquals(officeWork, projectForTaskQuery.getLastInstance()); Assertions.assertEquals(1, projectsForTask.size()); Assertions.assertTrue(projectsForTask.contains(officeWork)); } + @Test + public void canQuerySingleTargetRelationshipsAsCollectionsWhenRequested() { + EntityInstance officeWork = + store().entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField("title", "Office Work")); + store().relationships().connect(officeWork, "tasks", paperwork); + + RepositoryQuery projectsForTask = + query( + RepositoryQuerySpec.relationship( + todo, paperwork.getPrimaryKeyValue(), "task-of", false)); + + Assertions.assertTrue(projectsForTask.isResultACollection()); + Assertions.assertFalse(projectsForTask.lastMatchWasInstance()); + Assertions.assertEquals(1, projectsForTask.getListEntityInstances().size()); + Assertions.assertTrue(projectsForTask.getListEntityInstances().contains(officeWork)); + } + private RepositoryQuery query(final RepositoryQuerySpec spec) { return new RepositoryQuery(store(), spec).performQuery(); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/Thingifier.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/Thingifier.java index 467f5c25..27611efa 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/Thingifier.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/Thingifier.java @@ -3,6 +3,7 @@ import java.util.*; import uk.co.compendiumdev.thingifier.api.ThingifierRestAPIHandler; import uk.co.compendiumdev.thingifier.api.ermodelconversion.JsonPopulator; +import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiSpec; import uk.co.compendiumdev.thingifier.apiconfig.ApiDocsConfig; import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfig; import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfigProfile; @@ -31,6 +32,7 @@ public final class Thingifier implements AutoCloseable { private String initialParagraph; private final ThingifierApiConfig apiConfig; private final ThingifierApiConfigProfiles apiConfigProfiles; + private final ThingifierApiSpec apiSpec; public Thingifier() { this(new EntityRelModel()); @@ -42,6 +44,7 @@ public Thingifier(final EntityRelModel erm) { initialParagraph = ""; apiConfig = new ThingifierApiConfig(""); apiConfigProfiles = new ThingifierApiConfigProfiles(); + apiSpec = new ThingifierApiSpec(); apiDocsConfig = new ApiDocsConfig(); } @@ -58,6 +61,7 @@ public Thingifier( this.initialParagraph = initialParagraph; this.apiConfig = apiConfig; this.apiConfigProfiles = apiConfigProfiles; + this.apiSpec = new ThingifierApiSpec(); this.apiDocsConfig = apiDocsConfig; } @@ -208,6 +212,10 @@ public ThingifierApiConfigProfiles apiConfigProfiles() { return apiConfigProfiles; } + public ThingifierApiSpec apiSpec() { + return apiSpec; + } + public void configureWithProfile(final ThingifierApiConfigProfile profileToUse) { if (profileToUse == null) { System.out.println("API System Defaults Used"); diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/httpserver/ThingifierHttpApiRoutings.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/httpserver/ThingifierHttpApiRoutings.java index 9d4942f3..cb984d6a 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/httpserver/ThingifierHttpApiRoutings.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/adapter/httpserver/ThingifierHttpApiRoutings.java @@ -126,6 +126,9 @@ public ThingifierHttpApiRoutings( new ApiRoutingDefinitionDocGenerator(thingifier).generate(apiDefn.getPathPrefix()); for (RoutingDefinition defn : routingDefinitions.definitions()) { + if (defn.isDisabled()) { + continue; + } switch (defn.verb()) { case GET: if (defn.status().isReturnedFromCall()) { diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinition.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinition.java index ef6f342a..e262277b 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinition.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinition.java @@ -4,6 +4,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.List; +import java.util.Map; import uk.co.compendiumdev.thingifier.api.response.ResponseHeader; import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; @@ -66,4 +67,45 @@ public boolean hasObjectSchemaNamed(String aName) { public Collection getObjectSchemas() { return objectSchemas.values(); } + + public void updateOptionsAllowHeaders() { + final List verbOrder = + List.of( + RoutingVerb.OPTIONS, + RoutingVerb.GET, + RoutingVerb.HEAD, + RoutingVerb.POST, + RoutingVerb.QUERY, + RoutingVerb.PUT, + RoutingVerb.PATCH, + RoutingVerb.DELETE, + RoutingVerb.TRACE); + final Map> allowedByUrl = new HashMap<>(); + + for (RoutingDefinition route : routings) { + if (route.isHiddenFromDocumentation() || route.isDisabled()) { + continue; + } + if (route.verb() == RoutingVerb.OPTIONS || route.status().isReturnedFromCall()) { + allowedByUrl.computeIfAbsent(route.url(), key -> new ArrayList<>()); + if (!allowedByUrl.get(route.url()).contains(route.verb())) { + allowedByUrl.get(route.url()).add(route.verb()); + } + } + } + + for (RoutingDefinition route : routings) { + if (route.verb() != RoutingVerb.OPTIONS || route.header().isEmpty()) { + continue; + } + final List allowed = allowedByUrl.getOrDefault(route.url(), List.of()); + final String allowHeader = + verbOrder.stream() + .filter(allowed::contains) + .map(Enum::name) + .reduce((left, right) -> left + ", " + right) + .orElse("OPTIONS"); + route.replaceHeader(new ResponseHeader(route.header(), allowHeader)); + } + } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java index ce122ab7..37374a84 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGenerator.java @@ -370,10 +370,11 @@ public ApiRoutingDefinition generate(String apiPathPrefix) { .addRequestUrlParam(entityDefn.getField(uniqueIdFieldName)); for (RelationshipVectorDefinition rel : entityDefn.related().getRelationships()) { - addRoutingsForRelationship(defn, rel); + addRoutingsForRelationship(defn, rel, endPointPrefix); } } + thingifier.apiSpec().applyTo(defn, apiPathPrefix); return defn; } @@ -393,12 +394,26 @@ private Field getUniqueIdField(final EntityDefinition thingDefn) { // } } + private String relatedParameterNameFor(final Field field) { + if (field == null) { + return null; + } + String fieldName = field.getName(); + return "related" + fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1); + } + private void addRoutingsForRelationship( - final ApiRoutingDefinition defn, final RelationshipVectorDefinition relationship) { + final ApiRoutingDefinition defn, + final RelationshipVectorDefinition relationship, + final String endPointPrefix) { String fromName = relationship.getFrom().getName(); String toName = relationship.getTo().getName(); String relationshipName = relationship.getName(); + boolean getReturnsSingle = relationshipReturnsSingleInstance(relationship); + String relationshipGetTargetDescription = getReturnsSingle ? "item" : "items"; + String relationshipGetDocumentationVerb = + getReturnsSingle ? "return the" : "return all the"; final EntityDefinition thingDefn = relationship.getFrom(); @@ -427,17 +442,30 @@ private void addRoutingsForRelationship( fromNameForUrl = thingDefn.getName().toLowerCase(); } - String aUrl = fromNameForUrl + "/" + uniqueIdentifier + "/" + relationshipName; + String aUrl = + endPointPrefix + fromNameForUrl + "/" + uniqueIdentifier + "/" + relationshipName; defn.addRouting( String.format( - "return all the %s items related to %s, with given %s, by the relationship named %s", - toName, fromName, uniqueIdFieldName, relationshipName), + "%s %s %s related to %s, with given %s, by the relationship named %s", + relationshipGetDocumentationVerb, + toName, + relationshipGetTargetDescription, + fromName, + uniqueIdFieldName, + relationshipName), RoutingVerb.GET, aUrl, RoutingStatus.returnedFromCall()) + .addRequestUrlParam(uniqueIdField) .addPossibleStatus( RoutingStatus.returnValue( - 200, String.format("all the related the %s items", toName))); + 200, + String.format( + "%s related %s %s", + getReturnsSingle ? "the" : "all the", + toName, + relationshipGetTargetDescription))) + .returnPayload(200, relationshipGetReturnPayload(relationship)); defn.addRouting( String.format( @@ -446,6 +474,7 @@ private void addRoutingsForRelationship( RoutingVerb.QUERY, aUrl, RoutingStatus.returnedFromCall()) + .addRequestUrlParam(uniqueIdField) .addPossibleStatus( RoutingStatus.returnValue( 200, String.format("all the matching related %s items", toName))) @@ -457,15 +486,24 @@ private void addRoutingsForRelationship( defn.addRouting( String.format( - "headers for the %s items related to %s, with given %s, by the relationship named %s", - toName, fromName, uniqueIdFieldName, relationshipName), + "headers for the %s %s related to %s, with given %s, by the relationship named %s", + toName, + relationshipGetTargetDescription, + fromName, + uniqueIdFieldName, + relationshipName), RoutingVerb.HEAD, aUrl, RoutingStatus.returnedFromCall()) + .addRequestUrlParam(uniqueIdField) .addPossibleStatus( RoutingStatus.returnValue( 200, - String.format("headers for all the related the %s items", toName))); + String.format( + "headers for %s related %s %s", + getReturnsSingle ? "the" : "all the", + toName, + relationshipGetTargetDescription))); defn.addRouting( String.format("show all Options for endpoint of %s", aUrl), @@ -487,6 +525,8 @@ private void addRoutingsForRelationship( RoutingVerb.POST, aUrl, RoutingStatus.returnedFromCall()) + .addRequestUrlParam(uniqueIdField) + .requestPayload(relationship.getTo().getName()) .addPossibleStatus( RoutingStatus.returnValue(201, String.format("created the relationship"))) .addPossibleStatus( @@ -511,14 +551,21 @@ private void addRoutingsForRelationship( "method not allowed", RoutingVerb.PUT, aUrl, RoutingStatus.returnValue(405)); // we should be able to delete a relationship + final Field relatedUniqueIdField = getUniqueIdField(relationship.getTo()); + final String relatedUniqueIdentifier = + relatedUniqueIdField == null + ? uniqueIdentifier + : ":" + relatedParameterNameFor(relatedUniqueIdField); + final String aUrlDelete = - fromNameForUrl + endPointPrefix + + fromNameForUrl + "/" + uniqueIdentifier + "/" + relationshipName + "/" - + uniqueIdentifier; + + relatedUniqueIdentifier; defn.addRouting( String.format( "delete the instance of the relationship named %s between %s and %s using the %s", @@ -526,6 +573,9 @@ private void addRoutingsForRelationship( RoutingVerb.DELETE, aUrlDelete, RoutingStatus.returnedFromCall()) + .addRequestUrlParam(uniqueIdField) + .addRequestUrlParam( + relatedParameterNameFor(relatedUniqueIdField), relatedUniqueIdField) .addPossibleStatus( RoutingStatus.returnValue(204, String.format("deleted the relationship"))) .addPossibleStatus( @@ -568,4 +618,17 @@ private void addRoutingsForRelationship( defn.addRouting( "method not allowed", RoutingVerb.POST, aUrlDelete, RoutingStatus.returnValue(405)); } + + private boolean relationshipReturnsSingleInstance( + final RelationshipVectorDefinition relationship) { + return relationship.getCardinality().hasMaximumLimit() + && relationship.getCardinality().maximumLimit() == 1; + } + + private String relationshipGetReturnPayload(final RelationshipVectorDefinition relationship) { + if (relationshipReturnsSingleInstance(relationship)) { + return relationship.getTo().getName(); + } + return relationship.getTo().getPlural(); + } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java index 7139ee6d..7740c903 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/docgen/RoutingDefinition.java @@ -12,16 +12,21 @@ public class RoutingDefinition { private final RoutingVerb verb; private final String url; private final RoutingStatus routingStatus; - private final ResponseHeader header; + private ResponseHeader header; private String documentation = ""; private boolean isFilterable; private EntityDefinition filterableEntityDefn; private List possibleStatusResponses; private HashMap returnPayload; private String requestPayload; - private List requestUrlParams; + private List requestUrlParams; private HashMap customHeaders; private boolean usesBasicAuth = false; + private boolean usesBearerAuth = false; + private boolean hiddenFromDocumentation = false; + private boolean disabled = false; + private String requestEntityViewName; + private HashMap responseEntityViewNames; public RoutingDefinition( RoutingVerb verb, String url, RoutingStatus routingStatus, ResponseHeader header) { @@ -42,6 +47,8 @@ public RoutingDefinition( returnPayload = new HashMap<>(); requestPayload = null; customHeaders = new HashMap<>(); + requestEntityViewName = null; + responseEntityViewNames = new HashMap<>(); } public RoutingVerb verb() { @@ -83,6 +90,11 @@ public String headerValue() { return header.headerValue; } + public RoutingDefinition replaceHeader(final ResponseHeader header) { + this.header = header; + return this; + } + public String getDocumentation() { return this.documentation; } @@ -149,8 +161,51 @@ public String getRequestPayload() { return requestPayload; } + public RoutingDefinition requestEntityView(final String viewName) { + requestEntityViewName = viewName; + requestPayload("create_" + viewName); + return this; + } + + public boolean hasRequestEntityView() { + return requestEntityViewName != null; + } + + public String getRequestEntityView() { + return requestEntityViewName; + } + + public RoutingDefinition responseEntityView(final int statusCode, final String viewName) { + responseEntityViewNames.put(statusCode, viewName); + returnPayload(statusCode, viewName); + return this; + } + + public boolean hasResponseEntityViewFor(final int statusCode) { + return responseEntityViewNames.containsKey(statusCode); + } + + public String getResponseEntityViewFor(final int statusCode) { + return responseEntityViewNames.get(statusCode); + } + + public Collection returnPayloadStatusCodes() { + return new ArrayList<>(returnPayload.keySet()); + } + public RoutingDefinition addRequestUrlParam(Field aField) { - requestUrlParams.add(aField); + if (aField == null) { + return this; + } + requestUrlParams.add(new RequestUrlParameter(aField.getName(), aField)); + return this; + } + + public RoutingDefinition addRequestUrlParam(final String parameterName, final Field field) { + if (parameterName == null || field == null) { + return this; + } + requestUrlParams.add(new RequestUrlParameter(parameterName, field)); return this; } @@ -159,6 +214,14 @@ public Boolean hasRequestUrlParams() { } public List getRequestUrlParams() { + List fields = new ArrayList<>(); + for (RequestUrlParameter parameter : requestUrlParams) { + fields.add(parameter.field()); + } + return fields; + } + + public List getRequestUrlParameters() { return new ArrayList<>(requestUrlParams); } @@ -191,4 +254,51 @@ public RoutingDefinition secureWithBasicAuth() { public boolean isSecuredByBasicAuth() { return usesBasicAuth; } + + public RoutingDefinition secureWithBearerAuth() { + usesBearerAuth = true; + return this; + } + + public boolean isSecuredByBearerAuth() { + return usesBearerAuth; + } + + public RoutingDefinition hideFromDocumentation() { + hiddenFromDocumentation = true; + return this; + } + + public boolean isHiddenFromDocumentation() { + return hiddenFromDocumentation; + } + + public RoutingDefinition disable() { + disabled = true; + hiddenFromDocumentation = true; + return this; + } + + public boolean isDisabled() { + return disabled; + } + + public static final class RequestUrlParameter { + + private final String name; + private final Field field; + + public RequestUrlParameter(final String name, final Field field) { + this.name = name; + this.field = field; + } + + public String name() { + return name; + } + + public Field field() { + return field; + } + } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ermodelconversion/JsonThing.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ermodelconversion/JsonThing.java index f54d040f..c239ecd0 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ermodelconversion/JsonThing.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ermodelconversion/JsonThing.java @@ -5,6 +5,7 @@ import java.util.*; import uk.co.compendiumdev.thingifier.apiconfig.JsonOutputConfig; import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityViewDefinition; import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; import uk.co.compendiumdev.thingifier.core.domain.definitions.field.instance.FieldValue; @@ -43,6 +44,15 @@ public String asJsonTypedArrayWithContentsUntyped( .toString(); } + public String asJsonTypedArrayWithContentsUntyped( + final List things, + String typeName, + final RelationshipRepository relationships, + final EntityViewDefinition view) { + return asJsonObjectTypedArrayWithContentsUntyped(things, typeName, relationships, view) + .toString(); + } + /* This is suitable for passing through GsonBuilderPretty Printing e.g. to get @@ -74,6 +84,16 @@ public JsonObject asJsonObjectTypedArrayWithContentsUntyped( return arrayObj; } + public JsonObject asJsonObjectTypedArrayWithContentsUntyped( + final List things, + String typeName, + final RelationshipRepository relationships, + final EntityViewDefinition view) { + final JsonObject arrayObj = new JsonObject(); + arrayObj.add(typeName, asJsonArray(things, relationships, view)); + return arrayObj; + } + public JsonObject asJsonObjectTypedDraftArrayWithContentsUntyped( final List things, String typeName) { final JsonObject arrayObj = new JsonObject(); @@ -98,13 +118,20 @@ private JsonArray asJsonArray(final Collection things) { private JsonArray asJsonArray( final Collection things, final RelationshipRepository relationships) { + return asJsonArray(things, relationships, null); + } + + private JsonArray asJsonArray( + final Collection things, + final RelationshipRepository relationships, + final EntityViewDefinition view) { // [{"guid":"bob"}, {"guid":"bob2"}] final JsonArray jsonArray = new JsonArray(); for (EntityInstance thing : things) { - jsonArray.add(asJsonObject(thing, relationships)); + jsonArray.add(asJsonObject(thing, relationships, view)); } // System.out.println(jsonArray.toString()); @@ -168,7 +195,8 @@ public JsonObject asJsonObject(final InstanceFields fields) { return jsonobj; } - private JsonObject asFieldJsonObject(final EntityInstance instance) { + private JsonObject asFieldJsonObject( + final EntityInstance instance, final EntityViewDefinition view) { final JsonObject jsonobj = new JsonObject(); if (instance == null) { @@ -176,6 +204,9 @@ private JsonObject asFieldJsonObject(final EntityInstance instance) { } for (String fieldName : instance.getFieldNames()) { + if (view != null && !view.isResponseVisible(fieldName)) { + continue; + } Field theField = instance.getEntity().getField(fieldName); try { @@ -297,6 +328,13 @@ public JsonObject asJsonObject(final EntityInstance thingInstance) { public JsonObject asJsonObject( final EntityInstance thingInstance, final RelationshipRepository relationshipsPort) { + return asJsonObject(thingInstance, relationshipsPort, null); + } + + public JsonObject asJsonObject( + final EntityInstance thingInstance, + final RelationshipRepository relationshipsPort, + final EntityViewDefinition view) { // todo: I swallowed exception generation in here because I was passing in the 'input' // representations @@ -309,7 +347,7 @@ public JsonObject asJsonObject( return new JsonObject(); } - final JsonObject jsonobj = asFieldJsonObject(thingInstance); + final JsonObject jsonobj = asFieldJsonObject(thingInstance, view); /* "relationships" : [ @@ -424,9 +462,17 @@ public String asJsonTypedArrayWithContentsTyped( final List things, EntityDefinition defn, final RelationshipRepository relationships) { + return asJsonTypedArrayWithContentsTyped(things, defn, relationships, null); + } + + public String asJsonTypedArrayWithContentsTyped( + final List things, + EntityDefinition defn, + final RelationshipRepository relationships, + final EntityViewDefinition view) { final JsonObject arrayObj = new JsonObject(); - arrayObj.add(defn.getPlural(), asJsonArrayInstanceWrapped(things, relationships)); + arrayObj.add(defn.getPlural(), asJsonArrayInstanceWrapped(things, relationships, view)); return arrayObj.toString(); } @@ -438,7 +484,9 @@ public String asJsonTypedArrayWithContentsTyped( * @return */ private JsonArray asJsonArrayInstanceWrapped( - Collection things, final RelationshipRepository relationships) { + Collection things, + final RelationshipRepository relationships, + final EntityViewDefinition view) { // [{"item":{"guid":"bob"}}, {"item":{"guid":"bob2"}}] @@ -447,7 +495,7 @@ private JsonArray asJsonArrayInstanceWrapped( for (EntityInstance thing : things) { JsonObject jsonObj = new JsonObject(); - jsonObj.add(thing.getEntity().getName(), asJsonObject(thing, relationships)); + jsonObj.add(thing.getEntity().getName(), asJsonObject(thing, relationships, view)); jsonArray.add(jsonObj); } @@ -462,9 +510,16 @@ public JsonObject asNamedJsonObject(final EntityInstance instance) { public JsonObject asNamedJsonObject( final EntityInstance instance, final RelationshipRepository relationships) { + return asNamedJsonObject(instance, relationships, null); + } + + public JsonObject asNamedJsonObject( + final EntityInstance instance, + final RelationshipRepository relationships, + final EntityViewDefinition view) { final JsonObject retObj = new JsonObject(); - retObj.add(instance.getEntity().getName(), asJsonObject(instance, relationships)); + retObj.add(instance.getEntity().getName(), asJsonObject(instance, relationships, view)); return retObj; } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ermodelconversion/XmlThing.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ermodelconversion/XmlThing.java index baff36b5..aa3cc52e 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ermodelconversion/XmlThing.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/ermodelconversion/XmlThing.java @@ -4,6 +4,7 @@ import org.json.JSONObject; import org.json.XML; import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityViewDefinition; import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; import uk.co.compendiumdev.thingifier.core.repository.RelationshipRepository; @@ -22,8 +23,15 @@ public String getSingleObjectXml(final EntityInstance instance) { public String getSingleObjectXml( final EntityInstance instance, final RelationshipRepository relationships) { + return getSingleObjectXml(instance, relationships, null); + } + + public String getSingleObjectXml( + final EntityInstance instance, + final RelationshipRepository relationships, + final EntityViewDefinition view) { String parseForXMLOutput = - jsonConvertor.asNamedJsonObject(instance, relationships).toString(); + jsonConvertor.asNamedJsonObject(instance, relationships, view).toString(); // System.out.println(parseForXMLOutput); return XML.toString(new JSONObject(parseForXMLOutput)); } @@ -42,9 +50,17 @@ public String getCollectionOfThings( final List thingsToReturn, final EntityDefinition typeOfThingReturned, final RelationshipRepository relationships) { + return getCollectionOfThings(thingsToReturn, typeOfThingReturned, relationships, null); + } + + public String getCollectionOfThings( + final List thingsToReturn, + final EntityDefinition typeOfThingReturned, + final RelationshipRepository relationships, + final EntityViewDefinition view) { String parseForXMLOutput = jsonConvertor.asJsonTypedArrayWithContentsTyped( - thingsToReturn, typeOfThingReturned, relationships); + thingsToReturn, typeOfThingReturned, relationships, view); String output = XML.toString(new JSONObject(parseForXMLOutput)); diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java index bf89260f..c4535630 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/http/ThingifierHttpApi.java @@ -3,11 +3,25 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.SchemaCatalog; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.ThingifierSchemaCatalog; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.CollectionRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.InstanceRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.RelationshipCollectionRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRoute; +import uk.co.compendiumdev.thingifier.adapter.http.apihandlers.route.ThingRouteMapper; import uk.co.compendiumdev.thingifier.adapter.http.messagehooks.HttpApiRequestHook; import uk.co.compendiumdev.thingifier.adapter.http.messagehooks.HttpApiResponseHook; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; import uk.co.compendiumdev.thingifier.api.ermodelconversion.JsonThing; +import uk.co.compendiumdev.thingifier.api.http.bodyparser.ApiBodyField; import uk.co.compendiumdev.thingifier.api.response.ApiResponse; +import uk.co.compendiumdev.thingifier.api.spec.ThingifierApiRouteRule; +import uk.co.compendiumdev.thingifier.application.schema.RelationshipSpec; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityViewDefinition; public final class ThingifierHttpApi { @@ -75,9 +89,13 @@ private HttpApiResponse handleRequest(final HttpApiRequest request, HttpVerb ver request.removePrefixFromPath(prefix); } + final HttpVerb effectiveVerb = MethodOverrideParser.getEffectiveVerb(request, verb); + if (isDisabledByApiSpec(request, effectiveVerb)) { + return disabledRouteResponse(request); + } + // any pre-request override processing HttpApiResponse httpResponse = runTheHttpApiRequestHooksOn(request); - final HttpVerb effectiveVerb = MethodOverrideParser.getEffectiveVerb(request, verb); // TODO: consider 'validation' hooks which can be used to override/augment validation @@ -86,6 +104,10 @@ private HttpApiResponse handleRequest(final HttpApiRequest request, HttpVerb ver httpResponse = validateRequestSyntax(request, effectiveVerb); } + if (httpResponse == null) { + httpResponse = validateEntityViewInput(request, effectiveVerb); + } + // TODO: consider 'processing' hooks which can be used to override the generic processing // no httpResponse generated after validation so it is not in error @@ -114,6 +136,32 @@ private HttpApiResponse handleRequest(final HttpApiRequest request, HttpVerb ver return runTheHttpApiResponseHooksOn(request, httpResponse); } + private boolean isDisabledByApiSpec(final HttpApiRequest request, final HttpVerb verb) { + return routeRuleFor(request, verb).map(ThingifierApiRouteRule::isDisabled).orElse(false); + } + + private Optional routeRuleFor( + final HttpApiRequest request, final HttpVerb verb) { + try { + return thingifier + .apiSpec() + .ruleFor( + RoutingVerb.valueOf(verb.name()), + request.getPath(), + thingifier.apiConfig().getApiEndPointPrefix()); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + private HttpApiResponse disabledRouteResponse(final HttpApiRequest request) { + return new HttpApiResponse( + request.getHeaders(), + ApiResponse.error404("Could not find any instances with " + request.getPath()), + jsonThing, + thingifier.apiConfig()); + } + /** return an error response if the request is invalid, null if valid */ public HttpApiResponse validateRequestSyntax( final HttpApiRequest request, final HttpVerb verb) { @@ -165,9 +213,112 @@ public ApiResponse routeAndProcessRequest(final HttpApiRequest request, HttpVerb break; } + applyResponseEntityView(request, verb, apiResponse); return apiResponse; } + private HttpApiResponse validateEntityViewInput( + final HttpApiRequest request, final HttpVerb verb) { + if (verb != HttpVerb.POST && verb != HttpVerb.PUT) { + return null; + } + + final Optional matchingRule = routeRuleFor(request, verb); + if (matchingRule.isEmpty() || !matchingRule.get().hasRequestEntityView()) { + return null; + } + + final EntityDefinition entity = targetEntityFor(request.getPath()); + if (entity == null) { + return null; + } + + final String viewName = matchingRule.get().getRequestEntityView(); + if (!entity.hasViewNamed(viewName)) { + return new HttpApiResponse( + request.getHeaders(), + ApiResponse.error( + 500, + String.format( + "Entity view %s is not defined for %s", + viewName, entity.getName())), + jsonThing, + thingifier.apiConfig()); + } + + final EntityViewDefinition view = entity.getViewNamed(viewName); + final List disallowedFields = new ArrayList<>(); + for (ApiBodyField field : + ApiRequestEnvelope.from(request, verb, thingifier.getThingNames()) + .bodyFields() + .topLevelFields()) { + if (entity.hasFieldNameDefined(field.name()) && !view.isInputAllowed(field.name())) { + disallowedFields.add(field.name()); + } + } + + if (disallowedFields.isEmpty()) { + return null; + } + + return new HttpApiResponse( + request.getHeaders(), + ApiResponse.error( + 422, + String.format( + "Fields are not allowed by %s: %s", + viewName, String.join(", ", disallowedFields))), + jsonThing, + thingifier.apiConfig()); + } + + private void applyResponseEntityView( + final HttpApiRequest request, final HttpVerb verb, final ApiResponse apiResponse) { + if (apiResponse == null + || apiResponse.isErrorResponse() + || apiResponse.hasABodyOverride()) { + return; + } + + final Optional matchingRule = routeRuleFor(request, verb); + if (matchingRule.isEmpty()) { + return; + } + + final String viewName = + matchingRule.get().responseEntityViewFor(apiResponse.getStatusCode()); + if (viewName == null || apiResponse.getTypeOfThingReturned() == null) { + return; + } + + final EntityDefinition entity = apiResponse.getTypeOfThingReturned(); + if (entity.hasViewNamed(viewName)) { + apiResponse.usingEntityView(entity.getViewNamed(viewName)); + } + } + + private EntityDefinition targetEntityFor(final String path) { + final SchemaCatalog schema = new ThingifierSchemaCatalog(thingifier); + final ThingRoute route = new ThingRouteMapper(schema).map(path); + if (route instanceof CollectionRoute) { + return schema.definitionWithSingularOrPluralNamed( + ((CollectionRoute) route).entity().name()); + } + if (route instanceof InstanceRoute) { + return schema.definitionWithSingularOrPluralNamed( + ((InstanceRoute) route).entity().name()); + } + if (route instanceof RelationshipCollectionRoute) { + final RelationshipCollectionRoute relationship = (RelationshipCollectionRoute) route; + for (RelationshipSpec spec : relationship.parentEntity().relationships()) { + if (spec.name().equals(relationship.relationshipName())) { + return schema.definitionWithSingularOrPluralNamed(spec.toEntityName()); + } + } + } + return null; + } + public HttpApiResponse get(final HttpApiRequest request) { return handleRequest(request, HttpVerb.GET); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponse.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponse.java index 18592cfb..0ba88850 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponse.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponse.java @@ -5,6 +5,7 @@ import uk.co.compendiumdev.thingifier.api.http.headers.HttpHeadersBlock; import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfig; import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityViewDefinition; import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstance; import uk.co.compendiumdev.thingifier.core.domain.instances.EntityInstanceDraft; import uk.co.compendiumdev.thingifier.core.repository.RelationshipRepository; @@ -28,6 +29,7 @@ public final class ApiResponse { private HttpHeadersBlock headers; private EntityDefinition typeOfResults; + private EntityViewDefinition responseView; private String body; private RelationshipRepository relationshipRepository; @@ -41,6 +43,7 @@ public ApiResponse(final int aStatusCode) { errorMessages = new ArrayList<>(); hasBody = false; body = null; + responseView = null; } public ApiResponse( @@ -224,6 +227,19 @@ public EntityDefinition getTypeOfThingReturned() { return typeOfResults; } + public ApiResponse usingEntityView(final EntityViewDefinition view) { + responseView = view; + return this; + } + + public boolean hasResponseView() { + return responseView != null; + } + + public EntityViewDefinition getResponseView() { + return responseView; + } + public boolean hasABody() { return this.hasBody; } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsJson.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsJson.java index bd320c44..bdddf933 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsJson.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsJson.java @@ -49,7 +49,8 @@ public String getJson() { jsonThing.asJsonTypedArrayWithContentsUntyped( apiResponse.getReturnedInstanceCollection(), typeName, - apiResponse.getRelationshipRepository()); + apiResponse.getRelationshipRepository(), + apiResponse.getResponseView()); } else { if (things.size() == 0) { output = "{}"; @@ -66,7 +67,10 @@ public String getJson() { // return JsonThing.asNamedJsonObject(instance).toString(); return jsonThing - .asJsonObject(instance, apiResponse.getRelationshipRepository()) + .asJsonObject( + instance, + apiResponse.getRelationshipRepository(), + apiResponse.getResponseView()) .toString(); } } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsXml.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsXml.java index 9b4f5e38..e1ccaaa0 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsXml.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/response/ApiResponseAsXml.java @@ -58,7 +58,8 @@ public String getXml() { xmlThing.getCollectionOfThings( thingsToReturn, apiResponse.getTypeOfThingReturned(), - apiResponse.getRelationshipRepository()); + apiResponse.getRelationshipRepository(), + apiResponse.getResponseView()); } catch (Exception e) { // TODO: if this happens then the status code is going to be wrong, should probably // throw an exception instead @@ -78,7 +79,9 @@ public String getXml() { try { output = xmlThing.getSingleObjectXml( - instance, apiResponse.getRelationshipRepository()); + instance, + apiResponse.getRelationshipRepository(), + apiResponse.getResponseView()); } catch (Exception e) { // TODO: if this happens then the status code is going to be wrong output = getErrorMessageXml(e.getMessage()); diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiQueryHandler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiQueryHandler.java index 64388f03..02ff7710 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiQueryHandler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiQueryHandler.java @@ -53,7 +53,7 @@ public ApiResponse handle( } RepositoryQueryResult queryResults = - runtime.queryService().execute(mapping.getQuery(), context.store()); + runtime.queryService().execute(mapping.getQuery(), context.store(), false); return apiMapper .map(url, queryResults) .setHeader( diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java new file mode 100644 index 00000000..b2de2a27 --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiRouteRule.java @@ -0,0 +1,160 @@ +package uk.co.compendiumdev.thingifier.api.spec; + +import java.util.HashMap; +import java.util.Map; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; + +public final class ThingifierApiRouteRule { + + private final RoutingVerb verb; + private final String pathPattern; + private boolean hidden; + private boolean disabled; + private boolean usesBasicAuth; + private boolean usesBearerAuth; + private String documentation; + private String requestPayload; + private String requestEntityView; + private String defaultEntityView; + private Map responseEntityViews; + + ThingifierApiRouteRule(final RoutingVerb verb, final String pathPattern) { + this.verb = verb; + this.pathPattern = pathPattern == null ? "" : pathPattern; + this.hidden = false; + this.disabled = false; + this.usesBasicAuth = false; + this.usesBearerAuth = false; + this.documentation = null; + this.requestPayload = null; + this.requestEntityView = null; + this.defaultEntityView = null; + this.responseEntityViews = new HashMap<>(); + } + + public RoutingVerb verb() { + return verb; + } + + public String pathPattern() { + return pathPattern; + } + + public ThingifierApiRouteRule hide() { + hidden = true; + return this; + } + + public ThingifierApiRouteRule hideFromDocs() { + return hide(); + } + + public ThingifierApiRouteRule disable() { + disabled = true; + return this; + } + + public boolean isHidden() { + return hidden; + } + + public boolean isDisabled() { + return disabled; + } + + public ThingifierApiRouteRule secureWithBasicAuth() { + usesBasicAuth = true; + return this; + } + + public ThingifierApiRouteRule secureWithBearerAuth() { + usesBearerAuth = true; + return this; + } + + public ThingifierApiRouteRule addDocumentation(final String documentation) { + this.documentation = documentation; + return this; + } + + public ThingifierApiRouteRule requestPayload(final String requestPayload) { + this.requestPayload = requestPayload; + return this; + } + + public ThingifierApiRouteRule requestEntityView(final String viewName) { + this.requestEntityView = viewName; + return this; + } + + public ThingifierApiRouteRule responseEntityView(final int statusCode, final String viewName) { + this.responseEntityViews.put(statusCode, viewName); + return this; + } + + public ThingifierApiRouteRule entityView(final String viewName) { + this.requestEntityView = viewName; + this.defaultEntityView = viewName; + return this; + } + + public boolean hasRequestEntityView() { + return requestEntityView != null; + } + + public String getRequestEntityView() { + return requestEntityView; + } + + public String responseEntityViewFor(final int statusCode) { + if (responseEntityViews.containsKey(statusCode)) { + return responseEntityViews.get(statusCode); + } + if (defaultEntityView != null && statusCode >= 200 && statusCode < 300) { + return defaultEntityView; + } + return null; + } + + void applyTo(final RoutingDefinition route) { + if (hidden) { + route.hideFromDocumentation(); + } + if (disabled) { + route.disable(); + } + if (usesBasicAuth) { + route.secureWithBasicAuth(); + } + if (usesBearerAuth) { + route.secureWithBearerAuth(); + } + if (documentation != null) { + route.addDocumentation(documentation); + } + if (requestPayload != null) { + route.requestPayload(requestPayload); + } + if (requestEntityView != null) { + route.requestEntityView(requestEntityView); + } + for (Map.Entry responseView : responseEntityViews.entrySet()) { + route.responseEntityView(responseView.getKey(), responseView.getValue()); + } + if (defaultEntityView != null) { + if (route.returnPayloadStatusCodes().isEmpty()) { + route.responseEntityView(200, defaultEntityView); + route.responseEntityView(201, defaultEntityView); + } else { + for (Integer statusCode : route.returnPayloadStatusCodes()) { + if (statusCode >= 200 + && statusCode < 300 + && !route.hasResponseEntityViewFor(statusCode)) { + route.responseEntityView(statusCode, defaultEntityView); + } + } + } + } + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java new file mode 100644 index 00000000..4bd8fd0f --- /dev/null +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpec.java @@ -0,0 +1,180 @@ +package uk.co.compendiumdev.thingifier.api.spec; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; + +public final class ThingifierApiSpec { + + private final List routeRules; + + public ThingifierApiSpec() { + routeRules = new ArrayList<>(); + } + + public ThingifierApiRouteRule route(final RoutingVerb verb, final String pathPattern) { + final ThingifierApiRouteRule rule = new ThingifierApiRouteRule(verb, pathPattern); + routeRules.add(rule); + return rule; + } + + public ThingifierApiRouteRule route(final String verb, final String pathPattern) { + return route(RoutingVerb.valueOf(verb.trim().toUpperCase()), pathPattern); + } + + public ThingifierApiSpec hideEntityRoutes(final String entityPath) { + configureEntityRoutes(entityPath, false); + return this; + } + + public ThingifierApiSpec disableEntityRoutes(final String entityPath) { + configureEntityRoutes(entityPath, true); + return this; + } + + public ThingifierApiSpec hideRelationshipRoutes( + final String parentEntityPath, final String relationshipName) { + configureRelationshipRoutes(parentEntityPath, relationshipName, false); + return this; + } + + public ThingifierApiSpec disableRelationshipRoutes( + final String parentEntityPath, final String relationshipName) { + configureRelationshipRoutes(parentEntityPath, relationshipName, true); + return this; + } + + public void applyTo(final ApiRoutingDefinition routingDefinition, final String apiPathPrefix) { + for (RoutingDefinition route : routingDefinition.definitions()) { + ruleFor(route.verb(), route.url(), apiPathPrefix) + .ifPresent(rule -> rule.applyTo(route)); + } + routingDefinition.updateOptionsAllowHeaders(); + } + + public boolean isDisabled(final String verb, final String path, final String apiPathPrefix) { + return ruleFor(verb, path, apiPathPrefix) + .map(ThingifierApiRouteRule::isDisabled) + .orElse(false); + } + + public boolean isDisabled( + final RoutingVerb verb, final String path, final String apiPathPrefix) { + return ruleFor(verb, path, apiPathPrefix) + .map(ThingifierApiRouteRule::isDisabled) + .orElse(false); + } + + public Optional ruleFor( + final String verb, final String path, final String apiPathPrefix) { + return ruleFor(RoutingVerb.valueOf(verb.trim().toUpperCase()), path, apiPathPrefix); + } + + public Optional ruleFor( + final RoutingVerb verb, final String path, final String apiPathPrefix) { + return routeRules.stream() + .filter(rule -> rule.verb() == verb) + .filter(rule -> pathsMatch(rule.pathPattern(), path, apiPathPrefix)) + .findFirst(); + } + + private void configureEntityRoutes(final String entityPath, final boolean disable) { + final String collectionPath = "/" + normalize(entityPath); + final String instancePath = collectionPath + "/{id}"; + for (RoutingVerb verb : RoutingVerb.values()) { + configureRoute(verb, collectionPath, disable); + configureRoute(verb, instancePath, disable); + } + } + + private void configureRelationshipRoutes( + final String parentEntityPath, final String relationshipName, final boolean disable) { + final String relationshipPath = + "/" + normalize(parentEntityPath) + "/{id}/" + normalize(relationshipName); + final String relationshipInstancePath = relationshipPath + "/{relatedId}"; + for (RoutingVerb verb : RoutingVerb.values()) { + configureRoute(verb, relationshipPath, disable); + configureRoute(verb, relationshipInstancePath, disable); + } + } + + private void configureRoute( + final RoutingVerb verb, final String pathPattern, final boolean disable) { + final ThingifierApiRouteRule rule = route(verb, pathPattern); + if (disable) { + rule.disable(); + } else { + rule.hide(); + } + } + + private boolean pathsMatch( + final String rulePath, final String candidatePath, final String apiPathPrefix) { + final List ruleSegments = segments(rulePath, apiPathPrefix); + final List candidateSegments = segments(candidatePath, apiPathPrefix); + if (ruleSegments.size() != candidateSegments.size()) { + return false; + } + for (int index = 0; index < ruleSegments.size(); index++) { + final String ruleSegment = ruleSegments.get(index); + final String candidateSegment = candidateSegments.get(index); + if (isWildcard(ruleSegment) || isWildcard(candidateSegment)) { + continue; + } + if (!ruleSegment.equals(candidateSegment)) { + return false; + } + } + return true; + } + + private List segments(final String path, final String apiPathPrefix) { + final String normalized = removePrefix(normalize(path), normalize(apiPathPrefix)); + if (normalized.isEmpty()) { + return List.of(); + } + return Arrays.stream(normalized.split("/")).map(this::normalizeParameterSegment).toList(); + } + + private String normalize(final String path) { + String normalized = path == null ? "" : path.trim(); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + private String removePrefix(final String path, final String apiPathPrefix) { + if (apiPathPrefix == null || apiPathPrefix.isEmpty()) { + return path; + } + if (path.equals(apiPathPrefix)) { + return ""; + } + if (path.startsWith(apiPathPrefix + "/")) { + return path.substring(apiPathPrefix.length() + 1); + } + return path; + } + + private String normalizeParameterSegment(final String segment) { + if (segment.startsWith(":")) { + return "*"; + } + if (segment.startsWith("{") && segment.endsWith("}")) { + return "*"; + } + return segment; + } + + private boolean isWildcard(final String segment) { + return "*".equals(segment); + } +} diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/StatusCodeConfig.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/StatusCodeConfig.java index 505bd956..358c0556 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/StatusCodeConfig.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/apiconfig/StatusCodeConfig.java @@ -33,6 +33,7 @@ public int acceptTypeNotSupported() { public void setFrom(final StatusCodeConfig statusCodes) { acceptTypeNotSupportedValue = statusCodes.acceptTypeNotSupported(); contentTypeNotSupportedValue = statusCodes.contentTypeNotSupported(); + maxRequestBodyLengthBytes = statusCodes.getMaxRequestBodyLengthBytes(); } public int contentTypeNotSupported() { diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ThingQueryService.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ThingQueryService.java index df1fb8f0..73d8dfa4 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ThingQueryService.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/ThingQueryService.java @@ -20,15 +20,27 @@ public ThingQueryService(final SchemaDefinitionResolver schema) { } public RepositoryQueryResult execute(final ThingReadQuery query, final ThingStore store) { - return new RepositoryQuery(store, specFor(query)).performQuery(query.getQueryParams()); + return execute(query, store, true); } - private RepositoryQuerySpec specFor(final ThingReadQuery query) { + public RepositoryQueryResult execute( + final ThingReadQuery query, + final ThingStore store, + final boolean singleTargetRelationshipsAsInstances) { + return new RepositoryQuery(store, specFor(query, singleTargetRelationshipsAsInstances)) + .performQuery(query.getQueryParams()); + } + + private RepositoryQuerySpec specFor( + final ThingReadQuery query, final boolean singleTargetRelationshipsAsInstances) { EntityDefinition entity = schema.entityNamed(query.getEntityName()); if (query instanceof ReadRelationshipQuery) { ReadRelationshipQuery relationship = (ReadRelationshipQuery) query; return RepositoryQuerySpec.relationship( - entity, relationship.getIdentifier(), relationship.getRelationshipName()); + entity, + relationship.getIdentifier(), + relationship.getRelationshipName(), + singleTargetRelationshipsAsInstances); } if (query instanceof ReadInstanceQuery) { diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/EntityDefinitionSpec.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/EntityDefinitionSpec.java index 0b2a2ad2..5460d6af 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/EntityDefinitionSpec.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/EntityDefinitionSpec.java @@ -8,6 +8,7 @@ public final class EntityDefinitionSpec { private final String name; private final String pluralName; + private final String description; private final int maxInstances; private final String primaryKeyFieldName; private final List fields; @@ -15,6 +16,7 @@ public final class EntityDefinitionSpec { private EntityDefinitionSpec(final Builder builder) { name = builder.name; pluralName = builder.pluralName; + description = builder.description; maxInstances = builder.maxInstances; primaryKeyFieldName = builder.primaryKeyFieldName; fields = Collections.unmodifiableList(new ArrayList<>(builder.fields)); @@ -32,6 +34,10 @@ public String pluralName() { return pluralName; } + public String description() { + return description; + } + public int maxInstances() { return maxInstances; } @@ -61,6 +67,7 @@ public static final class Builder { private final String name; private String pluralName; + private String description; private int maxInstances; private String primaryKeyFieldName; private final List fields; @@ -76,6 +83,11 @@ public Builder plural(final String pluralName) { return this; } + public Builder description(final String description) { + this.description = description; + return this; + } + public Builder maxInstances(final int maxInstances) { this.maxInstances = maxInstances; return this; diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelAssembler.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelAssembler.java index 243cd7aa..042854be 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelAssembler.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelAssembler.java @@ -53,8 +53,12 @@ public Thingifier assemble( emptyIfNull(definition.title()), emptyIfNull(definition.description())); for (EntityDefinitionSpec entitySpec : definition.entities()) { - thingifier.defineThing( - entitySpec.name(), entitySpec.pluralName(), entitySpec.maxInstances()); + final EntityDefinition entity = + thingifier.defineThing( + entitySpec.name(), entitySpec.pluralName(), entitySpec.maxInstances()); + if (entitySpec.description() != null) { + entity.withDescription(entitySpec.description()); + } } for (EntityDefinitionSpec entitySpec : definition.entities()) { diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelExporter.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelExporter.java index f2ef1a4e..d4e12bb1 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelExporter.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelExporter.java @@ -52,6 +52,9 @@ private EntityDefinitionSpec entitySpecFor(final EntityDefinition entity) { EntityDefinitionSpec.named(entity.getName()) .plural(entity.getPlural()) .maxInstances(entity.getMaxInstanceLimit()); + if (entity.hasDescription()) { + builder.description(entity.getDescription()); + } if (entity.hasPrimaryKeyField()) { builder.primaryKey(entity.getPrimaryKeyField().getName()); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGenerator.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGenerator.java index 48ee0e44..3b0a63a3 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGenerator.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGenerator.java @@ -16,6 +16,7 @@ import uk.co.compendiumdev.thingifier.api.http.bodyparser.xml.GenericXMLPrettyPrinter; import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfig; import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityViewDefinition; import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; import uk.co.compendiumdev.thingifier.core.domain.definitions.relationship.RelationshipDefinition; @@ -31,6 +32,8 @@ public class RestApiDocumentationGenerator { private static final String DEFAULT_META_ROBOTS = "index,follow"; private static final String DEFAULT_OG_TYPE = "website"; private static final String DEFAULT_TWITTER_CARD = "summary_large_image"; + private static final String MERMAID_ESM_CDN = + "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"; private final Thingifier thingifier; private final Collection relationships; private final JsonThing jsonThing; @@ -177,6 +180,9 @@ public String getApiDocumentation( for (EntityDefinition aThingDefinition : definitions) { output.append(heading(4, aThingDefinition.getName())); + if (aThingDefinition.hasDescription()) { + output.append(paragraph(escapeHtmlText(aThingDefinition.getDescription()))); + } output.append("Fields:\n"); @@ -188,7 +194,7 @@ public String getApiDocumentation( output.append(""); output.append("Fieldname\n"); output.append("Type\n"); - output.append("Validation\n"); + output.append("Description\n"); output.append(""); output.append("\n"); @@ -212,9 +218,14 @@ public String getApiDocumentation( output.append(""); output.append("
    "); + if (theField.hasDescription()) { + output.append( + "
  • " + escapeHtmlText(theField.getDescription()) + "
  • \n"); + } for (ValidationRule validation : theField.getAllValidationRules()) { // use the validation error message in the documentation - output.append("
  • " + validation.getExplanation() + "
  • \n"); + output.append( + "
  • " + escapeHtmlText(validation.getExplanation()) + "
  • \n"); } output.append("
\n"); @@ -234,6 +245,47 @@ public String getApiDocumentation( output.append("\n"); output.append("\n"); + if (!aThingDefinition.getViews().isEmpty()) { + output.append("Views:\n"); + output.append("\n"); + output.append("\n"); + output.append(""); + output.append("\n"); + output.append("\n"); + output.append("\n"); + output.append("\n"); + output.append(""); + output.append("\n"); + output.append("\n"); + for (EntityViewDefinition view : aThingDefinition.getViews()) { + output.append(""); + output.append(String.format("", escapeHtmlText(view.getName()))); + output.append( + String.format( + "", + escapeHtmlText( + fieldsInView( + aThingDefinition, + view::isRequestVisible)))); + output.append( + String.format( + "", + escapeHtmlText( + fieldsInView( + aThingDefinition, + view::isResponseVisible)))); + output.append( + String.format( + "", + escapeHtmlText( + fieldsInView( + aThingDefinition, view::isInputAllowed)))); + output.append(""); + } + output.append("\n"); + output.append("
ViewRequest FieldsResponse FieldsInput Allowed Fields
%s%s%s%s
\n"); + } + // show an example if (thingifier.apiConfig().willApiAllowJsonForResponses()) { output.append("

Example JSON Output from API calls

\n"); @@ -305,37 +357,18 @@ public String getApiDocumentation( if (relationships != null && !relationships.isEmpty()) { output.append(heading(3, "Relationships")); + output.append(mermaidErDiagram()); output.append("
    \n"); for (RelationshipDefinition relationship : relationships) { - RelationshipVectorDefinition fromToRelationship = - relationship.getFromRelationship(); - - // task-of : task => project - String reportLine = - String.format( - "
  • %s : %s => %s%n", - fromToRelationship.getName(), - fromToRelationship.getFrom().getName(), - fromToRelationship.getTo().getName()); - - // for a two way relationship can it be combined on to one line e.g. - // tasks/task-of : project =(tasks)=> task / task=(task-of)=> project - + output.append(relationshipLine(relationship.getFromRelationship())); if (relationship.isTwoWay()) { - reportLine = - String.format( - "
  • %1$s/%2$s : %3$s =(%1$s)=> %4$s / %4$s =(%2$s)=> %3$s %n", - relationship.getFromRelationship().getName(), - relationship.getReversedRelationship().getName(), - relationship.getFromRelationship().getFrom().getName(), - relationship.getFromRelationship().getTo().getName()); + output.append(relationshipLine(relationship.getReversedRelationship())); } - - output.append(reportLine); } output.append("
\n"); + output.append(mermaidEsmScript()); } // output the API documentation @@ -354,6 +387,9 @@ public String getApiDocumentation( String currentEndPoint = ""; for (RoutingDefinition routingDefn : routingDefinitions.definitions()) { + if (routingDefn.isHiddenFromDocumentation() || routingDefn.isDisabled()) { + continue; + } // only show if not a method not allowed method if (!currentEndPoint.equalsIgnoreCase(routingDefn.url())) { // new endpoint @@ -457,6 +493,102 @@ public String getApiDocumentation( return output.toString(); } + private String relationshipLine(final RelationshipVectorDefinition relationship) { + return String.format( + "
  • %s : %s =(%s, max %s)=> %s
  • %n", + escapeHtmlText(relationship.getName()), + escapeHtmlText(relationship.getFrom().getName()), + escapeHtmlText(relationship.getName()), + escapeHtmlText(relationship.getCardinality().right()), + escapeHtmlText(relationship.getTo().getName())); + } + + private String fieldsInView( + final EntityDefinition entity, final java.util.function.Predicate included) { + final List fieldNames = new ArrayList<>(); + for (String fieldName : entity.getFieldNames()) { + if (included.test(fieldName)) { + fieldNames.add(fieldName); + } + } + return String.join(", ", fieldNames); + } + + private String mermaidErDiagram() { + StringBuilder diagram = new StringBuilder(); + diagram.append("
    \n");
    +        diagram.append("erDiagram\n");
    +        for (RelationshipDefinition relationship : relationships) {
    +            RelationshipVectorDefinition fromRelationship = relationship.getFromRelationship();
    +            diagram.append("    ")
    +                    .append(mermaidEntityId(fromRelationship.getFrom()))
    +                    .append(" ")
    +                    .append(mermaidLeftCardinalityMarker(fromRelationship.getCardinality().left()))
    +                    .append("--")
    +                    .append(
    +                            mermaidRightCardinalityMarker(
    +                                    fromRelationship.getCardinality().right()))
    +                    .append(" ")
    +                    .append(mermaidEntityId(fromRelationship.getTo()))
    +                    .append(" : ")
    +                    .append(mermaidRelationshipLabel(fromRelationship.getName()))
    +                    .append("\n");
    +        }
    +        diagram.append("
    \n"); + return diagram.toString(); + } + + private String mermaidEsmScript() { + return "\n"; + } + + private String mermaidEntityId(final EntityDefinition entity) { + String sanitized = entity.getName().replaceAll("[^A-Za-z0-9_]", "_").toUpperCase(); + sanitized = sanitized.replaceAll("_+", "_"); + sanitized = sanitized.replaceAll("^_+|_+$", ""); + if (sanitized.isEmpty()) { + sanitized = "ENTITY"; + } + if (Character.isDigit(sanitized.charAt(0))) { + sanitized = "ENTITY_" + sanitized; + } + return sanitized; + } + + private String mermaidRelationshipLabel(final String relationshipName) { + String sanitized = relationshipName.replaceAll("[^A-Za-z0-9 _-]", " ").trim(); + sanitized = sanitized.replaceAll("\\s+", " "); + if (sanitized.isEmpty()) { + return "relates to"; + } + return sanitized; + } + + private String mermaidLeftCardinalityMarker(final String cardinality) { + if ("1".equals(cardinality)) { + return "||"; + } + if ("0".equals(cardinality)) { + return "|o"; + } + return "}o"; + } + + private String mermaidRightCardinalityMarker(final String cardinality) { + if ("1".equals(cardinality)) { + return "||"; + } + if ("0".equals(cardinality)) { + return "o|"; + } + return "o{"; + } + private String openApiVersionLinks() { StringBuilder links = new StringBuilder(); links.append("
      %n".formatted()); @@ -629,6 +761,10 @@ private String escapeHtmlAttribute(final String value) { .replace(">", ">"); } + private String escapeHtmlText(final String value) { + return escapeHtmlAttribute(value); + } + private String getExampleFilter(final EntityDefinition filterableEntity) { String exampleFilters = ""; List exampleFields = new ArrayList<>(); diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerUiPage.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerUiPage.java index b15cde3b..18f42c69 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerUiPage.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/SwaggerUiPage.java @@ -169,6 +169,14 @@ private String lightThemeCss() { + "color:#222!important;border-color:#8b8b8b!important;}", ".swagger-ui .btn,.swagger-ui button{background:#fff!important;" + "color:#222!important;border-color:#777!important;}", + ".swagger-ui .authorization__btn{display:inline-flex!important;" + + "align-items:center!important;justify-content:center!important;" + + "min-width:32px!important;height:28px!important;margin:0 8px!important;" + + "border:1px solid #1f5f8b!important;border-radius:4px!important;" + + "background:#eef7fc!important;color:#1f5f8b!important;}", + ".swagger-ui .authorization__btn svg{display:block!important;" + + "width:18px!important;height:18px!important;fill:#1f5f8b!important;}", + ".swagger-ui .authorization__btn svg path{fill:#1f5f8b!important;}", ".swagger-ui .highlight-code,.swagger-ui .microlight{background:#f7f7f7!important;" + "color:#222!important;}"); } diff --git a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java index 2fe4140c..533f93c5 100644 --- a/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java +++ b/thingifier/src/main/java/uk/co/compendiumdev/thingifier/swaggerizer/Swaggerizer.java @@ -23,6 +23,7 @@ import uk.co.compendiumdev.thingifier.api.docgen.ThingifierApiDocumentationDefn; import uk.co.compendiumdev.thingifier.api.http.ThingifierHttpApi; import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityViewDefinition; import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; import uk.co.compendiumdev.thingifier.core.domain.definitions.validation.ValidationRule; @@ -133,7 +134,8 @@ public OpenAPI swagger(SwaggerGenerationConfig config) { final Paths paths = api.getPaths(); for (RoutingDefinition route : routes) { - if (!processedAdditionalRoutes.contains(route.url())) { + if (!processedAdditionalRoutes.contains(route.url()) + && hasVisibleRouteForUrl(routes, route.url(), config)) { final PathItem path = new PathItem(); String prefix = ""; @@ -147,10 +149,7 @@ public OpenAPI swagger(SwaggerGenerationConfig config) { for (RoutingDefinition subroute : routes) { if (subroute.url().contentEquals(route.url())) { - if (!config.includeMethodNotAllowedEndpoints - && subroute.status() != null - && subroute.status().value() == 405) { - // method not allowed so do not add it to the swagger + if (!isVisibleRoute(subroute, config)) { continue; } @@ -239,31 +238,32 @@ public OpenAPI swagger(SwaggerGenerationConfig config) { } if (subroute.isSecuredByBasicAuth()) { - if (components.getSecuritySchemes() == null - || !components - .getSecuritySchemes() - .containsKey("basicAuth")) { - components.addSecuritySchemes( - "basicAuth", - new SecurityScheme() - .type(SecurityScheme.Type.HTTP) - .scheme("basic")); - } + addHttpSecurityScheme(components, "basicAuth", "basic", null); operation.addSecurityItem( new SecurityRequirement().addList("basicAuth")); } + if (subroute.isSecuredByBearerAuth()) { + addHttpSecurityScheme(components, "bearerAuth", "bearer", null); + operation.addSecurityItem( + new SecurityRequirement().addList("bearerAuth")); + } + if (subroute.hasRequestUrlParams()) { List urlParameters = new ArrayList<>(); - // TODO: create a Field to Swaggerizer param method/class - List paramFields = subroute.getRequestUrlParams(); - for (Field aField : paramFields) { + for (RoutingDefinition.RequestUrlParameter urlParameter : + subroute.getRequestUrlParameters()) { + Field aField = urlParameter.field(); Parameter param = new Parameter(); param.in("path") - .name(aField.getName()) + .name(urlParameter.name()) + .required(true) .example(aField.getRandomExampleValue()); + if (aField.hasDescription()) { + param.setDescription(aField.getDescription()); + } // if it is in path it will always be required // but we can remove the type validation @@ -300,6 +300,28 @@ public OpenAPI swagger(SwaggerGenerationConfig config) { return api; } + private boolean hasVisibleRouteForUrl( + final List routes, + final String url, + final SwaggerGenerationConfig config) { + for (RoutingDefinition route : routes) { + if (route.url().contentEquals(url) && isVisibleRoute(route, config)) { + return true; + } + } + return false; + } + + private boolean isVisibleRoute( + final RoutingDefinition route, final SwaggerGenerationConfig config) { + if (route.isHiddenFromDocumentation() || route.isDisabled()) { + return false; + } + return config.includeMethodNotAllowedEndpoints + || route.status() == null + || route.status().value() != 405; + } + private Components convertEntityDefinitionsToComponents( ApiRoutingDefinition routingDefinitions) { Components components = new Components(); @@ -317,6 +339,15 @@ private Components convertEntityDefinitionsToComponents( // add list response for entity plural ArraySchema arrayObject = asArrayObjectSchema(objectSchemaDefinition); components.addSchemas(objectSchemaDefinition.getPlural(), arrayObject); + + for (EntityViewDefinition view : objectSchemaDefinition.getViews()) { + ObjectSchema viewObject = asResponseViewObjectSchema(view); + components.addSchemas(view.getName(), viewObject); + + ObjectSchema createViewObject = asRequestViewObjectSchema(view); + createViewObject.title("create " + createViewObject.getTitle()); + components.addSchemas("create_" + view.getName(), createViewObject); + } } return components; } @@ -423,6 +454,24 @@ private void addRouteCustomHeaders( } } + private void addHttpSecurityScheme( + final Components components, + final String name, + final String scheme, + final String bearerFormat) { + if (components.getSecuritySchemes() != null + && components.getSecuritySchemes().containsKey(name)) { + return; + } + + final SecurityScheme securityScheme = + new SecurityScheme().type(SecurityScheme.Type.HTTP).scheme(scheme); + if (bearerFormat != null) { + securityScheme.bearerFormat(bearerFormat); + } + components.addSecuritySchemes(name, securityScheme); + } + private ArraySchema asArrayObjectSchema(EntityDefinition objectSchemaDefinition) { ArraySchema arrayObject = new ArraySchema(); @@ -452,15 +501,40 @@ private static ObjectSchema asCreateObjectSchema(EntityDefinition objectSchemaDe return asObjectSchema(objectSchemaDefinition, true); } + private static ObjectSchema asRequestViewObjectSchema(final EntityViewDefinition view) { + return asObjectSchema(view.getEntity(), true, view, true); + } + + private static ObjectSchema asResponseViewObjectSchema(final EntityViewDefinition view) { + return asObjectSchema(view.getEntity(), false, view, false); + } + // no auto fields in create private static ObjectSchema asObjectSchema( EntityDefinition objectSchemaDefinition, Boolean skipAutos) { + return asObjectSchema(objectSchemaDefinition, skipAutos, null, false); + } + + private static ObjectSchema asObjectSchema( + EntityDefinition objectSchemaDefinition, + Boolean skipAutos, + EntityViewDefinition view, + Boolean requestSchema) { ObjectSchema object = new ObjectSchema(); - object.setDescription(objectSchemaDefinition.getName()); - object.setTitle(objectSchemaDefinition.getName()); + object.setDescription(schemaDescriptionFor(objectSchemaDefinition)); + object.setTitle(view == null ? objectSchemaDefinition.getName() : view.getName()); for (String propertyName : objectSchemaDefinition.getFieldNames()) { Field propertyDefinition = objectSchemaDefinition.getField(propertyName); + if (view != null) { + final boolean visible = + requestSchema + ? view.isRequestVisible(propertyName) + : view.isResponseVisible(propertyName); + if (!visible) { + continue; + } + } if (skipAutos && (propertyDefinition.getType() == FieldType.AUTO_GUID || propertyDefinition.getType() == FieldType.AUTO_INCREMENT)) { @@ -514,6 +588,13 @@ private static ObjectSchema asObjectSchema( return object; } + private static String schemaDescriptionFor(final EntityDefinition objectSchemaDefinition) { + if (objectSchemaDefinition.hasDescription()) { + return objectSchemaDefinition.getDescription(); + } + return objectSchemaDefinition.getName(); + } + private static String joinStrings(List description, String postfix) { StringBuilder joined = new StringBuilder(); String prependSpace = ""; diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGeneratorTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGeneratorTest.java index 27840924..86ccbb3d 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGeneratorTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/docgen/ApiRoutingDefinitionDocGeneratorTest.java @@ -70,6 +70,23 @@ public void relationshipWritesDocumentValidationAndConflictStatuses() { Assertions.assertTrue( statuses(route(definition, RoutingVerb.POST, "projects/:id/tasks")) .containsAll(Set.of(201, 400, 404, 422, 409))); + Assertions.assertEquals( + Set.of("id"), + parameterNames(route(definition, RoutingVerb.GET, "projects/:id/tasks"))); + Assertions.assertTrue( + route(definition, RoutingVerb.GET, "projects/:id/tasks").hasReturnPayloadFor(200)); + Assertions.assertEquals( + "todos", + route(definition, RoutingVerb.GET, "projects/:id/tasks").getReturnPayloadFor(200)); + Assertions.assertEquals( + "todo", + route(definition, RoutingVerb.POST, "projects/:id/tasks").getRequestPayload()); + Assertions.assertEquals( + "project", + route(definition, RoutingVerb.GET, "todos/:id/tasksof").getReturnPayloadFor(200)); + Assertions.assertEquals( + "projects", + route(definition, RoutingVerb.QUERY, "todos/:id/tasksof").getReturnPayloadFor(200)); Assertions.assertTrue( statuses(route(definition, RoutingVerb.QUERY, "projects/:id/tasks")) .containsAll(Set.of(200, 400, 413, 415))); @@ -77,11 +94,27 @@ public void relationshipWritesDocumentValidationAndConflictStatuses() { "OPTIONS, GET, HEAD, POST, QUERY", route(definition, RoutingVerb.OPTIONS, "projects/:id/tasks").headerValue()); Assertions.assertTrue( - statuses(route(definition, RoutingVerb.DELETE, "projects/:id/tasks/:id")) + statuses(route(definition, RoutingVerb.DELETE, "projects/:id/tasks/:relatedId")) .containsAll(Set.of(204, 400, 404, 422, 409))); + Assertions.assertEquals( + Set.of("id", "relatedId"), + parameterNames( + route(definition, RoutingVerb.DELETE, "projects/:id/tasks/:relatedId"))); Assertions.assertEquals( 405, - route(definition, RoutingVerb.QUERY, "projects/:id/tasks/:id").status().value()); + route(definition, RoutingVerb.QUERY, "projects/:id/tasks/:relatedId") + .status() + .value()); + } + + @Test + public void relationshipRoutesUseConfiguredPathPrefix() { + ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(model()).generate("/api"); + + Assertions.assertNotNull(route(definition, RoutingVerb.GET, "api/projects/:id/tasks")); + Assertions.assertNotNull( + route(definition, RoutingVerb.DELETE, "api/projects/:id/tasks/:relatedId")); } private Thingifier model() { @@ -97,7 +130,7 @@ private Thingifier model() { thingifier .defineRelationship(project, todo, "tasks", Cardinality.ONE_TO_MANY()) - .whenReversed(Cardinality.ONE_TO_MANY(), "tasksof"); + .whenReversed(Cardinality.ONE_TO_ONE(), "tasksof"); return thingifier; } @@ -116,4 +149,10 @@ private Set statuses(final RoutingDefinition route) { .map(RoutingStatus::value) .collect(Collectors.toSet()); } + + private Set parameterNames(final RoutingDefinition route) { + return route.getRequestUrlParameters().stream() + .map(RoutingDefinition.RequestUrlParameter::name) + .collect(Collectors.toSet()); + } } diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/http/ThingifierRequestContextTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/http/ThingifierRequestContextTest.java index b511df4a..03e3ef5c 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/http/ThingifierRequestContextTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/http/ThingifierRequestContextTest.java @@ -21,7 +21,7 @@ public void defaultsToDefaultDatabaseWhenSessionHeaderIsAbsent() { } @Test - public void resolvesSessionDatabaseAndPreservesHeaders() { + public void resolvesSessionDatabaseFromHeader() { Thingifier thingifier = new Thingifier(); HttpHeadersBlock headers = new HttpHeadersBlock(); headers.put(ThingifierHttpApi.HTTP_SESSION_HEADER_NAME, "session-one"); diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiGetHandlerTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiGetHandlerTest.java index 2398ca86..5b2df558 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiGetHandlerTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiGetHandlerTest.java @@ -72,6 +72,32 @@ public void relationshipReadPreservesResultTypeMetadata() { Assertions.assertEquals(task, response.getTypeOfThingReturned()); } + @Test + public void singleTargetRelationshipReadReturnsRelatedInstance() { + Thingifier thingifier = taskProjectThingifier(); + ThingStore store = storeFor(thingifier); + EntityDefinition project = thingifier.getDefinitionNamed("project"); + EntityInstance taskInstance = createTask(thingifier, "Task"); + EntityInstance projectInstance = + store.entities() + .create( + EntityInstanceDraft.forEntity(project) + .withField("title", "Project")); + store.relationships().connect(projectInstance, "tasks", taskInstance); + + ApiResponse response = + thingifier + .api() + .get( + "task/" + taskInstance.getPrimaryKeyValue() + "/task-of", + params(), + headers()); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertFalse(response.isCollection()); + Assertions.assertEquals(projectInstance, response.getReturnedInstance()); + } + @Test public void missingInstanceMapsToNotFound() { Thingifier thingifier = taskProjectThingifier(); @@ -138,7 +164,7 @@ private Thingifier taskProjectThingifier() { thingifier .defineRelationship(project, task, "tasks", Cardinality.ONE_TO_MANY()) - .whenReversed(Cardinality.ONE_TO_MANY(), "task-of"); + .whenReversed(Cardinality.ONE_TO_ONE(), "task-of"); return thingifier; } diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiQueryHandlerTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiQueryHandlerTest.java index e7870c43..f9c7d018 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiQueryHandlerTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/restapihandlers/RestApiQueryHandlerTest.java @@ -77,6 +77,28 @@ public void queryRelationshipCollectionFiltersRelatedInstancesOnly() { matching, response.apiResponse().getReturnedInstanceCollection().get(0)); } + @Test + public void querySingleTargetRelationshipStillReturnsACollection() { + Thingifier thingifier = taskProjectThingifier(); + ThingStore store = storeFor(thingifier); + EntityInstance project = createProject(thingifier, "Project"); + EntityInstance task = createTask(thingifier, "Keep", "Open"); + store.relationships().connect(project, "tasks", task); + + HttpApiResponse response = + new ThingifierHttpApi(thingifier) + .queryRequest( + query( + "tasks/" + task.getPrimaryKeyValue() + "/task-of", + "title=Project")); + + Assertions.assertEquals(200, response.getStatusCode()); + Assertions.assertTrue(response.apiResponse().isCollection()); + Assertions.assertEquals(1, response.apiResponse().getReturnedInstanceCollection().size()); + Assertions.assertEquals( + project, response.apiResponse().getReturnedInstanceCollection().get(0)); + } + @Test public void querySingleInstanceIsMethodNotAllowed() { Thingifier thingifier = taskProjectThingifier(); @@ -213,7 +235,7 @@ private Thingifier taskProjectThingifier() { thingifier .defineRelationship(project, task, "tasks", Cardinality.ONE_TO_MANY()) - .whenReversed(Cardinality.ONE_TO_MANY(), "task-of"); + .whenReversed(Cardinality.ONE_TO_ONE(), "task-of"); return thingifier; } diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpecTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpecTest.java new file mode 100644 index 00000000..15c132a6 --- /dev/null +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/api/spec/ThingifierApiSpecTest.java @@ -0,0 +1,228 @@ +package uk.co.compendiumdev.thingifier.api.spec; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinitionDocGenerator; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; +import uk.co.compendiumdev.thingifier.api.http.HttpApiRequest; +import uk.co.compendiumdev.thingifier.api.http.HttpApiResponse; +import uk.co.compendiumdev.thingifier.api.http.ThingifierHttpApi; +import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfig; +import uk.co.compendiumdev.thingifier.core.domain.definitions.Cardinality; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; + +class ThingifierApiSpecTest { + + @Test + void emptyApiSpecLeavesGeneratedRoutesUnchanged() { + final ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(model()).generate("/api"); + + final RoutingDefinition route = route(definition, RoutingVerb.POST, "api/todos"); + Assertions.assertFalse(route.isDisabled()); + Assertions.assertFalse(route.isHiddenFromDocumentation()); + Assertions.assertFalse(route.isSecuredByBearerAuth()); + Assertions.assertEquals( + "OPTIONS, GET, HEAD, POST, QUERY", + route(definition, RoutingVerb.OPTIONS, "api/todos").headerValue()); + } + + @Test + void apiSpecCanSecureAndOverrideGeneratedRouteDocumentation() { + final Thingifier thingifier = model(); + thingifier + .apiSpec() + .route(RoutingVerb.POST, "/api/projects/{projectId}/tasks") + .secureWithBearerAuth() + .addDocumentation("create a task through the project API") + .requestPayload("create_todo"); + + final ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(thingifier).generate("/api"); + final RoutingDefinition route = + route(definition, RoutingVerb.POST, "api/projects/:id/tasks"); + + Assertions.assertTrue(route.isSecuredByBearerAuth()); + Assertions.assertEquals("create a task through the project API", route.getDocumentation()); + Assertions.assertEquals("create_todo", route.getRequestPayload()); + } + + @Test + void disabledGeneratedRouteIsHiddenAndRemovedFromOptions() { + final Thingifier thingifier = model(); + thingifier.apiSpec().route(RoutingVerb.POST, "/api/todos").disable(); + + final ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(thingifier).generate("/api"); + + final RoutingDefinition post = route(definition, RoutingVerb.POST, "api/todos"); + Assertions.assertTrue(post.isDisabled()); + Assertions.assertTrue(post.isHiddenFromDocumentation()); + Assertions.assertEquals( + "OPTIONS, GET, HEAD, QUERY", + route(definition, RoutingVerb.OPTIONS, "api/todos").headerValue()); + } + + @Test + void disabledGeneratedRouteReturns404FromInternalApi() { + final Thingifier thingifier = model(); + thingifier.apiConfig().setFrom(new ThingifierApiConfig("/api")); + thingifier.apiSpec().route(RoutingVerb.POST, "/api/todos").disable(); + + final HttpApiResponse response = + new ThingifierHttpApi(thingifier).post(new HttpApiRequest("/api/todos")); + + Assertions.assertEquals(404, response.getStatusCode()); + } + + @Test + void apiSpecCanDisableAllRoutesForAnEntity() { + final Thingifier thingifier = model(); + thingifier.apiSpec().disableEntityRoutes("/api/todos"); + + final ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(thingifier).generate("/api"); + + Assertions.assertTrue(route(definition, RoutingVerb.GET, "api/todos").isDisabled()); + Assertions.assertTrue(route(definition, RoutingVerb.POST, "api/todos").isDisabled()); + Assertions.assertTrue(route(definition, RoutingVerb.GET, "api/todos/:id").isDisabled()); + Assertions.assertTrue(route(definition, RoutingVerb.PUT, "api/todos/:id").isDisabled()); + } + + @Test + void apiSpecCanDisableAllRoutesForARelationship() { + final Thingifier thingifier = model(); + thingifier.apiSpec().disableRelationshipRoutes("/api/projects", "tasks"); + + final ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(thingifier).generate("/api"); + + Assertions.assertTrue( + route(definition, RoutingVerb.GET, "api/projects/:id/tasks").isDisabled()); + Assertions.assertTrue( + route(definition, RoutingVerb.POST, "api/projects/:id/tasks").isDisabled()); + Assertions.assertTrue( + route(definition, RoutingVerb.DELETE, "api/projects/:id/tasks/:relatedId") + .isDisabled()); + Assertions.assertTrue( + route(definition, RoutingVerb.OPTIONS, "api/projects/:id/tasks/:relatedId") + .isDisabled()); + } + + @Test + void apiSpecCanHideAllRoutesForARelationshipWithoutDisablingThem() { + final Thingifier thingifier = model(); + thingifier.apiSpec().hideRelationshipRoutes("/api/projects", "tasks"); + + final ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(thingifier).generate("/api"); + + final RoutingDefinition get = route(definition, RoutingVerb.GET, "api/projects/:id/tasks"); + Assertions.assertTrue(get.isHiddenFromDocumentation()); + Assertions.assertFalse(get.isDisabled()); + + final RoutingDefinition delete = + route(definition, RoutingVerb.DELETE, "api/projects/:id/tasks/:relatedId"); + Assertions.assertTrue(delete.isHiddenFromDocumentation()); + Assertions.assertFalse(delete.isDisabled()); + } + + @Test + void apiSpecCanBindEntityViewsToGeneratedRoutes() { + final Thingifier thingifier = viewModel(); + thingifier.apiSpec().route(RoutingVerb.POST, "/api/items").entityView("PublicItem"); + + final ApiRoutingDefinition definition = + new ApiRoutingDefinitionDocGenerator(thingifier).generate("/api"); + final RoutingDefinition route = route(definition, RoutingVerb.POST, "api/items"); + + Assertions.assertEquals("create_PublicItem", route.getRequestPayload()); + Assertions.assertEquals("PublicItem", route.getReturnPayloadFor(201)); + Assertions.assertTrue(route.hasRequestEntityView()); + Assertions.assertEquals("PublicItem", route.getResponseEntityViewFor(201)); + } + + @Test + void entityViewsFilterResponsesAndRejectDisallowedInputFieldsAtRuntime() { + final Thingifier thingifier = viewModel(); + thingifier.apiConfig().setFrom(new ThingifierApiConfig("/api")); + thingifier.apiSpec().route(RoutingVerb.POST, "/api/items").entityView("PublicItem"); + final ThingifierHttpApi api = new ThingifierHttpApi(thingifier); + + final HttpApiResponse createResponse = + api.post( + jsonRequest( + "/api/items", + "{\"name\":\"visible\",\"secret\":\"stored but hidden\"}")); + + Assertions.assertEquals(201, createResponse.getStatusCode()); + Assertions.assertTrue(createResponse.getBody().contains("visible")); + Assertions.assertFalse(createResponse.getBody().contains("secret")); + Assertions.assertFalse(createResponse.getBody().contains("stored but hidden")); + + final HttpApiResponse getResponse = api.get(new HttpApiRequest("/api/items/1")); + Assertions.assertEquals(200, getResponse.getStatusCode()); + Assertions.assertTrue(getResponse.getBody().contains("stored but hidden")); + + final HttpApiResponse disallowedResponse = + api.post( + jsonRequest("/api/items", "{\"name\":\"visible\",\"forbidden\":\"nope\"}")); + + Assertions.assertEquals(422, disallowedResponse.getStatusCode()); + Assertions.assertTrue(disallowedResponse.getBody().contains("forbidden")); + } + + private Thingifier model() { + Thingifier thingifier = new Thingifier(); + + EntityDefinition project = thingifier.defineThing("project", "projects", 5); + project.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + project.addField(Field.is("title", FieldType.STRING)); + + EntityDefinition todo = thingifier.defineThing("todo", "todos", 5); + todo.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + todo.addField(Field.is("title", FieldType.STRING).makeMandatory()); + + thingifier + .defineRelationship(project, todo, "tasks", Cardinality.ONE_TO_MANY()) + .whenReversed(Cardinality.ONE_TO_ONE(), "project"); + + return thingifier; + } + + private Thingifier viewModel() { + Thingifier thingifier = new Thingifier(); + EntityDefinition item = thingifier.defineThing("item", "items", 5); + item.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + item.addField(Field.is("name", FieldType.STRING).makeMandatory()); + item.addField(Field.is("secret", FieldType.STRING)); + item.addField(Field.is("forbidden", FieldType.STRING)); + item.defineView("PublicItem") + .hideRequestFields("secret") + .hideResponseFields("secret") + .disallowInputFields("forbidden"); + return thingifier; + } + + private HttpApiRequest jsonRequest(final String path, final String body) { + return new HttpApiRequest(path) + .setVerb("POST") + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "application/json") + .setBody(body); + } + + private RoutingDefinition route( + final ApiRoutingDefinition definition, final RoutingVerb verb, final String url) { + return definition.definitions().stream() + .filter(route -> route.verb() == verb) + .filter(route -> route.url().equals(url)) + .findFirst() + .orElseThrow(); + } +} diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelAssemblerTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelAssemblerTest.java index 74fb5626..f9f30644 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelAssemblerTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelAssemblerTest.java @@ -21,6 +21,8 @@ void validSpecsAssembleIntoThingifierSchema() { Assertions.assertNotNull(thingifier.getDefinitionNamed("project")); EntityDefinition task = thingifier.getDefinitionNamed("task"); + Assertions.assertEquals( + "Task items that can be assigned to projects.", task.getDescription()); Assertions.assertEquals("id", task.getPrimaryKeyField().getName()); Assertions.assertTrue(task.getField("title").isMandatory()); Assertions.assertEquals(FieldType.STRING, task.getField("title").getType()); @@ -143,6 +145,7 @@ private ThingifierModelDefinition taskProjectDefinition() { .entity( EntityDefinitionSpec.named("task") .plural("todos") + .description("Task items that can be assigned to projects.") .primaryKey("id") .field(FieldDefinitionSpec.named("id", "auto-increment").build()) .field( diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelExporterTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelExporterTest.java index cb44f585..f5849780 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelExporterTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/application/schema/definition/ThingifierModelExporterTest.java @@ -24,6 +24,7 @@ void exporterCapturesJavaBuiltEntitiesAndFieldMetadata() { Assertions.assertEquals("Todo Model", definition.title()); Assertions.assertEquals("Model description", definition.description()); + Assertions.assertEquals("A task item to complete.", task.description()); Assertions.assertEquals("id", task.primaryKeyFieldName()); Assertions.assertEquals("auto-increment", task.fieldNamed("id").type()); Assertions.assertTrue(title.required()); @@ -78,6 +79,8 @@ void exportedDefinitionCanBeAssembledAgain() { Thingifier assembled = new ThingifierModelAssembler().assemble(definition); Assertions.assertNotNull(assembled.getDefinitionNamed("task")); + Assertions.assertEquals( + "A task item to complete.", assembled.getDefinitionNamed("task").getDescription()); Assertions.assertEquals( "id", assembled.getDefinitionNamed("task").getPrimaryKeyField().getName()); Assertions.assertTrue(assembled.hasRelationshipNamed("tasks")); @@ -89,6 +92,7 @@ private Thingifier model() { thingifier.setDocumentation("Todo Model", "Model description"); EntityDefinition task = thingifier.defineThing("task", "tasks"); + task.withDescription("A task item to complete."); task.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); task.addFields( Field.is("title", FieldType.STRING) diff --git a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGeneratorTest.java b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGeneratorTest.java index 0486a743..4f06e0aa 100644 --- a/thingifier/src/test/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGeneratorTest.java +++ b/thingifier/src/test/java/uk/co/compendiumdev/thingifier/htmlgui/htmlgen/RestApiDocumentationGeneratorTest.java @@ -5,7 +5,13 @@ import org.junit.jupiter.api.Test; import uk.co.compendiumdev.thingifier.Thingifier; import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinitionDocGenerator; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingVerb; import uk.co.compendiumdev.thingifier.api.docgen.ThingifierApiDocumentationDefn; +import uk.co.compendiumdev.thingifier.core.domain.definitions.Cardinality; +import uk.co.compendiumdev.thingifier.core.domain.definitions.EntityDefinition; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.Field; +import uk.co.compendiumdev.thingifier.core.domain.definitions.field.definition.FieldType; class RestApiDocumentationGeneratorTest { @@ -37,6 +43,8 @@ void apiDocumentationShowsSwaggerUiLinkByDefault() { Assertions.assertFalse(docs.contains("download normal swagger file")); Assertions.assertFalse(docs.contains("download swagger file with less validation")); Assertions.assertFalse(docs.contains("Add ?download")); + Assertions.assertFalse(docs.contains("class='mermaid'")); + Assertions.assertFalse(docs.contains("mermaid.esm.min.mjs")); } @Test @@ -59,4 +67,114 @@ void apiDocumentationCanHideSwaggerUiLink() { Assertions.assertFalse(docs.contains("href='/mirror/docs/swagger'")); Assertions.assertTrue(docs.contains("
    • OpenAPI v 3.0 JSON")); } + + @Test + void apiDocumentationShowsEntityDescriptions() { + final Thingifier thingifier = new Thingifier(); + thingifier.setDocumentation("Task API", "Task API docs."); + final EntityDefinition task = thingifier.defineThing("task", "tasks"); + task.withDescription("A task & project ."); + task.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + task.addField( + Field.is("done", FieldType.BOOLEAN) + .withDescription("Whether the task has been completed.")); + + final String docs = + new RestApiDocumentationGenerator(thingifier, new DefaultGUIHTML()) + .getApiDocumentation( + new ApiRoutingDefinitionDocGenerator(thingifier).generate("/api"), + List.of(), + new ThingifierApiDocumentationDefn(), + "/api", + "https://example.com/api/docs"); + + Assertions.assertTrue(docs.contains("

      task

      ")); + Assertions.assertTrue(docs.contains("

      A task & project <summary>.

      ")); + Assertions.assertTrue(docs.contains("Description")); + Assertions.assertFalse(docs.contains("Validation")); + Assertions.assertTrue( + docs.contains( + "
    • Whether the task has been completed.
    • \n" + + "
    • Value must be a Boolean (true, false) value
    • ")); + } + + @Test + void apiDocumentationShowsTwoWayRelationshipsAsSeparateDirections() { + final Thingifier thingifier = new Thingifier(); + final EntityDefinition project = thingifier.defineThing("project", "projects"); + project.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + project.addField(Field.is("title", FieldType.STRING)); + final EntityDefinition task = thingifier.defineThing("task", "tasks"); + task.addAsPrimaryKeyField(Field.is("id", FieldType.AUTO_INCREMENT)); + task.addField(Field.is("title", FieldType.STRING)); + thingifier + .defineRelationship(project, task, "tasks", Cardinality.ONE_TO_MANY()) + .whenReversed(Cardinality.ONE_TO_ONE(), "project"); + + final String docs = + new RestApiDocumentationGenerator(thingifier, new DefaultGUIHTML()) + .getApiDocumentation( + new ApiRoutingDefinitionDocGenerator(thingifier).generate(""), + List.of(), + new ThingifierApiDocumentationDefn(), + "", + "https://example.com/docs"); + + Assertions.assertTrue(docs.contains("
    • tasks : project =(tasks, max *)=> task
    • ")); + Assertions.assertTrue(docs.contains("
    • project : task =(project, max 1)=> project
    • ")); + Assertions.assertFalse(docs.contains("tasks/project :")); + Assertions.assertTrue(docs.contains("
      \nerDiagram"));
      +        Assertions.assertTrue(docs.contains("    PROJECT ||--o{ TASK : tasks\n"));
      +        Assertions.assertTrue(docs.contains("