Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions smithy-aws-protocol-tests/model/ec2Query/xml-structs.smithy
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ apply XmlNamespaces @httpResponseTests([
protocol: ec2Query,
code: 200,
body: """
<XmlNamespacesResponse xmlns="http://foo.com" xmlns="https://example.com/">
<XmlNamespacesResponse xmlns="https://example.com/">
<nested>
<foo xmlns:baz="http://baz.com">Foo</foo>
<values xmlns="http://qux.com">
Expand Down Expand Up @@ -457,7 +457,7 @@ apply IgnoresWrappingXmlName @httpResponseTests([
protocol: ec2Query,
code: 200,
body: """
<IgnoresWrappingXmlNameResponse xmlns="http://foo.com" xmlns="https://example.com/">
<IgnoresWrappingXmlNameResponse xmlns="https://example.com/">
<foo>bar</foo>
<RequestId>requestid</RequestId>
</IgnoresWrappingXmlNameResponse>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,20 @@

package software.amazon.smithy.protocoltests.traits;

import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.knowledge.OperationIndex;
import software.amazon.smithy.model.loader.ModelSyntaxException;
import software.amazon.smithy.model.node.Node;
import software.amazon.smithy.model.node.ObjectNode;
import software.amazon.smithy.model.shapes.OperationShape;
Expand All @@ -33,29 +40,44 @@
import software.amazon.smithy.model.validation.NodeValidationVisitor;
import software.amazon.smithy.model.validation.ValidationEvent;
import software.amazon.smithy.model.validation.node.TimestampValidationStrategy;
import software.amazon.smithy.utils.MediaType;

/**
* Validates the following:
*
* <ul>
* <li>XML and JSON bodyMediaTypes contain valid content.</li>
* <li>vendorParamsShape is a valid shape.</li>
* <li>Vendor params are compatible with any referenced shape.</li>
* <li>Params for a test case are valid for the model.</li>
* </ul>
*
* @param <T> Type of test case to validate.
*/
abstract class ProtocolTestCaseValidator<T extends Trait> extends AbstractValidator {

private final Class<T> traitClass;
private final ShapeId traitId;
private final String descriptor;
private final DocumentBuilderFactory documentBuilderFactory;

ProtocolTestCaseValidator(ShapeId traitId, Class<T> traitClass, String descriptor) {
this.traitId = traitId;
this.traitClass = traitClass;
this.descriptor = descriptor;
documentBuilderFactory = DocumentBuilderFactory.newInstance();
}

@Override
public List<ValidationEvent> validate(Model model) {
OperationIndex operationIndex = OperationIndex.of(model);

return Stream.concat(model.shapes(OperationShape.class), model.shapes(StructureShape.class))
.filter(shape -> shape.hasTrait(traitClass))
.flatMap(shape -> {
return validateOperation(model, operationIndex, shape, shape.expectTrait(traitClass)).stream();
})
.collect(Collectors.toList());
List<ValidationEvent> events = new ArrayList<>();
for (Shape shape : model.getShapesWithTrait(traitClass)) {
events.addAll(validateShape(model, operationIndex, shape, shape.expectTrait(traitClass)));
}

return events;
}

abstract StructureShape getStructure(Shape shape, OperationIndex operationIndex);
Expand All @@ -66,7 +88,7 @@ boolean isValidatedBy(Shape shape) {
return shape instanceof OperationShape;
}

private List<ValidationEvent> validateOperation(
private List<ValidationEvent> validateShape(
Model model,
OperationIndex operationIndex,
Shape shape,
Expand All @@ -78,6 +100,9 @@ private List<ValidationEvent> validateOperation(
for (int i = 0; i < testCases.size(); i++) {
HttpMessageTestCase testCase = testCases.get(i);

// Validate the syntax of known media types like XML and JSON.
events.addAll(validateMediaType(shape, trait, testCase));

// Validate the vendorParams for the test case if we have a shape defined.
Optional<ShapeId> vendorParamsShapeOptional = testCase.getVendorParamsShape();
ObjectNode vendorParams = testCase.getVendorParams();
Expand Down Expand Up @@ -127,4 +152,59 @@ private NodeValidationVisitor createVisitor(
.allowBoxedNull(true)
.build();
}

private List<ValidationEvent> validateMediaType(Shape shape, Trait trait, HttpMessageTestCase test) {
// Only validate the body if it's a non-empty string. Some protocols
// require a content-type header even with no payload.
if (!test.getBody().filter(s -> !s.isEmpty()).isPresent()) {
return Collections.emptyList();
}

String rawMediaType = test.getBodyMediaType().orElse("application/octet-stream");
MediaType mediaType = MediaType.from(rawMediaType);
List<ValidationEvent> events = new ArrayList<>();
if (isXml(mediaType)) {
validateXml(shape, trait, test).ifPresent(events::add);
} else if (isJson(mediaType)) {
validateJson(shape, trait, test).ifPresent(events::add);
}

return events;
}

private boolean isXml(MediaType mediaType) {
return mediaType.getSubtype().equals("xml") || mediaType.getSuffix().orElse("").equals("xml");
}

private boolean isJson(MediaType mediaType) {
return mediaType.getSubtype().equals("json") || mediaType.getSuffix().orElse("").equals("json");
}

private Optional<ValidationEvent> validateXml(Shape shape, Trait trait, HttpMessageTestCase test) {
try {
DocumentBuilder builder = documentBuilderFactory.newDocumentBuilder();
builder.parse(new InputSource(new StringReader(test.getBody().orElse(""))));
return Optional.empty();
} catch (ParserConfigurationException | SAXException | IOException e) {
return Optional.of(emitMediaTypeError(shape, trait, test, e));
}
}

private Optional<ValidationEvent> validateJson(Shape shape, Trait trait, HttpMessageTestCase test) {
try {
Node.parse(test.getBody().orElse(""));
return Optional.empty();
} catch (ModelSyntaxException e) {
return Optional.of(emitMediaTypeError(shape, trait, test, e));
}
}

private ValidationEvent emitMediaTypeError(Shape shape, Trait trait, HttpMessageTestCase test, Throwable e) {
return danger(shape, trait, String.format(
"Invalid %s content in `%s` protocol test case `%s`: %s",
test.getBodyMediaType().orElse(""),
trait.toShapeId(),
test.getId(),
e.getMessage()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[DANGER] smithy.example#SayHello: Invalid application/json content in `smithy.test#httpRequestTests` protocol test case `foo1` | HttpRequestTestsInput
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace smithy.example

use smithy.test#httpRequestTests

@trait
@protocolDefinition
structure testProtocol {}

@http(method: "POST", uri: "/")
@httpRequestTests([
{
id: "foo1",
protocol: testProtocol,
method: "POST",
uri: "/",
params: {
type: true
},
bodyMediaType: "application/json",
body: """
{
"foo": "Oh no, we are missing a comma!"
"bar": true
}
"""
}
])
operation SayHello {
input: SayHelloInput
}

structure SayHelloInput {
type: Boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[DANGER] smithy.example#SayHello: Invalid application/xml content in `smithy.test#httpRequestTests` protocol test case `foo1` | HttpRequestTestsInput
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
namespace smithy.example

use smithy.test#httpRequestTests

@trait
@protocolDefinition
structure testProtocol {}

@http(method: "POST", uri: "/")
@httpRequestTests([
{
id: "foo1",
protocol: testProtocol,
method: "POST",
uri: "/",
params: {
type: true
},
bodyMediaType: "application/xml",
body: """
<XmlNamespacesResponse xmlns="http://foo.com" xmlns="https://example.com/">
<nested>
<foo xmlns:baz="http://baz.com">Foo</foo>
<values xmlns="http://qux.com">
<member xmlns="http://bux.com">Bar</member>
<member xmlns="http://bux.com">Baz</member>
</values>
</nested>
<RequestId>requestid</RequestId>
</XmlNamespacesResponse>
"""
}
])
operation SayHello {
input: SayHelloInput
}

structure SayHelloInput {
type: Boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace smithy.example

use smithy.test#httpRequestTests

@trait
@protocolDefinition
structure testProtocol {}

@http(method: "POST", uri: "/")
@httpRequestTests([
{
id: "foo1",
protocol: testProtocol,
method: "POST",
uri: "/",
params: {
type: true
},
bodyMediaType: "application/json",
body: """
{
"foo": true,
"bar": true
}
"""
}
])
operation SayHello {
input: SayHelloInput
}

structure SayHelloInput {
type: Boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
namespace smithy.example

use smithy.test#httpRequestTests

@trait
@protocolDefinition
structure testProtocol {}

@http(method: "POST", uri: "/")
@httpRequestTests([
{
id: "foo1",
protocol: testProtocol,
method: "POST",
uri: "/",
params: {
type: true
},
bodyMediaType: "application/xml",
body: """
<XmlNamespacesResponse xmlns="https://example.com/">
<nested>
<foo xmlns:baz="http://baz.com">Foo</foo>
<values xmlns="http://qux.com">
<member xmlns="http://bux.com">Bar</member>
<member xmlns="http://bux.com">Baz</member>
</values>
</nested>
<RequestId>requestid</RequestId>
</XmlNamespacesResponse>
"""
}
])
operation SayHello {
input: SayHelloInput
}

structure SayHelloInput {
type: Boolean
}