Skip to content

Commit

Permalink
Add operation recommendation for GET/HEAD w/body
Browse files Browse the repository at this point in the history
Recommendation can be disabled via system property:

-Dopenapi.generator.rule.anti-patterns.uri-unexpected-body=false
  • Loading branch information
jimschubert committed Jan 31, 2020
1 parent 2a5191f commit 5d4ab50
Show file tree
Hide file tree
Showing 7 changed files with 273 additions and 20 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;

/**
* A validator which evaluates an OpenAPI 3.x specification document
Expand Down Expand Up @@ -43,6 +42,7 @@ public ValidationResult validate(OpenAPI specification) {
OpenApiParameterValidations parameterValidations = new OpenApiParameterValidations(ruleConfiguration);
OpenApiSecuritySchemeValidations securitySchemeValidations = new OpenApiSecuritySchemeValidations(ruleConfiguration);
OpenApiSchemaValidations schemaValidations = new OpenApiSchemaValidations(ruleConfiguration);
OpenApiOperationValidations operationValidations = new OpenApiOperationValidations(ruleConfiguration);

if (ruleConfiguration.isEnableUnusedSchemasRecommendation()) {
ValidationRule unusedSchema = ValidationRule.create(Severity.WARNING, "Unused schema", "A schema was determined to be unused.", s -> ValidationRule.Pass.empty());
Expand All @@ -61,17 +61,16 @@ public ValidationResult validate(OpenAPI specification) {
List<Parameter> pathParameters = pathItem.getParameters();
if (pathParameters != null) parameters.addAll(pathItem.getParameters());

// parameters on each operation method
Stream.of(
pathItem.getGet(),
pathItem.getDelete(),
pathItem.getHead(),
pathItem.getOptions(),
pathItem.getPatch(),
pathItem.getPost(),
pathItem.getPut(),
pathItem.getTrace()).forEach(op -> {
if (op != null && op.getParameters() != null) parameters.addAll(op.getParameters());
pathItem.readOperationsMap().forEach((httpMethod, op) -> {
if (op != null) {
// parameters on each operation method
if (op.getParameters() != null) {
parameters.addAll(op.getParameters());
}

OperationWrapper wrapper = new OperationWrapper(op, httpMethod);
validationResult.consume(operationValidations.validate(wrapper));
}
});
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package org.openapitools.codegen.validations.oas;

import io.swagger.v3.oas.models.PathItem;
import io.swagger.v3.oas.models.parameters.RequestBody;
import org.apache.commons.lang3.StringUtils;
import org.openapitools.codegen.validation.GenericValidator;
import org.openapitools.codegen.validation.ValidationRule;

import java.util.ArrayList;
import java.util.Locale;

/**
* A standalone instance for evaluating rule and recommendations related to OAS {@link io.swagger.v3.oas.models.Operation}
*/
class OpenApiOperationValidations extends GenericValidator<OperationWrapper> {
OpenApiOperationValidations(RuleConfiguration ruleConfiguration) {
super(new ArrayList<>());
if (ruleConfiguration.isEnableRecommendations()) {
if (ruleConfiguration.isEnableApiRequestUriWithBodyRecommendation()) {
rules.add(ValidationRule.warn(
"API GET/HEAD defined with request body",
"While technically allowed, GET/HEAD with request body may indicate programming error, and is considered an anti-pattern.",
OpenApiOperationValidations::checkAntipatternGetOrHeadWithBody
));
}
}
}

/**
* Determines whether a GET or HEAD operation is configured to expect a body.
* <p>
* RFC7231 describes this behavior as:
* <p>
* A payload within a GET request message has no defined semantics;
* sending a payload body on a GET request might cause some existing
* implementations to reject the request.
* <p>
* See https://tools.ietf.org/html/rfc7231#section-4.3.1
* <p>
* Because there are no defined semantics, and because some client and server implementations
* may silently ignore the entire body (see https://xhr.spec.whatwg.org/#the-send()-method) or
* throw an error (see https://fetch.spec.whatwg.org/#ref-for-dfn-throw%E2%91%A1%E2%91%A1),
* we maintain that the existence of a body for this operation is most likely programmer error and raise awareness.
*
* @param wrapper Wraps an operation with accompanying HTTP Method
* @return {@link ValidationRule.Pass} if the check succeeds, otherwise {@link ValidationRule.Fail}
*/
private static ValidationRule.Result checkAntipatternGetOrHeadWithBody(OperationWrapper wrapper) {
if (wrapper == null) {
return ValidationRule.Pass.empty();
}

ValidationRule.Result result = ValidationRule.Pass.empty();

if (wrapper.getHttpMethod() == PathItem.HttpMethod.GET || wrapper.getHttpMethod() == PathItem.HttpMethod.HEAD) {
RequestBody body = wrapper.getOperation().getRequestBody();

if (body != null) {
if (StringUtils.isNotEmpty(body.get$ref()) || (body.getContent() != null && body.getContent().size() > 0)) {
result = new ValidationRule.Fail();
result.setDetails(String.format(
Locale.ROOT,
"%s %s contains a request body and is considered an anti-pattern.",
wrapper.getHttpMethod().name(),
wrapper.getOperation().getOperationId())
);
}
}

}

return result;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ class OpenApiParameterValidations extends GenericValidator<Parameter> {
* Apache and Nginx default to legacy CGI behavior in which header with underscore are ignored. Raise this for awareness to the user.
*
* @param parameter Any spec doc parameter. The method will handle {@link HeaderParameter} evaluation.
*
* @return {@link ValidationRule.Pass} if the check succeeds, otherwise {@link ValidationRule.Fail} with details "[key] contains an underscore."
*/
private static ValidationRule.Result apacheNginxHeaderCheck(Parameter parameter) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ class OpenApiSecuritySchemeValidations extends GenericValidator<SecurityScheme>
* Apache and Nginx default to legacy CGI behavior in which header with underscore are ignored. Raise this for awareness to the user.
*
* @param securityScheme Security schemes are often used as header parameters (e.g. APIKEY).
*
* @return <code>true</code> if the check succeeds (header does not have an underscore, e.g. 'api_key')
*/
private static ValidationRule.Result apacheNginxHeaderCheck(SecurityScheme securityScheme) {
if (securityScheme == null || securityScheme.getIn() != SecurityScheme.In.HEADER) return ValidationRule.Pass.empty();
if (securityScheme == null || securityScheme.getIn() != SecurityScheme.In.HEADER)
return ValidationRule.Pass.empty();
ValidationRule.Result result = ValidationRule.Pass.empty();

String key = securityScheme.getName();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.openapitools.codegen.validations.oas;

import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.PathItem;

/**
* Encapsulates an operation with its HTTP Method. In OAS, the {@link PathItem} structure contains more than what we'd
* want to evaluate for operation-only checks.
*/
public class OperationWrapper {
private Operation operation;
private PathItem.HttpMethod httpMethod;

/**
* Constructs a new instance of {@link OperationWrapper}
*
* @param operation The operation instances to wrap
* @param httpMethod The http method to wrap
*/
OperationWrapper(Operation operation, PathItem.HttpMethod httpMethod) {
this.operation = operation;
this.httpMethod = httpMethod;
}

/**
* Gets the operation associated with the http method
*
* @return An operation instance
*/
public Operation getOperation() {
return operation;
}

/**
* Gets the http method associated with the operation
*
* @return The http method
*/
public PathItem.HttpMethod getHttpMethod() {
return httpMethod;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ public class RuleConfiguration {
private boolean enableOneOfWithPropertiesRecommendation = defaultedBoolean(propertyPrefix + ".oneof-properties-ambiguity", true);
private boolean enableUnusedSchemasRecommendation = defaultedBoolean(propertyPrefix + ".unused-schemas", true);

private boolean enableApiRequestUriWithBodyRecommendation = defaultedBoolean(propertyPrefix + ".anti-patterns.uri-unexpected-body", true);

@SuppressWarnings("SameParameterValue")
private static boolean defaultedBoolean(String key, boolean defaultValue) {
String property = System.getProperty(key);
Expand All @@ -30,7 +32,7 @@ public boolean isEnableApacheNginxUnderscoreRecommendation() {

/**
* Enable or Disable the recommendation check for Apache/Nginx potentially ignoring header with underscore by default.
*
* <p>
* For more details, see {@link RuleConfiguration#isEnableApacheNginxUnderscoreRecommendation()}
*
* @param enableApacheNginxUnderscoreRecommendation <code>true</code> to enable, <code>false</code> to disable
Expand All @@ -40,8 +42,28 @@ public void setEnableApacheNginxUnderscoreRecommendation(boolean enableApacheNgi
}

/**
* Gets whether the recommendation check for oneOf with sibling properties exists.
* Gets whether we will raise awareness a GET or HEAD operation is defined with body.
*
* @return <code>true</code> if enabled, <code>false</code> if disabled
*/
public boolean isEnableApiRequestUriWithBodyRecommendation() {
return enableApiRequestUriWithBodyRecommendation;
}

/**
* Enable or Disable the recommendation check for GET or HEAD operations with bodies.
* <p>
* For more details, see {@link RuleConfiguration#isEnableApiRequestUriWithBodyRecommendation()}
*
* @param enableApiRequestUriWithBodyRecommendation <code>true</code> to enable, <code>false</code> to disable
*/
public void setEnableApiRequestUriWithBodyRecommendation(boolean enableApiRequestUriWithBodyRecommendation) {
this.enableApiRequestUriWithBodyRecommendation = enableApiRequestUriWithBodyRecommendation;
}

/**
* Gets whether the recommendation check for oneOf with sibling properties exists.
* <p>
* JSON Schema defines oneOf as a validation property which can be applied to any schema.
* <p>
* OpenAPI Specification is a variant of JSON Schema for which oneOf is defined as:
Expand All @@ -59,7 +81,7 @@ public boolean isEnableOneOfWithPropertiesRecommendation() {

/**
* Enable or Disable the recommendation check for schemas containing properties and oneOf definitions.
*
* <p>
* For more details, see {@link RuleConfiguration#isEnableOneOfWithPropertiesRecommendation()}
*
* @param enableOneOfWithPropertiesRecommendation <code>true</code> to enable, <code>false</code> to disable
Expand Down Expand Up @@ -90,7 +112,7 @@ public void setEnableRecommendations(boolean enableRecommendations) {

/**
* Gets whether the recommendation to check for unused schemas is enabled.
*
* <p>
* While the tooling may or may not support generation of models representing unused schemas, we take the stance that
* a schema which is defined but not referenced in an operation or by some schema bound to an operation may be a good
* indicator of a programming error. We surface this information to the user in case the orphaned schema(s) are not
Expand All @@ -104,7 +126,7 @@ public boolean isEnableUnusedSchemasRecommendation() {

/**
* Enable or Disable the recommendation check for unused schemas.
*
* <p>
* For more details, see {@link RuleConfiguration#isEnableUnusedSchemasRecommendation()}
*
* @param enableUnusedSchemasRecommendation <code>true</code> to enable, <code>false</code> to disable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package org.openapitools.codegen.validations.oas;

import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.PathItem;
import io.swagger.v3.oas.models.media.Content;
import io.swagger.v3.oas.models.media.MediaType;
import io.swagger.v3.oas.models.parameters.RequestBody;
import org.apache.commons.lang3.StringUtils;
import org.openapitools.codegen.validation.Invalid;
import org.openapitools.codegen.validation.ValidationResult;
import org.testng.Assert;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;

import java.util.List;
import java.util.stream.Collectors;

public class OpenApiOperationValidationsTest {
@DataProvider(name = "getOrHeadWithBodyExpectations")
public Object[][] getOrHeadWithBodyExpectations() {
return new Object[][]{
/* method */ /* operationId */ /* ref */ /* content */ /* triggers warning */
{PathItem.HttpMethod.GET, "opWithRerf", "#/components/schemas/Animal", null, true},
{PathItem.HttpMethod.GET, "opWithRerf", null, new Content().addMediaType("a", new MediaType()), true},
{PathItem.HttpMethod.GET, "opWithoutRerf", null, null, false},
{PathItem.HttpMethod.HEAD, "opWithRerf", "#/components/schemas/Animal", null, true},
{PathItem.HttpMethod.HEAD, "opWithRerf", null, new Content().addMediaType("a", new MediaType()), true},
{PathItem.HttpMethod.HEAD, "opWithoutRerf", null, null, false},
{PathItem.HttpMethod.POST, "opWithRerf", "#/components/schemas/Animal", null, false},
{PathItem.HttpMethod.POST, "opWithRerf", null, new Content().addMediaType("a", new MediaType()), false},
{PathItem.HttpMethod.POST, "opWithoutRerf", null, null, false}
};
}

@Test(dataProvider = "getOrHeadWithBodyExpectations")
public void testGetOrHeadWithBody(PathItem.HttpMethod method, String operationId, String ref, Content content, boolean shouldTriggerFailure) {
RuleConfiguration config = new RuleConfiguration();
config.setEnableRecommendations(true);
OpenApiOperationValidations validator = new OpenApiOperationValidations(config);

Operation op = new Operation().operationId(operationId);
RequestBody body = new RequestBody();
if (StringUtils.isNotEmpty(ref) || content != null) {
body.$ref(ref);
body.content(content);

op.setRequestBody(body);
}

ValidationResult result = validator.validate(new OperationWrapper(op, method));
Assert.assertNotNull(result.getWarnings());

List<Invalid> warnings = result.getWarnings().stream()
.filter(invalid -> "API GET/HEAD defined with request body".equals(invalid.getRule().getDescription()))
.collect(Collectors.toList());

Assert.assertNotNull(warnings);
if (shouldTriggerFailure) {
Assert.assertEquals(warnings.size(), 1, "Expected warnings to include recommendation.");
} else {
Assert.assertEquals(warnings.size(), 0, "Expected warnings not to include recommendation.");
}
}

@Test(dataProvider = "getOrHeadWithBodyExpectations")
public void testGetOrHeadWithBodyWithDisabledRecommendations(PathItem.HttpMethod method, String operationId, String ref, Content content, boolean shouldTriggerFailure) {
RuleConfiguration config = new RuleConfiguration();
config.setEnableRecommendations(false);
OpenApiOperationValidations validator = new OpenApiOperationValidations(config);

Operation op = new Operation().operationId(operationId);
RequestBody body = new RequestBody();
if (StringUtils.isNotEmpty(ref) || content != null) {
body.$ref(ref);
body.content(content);

op.setRequestBody(body);
}

ValidationResult result = validator.validate(new OperationWrapper(op, method));
Assert.assertNotNull(result.getWarnings());

List<Invalid> warnings = result.getWarnings().stream()
.filter(invalid -> "API GET/HEAD defined with request body".equals(invalid.getRule().getDescription()))
.collect(Collectors.toList());

Assert.assertNotNull(warnings);
Assert.assertEquals(warnings.size(), 0, "Expected warnings not to include recommendation.");
}

@Test(dataProvider = "getOrHeadWithBodyExpectations")
public void testGetOrHeadWithBodyWithDisabledRule(PathItem.HttpMethod method, String operationId, String ref, Content content, boolean shouldTriggerFailure) {
RuleConfiguration config = new RuleConfiguration();
config.setEnableApiRequestUriWithBodyRecommendation(false);
OpenApiOperationValidations validator = new OpenApiOperationValidations(config);

Operation op = new Operation().operationId(operationId);
RequestBody body = new RequestBody();
if (StringUtils.isNotEmpty(ref) || content != null) {
body.$ref(ref);
body.content(content);

op.setRequestBody(body);
}

ValidationResult result = validator.validate(new OperationWrapper(op, method));
Assert.assertNotNull(result.getWarnings());

List<Invalid> warnings = result.getWarnings().stream()
.filter(invalid -> "API GET/HEAD defined with request body".equals(invalid.getRule().getDescription()))
.collect(Collectors.toList());

Assert.assertNotNull(warnings);
Assert.assertEquals(warnings.size(), 0, "Expected warnings not to include recommendation.");
}
}

0 comments on commit 5d4ab50

Please sign in to comment.