diff --git a/ctp-validators/src/main/java/com/commercetools/rmf/validators/RuleOptionType.java b/ctp-validators/src/main/java/com/commercetools/rmf/validators/RuleOptionType.java index 2c6eaafbf..0332f7138 100644 --- a/ctp-validators/src/main/java/com/commercetools/rmf/validators/RuleOptionType.java +++ b/ctp-validators/src/main/java/com/commercetools/rmf/validators/RuleOptionType.java @@ -1,7 +1,8 @@ package com.commercetools.rmf.validators; enum RuleOptionType { - EXCLUDE("exclude"); + EXCLUDE("exclude"), + ACTION_VERB("action-verb"); private final String type; diff --git a/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ResourceAllowedCharactersRule.kt b/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ResourceAllowedCharactersRule.kt new file mode 100644 index 000000000..9b79c4572 --- /dev/null +++ b/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ResourceAllowedCharactersRule.kt @@ -0,0 +1,45 @@ +package com.commercetools.rmf.validators + +import com.damnhandy.uri.template.Literal +import io.vrap.rmf.raml.model.resources.Resource +import org.eclipse.emf.common.util.Diagnostic +import java.util.* + +@ValidatorSet +class ResourceAllowedCharactersRule(severity: RuleSeverity, options: List? = null) : ResourcesRule(severity, options) { + + private val exclude: List = + (options?.filter { ruleOption -> ruleOption.type.lowercase(Locale.getDefault()) == RuleOptionType.EXCLUDE.toString() }?.map { ruleOption -> ruleOption.value }?.plus("") ?: defaultExcludes) + + override fun caseResource(resource: Resource): List { + val validationResults: MutableList = ArrayList() + + if (exclude.contains(resource.fullUri.template).not()) { + resource.relativeUri.components.filterIsInstance(Literal::class.java) + .forEach { literal -> + val segments = literal.value.split("/").filter { it.isNotEmpty() } + segments.forEach { segment -> + val cleaned = segment.removeSuffix("=") + if (cleaned.isNotEmpty() && !cleaned.matches(Regex("^[a-z0-9-]+$")) && exclude.contains(cleaned).not()) { + validationResults.add(create(resource, "Resource \"{0}\" path segment \"{1}\" must only contain lowercase letters, digits, and hyphens", resource.fullUri.template, cleaned)) + } + } + } + } + return validationResults + } + + companion object : ValidatorFactory { + private val defaultExcludes by lazy { listOf("") } + + @JvmStatic + override fun create(options: List?): ResourceAllowedCharactersRule { + return ResourceAllowedCharactersRule(RuleSeverity.ERROR, options) + } + + @JvmStatic + override fun create(severity: RuleSeverity, options: List?): ResourceAllowedCharactersRule { + return ResourceAllowedCharactersRule(severity, options) + } + } +} diff --git a/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ResourceClassifier.kt b/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ResourceClassifier.kt new file mode 100644 index 000000000..e948d5e37 --- /dev/null +++ b/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ResourceClassifier.kt @@ -0,0 +1,159 @@ +package com.commercetools.rmf.validators + +import com.damnhandy.uri.template.Expression +import com.damnhandy.uri.template.Literal +import io.vrap.rmf.raml.model.resources.HttpMethod +import io.vrap.rmf.raml.model.resources.Resource + +/** + * Classification categories for API resource path segments. + * + * Each category represents a different semantic role a path segment can play: + * - COLLECTION: a resource collection (e.g. /products, /orders) — must be plural + * - IDENTIFIED_OBJECT: an identified instance (e.g. /{id}, /key={key}) — skip + * - SCOPING_PREFIX: a scoping prefix (e.g. /me, /in-store, /as-associate) — skip + * - ACTION_ENDPOINT: an action verb endpoint (e.g. /login, /search, /replicate) — skip + * - MATCHING_ENDPOINT: a matching criteria endpoint (e.g. /matching-cart) — skip + * - IDENTIFIER_LOOKUP: a lookup by identifier (e.g. /email-token={token}) — skip + * - SINGLETON: a singleton resource (e.g. /graphql, /active-cart) — must be singular + * - UNKNOWN: unclassifiable — skip (safety fallback) + */ +enum class ResourceCategory { + COLLECTION, + IDENTIFIED_OBJECT, + SCOPING_PREFIX, + ACTION_ENDPOINT, + MATCHING_ENDPOINT, + IDENTIFIER_LOOKUP, + SINGLETON, + UNKNOWN +} + +/** + * Classifies a RAML [Resource] into a [ResourceCategory] based on structural signals. + * + * Classification logic (order matters — first match wins): + * 1. URI literal contains `=` → IDENTIFIER_LOOKUP + * 2. URI is purely `/{variable}` → IDENTIFIED_OBJECT + * 3. resourcePathName starts with `as-` or `in-`, or equals `me` → SCOPING_PREFIX + * 4. resourcePathName starts with `matching-` → MATCHING_ENDPOINT + * 5. Resource type name is `baseDomain` → COLLECTION + * 6. Resource type name is `baseResource` → IDENTIFIED_OBJECT + * 7. Has child resource with `/{variable}` or `/key={key}` URI → COLLECTION + * 8. resourcePathName matches action verb whitelist, OR is a POST-only leaf → ACTION_ENDPOINT + * 9. Has GET, no identified-object children → SINGLETON + * 10. Has non-trivial children → COLLECTION + * 11. Otherwise → UNKNOWN + */ +object ResourceClassifier { + + private val defaultActionVerbs: Set by lazy { + val stream = ResourceClassifier::class.java.getResourceAsStream("/default-action-verbs.txt") + stream?.bufferedReader()?.readLines() + ?.map { it.trim() } + ?.filter { it.isNotEmpty() && !it.startsWith("#") } + ?.toSet() + ?: emptySet() + } + + /** + * Classifies the given [resource] into a [ResourceCategory]. + * + * @param resource the RAML resource to classify + * @param additionalActionVerbs extra action verbs to merge with the defaults (e.g. from ruleset.xml) + * @return the classification category + */ + fun classify(resource: Resource, additionalActionVerbs: List = emptyList()): ResourceCategory { + val resourcePathName = resource.resourcePathName ?: return ResourceCategory.UNKNOWN + + // Step 1: URI literal contains "=" → IDENTIFIER_LOOKUP + val literals = resource.relativeUri.components.filterIsInstance(Literal::class.java) + if (literals.any { it.value.contains("=") }) { + return ResourceCategory.IDENTIFIER_LOOKUP + } + + // Step 2: URI is purely /{variable} (one "/" literal + one expression) → IDENTIFIED_OBJECT + val componentsList = resource.relativeUri.components.toList() + if (componentsList.size == 2 + && componentsList[0] is Literal && (componentsList[0] as Literal).value == "/" + && componentsList[1] is Expression) { + return ResourceCategory.IDENTIFIED_OBJECT + } + + // Step 3: Scoping prefix (as-*, in-*, me) + if (resourcePathName.startsWith("as-") || resourcePathName.startsWith("in-") || resourcePathName == "me") { + return ResourceCategory.SCOPING_PREFIX + } + + // Step 4: Matching endpoint (matching-* pattern) + if (resourcePathName.startsWith("matching-")) { + return ResourceCategory.MATCHING_ENDPOINT + } + + // Step 5 & 6: Resource type name + val typeName = resource.type?.type?.name + if (typeName == "baseDomain") { + return ResourceCategory.COLLECTION + } + if (typeName == "baseResource") { + return ResourceCategory.IDENTIFIED_OBJECT + } + + // Step 7: Has child with /{variable} or /key={key} → COLLECTION + if (hasIdentifiedObjectChild(resource)) { + return ResourceCategory.COLLECTION + } + + // Step 8: Action verb whitelist or POST-only leaf + val allActionVerbs = defaultActionVerbs + additionalActionVerbs.toSet() + if (allActionVerbs.contains(resourcePathName)) { + return ResourceCategory.ACTION_ENDPOINT + } + if (isPostOnlyLeaf(resource)) { + return ResourceCategory.ACTION_ENDPOINT + } + + // Step 9: Has GET, no identified-object children → SINGLETON + if (resource.getMethod(HttpMethod.GET) != null && !hasIdentifiedObjectChild(resource)) { + return ResourceCategory.SINGLETON + } + + // Step 10: Has non-trivial children → COLLECTION + if (resource.resources != null && resource.resources.isNotEmpty()) { + return ResourceCategory.COLLECTION + } + + // Step 11: Fallback + return ResourceCategory.UNKNOWN + } + + /** + * Checks if the resource has a child that looks like an identified object: + * - `/{variable}` pattern (single expression after `/`) + * - `/key={key}` pattern (literal with `=`) + */ + private fun hasIdentifiedObjectChild(resource: Resource): Boolean { + if (resource.resources == null) return false + return resource.resources.any { child -> + val childComponentsList = child.relativeUri.components.toList() + // /{variable} pattern + val isIdChild = childComponentsList.size == 2 + && childComponentsList[0] is Literal && (childComponentsList[0] as Literal).value == "/" + && childComponentsList[1] is Expression + // /key={key} pattern + val isKeyChild = childComponentsList.filterIsInstance(Literal::class.java) + .any { it.value.contains("=") } + isIdChild || isKeyChild + } + } + + /** + * Checks if the resource is a POST-only leaf (no children, only POST method). + */ + private fun isPostOnlyLeaf(resource: Resource): Boolean { + val hasChildren = resource.resources != null && resource.resources.isNotEmpty() + if (hasChildren) return false + val methods = resource.methods ?: return false + return methods.size == 1 && methods.first().method == HttpMethod.POST + } +} diff --git a/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ResourceNoFileExtensionRule.kt b/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ResourceNoFileExtensionRule.kt new file mode 100644 index 000000000..27930a520 --- /dev/null +++ b/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ResourceNoFileExtensionRule.kt @@ -0,0 +1,47 @@ +package com.commercetools.rmf.validators + +import com.damnhandy.uri.template.Literal +import io.vrap.rmf.raml.model.resources.Resource +import org.eclipse.emf.common.util.Diagnostic +import java.util.* + +@ValidatorSet +class ResourceNoFileExtensionRule(severity: RuleSeverity, options: List? = null) : ResourcesRule(severity, options) { + + private val exclude: List = + (options?.filter { ruleOption -> ruleOption.type.lowercase(Locale.getDefault()) == RuleOptionType.EXCLUDE.toString() }?.map { ruleOption -> ruleOption.value }?.plus("") ?: defaultExcludes) + + private val fileExtensionPattern = Regex("\\.[a-zA-Z]{2,4}$") + + override fun caseResource(resource: Resource): List { + val validationResults: MutableList = ArrayList() + + if (exclude.contains(resource.fullUri.template).not()) { + resource.relativeUri.components.filterIsInstance(Literal::class.java) + .forEach { literal -> + val segments = literal.value.split("/").filter { it.isNotEmpty() } + segments.forEach { segment -> + val cleaned = segment.removeSuffix("=") + if (cleaned.isNotEmpty() && fileExtensionPattern.containsMatchIn(cleaned) && exclude.contains(cleaned).not()) { + validationResults.add(create(resource, "Resource \"{0}\" path segment \"{1}\" must not contain a file extension", resource.fullUri.template, cleaned)) + } + } + } + } + return validationResults + } + + companion object : ValidatorFactory { + private val defaultExcludes by lazy { listOf("") } + + @JvmStatic + override fun create(options: List?): ResourceNoFileExtensionRule { + return ResourceNoFileExtensionRule(RuleSeverity.ERROR, options) + } + + @JvmStatic + override fun create(severity: RuleSeverity, options: List?): ResourceNoFileExtensionRule { + return ResourceNoFileExtensionRule(severity, options) + } + } +} diff --git a/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ResourcePluralRule.kt b/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ResourcePluralRule.kt index dbeb80d37..29dd65ca3 100644 --- a/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ResourcePluralRule.kt +++ b/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ResourcePluralRule.kt @@ -11,8 +11,16 @@ class ResourcePluralRule(severity: RuleSeverity, options: List? = nu private val exclude: List = (options?.filter { ruleOption -> ruleOption.type.lowercase(Locale.getDefault()) == RuleOptionType.EXCLUDE.toString() }?.map { ruleOption -> ruleOption.value }?.plus("") ?: defaultExcludes) + private val actionVerbs: List = + (options?.filter { ruleOption -> ruleOption.type.lowercase(Locale.getDefault()) == RuleOptionType.ACTION_VERB.toString() }?.map { ruleOption -> ruleOption.value } + ?: emptyList()) + override fun caseResource(resource: Resource): List { val validationResults: MutableList = ArrayList() + + val category = ResourceClassifier.classify(resource, actionVerbs) + if (category != ResourceCategory.COLLECTION) return emptyList() + val resourcePathName = resource.resourcePathName val pluralName = English.plural(English.singular(resourcePathName)) if (exclude.contains(resourcePathName).not() && pluralName != resourcePathName) { @@ -22,7 +30,7 @@ class ResourcePluralRule(severity: RuleSeverity, options: List? = nu } companion object : ValidatorFactory { - private val defaultExcludes by lazy { listOf("", "inventory", "login", "me", "import", "in-store") } + private val defaultExcludes by lazy { listOf("", "inventory") } @JvmStatic override fun create(options: List?): ResourcePluralRule { diff --git a/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ResourceSingularRule.kt b/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ResourceSingularRule.kt new file mode 100644 index 000000000..812873f97 --- /dev/null +++ b/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ResourceSingularRule.kt @@ -0,0 +1,45 @@ +package com.commercetools.rmf.validators + +import com.hypertino.inflector.English +import io.vrap.rmf.raml.model.resources.Resource +import org.eclipse.emf.common.util.Diagnostic +import java.util.* + +@ValidatorSet +class ResourceSingularRule(severity: RuleSeverity, options: List? = null) : ResourcesRule(severity, options) { + + private val exclude: List = + (options?.filter { ruleOption -> ruleOption.type.lowercase(Locale.getDefault()) == RuleOptionType.EXCLUDE.toString() }?.map { ruleOption -> ruleOption.value }?.plus("") ?: defaultExcludes) + + private val actionVerbs: List = + (options?.filter { ruleOption -> ruleOption.type.lowercase(Locale.getDefault()) == RuleOptionType.ACTION_VERB.toString() }?.map { ruleOption -> ruleOption.value } + ?: emptyList()) + + override fun caseResource(resource: Resource): List { + val validationResults: MutableList = ArrayList() + + val category = ResourceClassifier.classify(resource, actionVerbs) + if (category != ResourceCategory.SINGLETON) return emptyList() + + val resourcePathName = resource.resourcePathName + val singularName = English.singular(resourcePathName) + if (exclude.contains(resourcePathName).not() && singularName != resourcePathName) { + validationResults.add(create(resource, "Singleton resource \"{0}\" must be singular", resourcePathName)) + } + return validationResults + } + + companion object : ValidatorFactory { + private val defaultExcludes by lazy { listOf("") } + + @JvmStatic + override fun create(options: List?): ResourceSingularRule { + return ResourceSingularRule(RuleSeverity.ERROR, options) + } + + @JvmStatic + override fun create(severity: RuleSeverity, options: List?): ResourceSingularRule { + return ResourceSingularRule(severity, options) + } + } +} diff --git a/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ScopingOrderRule.kt b/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ScopingOrderRule.kt new file mode 100644 index 000000000..4f8871349 --- /dev/null +++ b/ctp-validators/src/main/kotlin/com/commercetools/rmf/validators/ScopingOrderRule.kt @@ -0,0 +1,74 @@ +package com.commercetools.rmf.validators + +import io.vrap.rmf.raml.model.resources.Resource +import org.eclipse.emf.common.util.Diagnostic +import java.util.* + +/** + * Validates that scoping prefixes in a resource path appear in the correct composition order: + * `as-*` → `in-*` → `me` + * + * Not all prefixes need to be present — only those that appear must be in the correct relative order. + * Only fires on resources that have methods defined (to avoid duplicate diagnostics on intermediate nodes). + */ +@ValidatorSet +class ScopingOrderRule(severity: RuleSeverity, options: List? = null) : ResourcesRule(severity, options) { + + private val exclude: List = + (options?.filter { ruleOption -> ruleOption.type.lowercase(Locale.getDefault()) == RuleOptionType.EXCLUDE.toString() }?.map { ruleOption -> ruleOption.value }?.plus("") ?: defaultExcludes) + + override fun caseResource(resource: Resource): List { + val validationResults: MutableList = ArrayList() + + // Only check resources that have methods (to avoid duplicates on intermediate nodes) + if (resource.methods == null || resource.methods.isEmpty()) return emptyList() + + val fullUri = resource.fullUri?.template ?: return emptyList() + if (exclude.contains(fullUri)) return emptyList() + + val segments = fullUri.split("/").filter { it.isNotEmpty() } + + // Extract scoping prefix positions + val scopingPositions = mutableListOf>() + segments.forEach { segment -> + // Remove any URI template expressions for comparison + val clean = segment.replace(Regex("\\{[^}]+}"), "").trimEnd('=') + when { + clean.startsWith("as-") -> scopingPositions.add(Pair(SCOPE_ORDER_AS, "as-* ($clean)")) + clean.startsWith("in-") -> scopingPositions.add(Pair(SCOPE_ORDER_IN, "in-* ($clean)")) + clean == "me" -> scopingPositions.add(Pair(SCOPE_ORDER_ME, "me")) + } + } + + // Check that the scoping prefixes are in non-decreasing order + for (i in 1 until scopingPositions.size) { + val prev = scopingPositions[i - 1] + val curr = scopingPositions[i] + if (prev.first > curr.first) { + validationResults.add(create(resource, + "Resource \"{0}\" has incorrect scoping order: \"{1}\" must appear before \"{2}\"", + fullUri, curr.second, prev.second)) + } + } + + return validationResults + } + + companion object : ValidatorFactory { + private const val SCOPE_ORDER_AS = 1 + private const val SCOPE_ORDER_IN = 2 + private const val SCOPE_ORDER_ME = 3 + + private val defaultExcludes by lazy { listOf("") } + + @JvmStatic + override fun create(options: List?): ScopingOrderRule { + return ScopingOrderRule(RuleSeverity.ERROR, options) + } + + @JvmStatic + override fun create(severity: RuleSeverity, options: List?): ScopingOrderRule { + return ScopingOrderRule(severity, options) + } + } +} diff --git a/ctp-validators/src/main/resources/default-action-verbs.txt b/ctp-validators/src/main/resources/default-action-verbs.txt new file mode 100644 index 000000000..230af6bf9 --- /dev/null +++ b/ctp-validators/src/main/resources/default-action-verbs.txt @@ -0,0 +1,13 @@ +replicate +import +export +apply +login +signup +confirm +reset +merge +search +suggest +validate +bulk diff --git a/ctp-validators/src/test/groovy/com/commercetools/rmf/validators/ValidatorRulesTest.groovy b/ctp-validators/src/test/groovy/com/commercetools/rmf/validators/ValidatorRulesTest.groovy index fab0dcaa7..95792ce01 100644 --- a/ctp-validators/src/test/groovy/com/commercetools/rmf/validators/ValidatorRulesTest.groovy +++ b/ctp-validators/src/test/groovy/com/commercetools/rmf/validators/ValidatorRulesTest.groovy @@ -475,57 +475,104 @@ class ValidatorRulesTest extends Specification implements ValidatorFixtures { result.validationResults.size() == 17 } - def "property min max abbreviation rule"() { + def "resource allowed characters rule"() { when: - def options = singletonList(new RuleOption(RuleOptionType.EXCLUDE.toString(), "InvalidMinMax:minimumExcluded")) - def validators = Arrays.asList(new TypesValidator(Arrays.asList(PropertyMinMaxAbbreviationRule.create(options)))) - def uri = uriFromClasspath("/property-minmax-abbreviation-rule.raml") + def validators = Arrays.asList(new ResourcesValidator(Arrays.asList(ResourceAllowedCharactersRule.create(emptyList())))) + def uri = uriFromClasspath("/resource-path-segment-rule.raml") def result = new RamlModelBuilder(validators).buildApi(uri) then: - result.validationResults.size() == 8 - result.validationResults[0].message == "Property \"minimumQuantity\" of type \"InvalidMinMax\" must use \"min\"/\"max\" instead of \"minimum\"/\"maximum\"" - result.validationResults[1].message == "Property \"maximumPrice\" of type \"InvalidMinMax\" must use \"min\"/\"max\" instead of \"minimum\"/\"maximum\"" - result.validationResults[2].message == "Property \"minimumOrder\" of type \"InvalidMinMax\" must use \"min\"/\"max\" instead of \"minimum\"/\"maximum\"" - result.validationResults[3].message == "Property \"maximumItems\" of type \"InvalidMinMax\" must use \"min\"/\"max\" instead of \"minimum\"/\"maximum\"" - result.validationResults[4].message == "Property \"minimum\" of type \"InvalidMinMax\" must use \"min\"/\"max\" instead of \"minimum\"/\"maximum\"" - result.validationResults[5].message == "Property \"maximum\" of type \"InvalidMinMax\" must use \"min\"/\"max\" instead of \"minimum\"/\"maximum\"" - result.validationResults[6].message == "Property \"minimumTypeNumber\" of type \"InvalidMinMax\" must use \"min\"/\"max\" instead of \"minimum\"/\"maximum\"" - result.validationResults[7].message == "Property \"minimumNumber\" of type \"InvalidMinMax\" must use \"min\"/\"max\" instead of \"minimum\"/\"maximum\"" + result.validationResults.size() == 4 + result.validationResults[0].message == "Resource \"/{projectKey}/invalid_underscore\" path segment \"invalid_underscore\" must only contain lowercase letters, digits, and hyphens" + result.validationResults[1].message == "Resource \"/{projectKey}/invalid~tilde\" path segment \"invalid~tilde\" must only contain lowercase letters, digits, and hyphens" + result.validationResults[2].message == "Resource \"/{projectKey}/products.json\" path segment \"products.json\" must only contain lowercase letters, digits, and hyphens" + result.validationResults[3].message == "Resource \"/{projectKey}/export.csv\" path segment \"export.csv\" must only contain lowercase letters, digits, and hyphens" + } + + def "resource allowed characters rule with exclusions"() { + when: + def options = singletonList(new RuleOption(RuleOptionType.EXCLUDE.toString(), "invalid_underscore")) + def validators = Arrays.asList(new ResourcesValidator(Arrays.asList(ResourceAllowedCharactersRule.create(options)))) + def uri = uriFromClasspath("/resource-path-segment-rule.raml") + def result = new RamlModelBuilder(validators).buildApi(uri) + then: + result.validationResults.size() == 3 + } + + def "resource no file extension rule"() { + when: + def validators = Arrays.asList(new ResourcesValidator(Arrays.asList(ResourceNoFileExtensionRule.create(emptyList())))) + def uri = uriFromClasspath("/resource-path-segment-rule.raml") + def result = new RamlModelBuilder(validators).buildApi(uri) + then: + result.validationResults.size() == 2 + result.validationResults[0].message == "Resource \"/{projectKey}/products.json\" path segment \"products.json\" must not contain a file extension" + result.validationResults[1].message == "Resource \"/{projectKey}/export.csv\" path segment \"export.csv\" must not contain a file extension" + } + + def "resource no file extension rule with exclusions"() { + when: + def options = singletonList(new RuleOption(RuleOptionType.EXCLUDE.toString(), "products.json")) + def validators = Arrays.asList(new ResourcesValidator(Arrays.asList(ResourceNoFileExtensionRule.create(options)))) + def uri = uriFromClasspath("/resource-path-segment-rule.raml") + def result = new RamlModelBuilder(validators).buildApi(uri) + then: + result.validationResults.size() == 1 + } + + def "resource plural rule with classifier - valid collections and skipped non-collections"() { + when: + def validators = Arrays.asList(new ResourcesValidator(Arrays.asList(ResourcePluralRule.create(emptyList())))) + def uri = uriFromClasspath("/resource-classifier-plural.raml") + def result = new RamlModelBuilder(validators).buildApi(uri) + then: + result.validationResults.size() == 0 } - def "property min max abbreviation rule without exclusions"() { + def "resource plural rule with classifier - singular collection detected"() { when: - def validators = Arrays.asList(new TypesValidator(Arrays.asList(PropertyMinMaxAbbreviationRule.create(emptyList())))) - def uri = uriFromClasspath("/property-minmax-abbreviation-rule.raml") + def validators = Arrays.asList(new ResourcesValidator(Arrays.asList(ResourcePluralRule.create(emptyList())))) + def uri = uriFromClasspath("/resource-classifier-plural-invalid.raml") def result = new RamlModelBuilder(validators).buildApi(uri) then: - result.validationResults.size() == 9 + result.validationResults.size() == 1 + result.validationResults[0].message == "Resource \"category\" must be plural" } - def "parameter min max abbreviation rule"() { + def "resource singular rule - valid singletons"() { when: - def options = singletonList(new RuleOption(RuleOptionType.EXCLUDE.toString(), "minimumExcluded")) - def validators = Arrays.asList(new ResourcesValidator(Arrays.asList(ParameterMinMaxAbbreviationRule.create(options)))) - def uri = uriFromClasspath("/parameter-minmax-abbreviation-rule.raml") + def validators = Arrays.asList(new ResourcesValidator(Arrays.asList(ResourceSingularRule.create(emptyList())))) + def uri = uriFromClasspath("/resource-singular-rule.raml") def result = new RamlModelBuilder(validators).buildApi(uri) then: - result.validationResults.size() == 8 - result.validationResults[0].message == "Query parameter \"minimumQuantity\" must use \"min\"/\"max\" instead of \"minimum\"/\"maximum\"" - result.validationResults[1].message == "Query parameter \"maximumPrice\" must use \"min\"/\"max\" instead of \"minimum\"/\"maximum\"" - result.validationResults[2].message == "Query parameter \"minimum\" must use \"min\"/\"max\" instead of \"minimum\"/\"maximum\"" - result.validationResults[3].message == "Query parameter \"maximum\" must use \"min\"/\"max\" instead of \"minimum\"/\"maximum\"" - result.validationResults[4].message == "Query parameter \"minimumTypeNumber\" must use \"min\"/\"max\" instead of \"minimum\"/\"maximum\"" - result.validationResults[5].message == "Query parameter \"minimumDescNumber\" must use \"min\"/\"max\" instead of \"minimum\"/\"maximum\"" - result.validationResults[6].message == "Header \"minimumRetry\" must use \"min\"/\"max\" instead of \"minimum\"/\"maximum\"" - result.validationResults[7].message == "Header \"maximumRetry\" must use \"min\"/\"max\" instead of \"minimum\"/\"maximum\"" + result.validationResults.size() == 0 } - def "parameter min max abbreviation rule without exclusions"() { + def "resource singular rule - plural singleton detected"() { when: - def validators = Arrays.asList(new ResourcesValidator(Arrays.asList(ParameterMinMaxAbbreviationRule.create(emptyList())))) - def uri = uriFromClasspath("/parameter-minmax-abbreviation-rule.raml") + def validators = Arrays.asList(new ResourcesValidator(Arrays.asList(ResourceSingularRule.create(emptyList())))) + def uri = uriFromClasspath("/resource-singular-rule-invalid.raml") def result = new RamlModelBuilder(validators).buildApi(uri) then: - result.validationResults.size() == 9 + result.validationResults.size() == 1 + result.validationResults[0].message == "Singleton resource \"configurations\" must be singular" + } + + def "scoping order rule - valid order"() { + when: + def validators = Arrays.asList(new ResourcesValidator(Arrays.asList(ScopingOrderRule.create(emptyList())))) + def uri = uriFromClasspath("/scoping-order-rule.raml") + def result = new RamlModelBuilder(validators).buildApi(uri) + then: + result.validationResults.size() == 0 + } + + def "scoping order rule - invalid order"() { + when: + def validators = Arrays.asList(new ResourcesValidator(Arrays.asList(ScopingOrderRule.create(emptyList())))) + def uri = uriFromClasspath("/scoping-order-rule-invalid.raml") + def result = new RamlModelBuilder(validators).buildApi(uri) + then: + result.validationResults.size() == 1 + result.validationResults[0].message.contains("incorrect scoping order") } } diff --git a/ctp-validators/src/test/resources/plural-resource-invalid.raml b/ctp-validators/src/test/resources/plural-resource-invalid.raml index 11923c84c..c653e6ceb 100644 --- a/ctp-validators/src/test/resources/plural-resource-invalid.raml +++ b/ctp-validators/src/test/resources/plural-resource-invalid.raml @@ -3,4 +3,6 @@ title: plural rule test /{projectKey}: /categories: + /{id}: /invalid: + /{id}: diff --git a/ctp-validators/src/test/resources/resource-classifier-plural-invalid.raml b/ctp-validators/src/test/resources/resource-classifier-plural-invalid.raml new file mode 100644 index 000000000..c9eab231a --- /dev/null +++ b/ctp-validators/src/test/resources/resource-classifier-plural-invalid.raml @@ -0,0 +1,19 @@ +#%RAML 1.0 +title: resource classifier plural rule invalid test + +resourceTypes: + baseDomain: + get: + post: + +/{projectKey}: + /category: + type: + baseDomain: + resourceType: Category + resourceQueryType: CategoryPagedQueryResponse + resourceDraft: CategoryDraft + whereExample: 'name(en = "Category")' + sortExample: createdAt asc + get: + post: diff --git a/ctp-validators/src/test/resources/resource-classifier-plural.raml b/ctp-validators/src/test/resources/resource-classifier-plural.raml new file mode 100644 index 000000000..0c00b788e --- /dev/null +++ b/ctp-validators/src/test/resources/resource-classifier-plural.raml @@ -0,0 +1,105 @@ +#%RAML 1.0 +title: resource classifier plural rule test + +resourceTypes: + baseDomain: + get: + post: + baseResource: + get: + post: + delete: + base: + description: <> + +/{projectKey}: + /categories: + type: + baseDomain: + resourceType: Category + resourceQueryType: CategoryPagedQueryResponse + resourceDraft: CategoryDraft + whereExample: 'name(en = "Category")' + sortExample: createdAt asc + get: + post: + /{id}: + type: + baseResource: + uriParameterName: id + resourceType: Category + get: + post: + delete: + /login: + type: base + post: + /me: + type: base + get: + /active-cart: + type: base + get: + /in-store: + /key={storeKey}: + /carts: + get: + post: + /as-associate: + /{associateId}: + /in-business-unit: + /key={businessUnitKey}: + /carts: + get: + /shipping-methods: + get: + /matching-cart: + type: base + post: + /matching-location: + type: base + post: + /search: + type: base + post: + /suggest: + type: base + post: + /graphql: + type: base + post: + /carts: + type: + baseDomain: + resourceType: Cart + resourceQueryType: CartPagedQueryResponse + resourceDraft: CartDraft + whereExample: 'customerEmail = "john.doe@example.com"' + sortExample: createdAt asc + get: + post: + /replicate: + type: base + post: + /{id}: + type: + baseResource: + uriParameterName: id + resourceType: Cart + get: + post: + delete: + /email-token={token}: + get: + /key={key}: + get: + /orders: + get: + /{id}: + get: + /import: + type: base + post: + /bulk: + type: base + post: diff --git a/ctp-validators/src/test/resources/resource-path-segment-rule.raml b/ctp-validators/src/test/resources/resource-path-segment-rule.raml new file mode 100644 index 000000000..7061e340b --- /dev/null +++ b/ctp-validators/src/test/resources/resource-path-segment-rule.raml @@ -0,0 +1,19 @@ +#%RAML 1.0 +title: resource path segment rule + +/{projectKey}: + /valid: + /key={key}: + /{id}: + /valid-resource: + /{id}: + /also-valid-123: + /{id}: + /invalid_underscore: + /{id}: + /invalid~tilde: + /{id}: + /products.json: + /{id}: + /export.csv: + /{id}: diff --git a/ctp-validators/src/test/resources/resource-singular-rule-invalid.raml b/ctp-validators/src/test/resources/resource-singular-rule-invalid.raml new file mode 100644 index 000000000..4aa5fd96f --- /dev/null +++ b/ctp-validators/src/test/resources/resource-singular-rule-invalid.raml @@ -0,0 +1,6 @@ +#%RAML 1.0 +title: resource singular rule invalid test + +/{projectKey}: + /configurations: + get: diff --git a/ctp-validators/src/test/resources/resource-singular-rule.raml b/ctp-validators/src/test/resources/resource-singular-rule.raml new file mode 100644 index 000000000..4c06eae24 --- /dev/null +++ b/ctp-validators/src/test/resources/resource-singular-rule.raml @@ -0,0 +1,8 @@ +#%RAML 1.0 +title: resource singular rule test + +/{projectKey}: + /graphql: + post: + /active-cart: + get: diff --git a/ctp-validators/src/test/resources/scoping-order-rule-invalid.raml b/ctp-validators/src/test/resources/scoping-order-rule-invalid.raml new file mode 100644 index 000000000..013bdaed4 --- /dev/null +++ b/ctp-validators/src/test/resources/scoping-order-rule-invalid.raml @@ -0,0 +1,10 @@ +#%RAML 1.0 +title: scoping order rule test - invalid + +/{projectKey}: + /in-store: + /key={storeKey}: + /as-associate: + /{associateId}: + /carts: + get: diff --git a/ctp-validators/src/test/resources/scoping-order-rule.raml b/ctp-validators/src/test/resources/scoping-order-rule.raml new file mode 100644 index 000000000..47894ccb4 --- /dev/null +++ b/ctp-validators/src/test/resources/scoping-order-rule.raml @@ -0,0 +1,18 @@ +#%RAML 1.0 +title: scoping order rule test - valid + +/{projectKey}: + /as-associate: + /{associateId}: + /in-business-unit: + /key={businessUnitKey}: + /carts: + get: + /in-store: + /key={storeKey}: + /carts: + get: + /me: + get: + /carts: + get: